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 719378907SAtari911 */ 819378907SAtari911 919378907SAtari911if (!defined('DOKU_INC')) die(); 1019378907SAtari911 117e8ea635SAtari911// Set to true to enable verbose debug logging (should be false in production) 127e8ea635SAtari911if (!defined('CALENDAR_DEBUG')) { 137e8ea635SAtari911 define('CALENDAR_DEBUG', false); 147e8ea635SAtari911} 157e8ea635SAtari911 1619378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin { 1719378907SAtari911 187e8ea635SAtari911 /** 197e8ea635SAtari911 * Log debug message only if CALENDAR_DEBUG is enabled 207e8ea635SAtari911 */ 217e8ea635SAtari911 private function debugLog($message) { 227e8ea635SAtari911 if (CALENDAR_DEBUG) { 237e8ea635SAtari911 error_log($message); 247e8ea635SAtari911 } 257e8ea635SAtari911 } 267e8ea635SAtari911 277e8ea635SAtari911 /** 287e8ea635SAtari911 * Safely read and decode a JSON file with error handling 297e8ea635SAtari911 * @param string $filepath Path to JSON file 307e8ea635SAtari911 * @return array Decoded array or empty array on error 317e8ea635SAtari911 */ 327e8ea635SAtari911 private function safeJsonRead($filepath) { 337e8ea635SAtari911 if (!file_exists($filepath)) { 347e8ea635SAtari911 return []; 357e8ea635SAtari911 } 367e8ea635SAtari911 377e8ea635SAtari911 $contents = @file_get_contents($filepath); 387e8ea635SAtari911 if ($contents === false) { 397e8ea635SAtari911 $this->debugLog("Failed to read file: $filepath"); 407e8ea635SAtari911 return []; 417e8ea635SAtari911 } 427e8ea635SAtari911 437e8ea635SAtari911 $decoded = json_decode($contents, true); 447e8ea635SAtari911 if (json_last_error() !== JSON_ERROR_NONE) { 457e8ea635SAtari911 $this->debugLog("JSON decode error in $filepath: " . json_last_error_msg()); 467e8ea635SAtari911 return []; 477e8ea635SAtari911 } 487e8ea635SAtari911 497e8ea635SAtari911 return is_array($decoded) ? $decoded : []; 507e8ea635SAtari911 } 517e8ea635SAtari911 5219378907SAtari911 public function register(Doku_Event_Handler $controller) { 5319378907SAtari911 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax'); 5419378907SAtari911 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets'); 5519378907SAtari911 } 5619378907SAtari911 5719378907SAtari911 public function handleAjax(Doku_Event $event, $param) { 5819378907SAtari911 if ($event->data !== 'plugin_calendar') return; 5919378907SAtari911 $event->preventDefault(); 6019378907SAtari911 $event->stopPropagation(); 6119378907SAtari911 6219378907SAtari911 $action = $_REQUEST['action'] ?? ''; 6319378907SAtari911 647e8ea635SAtari911 // Actions that modify data require CSRF token verification 657e8ea635SAtari911 $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces', 667e8ea635SAtari911 'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring', 677e8ea635SAtari911 'trim_recurring', 'pause_recurring', 'resume_recurring', 687e8ea635SAtari911 'change_start_recurring', 'change_pattern_recurring']; 697e8ea635SAtari911 707e8ea635SAtari911 if (in_array($action, $writeActions)) { 717e8ea635SAtari911 // Check for valid security token 727e8ea635SAtari911 $sectok = $_REQUEST['sectok'] ?? ''; 737e8ea635SAtari911 if (!checkSecurityToken($sectok)) { 747e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']); 757e8ea635SAtari911 return; 767e8ea635SAtari911 } 777e8ea635SAtari911 } 787e8ea635SAtari911 7919378907SAtari911 switch ($action) { 8019378907SAtari911 case 'save_event': 8119378907SAtari911 $this->saveEvent(); 8219378907SAtari911 break; 8319378907SAtari911 case 'delete_event': 8419378907SAtari911 $this->deleteEvent(); 8519378907SAtari911 break; 8619378907SAtari911 case 'get_event': 8719378907SAtari911 $this->getEvent(); 8819378907SAtari911 break; 8919378907SAtari911 case 'load_month': 9019378907SAtari911 $this->loadMonth(); 9119378907SAtari911 break; 92*96df7d3eSAtari911 case 'search_all': 93*96df7d3eSAtari911 $this->searchAllDates(); 94*96df7d3eSAtari911 break; 9519378907SAtari911 case 'toggle_task': 9619378907SAtari911 $this->toggleTaskComplete(); 9719378907SAtari911 break; 987e8ea635SAtari911 case 'cleanup_empty_namespaces': 997e8ea635SAtari911 case 'trim_all_past_recurring': 1007e8ea635SAtari911 case 'rescan_recurring': 1017e8ea635SAtari911 case 'extend_recurring': 1027e8ea635SAtari911 case 'trim_recurring': 1037e8ea635SAtari911 case 'pause_recurring': 1047e8ea635SAtari911 case 'resume_recurring': 1057e8ea635SAtari911 case 'change_start_recurring': 1067e8ea635SAtari911 case 'change_pattern_recurring': 1077e8ea635SAtari911 $this->routeToAdmin($action); 1087e8ea635SAtari911 break; 10919378907SAtari911 default: 11019378907SAtari911 echo json_encode(['success' => false, 'error' => 'Unknown action']); 11119378907SAtari911 } 11219378907SAtari911 } 11319378907SAtari911 1147e8ea635SAtari911 /** 1157e8ea635SAtari911 * Route AJAX actions to admin plugin methods 1167e8ea635SAtari911 */ 1177e8ea635SAtari911 private function routeToAdmin($action) { 1187e8ea635SAtari911 $admin = plugin_load('admin', 'calendar'); 1197e8ea635SAtari911 if ($admin && method_exists($admin, 'handleAjaxAction')) { 1207e8ea635SAtari911 $admin->handleAjaxAction($action); 1217e8ea635SAtari911 } else { 1227e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Admin handler not available']); 1237e8ea635SAtari911 } 1247e8ea635SAtari911 } 1257e8ea635SAtari911 12619378907SAtari911 private function saveEvent() { 12719378907SAtari911 global $INPUT; 12819378907SAtari911 12919378907SAtari911 $namespace = $INPUT->str('namespace', ''); 13019378907SAtari911 $date = $INPUT->str('date'); 13119378907SAtari911 $eventId = $INPUT->str('eventId', ''); 13219378907SAtari911 $title = $INPUT->str('title'); 13319378907SAtari911 $time = $INPUT->str('time', ''); 1341d05cddcSAtari911 $endTime = $INPUT->str('endTime', ''); 13519378907SAtari911 $description = $INPUT->str('description', ''); 13619378907SAtari911 $color = $INPUT->str('color', '#3498db'); 13719378907SAtari911 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 13819378907SAtari911 $isTask = $INPUT->bool('isTask', false); 13919378907SAtari911 $completed = $INPUT->bool('completed', false); 14019378907SAtari911 $endDate = $INPUT->str('endDate', ''); 14187ac9bf3SAtari911 $isRecurring = $INPUT->bool('isRecurring', false); 14287ac9bf3SAtari911 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 14387ac9bf3SAtari911 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 14419378907SAtari911 145*96df7d3eSAtari911 // New recurrence options 146*96df7d3eSAtari911 $recurrenceInterval = $INPUT->int('recurrenceInterval', 1); 147*96df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 148*96df7d3eSAtari911 if ($recurrenceInterval > 99) $recurrenceInterval = 99; 149*96df7d3eSAtari911 150*96df7d3eSAtari911 $weekDaysStr = $INPUT->str('weekDays', ''); 151*96df7d3eSAtari911 $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : []; 152*96df7d3eSAtari911 153*96df7d3eSAtari911 $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth'); 154*96df7d3eSAtari911 $monthDay = $INPUT->int('monthDay', 0); 155*96df7d3eSAtari911 $ordinalWeek = $INPUT->int('ordinalWeek', 1); 156*96df7d3eSAtari911 $ordinalDay = $INPUT->int('ordinalDay', 0); 157*96df7d3eSAtari911 158*96df7d3eSAtari911 $this->debugLog("=== Calendar saveEvent START ==="); 159*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'"); 160*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'"); 161*96df7d3eSAtari911 16219378907SAtari911 if (!$date || !$title) { 16319378907SAtari911 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 16419378907SAtari911 return; 16519378907SAtari911 } 16619378907SAtari911 1677e8ea635SAtari911 // Validate date format (YYYY-MM-DD) 1687e8ea635SAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { 1697e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid date format']); 1707e8ea635SAtari911 return; 1717e8ea635SAtari911 } 1727e8ea635SAtari911 1737e8ea635SAtari911 // Validate oldDate if provided 1747e8ea635SAtari911 if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) { 1757e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid old date format']); 1767e8ea635SAtari911 return; 1777e8ea635SAtari911 } 1787e8ea635SAtari911 1797e8ea635SAtari911 // Validate endDate if provided 1807e8ea635SAtari911 if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) { 1817e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end date format']); 1827e8ea635SAtari911 return; 1837e8ea635SAtari911 } 1847e8ea635SAtari911 1857e8ea635SAtari911 // Validate time format (HH:MM) if provided 1867e8ea635SAtari911 if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) { 1877e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid time format']); 1887e8ea635SAtari911 return; 1897e8ea635SAtari911 } 1907e8ea635SAtari911 1917e8ea635SAtari911 // Validate endTime format if provided 1927e8ea635SAtari911 if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) { 1937e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end time format']); 1947e8ea635SAtari911 return; 1957e8ea635SAtari911 } 1967e8ea635SAtari911 1977e8ea635SAtari911 // Validate color format (hex color) 1987e8ea635SAtari911 if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) { 1997e8ea635SAtari911 $color = '#3498db'; // Reset to default if invalid 2007e8ea635SAtari911 } 2017e8ea635SAtari911 2027e8ea635SAtari911 // Validate namespace (prevent path traversal) 2037e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 2047e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 2057e8ea635SAtari911 return; 2067e8ea635SAtari911 } 2077e8ea635SAtari911 2087e8ea635SAtari911 // Validate recurrence type 2097e8ea635SAtari911 $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly']; 2107e8ea635SAtari911 if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) { 2117e8ea635SAtari911 $recurrenceType = 'weekly'; 2127e8ea635SAtari911 } 2137e8ea635SAtari911 2147e8ea635SAtari911 // Validate recurrenceEnd if provided 2157e8ea635SAtari911 if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) { 2167e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']); 2177e8ea635SAtari911 return; 2187e8ea635SAtari911 } 2197e8ea635SAtari911 2207e8ea635SAtari911 // Sanitize title length 2217e8ea635SAtari911 $title = substr(trim($title), 0, 500); 2227e8ea635SAtari911 2237e8ea635SAtari911 // Sanitize description length 2247e8ea635SAtari911 $description = substr($description, 0, 10000); 2257e8ea635SAtari911 226*96df7d3eSAtari911 // If editing, find the event's ACTUAL namespace (for finding/deleting old event) 227*96df7d3eSAtari911 // We need to search ALL namespaces because user may be changing namespace 228*96df7d3eSAtari911 $oldNamespace = null; // null means "not found yet" 229e3a9f44cSAtari911 if ($eventId) { 2301d05cddcSAtari911 // Use oldDate if available (date was changed), otherwise use current date 2311d05cddcSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 2321d05cddcSAtari911 233*96df7d3eSAtari911 // Search using wildcard to find event in ANY namespace 234*96df7d3eSAtari911 $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*'); 235*96df7d3eSAtari911 236*96df7d3eSAtari911 if ($foundNamespace !== null) { 237*96df7d3eSAtari911 $oldNamespace = $foundNamespace; // Could be '' for default namespace 2387e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'"); 239*96df7d3eSAtari911 } else { 240*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace"); 2411d05cddcSAtari911 } 242e3a9f44cSAtari911 } 243e3a9f44cSAtari911 2441d05cddcSAtari911 // Use the namespace provided by the user (allow namespace changes!) 2451d05cddcSAtari911 // But normalize wildcards and multi-namespace to empty for NEW events 2461d05cddcSAtari911 if (!$eventId) { 2477e8ea635SAtari911 $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'"); 248e3a9f44cSAtari911 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 249e3a9f44cSAtari911 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 2507e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty"); 251e3a9f44cSAtari911 $namespace = ''; 2521d05cddcSAtari911 } else { 2537e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'"); 254e3a9f44cSAtari911 } 2551d05cddcSAtari911 } else { 2567e8ea635SAtari911 $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'"); 257e3a9f44cSAtari911 } 258e3a9f44cSAtari911 25987ac9bf3SAtari911 // Generate event ID if new 26087ac9bf3SAtari911 $generatedId = $eventId ?: uniqid(); 26187ac9bf3SAtari911 2629ccd446eSAtari911 // If editing a recurring event, load existing data to preserve unchanged fields 2639ccd446eSAtari911 $existingEventData = null; 2649ccd446eSAtari911 if ($eventId && $isRecurring) { 2659ccd446eSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 266*96df7d3eSAtari911 // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use '' 267*96df7d3eSAtari911 $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace); 2689ccd446eSAtari911 if ($existingEventData) { 2697e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'"); 2709ccd446eSAtari911 } 2719ccd446eSAtari911 } 2729ccd446eSAtari911 27387ac9bf3SAtari911 // If recurring, generate multiple events 27487ac9bf3SAtari911 if ($isRecurring) { 2759ccd446eSAtari911 // Merge with existing data if editing (preserve values that weren't changed) 2769ccd446eSAtari911 if ($existingEventData) { 2779ccd446eSAtari911 $title = $title ?: $existingEventData['title']; 2789ccd446eSAtari911 $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : ''); 2799ccd446eSAtari911 $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : ''); 2809ccd446eSAtari911 $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : ''); 2819ccd446eSAtari911 // Only use existing color if new color is default 2829ccd446eSAtari911 if ($color === '#3498db' && isset($existingEventData['color'])) { 2839ccd446eSAtari911 $color = $existingEventData['color']; 2849ccd446eSAtari911 } 2859ccd446eSAtari911 2869ccd446eSAtari911 // Preserve namespace in these cases: 2879ccd446eSAtari911 // 1. Namespace field is empty (user didn't select anything) 2889ccd446eSAtari911 // 2. Namespace contains wildcards (like "personal;work" or "work*") 2899ccd446eSAtari911 // 3. Namespace is the same as what was passed (no change intended) 2909ccd446eSAtari911 $receivedNamespace = $namespace; 2919ccd446eSAtari911 if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 2929ccd446eSAtari911 if (isset($existingEventData['namespace'])) { 2939ccd446eSAtari911 $namespace = $existingEventData['namespace']; 2947e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')"); 2959ccd446eSAtari911 } else { 2967e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')"); 2979ccd446eSAtari911 } 2989ccd446eSAtari911 } else { 2997e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')"); 3009ccd446eSAtari911 } 3019ccd446eSAtari911 } else { 3027e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'"); 3039ccd446eSAtari911 } 3049ccd446eSAtari911 305*96df7d3eSAtari911 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description, 306*96df7d3eSAtari911 $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd, 307*96df7d3eSAtari911 $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId); 30887ac9bf3SAtari911 echo json_encode(['success' => true]); 30987ac9bf3SAtari911 return; 31087ac9bf3SAtari911 } 31187ac9bf3SAtari911 31219378907SAtari911 list($year, $month, $day) = explode('-', $date); 31319378907SAtari911 3141d05cddcSAtari911 // NEW namespace directory (where we'll save) 31519378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 31619378907SAtari911 if ($namespace) { 31719378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 31819378907SAtari911 } 31919378907SAtari911 $dataDir .= 'calendar/'; 32019378907SAtari911 32119378907SAtari911 if (!is_dir($dataDir)) { 32219378907SAtari911 mkdir($dataDir, 0755, true); 32319378907SAtari911 } 32419378907SAtari911 32519378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 32619378907SAtari911 327*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'"); 328*96df7d3eSAtari911 32919378907SAtari911 $events = []; 33019378907SAtari911 if (file_exists($eventFile)) { 33119378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 332*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location"); 333*96df7d3eSAtari911 } else { 334*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: New location file does not exist yet"); 33519378907SAtari911 } 33619378907SAtari911 3371d05cddcSAtari911 // If editing and (date changed OR namespace changed), remove from old location first 338*96df7d3eSAtari911 // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace 339*96df7d3eSAtari911 $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace); 3401d05cddcSAtari911 $dateChanged = ($eventId && $oldDate && $oldDate !== $date); 3411d05cddcSAtari911 342*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO')); 343*96df7d3eSAtari911 3441d05cddcSAtari911 if ($namespaceChanged || $dateChanged) { 3451d05cddcSAtari911 // Construct OLD data directory using OLD namespace 3461d05cddcSAtari911 $oldDataDir = DOKU_INC . 'data/meta/'; 3471d05cddcSAtari911 if ($oldNamespace) { 3481d05cddcSAtari911 $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/'; 3491d05cddcSAtari911 } 3501d05cddcSAtari911 $oldDataDir .= 'calendar/'; 3511d05cddcSAtari911 3521d05cddcSAtari911 $deleteDate = $dateChanged ? $oldDate : $date; 3531d05cddcSAtari911 list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate); 3541d05cddcSAtari911 $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 35519378907SAtari911 356*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'"); 357*96df7d3eSAtari911 35819378907SAtari911 if (file_exists($oldEventFile)) { 35919378907SAtari911 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 360*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates"); 361*96df7d3eSAtari911 3621d05cddcSAtari911 if (isset($oldEvents[$deleteDate])) { 363*96df7d3eSAtari911 $countBefore = count($oldEvents[$deleteDate]); 3641d05cddcSAtari911 $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) { 36519378907SAtari911 return $evt['id'] !== $eventId; 366e3a9f44cSAtari911 })); 367*96df7d3eSAtari911 $countAfter = count($oldEvents[$deleteDate]); 368*96df7d3eSAtari911 369*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter"); 37019378907SAtari911 3711d05cddcSAtari911 if (empty($oldEvents[$deleteDate])) { 3721d05cddcSAtari911 unset($oldEvents[$deleteDate]); 37319378907SAtari911 } 37419378907SAtari911 37519378907SAtari911 file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT)); 376*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'"); 377*96df7d3eSAtari911 } else { 378*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file"); 37919378907SAtari911 } 380*96df7d3eSAtari911 } else { 381*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile"); 38219378907SAtari911 } 383*96df7d3eSAtari911 } else { 384*96df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location"); 38519378907SAtari911 } 38619378907SAtari911 38719378907SAtari911 if (!isset($events[$date])) { 38819378907SAtari911 $events[$date] = []; 389e3a9f44cSAtari911 } elseif (!is_array($events[$date])) { 390e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 3917e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 392e3a9f44cSAtari911 $events[$date] = []; 39319378907SAtari911 } 39419378907SAtari911 395e3a9f44cSAtari911 // Store the namespace with the event 39619378907SAtari911 $eventData = [ 39787ac9bf3SAtari911 'id' => $generatedId, 39819378907SAtari911 'title' => $title, 39919378907SAtari911 'time' => $time, 4001d05cddcSAtari911 'endTime' => $endTime, 40119378907SAtari911 'description' => $description, 40219378907SAtari911 'color' => $color, 40319378907SAtari911 'isTask' => $isTask, 40419378907SAtari911 'completed' => $completed, 40519378907SAtari911 'endDate' => $endDate, 406e3a9f44cSAtari911 'namespace' => $namespace, // Store namespace with event 40719378907SAtari911 'created' => date('Y-m-d H:i:s') 40819378907SAtari911 ]; 40919378907SAtari911 4101d05cddcSAtari911 // Debug logging 4117e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile"); 4121d05cddcSAtari911 41319378907SAtari911 // If editing, replace existing event 41419378907SAtari911 if ($eventId) { 41519378907SAtari911 $found = false; 41619378907SAtari911 foreach ($events[$date] as $key => $evt) { 41719378907SAtari911 if ($evt['id'] === $eventId) { 41819378907SAtari911 $events[$date][$key] = $eventData; 41919378907SAtari911 $found = true; 42019378907SAtari911 break; 42119378907SAtari911 } 42219378907SAtari911 } 42319378907SAtari911 if (!$found) { 42419378907SAtari911 $events[$date][] = $eventData; 42519378907SAtari911 } 42619378907SAtari911 } else { 42719378907SAtari911 $events[$date][] = $eventData; 42819378907SAtari911 } 42919378907SAtari911 43019378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 43119378907SAtari911 432e3a9f44cSAtari911 // If event spans multiple months, add it to the first day of each subsequent month 433e3a9f44cSAtari911 if ($endDate && $endDate !== $date) { 434e3a9f44cSAtari911 $startDateObj = new DateTime($date); 435e3a9f44cSAtari911 $endDateObj = new DateTime($endDate); 436e3a9f44cSAtari911 437e3a9f44cSAtari911 // Get the month/year of the start date 438e3a9f44cSAtari911 $startMonth = $startDateObj->format('Y-m'); 439e3a9f44cSAtari911 440e3a9f44cSAtari911 // Iterate through each month the event spans 441e3a9f44cSAtari911 $currentDate = clone $startDateObj; 442e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 443e3a9f44cSAtari911 444e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 445e3a9f44cSAtari911 $currentMonth = $currentDate->format('Y-m'); 446e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 447e3a9f44cSAtari911 448e3a9f44cSAtari911 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 449e3a9f44cSAtari911 450e3a9f44cSAtari911 // Get the file for this month 451e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 452e3a9f44cSAtari911 453e3a9f44cSAtari911 $currentEvents = []; 454e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 455e3a9f44cSAtari911 $contents = file_get_contents($currentEventFile); 456e3a9f44cSAtari911 $decoded = json_decode($contents, true); 457e3a9f44cSAtari911 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 458e3a9f44cSAtari911 $currentEvents = $decoded; 459e3a9f44cSAtari911 } else { 4607e8ea635SAtari911 $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 461e3a9f44cSAtari911 } 462e3a9f44cSAtari911 } 463e3a9f44cSAtari911 464e3a9f44cSAtari911 // Add entry for the first day of this month 465e3a9f44cSAtari911 if (!isset($currentEvents[$firstDayOfMonth])) { 466e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 467e3a9f44cSAtari911 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 468e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 4697e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 470e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 471e3a9f44cSAtari911 } 472e3a9f44cSAtari911 473e3a9f44cSAtari911 // Create a copy with the original start date preserved 474e3a9f44cSAtari911 $eventDataForMonth = $eventData; 475e3a9f44cSAtari911 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 476e3a9f44cSAtari911 477e3a9f44cSAtari911 // Check if event already exists (when editing) 478e3a9f44cSAtari911 $found = false; 479e3a9f44cSAtari911 if ($eventId) { 480e3a9f44cSAtari911 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 481e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 482e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 483e3a9f44cSAtari911 $found = true; 484e3a9f44cSAtari911 break; 485e3a9f44cSAtari911 } 486e3a9f44cSAtari911 } 487e3a9f44cSAtari911 } 488e3a9f44cSAtari911 489e3a9f44cSAtari911 if (!$found) { 490e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 491e3a9f44cSAtari911 } 492e3a9f44cSAtari911 493e3a9f44cSAtari911 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 494e3a9f44cSAtari911 495e3a9f44cSAtari911 // Move to next month 496e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 497e3a9f44cSAtari911 } 498e3a9f44cSAtari911 } 499e3a9f44cSAtari911 50019378907SAtari911 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 50119378907SAtari911 } 50219378907SAtari911 50319378907SAtari911 private function deleteEvent() { 50419378907SAtari911 global $INPUT; 50519378907SAtari911 50619378907SAtari911 $namespace = $INPUT->str('namespace', ''); 50719378907SAtari911 $date = $INPUT->str('date'); 50819378907SAtari911 $eventId = $INPUT->str('eventId'); 50919378907SAtari911 510e3a9f44cSAtari911 // Find where the event actually lives 511e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 512e3a9f44cSAtari911 513e3a9f44cSAtari911 if ($storedNamespace === null) { 514e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 515e3a9f44cSAtari911 return; 516e3a9f44cSAtari911 } 517e3a9f44cSAtari911 518e3a9f44cSAtari911 // Use the found namespace 519e3a9f44cSAtari911 $namespace = $storedNamespace; 520e3a9f44cSAtari911 52119378907SAtari911 list($year, $month, $day) = explode('-', $date); 52219378907SAtari911 52319378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 52419378907SAtari911 if ($namespace) { 52519378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 52619378907SAtari911 } 52719378907SAtari911 $dataDir .= 'calendar/'; 52819378907SAtari911 52919378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 53019378907SAtari911 5319ccd446eSAtari911 // First, get the event to check if it spans multiple months or is recurring 532e3a9f44cSAtari911 $eventToDelete = null; 5339ccd446eSAtari911 $isRecurring = false; 5349ccd446eSAtari911 $recurringId = null; 5359ccd446eSAtari911 53619378907SAtari911 if (file_exists($eventFile)) { 53719378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 53819378907SAtari911 53919378907SAtari911 if (isset($events[$date])) { 540e3a9f44cSAtari911 foreach ($events[$date] as $event) { 541e3a9f44cSAtari911 if ($event['id'] === $eventId) { 542e3a9f44cSAtari911 $eventToDelete = $event; 5439ccd446eSAtari911 $isRecurring = isset($event['recurring']) && $event['recurring']; 5449ccd446eSAtari911 $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 545e3a9f44cSAtari911 break; 546e3a9f44cSAtari911 } 547e3a9f44cSAtari911 } 548e3a9f44cSAtari911 549e3a9f44cSAtari911 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 55019378907SAtari911 return $event['id'] !== $eventId; 551e3a9f44cSAtari911 })); 55219378907SAtari911 55319378907SAtari911 if (empty($events[$date])) { 55419378907SAtari911 unset($events[$date]); 55519378907SAtari911 } 55619378907SAtari911 55719378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 55819378907SAtari911 } 55919378907SAtari911 } 56019378907SAtari911 5619ccd446eSAtari911 // If this is a recurring event, delete ALL occurrences with the same recurringId 5629ccd446eSAtari911 if ($isRecurring && $recurringId) { 5639ccd446eSAtari911 $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir); 5649ccd446eSAtari911 } 5659ccd446eSAtari911 566e3a9f44cSAtari911 // If event spans multiple months, delete it from the first day of each subsequent month 567e3a9f44cSAtari911 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 568e3a9f44cSAtari911 $startDateObj = new DateTime($date); 569e3a9f44cSAtari911 $endDateObj = new DateTime($eventToDelete['endDate']); 570e3a9f44cSAtari911 571e3a9f44cSAtari911 // Iterate through each month the event spans 572e3a9f44cSAtari911 $currentDate = clone $startDateObj; 573e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 574e3a9f44cSAtari911 575e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 576e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 577e3a9f44cSAtari911 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 578e3a9f44cSAtari911 579e3a9f44cSAtari911 // Get the file for this month 580e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 581e3a9f44cSAtari911 582e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 583e3a9f44cSAtari911 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 584e3a9f44cSAtari911 585e3a9f44cSAtari911 if (isset($currentEvents[$firstDayOfMonth])) { 586e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 587e3a9f44cSAtari911 return $event['id'] !== $eventId; 588e3a9f44cSAtari911 })); 589e3a9f44cSAtari911 590e3a9f44cSAtari911 if (empty($currentEvents[$firstDayOfMonth])) { 591e3a9f44cSAtari911 unset($currentEvents[$firstDayOfMonth]); 592e3a9f44cSAtari911 } 593e3a9f44cSAtari911 594e3a9f44cSAtari911 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 595e3a9f44cSAtari911 } 596e3a9f44cSAtari911 } 597e3a9f44cSAtari911 598e3a9f44cSAtari911 // Move to next month 599e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 600e3a9f44cSAtari911 } 601e3a9f44cSAtari911 } 602e3a9f44cSAtari911 60319378907SAtari911 echo json_encode(['success' => true]); 60419378907SAtari911 } 60519378907SAtari911 60619378907SAtari911 private function getEvent() { 60719378907SAtari911 global $INPUT; 60819378907SAtari911 60919378907SAtari911 $namespace = $INPUT->str('namespace', ''); 61019378907SAtari911 $date = $INPUT->str('date'); 61119378907SAtari911 $eventId = $INPUT->str('eventId'); 61219378907SAtari911 613e3a9f44cSAtari911 // Find where the event actually lives 614e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 615e3a9f44cSAtari911 616e3a9f44cSAtari911 if ($storedNamespace === null) { 617e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 618e3a9f44cSAtari911 return; 619e3a9f44cSAtari911 } 620e3a9f44cSAtari911 621e3a9f44cSAtari911 // Use the found namespace 622e3a9f44cSAtari911 $namespace = $storedNamespace; 623e3a9f44cSAtari911 62419378907SAtari911 list($year, $month, $day) = explode('-', $date); 62519378907SAtari911 62619378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 62719378907SAtari911 if ($namespace) { 62819378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 62919378907SAtari911 } 63019378907SAtari911 $dataDir .= 'calendar/'; 63119378907SAtari911 63219378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 63319378907SAtari911 63419378907SAtari911 if (file_exists($eventFile)) { 63519378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 63619378907SAtari911 63719378907SAtari911 if (isset($events[$date])) { 63819378907SAtari911 foreach ($events[$date] as $event) { 63919378907SAtari911 if ($event['id'] === $eventId) { 6401d05cddcSAtari911 // Include the namespace so JavaScript knows where this event actually lives 6411d05cddcSAtari911 $event['namespace'] = $namespace; 64219378907SAtari911 echo json_encode(['success' => true, 'event' => $event]); 64319378907SAtari911 return; 64419378907SAtari911 } 64519378907SAtari911 } 64619378907SAtari911 } 64719378907SAtari911 } 64819378907SAtari911 64919378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 65019378907SAtari911 } 65119378907SAtari911 65219378907SAtari911 private function loadMonth() { 65319378907SAtari911 global $INPUT; 65419378907SAtari911 655e3a9f44cSAtari911 // Prevent caching of AJAX responses 656e3a9f44cSAtari911 header('Cache-Control: no-cache, no-store, must-revalidate'); 657e3a9f44cSAtari911 header('Pragma: no-cache'); 658e3a9f44cSAtari911 header('Expires: 0'); 659e3a9f44cSAtari911 66019378907SAtari911 $namespace = $INPUT->str('namespace', ''); 66119378907SAtari911 $year = $INPUT->int('year'); 66219378907SAtari911 $month = $INPUT->int('month'); 66319378907SAtari911 6647e8ea635SAtari911 // Validate year (reasonable range: 1970-2100) 6657e8ea635SAtari911 if ($year < 1970 || $year > 2100) { 6667e8ea635SAtari911 $year = (int)date('Y'); 6677e8ea635SAtari911 } 6687e8ea635SAtari911 6697e8ea635SAtari911 // Validate month (1-12) 6707e8ea635SAtari911 if ($month < 1 || $month > 12) { 6717e8ea635SAtari911 $month = (int)date('n'); 6727e8ea635SAtari911 } 6737e8ea635SAtari911 6747e8ea635SAtari911 // Validate namespace format 6757e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 6767e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 6777e8ea635SAtari911 return; 6787e8ea635SAtari911 } 6797e8ea635SAtari911 6807e8ea635SAtari911 $this->debugLog("=== Calendar loadMonth DEBUG ==="); 6817e8ea635SAtari911 $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'"); 682e3a9f44cSAtari911 683e3a9f44cSAtari911 // Check if multi-namespace or wildcard 684e3a9f44cSAtari911 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 685e3a9f44cSAtari911 6867e8ea635SAtari911 $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 687e3a9f44cSAtari911 688e3a9f44cSAtari911 if ($isMultiNamespace) { 689e3a9f44cSAtari911 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 690e3a9f44cSAtari911 } else { 691e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 692e3a9f44cSAtari911 } 693e3a9f44cSAtari911 6947e8ea635SAtari911 $this->debugLog("Returning " . count($events) . " date keys"); 695e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 6967e8ea635SAtari911 $this->debugLog(" dateKey=$dateKey has " . count($dayEvents) . " events"); 697e3a9f44cSAtari911 } 698e3a9f44cSAtari911 699e3a9f44cSAtari911 echo json_encode([ 700e3a9f44cSAtari911 'success' => true, 701e3a9f44cSAtari911 'year' => $year, 702e3a9f44cSAtari911 'month' => $month, 703e3a9f44cSAtari911 'events' => $events 704e3a9f44cSAtari911 ]); 705e3a9f44cSAtari911 } 706e3a9f44cSAtari911 707e3a9f44cSAtari911 private function loadEventsSingleNamespace($namespace, $year, $month) { 70819378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 70919378907SAtari911 if ($namespace) { 71019378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 71119378907SAtari911 } 71219378907SAtari911 $dataDir .= 'calendar/'; 71319378907SAtari911 714e3a9f44cSAtari911 // Load ONLY current month 71587ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 71619378907SAtari911 $events = []; 71719378907SAtari911 if (file_exists($eventFile)) { 71887ac9bf3SAtari911 $contents = file_get_contents($eventFile); 71987ac9bf3SAtari911 $decoded = json_decode($contents, true); 72087ac9bf3SAtari911 if (json_last_error() === JSON_ERROR_NONE) { 72187ac9bf3SAtari911 $events = $decoded; 72287ac9bf3SAtari911 } 72387ac9bf3SAtari911 } 72487ac9bf3SAtari911 725e3a9f44cSAtari911 return $events; 72687ac9bf3SAtari911 } 727e3a9f44cSAtari911 728e3a9f44cSAtari911 private function loadEventsMultiNamespace($namespaces, $year, $month) { 729e3a9f44cSAtari911 // Check for wildcard pattern 730e3a9f44cSAtari911 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 731e3a9f44cSAtari911 $baseNamespace = $matches[1]; 732e3a9f44cSAtari911 return $this->loadEventsWildcard($baseNamespace, $year, $month); 733e3a9f44cSAtari911 } 734e3a9f44cSAtari911 735e3a9f44cSAtari911 // Check for root wildcard 736e3a9f44cSAtari911 if ($namespaces === '*') { 737e3a9f44cSAtari911 return $this->loadEventsWildcard('', $year, $month); 738e3a9f44cSAtari911 } 739e3a9f44cSAtari911 740e3a9f44cSAtari911 // Parse namespace list (semicolon separated) 741e3a9f44cSAtari911 $namespaceList = array_map('trim', explode(';', $namespaces)); 742e3a9f44cSAtari911 743e3a9f44cSAtari911 // Load events from all namespaces 744e3a9f44cSAtari911 $allEvents = []; 745e3a9f44cSAtari911 foreach ($namespaceList as $ns) { 746e3a9f44cSAtari911 $ns = trim($ns); 747e3a9f44cSAtari911 if (empty($ns)) continue; 748e3a9f44cSAtari911 749e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 750e3a9f44cSAtari911 751e3a9f44cSAtari911 // Add namespace tag to each event 752e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 753e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 754e3a9f44cSAtari911 $allEvents[$dateKey] = []; 755e3a9f44cSAtari911 } 756e3a9f44cSAtari911 foreach ($dayEvents as $event) { 757e3a9f44cSAtari911 $event['_namespace'] = $ns; 758e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 759e3a9f44cSAtari911 } 76087ac9bf3SAtari911 } 76187ac9bf3SAtari911 } 76287ac9bf3SAtari911 763e3a9f44cSAtari911 return $allEvents; 764e3a9f44cSAtari911 } 76519378907SAtari911 766e3a9f44cSAtari911 private function loadEventsWildcard($baseNamespace, $year, $month) { 767e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 768e3a9f44cSAtari911 if ($baseNamespace) { 769e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 770e3a9f44cSAtari911 } 771e3a9f44cSAtari911 772e3a9f44cSAtari911 $allEvents = []; 773e3a9f44cSAtari911 774e3a9f44cSAtari911 // First, load events from the base namespace itself 775e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 776e3a9f44cSAtari911 777e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 778e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 779e3a9f44cSAtari911 $allEvents[$dateKey] = []; 780e3a9f44cSAtari911 } 781e3a9f44cSAtari911 foreach ($dayEvents as $event) { 782e3a9f44cSAtari911 $event['_namespace'] = $baseNamespace; 783e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 784e3a9f44cSAtari911 } 785e3a9f44cSAtari911 } 786e3a9f44cSAtari911 787e3a9f44cSAtari911 // Recursively find all subdirectories 788e3a9f44cSAtari911 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 789e3a9f44cSAtari911 790e3a9f44cSAtari911 return $allEvents; 791e3a9f44cSAtari911 } 792e3a9f44cSAtari911 793e3a9f44cSAtari911 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 794e3a9f44cSAtari911 if (!is_dir($dir)) return; 795e3a9f44cSAtari911 796e3a9f44cSAtari911 $items = scandir($dir); 797e3a9f44cSAtari911 foreach ($items as $item) { 798e3a9f44cSAtari911 if ($item === '.' || $item === '..') continue; 799e3a9f44cSAtari911 800e3a9f44cSAtari911 $path = $dir . $item; 801e3a9f44cSAtari911 if (is_dir($path) && $item !== 'calendar') { 802e3a9f44cSAtari911 // This is a namespace directory 803e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 804e3a9f44cSAtari911 805e3a9f44cSAtari911 // Load events from this namespace 806e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 807e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 808e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 809e3a9f44cSAtari911 $allEvents[$dateKey] = []; 810e3a9f44cSAtari911 } 811e3a9f44cSAtari911 foreach ($dayEvents as $event) { 812e3a9f44cSAtari911 $event['_namespace'] = $namespace; 813e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 814e3a9f44cSAtari911 } 815e3a9f44cSAtari911 } 816e3a9f44cSAtari911 817e3a9f44cSAtari911 // Recurse into subdirectories 818e3a9f44cSAtari911 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 819e3a9f44cSAtari911 } 820e3a9f44cSAtari911 } 82119378907SAtari911 } 82219378907SAtari911 823*96df7d3eSAtari911 /** 824*96df7d3eSAtari911 * Search all dates for events matching the search term 825*96df7d3eSAtari911 */ 826*96df7d3eSAtari911 private function searchAllDates() { 827*96df7d3eSAtari911 global $INPUT; 828*96df7d3eSAtari911 829*96df7d3eSAtari911 $searchTerm = strtolower(trim($INPUT->str('search', ''))); 830*96df7d3eSAtari911 $namespace = $INPUT->str('namespace', ''); 831*96df7d3eSAtari911 832*96df7d3eSAtari911 if (strlen($searchTerm) < 2) { 833*96df7d3eSAtari911 echo json_encode(['success' => false, 'error' => 'Search term too short']); 834*96df7d3eSAtari911 return; 835*96df7d3eSAtari911 } 836*96df7d3eSAtari911 837*96df7d3eSAtari911 // Normalize search term for fuzzy matching 838*96df7d3eSAtari911 $normalizedSearch = $this->normalizeForSearch($searchTerm); 839*96df7d3eSAtari911 840*96df7d3eSAtari911 $results = []; 841*96df7d3eSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 842*96df7d3eSAtari911 843*96df7d3eSAtari911 // Helper to search calendar directory 844*96df7d3eSAtari911 $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results) { 845*96df7d3eSAtari911 if (!is_dir($calDir)) return; 846*96df7d3eSAtari911 847*96df7d3eSAtari911 foreach (glob($calDir . '/*.json') as $file) { 848*96df7d3eSAtari911 $data = @json_decode(file_get_contents($file), true); 849*96df7d3eSAtari911 if (!$data || !is_array($data)) continue; 850*96df7d3eSAtari911 851*96df7d3eSAtari911 foreach ($data as $dateKey => $dayEvents) { 852*96df7d3eSAtari911 // Skip non-date keys 853*96df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 854*96df7d3eSAtari911 if (!is_array($dayEvents)) continue; 855*96df7d3eSAtari911 856*96df7d3eSAtari911 foreach ($dayEvents as $event) { 857*96df7d3eSAtari911 if (!isset($event['title'])) continue; 858*96df7d3eSAtari911 859*96df7d3eSAtari911 // Build searchable text 860*96df7d3eSAtari911 $searchableText = strtolower($event['title']); 861*96df7d3eSAtari911 if (isset($event['description'])) { 862*96df7d3eSAtari911 $searchableText .= ' ' . strtolower($event['description']); 863*96df7d3eSAtari911 } 864*96df7d3eSAtari911 865*96df7d3eSAtari911 // Normalize for fuzzy matching 866*96df7d3eSAtari911 $normalizedText = $this->normalizeForSearch($searchableText); 867*96df7d3eSAtari911 868*96df7d3eSAtari911 // Check if matches using fuzzy match 869*96df7d3eSAtari911 if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) { 870*96df7d3eSAtari911 $results[] = [ 871*96df7d3eSAtari911 'date' => $dateKey, 872*96df7d3eSAtari911 'title' => $event['title'], 873*96df7d3eSAtari911 'time' => isset($event['time']) ? $event['time'] : '', 874*96df7d3eSAtari911 'endTime' => isset($event['endTime']) ? $event['endTime'] : '', 875*96df7d3eSAtari911 'color' => isset($event['color']) ? $event['color'] : '', 876*96df7d3eSAtari911 'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace, 877*96df7d3eSAtari911 'id' => isset($event['id']) ? $event['id'] : '' 878*96df7d3eSAtari911 ]; 879*96df7d3eSAtari911 } 880*96df7d3eSAtari911 } 881*96df7d3eSAtari911 } 882*96df7d3eSAtari911 } 883*96df7d3eSAtari911 }; 884*96df7d3eSAtari911 885*96df7d3eSAtari911 // Search root calendar directory 886*96df7d3eSAtari911 $searchCalendarDir($dataDir . 'calendar', ''); 887*96df7d3eSAtari911 888*96df7d3eSAtari911 // Search namespace directories 889*96df7d3eSAtari911 $this->searchNamespaceDirs($dataDir, $searchCalendarDir); 890*96df7d3eSAtari911 891*96df7d3eSAtari911 // Sort results by date (newest first for past, oldest first for future) 892*96df7d3eSAtari911 usort($results, function($a, $b) { 893*96df7d3eSAtari911 return strcmp($a['date'], $b['date']); 894*96df7d3eSAtari911 }); 895*96df7d3eSAtari911 896*96df7d3eSAtari911 // Limit results 897*96df7d3eSAtari911 $results = array_slice($results, 0, 50); 898*96df7d3eSAtari911 899*96df7d3eSAtari911 echo json_encode([ 900*96df7d3eSAtari911 'success' => true, 901*96df7d3eSAtari911 'results' => $results, 902*96df7d3eSAtari911 'total' => count($results) 903*96df7d3eSAtari911 ]); 904*96df7d3eSAtari911 } 905*96df7d3eSAtari911 906*96df7d3eSAtari911 /** 907*96df7d3eSAtari911 * Check if normalized text matches normalized search term 908*96df7d3eSAtari911 * Supports multi-word search where all words must be present 909*96df7d3eSAtari911 */ 910*96df7d3eSAtari911 private function fuzzyMatchText($normalizedText, $normalizedSearch) { 911*96df7d3eSAtari911 // Direct substring match 912*96df7d3eSAtari911 if (strpos($normalizedText, $normalizedSearch) !== false) { 913*96df7d3eSAtari911 return true; 914*96df7d3eSAtari911 } 915*96df7d3eSAtari911 916*96df7d3eSAtari911 // Multi-word search: all words must be present 917*96df7d3eSAtari911 $searchWords = array_filter(explode(' ', $normalizedSearch)); 918*96df7d3eSAtari911 if (count($searchWords) > 1) { 919*96df7d3eSAtari911 foreach ($searchWords as $word) { 920*96df7d3eSAtari911 if (strlen($word) > 0 && strpos($normalizedText, $word) === false) { 921*96df7d3eSAtari911 return false; 922*96df7d3eSAtari911 } 923*96df7d3eSAtari911 } 924*96df7d3eSAtari911 return true; 925*96df7d3eSAtari911 } 926*96df7d3eSAtari911 927*96df7d3eSAtari911 return false; 928*96df7d3eSAtari911 } 929*96df7d3eSAtari911 930*96df7d3eSAtari911 /** 931*96df7d3eSAtari911 * Normalize text for fuzzy search matching 932*96df7d3eSAtari911 * Removes apostrophes, extra spaces, and common variations 933*96df7d3eSAtari911 */ 934*96df7d3eSAtari911 private function normalizeForSearch($text) { 935*96df7d3eSAtari911 // Convert to lowercase 936*96df7d3eSAtari911 $text = strtolower($text); 937*96df7d3eSAtari911 938*96df7d3eSAtari911 // Remove apostrophes and quotes (father's -> fathers) 939*96df7d3eSAtari911 $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text); 940*96df7d3eSAtari911 941*96df7d3eSAtari911 // Normalize dashes and underscores to spaces 942*96df7d3eSAtari911 $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text); 943*96df7d3eSAtari911 944*96df7d3eSAtari911 // Remove other punctuation but keep letters, numbers, spaces 945*96df7d3eSAtari911 $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text); 946*96df7d3eSAtari911 947*96df7d3eSAtari911 // Normalize multiple spaces to single space 948*96df7d3eSAtari911 $text = preg_replace('/\s+/', ' ', $text); 949*96df7d3eSAtari911 950*96df7d3eSAtari911 // Trim 951*96df7d3eSAtari911 $text = trim($text); 952*96df7d3eSAtari911 953*96df7d3eSAtari911 return $text; 954*96df7d3eSAtari911 } 955*96df7d3eSAtari911 956*96df7d3eSAtari911 /** 957*96df7d3eSAtari911 * Recursively search namespace directories for calendar data 958*96df7d3eSAtari911 */ 959*96df7d3eSAtari911 private function searchNamespaceDirs($baseDir, $callback) { 960*96df7d3eSAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 961*96df7d3eSAtari911 $name = basename($nsDir); 962*96df7d3eSAtari911 if ($name === 'calendar') continue; 963*96df7d3eSAtari911 964*96df7d3eSAtari911 $calDir = $nsDir . '/calendar'; 965*96df7d3eSAtari911 if (is_dir($calDir)) { 966*96df7d3eSAtari911 $relPath = str_replace(DOKU_INC . 'data/meta/', '', $nsDir); 967*96df7d3eSAtari911 $namespace = str_replace('/', ':', $relPath); 968*96df7d3eSAtari911 $callback($calDir, $namespace); 969*96df7d3eSAtari911 } 970*96df7d3eSAtari911 971*96df7d3eSAtari911 // Recurse 972*96df7d3eSAtari911 $this->searchNamespaceDirs($nsDir . '/', $callback); 973*96df7d3eSAtari911 } 974*96df7d3eSAtari911 } 975*96df7d3eSAtari911 97619378907SAtari911 private function toggleTaskComplete() { 97719378907SAtari911 global $INPUT; 97819378907SAtari911 97919378907SAtari911 $namespace = $INPUT->str('namespace', ''); 98019378907SAtari911 $date = $INPUT->str('date'); 98119378907SAtari911 $eventId = $INPUT->str('eventId'); 98219378907SAtari911 $completed = $INPUT->bool('completed', false); 98319378907SAtari911 984e3a9f44cSAtari911 // Find where the event actually lives 985e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 986e3a9f44cSAtari911 987e3a9f44cSAtari911 if ($storedNamespace === null) { 988e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 989e3a9f44cSAtari911 return; 990e3a9f44cSAtari911 } 991e3a9f44cSAtari911 992e3a9f44cSAtari911 // Use the found namespace 993e3a9f44cSAtari911 $namespace = $storedNamespace; 994e3a9f44cSAtari911 99519378907SAtari911 list($year, $month, $day) = explode('-', $date); 99619378907SAtari911 99719378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 99819378907SAtari911 if ($namespace) { 99919378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 100019378907SAtari911 } 100119378907SAtari911 $dataDir .= 'calendar/'; 100219378907SAtari911 100319378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 100419378907SAtari911 100519378907SAtari911 if (file_exists($eventFile)) { 100619378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 100719378907SAtari911 100819378907SAtari911 if (isset($events[$date])) { 100919378907SAtari911 foreach ($events[$date] as $key => $event) { 101019378907SAtari911 if ($event['id'] === $eventId) { 101119378907SAtari911 $events[$date][$key]['completed'] = $completed; 101219378907SAtari911 break; 101319378907SAtari911 } 101419378907SAtari911 } 101519378907SAtari911 101619378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 101719378907SAtari911 echo json_encode(['success' => true, 'events' => $events]); 101819378907SAtari911 return; 101919378907SAtari911 } 102019378907SAtari911 } 102119378907SAtari911 102219378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 102319378907SAtari911 } 102419378907SAtari911 1025*96df7d3eSAtari911 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime, 1026*96df7d3eSAtari911 $description, $color, $isTask, $recurrenceType, $recurrenceInterval, 1027*96df7d3eSAtari911 $recurrenceEnd, $weekDays, $monthlyType, $monthDay, 1028*96df7d3eSAtari911 $ordinalWeek, $ordinalDay, $baseId) { 102987ac9bf3SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 103087ac9bf3SAtari911 if ($namespace) { 103187ac9bf3SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 103287ac9bf3SAtari911 } 103387ac9bf3SAtari911 $dataDir .= 'calendar/'; 103487ac9bf3SAtari911 103587ac9bf3SAtari911 if (!is_dir($dataDir)) { 103687ac9bf3SAtari911 mkdir($dataDir, 0755, true); 103787ac9bf3SAtari911 } 103887ac9bf3SAtari911 1039*96df7d3eSAtari911 // Ensure interval is at least 1 1040*96df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 104187ac9bf3SAtari911 104287ac9bf3SAtari911 // Set maximum end date if not specified (1 year from start) 104387ac9bf3SAtari911 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 104487ac9bf3SAtari911 104587ac9bf3SAtari911 // Calculate event duration for multi-day events 104687ac9bf3SAtari911 $eventDuration = 0; 104787ac9bf3SAtari911 if ($endDate && $endDate !== $startDate) { 104887ac9bf3SAtari911 $start = new DateTime($startDate); 104987ac9bf3SAtari911 $end = new DateTime($endDate); 105087ac9bf3SAtari911 $eventDuration = $start->diff($end)->days; 105187ac9bf3SAtari911 } 105287ac9bf3SAtari911 105387ac9bf3SAtari911 // Generate recurring events 105487ac9bf3SAtari911 $currentDate = new DateTime($startDate); 105587ac9bf3SAtari911 $endLimit = new DateTime($maxEnd); 105687ac9bf3SAtari911 $counter = 0; 1057*96df7d3eSAtari911 $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year) 1058*96df7d3eSAtari911 1059*96df7d3eSAtari911 // For weekly with specific days, we need to track the interval counter differently 1060*96df7d3eSAtari911 $weekCounter = 0; 1061*96df7d3eSAtari911 $startWeekNumber = (int)$currentDate->format('W'); 1062*96df7d3eSAtari911 $startYear = (int)$currentDate->format('Y'); 106387ac9bf3SAtari911 106487ac9bf3SAtari911 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 1065*96df7d3eSAtari911 $shouldCreateEvent = false; 1066*96df7d3eSAtari911 1067*96df7d3eSAtari911 switch ($recurrenceType) { 1068*96df7d3eSAtari911 case 'daily': 1069*96df7d3eSAtari911 // Every N days from start 1070*96df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 1071*96df7d3eSAtari911 $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0); 1072*96df7d3eSAtari911 break; 1073*96df7d3eSAtari911 1074*96df7d3eSAtari911 case 'weekly': 1075*96df7d3eSAtari911 // Every N weeks, on specified days 1076*96df7d3eSAtari911 $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat 1077*96df7d3eSAtari911 1078*96df7d3eSAtari911 // Calculate weeks since start 1079*96df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 1080*96df7d3eSAtari911 $weeksSinceStart = floor($daysSinceStart / 7); 1081*96df7d3eSAtari911 1082*96df7d3eSAtari911 // Check if we're in the right week (every N weeks) 1083*96df7d3eSAtari911 $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0); 1084*96df7d3eSAtari911 1085*96df7d3eSAtari911 // Check if this day is selected 1086*96df7d3eSAtari911 $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays); 1087*96df7d3eSAtari911 1088*96df7d3eSAtari911 // For the first week, only include days on or after the start date 1089*96df7d3eSAtari911 $isOnOrAfterStart = ($currentDate >= new DateTime($startDate)); 1090*96df7d3eSAtari911 1091*96df7d3eSAtari911 $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart; 1092*96df7d3eSAtari911 break; 1093*96df7d3eSAtari911 1094*96df7d3eSAtari911 case 'monthly': 1095*96df7d3eSAtari911 // Calculate months since start 1096*96df7d3eSAtari911 $startDT = new DateTime($startDate); 1097*96df7d3eSAtari911 $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) + 1098*96df7d3eSAtari911 ($currentDate->format('n') - $startDT->format('n')); 1099*96df7d3eSAtari911 1100*96df7d3eSAtari911 // Check if we're in the right month (every N months) 1101*96df7d3eSAtari911 $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0); 1102*96df7d3eSAtari911 1103*96df7d3eSAtari911 if (!$isCorrectMonth) { 1104*96df7d3eSAtari911 // Skip to first day of next potential month 1105*96df7d3eSAtari911 $currentDate->modify('first day of next month'); 1106*96df7d3eSAtari911 continue 2; 1107*96df7d3eSAtari911 } 1108*96df7d3eSAtari911 1109*96df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 1110*96df7d3eSAtari911 // Specific day of month (e.g., 15th) 1111*96df7d3eSAtari911 $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j'); 1112*96df7d3eSAtari911 $currentDay = (int)$currentDate->format('j'); 1113*96df7d3eSAtari911 $daysInMonth = (int)$currentDate->format('t'); 1114*96df7d3eSAtari911 1115*96df7d3eSAtari911 // If target day exceeds days in month, use last day 1116*96df7d3eSAtari911 $effectiveTargetDay = min($targetDay, $daysInMonth); 1117*96df7d3eSAtari911 $shouldCreateEvent = ($currentDay === $effectiveTargetDay); 1118*96df7d3eSAtari911 } else { 1119*96df7d3eSAtari911 // Ordinal weekday (e.g., 2nd Wednesday, last Friday) 1120*96df7d3eSAtari911 $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay); 1121*96df7d3eSAtari911 } 1122*96df7d3eSAtari911 break; 1123*96df7d3eSAtari911 1124*96df7d3eSAtari911 case 'yearly': 1125*96df7d3eSAtari911 // Every N years on same month/day 1126*96df7d3eSAtari911 $startDT = new DateTime($startDate); 1127*96df7d3eSAtari911 $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y'); 1128*96df7d3eSAtari911 1129*96df7d3eSAtari911 // Check if we're in the right year 1130*96df7d3eSAtari911 $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0); 1131*96df7d3eSAtari911 1132*96df7d3eSAtari911 // Check if it's the same month and day 1133*96df7d3eSAtari911 $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d')); 1134*96df7d3eSAtari911 1135*96df7d3eSAtari911 $shouldCreateEvent = $isCorrectYear && $sameMonthDay; 1136*96df7d3eSAtari911 break; 1137*96df7d3eSAtari911 1138*96df7d3eSAtari911 default: 1139*96df7d3eSAtari911 $shouldCreateEvent = false; 1140*96df7d3eSAtari911 } 1141*96df7d3eSAtari911 1142*96df7d3eSAtari911 if ($shouldCreateEvent) { 114387ac9bf3SAtari911 $dateKey = $currentDate->format('Y-m-d'); 114487ac9bf3SAtari911 list($year, $month, $day) = explode('-', $dateKey); 114587ac9bf3SAtari911 114687ac9bf3SAtari911 // Calculate end date for this occurrence if multi-day 114787ac9bf3SAtari911 $occurrenceEndDate = ''; 114887ac9bf3SAtari911 if ($eventDuration > 0) { 114987ac9bf3SAtari911 $occurrenceEnd = clone $currentDate; 115087ac9bf3SAtari911 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 115187ac9bf3SAtari911 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 115287ac9bf3SAtari911 } 115387ac9bf3SAtari911 115487ac9bf3SAtari911 // Load month file 115587ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 115687ac9bf3SAtari911 $events = []; 115787ac9bf3SAtari911 if (file_exists($eventFile)) { 115887ac9bf3SAtari911 $events = json_decode(file_get_contents($eventFile), true); 1159*96df7d3eSAtari911 if (!is_array($events)) $events = []; 116087ac9bf3SAtari911 } 116187ac9bf3SAtari911 116287ac9bf3SAtari911 if (!isset($events[$dateKey])) { 116387ac9bf3SAtari911 $events[$dateKey] = []; 116487ac9bf3SAtari911 } 116587ac9bf3SAtari911 116687ac9bf3SAtari911 // Create event for this occurrence 116787ac9bf3SAtari911 $eventData = [ 116887ac9bf3SAtari911 'id' => $baseId . '-' . $counter, 116987ac9bf3SAtari911 'title' => $title, 117087ac9bf3SAtari911 'time' => $time, 11711d05cddcSAtari911 'endTime' => $endTime, 117287ac9bf3SAtari911 'description' => $description, 117387ac9bf3SAtari911 'color' => $color, 117487ac9bf3SAtari911 'isTask' => $isTask, 117587ac9bf3SAtari911 'completed' => false, 117687ac9bf3SAtari911 'endDate' => $occurrenceEndDate, 117787ac9bf3SAtari911 'recurring' => true, 117887ac9bf3SAtari911 'recurringId' => $baseId, 1179*96df7d3eSAtari911 'recurrenceType' => $recurrenceType, 1180*96df7d3eSAtari911 'recurrenceInterval' => $recurrenceInterval, 1181*96df7d3eSAtari911 'namespace' => $namespace, 118287ac9bf3SAtari911 'created' => date('Y-m-d H:i:s') 118387ac9bf3SAtari911 ]; 118487ac9bf3SAtari911 1185*96df7d3eSAtari911 // Store additional recurrence info for reference 1186*96df7d3eSAtari911 if ($recurrenceType === 'weekly' && !empty($weekDays)) { 1187*96df7d3eSAtari911 $eventData['weekDays'] = $weekDays; 1188*96df7d3eSAtari911 } 1189*96df7d3eSAtari911 if ($recurrenceType === 'monthly') { 1190*96df7d3eSAtari911 $eventData['monthlyType'] = $monthlyType; 1191*96df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 1192*96df7d3eSAtari911 $eventData['monthDay'] = $monthDay; 1193*96df7d3eSAtari911 } else { 1194*96df7d3eSAtari911 $eventData['ordinalWeek'] = $ordinalWeek; 1195*96df7d3eSAtari911 $eventData['ordinalDay'] = $ordinalDay; 1196*96df7d3eSAtari911 } 1197*96df7d3eSAtari911 } 1198*96df7d3eSAtari911 119987ac9bf3SAtari911 $events[$dateKey][] = $eventData; 120087ac9bf3SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 120187ac9bf3SAtari911 120287ac9bf3SAtari911 $counter++; 120387ac9bf3SAtari911 } 1204*96df7d3eSAtari911 1205*96df7d3eSAtari911 // Move to next day (we check each day individually for complex patterns) 1206*96df7d3eSAtari911 $currentDate->modify('+1 day'); 1207*96df7d3eSAtari911 } 1208*96df7d3eSAtari911 } 1209*96df7d3eSAtari911 1210*96df7d3eSAtari911 /** 1211*96df7d3eSAtari911 * Check if a date is the Nth occurrence of a weekday in its month 1212*96df7d3eSAtari911 * @param DateTime $date The date to check 1213*96df7d3eSAtari911 * @param int $ordinalWeek 1-5 for first-fifth, -1 for last 1214*96df7d3eSAtari911 * @param int $targetDayOfWeek 0=Sunday through 6=Saturday 1215*96df7d3eSAtari911 * @return bool 1216*96df7d3eSAtari911 */ 1217*96df7d3eSAtari911 private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) { 1218*96df7d3eSAtari911 $currentDayOfWeek = (int)$date->format('w'); 1219*96df7d3eSAtari911 1220*96df7d3eSAtari911 // First, check if it's the right day of week 1221*96df7d3eSAtari911 if ($currentDayOfWeek !== $targetDayOfWeek) { 1222*96df7d3eSAtari911 return false; 1223*96df7d3eSAtari911 } 1224*96df7d3eSAtari911 1225*96df7d3eSAtari911 $dayOfMonth = (int)$date->format('j'); 1226*96df7d3eSAtari911 $daysInMonth = (int)$date->format('t'); 1227*96df7d3eSAtari911 1228*96df7d3eSAtari911 if ($ordinalWeek === -1) { 1229*96df7d3eSAtari911 // Last occurrence: check if there's no more of this weekday in the month 1230*96df7d3eSAtari911 $daysRemaining = $daysInMonth - $dayOfMonth; 1231*96df7d3eSAtari911 return $daysRemaining < 7; 1232*96df7d3eSAtari911 } else { 1233*96df7d3eSAtari911 // Nth occurrence: check which occurrence this is 1234*96df7d3eSAtari911 $weekNumber = ceil($dayOfMonth / 7); 1235*96df7d3eSAtari911 return $weekNumber === $ordinalWeek; 1236*96df7d3eSAtari911 } 123787ac9bf3SAtari911 } 123887ac9bf3SAtari911 123919378907SAtari911 public function addAssets(Doku_Event $event, $param) { 124019378907SAtari911 $event->data['link'][] = array( 124119378907SAtari911 'type' => 'text/css', 124219378907SAtari911 'rel' => 'stylesheet', 124319378907SAtari911 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 124419378907SAtari911 ); 124519378907SAtari911 1246*96df7d3eSAtari911 // Load the main calendar JavaScript 1247*96df7d3eSAtari911 // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues 1248*96df7d3eSAtari911 // The actual code is in calendar-main.js 124919378907SAtari911 $event->data['script'][] = array( 125019378907SAtari911 'type' => 'text/javascript', 1251*96df7d3eSAtari911 'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js' 125219378907SAtari911 ); 125319378907SAtari911 } 1254e3a9f44cSAtari911 // Helper function to find an event's stored namespace 1255e3a9f44cSAtari911 private function findEventNamespace($eventId, $date, $searchNamespace) { 1256e3a9f44cSAtari911 list($year, $month, $day) = explode('-', $date); 1257e3a9f44cSAtari911 1258e3a9f44cSAtari911 // List of namespaces to check 1259e3a9f44cSAtari911 $namespacesToCheck = ['']; 1260e3a9f44cSAtari911 1261e3a9f44cSAtari911 // If searchNamespace is a wildcard or multi, we need to search multiple locations 1262e3a9f44cSAtari911 if (!empty($searchNamespace)) { 1263e3a9f44cSAtari911 if (strpos($searchNamespace, ';') !== false) { 1264e3a9f44cSAtari911 // Multi-namespace - check each one 1265e3a9f44cSAtari911 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 1266e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1267e3a9f44cSAtari911 } elseif (strpos($searchNamespace, '*') !== false) { 1268e3a9f44cSAtari911 // Wildcard - need to scan directories 1269e3a9f44cSAtari911 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 1270e3a9f44cSAtari911 $namespacesToCheck = $this->findAllNamespaces($baseNs); 1271e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1272e3a9f44cSAtari911 } else { 1273e3a9f44cSAtari911 // Single namespace 1274e3a9f44cSAtari911 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 1275e3a9f44cSAtari911 } 1276e3a9f44cSAtari911 } 1277e3a9f44cSAtari911 1278*96df7d3eSAtari911 $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck))); 1279*96df7d3eSAtari911 1280e3a9f44cSAtari911 // Search for the event in all possible namespaces 1281e3a9f44cSAtari911 foreach ($namespacesToCheck as $ns) { 1282e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 1283e3a9f44cSAtari911 if ($ns) { 1284e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $ns) . '/'; 1285e3a9f44cSAtari911 } 1286e3a9f44cSAtari911 $dataDir .= 'calendar/'; 1287e3a9f44cSAtari911 1288e3a9f44cSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1289e3a9f44cSAtari911 1290e3a9f44cSAtari911 if (file_exists($eventFile)) { 1291e3a9f44cSAtari911 $events = json_decode(file_get_contents($eventFile), true); 1292e3a9f44cSAtari911 if (isset($events[$date])) { 1293e3a9f44cSAtari911 foreach ($events[$date] as $evt) { 1294e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 1295*96df7d3eSAtari911 // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace 1296*96df7d3eSAtari911 // The directory is what matters for deletion - that's where the file actually is 1297*96df7d3eSAtari911 $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')"); 1298*96df7d3eSAtari911 return $ns; 1299e3a9f44cSAtari911 } 1300e3a9f44cSAtari911 } 1301e3a9f44cSAtari911 } 1302e3a9f44cSAtari911 } 1303e3a9f44cSAtari911 } 1304e3a9f44cSAtari911 1305*96df7d3eSAtari911 $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace"); 1306e3a9f44cSAtari911 return null; // Event not found 1307e3a9f44cSAtari911 } 1308e3a9f44cSAtari911 1309e3a9f44cSAtari911 // Helper to find all namespaces under a base namespace 1310e3a9f44cSAtari911 private function findAllNamespaces($baseNamespace) { 1311e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 1312e3a9f44cSAtari911 if ($baseNamespace) { 1313e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 1314e3a9f44cSAtari911 } 1315e3a9f44cSAtari911 1316e3a9f44cSAtari911 $namespaces = []; 1317e3a9f44cSAtari911 if ($baseNamespace) { 1318e3a9f44cSAtari911 $namespaces[] = $baseNamespace; 1319e3a9f44cSAtari911 } 1320e3a9f44cSAtari911 1321e3a9f44cSAtari911 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 1322e3a9f44cSAtari911 1323*96df7d3eSAtari911 $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces))); 1324*96df7d3eSAtari911 1325e3a9f44cSAtari911 return $namespaces; 1326e3a9f44cSAtari911 } 1327e3a9f44cSAtari911 1328e3a9f44cSAtari911 // Recursive scan for namespaces 1329e3a9f44cSAtari911 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 1330e3a9f44cSAtari911 if (!is_dir($dir)) return; 1331e3a9f44cSAtari911 1332e3a9f44cSAtari911 $items = scandir($dir); 1333e3a9f44cSAtari911 foreach ($items as $item) { 1334e3a9f44cSAtari911 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 1335e3a9f44cSAtari911 1336e3a9f44cSAtari911 $path = $dir . $item; 1337e3a9f44cSAtari911 if (is_dir($path)) { 1338e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1339e3a9f44cSAtari911 $namespaces[] = $namespace; 1340e3a9f44cSAtari911 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 1341e3a9f44cSAtari911 } 1342e3a9f44cSAtari911 } 1343e3a9f44cSAtari911 } 13449ccd446eSAtari911 13459ccd446eSAtari911 /** 13469ccd446eSAtari911 * Delete all instances of a recurring event across all months 13479ccd446eSAtari911 */ 13489ccd446eSAtari911 private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) { 13499ccd446eSAtari911 // Scan all JSON files in the calendar directory 13509ccd446eSAtari911 $calendarFiles = glob($dataDir . '*.json'); 13519ccd446eSAtari911 13529ccd446eSAtari911 foreach ($calendarFiles as $file) { 13539ccd446eSAtari911 $modified = false; 13549ccd446eSAtari911 $events = json_decode(file_get_contents($file), true); 13559ccd446eSAtari911 13569ccd446eSAtari911 if (!$events) continue; 13579ccd446eSAtari911 13589ccd446eSAtari911 // Check each date in the file 13599ccd446eSAtari911 foreach ($events as $date => &$dayEvents) { 13609ccd446eSAtari911 // Filter out events with matching recurringId 13619ccd446eSAtari911 $originalCount = count($dayEvents); 13629ccd446eSAtari911 $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) { 13639ccd446eSAtari911 $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 13649ccd446eSAtari911 return $eventRecurringId !== $recurringId; 13659ccd446eSAtari911 })); 13669ccd446eSAtari911 13679ccd446eSAtari911 if (count($dayEvents) !== $originalCount) { 13689ccd446eSAtari911 $modified = true; 13699ccd446eSAtari911 } 13709ccd446eSAtari911 13719ccd446eSAtari911 // Remove empty dates 13729ccd446eSAtari911 if (empty($dayEvents)) { 13739ccd446eSAtari911 unset($events[$date]); 13749ccd446eSAtari911 } 13759ccd446eSAtari911 } 13769ccd446eSAtari911 13779ccd446eSAtari911 // Save if modified 13789ccd446eSAtari911 if ($modified) { 13799ccd446eSAtari911 file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT)); 13809ccd446eSAtari911 } 13819ccd446eSAtari911 } 13829ccd446eSAtari911 } 13839ccd446eSAtari911 13849ccd446eSAtari911 /** 13859ccd446eSAtari911 * Get existing event data for preserving unchanged fields during edit 13869ccd446eSAtari911 */ 13879ccd446eSAtari911 private function getExistingEventData($eventId, $date, $namespace) { 13889ccd446eSAtari911 list($year, $month, $day) = explode('-', $date); 13899ccd446eSAtari911 13909ccd446eSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 13919ccd446eSAtari911 if ($namespace) { 13929ccd446eSAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 13939ccd446eSAtari911 } 13949ccd446eSAtari911 $dataDir .= 'calendar/'; 13959ccd446eSAtari911 13969ccd446eSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 13979ccd446eSAtari911 13989ccd446eSAtari911 if (!file_exists($eventFile)) { 13999ccd446eSAtari911 return null; 14009ccd446eSAtari911 } 14019ccd446eSAtari911 14029ccd446eSAtari911 $events = json_decode(file_get_contents($eventFile), true); 14039ccd446eSAtari911 14049ccd446eSAtari911 if (!isset($events[$date])) { 14059ccd446eSAtari911 return null; 14069ccd446eSAtari911 } 14079ccd446eSAtari911 14089ccd446eSAtari911 // Find the event by ID 14099ccd446eSAtari911 foreach ($events[$date] as $event) { 14109ccd446eSAtari911 if ($event['id'] === $eventId) { 14119ccd446eSAtari911 return $event; 14129ccd446eSAtari911 } 14139ccd446eSAtari911 } 14149ccd446eSAtari911 14159ccd446eSAtari911 return null; 14169ccd446eSAtari911 } 141719378907SAtari911} 1418