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