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*2866e827SAtari911 * @version 7.2.6 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 17815440faSAtari911// Load new class dependencies 18815440faSAtari911require_once __DIR__ . '/classes/FileHandler.php'; 19815440faSAtari911require_once __DIR__ . '/classes/EventCache.php'; 20815440faSAtari911require_once __DIR__ . '/classes/RateLimiter.php'; 21815440faSAtari911require_once __DIR__ . '/classes/EventManager.php'; 22815440faSAtari911require_once __DIR__ . '/classes/AuditLogger.php'; 23815440faSAtari911require_once __DIR__ . '/classes/GoogleCalendarSync.php'; 24815440faSAtari911 2519378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin { 2619378907SAtari911 27*2866e827SAtari911 /** 28*2866e827SAtari911 * Get the meta directory path (farm-safe) 29*2866e827SAtari911 * Uses $conf['metadir'] instead of hardcoded DOKU_INC . 'data/meta/' 30*2866e827SAtari911 */ 31*2866e827SAtari911 private function metaDir() { 32*2866e827SAtari911 global $conf; 33*2866e827SAtari911 return rtrim($conf['metadir'], '/') . '/'; 34*2866e827SAtari911 } 35*2866e827SAtari911 36*2866e827SAtari911 /** 37*2866e827SAtari911 * Check if the current user has read access to a namespace 38*2866e827SAtari911 * Uses DokuWiki's ACL system for farm-safe permission checks 39*2866e827SAtari911 * 40*2866e827SAtari911 * @param string $namespace Namespace to check (empty = root) 41*2866e827SAtari911 * @return bool True if user has at least AUTH_READ 42*2866e827SAtari911 */ 43*2866e827SAtari911 private function checkNamespaceRead($namespace) { 44*2866e827SAtari911 if (empty($namespace) || $namespace === '*') return true; 45*2866e827SAtari911 // Strip wildcards and semicolons for ACL check 46*2866e827SAtari911 $ns = str_replace(['*', ';'], '', $namespace); 47*2866e827SAtari911 if (empty($ns)) return true; 48*2866e827SAtari911 $perm = auth_quickaclcheck($ns . ':*'); 49*2866e827SAtari911 return ($perm >= AUTH_READ); 50*2866e827SAtari911 } 51*2866e827SAtari911 52*2866e827SAtari911 /** 53*2866e827SAtari911 * Check if the current user has edit access to a namespace 54*2866e827SAtari911 * 55*2866e827SAtari911 * @param string $namespace Namespace to check (empty = root) 56*2866e827SAtari911 * @return bool True if user has at least AUTH_EDIT 57*2866e827SAtari911 */ 58*2866e827SAtari911 private function checkNamespaceEdit($namespace) { 59*2866e827SAtari911 if (empty($namespace)) return true; 60*2866e827SAtari911 $perm = auth_quickaclcheck($namespace . ':*'); 61*2866e827SAtari911 return ($perm >= AUTH_EDIT); 62*2866e827SAtari911 } 63*2866e827SAtari911 64815440faSAtari911 /** @var CalendarAuditLogger */ 65815440faSAtari911 private $auditLogger = null; 66815440faSAtari911 67815440faSAtari911 /** @var GoogleCalendarSync */ 68815440faSAtari911 private $googleSync = null; 69815440faSAtari911 70815440faSAtari911 /** 71815440faSAtari911 * Get the audit logger instance 72815440faSAtari911 */ 73815440faSAtari911 private function getAuditLogger() { 74815440faSAtari911 if ($this->auditLogger === null) { 75815440faSAtari911 $this->auditLogger = new CalendarAuditLogger(); 76815440faSAtari911 } 77815440faSAtari911 return $this->auditLogger; 78815440faSAtari911 } 79815440faSAtari911 80815440faSAtari911 /** 81815440faSAtari911 * Get the Google Calendar sync instance 82815440faSAtari911 */ 83815440faSAtari911 private function getGoogleSync() { 84815440faSAtari911 if ($this->googleSync === null) { 85815440faSAtari911 $this->googleSync = new GoogleCalendarSync(); 86815440faSAtari911 } 87815440faSAtari911 return $this->googleSync; 88815440faSAtari911 } 89815440faSAtari911 907e8ea635SAtari911 /** 917e8ea635SAtari911 * Log debug message only if CALENDAR_DEBUG is enabled 927e8ea635SAtari911 */ 937e8ea635SAtari911 private function debugLog($message) { 947e8ea635SAtari911 if (CALENDAR_DEBUG) { 957e8ea635SAtari911 error_log($message); 967e8ea635SAtari911 } 977e8ea635SAtari911 } 987e8ea635SAtari911 997e8ea635SAtari911 /** 1007e8ea635SAtari911 * Safely read and decode a JSON file with error handling 101815440faSAtari911 * Uses the new CalendarFileHandler for atomic reads with locking 1027e8ea635SAtari911 * @param string $filepath Path to JSON file 1037e8ea635SAtari911 * @return array Decoded array or empty array on error 1047e8ea635SAtari911 */ 1057e8ea635SAtari911 private function safeJsonRead($filepath) { 106815440faSAtari911 return CalendarFileHandler::readJson($filepath); 1077e8ea635SAtari911 } 1087e8ea635SAtari911 109815440faSAtari911 /** 110815440faSAtari911 * Safely write JSON data to file with atomic writes 111815440faSAtari911 * Uses the new CalendarFileHandler for atomic writes with locking 112815440faSAtari911 * @param string $filepath Path to JSON file 113815440faSAtari911 * @param array $data Data to write 114815440faSAtari911 * @return bool Success status 115815440faSAtari911 */ 116815440faSAtari911 private function safeJsonWrite($filepath, array $data) { 117815440faSAtari911 return CalendarFileHandler::writeJson($filepath, $data); 1187e8ea635SAtari911 } 1197e8ea635SAtari911 12019378907SAtari911 public function register(Doku_Event_Handler $controller) { 12119378907SAtari911 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax'); 12219378907SAtari911 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets'); 12319378907SAtari911 } 12419378907SAtari911 12519378907SAtari911 public function handleAjax(Doku_Event $event, $param) { 12619378907SAtari911 if ($event->data !== 'plugin_calendar') return; 12719378907SAtari911 $event->preventDefault(); 12819378907SAtari911 $event->stopPropagation(); 12919378907SAtari911 13019378907SAtari911 $action = $_REQUEST['action'] ?? ''; 13119378907SAtari911 132b498f308SAtari911 // Actions that modify data require authentication and CSRF token verification 1337e8ea635SAtari911 $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces', 1347e8ea635SAtari911 'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring', 1357e8ea635SAtari911 'trim_recurring', 'pause_recurring', 'resume_recurring', 1367e8ea635SAtari911 'change_start_recurring', 'change_pattern_recurring']; 1377e8ea635SAtari911 138815440faSAtari911 $isWriteAction = in_array($action, $writeActions); 139815440faSAtari911 140815440faSAtari911 // Rate limiting check - apply to all AJAX actions 141815440faSAtari911 if (!CalendarRateLimiter::check($action, $isWriteAction)) { 142815440faSAtari911 CalendarRateLimiter::addHeaders($action, $isWriteAction); 143815440faSAtari911 http_response_code(429); 144815440faSAtari911 echo json_encode([ 145815440faSAtari911 'success' => false, 146815440faSAtari911 'error' => 'Rate limit exceeded. Please wait before making more requests.', 147815440faSAtari911 'retry_after' => CalendarRateLimiter::getRemaining($action, $isWriteAction)['reset'] 148815440faSAtari911 ]); 149815440faSAtari911 return; 150815440faSAtari911 } 151815440faSAtari911 152815440faSAtari911 // Add rate limit headers to all responses 153815440faSAtari911 CalendarRateLimiter::addHeaders($action, $isWriteAction); 154815440faSAtari911 155815440faSAtari911 if ($isWriteAction) { 156b498f308SAtari911 global $INPUT, $INFO; 157b498f308SAtari911 158b498f308SAtari911 // Check if user is logged in (at minimum) 159b498f308SAtari911 if (empty($_SERVER['REMOTE_USER'])) { 160b498f308SAtari911 echo json_encode(['success' => false, 'error' => 'Authentication required. Please log in.']); 161b498f308SAtari911 return; 162b498f308SAtari911 } 163b498f308SAtari911 164b498f308SAtari911 // Check for valid security token - try multiple sources 165b498f308SAtari911 $sectok = $INPUT->str('sectok', ''); 166b498f308SAtari911 if (empty($sectok)) { 1677e8ea635SAtari911 $sectok = $_REQUEST['sectok'] ?? ''; 168b498f308SAtari911 } 169b498f308SAtari911 170b498f308SAtari911 // Use DokuWiki's built-in check 1717e8ea635SAtari911 if (!checkSecurityToken($sectok)) { 172b498f308SAtari911 // Log for debugging 173b498f308SAtari911 $this->debugLog("Security token check failed. Received: '$sectok'"); 1747e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']); 1757e8ea635SAtari911 return; 1767e8ea635SAtari911 } 1777e8ea635SAtari911 } 1787e8ea635SAtari911 17919378907SAtari911 switch ($action) { 18019378907SAtari911 case 'save_event': 18119378907SAtari911 $this->saveEvent(); 18219378907SAtari911 break; 18319378907SAtari911 case 'delete_event': 18419378907SAtari911 $this->deleteEvent(); 18519378907SAtari911 break; 18619378907SAtari911 case 'get_event': 18719378907SAtari911 $this->getEvent(); 18819378907SAtari911 break; 18919378907SAtari911 case 'load_month': 19019378907SAtari911 $this->loadMonth(); 19119378907SAtari911 break; 192da206178SAtari911 case 'get_static_calendar': 193da206178SAtari911 $this->getStaticCalendar(); 194da206178SAtari911 break; 19596df7d3eSAtari911 case 'search_all': 19696df7d3eSAtari911 $this->searchAllDates(); 19796df7d3eSAtari911 break; 19819378907SAtari911 case 'toggle_task': 19919378907SAtari911 $this->toggleTaskComplete(); 20019378907SAtari911 break; 201815440faSAtari911 case 'google_auth_url': 202815440faSAtari911 $this->getGoogleAuthUrl(); 203815440faSAtari911 break; 204815440faSAtari911 case 'google_callback': 205815440faSAtari911 $this->handleGoogleCallback(); 206815440faSAtari911 break; 207815440faSAtari911 case 'google_status': 208815440faSAtari911 $this->getGoogleStatus(); 209815440faSAtari911 break; 210815440faSAtari911 case 'google_calendars': 211815440faSAtari911 $this->getGoogleCalendars(); 212815440faSAtari911 break; 213815440faSAtari911 case 'google_import': 214815440faSAtari911 $this->googleImport(); 215815440faSAtari911 break; 216815440faSAtari911 case 'google_export': 217815440faSAtari911 $this->googleExport(); 218815440faSAtari911 break; 219815440faSAtari911 case 'google_disconnect': 220815440faSAtari911 $this->googleDisconnect(); 221815440faSAtari911 break; 2227e8ea635SAtari911 case 'cleanup_empty_namespaces': 2237e8ea635SAtari911 case 'trim_all_past_recurring': 2247e8ea635SAtari911 case 'rescan_recurring': 2257e8ea635SAtari911 case 'extend_recurring': 2267e8ea635SAtari911 case 'trim_recurring': 2277e8ea635SAtari911 case 'pause_recurring': 2287e8ea635SAtari911 case 'resume_recurring': 2297e8ea635SAtari911 case 'change_start_recurring': 2307e8ea635SAtari911 case 'change_pattern_recurring': 2317e8ea635SAtari911 $this->routeToAdmin($action); 2327e8ea635SAtari911 break; 23319378907SAtari911 default: 23419378907SAtari911 echo json_encode(['success' => false, 'error' => 'Unknown action']); 23519378907SAtari911 } 23619378907SAtari911 } 23719378907SAtari911 2387e8ea635SAtari911 /** 2397e8ea635SAtari911 * Route AJAX actions to admin plugin methods 2407e8ea635SAtari911 */ 2417e8ea635SAtari911 private function routeToAdmin($action) { 2427e8ea635SAtari911 $admin = plugin_load('admin', 'calendar'); 2437e8ea635SAtari911 if ($admin && method_exists($admin, 'handleAjaxAction')) { 2447e8ea635SAtari911 $admin->handleAjaxAction($action); 2457e8ea635SAtari911 } else { 2467e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Admin handler not available']); 2477e8ea635SAtari911 } 2487e8ea635SAtari911 } 2497e8ea635SAtari911 25019378907SAtari911 private function saveEvent() { 25119378907SAtari911 global $INPUT; 25219378907SAtari911 25319378907SAtari911 $namespace = $INPUT->str('namespace', ''); 25419378907SAtari911 $date = $INPUT->str('date'); 25519378907SAtari911 $eventId = $INPUT->str('eventId', ''); 25619378907SAtari911 $title = $INPUT->str('title'); 25719378907SAtari911 $time = $INPUT->str('time', ''); 2581d05cddcSAtari911 $endTime = $INPUT->str('endTime', ''); 25919378907SAtari911 $description = $INPUT->str('description', ''); 26019378907SAtari911 $color = $INPUT->str('color', '#3498db'); 26119378907SAtari911 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 26219378907SAtari911 $isTask = $INPUT->bool('isTask', false); 26319378907SAtari911 $completed = $INPUT->bool('completed', false); 26419378907SAtari911 $endDate = $INPUT->str('endDate', ''); 26587ac9bf3SAtari911 $isRecurring = $INPUT->bool('isRecurring', false); 26687ac9bf3SAtari911 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 26787ac9bf3SAtari911 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 26819378907SAtari911 26996df7d3eSAtari911 // New recurrence options 27096df7d3eSAtari911 $recurrenceInterval = $INPUT->int('recurrenceInterval', 1); 27196df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 27296df7d3eSAtari911 if ($recurrenceInterval > 99) $recurrenceInterval = 99; 27396df7d3eSAtari911 27496df7d3eSAtari911 $weekDaysStr = $INPUT->str('weekDays', ''); 275*2866e827SAtari911 $weekDays = ($weekDaysStr !== '') ? array_map('intval', explode(',', $weekDaysStr)) : []; 27696df7d3eSAtari911 27796df7d3eSAtari911 $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth'); 27896df7d3eSAtari911 $monthDay = $INPUT->int('monthDay', 0); 27996df7d3eSAtari911 $ordinalWeek = $INPUT->int('ordinalWeek', 1); 28096df7d3eSAtari911 $ordinalDay = $INPUT->int('ordinalDay', 0); 28196df7d3eSAtari911 28296df7d3eSAtari911 $this->debugLog("=== Calendar saveEvent START ==="); 28396df7d3eSAtari911 $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'"); 28496df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'"); 28596df7d3eSAtari911 28619378907SAtari911 if (!$date || !$title) { 28719378907SAtari911 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 28819378907SAtari911 return; 28919378907SAtari911 } 29019378907SAtari911 2917e8ea635SAtari911 // Validate date format (YYYY-MM-DD) 2927e8ea635SAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { 2937e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid date format']); 2947e8ea635SAtari911 return; 2957e8ea635SAtari911 } 2967e8ea635SAtari911 2977e8ea635SAtari911 // Validate oldDate if provided 2987e8ea635SAtari911 if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) { 2997e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid old date format']); 3007e8ea635SAtari911 return; 3017e8ea635SAtari911 } 3027e8ea635SAtari911 3037e8ea635SAtari911 // Validate endDate if provided 3047e8ea635SAtari911 if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) { 3057e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end date format']); 3067e8ea635SAtari911 return; 3077e8ea635SAtari911 } 3087e8ea635SAtari911 3097e8ea635SAtari911 // Validate time format (HH:MM) if provided 3107e8ea635SAtari911 if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) { 3117e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid time format']); 3127e8ea635SAtari911 return; 3137e8ea635SAtari911 } 3147e8ea635SAtari911 3157e8ea635SAtari911 // Validate endTime format if provided 3167e8ea635SAtari911 if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) { 3177e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end time format']); 3187e8ea635SAtari911 return; 3197e8ea635SAtari911 } 3207e8ea635SAtari911 3217e8ea635SAtari911 // Validate color format (hex color) 3227e8ea635SAtari911 if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) { 3237e8ea635SAtari911 $color = '#3498db'; // Reset to default if invalid 3247e8ea635SAtari911 } 3257e8ea635SAtari911 3267e8ea635SAtari911 // Validate namespace (prevent path traversal) 3277e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 3287e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 3297e8ea635SAtari911 return; 3307e8ea635SAtari911 } 3317e8ea635SAtari911 332*2866e827SAtari911 // ACL check: verify edit access to the target namespace 333*2866e827SAtari911 if (!$this->checkNamespaceEdit($namespace)) { 334*2866e827SAtari911 echo json_encode(['success' => false, 'error' => 'You do not have permission to edit events in this namespace']); 335*2866e827SAtari911 return; 336*2866e827SAtari911 } 337*2866e827SAtari911 3387e8ea635SAtari911 // Validate recurrence type 3397e8ea635SAtari911 $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly']; 3407e8ea635SAtari911 if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) { 3417e8ea635SAtari911 $recurrenceType = 'weekly'; 3427e8ea635SAtari911 } 3437e8ea635SAtari911 3447e8ea635SAtari911 // Validate recurrenceEnd if provided 3457e8ea635SAtari911 if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) { 3467e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']); 3477e8ea635SAtari911 return; 3487e8ea635SAtari911 } 3497e8ea635SAtari911 3507e8ea635SAtari911 // Sanitize title length 3517e8ea635SAtari911 $title = substr(trim($title), 0, 500); 3527e8ea635SAtari911 3537e8ea635SAtari911 // Sanitize description length 3547e8ea635SAtari911 $description = substr($description, 0, 10000); 3557e8ea635SAtari911 35696df7d3eSAtari911 // If editing, find the event's ACTUAL namespace (for finding/deleting old event) 35796df7d3eSAtari911 // We need to search ALL namespaces because user may be changing namespace 35896df7d3eSAtari911 $oldNamespace = null; // null means "not found yet" 359e3a9f44cSAtari911 if ($eventId) { 3601d05cddcSAtari911 // Use oldDate if available (date was changed), otherwise use current date 3611d05cddcSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 3621d05cddcSAtari911 36396df7d3eSAtari911 // Search using wildcard to find event in ANY namespace 36496df7d3eSAtari911 $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*'); 36596df7d3eSAtari911 36696df7d3eSAtari911 if ($foundNamespace !== null) { 36796df7d3eSAtari911 $oldNamespace = $foundNamespace; // Could be '' for default namespace 3687e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'"); 36996df7d3eSAtari911 } else { 37096df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace"); 3711d05cddcSAtari911 } 372e3a9f44cSAtari911 } 373e3a9f44cSAtari911 3741d05cddcSAtari911 // Use the namespace provided by the user (allow namespace changes!) 3751d05cddcSAtari911 // But normalize wildcards and multi-namespace to empty for NEW events 3761d05cddcSAtari911 if (!$eventId) { 3777e8ea635SAtari911 $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'"); 378e3a9f44cSAtari911 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 379e3a9f44cSAtari911 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 3807e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty"); 381e3a9f44cSAtari911 $namespace = ''; 3821d05cddcSAtari911 } else { 3837e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'"); 384e3a9f44cSAtari911 } 3851d05cddcSAtari911 } else { 3867e8ea635SAtari911 $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'"); 387e3a9f44cSAtari911 } 388e3a9f44cSAtari911 38987ac9bf3SAtari911 // Generate event ID if new 39087ac9bf3SAtari911 $generatedId = $eventId ?: uniqid(); 39187ac9bf3SAtari911 3929ccd446eSAtari911 // If editing a recurring event, load existing data to preserve unchanged fields 3939ccd446eSAtari911 $existingEventData = null; 3949ccd446eSAtari911 if ($eventId && $isRecurring) { 3959ccd446eSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 39696df7d3eSAtari911 // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use '' 39796df7d3eSAtari911 $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace); 3989ccd446eSAtari911 if ($existingEventData) { 3997e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'"); 4009ccd446eSAtari911 } 4019ccd446eSAtari911 } 4029ccd446eSAtari911 40387ac9bf3SAtari911 // If recurring, generate multiple events 40487ac9bf3SAtari911 if ($isRecurring) { 4059ccd446eSAtari911 // Merge with existing data if editing (preserve values that weren't changed) 4069ccd446eSAtari911 if ($existingEventData) { 4079ccd446eSAtari911 $title = $title ?: $existingEventData['title']; 4089ccd446eSAtari911 $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : ''); 4099ccd446eSAtari911 $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : ''); 4109ccd446eSAtari911 $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : ''); 4119ccd446eSAtari911 // Only use existing color if new color is default 4129ccd446eSAtari911 if ($color === '#3498db' && isset($existingEventData['color'])) { 4139ccd446eSAtari911 $color = $existingEventData['color']; 4149ccd446eSAtari911 } 4159ccd446eSAtari911 4169ccd446eSAtari911 // Preserve namespace in these cases: 4179ccd446eSAtari911 // 1. Namespace field is empty (user didn't select anything) 4189ccd446eSAtari911 // 2. Namespace contains wildcards (like "personal;work" or "work*") 4199ccd446eSAtari911 // 3. Namespace is the same as what was passed (no change intended) 4209ccd446eSAtari911 $receivedNamespace = $namespace; 4219ccd446eSAtari911 if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 4229ccd446eSAtari911 if (isset($existingEventData['namespace'])) { 4239ccd446eSAtari911 $namespace = $existingEventData['namespace']; 4247e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')"); 4259ccd446eSAtari911 } else { 4267e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')"); 4279ccd446eSAtari911 } 4289ccd446eSAtari911 } else { 4297e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')"); 4309ccd446eSAtari911 } 4319ccd446eSAtari911 } else { 4327e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'"); 4339ccd446eSAtari911 } 4349ccd446eSAtari911 43596df7d3eSAtari911 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description, 43696df7d3eSAtari911 $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd, 43796df7d3eSAtari911 $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId); 43887ac9bf3SAtari911 echo json_encode(['success' => true]); 43987ac9bf3SAtari911 return; 44087ac9bf3SAtari911 } 44187ac9bf3SAtari911 44219378907SAtari911 list($year, $month, $day) = explode('-', $date); 44319378907SAtari911 4441d05cddcSAtari911 // NEW namespace directory (where we'll save) 445*2866e827SAtari911 $dataDir = $this->metaDir(); 44619378907SAtari911 if ($namespace) { 44719378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 44819378907SAtari911 } 44919378907SAtari911 $dataDir .= 'calendar/'; 45019378907SAtari911 45119378907SAtari911 if (!is_dir($dataDir)) { 45219378907SAtari911 mkdir($dataDir, 0755, true); 45319378907SAtari911 } 45419378907SAtari911 45519378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 45619378907SAtari911 45796df7d3eSAtari911 $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'"); 45896df7d3eSAtari911 45919378907SAtari911 $events = []; 46019378907SAtari911 if (file_exists($eventFile)) { 46119378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 46296df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location"); 46396df7d3eSAtari911 } else { 46496df7d3eSAtari911 $this->debugLog("Calendar saveEvent: New location file does not exist yet"); 46519378907SAtari911 } 46619378907SAtari911 4671d05cddcSAtari911 // If editing and (date changed OR namespace changed), remove from old location first 46896df7d3eSAtari911 // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace 46996df7d3eSAtari911 $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace); 4701d05cddcSAtari911 $dateChanged = ($eventId && $oldDate && $oldDate !== $date); 4711d05cddcSAtari911 47296df7d3eSAtari911 $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO')); 47396df7d3eSAtari911 4741d05cddcSAtari911 if ($namespaceChanged || $dateChanged) { 4751d05cddcSAtari911 // Construct OLD data directory using OLD namespace 476*2866e827SAtari911 $oldDataDir = $this->metaDir(); 4771d05cddcSAtari911 if ($oldNamespace) { 4781d05cddcSAtari911 $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/'; 4791d05cddcSAtari911 } 4801d05cddcSAtari911 $oldDataDir .= 'calendar/'; 4811d05cddcSAtari911 4821d05cddcSAtari911 $deleteDate = $dateChanged ? $oldDate : $date; 4831d05cddcSAtari911 list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate); 4841d05cddcSAtari911 $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 48519378907SAtari911 48696df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'"); 48796df7d3eSAtari911 48819378907SAtari911 if (file_exists($oldEventFile)) { 48919378907SAtari911 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 49096df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates"); 49196df7d3eSAtari911 4921d05cddcSAtari911 if (isset($oldEvents[$deleteDate])) { 49396df7d3eSAtari911 $countBefore = count($oldEvents[$deleteDate]); 4941d05cddcSAtari911 $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) { 49519378907SAtari911 return $evt['id'] !== $eventId; 496e3a9f44cSAtari911 })); 49796df7d3eSAtari911 $countAfter = count($oldEvents[$deleteDate]); 49896df7d3eSAtari911 49996df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter"); 50019378907SAtari911 5011d05cddcSAtari911 if (empty($oldEvents[$deleteDate])) { 5021d05cddcSAtari911 unset($oldEvents[$deleteDate]); 50319378907SAtari911 } 50419378907SAtari911 505815440faSAtari911 CalendarFileHandler::writeJson($oldEventFile, $oldEvents); 50696df7d3eSAtari911 $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'"); 50796df7d3eSAtari911 } else { 50896df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file"); 50919378907SAtari911 } 51096df7d3eSAtari911 } else { 51196df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile"); 51219378907SAtari911 } 51396df7d3eSAtari911 } else { 51496df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location"); 51519378907SAtari911 } 51619378907SAtari911 51719378907SAtari911 if (!isset($events[$date])) { 51819378907SAtari911 $events[$date] = []; 519e3a9f44cSAtari911 } elseif (!is_array($events[$date])) { 520e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 5217e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 522e3a9f44cSAtari911 $events[$date] = []; 52319378907SAtari911 } 52419378907SAtari911 525e3a9f44cSAtari911 // Store the namespace with the event 52619378907SAtari911 $eventData = [ 52787ac9bf3SAtari911 'id' => $generatedId, 52819378907SAtari911 'title' => $title, 52919378907SAtari911 'time' => $time, 5301d05cddcSAtari911 'endTime' => $endTime, 53119378907SAtari911 'description' => $description, 53219378907SAtari911 'color' => $color, 53319378907SAtari911 'isTask' => $isTask, 53419378907SAtari911 'completed' => $completed, 53519378907SAtari911 'endDate' => $endDate, 536e3a9f44cSAtari911 'namespace' => $namespace, // Store namespace with event 53719378907SAtari911 'created' => date('Y-m-d H:i:s') 53819378907SAtari911 ]; 53919378907SAtari911 5401d05cddcSAtari911 // Debug logging 5417e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile"); 5421d05cddcSAtari911 54319378907SAtari911 // If editing, replace existing event 54419378907SAtari911 if ($eventId) { 54519378907SAtari911 $found = false; 54619378907SAtari911 foreach ($events[$date] as $key => $evt) { 54719378907SAtari911 if ($evt['id'] === $eventId) { 54819378907SAtari911 $events[$date][$key] = $eventData; 54919378907SAtari911 $found = true; 55019378907SAtari911 break; 55119378907SAtari911 } 55219378907SAtari911 } 55319378907SAtari911 if (!$found) { 55419378907SAtari911 $events[$date][] = $eventData; 55519378907SAtari911 } 55619378907SAtari911 } else { 55719378907SAtari911 $events[$date][] = $eventData; 55819378907SAtari911 } 55919378907SAtari911 560815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 56119378907SAtari911 562e3a9f44cSAtari911 // If event spans multiple months, add it to the first day of each subsequent month 563e3a9f44cSAtari911 if ($endDate && $endDate !== $date) { 564e3a9f44cSAtari911 $startDateObj = new DateTime($date); 565e3a9f44cSAtari911 $endDateObj = new DateTime($endDate); 566e3a9f44cSAtari911 567e3a9f44cSAtari911 // Get the month/year of the start date 568e3a9f44cSAtari911 $startMonth = $startDateObj->format('Y-m'); 569e3a9f44cSAtari911 570e3a9f44cSAtari911 // Iterate through each month the event spans 571e3a9f44cSAtari911 $currentDate = clone $startDateObj; 572e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 573e3a9f44cSAtari911 574e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 575e3a9f44cSAtari911 $currentMonth = $currentDate->format('Y-m'); 576e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 577e3a9f44cSAtari911 578e3a9f44cSAtari911 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 579e3a9f44cSAtari911 580e3a9f44cSAtari911 // Get the file for this month 581e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 582e3a9f44cSAtari911 583e3a9f44cSAtari911 $currentEvents = []; 584e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 585e3a9f44cSAtari911 $contents = file_get_contents($currentEventFile); 586e3a9f44cSAtari911 $decoded = json_decode($contents, true); 587e3a9f44cSAtari911 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 588e3a9f44cSAtari911 $currentEvents = $decoded; 589e3a9f44cSAtari911 } else { 5907e8ea635SAtari911 $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 591e3a9f44cSAtari911 } 592e3a9f44cSAtari911 } 593e3a9f44cSAtari911 594e3a9f44cSAtari911 // Add entry for the first day of this month 595e3a9f44cSAtari911 if (!isset($currentEvents[$firstDayOfMonth])) { 596e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 597e3a9f44cSAtari911 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 598e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 5997e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 600e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 601e3a9f44cSAtari911 } 602e3a9f44cSAtari911 603e3a9f44cSAtari911 // Create a copy with the original start date preserved 604e3a9f44cSAtari911 $eventDataForMonth = $eventData; 605e3a9f44cSAtari911 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 606e3a9f44cSAtari911 607e3a9f44cSAtari911 // Check if event already exists (when editing) 608e3a9f44cSAtari911 $found = false; 609e3a9f44cSAtari911 if ($eventId) { 610e3a9f44cSAtari911 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 611e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 612e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 613e3a9f44cSAtari911 $found = true; 614e3a9f44cSAtari911 break; 615e3a9f44cSAtari911 } 616e3a9f44cSAtari911 } 617e3a9f44cSAtari911 } 618e3a9f44cSAtari911 619e3a9f44cSAtari911 if (!$found) { 620e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 621e3a9f44cSAtari911 } 622e3a9f44cSAtari911 623815440faSAtari911 CalendarFileHandler::writeJson($currentEventFile, $currentEvents); 624e3a9f44cSAtari911 625e3a9f44cSAtari911 // Move to next month 626e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 627e3a9f44cSAtari911 } 628e3a9f44cSAtari911 } 629e3a9f44cSAtari911 630815440faSAtari911 // Audit logging 631815440faSAtari911 $audit = $this->getAuditLogger(); 632815440faSAtari911 if ($eventId && ($dateChanged || $namespaceChanged)) { 633815440faSAtari911 // Event was moved 634815440faSAtari911 $audit->logMove($namespace, $oldDate ?: $date, $date, $generatedId, $title); 635815440faSAtari911 } elseif ($eventId) { 636815440faSAtari911 // Event was updated 637815440faSAtari911 $audit->logUpdate($namespace, $date, $generatedId, $title); 638815440faSAtari911 } else { 639815440faSAtari911 // New event created 640815440faSAtari911 $audit->logCreate($namespace, $date, $generatedId, $title); 641815440faSAtari911 } 642815440faSAtari911 64319378907SAtari911 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 64419378907SAtari911 } 64519378907SAtari911 64619378907SAtari911 private function deleteEvent() { 64719378907SAtari911 global $INPUT; 64819378907SAtari911 64919378907SAtari911 $namespace = $INPUT->str('namespace', ''); 65019378907SAtari911 $date = $INPUT->str('date'); 65119378907SAtari911 $eventId = $INPUT->str('eventId'); 65219378907SAtari911 653e3a9f44cSAtari911 // Find where the event actually lives 654e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 655e3a9f44cSAtari911 656e3a9f44cSAtari911 if ($storedNamespace === null) { 657e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 658e3a9f44cSAtari911 return; 659e3a9f44cSAtari911 } 660e3a9f44cSAtari911 661e3a9f44cSAtari911 // Use the found namespace 662e3a9f44cSAtari911 $namespace = $storedNamespace; 663e3a9f44cSAtari911 664*2866e827SAtari911 // ACL check: verify edit access to delete events 665*2866e827SAtari911 if (!$this->checkNamespaceEdit($namespace)) { 666*2866e827SAtari911 echo json_encode(['success' => false, 'error' => 'You do not have permission to delete events in this namespace']); 667*2866e827SAtari911 return; 668*2866e827SAtari911 } 669*2866e827SAtari911 67019378907SAtari911 list($year, $month, $day) = explode('-', $date); 67119378907SAtari911 672*2866e827SAtari911 $dataDir = $this->metaDir(); 67319378907SAtari911 if ($namespace) { 67419378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 67519378907SAtari911 } 67619378907SAtari911 $dataDir .= 'calendar/'; 67719378907SAtari911 67819378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 67919378907SAtari911 6809ccd446eSAtari911 // First, get the event to check if it spans multiple months or is recurring 681e3a9f44cSAtari911 $eventToDelete = null; 6829ccd446eSAtari911 $isRecurring = false; 6839ccd446eSAtari911 $recurringId = null; 6849ccd446eSAtari911 68519378907SAtari911 if (file_exists($eventFile)) { 68619378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 68719378907SAtari911 68819378907SAtari911 if (isset($events[$date])) { 689e3a9f44cSAtari911 foreach ($events[$date] as $event) { 690e3a9f44cSAtari911 if ($event['id'] === $eventId) { 691e3a9f44cSAtari911 $eventToDelete = $event; 6929ccd446eSAtari911 $isRecurring = isset($event['recurring']) && $event['recurring']; 6939ccd446eSAtari911 $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 694e3a9f44cSAtari911 break; 695e3a9f44cSAtari911 } 696e3a9f44cSAtari911 } 697e3a9f44cSAtari911 698e3a9f44cSAtari911 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 69919378907SAtari911 return $event['id'] !== $eventId; 700e3a9f44cSAtari911 })); 70119378907SAtari911 70219378907SAtari911 if (empty($events[$date])) { 70319378907SAtari911 unset($events[$date]); 70419378907SAtari911 } 70519378907SAtari911 706815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 70719378907SAtari911 } 70819378907SAtari911 } 70919378907SAtari911 7109ccd446eSAtari911 // If this is a recurring event, delete ALL occurrences with the same recurringId 7119ccd446eSAtari911 if ($isRecurring && $recurringId) { 7129ccd446eSAtari911 $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir); 7139ccd446eSAtari911 } 7149ccd446eSAtari911 715e3a9f44cSAtari911 // If event spans multiple months, delete it from the first day of each subsequent month 716e3a9f44cSAtari911 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 717e3a9f44cSAtari911 $startDateObj = new DateTime($date); 718e3a9f44cSAtari911 $endDateObj = new DateTime($eventToDelete['endDate']); 719e3a9f44cSAtari911 720e3a9f44cSAtari911 // Iterate through each month the event spans 721e3a9f44cSAtari911 $currentDate = clone $startDateObj; 722e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 723e3a9f44cSAtari911 724e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 725e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 726e3a9f44cSAtari911 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 727e3a9f44cSAtari911 728e3a9f44cSAtari911 // Get the file for this month 729e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 730e3a9f44cSAtari911 731e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 732e3a9f44cSAtari911 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 733e3a9f44cSAtari911 734e3a9f44cSAtari911 if (isset($currentEvents[$firstDayOfMonth])) { 735e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 736e3a9f44cSAtari911 return $event['id'] !== $eventId; 737e3a9f44cSAtari911 })); 738e3a9f44cSAtari911 739e3a9f44cSAtari911 if (empty($currentEvents[$firstDayOfMonth])) { 740e3a9f44cSAtari911 unset($currentEvents[$firstDayOfMonth]); 741e3a9f44cSAtari911 } 742e3a9f44cSAtari911 743815440faSAtari911 CalendarFileHandler::writeJson($currentEventFile, $currentEvents); 744e3a9f44cSAtari911 } 745e3a9f44cSAtari911 } 746e3a9f44cSAtari911 747e3a9f44cSAtari911 // Move to next month 748e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 749e3a9f44cSAtari911 } 750e3a9f44cSAtari911 } 751e3a9f44cSAtari911 752815440faSAtari911 // Audit logging 753815440faSAtari911 $audit = $this->getAuditLogger(); 754815440faSAtari911 $eventTitle = $eventToDelete ? ($eventToDelete['title'] ?? '') : ''; 755815440faSAtari911 $audit->logDelete($namespace, $date, $eventId, $eventTitle); 756815440faSAtari911 75719378907SAtari911 echo json_encode(['success' => true]); 75819378907SAtari911 } 75919378907SAtari911 76019378907SAtari911 private function getEvent() { 76119378907SAtari911 global $INPUT; 76219378907SAtari911 76319378907SAtari911 $namespace = $INPUT->str('namespace', ''); 76419378907SAtari911 $date = $INPUT->str('date'); 76519378907SAtari911 $eventId = $INPUT->str('eventId'); 76619378907SAtari911 767e3a9f44cSAtari911 // Find where the event actually lives 768e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 769e3a9f44cSAtari911 770e3a9f44cSAtari911 if ($storedNamespace === null) { 771e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 772e3a9f44cSAtari911 return; 773e3a9f44cSAtari911 } 774e3a9f44cSAtari911 775e3a9f44cSAtari911 // Use the found namespace 776e3a9f44cSAtari911 $namespace = $storedNamespace; 777e3a9f44cSAtari911 778*2866e827SAtari911 // ACL check: verify read access to the event's namespace 779*2866e827SAtari911 if (!$this->checkNamespaceRead($namespace)) { 780*2866e827SAtari911 echo json_encode(['success' => false, 'error' => 'Access denied']); 781*2866e827SAtari911 return; 782*2866e827SAtari911 } 783*2866e827SAtari911 78419378907SAtari911 list($year, $month, $day) = explode('-', $date); 78519378907SAtari911 786*2866e827SAtari911 $dataDir = $this->metaDir(); 78719378907SAtari911 if ($namespace) { 78819378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 78919378907SAtari911 } 79019378907SAtari911 $dataDir .= 'calendar/'; 79119378907SAtari911 79219378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 79319378907SAtari911 79419378907SAtari911 if (file_exists($eventFile)) { 79519378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 79619378907SAtari911 79719378907SAtari911 if (isset($events[$date])) { 79819378907SAtari911 foreach ($events[$date] as $event) { 79919378907SAtari911 if ($event['id'] === $eventId) { 8001d05cddcSAtari911 // Include the namespace so JavaScript knows where this event actually lives 8011d05cddcSAtari911 $event['namespace'] = $namespace; 80219378907SAtari911 echo json_encode(['success' => true, 'event' => $event]); 80319378907SAtari911 return; 80419378907SAtari911 } 80519378907SAtari911 } 80619378907SAtari911 } 80719378907SAtari911 } 80819378907SAtari911 80919378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 81019378907SAtari911 } 81119378907SAtari911 81219378907SAtari911 private function loadMonth() { 81319378907SAtari911 global $INPUT; 81419378907SAtari911 815e3a9f44cSAtari911 // Prevent caching of AJAX responses 816e3a9f44cSAtari911 header('Cache-Control: no-cache, no-store, must-revalidate'); 817e3a9f44cSAtari911 header('Pragma: no-cache'); 818e3a9f44cSAtari911 header('Expires: 0'); 819e3a9f44cSAtari911 82019378907SAtari911 $namespace = $INPUT->str('namespace', ''); 82119378907SAtari911 $year = $INPUT->int('year'); 82219378907SAtari911 $month = $INPUT->int('month'); 823*2866e827SAtari911 $exclude = $INPUT->str('exclude', ''); 824*2866e827SAtari911 $excludeList = $this->parseExcludeList($exclude); 82519378907SAtari911 8267e8ea635SAtari911 // Validate year (reasonable range: 1970-2100) 8277e8ea635SAtari911 if ($year < 1970 || $year > 2100) { 8287e8ea635SAtari911 $year = (int)date('Y'); 8297e8ea635SAtari911 } 8307e8ea635SAtari911 8317e8ea635SAtari911 // Validate month (1-12) 8327e8ea635SAtari911 if ($month < 1 || $month > 12) { 8337e8ea635SAtari911 $month = (int)date('n'); 8347e8ea635SAtari911 } 8357e8ea635SAtari911 8367e8ea635SAtari911 // Validate namespace format 8377e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 8387e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 8397e8ea635SAtari911 return; 8407e8ea635SAtari911 } 8417e8ea635SAtari911 842*2866e827SAtari911 // ACL check: for single namespace, verify read access 843*2866e827SAtari911 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 844*2866e827SAtari911 if (!$isMultiNamespace && !$this->checkNamespaceRead($namespace)) { 845*2866e827SAtari911 echo json_encode(['success' => false, 'error' => 'Access denied']); 846*2866e827SAtari911 return; 847*2866e827SAtari911 } 848*2866e827SAtari911 8497e8ea635SAtari911 $this->debugLog("=== Calendar loadMonth DEBUG ==="); 850*2866e827SAtari911 $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace', exclude='$exclude'"); 851e3a9f44cSAtari911 852e3a9f44cSAtari911 // Check if multi-namespace or wildcard 853e3a9f44cSAtari911 8547e8ea635SAtari911 $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 855e3a9f44cSAtari911 856e3a9f44cSAtari911 if ($isMultiNamespace) { 857*2866e827SAtari911 $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList); 858e3a9f44cSAtari911 } else { 859e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 860e3a9f44cSAtari911 } 861e3a9f44cSAtari911 8627e8ea635SAtari911 $this->debugLog("Returning " . count($events) . " date keys"); 863e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 8647e8ea635SAtari911 $this->debugLog(" dateKey=$dateKey has " . count($dayEvents) . " events"); 865e3a9f44cSAtari911 } 866e3a9f44cSAtari911 867e3a9f44cSAtari911 echo json_encode([ 868e3a9f44cSAtari911 'success' => true, 869e3a9f44cSAtari911 'year' => $year, 870e3a9f44cSAtari911 'month' => $month, 871e3a9f44cSAtari911 'events' => $events 872e3a9f44cSAtari911 ]); 873e3a9f44cSAtari911 } 874e3a9f44cSAtari911 875da206178SAtari911 /** 876da206178SAtari911 * Get static calendar HTML via AJAX for navigation 877da206178SAtari911 */ 878da206178SAtari911 private function getStaticCalendar() { 879da206178SAtari911 global $INPUT; 880da206178SAtari911 881da206178SAtari911 $namespace = $INPUT->str('namespace', ''); 882da206178SAtari911 $year = $INPUT->int('year'); 883da206178SAtari911 $month = $INPUT->int('month'); 884da206178SAtari911 885da206178SAtari911 // Validate 886da206178SAtari911 if ($year < 1970 || $year > 2100) { 887da206178SAtari911 $year = (int)date('Y'); 888da206178SAtari911 } 889da206178SAtari911 if ($month < 1 || $month > 12) { 890da206178SAtari911 $month = (int)date('n'); 891da206178SAtari911 } 892da206178SAtari911 893*2866e827SAtari911 // ACL check: verify read access for single namespace 894*2866e827SAtari911 $isMulti = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 895*2866e827SAtari911 if (!$isMulti && !$this->checkNamespaceRead($namespace)) { 896*2866e827SAtari911 echo json_encode(['success' => false, 'error' => 'Access denied']); 897*2866e827SAtari911 return; 898*2866e827SAtari911 } 899*2866e827SAtari911 900da206178SAtari911 // Get syntax plugin to render the static calendar 901da206178SAtari911 $syntax = plugin_load('syntax', 'calendar'); 902da206178SAtari911 if (!$syntax) { 903da206178SAtari911 echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']); 904da206178SAtari911 return; 905da206178SAtari911 } 906da206178SAtari911 907da206178SAtari911 // Build data array for render 908da206178SAtari911 $data = [ 909da206178SAtari911 'year' => $year, 910da206178SAtari911 'month' => $month, 911da206178SAtari911 'namespace' => $namespace, 912da206178SAtari911 'static' => true 913da206178SAtari911 ]; 914da206178SAtari911 915da206178SAtari911 // Call the render method via reflection (since renderStaticCalendar is private) 916da206178SAtari911 $reflector = new \ReflectionClass($syntax); 917da206178SAtari911 $method = $reflector->getMethod('renderStaticCalendar'); 918da206178SAtari911 $method->setAccessible(true); 919da206178SAtari911 $html = $method->invoke($syntax, $data); 920da206178SAtari911 921da206178SAtari911 echo json_encode([ 922da206178SAtari911 'success' => true, 923da206178SAtari911 'html' => $html 924da206178SAtari911 ]); 925da206178SAtari911 } 926da206178SAtari911 927e3a9f44cSAtari911 private function loadEventsSingleNamespace($namespace, $year, $month) { 928*2866e827SAtari911 $dataDir = $this->metaDir(); 92919378907SAtari911 if ($namespace) { 93019378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 93119378907SAtari911 } 93219378907SAtari911 $dataDir .= 'calendar/'; 93319378907SAtari911 934e3a9f44cSAtari911 // Load ONLY current month 93587ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 93619378907SAtari911 $events = []; 93719378907SAtari911 if (file_exists($eventFile)) { 93887ac9bf3SAtari911 $contents = file_get_contents($eventFile); 93987ac9bf3SAtari911 $decoded = json_decode($contents, true); 94087ac9bf3SAtari911 if (json_last_error() === JSON_ERROR_NONE) { 94187ac9bf3SAtari911 $events = $decoded; 94287ac9bf3SAtari911 } 94387ac9bf3SAtari911 } 94487ac9bf3SAtari911 945e3a9f44cSAtari911 return $events; 94687ac9bf3SAtari911 } 947e3a9f44cSAtari911 948*2866e827SAtari911 private function loadEventsMultiNamespace($namespaces, $year, $month, $excludeList = []) { 949e3a9f44cSAtari911 // Check for wildcard pattern 950e3a9f44cSAtari911 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 951e3a9f44cSAtari911 $baseNamespace = $matches[1]; 952*2866e827SAtari911 return $this->loadEventsWildcard($baseNamespace, $year, $month, $excludeList); 953e3a9f44cSAtari911 } 954e3a9f44cSAtari911 955e3a9f44cSAtari911 // Check for root wildcard 956e3a9f44cSAtari911 if ($namespaces === '*') { 957*2866e827SAtari911 return $this->loadEventsWildcard('', $year, $month, $excludeList); 958e3a9f44cSAtari911 } 959e3a9f44cSAtari911 960e3a9f44cSAtari911 // Parse namespace list (semicolon separated) 961e3a9f44cSAtari911 $namespaceList = array_map('trim', explode(';', $namespaces)); 962e3a9f44cSAtari911 963e3a9f44cSAtari911 // Load events from all namespaces 964e3a9f44cSAtari911 $allEvents = []; 965e3a9f44cSAtari911 foreach ($namespaceList as $ns) { 966e3a9f44cSAtari911 $ns = trim($ns); 967e3a9f44cSAtari911 if (empty($ns)) continue; 968e3a9f44cSAtari911 969*2866e827SAtari911 // Skip excluded namespaces 970*2866e827SAtari911 if ($this->isNamespaceExcluded($ns, $excludeList)) continue; 971*2866e827SAtari911 972*2866e827SAtari911 // ACL check: skip namespaces user cannot read 973*2866e827SAtari911 if (!$this->checkNamespaceRead($ns)) continue; 974*2866e827SAtari911 975e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 976e3a9f44cSAtari911 977e3a9f44cSAtari911 // Add namespace tag to each event 978e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 979e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 980e3a9f44cSAtari911 $allEvents[$dateKey] = []; 981e3a9f44cSAtari911 } 982e3a9f44cSAtari911 foreach ($dayEvents as $event) { 983e3a9f44cSAtari911 $event['_namespace'] = $ns; 984e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 985e3a9f44cSAtari911 } 98687ac9bf3SAtari911 } 98787ac9bf3SAtari911 } 98887ac9bf3SAtari911 989e3a9f44cSAtari911 return $allEvents; 990e3a9f44cSAtari911 } 99119378907SAtari911 992*2866e827SAtari911 private function loadEventsWildcard($baseNamespace, $year, $month, $excludeList = []) { 993*2866e827SAtari911 $metaDir = $this->metaDir(); 994*2866e827SAtari911 $dataDir = $metaDir; 995e3a9f44cSAtari911 if ($baseNamespace) { 996e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 997e3a9f44cSAtari911 } 998e3a9f44cSAtari911 999e3a9f44cSAtari911 $allEvents = []; 1000e3a9f44cSAtari911 1001*2866e827SAtari911 // Load events from the base namespace itself 1002*2866e827SAtari911 if (!$this->isNamespaceExcluded($baseNamespace, $excludeList) && $this->checkNamespaceRead($baseNamespace)) { 1003e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 1004e3a9f44cSAtari911 1005e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 1006e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 1007e3a9f44cSAtari911 $allEvents[$dateKey] = []; 1008e3a9f44cSAtari911 } 1009e3a9f44cSAtari911 foreach ($dayEvents as $event) { 1010e3a9f44cSAtari911 $event['_namespace'] = $baseNamespace; 1011e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 1012e3a9f44cSAtari911 } 1013e3a9f44cSAtari911 } 1014*2866e827SAtari911 } 1015e3a9f44cSAtari911 1016*2866e827SAtari911 // Find all calendar directories efficiently using iterative glob 1017*2866e827SAtari911 $this->findCalendarNamespaces($dataDir, $metaDir, $year, $month, $allEvents, $excludeList); 1018e3a9f44cSAtari911 1019e3a9f44cSAtari911 return $allEvents; 1020e3a9f44cSAtari911 } 1021e3a9f44cSAtari911 1022*2866e827SAtari911 /** 1023*2866e827SAtari911 * Find namespaces with calendar data using iterative glob 1024*2866e827SAtari911 * Searches for 'calendar/' directories at increasing depth without 1025*2866e827SAtari911 * scanning every directory in data/meta 1026*2866e827SAtari911 */ 1027*2866e827SAtari911 private function findCalendarNamespaces($baseDir, $metaDir, $year, $month, &$allEvents, $excludeList = []) { 1028*2866e827SAtari911 if (!is_dir($baseDir)) return; 1029e3a9f44cSAtari911 1030*2866e827SAtari911 $maxDepth = 10; 1031*2866e827SAtari911 $metaDirLen = strlen($metaDir); 1032e3a9f44cSAtari911 1033*2866e827SAtari911 for ($depth = 1; $depth <= $maxDepth; $depth++) { 1034*2866e827SAtari911 $pattern = $baseDir . str_repeat('*/', $depth) . 'calendar'; 1035*2866e827SAtari911 $calDirs = glob($pattern, GLOB_ONLYDIR); 1036e3a9f44cSAtari911 1037*2866e827SAtari911 if (empty($calDirs)) { 1038*2866e827SAtari911 if ($depth > 3) break; 1039*2866e827SAtari911 continue; 1040*2866e827SAtari911 } 1041*2866e827SAtari911 1042*2866e827SAtari911 foreach ($calDirs as $calDir) { 1043*2866e827SAtari911 $nsDir = dirname($calDir); 1044*2866e827SAtari911 $relPath = substr($nsDir, $metaDirLen); 1045*2866e827SAtari911 $namespace = str_replace('/', ':', trim($relPath, '/')); 1046*2866e827SAtari911 1047*2866e827SAtari911 if (empty($namespace)) continue; 1048*2866e827SAtari911 if ($this->isNamespaceExcluded($namespace, $excludeList)) continue; 1049*2866e827SAtari911 if (!$this->checkNamespaceRead($namespace)) continue; 1050*2866e827SAtari911 1051e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 1052e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 1053e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 1054e3a9f44cSAtari911 $allEvents[$dateKey] = []; 1055e3a9f44cSAtari911 } 1056e3a9f44cSAtari911 foreach ($dayEvents as $event) { 1057e3a9f44cSAtari911 $event['_namespace'] = $namespace; 1058e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 1059e3a9f44cSAtari911 } 1060e3a9f44cSAtari911 } 1061e3a9f44cSAtari911 } 1062e3a9f44cSAtari911 } 106319378907SAtari911 } 106419378907SAtari911 106596df7d3eSAtari911 /** 106696df7d3eSAtari911 * Search all dates for events matching the search term 106796df7d3eSAtari911 */ 106896df7d3eSAtari911 private function searchAllDates() { 106996df7d3eSAtari911 global $INPUT; 107096df7d3eSAtari911 107196df7d3eSAtari911 $searchTerm = strtolower(trim($INPUT->str('search', ''))); 107296df7d3eSAtari911 $namespace = $INPUT->str('namespace', ''); 1073*2866e827SAtari911 $exclude = $INPUT->str('exclude', ''); 1074*2866e827SAtari911 $excludeList = $this->parseExcludeList($exclude); 107596df7d3eSAtari911 107696df7d3eSAtari911 if (strlen($searchTerm) < 2) { 107796df7d3eSAtari911 echo json_encode(['success' => false, 'error' => 'Search term too short']); 107896df7d3eSAtari911 return; 107996df7d3eSAtari911 } 108096df7d3eSAtari911 108196df7d3eSAtari911 // Normalize search term for fuzzy matching 108296df7d3eSAtari911 $normalizedSearch = $this->normalizeForSearch($searchTerm); 108396df7d3eSAtari911 108496df7d3eSAtari911 $results = []; 1085*2866e827SAtari911 $dataDir = $this->metaDir(); 108696df7d3eSAtari911 108796df7d3eSAtari911 // Helper to search calendar directory 1088*2866e827SAtari911 $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results, $excludeList) { 108996df7d3eSAtari911 if (!is_dir($calDir)) return; 109096df7d3eSAtari911 1091*2866e827SAtari911 // Skip excluded namespaces 1092*2866e827SAtari911 if ($this->isNamespaceExcluded($eventNamespace, $excludeList)) return; 1093*2866e827SAtari911 1094*2866e827SAtari911 // ACL check: skip namespaces user cannot read 1095*2866e827SAtari911 if (!$this->checkNamespaceRead($eventNamespace)) return; 1096*2866e827SAtari911 109796df7d3eSAtari911 foreach (glob($calDir . '/*.json') as $file) { 109896df7d3eSAtari911 $data = @json_decode(file_get_contents($file), true); 109996df7d3eSAtari911 if (!$data || !is_array($data)) continue; 110096df7d3eSAtari911 110196df7d3eSAtari911 foreach ($data as $dateKey => $dayEvents) { 110296df7d3eSAtari911 // Skip non-date keys 110396df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 110496df7d3eSAtari911 if (!is_array($dayEvents)) continue; 110596df7d3eSAtari911 110696df7d3eSAtari911 foreach ($dayEvents as $event) { 110796df7d3eSAtari911 if (!isset($event['title'])) continue; 110896df7d3eSAtari911 110996df7d3eSAtari911 // Build searchable text 111096df7d3eSAtari911 $searchableText = strtolower($event['title']); 111196df7d3eSAtari911 if (isset($event['description'])) { 111296df7d3eSAtari911 $searchableText .= ' ' . strtolower($event['description']); 111396df7d3eSAtari911 } 111496df7d3eSAtari911 111596df7d3eSAtari911 // Normalize for fuzzy matching 111696df7d3eSAtari911 $normalizedText = $this->normalizeForSearch($searchableText); 111796df7d3eSAtari911 111896df7d3eSAtari911 // Check if matches using fuzzy match 111996df7d3eSAtari911 if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) { 112096df7d3eSAtari911 $results[] = [ 112196df7d3eSAtari911 'date' => $dateKey, 112296df7d3eSAtari911 'title' => $event['title'], 112396df7d3eSAtari911 'time' => isset($event['time']) ? $event['time'] : '', 112496df7d3eSAtari911 'endTime' => isset($event['endTime']) ? $event['endTime'] : '', 112596df7d3eSAtari911 'color' => isset($event['color']) ? $event['color'] : '', 112696df7d3eSAtari911 'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace, 112796df7d3eSAtari911 'id' => isset($event['id']) ? $event['id'] : '' 112896df7d3eSAtari911 ]; 112996df7d3eSAtari911 } 113096df7d3eSAtari911 } 113196df7d3eSAtari911 } 113296df7d3eSAtari911 } 113396df7d3eSAtari911 }; 113496df7d3eSAtari911 113596df7d3eSAtari911 // Search root calendar directory 113696df7d3eSAtari911 $searchCalendarDir($dataDir . 'calendar', ''); 113796df7d3eSAtari911 113896df7d3eSAtari911 // Search namespace directories 113996df7d3eSAtari911 $this->searchNamespaceDirs($dataDir, $searchCalendarDir); 114096df7d3eSAtari911 114196df7d3eSAtari911 // Sort results by date (newest first for past, oldest first for future) 114296df7d3eSAtari911 usort($results, function($a, $b) { 114396df7d3eSAtari911 return strcmp($a['date'], $b['date']); 114496df7d3eSAtari911 }); 114596df7d3eSAtari911 114696df7d3eSAtari911 // Limit results 114796df7d3eSAtari911 $results = array_slice($results, 0, 50); 114896df7d3eSAtari911 114996df7d3eSAtari911 echo json_encode([ 115096df7d3eSAtari911 'success' => true, 115196df7d3eSAtari911 'results' => $results, 115296df7d3eSAtari911 'total' => count($results) 115396df7d3eSAtari911 ]); 115496df7d3eSAtari911 } 115596df7d3eSAtari911 115696df7d3eSAtari911 /** 115796df7d3eSAtari911 * Check if normalized text matches normalized search term 115896df7d3eSAtari911 * Supports multi-word search where all words must be present 115996df7d3eSAtari911 */ 116096df7d3eSAtari911 private function fuzzyMatchText($normalizedText, $normalizedSearch) { 116196df7d3eSAtari911 // Direct substring match 116296df7d3eSAtari911 if (strpos($normalizedText, $normalizedSearch) !== false) { 116396df7d3eSAtari911 return true; 116496df7d3eSAtari911 } 116596df7d3eSAtari911 116696df7d3eSAtari911 // Multi-word search: all words must be present 116796df7d3eSAtari911 $searchWords = array_filter(explode(' ', $normalizedSearch)); 116896df7d3eSAtari911 if (count($searchWords) > 1) { 116996df7d3eSAtari911 foreach ($searchWords as $word) { 117096df7d3eSAtari911 if (strlen($word) > 0 && strpos($normalizedText, $word) === false) { 117196df7d3eSAtari911 return false; 117296df7d3eSAtari911 } 117396df7d3eSAtari911 } 117496df7d3eSAtari911 return true; 117596df7d3eSAtari911 } 117696df7d3eSAtari911 117796df7d3eSAtari911 return false; 117896df7d3eSAtari911 } 117996df7d3eSAtari911 118096df7d3eSAtari911 /** 118196df7d3eSAtari911 * Normalize text for fuzzy search matching 118296df7d3eSAtari911 * Removes apostrophes, extra spaces, and common variations 118396df7d3eSAtari911 */ 118496df7d3eSAtari911 private function normalizeForSearch($text) { 118596df7d3eSAtari911 // Convert to lowercase 118696df7d3eSAtari911 $text = strtolower($text); 118796df7d3eSAtari911 118896df7d3eSAtari911 // Remove apostrophes and quotes (father's -> fathers) 118996df7d3eSAtari911 $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text); 119096df7d3eSAtari911 119196df7d3eSAtari911 // Normalize dashes and underscores to spaces 119296df7d3eSAtari911 $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text); 119396df7d3eSAtari911 119496df7d3eSAtari911 // Remove other punctuation but keep letters, numbers, spaces 119596df7d3eSAtari911 $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text); 119696df7d3eSAtari911 119796df7d3eSAtari911 // Normalize multiple spaces to single space 119896df7d3eSAtari911 $text = preg_replace('/\s+/', ' ', $text); 119996df7d3eSAtari911 120096df7d3eSAtari911 // Trim 120196df7d3eSAtari911 $text = trim($text); 120296df7d3eSAtari911 120396df7d3eSAtari911 return $text; 120496df7d3eSAtari911 } 120596df7d3eSAtari911 120696df7d3eSAtari911 /** 1207*2866e827SAtari911 * Parse exclude parameter into an array of namespace strings 1208*2866e827SAtari911 * Supports semicolon-separated list: "journal;drafts;personal:private" 1209*2866e827SAtari911 */ 1210*2866e827SAtari911 private function parseExcludeList($exclude) { 1211*2866e827SAtari911 if (empty($exclude)) return []; 1212*2866e827SAtari911 return array_filter(array_map('trim', explode(';', $exclude)), function($v) { 1213*2866e827SAtari911 return $v !== ''; 1214*2866e827SAtari911 }); 1215*2866e827SAtari911 } 1216*2866e827SAtari911 1217*2866e827SAtari911 /** 1218*2866e827SAtari911 * Check if a namespace should be excluded 1219*2866e827SAtari911 * Matches exact names and prefixes (e.g., exclude "journal" also excludes "journal:sub") 1220*2866e827SAtari911 */ 1221*2866e827SAtari911 private function isNamespaceExcluded($namespace, $excludeList) { 1222*2866e827SAtari911 if (empty($excludeList) || $namespace === '') return false; 1223*2866e827SAtari911 foreach ($excludeList as $excluded) { 1224*2866e827SAtari911 if ($namespace === $excluded) return true; 1225*2866e827SAtari911 if (strpos($namespace, $excluded . ':') === 0) return true; 1226*2866e827SAtari911 } 1227*2866e827SAtari911 return false; 1228*2866e827SAtari911 } 1229*2866e827SAtari911 1230*2866e827SAtari911 /** 123196df7d3eSAtari911 * Recursively search namespace directories for calendar data 123296df7d3eSAtari911 */ 123396df7d3eSAtari911 private function searchNamespaceDirs($baseDir, $callback) { 123496df7d3eSAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 123596df7d3eSAtari911 $name = basename($nsDir); 123696df7d3eSAtari911 if ($name === 'calendar') continue; 123796df7d3eSAtari911 123896df7d3eSAtari911 $calDir = $nsDir . '/calendar'; 123996df7d3eSAtari911 if (is_dir($calDir)) { 1240*2866e827SAtari911 $relPath = str_replace($this->metaDir(), '', $nsDir); 124196df7d3eSAtari911 $namespace = str_replace('/', ':', $relPath); 124296df7d3eSAtari911 $callback($calDir, $namespace); 124396df7d3eSAtari911 } 124496df7d3eSAtari911 124596df7d3eSAtari911 // Recurse 124696df7d3eSAtari911 $this->searchNamespaceDirs($nsDir . '/', $callback); 124796df7d3eSAtari911 } 124896df7d3eSAtari911 } 124996df7d3eSAtari911 125019378907SAtari911 private function toggleTaskComplete() { 125119378907SAtari911 global $INPUT; 125219378907SAtari911 125319378907SAtari911 $namespace = $INPUT->str('namespace', ''); 125419378907SAtari911 $date = $INPUT->str('date'); 125519378907SAtari911 $eventId = $INPUT->str('eventId'); 125619378907SAtari911 $completed = $INPUT->bool('completed', false); 125719378907SAtari911 1258e3a9f44cSAtari911 // Find where the event actually lives 1259e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 1260e3a9f44cSAtari911 1261e3a9f44cSAtari911 if ($storedNamespace === null) { 1262e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 1263e3a9f44cSAtari911 return; 1264e3a9f44cSAtari911 } 1265e3a9f44cSAtari911 1266e3a9f44cSAtari911 // Use the found namespace 1267e3a9f44cSAtari911 $namespace = $storedNamespace; 1268e3a9f44cSAtari911 1269*2866e827SAtari911 // ACL check: verify edit access to toggle tasks 1270*2866e827SAtari911 if (!$this->checkNamespaceEdit($namespace)) { 1271*2866e827SAtari911 echo json_encode(['success' => false, 'error' => 'You do not have permission to edit events in this namespace']); 1272*2866e827SAtari911 return; 1273*2866e827SAtari911 } 1274*2866e827SAtari911 127519378907SAtari911 list($year, $month, $day) = explode('-', $date); 127619378907SAtari911 1277*2866e827SAtari911 $dataDir = $this->metaDir(); 127819378907SAtari911 if ($namespace) { 127919378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 128019378907SAtari911 } 128119378907SAtari911 $dataDir .= 'calendar/'; 128219378907SAtari911 128319378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 128419378907SAtari911 128519378907SAtari911 if (file_exists($eventFile)) { 128619378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 128719378907SAtari911 128819378907SAtari911 if (isset($events[$date])) { 1289815440faSAtari911 $eventTitle = ''; 129019378907SAtari911 foreach ($events[$date] as $key => $event) { 129119378907SAtari911 if ($event['id'] === $eventId) { 129219378907SAtari911 $events[$date][$key]['completed'] = $completed; 1293815440faSAtari911 $eventTitle = $event['title'] ?? ''; 129419378907SAtari911 break; 129519378907SAtari911 } 129619378907SAtari911 } 129719378907SAtari911 1298815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 1299815440faSAtari911 1300815440faSAtari911 // Audit logging 1301815440faSAtari911 $audit = $this->getAuditLogger(); 1302815440faSAtari911 $audit->logTaskToggle($namespace, $date, $eventId, $eventTitle, $completed); 1303815440faSAtari911 130419378907SAtari911 echo json_encode(['success' => true, 'events' => $events]); 130519378907SAtari911 return; 130619378907SAtari911 } 130719378907SAtari911 } 130819378907SAtari911 130919378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 131019378907SAtari911 } 131119378907SAtari911 1312815440faSAtari911 // ======================================================================== 1313815440faSAtari911 // GOOGLE CALENDAR SYNC HANDLERS 1314815440faSAtari911 // ======================================================================== 1315815440faSAtari911 1316815440faSAtari911 /** 1317815440faSAtari911 * Get Google OAuth authorization URL 1318815440faSAtari911 */ 1319815440faSAtari911 private function getGoogleAuthUrl() { 1320815440faSAtari911 if (!auth_isadmin()) { 1321815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1322815440faSAtari911 return; 1323815440faSAtari911 } 1324815440faSAtari911 1325815440faSAtari911 $sync = $this->getGoogleSync(); 1326815440faSAtari911 1327815440faSAtari911 if (!$sync->isConfigured()) { 1328815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Google sync not configured. Please enter Client ID and Secret first.']); 1329815440faSAtari911 return; 1330815440faSAtari911 } 1331815440faSAtari911 1332815440faSAtari911 // Build redirect URI 1333815440faSAtari911 $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback'; 1334815440faSAtari911 1335815440faSAtari911 $authUrl = $sync->getAuthUrl($redirectUri); 1336815440faSAtari911 1337815440faSAtari911 echo json_encode(['success' => true, 'url' => $authUrl]); 1338815440faSAtari911 } 1339815440faSAtari911 1340815440faSAtari911 /** 1341815440faSAtari911 * Handle Google OAuth callback 1342815440faSAtari911 */ 1343815440faSAtari911 private function handleGoogleCallback() { 1344815440faSAtari911 global $INPUT; 1345815440faSAtari911 1346815440faSAtari911 $code = $INPUT->str('code'); 1347815440faSAtari911 $state = $INPUT->str('state'); 1348815440faSAtari911 $error = $INPUT->str('error'); 1349815440faSAtari911 1350815440faSAtari911 // Check for OAuth error 1351815440faSAtari911 if ($error) { 1352815440faSAtari911 $this->showGoogleCallbackResult(false, 'Authorization denied: ' . $error); 1353815440faSAtari911 return; 1354815440faSAtari911 } 1355815440faSAtari911 1356815440faSAtari911 if (!$code) { 1357815440faSAtari911 $this->showGoogleCallbackResult(false, 'No authorization code received'); 1358815440faSAtari911 return; 1359815440faSAtari911 } 1360815440faSAtari911 1361815440faSAtari911 $sync = $this->getGoogleSync(); 1362815440faSAtari911 1363815440faSAtari911 // Verify state for CSRF protection 1364815440faSAtari911 if (!$sync->verifyState($state)) { 1365815440faSAtari911 $this->showGoogleCallbackResult(false, 'Invalid state parameter'); 1366815440faSAtari911 return; 1367815440faSAtari911 } 1368815440faSAtari911 1369815440faSAtari911 // Exchange code for tokens 1370815440faSAtari911 $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback'; 1371815440faSAtari911 $result = $sync->handleCallback($code, $redirectUri); 1372815440faSAtari911 1373815440faSAtari911 if ($result['success']) { 1374815440faSAtari911 $this->showGoogleCallbackResult(true, 'Successfully connected to Google Calendar!'); 1375815440faSAtari911 } else { 1376815440faSAtari911 $this->showGoogleCallbackResult(false, $result['error']); 1377815440faSAtari911 } 1378815440faSAtari911 } 1379815440faSAtari911 1380815440faSAtari911 /** 1381815440faSAtari911 * Show OAuth callback result page 1382815440faSAtari911 */ 1383815440faSAtari911 private function showGoogleCallbackResult($success, $message) { 1384815440faSAtari911 $status = $success ? 'Success!' : 'Error'; 1385815440faSAtari911 $color = $success ? '#2ecc71' : '#e74c3c'; 1386815440faSAtari911 1387815440faSAtari911 echo '<!DOCTYPE html> 1388815440faSAtari911<html> 1389815440faSAtari911<head> 1390815440faSAtari911 <title>Google Calendar - ' . $status . '</title> 1391815440faSAtari911 <style> 1392815440faSAtari911 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 1393815440faSAtari911 display: flex; align-items: center; justify-content: center; 1394815440faSAtari911 min-height: 100vh; margin: 0; background: #f5f5f5; } 1395815440faSAtari911 .card { background: white; padding: 40px; border-radius: 12px; 1396815440faSAtari911 box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; } 1397815440faSAtari911 h1 { color: ' . $color . '; margin: 0 0 16px 0; } 1398815440faSAtari911 p { color: #666; margin: 0 0 24px 0; } 1399815440faSAtari911 button { background: #3498db; color: white; border: none; padding: 12px 24px; 1400815440faSAtari911 border-radius: 6px; cursor: pointer; font-size: 14px; } 1401815440faSAtari911 button:hover { background: #2980b9; } 1402815440faSAtari911 </style> 1403815440faSAtari911</head> 1404815440faSAtari911<body> 1405815440faSAtari911 <div class="card"> 1406815440faSAtari911 <h1>' . ($success ? '✓' : '✕') . ' ' . $status . '</h1> 1407815440faSAtari911 <p>' . htmlspecialchars($message) . '</p> 1408815440faSAtari911 <button onclick="window.close()">Close Window</button> 1409815440faSAtari911 </div> 1410815440faSAtari911 <script> 1411815440faSAtari911 // Notify parent window 1412815440faSAtari911 if (window.opener) { 1413815440faSAtari911 window.opener.postMessage({ type: "google_auth_complete", success: ' . ($success ? 'true' : 'false') . ' }, "*"); 1414815440faSAtari911 } 1415815440faSAtari911 </script> 1416815440faSAtari911</body> 1417815440faSAtari911</html>'; 1418815440faSAtari911 } 1419815440faSAtari911 1420815440faSAtari911 /** 1421815440faSAtari911 * Get Google sync status 1422815440faSAtari911 */ 1423815440faSAtari911 private function getGoogleStatus() { 1424815440faSAtari911 $sync = $this->getGoogleSync(); 1425815440faSAtari911 echo json_encode(['success' => true, 'status' => $sync->getStatus()]); 1426815440faSAtari911 } 1427815440faSAtari911 1428815440faSAtari911 /** 1429815440faSAtari911 * Get list of Google calendars 1430815440faSAtari911 */ 1431815440faSAtari911 private function getGoogleCalendars() { 1432815440faSAtari911 if (!auth_isadmin()) { 1433815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1434815440faSAtari911 return; 1435815440faSAtari911 } 1436815440faSAtari911 1437815440faSAtari911 $sync = $this->getGoogleSync(); 1438815440faSAtari911 $result = $sync->getCalendars(); 1439815440faSAtari911 echo json_encode($result); 1440815440faSAtari911 } 1441815440faSAtari911 1442815440faSAtari911 /** 1443815440faSAtari911 * Import events from Google Calendar 1444815440faSAtari911 */ 1445815440faSAtari911 private function googleImport() { 1446815440faSAtari911 global $INPUT; 1447815440faSAtari911 1448815440faSAtari911 if (!auth_isadmin()) { 1449815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1450815440faSAtari911 return; 1451815440faSAtari911 } 1452815440faSAtari911 1453815440faSAtari911 $namespace = $INPUT->str('namespace', ''); 1454815440faSAtari911 $startDate = $INPUT->str('startDate', ''); 1455815440faSAtari911 $endDate = $INPUT->str('endDate', ''); 1456815440faSAtari911 1457815440faSAtari911 $sync = $this->getGoogleSync(); 1458815440faSAtari911 $result = $sync->importEvents($namespace, $startDate ?: null, $endDate ?: null); 1459815440faSAtari911 1460815440faSAtari911 echo json_encode($result); 1461815440faSAtari911 } 1462815440faSAtari911 1463815440faSAtari911 /** 1464815440faSAtari911 * Export events to Google Calendar 1465815440faSAtari911 */ 1466815440faSAtari911 private function googleExport() { 1467815440faSAtari911 global $INPUT; 1468815440faSAtari911 1469815440faSAtari911 if (!auth_isadmin()) { 1470815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1471815440faSAtari911 return; 1472815440faSAtari911 } 1473815440faSAtari911 1474815440faSAtari911 $namespace = $INPUT->str('namespace', ''); 1475815440faSAtari911 $startDate = $INPUT->str('startDate', ''); 1476815440faSAtari911 $endDate = $INPUT->str('endDate', ''); 1477815440faSAtari911 1478815440faSAtari911 $sync = $this->getGoogleSync(); 1479815440faSAtari911 $result = $sync->exportEvents($namespace, $startDate ?: null, $endDate ?: null); 1480815440faSAtari911 1481815440faSAtari911 echo json_encode($result); 1482815440faSAtari911 } 1483815440faSAtari911 1484815440faSAtari911 /** 1485815440faSAtari911 * Disconnect from Google Calendar 1486815440faSAtari911 */ 1487815440faSAtari911 private function googleDisconnect() { 1488815440faSAtari911 if (!auth_isadmin()) { 1489815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1490815440faSAtari911 return; 1491815440faSAtari911 } 1492815440faSAtari911 1493815440faSAtari911 $sync = $this->getGoogleSync(); 1494815440faSAtari911 $sync->disconnect(); 1495815440faSAtari911 1496815440faSAtari911 echo json_encode(['success' => true]); 1497815440faSAtari911 } 1498815440faSAtari911 149996df7d3eSAtari911 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime, 150096df7d3eSAtari911 $description, $color, $isTask, $recurrenceType, $recurrenceInterval, 150196df7d3eSAtari911 $recurrenceEnd, $weekDays, $monthlyType, $monthDay, 150296df7d3eSAtari911 $ordinalWeek, $ordinalDay, $baseId) { 1503*2866e827SAtari911 $dataDir = $this->metaDir(); 150487ac9bf3SAtari911 if ($namespace) { 150587ac9bf3SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 150687ac9bf3SAtari911 } 150787ac9bf3SAtari911 $dataDir .= 'calendar/'; 150887ac9bf3SAtari911 150987ac9bf3SAtari911 if (!is_dir($dataDir)) { 151087ac9bf3SAtari911 mkdir($dataDir, 0755, true); 151187ac9bf3SAtari911 } 151287ac9bf3SAtari911 151396df7d3eSAtari911 // Ensure interval is at least 1 151496df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 151587ac9bf3SAtari911 151687ac9bf3SAtari911 // Set maximum end date if not specified (1 year from start) 151787ac9bf3SAtari911 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 151887ac9bf3SAtari911 151987ac9bf3SAtari911 // Calculate event duration for multi-day events 152087ac9bf3SAtari911 $eventDuration = 0; 152187ac9bf3SAtari911 if ($endDate && $endDate !== $startDate) { 152287ac9bf3SAtari911 $start = new DateTime($startDate); 152387ac9bf3SAtari911 $end = new DateTime($endDate); 152487ac9bf3SAtari911 $eventDuration = $start->diff($end)->days; 152587ac9bf3SAtari911 } 152687ac9bf3SAtari911 152787ac9bf3SAtari911 // Generate recurring events 152887ac9bf3SAtari911 $currentDate = new DateTime($startDate); 152987ac9bf3SAtari911 $endLimit = new DateTime($maxEnd); 153087ac9bf3SAtari911 $counter = 0; 153196df7d3eSAtari911 $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year) 153296df7d3eSAtari911 153396df7d3eSAtari911 // For weekly with specific days, we need to track the interval counter differently 153496df7d3eSAtari911 $weekCounter = 0; 153596df7d3eSAtari911 $startWeekNumber = (int)$currentDate->format('W'); 153696df7d3eSAtari911 $startYear = (int)$currentDate->format('Y'); 153787ac9bf3SAtari911 153887ac9bf3SAtari911 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 153996df7d3eSAtari911 $shouldCreateEvent = false; 154096df7d3eSAtari911 154196df7d3eSAtari911 switch ($recurrenceType) { 154296df7d3eSAtari911 case 'daily': 154396df7d3eSAtari911 // Every N days from start 154496df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 154596df7d3eSAtari911 $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0); 154696df7d3eSAtari911 break; 154796df7d3eSAtari911 154896df7d3eSAtari911 case 'weekly': 154996df7d3eSAtari911 // Every N weeks, on specified days 155096df7d3eSAtari911 $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat 155196df7d3eSAtari911 155296df7d3eSAtari911 // Calculate weeks since start 155396df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 155496df7d3eSAtari911 $weeksSinceStart = floor($daysSinceStart / 7); 155596df7d3eSAtari911 155696df7d3eSAtari911 // Check if we're in the right week (every N weeks) 155796df7d3eSAtari911 $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0); 155896df7d3eSAtari911 155996df7d3eSAtari911 // Check if this day is selected 156096df7d3eSAtari911 $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays); 156196df7d3eSAtari911 156296df7d3eSAtari911 // For the first week, only include days on or after the start date 156396df7d3eSAtari911 $isOnOrAfterStart = ($currentDate >= new DateTime($startDate)); 156496df7d3eSAtari911 156596df7d3eSAtari911 $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart; 156696df7d3eSAtari911 break; 156796df7d3eSAtari911 156896df7d3eSAtari911 case 'monthly': 156996df7d3eSAtari911 // Calculate months since start 157096df7d3eSAtari911 $startDT = new DateTime($startDate); 157196df7d3eSAtari911 $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) + 157296df7d3eSAtari911 ($currentDate->format('n') - $startDT->format('n')); 157396df7d3eSAtari911 157496df7d3eSAtari911 // Check if we're in the right month (every N months) 157596df7d3eSAtari911 $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0); 157696df7d3eSAtari911 157796df7d3eSAtari911 if (!$isCorrectMonth) { 157896df7d3eSAtari911 // Skip to first day of next potential month 157996df7d3eSAtari911 $currentDate->modify('first day of next month'); 158096df7d3eSAtari911 continue 2; 158196df7d3eSAtari911 } 158296df7d3eSAtari911 158396df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 158496df7d3eSAtari911 // Specific day of month (e.g., 15th) 158596df7d3eSAtari911 $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j'); 158696df7d3eSAtari911 $currentDay = (int)$currentDate->format('j'); 158796df7d3eSAtari911 $daysInMonth = (int)$currentDate->format('t'); 158896df7d3eSAtari911 158996df7d3eSAtari911 // If target day exceeds days in month, use last day 159096df7d3eSAtari911 $effectiveTargetDay = min($targetDay, $daysInMonth); 159196df7d3eSAtari911 $shouldCreateEvent = ($currentDay === $effectiveTargetDay); 159296df7d3eSAtari911 } else { 159396df7d3eSAtari911 // Ordinal weekday (e.g., 2nd Wednesday, last Friday) 159496df7d3eSAtari911 $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay); 159596df7d3eSAtari911 } 159696df7d3eSAtari911 break; 159796df7d3eSAtari911 159896df7d3eSAtari911 case 'yearly': 159996df7d3eSAtari911 // Every N years on same month/day 160096df7d3eSAtari911 $startDT = new DateTime($startDate); 160196df7d3eSAtari911 $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y'); 160296df7d3eSAtari911 160396df7d3eSAtari911 // Check if we're in the right year 160496df7d3eSAtari911 $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0); 160596df7d3eSAtari911 160696df7d3eSAtari911 // Check if it's the same month and day 160796df7d3eSAtari911 $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d')); 160896df7d3eSAtari911 160996df7d3eSAtari911 $shouldCreateEvent = $isCorrectYear && $sameMonthDay; 161096df7d3eSAtari911 break; 161196df7d3eSAtari911 161296df7d3eSAtari911 default: 161396df7d3eSAtari911 $shouldCreateEvent = false; 161496df7d3eSAtari911 } 161596df7d3eSAtari911 161696df7d3eSAtari911 if ($shouldCreateEvent) { 161787ac9bf3SAtari911 $dateKey = $currentDate->format('Y-m-d'); 161887ac9bf3SAtari911 list($year, $month, $day) = explode('-', $dateKey); 161987ac9bf3SAtari911 162087ac9bf3SAtari911 // Calculate end date for this occurrence if multi-day 162187ac9bf3SAtari911 $occurrenceEndDate = ''; 162287ac9bf3SAtari911 if ($eventDuration > 0) { 162387ac9bf3SAtari911 $occurrenceEnd = clone $currentDate; 162487ac9bf3SAtari911 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 162587ac9bf3SAtari911 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 162687ac9bf3SAtari911 } 162787ac9bf3SAtari911 162887ac9bf3SAtari911 // Load month file 162987ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 163087ac9bf3SAtari911 $events = []; 163187ac9bf3SAtari911 if (file_exists($eventFile)) { 163287ac9bf3SAtari911 $events = json_decode(file_get_contents($eventFile), true); 163396df7d3eSAtari911 if (!is_array($events)) $events = []; 163487ac9bf3SAtari911 } 163587ac9bf3SAtari911 163687ac9bf3SAtari911 if (!isset($events[$dateKey])) { 163787ac9bf3SAtari911 $events[$dateKey] = []; 163887ac9bf3SAtari911 } 163987ac9bf3SAtari911 164087ac9bf3SAtari911 // Create event for this occurrence 164187ac9bf3SAtari911 $eventData = [ 164287ac9bf3SAtari911 'id' => $baseId . '-' . $counter, 164387ac9bf3SAtari911 'title' => $title, 164487ac9bf3SAtari911 'time' => $time, 16451d05cddcSAtari911 'endTime' => $endTime, 164687ac9bf3SAtari911 'description' => $description, 164787ac9bf3SAtari911 'color' => $color, 164887ac9bf3SAtari911 'isTask' => $isTask, 164987ac9bf3SAtari911 'completed' => false, 165087ac9bf3SAtari911 'endDate' => $occurrenceEndDate, 165187ac9bf3SAtari911 'recurring' => true, 165287ac9bf3SAtari911 'recurringId' => $baseId, 165396df7d3eSAtari911 'recurrenceType' => $recurrenceType, 165496df7d3eSAtari911 'recurrenceInterval' => $recurrenceInterval, 165596df7d3eSAtari911 'namespace' => $namespace, 165687ac9bf3SAtari911 'created' => date('Y-m-d H:i:s') 165787ac9bf3SAtari911 ]; 165887ac9bf3SAtari911 165996df7d3eSAtari911 // Store additional recurrence info for reference 166096df7d3eSAtari911 if ($recurrenceType === 'weekly' && !empty($weekDays)) { 166196df7d3eSAtari911 $eventData['weekDays'] = $weekDays; 166296df7d3eSAtari911 } 166396df7d3eSAtari911 if ($recurrenceType === 'monthly') { 166496df7d3eSAtari911 $eventData['monthlyType'] = $monthlyType; 166596df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 166696df7d3eSAtari911 $eventData['monthDay'] = $monthDay; 166796df7d3eSAtari911 } else { 166896df7d3eSAtari911 $eventData['ordinalWeek'] = $ordinalWeek; 166996df7d3eSAtari911 $eventData['ordinalDay'] = $ordinalDay; 167096df7d3eSAtari911 } 167196df7d3eSAtari911 } 167296df7d3eSAtari911 167387ac9bf3SAtari911 $events[$dateKey][] = $eventData; 1674815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 167587ac9bf3SAtari911 167687ac9bf3SAtari911 $counter++; 167787ac9bf3SAtari911 } 167896df7d3eSAtari911 167996df7d3eSAtari911 // Move to next day (we check each day individually for complex patterns) 168096df7d3eSAtari911 $currentDate->modify('+1 day'); 168196df7d3eSAtari911 } 168296df7d3eSAtari911 } 168396df7d3eSAtari911 168496df7d3eSAtari911 /** 168596df7d3eSAtari911 * Check if a date is the Nth occurrence of a weekday in its month 168696df7d3eSAtari911 * @param DateTime $date The date to check 168796df7d3eSAtari911 * @param int $ordinalWeek 1-5 for first-fifth, -1 for last 168896df7d3eSAtari911 * @param int $targetDayOfWeek 0=Sunday through 6=Saturday 168996df7d3eSAtari911 * @return bool 169096df7d3eSAtari911 */ 169196df7d3eSAtari911 private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) { 169296df7d3eSAtari911 $currentDayOfWeek = (int)$date->format('w'); 169396df7d3eSAtari911 169496df7d3eSAtari911 // First, check if it's the right day of week 169596df7d3eSAtari911 if ($currentDayOfWeek !== $targetDayOfWeek) { 169696df7d3eSAtari911 return false; 169796df7d3eSAtari911 } 169896df7d3eSAtari911 169996df7d3eSAtari911 $dayOfMonth = (int)$date->format('j'); 170096df7d3eSAtari911 $daysInMonth = (int)$date->format('t'); 170196df7d3eSAtari911 170296df7d3eSAtari911 if ($ordinalWeek === -1) { 170396df7d3eSAtari911 // Last occurrence: check if there's no more of this weekday in the month 170496df7d3eSAtari911 $daysRemaining = $daysInMonth - $dayOfMonth; 170596df7d3eSAtari911 return $daysRemaining < 7; 170696df7d3eSAtari911 } else { 170796df7d3eSAtari911 // Nth occurrence: check which occurrence this is 170896df7d3eSAtari911 $weekNumber = ceil($dayOfMonth / 7); 170996df7d3eSAtari911 return $weekNumber === $ordinalWeek; 171096df7d3eSAtari911 } 171187ac9bf3SAtari911 } 171287ac9bf3SAtari911 171319378907SAtari911 public function addAssets(Doku_Event $event, $param) { 171419378907SAtari911 $event->data['link'][] = array( 171519378907SAtari911 'type' => 'text/css', 171619378907SAtari911 'rel' => 'stylesheet', 171719378907SAtari911 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 171819378907SAtari911 ); 171919378907SAtari911 172096df7d3eSAtari911 // Load the main calendar JavaScript 172196df7d3eSAtari911 // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues 172296df7d3eSAtari911 // The actual code is in calendar-main.js 172319378907SAtari911 $event->data['script'][] = array( 172419378907SAtari911 'type' => 'text/javascript', 172596df7d3eSAtari911 'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js' 172619378907SAtari911 ); 172719378907SAtari911 } 1728e3a9f44cSAtari911 // Helper function to find an event's stored namespace 1729e3a9f44cSAtari911 private function findEventNamespace($eventId, $date, $searchNamespace) { 1730e3a9f44cSAtari911 list($year, $month, $day) = explode('-', $date); 1731e3a9f44cSAtari911 1732e3a9f44cSAtari911 // List of namespaces to check 1733e3a9f44cSAtari911 $namespacesToCheck = ['']; 1734e3a9f44cSAtari911 1735e3a9f44cSAtari911 // If searchNamespace is a wildcard or multi, we need to search multiple locations 1736e3a9f44cSAtari911 if (!empty($searchNamespace)) { 1737e3a9f44cSAtari911 if (strpos($searchNamespace, ';') !== false) { 1738e3a9f44cSAtari911 // Multi-namespace - check each one 1739e3a9f44cSAtari911 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 1740e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1741e3a9f44cSAtari911 } elseif (strpos($searchNamespace, '*') !== false) { 1742e3a9f44cSAtari911 // Wildcard - need to scan directories 1743e3a9f44cSAtari911 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 1744e3a9f44cSAtari911 $namespacesToCheck = $this->findAllNamespaces($baseNs); 1745e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1746e3a9f44cSAtari911 } else { 1747e3a9f44cSAtari911 // Single namespace 1748e3a9f44cSAtari911 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 1749e3a9f44cSAtari911 } 1750e3a9f44cSAtari911 } 1751e3a9f44cSAtari911 175296df7d3eSAtari911 $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck))); 175396df7d3eSAtari911 1754e3a9f44cSAtari911 // Search for the event in all possible namespaces 1755e3a9f44cSAtari911 foreach ($namespacesToCheck as $ns) { 1756*2866e827SAtari911 $dataDir = $this->metaDir(); 1757e3a9f44cSAtari911 if ($ns) { 1758e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $ns) . '/'; 1759e3a9f44cSAtari911 } 1760e3a9f44cSAtari911 $dataDir .= 'calendar/'; 1761e3a9f44cSAtari911 1762e3a9f44cSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1763e3a9f44cSAtari911 1764e3a9f44cSAtari911 if (file_exists($eventFile)) { 1765e3a9f44cSAtari911 $events = json_decode(file_get_contents($eventFile), true); 1766e3a9f44cSAtari911 if (isset($events[$date])) { 1767e3a9f44cSAtari911 foreach ($events[$date] as $evt) { 1768e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 176996df7d3eSAtari911 // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace 177096df7d3eSAtari911 // The directory is what matters for deletion - that's where the file actually is 177196df7d3eSAtari911 $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')"); 177296df7d3eSAtari911 return $ns; 1773e3a9f44cSAtari911 } 1774e3a9f44cSAtari911 } 1775e3a9f44cSAtari911 } 1776e3a9f44cSAtari911 } 1777e3a9f44cSAtari911 } 1778e3a9f44cSAtari911 177996df7d3eSAtari911 $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace"); 1780e3a9f44cSAtari911 return null; // Event not found 1781e3a9f44cSAtari911 } 1782e3a9f44cSAtari911 1783e3a9f44cSAtari911 // Helper to find all namespaces under a base namespace 1784e3a9f44cSAtari911 private function findAllNamespaces($baseNamespace) { 1785*2866e827SAtari911 $dataDir = $this->metaDir(); 1786e3a9f44cSAtari911 if ($baseNamespace) { 1787e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 1788e3a9f44cSAtari911 } 1789e3a9f44cSAtari911 1790e3a9f44cSAtari911 $namespaces = []; 1791e3a9f44cSAtari911 if ($baseNamespace) { 1792e3a9f44cSAtari911 $namespaces[] = $baseNamespace; 1793e3a9f44cSAtari911 } 1794e3a9f44cSAtari911 1795e3a9f44cSAtari911 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 1796e3a9f44cSAtari911 179796df7d3eSAtari911 $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces))); 179896df7d3eSAtari911 1799e3a9f44cSAtari911 return $namespaces; 1800e3a9f44cSAtari911 } 1801e3a9f44cSAtari911 1802e3a9f44cSAtari911 // Recursive scan for namespaces 1803e3a9f44cSAtari911 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 1804e3a9f44cSAtari911 if (!is_dir($dir)) return; 1805e3a9f44cSAtari911 1806e3a9f44cSAtari911 $items = scandir($dir); 1807e3a9f44cSAtari911 foreach ($items as $item) { 1808e3a9f44cSAtari911 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 1809e3a9f44cSAtari911 1810e3a9f44cSAtari911 $path = $dir . $item; 1811e3a9f44cSAtari911 if (is_dir($path)) { 1812e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1813e3a9f44cSAtari911 $namespaces[] = $namespace; 1814e3a9f44cSAtari911 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 1815e3a9f44cSAtari911 } 1816e3a9f44cSAtari911 } 1817e3a9f44cSAtari911 } 18189ccd446eSAtari911 18199ccd446eSAtari911 /** 18209ccd446eSAtari911 * Delete all instances of a recurring event across all months 18219ccd446eSAtari911 */ 18229ccd446eSAtari911 private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) { 18239ccd446eSAtari911 // Scan all JSON files in the calendar directory 18249ccd446eSAtari911 $calendarFiles = glob($dataDir . '*.json'); 18259ccd446eSAtari911 18269ccd446eSAtari911 foreach ($calendarFiles as $file) { 18279ccd446eSAtari911 $modified = false; 18289ccd446eSAtari911 $events = json_decode(file_get_contents($file), true); 18299ccd446eSAtari911 18309ccd446eSAtari911 if (!$events) continue; 18319ccd446eSAtari911 18329ccd446eSAtari911 // Check each date in the file 18339ccd446eSAtari911 foreach ($events as $date => &$dayEvents) { 18349ccd446eSAtari911 // Filter out events with matching recurringId 18359ccd446eSAtari911 $originalCount = count($dayEvents); 18369ccd446eSAtari911 $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) { 18379ccd446eSAtari911 $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 18389ccd446eSAtari911 return $eventRecurringId !== $recurringId; 18399ccd446eSAtari911 })); 18409ccd446eSAtari911 18419ccd446eSAtari911 if (count($dayEvents) !== $originalCount) { 18429ccd446eSAtari911 $modified = true; 18439ccd446eSAtari911 } 18449ccd446eSAtari911 18459ccd446eSAtari911 // Remove empty dates 18469ccd446eSAtari911 if (empty($dayEvents)) { 18479ccd446eSAtari911 unset($events[$date]); 18489ccd446eSAtari911 } 18499ccd446eSAtari911 } 18509ccd446eSAtari911 18519ccd446eSAtari911 // Save if modified 18529ccd446eSAtari911 if ($modified) { 1853815440faSAtari911 CalendarFileHandler::writeJson($file, $events); 18549ccd446eSAtari911 } 18559ccd446eSAtari911 } 18569ccd446eSAtari911 } 18579ccd446eSAtari911 18589ccd446eSAtari911 /** 18599ccd446eSAtari911 * Get existing event data for preserving unchanged fields during edit 18609ccd446eSAtari911 */ 18619ccd446eSAtari911 private function getExistingEventData($eventId, $date, $namespace) { 18629ccd446eSAtari911 list($year, $month, $day) = explode('-', $date); 18639ccd446eSAtari911 1864*2866e827SAtari911 $dataDir = $this->metaDir(); 18659ccd446eSAtari911 if ($namespace) { 18669ccd446eSAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 18679ccd446eSAtari911 } 18689ccd446eSAtari911 $dataDir .= 'calendar/'; 18699ccd446eSAtari911 18709ccd446eSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 18719ccd446eSAtari911 18729ccd446eSAtari911 if (!file_exists($eventFile)) { 18739ccd446eSAtari911 return null; 18749ccd446eSAtari911 } 18759ccd446eSAtari911 18769ccd446eSAtari911 $events = json_decode(file_get_contents($eventFile), true); 18779ccd446eSAtari911 18789ccd446eSAtari911 if (!isset($events[$date])) { 18799ccd446eSAtari911 return null; 18809ccd446eSAtari911 } 18819ccd446eSAtari911 18829ccd446eSAtari911 // Find the event by ID 18839ccd446eSAtari911 foreach ($events[$date] as $event) { 18849ccd446eSAtari911 if ($event['id'] === $eventId) { 18859ccd446eSAtari911 return $event; 18869ccd446eSAtari911 } 18879ccd446eSAtari911 } 18889ccd446eSAtari911 18899ccd446eSAtari911 return null; 18909ccd446eSAtari911 } 189119378907SAtari911} 1892