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