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 11*7e8ea635SAtari911// Set to true to enable verbose debug logging (should be false in production) 12*7e8ea635SAtari911if (!defined('CALENDAR_DEBUG')) { 13*7e8ea635SAtari911 define('CALENDAR_DEBUG', false); 14*7e8ea635SAtari911} 15*7e8ea635SAtari911 1619378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin { 1719378907SAtari911 18*7e8ea635SAtari911 /** 19*7e8ea635SAtari911 * Log debug message only if CALENDAR_DEBUG is enabled 20*7e8ea635SAtari911 */ 21*7e8ea635SAtari911 private function debugLog($message) { 22*7e8ea635SAtari911 if (CALENDAR_DEBUG) { 23*7e8ea635SAtari911 error_log($message); 24*7e8ea635SAtari911 } 25*7e8ea635SAtari911 } 26*7e8ea635SAtari911 27*7e8ea635SAtari911 /** 28*7e8ea635SAtari911 * Safely read and decode a JSON file with error handling 29*7e8ea635SAtari911 * @param string $filepath Path to JSON file 30*7e8ea635SAtari911 * @return array Decoded array or empty array on error 31*7e8ea635SAtari911 */ 32*7e8ea635SAtari911 private function safeJsonRead($filepath) { 33*7e8ea635SAtari911 if (!file_exists($filepath)) { 34*7e8ea635SAtari911 return []; 35*7e8ea635SAtari911 } 36*7e8ea635SAtari911 37*7e8ea635SAtari911 $contents = @file_get_contents($filepath); 38*7e8ea635SAtari911 if ($contents === false) { 39*7e8ea635SAtari911 $this->debugLog("Failed to read file: $filepath"); 40*7e8ea635SAtari911 return []; 41*7e8ea635SAtari911 } 42*7e8ea635SAtari911 43*7e8ea635SAtari911 $decoded = json_decode($contents, true); 44*7e8ea635SAtari911 if (json_last_error() !== JSON_ERROR_NONE) { 45*7e8ea635SAtari911 $this->debugLog("JSON decode error in $filepath: " . json_last_error_msg()); 46*7e8ea635SAtari911 return []; 47*7e8ea635SAtari911 } 48*7e8ea635SAtari911 49*7e8ea635SAtari911 return is_array($decoded) ? $decoded : []; 50*7e8ea635SAtari911 } 51*7e8ea635SAtari911 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*7e8ea635SAtari911 // Actions that modify data require CSRF token verification 65*7e8ea635SAtari911 $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces', 66*7e8ea635SAtari911 'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring', 67*7e8ea635SAtari911 'trim_recurring', 'pause_recurring', 'resume_recurring', 68*7e8ea635SAtari911 'change_start_recurring', 'change_pattern_recurring']; 69*7e8ea635SAtari911 70*7e8ea635SAtari911 if (in_array($action, $writeActions)) { 71*7e8ea635SAtari911 // Check for valid security token 72*7e8ea635SAtari911 $sectok = $_REQUEST['sectok'] ?? ''; 73*7e8ea635SAtari911 if (!checkSecurityToken($sectok)) { 74*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']); 75*7e8ea635SAtari911 return; 76*7e8ea635SAtari911 } 77*7e8ea635SAtari911 } 78*7e8ea635SAtari911 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; 9219378907SAtari911 case 'toggle_task': 9319378907SAtari911 $this->toggleTaskComplete(); 9419378907SAtari911 break; 95*7e8ea635SAtari911 case 'cleanup_empty_namespaces': 96*7e8ea635SAtari911 case 'trim_all_past_recurring': 97*7e8ea635SAtari911 case 'rescan_recurring': 98*7e8ea635SAtari911 case 'extend_recurring': 99*7e8ea635SAtari911 case 'trim_recurring': 100*7e8ea635SAtari911 case 'pause_recurring': 101*7e8ea635SAtari911 case 'resume_recurring': 102*7e8ea635SAtari911 case 'change_start_recurring': 103*7e8ea635SAtari911 case 'change_pattern_recurring': 104*7e8ea635SAtari911 $this->routeToAdmin($action); 105*7e8ea635SAtari911 break; 10619378907SAtari911 default: 10719378907SAtari911 echo json_encode(['success' => false, 'error' => 'Unknown action']); 10819378907SAtari911 } 10919378907SAtari911 } 11019378907SAtari911 111*7e8ea635SAtari911 /** 112*7e8ea635SAtari911 * Route AJAX actions to admin plugin methods 113*7e8ea635SAtari911 */ 114*7e8ea635SAtari911 private function routeToAdmin($action) { 115*7e8ea635SAtari911 $admin = plugin_load('admin', 'calendar'); 116*7e8ea635SAtari911 if ($admin && method_exists($admin, 'handleAjaxAction')) { 117*7e8ea635SAtari911 $admin->handleAjaxAction($action); 118*7e8ea635SAtari911 } else { 119*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Admin handler not available']); 120*7e8ea635SAtari911 } 121*7e8ea635SAtari911 } 122*7e8ea635SAtari911 12319378907SAtari911 private function saveEvent() { 12419378907SAtari911 global $INPUT; 12519378907SAtari911 12619378907SAtari911 $namespace = $INPUT->str('namespace', ''); 12719378907SAtari911 $date = $INPUT->str('date'); 12819378907SAtari911 $eventId = $INPUT->str('eventId', ''); 12919378907SAtari911 $title = $INPUT->str('title'); 13019378907SAtari911 $time = $INPUT->str('time', ''); 1311d05cddcSAtari911 $endTime = $INPUT->str('endTime', ''); 13219378907SAtari911 $description = $INPUT->str('description', ''); 13319378907SAtari911 $color = $INPUT->str('color', '#3498db'); 13419378907SAtari911 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 13519378907SAtari911 $isTask = $INPUT->bool('isTask', false); 13619378907SAtari911 $completed = $INPUT->bool('completed', false); 13719378907SAtari911 $endDate = $INPUT->str('endDate', ''); 13887ac9bf3SAtari911 $isRecurring = $INPUT->bool('isRecurring', false); 13987ac9bf3SAtari911 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 14087ac9bf3SAtari911 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 14119378907SAtari911 14219378907SAtari911 if (!$date || !$title) { 14319378907SAtari911 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 14419378907SAtari911 return; 14519378907SAtari911 } 14619378907SAtari911 147*7e8ea635SAtari911 // Validate date format (YYYY-MM-DD) 148*7e8ea635SAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { 149*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid date format']); 150*7e8ea635SAtari911 return; 151*7e8ea635SAtari911 } 152*7e8ea635SAtari911 153*7e8ea635SAtari911 // Validate oldDate if provided 154*7e8ea635SAtari911 if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) { 155*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid old date format']); 156*7e8ea635SAtari911 return; 157*7e8ea635SAtari911 } 158*7e8ea635SAtari911 159*7e8ea635SAtari911 // Validate endDate if provided 160*7e8ea635SAtari911 if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) { 161*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end date format']); 162*7e8ea635SAtari911 return; 163*7e8ea635SAtari911 } 164*7e8ea635SAtari911 165*7e8ea635SAtari911 // Validate time format (HH:MM) if provided 166*7e8ea635SAtari911 if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) { 167*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid time format']); 168*7e8ea635SAtari911 return; 169*7e8ea635SAtari911 } 170*7e8ea635SAtari911 171*7e8ea635SAtari911 // Validate endTime format if provided 172*7e8ea635SAtari911 if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) { 173*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end time format']); 174*7e8ea635SAtari911 return; 175*7e8ea635SAtari911 } 176*7e8ea635SAtari911 177*7e8ea635SAtari911 // Validate color format (hex color) 178*7e8ea635SAtari911 if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) { 179*7e8ea635SAtari911 $color = '#3498db'; // Reset to default if invalid 180*7e8ea635SAtari911 } 181*7e8ea635SAtari911 182*7e8ea635SAtari911 // Validate namespace (prevent path traversal) 183*7e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 184*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 185*7e8ea635SAtari911 return; 186*7e8ea635SAtari911 } 187*7e8ea635SAtari911 188*7e8ea635SAtari911 // Validate recurrence type 189*7e8ea635SAtari911 $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly']; 190*7e8ea635SAtari911 if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) { 191*7e8ea635SAtari911 $recurrenceType = 'weekly'; 192*7e8ea635SAtari911 } 193*7e8ea635SAtari911 194*7e8ea635SAtari911 // Validate recurrenceEnd if provided 195*7e8ea635SAtari911 if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) { 196*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']); 197*7e8ea635SAtari911 return; 198*7e8ea635SAtari911 } 199*7e8ea635SAtari911 200*7e8ea635SAtari911 // Sanitize title length 201*7e8ea635SAtari911 $title = substr(trim($title), 0, 500); 202*7e8ea635SAtari911 203*7e8ea635SAtari911 // Sanitize description length 204*7e8ea635SAtari911 $description = substr($description, 0, 10000); 205*7e8ea635SAtari911 2061d05cddcSAtari911 // If editing, find the event's stored namespace (for finding/deleting old event) 207e3a9f44cSAtari911 $storedNamespace = ''; 2081d05cddcSAtari911 $oldNamespace = ''; 209e3a9f44cSAtari911 if ($eventId) { 2101d05cddcSAtari911 // Use oldDate if available (date was changed), otherwise use current date 2111d05cddcSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 2121d05cddcSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $searchDate, $namespace); 2131d05cddcSAtari911 2141d05cddcSAtari911 // Store the old namespace for deletion purposes 2151d05cddcSAtari911 if ($storedNamespace !== null) { 2161d05cddcSAtari911 $oldNamespace = $storedNamespace; 217*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'"); 2181d05cddcSAtari911 } 219e3a9f44cSAtari911 } 220e3a9f44cSAtari911 2211d05cddcSAtari911 // Use the namespace provided by the user (allow namespace changes!) 2221d05cddcSAtari911 // But normalize wildcards and multi-namespace to empty for NEW events 2231d05cddcSAtari911 if (!$eventId) { 224*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'"); 225e3a9f44cSAtari911 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 226e3a9f44cSAtari911 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 227*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty"); 228e3a9f44cSAtari911 $namespace = ''; 2291d05cddcSAtari911 } else { 230*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'"); 231e3a9f44cSAtari911 } 2321d05cddcSAtari911 } else { 233*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'"); 234e3a9f44cSAtari911 } 235e3a9f44cSAtari911 23687ac9bf3SAtari911 // Generate event ID if new 23787ac9bf3SAtari911 $generatedId = $eventId ?: uniqid(); 23887ac9bf3SAtari911 2399ccd446eSAtari911 // If editing a recurring event, load existing data to preserve unchanged fields 2409ccd446eSAtari911 $existingEventData = null; 2419ccd446eSAtari911 if ($eventId && $isRecurring) { 2429ccd446eSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 2439ccd446eSAtari911 $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?: $namespace); 2449ccd446eSAtari911 if ($existingEventData) { 245*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'"); 2469ccd446eSAtari911 } 2479ccd446eSAtari911 } 2489ccd446eSAtari911 24987ac9bf3SAtari911 // If recurring, generate multiple events 25087ac9bf3SAtari911 if ($isRecurring) { 2519ccd446eSAtari911 // Merge with existing data if editing (preserve values that weren't changed) 2529ccd446eSAtari911 if ($existingEventData) { 2539ccd446eSAtari911 $title = $title ?: $existingEventData['title']; 2549ccd446eSAtari911 $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : ''); 2559ccd446eSAtari911 $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : ''); 2569ccd446eSAtari911 $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : ''); 2579ccd446eSAtari911 // Only use existing color if new color is default 2589ccd446eSAtari911 if ($color === '#3498db' && isset($existingEventData['color'])) { 2599ccd446eSAtari911 $color = $existingEventData['color']; 2609ccd446eSAtari911 } 2619ccd446eSAtari911 2629ccd446eSAtari911 // Preserve namespace in these cases: 2639ccd446eSAtari911 // 1. Namespace field is empty (user didn't select anything) 2649ccd446eSAtari911 // 2. Namespace contains wildcards (like "personal;work" or "work*") 2659ccd446eSAtari911 // 3. Namespace is the same as what was passed (no change intended) 2669ccd446eSAtari911 $receivedNamespace = $namespace; 2679ccd446eSAtari911 if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 2689ccd446eSAtari911 if (isset($existingEventData['namespace'])) { 2699ccd446eSAtari911 $namespace = $existingEventData['namespace']; 270*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')"); 2719ccd446eSAtari911 } else { 272*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')"); 2739ccd446eSAtari911 } 2749ccd446eSAtari911 } else { 275*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')"); 2769ccd446eSAtari911 } 2779ccd446eSAtari911 } else { 278*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'"); 2799ccd446eSAtari911 } 2809ccd446eSAtari911 28187ac9bf3SAtari911 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $description, 28287ac9bf3SAtari911 $color, $isTask, $recurrenceType, $recurrenceEnd, $generatedId); 28387ac9bf3SAtari911 echo json_encode(['success' => true]); 28487ac9bf3SAtari911 return; 28587ac9bf3SAtari911 } 28687ac9bf3SAtari911 28719378907SAtari911 list($year, $month, $day) = explode('-', $date); 28819378907SAtari911 2891d05cddcSAtari911 // NEW namespace directory (where we'll save) 29019378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 29119378907SAtari911 if ($namespace) { 29219378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 29319378907SAtari911 } 29419378907SAtari911 $dataDir .= 'calendar/'; 29519378907SAtari911 29619378907SAtari911 if (!is_dir($dataDir)) { 29719378907SAtari911 mkdir($dataDir, 0755, true); 29819378907SAtari911 } 29919378907SAtari911 30019378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 30119378907SAtari911 30219378907SAtari911 $events = []; 30319378907SAtari911 if (file_exists($eventFile)) { 30419378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 30519378907SAtari911 } 30619378907SAtari911 3071d05cddcSAtari911 // If editing and (date changed OR namespace changed), remove from old location first 3081d05cddcSAtari911 $namespaceChanged = ($eventId && $oldNamespace !== '' && $oldNamespace !== $namespace); 3091d05cddcSAtari911 $dateChanged = ($eventId && $oldDate && $oldDate !== $date); 3101d05cddcSAtari911 3111d05cddcSAtari911 if ($namespaceChanged || $dateChanged) { 3121d05cddcSAtari911 // Construct OLD data directory using OLD namespace 3131d05cddcSAtari911 $oldDataDir = DOKU_INC . 'data/meta/'; 3141d05cddcSAtari911 if ($oldNamespace) { 3151d05cddcSAtari911 $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/'; 3161d05cddcSAtari911 } 3171d05cddcSAtari911 $oldDataDir .= 'calendar/'; 3181d05cddcSAtari911 3191d05cddcSAtari911 $deleteDate = $dateChanged ? $oldDate : $date; 3201d05cddcSAtari911 list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate); 3211d05cddcSAtari911 $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 32219378907SAtari911 32319378907SAtari911 if (file_exists($oldEventFile)) { 32419378907SAtari911 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 3251d05cddcSAtari911 if (isset($oldEvents[$deleteDate])) { 3261d05cddcSAtari911 $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) { 32719378907SAtari911 return $evt['id'] !== $eventId; 328e3a9f44cSAtari911 })); 32919378907SAtari911 3301d05cddcSAtari911 if (empty($oldEvents[$deleteDate])) { 3311d05cddcSAtari911 unset($oldEvents[$deleteDate]); 33219378907SAtari911 } 33319378907SAtari911 33419378907SAtari911 file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT)); 335*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Deleted event from old location - namespace:'$oldNamespace', date:'$deleteDate'"); 33619378907SAtari911 } 33719378907SAtari911 } 33819378907SAtari911 } 33919378907SAtari911 34019378907SAtari911 if (!isset($events[$date])) { 34119378907SAtari911 $events[$date] = []; 342e3a9f44cSAtari911 } elseif (!is_array($events[$date])) { 343e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 344*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 345e3a9f44cSAtari911 $events[$date] = []; 34619378907SAtari911 } 34719378907SAtari911 348e3a9f44cSAtari911 // Store the namespace with the event 34919378907SAtari911 $eventData = [ 35087ac9bf3SAtari911 'id' => $generatedId, 35119378907SAtari911 'title' => $title, 35219378907SAtari911 'time' => $time, 3531d05cddcSAtari911 'endTime' => $endTime, 35419378907SAtari911 'description' => $description, 35519378907SAtari911 'color' => $color, 35619378907SAtari911 'isTask' => $isTask, 35719378907SAtari911 'completed' => $completed, 35819378907SAtari911 'endDate' => $endDate, 359e3a9f44cSAtari911 'namespace' => $namespace, // Store namespace with event 36019378907SAtari911 'created' => date('Y-m-d H:i:s') 36119378907SAtari911 ]; 36219378907SAtari911 3631d05cddcSAtari911 // Debug logging 364*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile"); 3651d05cddcSAtari911 36619378907SAtari911 // If editing, replace existing event 36719378907SAtari911 if ($eventId) { 36819378907SAtari911 $found = false; 36919378907SAtari911 foreach ($events[$date] as $key => $evt) { 37019378907SAtari911 if ($evt['id'] === $eventId) { 37119378907SAtari911 $events[$date][$key] = $eventData; 37219378907SAtari911 $found = true; 37319378907SAtari911 break; 37419378907SAtari911 } 37519378907SAtari911 } 37619378907SAtari911 if (!$found) { 37719378907SAtari911 $events[$date][] = $eventData; 37819378907SAtari911 } 37919378907SAtari911 } else { 38019378907SAtari911 $events[$date][] = $eventData; 38119378907SAtari911 } 38219378907SAtari911 38319378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 38419378907SAtari911 385e3a9f44cSAtari911 // If event spans multiple months, add it to the first day of each subsequent month 386e3a9f44cSAtari911 if ($endDate && $endDate !== $date) { 387e3a9f44cSAtari911 $startDateObj = new DateTime($date); 388e3a9f44cSAtari911 $endDateObj = new DateTime($endDate); 389e3a9f44cSAtari911 390e3a9f44cSAtari911 // Get the month/year of the start date 391e3a9f44cSAtari911 $startMonth = $startDateObj->format('Y-m'); 392e3a9f44cSAtari911 393e3a9f44cSAtari911 // Iterate through each month the event spans 394e3a9f44cSAtari911 $currentDate = clone $startDateObj; 395e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 396e3a9f44cSAtari911 397e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 398e3a9f44cSAtari911 $currentMonth = $currentDate->format('Y-m'); 399e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 400e3a9f44cSAtari911 401e3a9f44cSAtari911 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 402e3a9f44cSAtari911 403e3a9f44cSAtari911 // Get the file for this month 404e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 405e3a9f44cSAtari911 406e3a9f44cSAtari911 $currentEvents = []; 407e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 408e3a9f44cSAtari911 $contents = file_get_contents($currentEventFile); 409e3a9f44cSAtari911 $decoded = json_decode($contents, true); 410e3a9f44cSAtari911 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 411e3a9f44cSAtari911 $currentEvents = $decoded; 412e3a9f44cSAtari911 } else { 413*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 414e3a9f44cSAtari911 } 415e3a9f44cSAtari911 } 416e3a9f44cSAtari911 417e3a9f44cSAtari911 // Add entry for the first day of this month 418e3a9f44cSAtari911 if (!isset($currentEvents[$firstDayOfMonth])) { 419e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 420e3a9f44cSAtari911 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 421e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 422*7e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 423e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 424e3a9f44cSAtari911 } 425e3a9f44cSAtari911 426e3a9f44cSAtari911 // Create a copy with the original start date preserved 427e3a9f44cSAtari911 $eventDataForMonth = $eventData; 428e3a9f44cSAtari911 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 429e3a9f44cSAtari911 430e3a9f44cSAtari911 // Check if event already exists (when editing) 431e3a9f44cSAtari911 $found = false; 432e3a9f44cSAtari911 if ($eventId) { 433e3a9f44cSAtari911 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 434e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 435e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 436e3a9f44cSAtari911 $found = true; 437e3a9f44cSAtari911 break; 438e3a9f44cSAtari911 } 439e3a9f44cSAtari911 } 440e3a9f44cSAtari911 } 441e3a9f44cSAtari911 442e3a9f44cSAtari911 if (!$found) { 443e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 444e3a9f44cSAtari911 } 445e3a9f44cSAtari911 446e3a9f44cSAtari911 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 447e3a9f44cSAtari911 448e3a9f44cSAtari911 // Move to next month 449e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 450e3a9f44cSAtari911 } 451e3a9f44cSAtari911 } 452e3a9f44cSAtari911 45319378907SAtari911 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 45419378907SAtari911 } 45519378907SAtari911 45619378907SAtari911 private function deleteEvent() { 45719378907SAtari911 global $INPUT; 45819378907SAtari911 45919378907SAtari911 $namespace = $INPUT->str('namespace', ''); 46019378907SAtari911 $date = $INPUT->str('date'); 46119378907SAtari911 $eventId = $INPUT->str('eventId'); 46219378907SAtari911 463e3a9f44cSAtari911 // Find where the event actually lives 464e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 465e3a9f44cSAtari911 466e3a9f44cSAtari911 if ($storedNamespace === null) { 467e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 468e3a9f44cSAtari911 return; 469e3a9f44cSAtari911 } 470e3a9f44cSAtari911 471e3a9f44cSAtari911 // Use the found namespace 472e3a9f44cSAtari911 $namespace = $storedNamespace; 473e3a9f44cSAtari911 47419378907SAtari911 list($year, $month, $day) = explode('-', $date); 47519378907SAtari911 47619378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 47719378907SAtari911 if ($namespace) { 47819378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 47919378907SAtari911 } 48019378907SAtari911 $dataDir .= 'calendar/'; 48119378907SAtari911 48219378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 48319378907SAtari911 4849ccd446eSAtari911 // First, get the event to check if it spans multiple months or is recurring 485e3a9f44cSAtari911 $eventToDelete = null; 4869ccd446eSAtari911 $isRecurring = false; 4879ccd446eSAtari911 $recurringId = null; 4889ccd446eSAtari911 48919378907SAtari911 if (file_exists($eventFile)) { 49019378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 49119378907SAtari911 49219378907SAtari911 if (isset($events[$date])) { 493e3a9f44cSAtari911 foreach ($events[$date] as $event) { 494e3a9f44cSAtari911 if ($event['id'] === $eventId) { 495e3a9f44cSAtari911 $eventToDelete = $event; 4969ccd446eSAtari911 $isRecurring = isset($event['recurring']) && $event['recurring']; 4979ccd446eSAtari911 $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 498e3a9f44cSAtari911 break; 499e3a9f44cSAtari911 } 500e3a9f44cSAtari911 } 501e3a9f44cSAtari911 502e3a9f44cSAtari911 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 50319378907SAtari911 return $event['id'] !== $eventId; 504e3a9f44cSAtari911 })); 50519378907SAtari911 50619378907SAtari911 if (empty($events[$date])) { 50719378907SAtari911 unset($events[$date]); 50819378907SAtari911 } 50919378907SAtari911 51019378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 51119378907SAtari911 } 51219378907SAtari911 } 51319378907SAtari911 5149ccd446eSAtari911 // If this is a recurring event, delete ALL occurrences with the same recurringId 5159ccd446eSAtari911 if ($isRecurring && $recurringId) { 5169ccd446eSAtari911 $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir); 5179ccd446eSAtari911 } 5189ccd446eSAtari911 519e3a9f44cSAtari911 // If event spans multiple months, delete it from the first day of each subsequent month 520e3a9f44cSAtari911 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 521e3a9f44cSAtari911 $startDateObj = new DateTime($date); 522e3a9f44cSAtari911 $endDateObj = new DateTime($eventToDelete['endDate']); 523e3a9f44cSAtari911 524e3a9f44cSAtari911 // Iterate through each month the event spans 525e3a9f44cSAtari911 $currentDate = clone $startDateObj; 526e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 527e3a9f44cSAtari911 528e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 529e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 530e3a9f44cSAtari911 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 531e3a9f44cSAtari911 532e3a9f44cSAtari911 // Get the file for this month 533e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 534e3a9f44cSAtari911 535e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 536e3a9f44cSAtari911 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 537e3a9f44cSAtari911 538e3a9f44cSAtari911 if (isset($currentEvents[$firstDayOfMonth])) { 539e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 540e3a9f44cSAtari911 return $event['id'] !== $eventId; 541e3a9f44cSAtari911 })); 542e3a9f44cSAtari911 543e3a9f44cSAtari911 if (empty($currentEvents[$firstDayOfMonth])) { 544e3a9f44cSAtari911 unset($currentEvents[$firstDayOfMonth]); 545e3a9f44cSAtari911 } 546e3a9f44cSAtari911 547e3a9f44cSAtari911 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 548e3a9f44cSAtari911 } 549e3a9f44cSAtari911 } 550e3a9f44cSAtari911 551e3a9f44cSAtari911 // Move to next month 552e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 553e3a9f44cSAtari911 } 554e3a9f44cSAtari911 } 555e3a9f44cSAtari911 55619378907SAtari911 echo json_encode(['success' => true]); 55719378907SAtari911 } 55819378907SAtari911 55919378907SAtari911 private function getEvent() { 56019378907SAtari911 global $INPUT; 56119378907SAtari911 56219378907SAtari911 $namespace = $INPUT->str('namespace', ''); 56319378907SAtari911 $date = $INPUT->str('date'); 56419378907SAtari911 $eventId = $INPUT->str('eventId'); 56519378907SAtari911 566e3a9f44cSAtari911 // Find where the event actually lives 567e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 568e3a9f44cSAtari911 569e3a9f44cSAtari911 if ($storedNamespace === null) { 570e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 571e3a9f44cSAtari911 return; 572e3a9f44cSAtari911 } 573e3a9f44cSAtari911 574e3a9f44cSAtari911 // Use the found namespace 575e3a9f44cSAtari911 $namespace = $storedNamespace; 576e3a9f44cSAtari911 57719378907SAtari911 list($year, $month, $day) = explode('-', $date); 57819378907SAtari911 57919378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 58019378907SAtari911 if ($namespace) { 58119378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 58219378907SAtari911 } 58319378907SAtari911 $dataDir .= 'calendar/'; 58419378907SAtari911 58519378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 58619378907SAtari911 58719378907SAtari911 if (file_exists($eventFile)) { 58819378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 58919378907SAtari911 59019378907SAtari911 if (isset($events[$date])) { 59119378907SAtari911 foreach ($events[$date] as $event) { 59219378907SAtari911 if ($event['id'] === $eventId) { 5931d05cddcSAtari911 // Include the namespace so JavaScript knows where this event actually lives 5941d05cddcSAtari911 $event['namespace'] = $namespace; 59519378907SAtari911 echo json_encode(['success' => true, 'event' => $event]); 59619378907SAtari911 return; 59719378907SAtari911 } 59819378907SAtari911 } 59919378907SAtari911 } 60019378907SAtari911 } 60119378907SAtari911 60219378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 60319378907SAtari911 } 60419378907SAtari911 60519378907SAtari911 private function loadMonth() { 60619378907SAtari911 global $INPUT; 60719378907SAtari911 608e3a9f44cSAtari911 // Prevent caching of AJAX responses 609e3a9f44cSAtari911 header('Cache-Control: no-cache, no-store, must-revalidate'); 610e3a9f44cSAtari911 header('Pragma: no-cache'); 611e3a9f44cSAtari911 header('Expires: 0'); 612e3a9f44cSAtari911 61319378907SAtari911 $namespace = $INPUT->str('namespace', ''); 61419378907SAtari911 $year = $INPUT->int('year'); 61519378907SAtari911 $month = $INPUT->int('month'); 61619378907SAtari911 617*7e8ea635SAtari911 // Validate year (reasonable range: 1970-2100) 618*7e8ea635SAtari911 if ($year < 1970 || $year > 2100) { 619*7e8ea635SAtari911 $year = (int)date('Y'); 620*7e8ea635SAtari911 } 621*7e8ea635SAtari911 622*7e8ea635SAtari911 // Validate month (1-12) 623*7e8ea635SAtari911 if ($month < 1 || $month > 12) { 624*7e8ea635SAtari911 $month = (int)date('n'); 625*7e8ea635SAtari911 } 626*7e8ea635SAtari911 627*7e8ea635SAtari911 // Validate namespace format 628*7e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 629*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 630*7e8ea635SAtari911 return; 631*7e8ea635SAtari911 } 632*7e8ea635SAtari911 633*7e8ea635SAtari911 $this->debugLog("=== Calendar loadMonth DEBUG ==="); 634*7e8ea635SAtari911 $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'"); 635e3a9f44cSAtari911 636e3a9f44cSAtari911 // Check if multi-namespace or wildcard 637e3a9f44cSAtari911 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 638e3a9f44cSAtari911 639*7e8ea635SAtari911 $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 640e3a9f44cSAtari911 641e3a9f44cSAtari911 if ($isMultiNamespace) { 642e3a9f44cSAtari911 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 643e3a9f44cSAtari911 } else { 644e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 645e3a9f44cSAtari911 } 646e3a9f44cSAtari911 647*7e8ea635SAtari911 $this->debugLog("Returning " . count($events) . " date keys"); 648e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 649*7e8ea635SAtari911 $this->debugLog(" dateKey=$dateKey has " . count($dayEvents) . " events"); 650e3a9f44cSAtari911 } 651e3a9f44cSAtari911 652e3a9f44cSAtari911 echo json_encode([ 653e3a9f44cSAtari911 'success' => true, 654e3a9f44cSAtari911 'year' => $year, 655e3a9f44cSAtari911 'month' => $month, 656e3a9f44cSAtari911 'events' => $events 657e3a9f44cSAtari911 ]); 658e3a9f44cSAtari911 } 659e3a9f44cSAtari911 660e3a9f44cSAtari911 private function loadEventsSingleNamespace($namespace, $year, $month) { 66119378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 66219378907SAtari911 if ($namespace) { 66319378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 66419378907SAtari911 } 66519378907SAtari911 $dataDir .= 'calendar/'; 66619378907SAtari911 667e3a9f44cSAtari911 // Load ONLY current month 66887ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 66919378907SAtari911 $events = []; 67019378907SAtari911 if (file_exists($eventFile)) { 67187ac9bf3SAtari911 $contents = file_get_contents($eventFile); 67287ac9bf3SAtari911 $decoded = json_decode($contents, true); 67387ac9bf3SAtari911 if (json_last_error() === JSON_ERROR_NONE) { 67487ac9bf3SAtari911 $events = $decoded; 67587ac9bf3SAtari911 } 67687ac9bf3SAtari911 } 67787ac9bf3SAtari911 678e3a9f44cSAtari911 return $events; 67987ac9bf3SAtari911 } 680e3a9f44cSAtari911 681e3a9f44cSAtari911 private function loadEventsMultiNamespace($namespaces, $year, $month) { 682e3a9f44cSAtari911 // Check for wildcard pattern 683e3a9f44cSAtari911 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 684e3a9f44cSAtari911 $baseNamespace = $matches[1]; 685e3a9f44cSAtari911 return $this->loadEventsWildcard($baseNamespace, $year, $month); 686e3a9f44cSAtari911 } 687e3a9f44cSAtari911 688e3a9f44cSAtari911 // Check for root wildcard 689e3a9f44cSAtari911 if ($namespaces === '*') { 690e3a9f44cSAtari911 return $this->loadEventsWildcard('', $year, $month); 691e3a9f44cSAtari911 } 692e3a9f44cSAtari911 693e3a9f44cSAtari911 // Parse namespace list (semicolon separated) 694e3a9f44cSAtari911 $namespaceList = array_map('trim', explode(';', $namespaces)); 695e3a9f44cSAtari911 696e3a9f44cSAtari911 // Load events from all namespaces 697e3a9f44cSAtari911 $allEvents = []; 698e3a9f44cSAtari911 foreach ($namespaceList as $ns) { 699e3a9f44cSAtari911 $ns = trim($ns); 700e3a9f44cSAtari911 if (empty($ns)) continue; 701e3a9f44cSAtari911 702e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 703e3a9f44cSAtari911 704e3a9f44cSAtari911 // Add namespace tag to each event 705e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 706e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 707e3a9f44cSAtari911 $allEvents[$dateKey] = []; 708e3a9f44cSAtari911 } 709e3a9f44cSAtari911 foreach ($dayEvents as $event) { 710e3a9f44cSAtari911 $event['_namespace'] = $ns; 711e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 712e3a9f44cSAtari911 } 71387ac9bf3SAtari911 } 71487ac9bf3SAtari911 } 71587ac9bf3SAtari911 716e3a9f44cSAtari911 return $allEvents; 717e3a9f44cSAtari911 } 71819378907SAtari911 719e3a9f44cSAtari911 private function loadEventsWildcard($baseNamespace, $year, $month) { 720e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 721e3a9f44cSAtari911 if ($baseNamespace) { 722e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 723e3a9f44cSAtari911 } 724e3a9f44cSAtari911 725e3a9f44cSAtari911 $allEvents = []; 726e3a9f44cSAtari911 727e3a9f44cSAtari911 // First, load events from the base namespace itself 728e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 729e3a9f44cSAtari911 730e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 731e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 732e3a9f44cSAtari911 $allEvents[$dateKey] = []; 733e3a9f44cSAtari911 } 734e3a9f44cSAtari911 foreach ($dayEvents as $event) { 735e3a9f44cSAtari911 $event['_namespace'] = $baseNamespace; 736e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 737e3a9f44cSAtari911 } 738e3a9f44cSAtari911 } 739e3a9f44cSAtari911 740e3a9f44cSAtari911 // Recursively find all subdirectories 741e3a9f44cSAtari911 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 742e3a9f44cSAtari911 743e3a9f44cSAtari911 return $allEvents; 744e3a9f44cSAtari911 } 745e3a9f44cSAtari911 746e3a9f44cSAtari911 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 747e3a9f44cSAtari911 if (!is_dir($dir)) return; 748e3a9f44cSAtari911 749e3a9f44cSAtari911 $items = scandir($dir); 750e3a9f44cSAtari911 foreach ($items as $item) { 751e3a9f44cSAtari911 if ($item === '.' || $item === '..') continue; 752e3a9f44cSAtari911 753e3a9f44cSAtari911 $path = $dir . $item; 754e3a9f44cSAtari911 if (is_dir($path) && $item !== 'calendar') { 755e3a9f44cSAtari911 // This is a namespace directory 756e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 757e3a9f44cSAtari911 758e3a9f44cSAtari911 // Load events from this namespace 759e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 760e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 761e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 762e3a9f44cSAtari911 $allEvents[$dateKey] = []; 763e3a9f44cSAtari911 } 764e3a9f44cSAtari911 foreach ($dayEvents as $event) { 765e3a9f44cSAtari911 $event['_namespace'] = $namespace; 766e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 767e3a9f44cSAtari911 } 768e3a9f44cSAtari911 } 769e3a9f44cSAtari911 770e3a9f44cSAtari911 // Recurse into subdirectories 771e3a9f44cSAtari911 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 772e3a9f44cSAtari911 } 773e3a9f44cSAtari911 } 77419378907SAtari911 } 77519378907SAtari911 77619378907SAtari911 private function toggleTaskComplete() { 77719378907SAtari911 global $INPUT; 77819378907SAtari911 77919378907SAtari911 $namespace = $INPUT->str('namespace', ''); 78019378907SAtari911 $date = $INPUT->str('date'); 78119378907SAtari911 $eventId = $INPUT->str('eventId'); 78219378907SAtari911 $completed = $INPUT->bool('completed', false); 78319378907SAtari911 784e3a9f44cSAtari911 // Find where the event actually lives 785e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 786e3a9f44cSAtari911 787e3a9f44cSAtari911 if ($storedNamespace === null) { 788e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 789e3a9f44cSAtari911 return; 790e3a9f44cSAtari911 } 791e3a9f44cSAtari911 792e3a9f44cSAtari911 // Use the found namespace 793e3a9f44cSAtari911 $namespace = $storedNamespace; 794e3a9f44cSAtari911 79519378907SAtari911 list($year, $month, $day) = explode('-', $date); 79619378907SAtari911 79719378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 79819378907SAtari911 if ($namespace) { 79919378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 80019378907SAtari911 } 80119378907SAtari911 $dataDir .= 'calendar/'; 80219378907SAtari911 80319378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 80419378907SAtari911 80519378907SAtari911 if (file_exists($eventFile)) { 80619378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 80719378907SAtari911 80819378907SAtari911 if (isset($events[$date])) { 80919378907SAtari911 foreach ($events[$date] as $key => $event) { 81019378907SAtari911 if ($event['id'] === $eventId) { 81119378907SAtari911 $events[$date][$key]['completed'] = $completed; 81219378907SAtari911 break; 81319378907SAtari911 } 81419378907SAtari911 } 81519378907SAtari911 81619378907SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 81719378907SAtari911 echo json_encode(['success' => true, 'events' => $events]); 81819378907SAtari911 return; 81919378907SAtari911 } 82019378907SAtari911 } 82119378907SAtari911 82219378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 82319378907SAtari911 } 82419378907SAtari911 82587ac9bf3SAtari911 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, 82687ac9bf3SAtari911 $description, $color, $isTask, $recurrenceType, 82787ac9bf3SAtari911 $recurrenceEnd, $baseId) { 82887ac9bf3SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 82987ac9bf3SAtari911 if ($namespace) { 83087ac9bf3SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 83187ac9bf3SAtari911 } 83287ac9bf3SAtari911 $dataDir .= 'calendar/'; 83387ac9bf3SAtari911 83487ac9bf3SAtari911 if (!is_dir($dataDir)) { 83587ac9bf3SAtari911 mkdir($dataDir, 0755, true); 83687ac9bf3SAtari911 } 83787ac9bf3SAtari911 83887ac9bf3SAtari911 // Calculate recurrence interval 83987ac9bf3SAtari911 $interval = ''; 84087ac9bf3SAtari911 switch ($recurrenceType) { 84187ac9bf3SAtari911 case 'daily': $interval = '+1 day'; break; 84287ac9bf3SAtari911 case 'weekly': $interval = '+1 week'; break; 84387ac9bf3SAtari911 case 'monthly': $interval = '+1 month'; break; 84487ac9bf3SAtari911 case 'yearly': $interval = '+1 year'; break; 84587ac9bf3SAtari911 default: $interval = '+1 week'; 84687ac9bf3SAtari911 } 84787ac9bf3SAtari911 84887ac9bf3SAtari911 // Set maximum end date if not specified (1 year from start) 84987ac9bf3SAtari911 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 85087ac9bf3SAtari911 85187ac9bf3SAtari911 // Calculate event duration for multi-day events 85287ac9bf3SAtari911 $eventDuration = 0; 85387ac9bf3SAtari911 if ($endDate && $endDate !== $startDate) { 85487ac9bf3SAtari911 $start = new DateTime($startDate); 85587ac9bf3SAtari911 $end = new DateTime($endDate); 85687ac9bf3SAtari911 $eventDuration = $start->diff($end)->days; 85787ac9bf3SAtari911 } 85887ac9bf3SAtari911 85987ac9bf3SAtari911 // Generate recurring events 86087ac9bf3SAtari911 $currentDate = new DateTime($startDate); 86187ac9bf3SAtari911 $endLimit = new DateTime($maxEnd); 86287ac9bf3SAtari911 $counter = 0; 86387ac9bf3SAtari911 $maxOccurrences = 100; // Prevent infinite loops 86487ac9bf3SAtari911 86587ac9bf3SAtari911 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 86687ac9bf3SAtari911 $dateKey = $currentDate->format('Y-m-d'); 86787ac9bf3SAtari911 list($year, $month, $day) = explode('-', $dateKey); 86887ac9bf3SAtari911 86987ac9bf3SAtari911 // Calculate end date for this occurrence if multi-day 87087ac9bf3SAtari911 $occurrenceEndDate = ''; 87187ac9bf3SAtari911 if ($eventDuration > 0) { 87287ac9bf3SAtari911 $occurrenceEnd = clone $currentDate; 87387ac9bf3SAtari911 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 87487ac9bf3SAtari911 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 87587ac9bf3SAtari911 } 87687ac9bf3SAtari911 87787ac9bf3SAtari911 // Load month file 87887ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 87987ac9bf3SAtari911 $events = []; 88087ac9bf3SAtari911 if (file_exists($eventFile)) { 88187ac9bf3SAtari911 $events = json_decode(file_get_contents($eventFile), true); 88287ac9bf3SAtari911 } 88387ac9bf3SAtari911 88487ac9bf3SAtari911 if (!isset($events[$dateKey])) { 88587ac9bf3SAtari911 $events[$dateKey] = []; 88687ac9bf3SAtari911 } 88787ac9bf3SAtari911 88887ac9bf3SAtari911 // Create event for this occurrence 88987ac9bf3SAtari911 $eventData = [ 89087ac9bf3SAtari911 'id' => $baseId . '-' . $counter, 89187ac9bf3SAtari911 'title' => $title, 89287ac9bf3SAtari911 'time' => $time, 8931d05cddcSAtari911 'endTime' => $endTime, 89487ac9bf3SAtari911 'description' => $description, 89587ac9bf3SAtari911 'color' => $color, 89687ac9bf3SAtari911 'isTask' => $isTask, 89787ac9bf3SAtari911 'completed' => false, 89887ac9bf3SAtari911 'endDate' => $occurrenceEndDate, 89987ac9bf3SAtari911 'recurring' => true, 90087ac9bf3SAtari911 'recurringId' => $baseId, 9011d05cddcSAtari911 'namespace' => $namespace, // Add namespace! 90287ac9bf3SAtari911 'created' => date('Y-m-d H:i:s') 90387ac9bf3SAtari911 ]; 90487ac9bf3SAtari911 90587ac9bf3SAtari911 $events[$dateKey][] = $eventData; 90687ac9bf3SAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 90787ac9bf3SAtari911 90887ac9bf3SAtari911 // Move to next occurrence 90987ac9bf3SAtari911 $currentDate->modify($interval); 91087ac9bf3SAtari911 $counter++; 91187ac9bf3SAtari911 } 91287ac9bf3SAtari911 } 91387ac9bf3SAtari911 91419378907SAtari911 public function addAssets(Doku_Event $event, $param) { 91519378907SAtari911 $event->data['link'][] = array( 91619378907SAtari911 'type' => 'text/css', 91719378907SAtari911 'rel' => 'stylesheet', 91819378907SAtari911 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 91919378907SAtari911 ); 92019378907SAtari911 92119378907SAtari911 $event->data['script'][] = array( 92219378907SAtari911 'type' => 'text/javascript', 92319378907SAtari911 'src' => DOKU_BASE . 'lib/plugins/calendar/script.js' 92419378907SAtari911 ); 92519378907SAtari911 } 926e3a9f44cSAtari911 // Helper function to find an event's stored namespace 927e3a9f44cSAtari911 private function findEventNamespace($eventId, $date, $searchNamespace) { 928e3a9f44cSAtari911 list($year, $month, $day) = explode('-', $date); 929e3a9f44cSAtari911 930e3a9f44cSAtari911 // List of namespaces to check 931e3a9f44cSAtari911 $namespacesToCheck = ['']; 932e3a9f44cSAtari911 933e3a9f44cSAtari911 // If searchNamespace is a wildcard or multi, we need to search multiple locations 934e3a9f44cSAtari911 if (!empty($searchNamespace)) { 935e3a9f44cSAtari911 if (strpos($searchNamespace, ';') !== false) { 936e3a9f44cSAtari911 // Multi-namespace - check each one 937e3a9f44cSAtari911 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 938e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 939e3a9f44cSAtari911 } elseif (strpos($searchNamespace, '*') !== false) { 940e3a9f44cSAtari911 // Wildcard - need to scan directories 941e3a9f44cSAtari911 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 942e3a9f44cSAtari911 $namespacesToCheck = $this->findAllNamespaces($baseNs); 943e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 944e3a9f44cSAtari911 } else { 945e3a9f44cSAtari911 // Single namespace 946e3a9f44cSAtari911 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 947e3a9f44cSAtari911 } 948e3a9f44cSAtari911 } 949e3a9f44cSAtari911 950e3a9f44cSAtari911 // Search for the event in all possible namespaces 951e3a9f44cSAtari911 foreach ($namespacesToCheck as $ns) { 952e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 953e3a9f44cSAtari911 if ($ns) { 954e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $ns) . '/'; 955e3a9f44cSAtari911 } 956e3a9f44cSAtari911 $dataDir .= 'calendar/'; 957e3a9f44cSAtari911 958e3a9f44cSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 959e3a9f44cSAtari911 960e3a9f44cSAtari911 if (file_exists($eventFile)) { 961e3a9f44cSAtari911 $events = json_decode(file_get_contents($eventFile), true); 962e3a9f44cSAtari911 if (isset($events[$date])) { 963e3a9f44cSAtari911 foreach ($events[$date] as $evt) { 964e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 965e3a9f44cSAtari911 // Found the event! Return its stored namespace 966e3a9f44cSAtari911 return isset($evt['namespace']) ? $evt['namespace'] : $ns; 967e3a9f44cSAtari911 } 968e3a9f44cSAtari911 } 969e3a9f44cSAtari911 } 970e3a9f44cSAtari911 } 971e3a9f44cSAtari911 } 972e3a9f44cSAtari911 973e3a9f44cSAtari911 return null; // Event not found 974e3a9f44cSAtari911 } 975e3a9f44cSAtari911 976e3a9f44cSAtari911 // Helper to find all namespaces under a base namespace 977e3a9f44cSAtari911 private function findAllNamespaces($baseNamespace) { 978e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 979e3a9f44cSAtari911 if ($baseNamespace) { 980e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 981e3a9f44cSAtari911 } 982e3a9f44cSAtari911 983e3a9f44cSAtari911 $namespaces = []; 984e3a9f44cSAtari911 if ($baseNamespace) { 985e3a9f44cSAtari911 $namespaces[] = $baseNamespace; 986e3a9f44cSAtari911 } 987e3a9f44cSAtari911 988e3a9f44cSAtari911 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 989e3a9f44cSAtari911 990e3a9f44cSAtari911 return $namespaces; 991e3a9f44cSAtari911 } 992e3a9f44cSAtari911 993e3a9f44cSAtari911 // Recursive scan for namespaces 994e3a9f44cSAtari911 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 995e3a9f44cSAtari911 if (!is_dir($dir)) return; 996e3a9f44cSAtari911 997e3a9f44cSAtari911 $items = scandir($dir); 998e3a9f44cSAtari911 foreach ($items as $item) { 999e3a9f44cSAtari911 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 1000e3a9f44cSAtari911 1001e3a9f44cSAtari911 $path = $dir . $item; 1002e3a9f44cSAtari911 if (is_dir($path)) { 1003e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1004e3a9f44cSAtari911 $namespaces[] = $namespace; 1005e3a9f44cSAtari911 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 1006e3a9f44cSAtari911 } 1007e3a9f44cSAtari911 } 1008e3a9f44cSAtari911 } 10099ccd446eSAtari911 10109ccd446eSAtari911 /** 10119ccd446eSAtari911 * Delete all instances of a recurring event across all months 10129ccd446eSAtari911 */ 10139ccd446eSAtari911 private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) { 10149ccd446eSAtari911 // Scan all JSON files in the calendar directory 10159ccd446eSAtari911 $calendarFiles = glob($dataDir . '*.json'); 10169ccd446eSAtari911 10179ccd446eSAtari911 foreach ($calendarFiles as $file) { 10189ccd446eSAtari911 $modified = false; 10199ccd446eSAtari911 $events = json_decode(file_get_contents($file), true); 10209ccd446eSAtari911 10219ccd446eSAtari911 if (!$events) continue; 10229ccd446eSAtari911 10239ccd446eSAtari911 // Check each date in the file 10249ccd446eSAtari911 foreach ($events as $date => &$dayEvents) { 10259ccd446eSAtari911 // Filter out events with matching recurringId 10269ccd446eSAtari911 $originalCount = count($dayEvents); 10279ccd446eSAtari911 $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) { 10289ccd446eSAtari911 $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 10299ccd446eSAtari911 return $eventRecurringId !== $recurringId; 10309ccd446eSAtari911 })); 10319ccd446eSAtari911 10329ccd446eSAtari911 if (count($dayEvents) !== $originalCount) { 10339ccd446eSAtari911 $modified = true; 10349ccd446eSAtari911 } 10359ccd446eSAtari911 10369ccd446eSAtari911 // Remove empty dates 10379ccd446eSAtari911 if (empty($dayEvents)) { 10389ccd446eSAtari911 unset($events[$date]); 10399ccd446eSAtari911 } 10409ccd446eSAtari911 } 10419ccd446eSAtari911 10429ccd446eSAtari911 // Save if modified 10439ccd446eSAtari911 if ($modified) { 10449ccd446eSAtari911 file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT)); 10459ccd446eSAtari911 } 10469ccd446eSAtari911 } 10479ccd446eSAtari911 } 10489ccd446eSAtari911 10499ccd446eSAtari911 /** 10509ccd446eSAtari911 * Get existing event data for preserving unchanged fields during edit 10519ccd446eSAtari911 */ 10529ccd446eSAtari911 private function getExistingEventData($eventId, $date, $namespace) { 10539ccd446eSAtari911 list($year, $month, $day) = explode('-', $date); 10549ccd446eSAtari911 10559ccd446eSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 10569ccd446eSAtari911 if ($namespace) { 10579ccd446eSAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 10589ccd446eSAtari911 } 10599ccd446eSAtari911 $dataDir .= 'calendar/'; 10609ccd446eSAtari911 10619ccd446eSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 10629ccd446eSAtari911 10639ccd446eSAtari911 if (!file_exists($eventFile)) { 10649ccd446eSAtari911 return null; 10659ccd446eSAtari911 } 10669ccd446eSAtari911 10679ccd446eSAtari911 $events = json_decode(file_get_contents($eventFile), true); 10689ccd446eSAtari911 10699ccd446eSAtari911 if (!isset($events[$date])) { 10709ccd446eSAtari911 return null; 10719ccd446eSAtari911 } 10729ccd446eSAtari911 10739ccd446eSAtari911 // Find the event by ID 10749ccd446eSAtari911 foreach ($events[$date] as $event) { 10759ccd446eSAtari911 if ($event['id'] === $eventId) { 10769ccd446eSAtari911 return $event; 10779ccd446eSAtari911 } 10789ccd446eSAtari911 } 10799ccd446eSAtari911 10809ccd446eSAtari911 return null; 10819ccd446eSAtari911 } 108219378907SAtari911} 1083