xref: /plugin/calendar/action.php (revision 7e8ea635dd19058d6f7c428adbbe02d9702096d7)
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