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 64*b498f308SAtari911 // Actions that modify data require authentication and 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)) { 71*b498f308SAtari911 global $INPUT, $INFO; 72*b498f308SAtari911 73*b498f308SAtari911 // Check if user is logged in (at minimum) 74*b498f308SAtari911 if (empty($_SERVER['REMOTE_USER'])) { 75*b498f308SAtari911 echo json_encode(['success' => false, 'error' => 'Authentication required. Please log in.']); 76*b498f308SAtari911 return; 77*b498f308SAtari911 } 78*b498f308SAtari911 79*b498f308SAtari911 // Check for valid security token - try multiple sources 80*b498f308SAtari911 $sectok = $INPUT->str('sectok', ''); 81*b498f308SAtari911 if (empty($sectok)) { 827e8ea635SAtari911 $sectok = $_REQUEST['sectok'] ?? ''; 83*b498f308SAtari911 } 84*b498f308SAtari911 85*b498f308SAtari911 // Use DokuWiki's built-in check 867e8ea635SAtari911 if (!checkSecurityToken($sectok)) { 87*b498f308SAtari911 // Log for debugging 88*b498f308SAtari911 $this->debugLog("Security token check failed. Received: '$sectok'"); 897e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']); 907e8ea635SAtari911 return; 917e8ea635SAtari911 } 927e8ea635SAtari911 } 937e8ea635SAtari911 9419378907SAtari911 switch ($action) { 9519378907SAtari911 case 'save_event': 9619378907SAtari911 $this->saveEvent(); 9719378907SAtari911 break; 9819378907SAtari911 case 'delete_event': 9919378907SAtari911 $this->deleteEvent(); 10019378907SAtari911 break; 10119378907SAtari911 case 'get_event': 10219378907SAtari911 $this->getEvent(); 10319378907SAtari911 break; 10419378907SAtari911 case 'load_month': 10519378907SAtari911 $this->loadMonth(); 10619378907SAtari911 break; 107da206178SAtari911 case 'get_static_calendar': 108da206178SAtari911 $this->getStaticCalendar(); 109da206178SAtari911 break; 11096df7d3eSAtari911 case 'search_all': 11196df7d3eSAtari911 $this->searchAllDates(); 11296df7d3eSAtari911 break; 11319378907SAtari911 case 'toggle_task': 11419378907SAtari911 $this->toggleTaskComplete(); 11519378907SAtari911 break; 1167e8ea635SAtari911 case 'cleanup_empty_namespaces': 1177e8ea635SAtari911 case 'trim_all_past_recurring': 1187e8ea635SAtari911 case 'rescan_recurring': 1197e8ea635SAtari911 case 'extend_recurring': 1207e8ea635SAtari911 case 'trim_recurring': 1217e8ea635SAtari911 case 'pause_recurring': 1227e8ea635SAtari911 case 'resume_recurring': 1237e8ea635SAtari911 case 'change_start_recurring': 1247e8ea635SAtari911 case 'change_pattern_recurring': 1257e8ea635SAtari911 $this->routeToAdmin($action); 1267e8ea635SAtari911 break; 12719378907SAtari911 default: 12819378907SAtari911 echo json_encode(['success' => false, 'error' => 'Unknown action']); 12919378907SAtari911 } 13019378907SAtari911 } 13119378907SAtari911 1327e8ea635SAtari911 /** 1337e8ea635SAtari911 * Route AJAX actions to admin plugin methods 1347e8ea635SAtari911 */ 1357e8ea635SAtari911 private function routeToAdmin($action) { 1367e8ea635SAtari911 $admin = plugin_load('admin', 'calendar'); 1377e8ea635SAtari911 if ($admin && method_exists($admin, 'handleAjaxAction')) { 1387e8ea635SAtari911 $admin->handleAjaxAction($action); 1397e8ea635SAtari911 } else { 1407e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Admin handler not available']); 1417e8ea635SAtari911 } 1427e8ea635SAtari911 } 1437e8ea635SAtari911 14419378907SAtari911 private function saveEvent() { 14519378907SAtari911 global $INPUT; 14619378907SAtari911 14719378907SAtari911 $namespace = $INPUT->str('namespace', ''); 14819378907SAtari911 $date = $INPUT->str('date'); 14919378907SAtari911 $eventId = $INPUT->str('eventId', ''); 15019378907SAtari911 $title = $INPUT->str('title'); 15119378907SAtari911 $time = $INPUT->str('time', ''); 1521d05cddcSAtari911 $endTime = $INPUT->str('endTime', ''); 15319378907SAtari911 $description = $INPUT->str('description', ''); 15419378907SAtari911 $color = $INPUT->str('color', '#3498db'); 15519378907SAtari911 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 15619378907SAtari911 $isTask = $INPUT->bool('isTask', false); 15719378907SAtari911 $completed = $INPUT->bool('completed', false); 15819378907SAtari911 $endDate = $INPUT->str('endDate', ''); 15987ac9bf3SAtari911 $isRecurring = $INPUT->bool('isRecurring', false); 16087ac9bf3SAtari911 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 16187ac9bf3SAtari911 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 16219378907SAtari911 16396df7d3eSAtari911 // New recurrence options 16496df7d3eSAtari911 $recurrenceInterval = $INPUT->int('recurrenceInterval', 1); 16596df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 16696df7d3eSAtari911 if ($recurrenceInterval > 99) $recurrenceInterval = 99; 16796df7d3eSAtari911 16896df7d3eSAtari911 $weekDaysStr = $INPUT->str('weekDays', ''); 16996df7d3eSAtari911 $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : []; 17096df7d3eSAtari911 17196df7d3eSAtari911 $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth'); 17296df7d3eSAtari911 $monthDay = $INPUT->int('monthDay', 0); 17396df7d3eSAtari911 $ordinalWeek = $INPUT->int('ordinalWeek', 1); 17496df7d3eSAtari911 $ordinalDay = $INPUT->int('ordinalDay', 0); 17596df7d3eSAtari911 17696df7d3eSAtari911 $this->debugLog("=== Calendar saveEvent START ==="); 17796df7d3eSAtari911 $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'"); 17896df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'"); 17996df7d3eSAtari911 18019378907SAtari911 if (!$date || !$title) { 18119378907SAtari911 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 18219378907SAtari911 return; 18319378907SAtari911 } 18419378907SAtari911 1857e8ea635SAtari911 // Validate date format (YYYY-MM-DD) 1867e8ea635SAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { 1877e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid date format']); 1887e8ea635SAtari911 return; 1897e8ea635SAtari911 } 1907e8ea635SAtari911 1917e8ea635SAtari911 // Validate oldDate if provided 1927e8ea635SAtari911 if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) { 1937e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid old date format']); 1947e8ea635SAtari911 return; 1957e8ea635SAtari911 } 1967e8ea635SAtari911 1977e8ea635SAtari911 // Validate endDate if provided 1987e8ea635SAtari911 if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) { 1997e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end date format']); 2007e8ea635SAtari911 return; 2017e8ea635SAtari911 } 2027e8ea635SAtari911 2037e8ea635SAtari911 // Validate time format (HH:MM) if provided 2047e8ea635SAtari911 if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) { 2057e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid time format']); 2067e8ea635SAtari911 return; 2077e8ea635SAtari911 } 2087e8ea635SAtari911 2097e8ea635SAtari911 // Validate endTime format if provided 2107e8ea635SAtari911 if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) { 2117e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end time format']); 2127e8ea635SAtari911 return; 2137e8ea635SAtari911 } 2147e8ea635SAtari911 2157e8ea635SAtari911 // Validate color format (hex color) 2167e8ea635SAtari911 if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) { 2177e8ea635SAtari911 $color = '#3498db'; // Reset to default if invalid 2187e8ea635SAtari911 } 2197e8ea635SAtari911 2207e8ea635SAtari911 // Validate namespace (prevent path traversal) 2217e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 2227e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 2237e8ea635SAtari911 return; 2247e8ea635SAtari911 } 2257e8ea635SAtari911 2267e8ea635SAtari911 // Validate recurrence type 2277e8ea635SAtari911 $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly']; 2287e8ea635SAtari911 if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) { 2297e8ea635SAtari911 $recurrenceType = 'weekly'; 2307e8ea635SAtari911 } 2317e8ea635SAtari911 2327e8ea635SAtari911 // Validate recurrenceEnd if provided 2337e8ea635SAtari911 if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) { 2347e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']); 2357e8ea635SAtari911 return; 2367e8ea635SAtari911 } 2377e8ea635SAtari911 2387e8ea635SAtari911 // Sanitize title length 2397e8ea635SAtari911 $title = substr(trim($title), 0, 500); 2407e8ea635SAtari911 2417e8ea635SAtari911 // Sanitize description length 2427e8ea635SAtari911 $description = substr($description, 0, 10000); 2437e8ea635SAtari911 24496df7d3eSAtari911 // If editing, find the event's ACTUAL namespace (for finding/deleting old event) 24596df7d3eSAtari911 // We need to search ALL namespaces because user may be changing namespace 24696df7d3eSAtari911 $oldNamespace = null; // null means "not found yet" 247e3a9f44cSAtari911 if ($eventId) { 2481d05cddcSAtari911 // Use oldDate if available (date was changed), otherwise use current date 2491d05cddcSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 2501d05cddcSAtari911 25196df7d3eSAtari911 // Search using wildcard to find event in ANY namespace 25296df7d3eSAtari911 $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*'); 25396df7d3eSAtari911 25496df7d3eSAtari911 if ($foundNamespace !== null) { 25596df7d3eSAtari911 $oldNamespace = $foundNamespace; // Could be '' for default namespace 2567e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'"); 25796df7d3eSAtari911 } else { 25896df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace"); 2591d05cddcSAtari911 } 260e3a9f44cSAtari911 } 261e3a9f44cSAtari911 2621d05cddcSAtari911 // Use the namespace provided by the user (allow namespace changes!) 2631d05cddcSAtari911 // But normalize wildcards and multi-namespace to empty for NEW events 2641d05cddcSAtari911 if (!$eventId) { 2657e8ea635SAtari911 $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'"); 266e3a9f44cSAtari911 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 267e3a9f44cSAtari911 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 2687e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty"); 269e3a9f44cSAtari911 $namespace = ''; 2701d05cddcSAtari911 } else { 2717e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'"); 272e3a9f44cSAtari911 } 2731d05cddcSAtari911 } else { 2747e8ea635SAtari911 $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'"); 275e3a9f44cSAtari911 } 276e3a9f44cSAtari911 27787ac9bf3SAtari911 // Generate event ID if new 27887ac9bf3SAtari911 $generatedId = $eventId ?: uniqid(); 27987ac9bf3SAtari911 2809ccd446eSAtari911 // If editing a recurring event, load existing data to preserve unchanged fields 2819ccd446eSAtari911 $existingEventData = null; 2829ccd446eSAtari911 if ($eventId && $isRecurring) { 2839ccd446eSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 28496df7d3eSAtari911 // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use '' 28596df7d3eSAtari911 $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace); 2869ccd446eSAtari911 if ($existingEventData) { 2877e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'"); 2889ccd446eSAtari911 } 2899ccd446eSAtari911 } 2909ccd446eSAtari911 29187ac9bf3SAtari911 // If recurring, generate multiple events 29287ac9bf3SAtari911 if ($isRecurring) { 2939ccd446eSAtari911 // Merge with existing data if editing (preserve values that weren't changed) 2949ccd446eSAtari911 if ($existingEventData) { 2959ccd446eSAtari911 $title = $title ?: $existingEventData['title']; 2969ccd446eSAtari911 $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : ''); 2979ccd446eSAtari911 $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : ''); 2989ccd446eSAtari911 $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : ''); 2999ccd446eSAtari911 // Only use existing color if new color is default 3009ccd446eSAtari911 if ($color === '#3498db' && isset($existingEventData['color'])) { 3019ccd446eSAtari911 $color = $existingEventData['color']; 3029ccd446eSAtari911 } 3039ccd446eSAtari911 3049ccd446eSAtari911 // Preserve namespace in these cases: 3059ccd446eSAtari911 // 1. Namespace field is empty (user didn't select anything) 3069ccd446eSAtari911 // 2. Namespace contains wildcards (like "personal;work" or "work*") 3079ccd446eSAtari911 // 3. Namespace is the same as what was passed (no change intended) 3089ccd446eSAtari911 $receivedNamespace = $namespace; 3099ccd446eSAtari911 if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 3109ccd446eSAtari911 if (isset($existingEventData['namespace'])) { 3119ccd446eSAtari911 $namespace = $existingEventData['namespace']; 3127e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')"); 3139ccd446eSAtari911 } else { 3147e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')"); 3159ccd446eSAtari911 } 3169ccd446eSAtari911 } else { 3177e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')"); 3189ccd446eSAtari911 } 3199ccd446eSAtari911 } else { 3207e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'"); 3219ccd446eSAtari911 } 3229ccd446eSAtari911 32396df7d3eSAtari911 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description, 32496df7d3eSAtari911 $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd, 32596df7d3eSAtari911 $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId); 32687ac9bf3SAtari911 echo json_encode(['success' => true]); 32787ac9bf3SAtari911 return; 32887ac9bf3SAtari911 } 32987ac9bf3SAtari911 33019378907SAtari911 list($year, $month, $day) = explode('-', $date); 33119378907SAtari911 3321d05cddcSAtari911 // NEW namespace directory (where we'll save) 33319378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 33419378907SAtari911 if ($namespace) { 33519378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 33619378907SAtari911 } 33719378907SAtari911 $dataDir .= 'calendar/'; 33819378907SAtari911 33919378907SAtari911 if (!is_dir($dataDir)) { 34019378907SAtari911 mkdir($dataDir, 0755, true); 34119378907SAtari911 } 34219378907SAtari911 34319378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 34419378907SAtari911 34596df7d3eSAtari911 $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'"); 34696df7d3eSAtari911 34719378907SAtari911 $events = []; 34819378907SAtari911 if (file_exists($eventFile)) { 34919378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 35096df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location"); 35196df7d3eSAtari911 } else { 35296df7d3eSAtari911 $this->debugLog("Calendar saveEvent: New location file does not exist yet"); 35319378907SAtari911 } 35419378907SAtari911 3551d05cddcSAtari911 // If editing and (date changed OR namespace changed), remove from old location first 35696df7d3eSAtari911 // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace 35796df7d3eSAtari911 $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace); 3581d05cddcSAtari911 $dateChanged = ($eventId && $oldDate && $oldDate !== $date); 3591d05cddcSAtari911 36096df7d3eSAtari911 $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO')); 36196df7d3eSAtari911 3621d05cddcSAtari911 if ($namespaceChanged || $dateChanged) { 3631d05cddcSAtari911 // Construct OLD data directory using OLD namespace 3641d05cddcSAtari911 $oldDataDir = DOKU_INC . 'data/meta/'; 3651d05cddcSAtari911 if ($oldNamespace) { 3661d05cddcSAtari911 $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/'; 3671d05cddcSAtari911 } 3681d05cddcSAtari911 $oldDataDir .= 'calendar/'; 3691d05cddcSAtari911 3701d05cddcSAtari911 $deleteDate = $dateChanged ? $oldDate : $date; 3711d05cddcSAtari911 list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate); 3721d05cddcSAtari911 $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 37319378907SAtari911 37496df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'"); 37596df7d3eSAtari911 37619378907SAtari911 if (file_exists($oldEventFile)) { 37719378907SAtari911 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 37896df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates"); 37996df7d3eSAtari911 3801d05cddcSAtari911 if (isset($oldEvents[$deleteDate])) { 38196df7d3eSAtari911 $countBefore = count($oldEvents[$deleteDate]); 3821d05cddcSAtari911 $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) { 38319378907SAtari911 return $evt['id'] !== $eventId; 384e3a9f44cSAtari911 })); 38596df7d3eSAtari911 $countAfter = count($oldEvents[$deleteDate]); 38696df7d3eSAtari911 38796df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter"); 38819378907SAtari911 3891d05cddcSAtari911 if (empty($oldEvents[$deleteDate])) { 3901d05cddcSAtari911 unset($oldEvents[$deleteDate]); 39119378907SAtari911 } 39219378907SAtari911 39319378907SAtari911 file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT)); 39496df7d3eSAtari911 $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'"); 39596df7d3eSAtari911 } else { 39696df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file"); 39719378907SAtari911 } 39896df7d3eSAtari911 } else { 39996df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile"); 40019378907SAtari911 } 40196df7d3eSAtari911 } else { 40296df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location"); 40319378907SAtari911 } 40419378907SAtari911 40519378907SAtari911 if (!isset($events[$date])) { 40619378907SAtari911 $events[$date] = []; 407e3a9f44cSAtari911 } elseif (!is_array($events[$date])) { 408e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 4097e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 410e3a9f44cSAtari911 $events[$date] = []; 41119378907SAtari911 } 41219378907SAtari911 413e3a9f44cSAtari911 // Store the namespace with the event 41419378907SAtari911 $eventData = [ 41587ac9bf3SAtari911 'id' => $generatedId, 41619378907SAtari911 'title' => $title, 41719378907SAtari911 'time' => $time, 4181d05cddcSAtari911 'endTime' => $endTime, 41919378907SAtari911 'description' => $description, 42019378907SAtari911 'color' => $color, 42119378907SAtari911 'isTask' => $isTask, 42219378907SAtari911 'completed' => $completed, 42319378907SAtari911 'endDate' => $endDate, 424e3a9f44cSAtari911 'namespace' => $namespace, // Store namespace with event 42519378907SAtari911 'created' => date('Y-m-d H:i:s') 42619378907SAtari911 ]; 42719378907SAtari911 4281d05cddcSAtari911 // Debug logging 4297e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile"); 4301d05cddcSAtari911 43119378907SAtari911 // If editing, replace existing event 43219378907SAtari911 if ($eventId) { 43319378907SAtari911 $found = false; 43419378907SAtari911 foreach ($events[$date] as $key => $evt) { 43519378907SAtari911 if ($evt['id'] === $eventId) { 43619378907SAtari911 $events[$date][$key] = $eventData; 43719378907SAtari911 $found = true; 43819378907SAtari911 break; 43919378907SAtari911 } 44019378907SAtari911 } 44119378907SAtari911 if (!$found) { 44219378907SAtari911 $events[$date][] = $eventData; 44319378907SAtari911 } 44419378907SAtari911 } else { 44519378907SAtari911 $events[$date][] = $eventData; 44619378907SAtari911 } 44719378907SAtari911 44819378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 44919378907SAtari911 450e3a9f44cSAtari911 // If event spans multiple months, add it to the first day of each subsequent month 451e3a9f44cSAtari911 if ($endDate && $endDate !== $date) { 452e3a9f44cSAtari911 $startDateObj = new DateTime($date); 453e3a9f44cSAtari911 $endDateObj = new DateTime($endDate); 454e3a9f44cSAtari911 455e3a9f44cSAtari911 // Get the month/year of the start date 456e3a9f44cSAtari911 $startMonth = $startDateObj->format('Y-m'); 457e3a9f44cSAtari911 458e3a9f44cSAtari911 // Iterate through each month the event spans 459e3a9f44cSAtari911 $currentDate = clone $startDateObj; 460e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 461e3a9f44cSAtari911 462e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 463e3a9f44cSAtari911 $currentMonth = $currentDate->format('Y-m'); 464e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 465e3a9f44cSAtari911 466e3a9f44cSAtari911 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 467e3a9f44cSAtari911 468e3a9f44cSAtari911 // Get the file for this month 469e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 470e3a9f44cSAtari911 471e3a9f44cSAtari911 $currentEvents = []; 472e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 473e3a9f44cSAtari911 $contents = file_get_contents($currentEventFile); 474e3a9f44cSAtari911 $decoded = json_decode($contents, true); 475e3a9f44cSAtari911 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 476e3a9f44cSAtari911 $currentEvents = $decoded; 477e3a9f44cSAtari911 } else { 4787e8ea635SAtari911 $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 479e3a9f44cSAtari911 } 480e3a9f44cSAtari911 } 481e3a9f44cSAtari911 482e3a9f44cSAtari911 // Add entry for the first day of this month 483e3a9f44cSAtari911 if (!isset($currentEvents[$firstDayOfMonth])) { 484e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 485e3a9f44cSAtari911 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 486e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 4877e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 488e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 489e3a9f44cSAtari911 } 490e3a9f44cSAtari911 491e3a9f44cSAtari911 // Create a copy with the original start date preserved 492e3a9f44cSAtari911 $eventDataForMonth = $eventData; 493e3a9f44cSAtari911 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 494e3a9f44cSAtari911 495e3a9f44cSAtari911 // Check if event already exists (when editing) 496e3a9f44cSAtari911 $found = false; 497e3a9f44cSAtari911 if ($eventId) { 498e3a9f44cSAtari911 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 499e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 500e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 501e3a9f44cSAtari911 $found = true; 502e3a9f44cSAtari911 break; 503e3a9f44cSAtari911 } 504e3a9f44cSAtari911 } 505e3a9f44cSAtari911 } 506e3a9f44cSAtari911 507e3a9f44cSAtari911 if (!$found) { 508e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 509e3a9f44cSAtari911 } 510e3a9f44cSAtari911 511e3a9f44cSAtari911 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 512e3a9f44cSAtari911 513e3a9f44cSAtari911 // Move to next month 514e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 515e3a9f44cSAtari911 } 516e3a9f44cSAtari911 } 517e3a9f44cSAtari911 51819378907SAtari911 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 51919378907SAtari911 } 52019378907SAtari911 52119378907SAtari911 private function deleteEvent() { 52219378907SAtari911 global $INPUT; 52319378907SAtari911 52419378907SAtari911 $namespace = $INPUT->str('namespace', ''); 52519378907SAtari911 $date = $INPUT->str('date'); 52619378907SAtari911 $eventId = $INPUT->str('eventId'); 52719378907SAtari911 528e3a9f44cSAtari911 // Find where the event actually lives 529e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 530e3a9f44cSAtari911 531e3a9f44cSAtari911 if ($storedNamespace === null) { 532e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 533e3a9f44cSAtari911 return; 534e3a9f44cSAtari911 } 535e3a9f44cSAtari911 536e3a9f44cSAtari911 // Use the found namespace 537e3a9f44cSAtari911 $namespace = $storedNamespace; 538e3a9f44cSAtari911 53919378907SAtari911 list($year, $month, $day) = explode('-', $date); 54019378907SAtari911 54119378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 54219378907SAtari911 if ($namespace) { 54319378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 54419378907SAtari911 } 54519378907SAtari911 $dataDir .= 'calendar/'; 54619378907SAtari911 54719378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 54819378907SAtari911 5499ccd446eSAtari911 // First, get the event to check if it spans multiple months or is recurring 550e3a9f44cSAtari911 $eventToDelete = null; 5519ccd446eSAtari911 $isRecurring = false; 5529ccd446eSAtari911 $recurringId = null; 5539ccd446eSAtari911 55419378907SAtari911 if (file_exists($eventFile)) { 55519378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 55619378907SAtari911 55719378907SAtari911 if (isset($events[$date])) { 558e3a9f44cSAtari911 foreach ($events[$date] as $event) { 559e3a9f44cSAtari911 if ($event['id'] === $eventId) { 560e3a9f44cSAtari911 $eventToDelete = $event; 5619ccd446eSAtari911 $isRecurring = isset($event['recurring']) && $event['recurring']; 5629ccd446eSAtari911 $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 563e3a9f44cSAtari911 break; 564e3a9f44cSAtari911 } 565e3a9f44cSAtari911 } 566e3a9f44cSAtari911 567e3a9f44cSAtari911 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 56819378907SAtari911 return $event['id'] !== $eventId; 569e3a9f44cSAtari911 })); 57019378907SAtari911 57119378907SAtari911 if (empty($events[$date])) { 57219378907SAtari911 unset($events[$date]); 57319378907SAtari911 } 57419378907SAtari911 57519378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 57619378907SAtari911 } 57719378907SAtari911 } 57819378907SAtari911 5799ccd446eSAtari911 // If this is a recurring event, delete ALL occurrences with the same recurringId 5809ccd446eSAtari911 if ($isRecurring && $recurringId) { 5819ccd446eSAtari911 $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir); 5829ccd446eSAtari911 } 5839ccd446eSAtari911 584e3a9f44cSAtari911 // If event spans multiple months, delete it from the first day of each subsequent month 585e3a9f44cSAtari911 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 586e3a9f44cSAtari911 $startDateObj = new DateTime($date); 587e3a9f44cSAtari911 $endDateObj = new DateTime($eventToDelete['endDate']); 588e3a9f44cSAtari911 589e3a9f44cSAtari911 // Iterate through each month the event spans 590e3a9f44cSAtari911 $currentDate = clone $startDateObj; 591e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 592e3a9f44cSAtari911 593e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 594e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 595e3a9f44cSAtari911 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 596e3a9f44cSAtari911 597e3a9f44cSAtari911 // Get the file for this month 598e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 599e3a9f44cSAtari911 600e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 601e3a9f44cSAtari911 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 602e3a9f44cSAtari911 603e3a9f44cSAtari911 if (isset($currentEvents[$firstDayOfMonth])) { 604e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 605e3a9f44cSAtari911 return $event['id'] !== $eventId; 606e3a9f44cSAtari911 })); 607e3a9f44cSAtari911 608e3a9f44cSAtari911 if (empty($currentEvents[$firstDayOfMonth])) { 609e3a9f44cSAtari911 unset($currentEvents[$firstDayOfMonth]); 610e3a9f44cSAtari911 } 611e3a9f44cSAtari911 612e3a9f44cSAtari911 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 613e3a9f44cSAtari911 } 614e3a9f44cSAtari911 } 615e3a9f44cSAtari911 616e3a9f44cSAtari911 // Move to next month 617e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 618e3a9f44cSAtari911 } 619e3a9f44cSAtari911 } 620e3a9f44cSAtari911 62119378907SAtari911 echo json_encode(['success' => true]); 62219378907SAtari911 } 62319378907SAtari911 62419378907SAtari911 private function getEvent() { 62519378907SAtari911 global $INPUT; 62619378907SAtari911 62719378907SAtari911 $namespace = $INPUT->str('namespace', ''); 62819378907SAtari911 $date = $INPUT->str('date'); 62919378907SAtari911 $eventId = $INPUT->str('eventId'); 63019378907SAtari911 631e3a9f44cSAtari911 // Find where the event actually lives 632e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 633e3a9f44cSAtari911 634e3a9f44cSAtari911 if ($storedNamespace === null) { 635e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 636e3a9f44cSAtari911 return; 637e3a9f44cSAtari911 } 638e3a9f44cSAtari911 639e3a9f44cSAtari911 // Use the found namespace 640e3a9f44cSAtari911 $namespace = $storedNamespace; 641e3a9f44cSAtari911 64219378907SAtari911 list($year, $month, $day) = explode('-', $date); 64319378907SAtari911 64419378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 64519378907SAtari911 if ($namespace) { 64619378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 64719378907SAtari911 } 64819378907SAtari911 $dataDir .= 'calendar/'; 64919378907SAtari911 65019378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 65119378907SAtari911 65219378907SAtari911 if (file_exists($eventFile)) { 65319378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 65419378907SAtari911 65519378907SAtari911 if (isset($events[$date])) { 65619378907SAtari911 foreach ($events[$date] as $event) { 65719378907SAtari911 if ($event['id'] === $eventId) { 6581d05cddcSAtari911 // Include the namespace so JavaScript knows where this event actually lives 6591d05cddcSAtari911 $event['namespace'] = $namespace; 66019378907SAtari911 echo json_encode(['success' => true, 'event' => $event]); 66119378907SAtari911 return; 66219378907SAtari911 } 66319378907SAtari911 } 66419378907SAtari911 } 66519378907SAtari911 } 66619378907SAtari911 66719378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 66819378907SAtari911 } 66919378907SAtari911 67019378907SAtari911 private function loadMonth() { 67119378907SAtari911 global $INPUT; 67219378907SAtari911 673e3a9f44cSAtari911 // Prevent caching of AJAX responses 674e3a9f44cSAtari911 header('Cache-Control: no-cache, no-store, must-revalidate'); 675e3a9f44cSAtari911 header('Pragma: no-cache'); 676e3a9f44cSAtari911 header('Expires: 0'); 677e3a9f44cSAtari911 67819378907SAtari911 $namespace = $INPUT->str('namespace', ''); 67919378907SAtari911 $year = $INPUT->int('year'); 68019378907SAtari911 $month = $INPUT->int('month'); 68119378907SAtari911 6827e8ea635SAtari911 // Validate year (reasonable range: 1970-2100) 6837e8ea635SAtari911 if ($year < 1970 || $year > 2100) { 6847e8ea635SAtari911 $year = (int)date('Y'); 6857e8ea635SAtari911 } 6867e8ea635SAtari911 6877e8ea635SAtari911 // Validate month (1-12) 6887e8ea635SAtari911 if ($month < 1 || $month > 12) { 6897e8ea635SAtari911 $month = (int)date('n'); 6907e8ea635SAtari911 } 6917e8ea635SAtari911 6927e8ea635SAtari911 // Validate namespace format 6937e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 6947e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 6957e8ea635SAtari911 return; 6967e8ea635SAtari911 } 6977e8ea635SAtari911 6987e8ea635SAtari911 $this->debugLog("=== Calendar loadMonth DEBUG ==="); 6997e8ea635SAtari911 $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'"); 700e3a9f44cSAtari911 701e3a9f44cSAtari911 // Check if multi-namespace or wildcard 702e3a9f44cSAtari911 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 703e3a9f44cSAtari911 7047e8ea635SAtari911 $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 705e3a9f44cSAtari911 706e3a9f44cSAtari911 if ($isMultiNamespace) { 707e3a9f44cSAtari911 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 708e3a9f44cSAtari911 } else { 709e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 710e3a9f44cSAtari911 } 711e3a9f44cSAtari911 7127e8ea635SAtari911 $this->debugLog("Returning " . count($events) . " date keys"); 713e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 7147e8ea635SAtari911 $this->debugLog(" dateKey=$dateKey has " . count($dayEvents) . " events"); 715e3a9f44cSAtari911 } 716e3a9f44cSAtari911 717e3a9f44cSAtari911 echo json_encode([ 718e3a9f44cSAtari911 'success' => true, 719e3a9f44cSAtari911 'year' => $year, 720e3a9f44cSAtari911 'month' => $month, 721e3a9f44cSAtari911 'events' => $events 722e3a9f44cSAtari911 ]); 723e3a9f44cSAtari911 } 724e3a9f44cSAtari911 725da206178SAtari911 /** 726da206178SAtari911 * Get static calendar HTML via AJAX for navigation 727da206178SAtari911 */ 728da206178SAtari911 private function getStaticCalendar() { 729da206178SAtari911 global $INPUT; 730da206178SAtari911 731da206178SAtari911 $namespace = $INPUT->str('namespace', ''); 732da206178SAtari911 $year = $INPUT->int('year'); 733da206178SAtari911 $month = $INPUT->int('month'); 734da206178SAtari911 735da206178SAtari911 // Validate 736da206178SAtari911 if ($year < 1970 || $year > 2100) { 737da206178SAtari911 $year = (int)date('Y'); 738da206178SAtari911 } 739da206178SAtari911 if ($month < 1 || $month > 12) { 740da206178SAtari911 $month = (int)date('n'); 741da206178SAtari911 } 742da206178SAtari911 743da206178SAtari911 // Get syntax plugin to render the static calendar 744da206178SAtari911 $syntax = plugin_load('syntax', 'calendar'); 745da206178SAtari911 if (!$syntax) { 746da206178SAtari911 echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']); 747da206178SAtari911 return; 748da206178SAtari911 } 749da206178SAtari911 750da206178SAtari911 // Build data array for render 751da206178SAtari911 $data = [ 752da206178SAtari911 'year' => $year, 753da206178SAtari911 'month' => $month, 754da206178SAtari911 'namespace' => $namespace, 755da206178SAtari911 'static' => true 756da206178SAtari911 ]; 757da206178SAtari911 758da206178SAtari911 // Call the render method via reflection (since renderStaticCalendar is private) 759da206178SAtari911 $reflector = new \ReflectionClass($syntax); 760da206178SAtari911 $method = $reflector->getMethod('renderStaticCalendar'); 761da206178SAtari911 $method->setAccessible(true); 762da206178SAtari911 $html = $method->invoke($syntax, $data); 763da206178SAtari911 764da206178SAtari911 echo json_encode([ 765da206178SAtari911 'success' => true, 766da206178SAtari911 'html' => $html 767da206178SAtari911 ]); 768da206178SAtari911 } 769da206178SAtari911 770e3a9f44cSAtari911 private function loadEventsSingleNamespace($namespace, $year, $month) { 77119378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 77219378907SAtari911 if ($namespace) { 77319378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 77419378907SAtari911 } 77519378907SAtari911 $dataDir .= 'calendar/'; 77619378907SAtari911 777e3a9f44cSAtari911 // Load ONLY current month 77887ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 77919378907SAtari911 $events = []; 78019378907SAtari911 if (file_exists($eventFile)) { 78187ac9bf3SAtari911 $contents = file_get_contents($eventFile); 78287ac9bf3SAtari911 $decoded = json_decode($contents, true); 78387ac9bf3SAtari911 if (json_last_error() === JSON_ERROR_NONE) { 78487ac9bf3SAtari911 $events = $decoded; 78587ac9bf3SAtari911 } 78687ac9bf3SAtari911 } 78787ac9bf3SAtari911 788e3a9f44cSAtari911 return $events; 78987ac9bf3SAtari911 } 790e3a9f44cSAtari911 791e3a9f44cSAtari911 private function loadEventsMultiNamespace($namespaces, $year, $month) { 792e3a9f44cSAtari911 // Check for wildcard pattern 793e3a9f44cSAtari911 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 794e3a9f44cSAtari911 $baseNamespace = $matches[1]; 795e3a9f44cSAtari911 return $this->loadEventsWildcard($baseNamespace, $year, $month); 796e3a9f44cSAtari911 } 797e3a9f44cSAtari911 798e3a9f44cSAtari911 // Check for root wildcard 799e3a9f44cSAtari911 if ($namespaces === '*') { 800e3a9f44cSAtari911 return $this->loadEventsWildcard('', $year, $month); 801e3a9f44cSAtari911 } 802e3a9f44cSAtari911 803e3a9f44cSAtari911 // Parse namespace list (semicolon separated) 804e3a9f44cSAtari911 $namespaceList = array_map('trim', explode(';', $namespaces)); 805e3a9f44cSAtari911 806e3a9f44cSAtari911 // Load events from all namespaces 807e3a9f44cSAtari911 $allEvents = []; 808e3a9f44cSAtari911 foreach ($namespaceList as $ns) { 809e3a9f44cSAtari911 $ns = trim($ns); 810e3a9f44cSAtari911 if (empty($ns)) continue; 811e3a9f44cSAtari911 812e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 813e3a9f44cSAtari911 814e3a9f44cSAtari911 // Add namespace tag to each event 815e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 816e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 817e3a9f44cSAtari911 $allEvents[$dateKey] = []; 818e3a9f44cSAtari911 } 819e3a9f44cSAtari911 foreach ($dayEvents as $event) { 820e3a9f44cSAtari911 $event['_namespace'] = $ns; 821e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 822e3a9f44cSAtari911 } 82387ac9bf3SAtari911 } 82487ac9bf3SAtari911 } 82587ac9bf3SAtari911 826e3a9f44cSAtari911 return $allEvents; 827e3a9f44cSAtari911 } 82819378907SAtari911 829e3a9f44cSAtari911 private function loadEventsWildcard($baseNamespace, $year, $month) { 830e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 831e3a9f44cSAtari911 if ($baseNamespace) { 832e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 833e3a9f44cSAtari911 } 834e3a9f44cSAtari911 835e3a9f44cSAtari911 $allEvents = []; 836e3a9f44cSAtari911 837e3a9f44cSAtari911 // First, load events from the base namespace itself 838e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 839e3a9f44cSAtari911 840e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 841e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 842e3a9f44cSAtari911 $allEvents[$dateKey] = []; 843e3a9f44cSAtari911 } 844e3a9f44cSAtari911 foreach ($dayEvents as $event) { 845e3a9f44cSAtari911 $event['_namespace'] = $baseNamespace; 846e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 847e3a9f44cSAtari911 } 848e3a9f44cSAtari911 } 849e3a9f44cSAtari911 850e3a9f44cSAtari911 // Recursively find all subdirectories 851e3a9f44cSAtari911 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 852e3a9f44cSAtari911 853e3a9f44cSAtari911 return $allEvents; 854e3a9f44cSAtari911 } 855e3a9f44cSAtari911 856e3a9f44cSAtari911 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 857e3a9f44cSAtari911 if (!is_dir($dir)) return; 858e3a9f44cSAtari911 859e3a9f44cSAtari911 $items = scandir($dir); 860e3a9f44cSAtari911 foreach ($items as $item) { 861e3a9f44cSAtari911 if ($item === '.' || $item === '..') continue; 862e3a9f44cSAtari911 863e3a9f44cSAtari911 $path = $dir . $item; 864e3a9f44cSAtari911 if (is_dir($path) && $item !== 'calendar') { 865e3a9f44cSAtari911 // This is a namespace directory 866e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 867e3a9f44cSAtari911 868e3a9f44cSAtari911 // Load events from this namespace 869e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 870e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 871e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 872e3a9f44cSAtari911 $allEvents[$dateKey] = []; 873e3a9f44cSAtari911 } 874e3a9f44cSAtari911 foreach ($dayEvents as $event) { 875e3a9f44cSAtari911 $event['_namespace'] = $namespace; 876e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 877e3a9f44cSAtari911 } 878e3a9f44cSAtari911 } 879e3a9f44cSAtari911 880e3a9f44cSAtari911 // Recurse into subdirectories 881e3a9f44cSAtari911 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 882e3a9f44cSAtari911 } 883e3a9f44cSAtari911 } 88419378907SAtari911 } 88519378907SAtari911 88696df7d3eSAtari911 /** 88796df7d3eSAtari911 * Search all dates for events matching the search term 88896df7d3eSAtari911 */ 88996df7d3eSAtari911 private function searchAllDates() { 89096df7d3eSAtari911 global $INPUT; 89196df7d3eSAtari911 89296df7d3eSAtari911 $searchTerm = strtolower(trim($INPUT->str('search', ''))); 89396df7d3eSAtari911 $namespace = $INPUT->str('namespace', ''); 89496df7d3eSAtari911 89596df7d3eSAtari911 if (strlen($searchTerm) < 2) { 89696df7d3eSAtari911 echo json_encode(['success' => false, 'error' => 'Search term too short']); 89796df7d3eSAtari911 return; 89896df7d3eSAtari911 } 89996df7d3eSAtari911 90096df7d3eSAtari911 // Normalize search term for fuzzy matching 90196df7d3eSAtari911 $normalizedSearch = $this->normalizeForSearch($searchTerm); 90296df7d3eSAtari911 90396df7d3eSAtari911 $results = []; 90496df7d3eSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 90596df7d3eSAtari911 90696df7d3eSAtari911 // Helper to search calendar directory 90796df7d3eSAtari911 $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results) { 90896df7d3eSAtari911 if (!is_dir($calDir)) return; 90996df7d3eSAtari911 91096df7d3eSAtari911 foreach (glob($calDir . '/*.json') as $file) { 91196df7d3eSAtari911 $data = @json_decode(file_get_contents($file), true); 91296df7d3eSAtari911 if (!$data || !is_array($data)) continue; 91396df7d3eSAtari911 91496df7d3eSAtari911 foreach ($data as $dateKey => $dayEvents) { 91596df7d3eSAtari911 // Skip non-date keys 91696df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 91796df7d3eSAtari911 if (!is_array($dayEvents)) continue; 91896df7d3eSAtari911 91996df7d3eSAtari911 foreach ($dayEvents as $event) { 92096df7d3eSAtari911 if (!isset($event['title'])) continue; 92196df7d3eSAtari911 92296df7d3eSAtari911 // Build searchable text 92396df7d3eSAtari911 $searchableText = strtolower($event['title']); 92496df7d3eSAtari911 if (isset($event['description'])) { 92596df7d3eSAtari911 $searchableText .= ' ' . strtolower($event['description']); 92696df7d3eSAtari911 } 92796df7d3eSAtari911 92896df7d3eSAtari911 // Normalize for fuzzy matching 92996df7d3eSAtari911 $normalizedText = $this->normalizeForSearch($searchableText); 93096df7d3eSAtari911 93196df7d3eSAtari911 // Check if matches using fuzzy match 93296df7d3eSAtari911 if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) { 93396df7d3eSAtari911 $results[] = [ 93496df7d3eSAtari911 'date' => $dateKey, 93596df7d3eSAtari911 'title' => $event['title'], 93696df7d3eSAtari911 'time' => isset($event['time']) ? $event['time'] : '', 93796df7d3eSAtari911 'endTime' => isset($event['endTime']) ? $event['endTime'] : '', 93896df7d3eSAtari911 'color' => isset($event['color']) ? $event['color'] : '', 93996df7d3eSAtari911 'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace, 94096df7d3eSAtari911 'id' => isset($event['id']) ? $event['id'] : '' 94196df7d3eSAtari911 ]; 94296df7d3eSAtari911 } 94396df7d3eSAtari911 } 94496df7d3eSAtari911 } 94596df7d3eSAtari911 } 94696df7d3eSAtari911 }; 94796df7d3eSAtari911 94896df7d3eSAtari911 // Search root calendar directory 94996df7d3eSAtari911 $searchCalendarDir($dataDir . 'calendar', ''); 95096df7d3eSAtari911 95196df7d3eSAtari911 // Search namespace directories 95296df7d3eSAtari911 $this->searchNamespaceDirs($dataDir, $searchCalendarDir); 95396df7d3eSAtari911 95496df7d3eSAtari911 // Sort results by date (newest first for past, oldest first for future) 95596df7d3eSAtari911 usort($results, function($a, $b) { 95696df7d3eSAtari911 return strcmp($a['date'], $b['date']); 95796df7d3eSAtari911 }); 95896df7d3eSAtari911 95996df7d3eSAtari911 // Limit results 96096df7d3eSAtari911 $results = array_slice($results, 0, 50); 96196df7d3eSAtari911 96296df7d3eSAtari911 echo json_encode([ 96396df7d3eSAtari911 'success' => true, 96496df7d3eSAtari911 'results' => $results, 96596df7d3eSAtari911 'total' => count($results) 96696df7d3eSAtari911 ]); 96796df7d3eSAtari911 } 96896df7d3eSAtari911 96996df7d3eSAtari911 /** 97096df7d3eSAtari911 * Check if normalized text matches normalized search term 97196df7d3eSAtari911 * Supports multi-word search where all words must be present 97296df7d3eSAtari911 */ 97396df7d3eSAtari911 private function fuzzyMatchText($normalizedText, $normalizedSearch) { 97496df7d3eSAtari911 // Direct substring match 97596df7d3eSAtari911 if (strpos($normalizedText, $normalizedSearch) !== false) { 97696df7d3eSAtari911 return true; 97796df7d3eSAtari911 } 97896df7d3eSAtari911 97996df7d3eSAtari911 // Multi-word search: all words must be present 98096df7d3eSAtari911 $searchWords = array_filter(explode(' ', $normalizedSearch)); 98196df7d3eSAtari911 if (count($searchWords) > 1) { 98296df7d3eSAtari911 foreach ($searchWords as $word) { 98396df7d3eSAtari911 if (strlen($word) > 0 && strpos($normalizedText, $word) === false) { 98496df7d3eSAtari911 return false; 98596df7d3eSAtari911 } 98696df7d3eSAtari911 } 98796df7d3eSAtari911 return true; 98896df7d3eSAtari911 } 98996df7d3eSAtari911 99096df7d3eSAtari911 return false; 99196df7d3eSAtari911 } 99296df7d3eSAtari911 99396df7d3eSAtari911 /** 99496df7d3eSAtari911 * Normalize text for fuzzy search matching 99596df7d3eSAtari911 * Removes apostrophes, extra spaces, and common variations 99696df7d3eSAtari911 */ 99796df7d3eSAtari911 private function normalizeForSearch($text) { 99896df7d3eSAtari911 // Convert to lowercase 99996df7d3eSAtari911 $text = strtolower($text); 100096df7d3eSAtari911 100196df7d3eSAtari911 // Remove apostrophes and quotes (father's -> fathers) 100296df7d3eSAtari911 $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text); 100396df7d3eSAtari911 100496df7d3eSAtari911 // Normalize dashes and underscores to spaces 100596df7d3eSAtari911 $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text); 100696df7d3eSAtari911 100796df7d3eSAtari911 // Remove other punctuation but keep letters, numbers, spaces 100896df7d3eSAtari911 $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text); 100996df7d3eSAtari911 101096df7d3eSAtari911 // Normalize multiple spaces to single space 101196df7d3eSAtari911 $text = preg_replace('/\s+/', ' ', $text); 101296df7d3eSAtari911 101396df7d3eSAtari911 // Trim 101496df7d3eSAtari911 $text = trim($text); 101596df7d3eSAtari911 101696df7d3eSAtari911 return $text; 101796df7d3eSAtari911 } 101896df7d3eSAtari911 101996df7d3eSAtari911 /** 102096df7d3eSAtari911 * Recursively search namespace directories for calendar data 102196df7d3eSAtari911 */ 102296df7d3eSAtari911 private function searchNamespaceDirs($baseDir, $callback) { 102396df7d3eSAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 102496df7d3eSAtari911 $name = basename($nsDir); 102596df7d3eSAtari911 if ($name === 'calendar') continue; 102696df7d3eSAtari911 102796df7d3eSAtari911 $calDir = $nsDir . '/calendar'; 102896df7d3eSAtari911 if (is_dir($calDir)) { 102996df7d3eSAtari911 $relPath = str_replace(DOKU_INC . 'data/meta/', '', $nsDir); 103096df7d3eSAtari911 $namespace = str_replace('/', ':', $relPath); 103196df7d3eSAtari911 $callback($calDir, $namespace); 103296df7d3eSAtari911 } 103396df7d3eSAtari911 103496df7d3eSAtari911 // Recurse 103596df7d3eSAtari911 $this->searchNamespaceDirs($nsDir . '/', $callback); 103696df7d3eSAtari911 } 103796df7d3eSAtari911 } 103896df7d3eSAtari911 103919378907SAtari911 private function toggleTaskComplete() { 104019378907SAtari911 global $INPUT; 104119378907SAtari911 104219378907SAtari911 $namespace = $INPUT->str('namespace', ''); 104319378907SAtari911 $date = $INPUT->str('date'); 104419378907SAtari911 $eventId = $INPUT->str('eventId'); 104519378907SAtari911 $completed = $INPUT->bool('completed', false); 104619378907SAtari911 1047e3a9f44cSAtari911 // Find where the event actually lives 1048e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 1049e3a9f44cSAtari911 1050e3a9f44cSAtari911 if ($storedNamespace === null) { 1051e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 1052e3a9f44cSAtari911 return; 1053e3a9f44cSAtari911 } 1054e3a9f44cSAtari911 1055e3a9f44cSAtari911 // Use the found namespace 1056e3a9f44cSAtari911 $namespace = $storedNamespace; 1057e3a9f44cSAtari911 105819378907SAtari911 list($year, $month, $day) = explode('-', $date); 105919378907SAtari911 106019378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 106119378907SAtari911 if ($namespace) { 106219378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 106319378907SAtari911 } 106419378907SAtari911 $dataDir .= 'calendar/'; 106519378907SAtari911 106619378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 106719378907SAtari911 106819378907SAtari911 if (file_exists($eventFile)) { 106919378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 107019378907SAtari911 107119378907SAtari911 if (isset($events[$date])) { 107219378907SAtari911 foreach ($events[$date] as $key => $event) { 107319378907SAtari911 if ($event['id'] === $eventId) { 107419378907SAtari911 $events[$date][$key]['completed'] = $completed; 107519378907SAtari911 break; 107619378907SAtari911 } 107719378907SAtari911 } 107819378907SAtari911 107919378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 108019378907SAtari911 echo json_encode(['success' => true, 'events' => $events]); 108119378907SAtari911 return; 108219378907SAtari911 } 108319378907SAtari911 } 108419378907SAtari911 108519378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 108619378907SAtari911 } 108719378907SAtari911 108896df7d3eSAtari911 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime, 108996df7d3eSAtari911 $description, $color, $isTask, $recurrenceType, $recurrenceInterval, 109096df7d3eSAtari911 $recurrenceEnd, $weekDays, $monthlyType, $monthDay, 109196df7d3eSAtari911 $ordinalWeek, $ordinalDay, $baseId) { 109287ac9bf3SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 109387ac9bf3SAtari911 if ($namespace) { 109487ac9bf3SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 109587ac9bf3SAtari911 } 109687ac9bf3SAtari911 $dataDir .= 'calendar/'; 109787ac9bf3SAtari911 109887ac9bf3SAtari911 if (!is_dir($dataDir)) { 109987ac9bf3SAtari911 mkdir($dataDir, 0755, true); 110087ac9bf3SAtari911 } 110187ac9bf3SAtari911 110296df7d3eSAtari911 // Ensure interval is at least 1 110396df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 110487ac9bf3SAtari911 110587ac9bf3SAtari911 // Set maximum end date if not specified (1 year from start) 110687ac9bf3SAtari911 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 110787ac9bf3SAtari911 110887ac9bf3SAtari911 // Calculate event duration for multi-day events 110987ac9bf3SAtari911 $eventDuration = 0; 111087ac9bf3SAtari911 if ($endDate && $endDate !== $startDate) { 111187ac9bf3SAtari911 $start = new DateTime($startDate); 111287ac9bf3SAtari911 $end = new DateTime($endDate); 111387ac9bf3SAtari911 $eventDuration = $start->diff($end)->days; 111487ac9bf3SAtari911 } 111587ac9bf3SAtari911 111687ac9bf3SAtari911 // Generate recurring events 111787ac9bf3SAtari911 $currentDate = new DateTime($startDate); 111887ac9bf3SAtari911 $endLimit = new DateTime($maxEnd); 111987ac9bf3SAtari911 $counter = 0; 112096df7d3eSAtari911 $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year) 112196df7d3eSAtari911 112296df7d3eSAtari911 // For weekly with specific days, we need to track the interval counter differently 112396df7d3eSAtari911 $weekCounter = 0; 112496df7d3eSAtari911 $startWeekNumber = (int)$currentDate->format('W'); 112596df7d3eSAtari911 $startYear = (int)$currentDate->format('Y'); 112687ac9bf3SAtari911 112787ac9bf3SAtari911 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 112896df7d3eSAtari911 $shouldCreateEvent = false; 112996df7d3eSAtari911 113096df7d3eSAtari911 switch ($recurrenceType) { 113196df7d3eSAtari911 case 'daily': 113296df7d3eSAtari911 // Every N days from start 113396df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 113496df7d3eSAtari911 $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0); 113596df7d3eSAtari911 break; 113696df7d3eSAtari911 113796df7d3eSAtari911 case 'weekly': 113896df7d3eSAtari911 // Every N weeks, on specified days 113996df7d3eSAtari911 $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat 114096df7d3eSAtari911 114196df7d3eSAtari911 // Calculate weeks since start 114296df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 114396df7d3eSAtari911 $weeksSinceStart = floor($daysSinceStart / 7); 114496df7d3eSAtari911 114596df7d3eSAtari911 // Check if we're in the right week (every N weeks) 114696df7d3eSAtari911 $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0); 114796df7d3eSAtari911 114896df7d3eSAtari911 // Check if this day is selected 114996df7d3eSAtari911 $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays); 115096df7d3eSAtari911 115196df7d3eSAtari911 // For the first week, only include days on or after the start date 115296df7d3eSAtari911 $isOnOrAfterStart = ($currentDate >= new DateTime($startDate)); 115396df7d3eSAtari911 115496df7d3eSAtari911 $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart; 115596df7d3eSAtari911 break; 115696df7d3eSAtari911 115796df7d3eSAtari911 case 'monthly': 115896df7d3eSAtari911 // Calculate months since start 115996df7d3eSAtari911 $startDT = new DateTime($startDate); 116096df7d3eSAtari911 $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) + 116196df7d3eSAtari911 ($currentDate->format('n') - $startDT->format('n')); 116296df7d3eSAtari911 116396df7d3eSAtari911 // Check if we're in the right month (every N months) 116496df7d3eSAtari911 $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0); 116596df7d3eSAtari911 116696df7d3eSAtari911 if (!$isCorrectMonth) { 116796df7d3eSAtari911 // Skip to first day of next potential month 116896df7d3eSAtari911 $currentDate->modify('first day of next month'); 116996df7d3eSAtari911 continue 2; 117096df7d3eSAtari911 } 117196df7d3eSAtari911 117296df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 117396df7d3eSAtari911 // Specific day of month (e.g., 15th) 117496df7d3eSAtari911 $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j'); 117596df7d3eSAtari911 $currentDay = (int)$currentDate->format('j'); 117696df7d3eSAtari911 $daysInMonth = (int)$currentDate->format('t'); 117796df7d3eSAtari911 117896df7d3eSAtari911 // If target day exceeds days in month, use last day 117996df7d3eSAtari911 $effectiveTargetDay = min($targetDay, $daysInMonth); 118096df7d3eSAtari911 $shouldCreateEvent = ($currentDay === $effectiveTargetDay); 118196df7d3eSAtari911 } else { 118296df7d3eSAtari911 // Ordinal weekday (e.g., 2nd Wednesday, last Friday) 118396df7d3eSAtari911 $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay); 118496df7d3eSAtari911 } 118596df7d3eSAtari911 break; 118696df7d3eSAtari911 118796df7d3eSAtari911 case 'yearly': 118896df7d3eSAtari911 // Every N years on same month/day 118996df7d3eSAtari911 $startDT = new DateTime($startDate); 119096df7d3eSAtari911 $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y'); 119196df7d3eSAtari911 119296df7d3eSAtari911 // Check if we're in the right year 119396df7d3eSAtari911 $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0); 119496df7d3eSAtari911 119596df7d3eSAtari911 // Check if it's the same month and day 119696df7d3eSAtari911 $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d')); 119796df7d3eSAtari911 119896df7d3eSAtari911 $shouldCreateEvent = $isCorrectYear && $sameMonthDay; 119996df7d3eSAtari911 break; 120096df7d3eSAtari911 120196df7d3eSAtari911 default: 120296df7d3eSAtari911 $shouldCreateEvent = false; 120396df7d3eSAtari911 } 120496df7d3eSAtari911 120596df7d3eSAtari911 if ($shouldCreateEvent) { 120687ac9bf3SAtari911 $dateKey = $currentDate->format('Y-m-d'); 120787ac9bf3SAtari911 list($year, $month, $day) = explode('-', $dateKey); 120887ac9bf3SAtari911 120987ac9bf3SAtari911 // Calculate end date for this occurrence if multi-day 121087ac9bf3SAtari911 $occurrenceEndDate = ''; 121187ac9bf3SAtari911 if ($eventDuration > 0) { 121287ac9bf3SAtari911 $occurrenceEnd = clone $currentDate; 121387ac9bf3SAtari911 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 121487ac9bf3SAtari911 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 121587ac9bf3SAtari911 } 121687ac9bf3SAtari911 121787ac9bf3SAtari911 // Load month file 121887ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 121987ac9bf3SAtari911 $events = []; 122087ac9bf3SAtari911 if (file_exists($eventFile)) { 122187ac9bf3SAtari911 $events = json_decode(file_get_contents($eventFile), true); 122296df7d3eSAtari911 if (!is_array($events)) $events = []; 122387ac9bf3SAtari911 } 122487ac9bf3SAtari911 122587ac9bf3SAtari911 if (!isset($events[$dateKey])) { 122687ac9bf3SAtari911 $events[$dateKey] = []; 122787ac9bf3SAtari911 } 122887ac9bf3SAtari911 122987ac9bf3SAtari911 // Create event for this occurrence 123087ac9bf3SAtari911 $eventData = [ 123187ac9bf3SAtari911 'id' => $baseId . '-' . $counter, 123287ac9bf3SAtari911 'title' => $title, 123387ac9bf3SAtari911 'time' => $time, 12341d05cddcSAtari911 'endTime' => $endTime, 123587ac9bf3SAtari911 'description' => $description, 123687ac9bf3SAtari911 'color' => $color, 123787ac9bf3SAtari911 'isTask' => $isTask, 123887ac9bf3SAtari911 'completed' => false, 123987ac9bf3SAtari911 'endDate' => $occurrenceEndDate, 124087ac9bf3SAtari911 'recurring' => true, 124187ac9bf3SAtari911 'recurringId' => $baseId, 124296df7d3eSAtari911 'recurrenceType' => $recurrenceType, 124396df7d3eSAtari911 'recurrenceInterval' => $recurrenceInterval, 124496df7d3eSAtari911 'namespace' => $namespace, 124587ac9bf3SAtari911 'created' => date('Y-m-d H:i:s') 124687ac9bf3SAtari911 ]; 124787ac9bf3SAtari911 124896df7d3eSAtari911 // Store additional recurrence info for reference 124996df7d3eSAtari911 if ($recurrenceType === 'weekly' && !empty($weekDays)) { 125096df7d3eSAtari911 $eventData['weekDays'] = $weekDays; 125196df7d3eSAtari911 } 125296df7d3eSAtari911 if ($recurrenceType === 'monthly') { 125396df7d3eSAtari911 $eventData['monthlyType'] = $monthlyType; 125496df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 125596df7d3eSAtari911 $eventData['monthDay'] = $monthDay; 125696df7d3eSAtari911 } else { 125796df7d3eSAtari911 $eventData['ordinalWeek'] = $ordinalWeek; 125896df7d3eSAtari911 $eventData['ordinalDay'] = $ordinalDay; 125996df7d3eSAtari911 } 126096df7d3eSAtari911 } 126196df7d3eSAtari911 126287ac9bf3SAtari911 $events[$dateKey][] = $eventData; 126387ac9bf3SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 126487ac9bf3SAtari911 126587ac9bf3SAtari911 $counter++; 126687ac9bf3SAtari911 } 126796df7d3eSAtari911 126896df7d3eSAtari911 // Move to next day (we check each day individually for complex patterns) 126996df7d3eSAtari911 $currentDate->modify('+1 day'); 127096df7d3eSAtari911 } 127196df7d3eSAtari911 } 127296df7d3eSAtari911 127396df7d3eSAtari911 /** 127496df7d3eSAtari911 * Check if a date is the Nth occurrence of a weekday in its month 127596df7d3eSAtari911 * @param DateTime $date The date to check 127696df7d3eSAtari911 * @param int $ordinalWeek 1-5 for first-fifth, -1 for last 127796df7d3eSAtari911 * @param int $targetDayOfWeek 0=Sunday through 6=Saturday 127896df7d3eSAtari911 * @return bool 127996df7d3eSAtari911 */ 128096df7d3eSAtari911 private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) { 128196df7d3eSAtari911 $currentDayOfWeek = (int)$date->format('w'); 128296df7d3eSAtari911 128396df7d3eSAtari911 // First, check if it's the right day of week 128496df7d3eSAtari911 if ($currentDayOfWeek !== $targetDayOfWeek) { 128596df7d3eSAtari911 return false; 128696df7d3eSAtari911 } 128796df7d3eSAtari911 128896df7d3eSAtari911 $dayOfMonth = (int)$date->format('j'); 128996df7d3eSAtari911 $daysInMonth = (int)$date->format('t'); 129096df7d3eSAtari911 129196df7d3eSAtari911 if ($ordinalWeek === -1) { 129296df7d3eSAtari911 // Last occurrence: check if there's no more of this weekday in the month 129396df7d3eSAtari911 $daysRemaining = $daysInMonth - $dayOfMonth; 129496df7d3eSAtari911 return $daysRemaining < 7; 129596df7d3eSAtari911 } else { 129696df7d3eSAtari911 // Nth occurrence: check which occurrence this is 129796df7d3eSAtari911 $weekNumber = ceil($dayOfMonth / 7); 129896df7d3eSAtari911 return $weekNumber === $ordinalWeek; 129996df7d3eSAtari911 } 130087ac9bf3SAtari911 } 130187ac9bf3SAtari911 130219378907SAtari911 public function addAssets(Doku_Event $event, $param) { 130319378907SAtari911 $event->data['link'][] = array( 130419378907SAtari911 'type' => 'text/css', 130519378907SAtari911 'rel' => 'stylesheet', 130619378907SAtari911 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 130719378907SAtari911 ); 130819378907SAtari911 130996df7d3eSAtari911 // Load the main calendar JavaScript 131096df7d3eSAtari911 // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues 131196df7d3eSAtari911 // The actual code is in calendar-main.js 131219378907SAtari911 $event->data['script'][] = array( 131319378907SAtari911 'type' => 'text/javascript', 131496df7d3eSAtari911 'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js' 131519378907SAtari911 ); 131619378907SAtari911 } 1317e3a9f44cSAtari911 // Helper function to find an event's stored namespace 1318e3a9f44cSAtari911 private function findEventNamespace($eventId, $date, $searchNamespace) { 1319e3a9f44cSAtari911 list($year, $month, $day) = explode('-', $date); 1320e3a9f44cSAtari911 1321e3a9f44cSAtari911 // List of namespaces to check 1322e3a9f44cSAtari911 $namespacesToCheck = ['']; 1323e3a9f44cSAtari911 1324e3a9f44cSAtari911 // If searchNamespace is a wildcard or multi, we need to search multiple locations 1325e3a9f44cSAtari911 if (!empty($searchNamespace)) { 1326e3a9f44cSAtari911 if (strpos($searchNamespace, ';') !== false) { 1327e3a9f44cSAtari911 // Multi-namespace - check each one 1328e3a9f44cSAtari911 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 1329e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1330e3a9f44cSAtari911 } elseif (strpos($searchNamespace, '*') !== false) { 1331e3a9f44cSAtari911 // Wildcard - need to scan directories 1332e3a9f44cSAtari911 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 1333e3a9f44cSAtari911 $namespacesToCheck = $this->findAllNamespaces($baseNs); 1334e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1335e3a9f44cSAtari911 } else { 1336e3a9f44cSAtari911 // Single namespace 1337e3a9f44cSAtari911 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 1338e3a9f44cSAtari911 } 1339e3a9f44cSAtari911 } 1340e3a9f44cSAtari911 134196df7d3eSAtari911 $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck))); 134296df7d3eSAtari911 1343e3a9f44cSAtari911 // Search for the event in all possible namespaces 1344e3a9f44cSAtari911 foreach ($namespacesToCheck as $ns) { 1345e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 1346e3a9f44cSAtari911 if ($ns) { 1347e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $ns) . '/'; 1348e3a9f44cSAtari911 } 1349e3a9f44cSAtari911 $dataDir .= 'calendar/'; 1350e3a9f44cSAtari911 1351e3a9f44cSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1352e3a9f44cSAtari911 1353e3a9f44cSAtari911 if (file_exists($eventFile)) { 1354e3a9f44cSAtari911 $events = json_decode(file_get_contents($eventFile), true); 1355e3a9f44cSAtari911 if (isset($events[$date])) { 1356e3a9f44cSAtari911 foreach ($events[$date] as $evt) { 1357e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 135896df7d3eSAtari911 // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace 135996df7d3eSAtari911 // The directory is what matters for deletion - that's where the file actually is 136096df7d3eSAtari911 $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')"); 136196df7d3eSAtari911 return $ns; 1362e3a9f44cSAtari911 } 1363e3a9f44cSAtari911 } 1364e3a9f44cSAtari911 } 1365e3a9f44cSAtari911 } 1366e3a9f44cSAtari911 } 1367e3a9f44cSAtari911 136896df7d3eSAtari911 $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace"); 1369e3a9f44cSAtari911 return null; // Event not found 1370e3a9f44cSAtari911 } 1371e3a9f44cSAtari911 1372e3a9f44cSAtari911 // Helper to find all namespaces under a base namespace 1373e3a9f44cSAtari911 private function findAllNamespaces($baseNamespace) { 1374e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 1375e3a9f44cSAtari911 if ($baseNamespace) { 1376e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 1377e3a9f44cSAtari911 } 1378e3a9f44cSAtari911 1379e3a9f44cSAtari911 $namespaces = []; 1380e3a9f44cSAtari911 if ($baseNamespace) { 1381e3a9f44cSAtari911 $namespaces[] = $baseNamespace; 1382e3a9f44cSAtari911 } 1383e3a9f44cSAtari911 1384e3a9f44cSAtari911 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 1385e3a9f44cSAtari911 138696df7d3eSAtari911 $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces))); 138796df7d3eSAtari911 1388e3a9f44cSAtari911 return $namespaces; 1389e3a9f44cSAtari911 } 1390e3a9f44cSAtari911 1391e3a9f44cSAtari911 // Recursive scan for namespaces 1392e3a9f44cSAtari911 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 1393e3a9f44cSAtari911 if (!is_dir($dir)) return; 1394e3a9f44cSAtari911 1395e3a9f44cSAtari911 $items = scandir($dir); 1396e3a9f44cSAtari911 foreach ($items as $item) { 1397e3a9f44cSAtari911 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 1398e3a9f44cSAtari911 1399e3a9f44cSAtari911 $path = $dir . $item; 1400e3a9f44cSAtari911 if (is_dir($path)) { 1401e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1402e3a9f44cSAtari911 $namespaces[] = $namespace; 1403e3a9f44cSAtari911 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 1404e3a9f44cSAtari911 } 1405e3a9f44cSAtari911 } 1406e3a9f44cSAtari911 } 14079ccd446eSAtari911 14089ccd446eSAtari911 /** 14099ccd446eSAtari911 * Delete all instances of a recurring event across all months 14109ccd446eSAtari911 */ 14119ccd446eSAtari911 private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) { 14129ccd446eSAtari911 // Scan all JSON files in the calendar directory 14139ccd446eSAtari911 $calendarFiles = glob($dataDir . '*.json'); 14149ccd446eSAtari911 14159ccd446eSAtari911 foreach ($calendarFiles as $file) { 14169ccd446eSAtari911 $modified = false; 14179ccd446eSAtari911 $events = json_decode(file_get_contents($file), true); 14189ccd446eSAtari911 14199ccd446eSAtari911 if (!$events) continue; 14209ccd446eSAtari911 14219ccd446eSAtari911 // Check each date in the file 14229ccd446eSAtari911 foreach ($events as $date => &$dayEvents) { 14239ccd446eSAtari911 // Filter out events with matching recurringId 14249ccd446eSAtari911 $originalCount = count($dayEvents); 14259ccd446eSAtari911 $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) { 14269ccd446eSAtari911 $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 14279ccd446eSAtari911 return $eventRecurringId !== $recurringId; 14289ccd446eSAtari911 })); 14299ccd446eSAtari911 14309ccd446eSAtari911 if (count($dayEvents) !== $originalCount) { 14319ccd446eSAtari911 $modified = true; 14329ccd446eSAtari911 } 14339ccd446eSAtari911 14349ccd446eSAtari911 // Remove empty dates 14359ccd446eSAtari911 if (empty($dayEvents)) { 14369ccd446eSAtari911 unset($events[$date]); 14379ccd446eSAtari911 } 14389ccd446eSAtari911 } 14399ccd446eSAtari911 14409ccd446eSAtari911 // Save if modified 14419ccd446eSAtari911 if ($modified) { 14429ccd446eSAtari911 file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT)); 14439ccd446eSAtari911 } 14449ccd446eSAtari911 } 14459ccd446eSAtari911 } 14469ccd446eSAtari911 14479ccd446eSAtari911 /** 14489ccd446eSAtari911 * Get existing event data for preserving unchanged fields during edit 14499ccd446eSAtari911 */ 14509ccd446eSAtari911 private function getExistingEventData($eventId, $date, $namespace) { 14519ccd446eSAtari911 list($year, $month, $day) = explode('-', $date); 14529ccd446eSAtari911 14539ccd446eSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 14549ccd446eSAtari911 if ($namespace) { 14559ccd446eSAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 14569ccd446eSAtari911 } 14579ccd446eSAtari911 $dataDir .= 'calendar/'; 14589ccd446eSAtari911 14599ccd446eSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 14609ccd446eSAtari911 14619ccd446eSAtari911 if (!file_exists($eventFile)) { 14629ccd446eSAtari911 return null; 14639ccd446eSAtari911 } 14649ccd446eSAtari911 14659ccd446eSAtari911 $events = json_decode(file_get_contents($eventFile), true); 14669ccd446eSAtari911 14679ccd446eSAtari911 if (!isset($events[$date])) { 14689ccd446eSAtari911 return null; 14699ccd446eSAtari911 } 14709ccd446eSAtari911 14719ccd446eSAtari911 // Find the event by ID 14729ccd446eSAtari911 foreach ($events[$date] as $event) { 14739ccd446eSAtari911 if ($event['id'] === $eventId) { 14749ccd446eSAtari911 return $event; 14759ccd446eSAtari911 } 14769ccd446eSAtari911 } 14779ccd446eSAtari911 14789ccd446eSAtari911 return null; 14799ccd446eSAtari911 } 148019378907SAtari911} 1481