xref: /plugin/calendar/action.php (revision 815440faa45e800c80f925739a5d3cff27fa36d2)
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
7*815440faSAtari911 * @version 7.0.8
819378907SAtari911 */
919378907SAtari911
1019378907SAtari911if (!defined('DOKU_INC')) die();
1119378907SAtari911
127e8ea635SAtari911// Set to true to enable verbose debug logging (should be false in production)
137e8ea635SAtari911if (!defined('CALENDAR_DEBUG')) {
147e8ea635SAtari911    define('CALENDAR_DEBUG', false);
157e8ea635SAtari911}
167e8ea635SAtari911
17*815440faSAtari911// Load new class dependencies
18*815440faSAtari911require_once __DIR__ . '/classes/FileHandler.php';
19*815440faSAtari911require_once __DIR__ . '/classes/EventCache.php';
20*815440faSAtari911require_once __DIR__ . '/classes/RateLimiter.php';
21*815440faSAtari911require_once __DIR__ . '/classes/EventManager.php';
22*815440faSAtari911require_once __DIR__ . '/classes/AuditLogger.php';
23*815440faSAtari911require_once __DIR__ . '/classes/GoogleCalendarSync.php';
24*815440faSAtari911
2519378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin {
2619378907SAtari911
27*815440faSAtari911    /** @var CalendarAuditLogger */
28*815440faSAtari911    private $auditLogger = null;
29*815440faSAtari911
30*815440faSAtari911    /** @var GoogleCalendarSync */
31*815440faSAtari911    private $googleSync = null;
32*815440faSAtari911
33*815440faSAtari911    /**
34*815440faSAtari911     * Get the audit logger instance
35*815440faSAtari911     */
36*815440faSAtari911    private function getAuditLogger() {
37*815440faSAtari911        if ($this->auditLogger === null) {
38*815440faSAtari911            $this->auditLogger = new CalendarAuditLogger();
39*815440faSAtari911        }
40*815440faSAtari911        return $this->auditLogger;
41*815440faSAtari911    }
42*815440faSAtari911
43*815440faSAtari911    /**
44*815440faSAtari911     * Get the Google Calendar sync instance
45*815440faSAtari911     */
46*815440faSAtari911    private function getGoogleSync() {
47*815440faSAtari911        if ($this->googleSync === null) {
48*815440faSAtari911            $this->googleSync = new GoogleCalendarSync();
49*815440faSAtari911        }
50*815440faSAtari911        return $this->googleSync;
51*815440faSAtari911    }
52*815440faSAtari911
537e8ea635SAtari911    /**
547e8ea635SAtari911     * Log debug message only if CALENDAR_DEBUG is enabled
557e8ea635SAtari911     */
567e8ea635SAtari911    private function debugLog($message) {
577e8ea635SAtari911        if (CALENDAR_DEBUG) {
587e8ea635SAtari911            error_log($message);
597e8ea635SAtari911        }
607e8ea635SAtari911    }
617e8ea635SAtari911
627e8ea635SAtari911    /**
637e8ea635SAtari911     * Safely read and decode a JSON file with error handling
64*815440faSAtari911     * Uses the new CalendarFileHandler for atomic reads with locking
657e8ea635SAtari911     * @param string $filepath Path to JSON file
667e8ea635SAtari911     * @return array Decoded array or empty array on error
677e8ea635SAtari911     */
687e8ea635SAtari911    private function safeJsonRead($filepath) {
69*815440faSAtari911        return CalendarFileHandler::readJson($filepath);
707e8ea635SAtari911    }
717e8ea635SAtari911
72*815440faSAtari911    /**
73*815440faSAtari911     * Safely write JSON data to file with atomic writes
74*815440faSAtari911     * Uses the new CalendarFileHandler for atomic writes with locking
75*815440faSAtari911     * @param string $filepath Path to JSON file
76*815440faSAtari911     * @param array $data Data to write
77*815440faSAtari911     * @return bool Success status
78*815440faSAtari911     */
79*815440faSAtari911    private function safeJsonWrite($filepath, array $data) {
80*815440faSAtari911        return CalendarFileHandler::writeJson($filepath, $data);
817e8ea635SAtari911    }
827e8ea635SAtari911
8319378907SAtari911    public function register(Doku_Event_Handler $controller) {
8419378907SAtari911        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax');
8519378907SAtari911        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets');
8619378907SAtari911    }
8719378907SAtari911
8819378907SAtari911    public function handleAjax(Doku_Event $event, $param) {
8919378907SAtari911        if ($event->data !== 'plugin_calendar') return;
9019378907SAtari911        $event->preventDefault();
9119378907SAtari911        $event->stopPropagation();
9219378907SAtari911
9319378907SAtari911        $action = $_REQUEST['action'] ?? '';
9419378907SAtari911
95b498f308SAtari911        // Actions that modify data require authentication and CSRF token verification
967e8ea635SAtari911        $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces',
977e8ea635SAtari911                         'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring',
987e8ea635SAtari911                         'trim_recurring', 'pause_recurring', 'resume_recurring',
997e8ea635SAtari911                         'change_start_recurring', 'change_pattern_recurring'];
1007e8ea635SAtari911
101*815440faSAtari911        $isWriteAction = in_array($action, $writeActions);
102*815440faSAtari911
103*815440faSAtari911        // Rate limiting check - apply to all AJAX actions
104*815440faSAtari911        if (!CalendarRateLimiter::check($action, $isWriteAction)) {
105*815440faSAtari911            CalendarRateLimiter::addHeaders($action, $isWriteAction);
106*815440faSAtari911            http_response_code(429);
107*815440faSAtari911            echo json_encode([
108*815440faSAtari911                'success' => false,
109*815440faSAtari911                'error' => 'Rate limit exceeded. Please wait before making more requests.',
110*815440faSAtari911                'retry_after' => CalendarRateLimiter::getRemaining($action, $isWriteAction)['reset']
111*815440faSAtari911            ]);
112*815440faSAtari911            return;
113*815440faSAtari911        }
114*815440faSAtari911
115*815440faSAtari911        // Add rate limit headers to all responses
116*815440faSAtari911        CalendarRateLimiter::addHeaders($action, $isWriteAction);
117*815440faSAtari911
118*815440faSAtari911        if ($isWriteAction) {
119b498f308SAtari911            global $INPUT, $INFO;
120b498f308SAtari911
121b498f308SAtari911            // Check if user is logged in (at minimum)
122b498f308SAtari911            if (empty($_SERVER['REMOTE_USER'])) {
123b498f308SAtari911                echo json_encode(['success' => false, 'error' => 'Authentication required. Please log in.']);
124b498f308SAtari911                return;
125b498f308SAtari911            }
126b498f308SAtari911
127b498f308SAtari911            // Check for valid security token - try multiple sources
128b498f308SAtari911            $sectok = $INPUT->str('sectok', '');
129b498f308SAtari911            if (empty($sectok)) {
1307e8ea635SAtari911                $sectok = $_REQUEST['sectok'] ?? '';
131b498f308SAtari911            }
132b498f308SAtari911
133b498f308SAtari911            // Use DokuWiki's built-in check
1347e8ea635SAtari911            if (!checkSecurityToken($sectok)) {
135b498f308SAtari911                // Log for debugging
136b498f308SAtari911                $this->debugLog("Security token check failed. Received: '$sectok'");
1377e8ea635SAtari911                echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']);
1387e8ea635SAtari911                return;
1397e8ea635SAtari911            }
1407e8ea635SAtari911        }
1417e8ea635SAtari911
14219378907SAtari911        switch ($action) {
14319378907SAtari911            case 'save_event':
14419378907SAtari911                $this->saveEvent();
14519378907SAtari911                break;
14619378907SAtari911            case 'delete_event':
14719378907SAtari911                $this->deleteEvent();
14819378907SAtari911                break;
14919378907SAtari911            case 'get_event':
15019378907SAtari911                $this->getEvent();
15119378907SAtari911                break;
15219378907SAtari911            case 'load_month':
15319378907SAtari911                $this->loadMonth();
15419378907SAtari911                break;
155da206178SAtari911            case 'get_static_calendar':
156da206178SAtari911                $this->getStaticCalendar();
157da206178SAtari911                break;
15896df7d3eSAtari911            case 'search_all':
15996df7d3eSAtari911                $this->searchAllDates();
16096df7d3eSAtari911                break;
16119378907SAtari911            case 'toggle_task':
16219378907SAtari911                $this->toggleTaskComplete();
16319378907SAtari911                break;
164*815440faSAtari911            case 'google_auth_url':
165*815440faSAtari911                $this->getGoogleAuthUrl();
166*815440faSAtari911                break;
167*815440faSAtari911            case 'google_callback':
168*815440faSAtari911                $this->handleGoogleCallback();
169*815440faSAtari911                break;
170*815440faSAtari911            case 'google_status':
171*815440faSAtari911                $this->getGoogleStatus();
172*815440faSAtari911                break;
173*815440faSAtari911            case 'google_calendars':
174*815440faSAtari911                $this->getGoogleCalendars();
175*815440faSAtari911                break;
176*815440faSAtari911            case 'google_import':
177*815440faSAtari911                $this->googleImport();
178*815440faSAtari911                break;
179*815440faSAtari911            case 'google_export':
180*815440faSAtari911                $this->googleExport();
181*815440faSAtari911                break;
182*815440faSAtari911            case 'google_disconnect':
183*815440faSAtari911                $this->googleDisconnect();
184*815440faSAtari911                break;
1857e8ea635SAtari911            case 'cleanup_empty_namespaces':
1867e8ea635SAtari911            case 'trim_all_past_recurring':
1877e8ea635SAtari911            case 'rescan_recurring':
1887e8ea635SAtari911            case 'extend_recurring':
1897e8ea635SAtari911            case 'trim_recurring':
1907e8ea635SAtari911            case 'pause_recurring':
1917e8ea635SAtari911            case 'resume_recurring':
1927e8ea635SAtari911            case 'change_start_recurring':
1937e8ea635SAtari911            case 'change_pattern_recurring':
1947e8ea635SAtari911                $this->routeToAdmin($action);
1957e8ea635SAtari911                break;
19619378907SAtari911            default:
19719378907SAtari911                echo json_encode(['success' => false, 'error' => 'Unknown action']);
19819378907SAtari911        }
19919378907SAtari911    }
20019378907SAtari911
2017e8ea635SAtari911    /**
2027e8ea635SAtari911     * Route AJAX actions to admin plugin methods
2037e8ea635SAtari911     */
2047e8ea635SAtari911    private function routeToAdmin($action) {
2057e8ea635SAtari911        $admin = plugin_load('admin', 'calendar');
2067e8ea635SAtari911        if ($admin && method_exists($admin, 'handleAjaxAction')) {
2077e8ea635SAtari911            $admin->handleAjaxAction($action);
2087e8ea635SAtari911        } else {
2097e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Admin handler not available']);
2107e8ea635SAtari911        }
2117e8ea635SAtari911    }
2127e8ea635SAtari911
21319378907SAtari911    private function saveEvent() {
21419378907SAtari911        global $INPUT;
21519378907SAtari911
21619378907SAtari911        $namespace = $INPUT->str('namespace', '');
21719378907SAtari911        $date = $INPUT->str('date');
21819378907SAtari911        $eventId = $INPUT->str('eventId', '');
21919378907SAtari911        $title = $INPUT->str('title');
22019378907SAtari911        $time = $INPUT->str('time', '');
2211d05cddcSAtari911        $endTime = $INPUT->str('endTime', '');
22219378907SAtari911        $description = $INPUT->str('description', '');
22319378907SAtari911        $color = $INPUT->str('color', '#3498db');
22419378907SAtari911        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
22519378907SAtari911        $isTask = $INPUT->bool('isTask', false);
22619378907SAtari911        $completed = $INPUT->bool('completed', false);
22719378907SAtari911        $endDate = $INPUT->str('endDate', '');
22887ac9bf3SAtari911        $isRecurring = $INPUT->bool('isRecurring', false);
22987ac9bf3SAtari911        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
23087ac9bf3SAtari911        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
23119378907SAtari911
23296df7d3eSAtari911        // New recurrence options
23396df7d3eSAtari911        $recurrenceInterval = $INPUT->int('recurrenceInterval', 1);
23496df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
23596df7d3eSAtari911        if ($recurrenceInterval > 99) $recurrenceInterval = 99;
23696df7d3eSAtari911
23796df7d3eSAtari911        $weekDaysStr = $INPUT->str('weekDays', '');
23896df7d3eSAtari911        $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : [];
23996df7d3eSAtari911
24096df7d3eSAtari911        $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth');
24196df7d3eSAtari911        $monthDay = $INPUT->int('monthDay', 0);
24296df7d3eSAtari911        $ordinalWeek = $INPUT->int('ordinalWeek', 1);
24396df7d3eSAtari911        $ordinalDay = $INPUT->int('ordinalDay', 0);
24496df7d3eSAtari911
24596df7d3eSAtari911        $this->debugLog("=== Calendar saveEvent START ===");
24696df7d3eSAtari911        $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'");
24796df7d3eSAtari911        $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'");
24896df7d3eSAtari911
24919378907SAtari911        if (!$date || !$title) {
25019378907SAtari911            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
25119378907SAtari911            return;
25219378907SAtari911        }
25319378907SAtari911
2547e8ea635SAtari911        // Validate date format (YYYY-MM-DD)
2557e8ea635SAtari911        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
2567e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid date format']);
2577e8ea635SAtari911            return;
2587e8ea635SAtari911        }
2597e8ea635SAtari911
2607e8ea635SAtari911        // Validate oldDate if provided
2617e8ea635SAtari911        if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) {
2627e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid old date format']);
2637e8ea635SAtari911            return;
2647e8ea635SAtari911        }
2657e8ea635SAtari911
2667e8ea635SAtari911        // Validate endDate if provided
2677e8ea635SAtari911        if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) {
2687e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end date format']);
2697e8ea635SAtari911            return;
2707e8ea635SAtari911        }
2717e8ea635SAtari911
2727e8ea635SAtari911        // Validate time format (HH:MM) if provided
2737e8ea635SAtari911        if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) {
2747e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid time format']);
2757e8ea635SAtari911            return;
2767e8ea635SAtari911        }
2777e8ea635SAtari911
2787e8ea635SAtari911        // Validate endTime format if provided
2797e8ea635SAtari911        if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) {
2807e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end time format']);
2817e8ea635SAtari911            return;
2827e8ea635SAtari911        }
2837e8ea635SAtari911
2847e8ea635SAtari911        // Validate color format (hex color)
2857e8ea635SAtari911        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
2867e8ea635SAtari911            $color = '#3498db'; // Reset to default if invalid
2877e8ea635SAtari911        }
2887e8ea635SAtari911
2897e8ea635SAtari911        // Validate namespace (prevent path traversal)
2907e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
2917e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
2927e8ea635SAtari911            return;
2937e8ea635SAtari911        }
2947e8ea635SAtari911
2957e8ea635SAtari911        // Validate recurrence type
2967e8ea635SAtari911        $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
2977e8ea635SAtari911        if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) {
2987e8ea635SAtari911            $recurrenceType = 'weekly';
2997e8ea635SAtari911        }
3007e8ea635SAtari911
3017e8ea635SAtari911        // Validate recurrenceEnd if provided
3027e8ea635SAtari911        if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) {
3037e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']);
3047e8ea635SAtari911            return;
3057e8ea635SAtari911        }
3067e8ea635SAtari911
3077e8ea635SAtari911        // Sanitize title length
3087e8ea635SAtari911        $title = substr(trim($title), 0, 500);
3097e8ea635SAtari911
3107e8ea635SAtari911        // Sanitize description length
3117e8ea635SAtari911        $description = substr($description, 0, 10000);
3127e8ea635SAtari911
31396df7d3eSAtari911        // If editing, find the event's ACTUAL namespace (for finding/deleting old event)
31496df7d3eSAtari911        // We need to search ALL namespaces because user may be changing namespace
31596df7d3eSAtari911        $oldNamespace = null;  // null means "not found yet"
316e3a9f44cSAtari911        if ($eventId) {
3171d05cddcSAtari911            // Use oldDate if available (date was changed), otherwise use current date
3181d05cddcSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
3191d05cddcSAtari911
32096df7d3eSAtari911            // Search using wildcard to find event in ANY namespace
32196df7d3eSAtari911            $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*');
32296df7d3eSAtari911
32396df7d3eSAtari911            if ($foundNamespace !== null) {
32496df7d3eSAtari911                $oldNamespace = $foundNamespace;  // Could be '' for default namespace
3257e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'");
32696df7d3eSAtari911            } else {
32796df7d3eSAtari911                $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace");
3281d05cddcSAtari911            }
329e3a9f44cSAtari911        }
330e3a9f44cSAtari911
3311d05cddcSAtari911        // Use the namespace provided by the user (allow namespace changes!)
3321d05cddcSAtari911        // But normalize wildcards and multi-namespace to empty for NEW events
3331d05cddcSAtari911        if (!$eventId) {
3347e8ea635SAtari911            $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'");
335e3a9f44cSAtari911            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
336e3a9f44cSAtari911            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
3377e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty");
338e3a9f44cSAtari911                $namespace = '';
3391d05cddcSAtari911            } else {
3407e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'");
341e3a9f44cSAtari911            }
3421d05cddcSAtari911        } else {
3437e8ea635SAtari911            $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'");
344e3a9f44cSAtari911        }
345e3a9f44cSAtari911
34687ac9bf3SAtari911        // Generate event ID if new
34787ac9bf3SAtari911        $generatedId = $eventId ?: uniqid();
34887ac9bf3SAtari911
3499ccd446eSAtari911        // If editing a recurring event, load existing data to preserve unchanged fields
3509ccd446eSAtari911        $existingEventData = null;
3519ccd446eSAtari911        if ($eventId && $isRecurring) {
3529ccd446eSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
35396df7d3eSAtari911            // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use ''
35496df7d3eSAtari911            $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace);
3559ccd446eSAtari911            if ($existingEventData) {
3567e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'");
3579ccd446eSAtari911            }
3589ccd446eSAtari911        }
3599ccd446eSAtari911
36087ac9bf3SAtari911        // If recurring, generate multiple events
36187ac9bf3SAtari911        if ($isRecurring) {
3629ccd446eSAtari911            // Merge with existing data if editing (preserve values that weren't changed)
3639ccd446eSAtari911            if ($existingEventData) {
3649ccd446eSAtari911                $title = $title ?: $existingEventData['title'];
3659ccd446eSAtari911                $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : '');
3669ccd446eSAtari911                $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : '');
3679ccd446eSAtari911                $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : '');
3689ccd446eSAtari911                // Only use existing color if new color is default
3699ccd446eSAtari911                if ($color === '#3498db' && isset($existingEventData['color'])) {
3709ccd446eSAtari911                    $color = $existingEventData['color'];
3719ccd446eSAtari911                }
3729ccd446eSAtari911
3739ccd446eSAtari911                // Preserve namespace in these cases:
3749ccd446eSAtari911                // 1. Namespace field is empty (user didn't select anything)
3759ccd446eSAtari911                // 2. Namespace contains wildcards (like "personal;work" or "work*")
3769ccd446eSAtari911                // 3. Namespace is the same as what was passed (no change intended)
3779ccd446eSAtari911                $receivedNamespace = $namespace;
3789ccd446eSAtari911                if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) {
3799ccd446eSAtari911                    if (isset($existingEventData['namespace'])) {
3809ccd446eSAtari911                        $namespace = $existingEventData['namespace'];
3817e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')");
3829ccd446eSAtari911                    } else {
3837e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')");
3849ccd446eSAtari911                    }
3859ccd446eSAtari911                } else {
3867e8ea635SAtari911                    $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')");
3879ccd446eSAtari911                }
3889ccd446eSAtari911            } else {
3897e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'");
3909ccd446eSAtari911            }
3919ccd446eSAtari911
39296df7d3eSAtari911            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description,
39396df7d3eSAtari911                                        $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd,
39496df7d3eSAtari911                                        $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId);
39587ac9bf3SAtari911            echo json_encode(['success' => true]);
39687ac9bf3SAtari911            return;
39787ac9bf3SAtari911        }
39887ac9bf3SAtari911
39919378907SAtari911        list($year, $month, $day) = explode('-', $date);
40019378907SAtari911
4011d05cddcSAtari911        // NEW namespace directory (where we'll save)
40219378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
40319378907SAtari911        if ($namespace) {
40419378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
40519378907SAtari911        }
40619378907SAtari911        $dataDir .= 'calendar/';
40719378907SAtari911
40819378907SAtari911        if (!is_dir($dataDir)) {
40919378907SAtari911            mkdir($dataDir, 0755, true);
41019378907SAtari911        }
41119378907SAtari911
41219378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
41319378907SAtari911
41496df7d3eSAtari911        $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'");
41596df7d3eSAtari911
41619378907SAtari911        $events = [];
41719378907SAtari911        if (file_exists($eventFile)) {
41819378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
41996df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location");
42096df7d3eSAtari911        } else {
42196df7d3eSAtari911            $this->debugLog("Calendar saveEvent: New location file does not exist yet");
42219378907SAtari911        }
42319378907SAtari911
4241d05cddcSAtari911        // If editing and (date changed OR namespace changed), remove from old location first
42596df7d3eSAtari911        // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace
42696df7d3eSAtari911        $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace);
4271d05cddcSAtari911        $dateChanged = ($eventId && $oldDate && $oldDate !== $date);
4281d05cddcSAtari911
42996df7d3eSAtari911        $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO'));
43096df7d3eSAtari911
4311d05cddcSAtari911        if ($namespaceChanged || $dateChanged) {
4321d05cddcSAtari911            // Construct OLD data directory using OLD namespace
4331d05cddcSAtari911            $oldDataDir = DOKU_INC . 'data/meta/';
4341d05cddcSAtari911            if ($oldNamespace) {
4351d05cddcSAtari911                $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/';
4361d05cddcSAtari911            }
4371d05cddcSAtari911            $oldDataDir .= 'calendar/';
4381d05cddcSAtari911
4391d05cddcSAtari911            $deleteDate = $dateChanged ? $oldDate : $date;
4401d05cddcSAtari911            list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate);
4411d05cddcSAtari911            $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
44219378907SAtari911
44396df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'");
44496df7d3eSAtari911
44519378907SAtari911            if (file_exists($oldEventFile)) {
44619378907SAtari911                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
44796df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates");
44896df7d3eSAtari911
4491d05cddcSAtari911                if (isset($oldEvents[$deleteDate])) {
45096df7d3eSAtari911                    $countBefore = count($oldEvents[$deleteDate]);
4511d05cddcSAtari911                    $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) {
45219378907SAtari911                        return $evt['id'] !== $eventId;
453e3a9f44cSAtari911                    }));
45496df7d3eSAtari911                    $countAfter = count($oldEvents[$deleteDate]);
45596df7d3eSAtari911
45696df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter");
45719378907SAtari911
4581d05cddcSAtari911                    if (empty($oldEvents[$deleteDate])) {
4591d05cddcSAtari911                        unset($oldEvents[$deleteDate]);
46019378907SAtari911                    }
46119378907SAtari911
462*815440faSAtari911                    CalendarFileHandler::writeJson($oldEventFile, $oldEvents);
46396df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'");
46496df7d3eSAtari911                } else {
46596df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file");
46619378907SAtari911                }
46796df7d3eSAtari911            } else {
46896df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile");
46919378907SAtari911            }
47096df7d3eSAtari911        } else {
47196df7d3eSAtari911            $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location");
47219378907SAtari911        }
47319378907SAtari911
47419378907SAtari911        if (!isset($events[$date])) {
47519378907SAtari911            $events[$date] = [];
476e3a9f44cSAtari911        } elseif (!is_array($events[$date])) {
477e3a9f44cSAtari911            // Fix corrupted data - ensure it's an array
4787e8ea635SAtari911            $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
479e3a9f44cSAtari911            $events[$date] = [];
48019378907SAtari911        }
48119378907SAtari911
482e3a9f44cSAtari911        // Store the namespace with the event
48319378907SAtari911        $eventData = [
48487ac9bf3SAtari911            'id' => $generatedId,
48519378907SAtari911            'title' => $title,
48619378907SAtari911            'time' => $time,
4871d05cddcSAtari911            'endTime' => $endTime,
48819378907SAtari911            'description' => $description,
48919378907SAtari911            'color' => $color,
49019378907SAtari911            'isTask' => $isTask,
49119378907SAtari911            'completed' => $completed,
49219378907SAtari911            'endDate' => $endDate,
493e3a9f44cSAtari911            'namespace' => $namespace, // Store namespace with event
49419378907SAtari911            'created' => date('Y-m-d H:i:s')
49519378907SAtari911        ];
49619378907SAtari911
4971d05cddcSAtari911        // Debug logging
4987e8ea635SAtari911        $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile");
4991d05cddcSAtari911
50019378907SAtari911        // If editing, replace existing event
50119378907SAtari911        if ($eventId) {
50219378907SAtari911            $found = false;
50319378907SAtari911            foreach ($events[$date] as $key => $evt) {
50419378907SAtari911                if ($evt['id'] === $eventId) {
50519378907SAtari911                    $events[$date][$key] = $eventData;
50619378907SAtari911                    $found = true;
50719378907SAtari911                    break;
50819378907SAtari911                }
50919378907SAtari911            }
51019378907SAtari911            if (!$found) {
51119378907SAtari911                $events[$date][] = $eventData;
51219378907SAtari911            }
51319378907SAtari911        } else {
51419378907SAtari911            $events[$date][] = $eventData;
51519378907SAtari911        }
51619378907SAtari911
517*815440faSAtari911        CalendarFileHandler::writeJson($eventFile, $events);
51819378907SAtari911
519e3a9f44cSAtari911        // If event spans multiple months, add it to the first day of each subsequent month
520e3a9f44cSAtari911        if ($endDate && $endDate !== $date) {
521e3a9f44cSAtari911            $startDateObj = new DateTime($date);
522e3a9f44cSAtari911            $endDateObj = new DateTime($endDate);
523e3a9f44cSAtari911
524e3a9f44cSAtari911            // Get the month/year of the start date
525e3a9f44cSAtari911            $startMonth = $startDateObj->format('Y-m');
526e3a9f44cSAtari911
527e3a9f44cSAtari911            // Iterate through each month the event spans
528e3a9f44cSAtari911            $currentDate = clone $startDateObj;
529e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
530e3a9f44cSAtari911
531e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
532e3a9f44cSAtari911                $currentMonth = $currentDate->format('Y-m');
533e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
534e3a9f44cSAtari911
535e3a9f44cSAtari911                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
536e3a9f44cSAtari911
537e3a9f44cSAtari911                // Get the file for this month
538e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
539e3a9f44cSAtari911
540e3a9f44cSAtari911                $currentEvents = [];
541e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
542e3a9f44cSAtari911                    $contents = file_get_contents($currentEventFile);
543e3a9f44cSAtari911                    $decoded = json_decode($contents, true);
544e3a9f44cSAtari911                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
545e3a9f44cSAtari911                        $currentEvents = $decoded;
546e3a9f44cSAtari911                    } else {
5477e8ea635SAtari911                        $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
548e3a9f44cSAtari911                    }
549e3a9f44cSAtari911                }
550e3a9f44cSAtari911
551e3a9f44cSAtari911                // Add entry for the first day of this month
552e3a9f44cSAtari911                if (!isset($currentEvents[$firstDayOfMonth])) {
553e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
554e3a9f44cSAtari911                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
555e3a9f44cSAtari911                    // Fix corrupted data - ensure it's an array
5567e8ea635SAtari911                    $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
557e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
558e3a9f44cSAtari911                }
559e3a9f44cSAtari911
560e3a9f44cSAtari911                // Create a copy with the original start date preserved
561e3a9f44cSAtari911                $eventDataForMonth = $eventData;
562e3a9f44cSAtari911                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
563e3a9f44cSAtari911
564e3a9f44cSAtari911                // Check if event already exists (when editing)
565e3a9f44cSAtari911                $found = false;
566e3a9f44cSAtari911                if ($eventId) {
567e3a9f44cSAtari911                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
568e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
569e3a9f44cSAtari911                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
570e3a9f44cSAtari911                            $found = true;
571e3a9f44cSAtari911                            break;
572e3a9f44cSAtari911                        }
573e3a9f44cSAtari911                    }
574e3a9f44cSAtari911                }
575e3a9f44cSAtari911
576e3a9f44cSAtari911                if (!$found) {
577e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
578e3a9f44cSAtari911                }
579e3a9f44cSAtari911
580*815440faSAtari911                CalendarFileHandler::writeJson($currentEventFile, $currentEvents);
581e3a9f44cSAtari911
582e3a9f44cSAtari911                // Move to next month
583e3a9f44cSAtari911                $currentDate->modify('first day of next month');
584e3a9f44cSAtari911            }
585e3a9f44cSAtari911        }
586e3a9f44cSAtari911
587*815440faSAtari911        // Audit logging
588*815440faSAtari911        $audit = $this->getAuditLogger();
589*815440faSAtari911        if ($eventId && ($dateChanged || $namespaceChanged)) {
590*815440faSAtari911            // Event was moved
591*815440faSAtari911            $audit->logMove($namespace, $oldDate ?: $date, $date, $generatedId, $title);
592*815440faSAtari911        } elseif ($eventId) {
593*815440faSAtari911            // Event was updated
594*815440faSAtari911            $audit->logUpdate($namespace, $date, $generatedId, $title);
595*815440faSAtari911        } else {
596*815440faSAtari911            // New event created
597*815440faSAtari911            $audit->logCreate($namespace, $date, $generatedId, $title);
598*815440faSAtari911        }
599*815440faSAtari911
60019378907SAtari911        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
60119378907SAtari911    }
60219378907SAtari911
60319378907SAtari911    private function deleteEvent() {
60419378907SAtari911        global $INPUT;
60519378907SAtari911
60619378907SAtari911        $namespace = $INPUT->str('namespace', '');
60719378907SAtari911        $date = $INPUT->str('date');
60819378907SAtari911        $eventId = $INPUT->str('eventId');
60919378907SAtari911
610e3a9f44cSAtari911        // Find where the event actually lives
611e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
612e3a9f44cSAtari911
613e3a9f44cSAtari911        if ($storedNamespace === null) {
614e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
615e3a9f44cSAtari911            return;
616e3a9f44cSAtari911        }
617e3a9f44cSAtari911
618e3a9f44cSAtari911        // Use the found namespace
619e3a9f44cSAtari911        $namespace = $storedNamespace;
620e3a9f44cSAtari911
62119378907SAtari911        list($year, $month, $day) = explode('-', $date);
62219378907SAtari911
62319378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
62419378907SAtari911        if ($namespace) {
62519378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
62619378907SAtari911        }
62719378907SAtari911        $dataDir .= 'calendar/';
62819378907SAtari911
62919378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
63019378907SAtari911
6319ccd446eSAtari911        // First, get the event to check if it spans multiple months or is recurring
632e3a9f44cSAtari911        $eventToDelete = null;
6339ccd446eSAtari911        $isRecurring = false;
6349ccd446eSAtari911        $recurringId = null;
6359ccd446eSAtari911
63619378907SAtari911        if (file_exists($eventFile)) {
63719378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
63819378907SAtari911
63919378907SAtari911            if (isset($events[$date])) {
640e3a9f44cSAtari911                foreach ($events[$date] as $event) {
641e3a9f44cSAtari911                    if ($event['id'] === $eventId) {
642e3a9f44cSAtari911                        $eventToDelete = $event;
6439ccd446eSAtari911                        $isRecurring = isset($event['recurring']) && $event['recurring'];
6449ccd446eSAtari911                        $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
645e3a9f44cSAtari911                        break;
646e3a9f44cSAtari911                    }
647e3a9f44cSAtari911                }
648e3a9f44cSAtari911
649e3a9f44cSAtari911                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
65019378907SAtari911                    return $event['id'] !== $eventId;
651e3a9f44cSAtari911                }));
65219378907SAtari911
65319378907SAtari911                if (empty($events[$date])) {
65419378907SAtari911                    unset($events[$date]);
65519378907SAtari911                }
65619378907SAtari911
657*815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
65819378907SAtari911            }
65919378907SAtari911        }
66019378907SAtari911
6619ccd446eSAtari911        // If this is a recurring event, delete ALL occurrences with the same recurringId
6629ccd446eSAtari911        if ($isRecurring && $recurringId) {
6639ccd446eSAtari911            $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir);
6649ccd446eSAtari911        }
6659ccd446eSAtari911
666e3a9f44cSAtari911        // If event spans multiple months, delete it from the first day of each subsequent month
667e3a9f44cSAtari911        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
668e3a9f44cSAtari911            $startDateObj = new DateTime($date);
669e3a9f44cSAtari911            $endDateObj = new DateTime($eventToDelete['endDate']);
670e3a9f44cSAtari911
671e3a9f44cSAtari911            // Iterate through each month the event spans
672e3a9f44cSAtari911            $currentDate = clone $startDateObj;
673e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
674e3a9f44cSAtari911
675e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
676e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
677e3a9f44cSAtari911                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
678e3a9f44cSAtari911
679e3a9f44cSAtari911                // Get the file for this month
680e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
681e3a9f44cSAtari911
682e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
683e3a9f44cSAtari911                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
684e3a9f44cSAtari911
685e3a9f44cSAtari911                    if (isset($currentEvents[$firstDayOfMonth])) {
686e3a9f44cSAtari911                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
687e3a9f44cSAtari911                            return $event['id'] !== $eventId;
688e3a9f44cSAtari911                        }));
689e3a9f44cSAtari911
690e3a9f44cSAtari911                        if (empty($currentEvents[$firstDayOfMonth])) {
691e3a9f44cSAtari911                            unset($currentEvents[$firstDayOfMonth]);
692e3a9f44cSAtari911                        }
693e3a9f44cSAtari911
694*815440faSAtari911                        CalendarFileHandler::writeJson($currentEventFile, $currentEvents);
695e3a9f44cSAtari911                    }
696e3a9f44cSAtari911                }
697e3a9f44cSAtari911
698e3a9f44cSAtari911                // Move to next month
699e3a9f44cSAtari911                $currentDate->modify('first day of next month');
700e3a9f44cSAtari911            }
701e3a9f44cSAtari911        }
702e3a9f44cSAtari911
703*815440faSAtari911        // Audit logging
704*815440faSAtari911        $audit = $this->getAuditLogger();
705*815440faSAtari911        $eventTitle = $eventToDelete ? ($eventToDelete['title'] ?? '') : '';
706*815440faSAtari911        $audit->logDelete($namespace, $date, $eventId, $eventTitle);
707*815440faSAtari911
70819378907SAtari911        echo json_encode(['success' => true]);
70919378907SAtari911    }
71019378907SAtari911
71119378907SAtari911    private function getEvent() {
71219378907SAtari911        global $INPUT;
71319378907SAtari911
71419378907SAtari911        $namespace = $INPUT->str('namespace', '');
71519378907SAtari911        $date = $INPUT->str('date');
71619378907SAtari911        $eventId = $INPUT->str('eventId');
71719378907SAtari911
718e3a9f44cSAtari911        // Find where the event actually lives
719e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
720e3a9f44cSAtari911
721e3a9f44cSAtari911        if ($storedNamespace === null) {
722e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
723e3a9f44cSAtari911            return;
724e3a9f44cSAtari911        }
725e3a9f44cSAtari911
726e3a9f44cSAtari911        // Use the found namespace
727e3a9f44cSAtari911        $namespace = $storedNamespace;
728e3a9f44cSAtari911
72919378907SAtari911        list($year, $month, $day) = explode('-', $date);
73019378907SAtari911
73119378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
73219378907SAtari911        if ($namespace) {
73319378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
73419378907SAtari911        }
73519378907SAtari911        $dataDir .= 'calendar/';
73619378907SAtari911
73719378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
73819378907SAtari911
73919378907SAtari911        if (file_exists($eventFile)) {
74019378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
74119378907SAtari911
74219378907SAtari911            if (isset($events[$date])) {
74319378907SAtari911                foreach ($events[$date] as $event) {
74419378907SAtari911                    if ($event['id'] === $eventId) {
7451d05cddcSAtari911                        // Include the namespace so JavaScript knows where this event actually lives
7461d05cddcSAtari911                        $event['namespace'] = $namespace;
74719378907SAtari911                        echo json_encode(['success' => true, 'event' => $event]);
74819378907SAtari911                        return;
74919378907SAtari911                    }
75019378907SAtari911                }
75119378907SAtari911            }
75219378907SAtari911        }
75319378907SAtari911
75419378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
75519378907SAtari911    }
75619378907SAtari911
75719378907SAtari911    private function loadMonth() {
75819378907SAtari911        global $INPUT;
75919378907SAtari911
760e3a9f44cSAtari911        // Prevent caching of AJAX responses
761e3a9f44cSAtari911        header('Cache-Control: no-cache, no-store, must-revalidate');
762e3a9f44cSAtari911        header('Pragma: no-cache');
763e3a9f44cSAtari911        header('Expires: 0');
764e3a9f44cSAtari911
76519378907SAtari911        $namespace = $INPUT->str('namespace', '');
76619378907SAtari911        $year = $INPUT->int('year');
76719378907SAtari911        $month = $INPUT->int('month');
76819378907SAtari911
7697e8ea635SAtari911        // Validate year (reasonable range: 1970-2100)
7707e8ea635SAtari911        if ($year < 1970 || $year > 2100) {
7717e8ea635SAtari911            $year = (int)date('Y');
7727e8ea635SAtari911        }
7737e8ea635SAtari911
7747e8ea635SAtari911        // Validate month (1-12)
7757e8ea635SAtari911        if ($month < 1 || $month > 12) {
7767e8ea635SAtari911            $month = (int)date('n');
7777e8ea635SAtari911        }
7787e8ea635SAtari911
7797e8ea635SAtari911        // Validate namespace format
7807e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
7817e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
7827e8ea635SAtari911            return;
7837e8ea635SAtari911        }
7847e8ea635SAtari911
7857e8ea635SAtari911        $this->debugLog("=== Calendar loadMonth DEBUG ===");
7867e8ea635SAtari911        $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'");
787e3a9f44cSAtari911
788e3a9f44cSAtari911        // Check if multi-namespace or wildcard
789e3a9f44cSAtari911        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
790e3a9f44cSAtari911
7917e8ea635SAtari911        $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
792e3a9f44cSAtari911
793e3a9f44cSAtari911        if ($isMultiNamespace) {
794e3a9f44cSAtari911            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
795e3a9f44cSAtari911        } else {
796e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
797e3a9f44cSAtari911        }
798e3a9f44cSAtari911
7997e8ea635SAtari911        $this->debugLog("Returning " . count($events) . " date keys");
800e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
8017e8ea635SAtari911            $this->debugLog("  dateKey=$dateKey has " . count($dayEvents) . " events");
802e3a9f44cSAtari911        }
803e3a9f44cSAtari911
804e3a9f44cSAtari911        echo json_encode([
805e3a9f44cSAtari911            'success' => true,
806e3a9f44cSAtari911            'year' => $year,
807e3a9f44cSAtari911            'month' => $month,
808e3a9f44cSAtari911            'events' => $events
809e3a9f44cSAtari911        ]);
810e3a9f44cSAtari911    }
811e3a9f44cSAtari911
812da206178SAtari911    /**
813da206178SAtari911     * Get static calendar HTML via AJAX for navigation
814da206178SAtari911     */
815da206178SAtari911    private function getStaticCalendar() {
816da206178SAtari911        global $INPUT;
817da206178SAtari911
818da206178SAtari911        $namespace = $INPUT->str('namespace', '');
819da206178SAtari911        $year = $INPUT->int('year');
820da206178SAtari911        $month = $INPUT->int('month');
821da206178SAtari911
822da206178SAtari911        // Validate
823da206178SAtari911        if ($year < 1970 || $year > 2100) {
824da206178SAtari911            $year = (int)date('Y');
825da206178SAtari911        }
826da206178SAtari911        if ($month < 1 || $month > 12) {
827da206178SAtari911            $month = (int)date('n');
828da206178SAtari911        }
829da206178SAtari911
830da206178SAtari911        // Get syntax plugin to render the static calendar
831da206178SAtari911        $syntax = plugin_load('syntax', 'calendar');
832da206178SAtari911        if (!$syntax) {
833da206178SAtari911            echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']);
834da206178SAtari911            return;
835da206178SAtari911        }
836da206178SAtari911
837da206178SAtari911        // Build data array for render
838da206178SAtari911        $data = [
839da206178SAtari911            'year' => $year,
840da206178SAtari911            'month' => $month,
841da206178SAtari911            'namespace' => $namespace,
842da206178SAtari911            'static' => true
843da206178SAtari911        ];
844da206178SAtari911
845da206178SAtari911        // Call the render method via reflection (since renderStaticCalendar is private)
846da206178SAtari911        $reflector = new \ReflectionClass($syntax);
847da206178SAtari911        $method = $reflector->getMethod('renderStaticCalendar');
848da206178SAtari911        $method->setAccessible(true);
849da206178SAtari911        $html = $method->invoke($syntax, $data);
850da206178SAtari911
851da206178SAtari911        echo json_encode([
852da206178SAtari911            'success' => true,
853da206178SAtari911            'html' => $html
854da206178SAtari911        ]);
855da206178SAtari911    }
856da206178SAtari911
857e3a9f44cSAtari911    private function loadEventsSingleNamespace($namespace, $year, $month) {
85819378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
85919378907SAtari911        if ($namespace) {
86019378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
86119378907SAtari911        }
86219378907SAtari911        $dataDir .= 'calendar/';
86319378907SAtari911
864e3a9f44cSAtari911        // Load ONLY current month
86587ac9bf3SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
86619378907SAtari911        $events = [];
86719378907SAtari911        if (file_exists($eventFile)) {
86887ac9bf3SAtari911            $contents = file_get_contents($eventFile);
86987ac9bf3SAtari911            $decoded = json_decode($contents, true);
87087ac9bf3SAtari911            if (json_last_error() === JSON_ERROR_NONE) {
87187ac9bf3SAtari911                $events = $decoded;
87287ac9bf3SAtari911            }
87387ac9bf3SAtari911        }
87487ac9bf3SAtari911
875e3a9f44cSAtari911        return $events;
87687ac9bf3SAtari911    }
877e3a9f44cSAtari911
878e3a9f44cSAtari911    private function loadEventsMultiNamespace($namespaces, $year, $month) {
879e3a9f44cSAtari911        // Check for wildcard pattern
880e3a9f44cSAtari911        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
881e3a9f44cSAtari911            $baseNamespace = $matches[1];
882e3a9f44cSAtari911            return $this->loadEventsWildcard($baseNamespace, $year, $month);
883e3a9f44cSAtari911        }
884e3a9f44cSAtari911
885e3a9f44cSAtari911        // Check for root wildcard
886e3a9f44cSAtari911        if ($namespaces === '*') {
887e3a9f44cSAtari911            return $this->loadEventsWildcard('', $year, $month);
888e3a9f44cSAtari911        }
889e3a9f44cSAtari911
890e3a9f44cSAtari911        // Parse namespace list (semicolon separated)
891e3a9f44cSAtari911        $namespaceList = array_map('trim', explode(';', $namespaces));
892e3a9f44cSAtari911
893e3a9f44cSAtari911        // Load events from all namespaces
894e3a9f44cSAtari911        $allEvents = [];
895e3a9f44cSAtari911        foreach ($namespaceList as $ns) {
896e3a9f44cSAtari911            $ns = trim($ns);
897e3a9f44cSAtari911            if (empty($ns)) continue;
898e3a9f44cSAtari911
899e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
900e3a9f44cSAtari911
901e3a9f44cSAtari911            // Add namespace tag to each event
902e3a9f44cSAtari911            foreach ($events as $dateKey => $dayEvents) {
903e3a9f44cSAtari911                if (!isset($allEvents[$dateKey])) {
904e3a9f44cSAtari911                    $allEvents[$dateKey] = [];
905e3a9f44cSAtari911                }
906e3a9f44cSAtari911                foreach ($dayEvents as $event) {
907e3a9f44cSAtari911                    $event['_namespace'] = $ns;
908e3a9f44cSAtari911                    $allEvents[$dateKey][] = $event;
909e3a9f44cSAtari911                }
91087ac9bf3SAtari911            }
91187ac9bf3SAtari911        }
91287ac9bf3SAtari911
913e3a9f44cSAtari911        return $allEvents;
914e3a9f44cSAtari911    }
91519378907SAtari911
916e3a9f44cSAtari911    private function loadEventsWildcard($baseNamespace, $year, $month) {
917e3a9f44cSAtari911        $dataDir = DOKU_INC . 'data/meta/';
918e3a9f44cSAtari911        if ($baseNamespace) {
919e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
920e3a9f44cSAtari911        }
921e3a9f44cSAtari911
922e3a9f44cSAtari911        $allEvents = [];
923e3a9f44cSAtari911
924e3a9f44cSAtari911        // First, load events from the base namespace itself
925e3a9f44cSAtari911        $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
926e3a9f44cSAtari911
927e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
928e3a9f44cSAtari911            if (!isset($allEvents[$dateKey])) {
929e3a9f44cSAtari911                $allEvents[$dateKey] = [];
930e3a9f44cSAtari911            }
931e3a9f44cSAtari911            foreach ($dayEvents as $event) {
932e3a9f44cSAtari911                $event['_namespace'] = $baseNamespace;
933e3a9f44cSAtari911                $allEvents[$dateKey][] = $event;
934e3a9f44cSAtari911            }
935e3a9f44cSAtari911        }
936e3a9f44cSAtari911
937e3a9f44cSAtari911        // Recursively find all subdirectories
938e3a9f44cSAtari911        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
939e3a9f44cSAtari911
940e3a9f44cSAtari911        return $allEvents;
941e3a9f44cSAtari911    }
942e3a9f44cSAtari911
943e3a9f44cSAtari911    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
944e3a9f44cSAtari911        if (!is_dir($dir)) return;
945e3a9f44cSAtari911
946e3a9f44cSAtari911        $items = scandir($dir);
947e3a9f44cSAtari911        foreach ($items as $item) {
948e3a9f44cSAtari911            if ($item === '.' || $item === '..') continue;
949e3a9f44cSAtari911
950e3a9f44cSAtari911            $path = $dir . $item;
951e3a9f44cSAtari911            if (is_dir($path) && $item !== 'calendar') {
952e3a9f44cSAtari911                // This is a namespace directory
953e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
954e3a9f44cSAtari911
955e3a9f44cSAtari911                // Load events from this namespace
956e3a9f44cSAtari911                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
957e3a9f44cSAtari911                foreach ($events as $dateKey => $dayEvents) {
958e3a9f44cSAtari911                    if (!isset($allEvents[$dateKey])) {
959e3a9f44cSAtari911                        $allEvents[$dateKey] = [];
960e3a9f44cSAtari911                    }
961e3a9f44cSAtari911                    foreach ($dayEvents as $event) {
962e3a9f44cSAtari911                        $event['_namespace'] = $namespace;
963e3a9f44cSAtari911                        $allEvents[$dateKey][] = $event;
964e3a9f44cSAtari911                    }
965e3a9f44cSAtari911                }
966e3a9f44cSAtari911
967e3a9f44cSAtari911                // Recurse into subdirectories
968e3a9f44cSAtari911                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
969e3a9f44cSAtari911            }
970e3a9f44cSAtari911        }
97119378907SAtari911    }
97219378907SAtari911
97396df7d3eSAtari911    /**
97496df7d3eSAtari911     * Search all dates for events matching the search term
97596df7d3eSAtari911     */
97696df7d3eSAtari911    private function searchAllDates() {
97796df7d3eSAtari911        global $INPUT;
97896df7d3eSAtari911
97996df7d3eSAtari911        $searchTerm = strtolower(trim($INPUT->str('search', '')));
98096df7d3eSAtari911        $namespace = $INPUT->str('namespace', '');
98196df7d3eSAtari911
98296df7d3eSAtari911        if (strlen($searchTerm) < 2) {
98396df7d3eSAtari911            echo json_encode(['success' => false, 'error' => 'Search term too short']);
98496df7d3eSAtari911            return;
98596df7d3eSAtari911        }
98696df7d3eSAtari911
98796df7d3eSAtari911        // Normalize search term for fuzzy matching
98896df7d3eSAtari911        $normalizedSearch = $this->normalizeForSearch($searchTerm);
98996df7d3eSAtari911
99096df7d3eSAtari911        $results = [];
99196df7d3eSAtari911        $dataDir = DOKU_INC . 'data/meta/';
99296df7d3eSAtari911
99396df7d3eSAtari911        // Helper to search calendar directory
99496df7d3eSAtari911        $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results) {
99596df7d3eSAtari911            if (!is_dir($calDir)) return;
99696df7d3eSAtari911
99796df7d3eSAtari911            foreach (glob($calDir . '/*.json') as $file) {
99896df7d3eSAtari911                $data = @json_decode(file_get_contents($file), true);
99996df7d3eSAtari911                if (!$data || !is_array($data)) continue;
100096df7d3eSAtari911
100196df7d3eSAtari911                foreach ($data as $dateKey => $dayEvents) {
100296df7d3eSAtari911                    // Skip non-date keys
100396df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
100496df7d3eSAtari911                    if (!is_array($dayEvents)) continue;
100596df7d3eSAtari911
100696df7d3eSAtari911                    foreach ($dayEvents as $event) {
100796df7d3eSAtari911                        if (!isset($event['title'])) continue;
100896df7d3eSAtari911
100996df7d3eSAtari911                        // Build searchable text
101096df7d3eSAtari911                        $searchableText = strtolower($event['title']);
101196df7d3eSAtari911                        if (isset($event['description'])) {
101296df7d3eSAtari911                            $searchableText .= ' ' . strtolower($event['description']);
101396df7d3eSAtari911                        }
101496df7d3eSAtari911
101596df7d3eSAtari911                        // Normalize for fuzzy matching
101696df7d3eSAtari911                        $normalizedText = $this->normalizeForSearch($searchableText);
101796df7d3eSAtari911
101896df7d3eSAtari911                        // Check if matches using fuzzy match
101996df7d3eSAtari911                        if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) {
102096df7d3eSAtari911                            $results[] = [
102196df7d3eSAtari911                                'date' => $dateKey,
102296df7d3eSAtari911                                'title' => $event['title'],
102396df7d3eSAtari911                                'time' => isset($event['time']) ? $event['time'] : '',
102496df7d3eSAtari911                                'endTime' => isset($event['endTime']) ? $event['endTime'] : '',
102596df7d3eSAtari911                                'color' => isset($event['color']) ? $event['color'] : '',
102696df7d3eSAtari911                                'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace,
102796df7d3eSAtari911                                'id' => isset($event['id']) ? $event['id'] : ''
102896df7d3eSAtari911                            ];
102996df7d3eSAtari911                        }
103096df7d3eSAtari911                    }
103196df7d3eSAtari911                }
103296df7d3eSAtari911            }
103396df7d3eSAtari911        };
103496df7d3eSAtari911
103596df7d3eSAtari911        // Search root calendar directory
103696df7d3eSAtari911        $searchCalendarDir($dataDir . 'calendar', '');
103796df7d3eSAtari911
103896df7d3eSAtari911        // Search namespace directories
103996df7d3eSAtari911        $this->searchNamespaceDirs($dataDir, $searchCalendarDir);
104096df7d3eSAtari911
104196df7d3eSAtari911        // Sort results by date (newest first for past, oldest first for future)
104296df7d3eSAtari911        usort($results, function($a, $b) {
104396df7d3eSAtari911            return strcmp($a['date'], $b['date']);
104496df7d3eSAtari911        });
104596df7d3eSAtari911
104696df7d3eSAtari911        // Limit results
104796df7d3eSAtari911        $results = array_slice($results, 0, 50);
104896df7d3eSAtari911
104996df7d3eSAtari911        echo json_encode([
105096df7d3eSAtari911            'success' => true,
105196df7d3eSAtari911            'results' => $results,
105296df7d3eSAtari911            'total' => count($results)
105396df7d3eSAtari911        ]);
105496df7d3eSAtari911    }
105596df7d3eSAtari911
105696df7d3eSAtari911    /**
105796df7d3eSAtari911     * Check if normalized text matches normalized search term
105896df7d3eSAtari911     * Supports multi-word search where all words must be present
105996df7d3eSAtari911     */
106096df7d3eSAtari911    private function fuzzyMatchText($normalizedText, $normalizedSearch) {
106196df7d3eSAtari911        // Direct substring match
106296df7d3eSAtari911        if (strpos($normalizedText, $normalizedSearch) !== false) {
106396df7d3eSAtari911            return true;
106496df7d3eSAtari911        }
106596df7d3eSAtari911
106696df7d3eSAtari911        // Multi-word search: all words must be present
106796df7d3eSAtari911        $searchWords = array_filter(explode(' ', $normalizedSearch));
106896df7d3eSAtari911        if (count($searchWords) > 1) {
106996df7d3eSAtari911            foreach ($searchWords as $word) {
107096df7d3eSAtari911                if (strlen($word) > 0 && strpos($normalizedText, $word) === false) {
107196df7d3eSAtari911                    return false;
107296df7d3eSAtari911                }
107396df7d3eSAtari911            }
107496df7d3eSAtari911            return true;
107596df7d3eSAtari911        }
107696df7d3eSAtari911
107796df7d3eSAtari911        return false;
107896df7d3eSAtari911    }
107996df7d3eSAtari911
108096df7d3eSAtari911    /**
108196df7d3eSAtari911     * Normalize text for fuzzy search matching
108296df7d3eSAtari911     * Removes apostrophes, extra spaces, and common variations
108396df7d3eSAtari911     */
108496df7d3eSAtari911    private function normalizeForSearch($text) {
108596df7d3eSAtari911        // Convert to lowercase
108696df7d3eSAtari911        $text = strtolower($text);
108796df7d3eSAtari911
108896df7d3eSAtari911        // Remove apostrophes and quotes (father's -> fathers)
108996df7d3eSAtari911        $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text);
109096df7d3eSAtari911
109196df7d3eSAtari911        // Normalize dashes and underscores to spaces
109296df7d3eSAtari911        $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text);
109396df7d3eSAtari911
109496df7d3eSAtari911        // Remove other punctuation but keep letters, numbers, spaces
109596df7d3eSAtari911        $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text);
109696df7d3eSAtari911
109796df7d3eSAtari911        // Normalize multiple spaces to single space
109896df7d3eSAtari911        $text = preg_replace('/\s+/', ' ', $text);
109996df7d3eSAtari911
110096df7d3eSAtari911        // Trim
110196df7d3eSAtari911        $text = trim($text);
110296df7d3eSAtari911
110396df7d3eSAtari911        return $text;
110496df7d3eSAtari911    }
110596df7d3eSAtari911
110696df7d3eSAtari911    /**
110796df7d3eSAtari911     * Recursively search namespace directories for calendar data
110896df7d3eSAtari911     */
110996df7d3eSAtari911    private function searchNamespaceDirs($baseDir, $callback) {
111096df7d3eSAtari911        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
111196df7d3eSAtari911            $name = basename($nsDir);
111296df7d3eSAtari911            if ($name === 'calendar') continue;
111396df7d3eSAtari911
111496df7d3eSAtari911            $calDir = $nsDir . '/calendar';
111596df7d3eSAtari911            if (is_dir($calDir)) {
111696df7d3eSAtari911                $relPath = str_replace(DOKU_INC . 'data/meta/', '', $nsDir);
111796df7d3eSAtari911                $namespace = str_replace('/', ':', $relPath);
111896df7d3eSAtari911                $callback($calDir, $namespace);
111996df7d3eSAtari911            }
112096df7d3eSAtari911
112196df7d3eSAtari911            // Recurse
112296df7d3eSAtari911            $this->searchNamespaceDirs($nsDir . '/', $callback);
112396df7d3eSAtari911        }
112496df7d3eSAtari911    }
112596df7d3eSAtari911
112619378907SAtari911    private function toggleTaskComplete() {
112719378907SAtari911        global $INPUT;
112819378907SAtari911
112919378907SAtari911        $namespace = $INPUT->str('namespace', '');
113019378907SAtari911        $date = $INPUT->str('date');
113119378907SAtari911        $eventId = $INPUT->str('eventId');
113219378907SAtari911        $completed = $INPUT->bool('completed', false);
113319378907SAtari911
1134e3a9f44cSAtari911        // Find where the event actually lives
1135e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
1136e3a9f44cSAtari911
1137e3a9f44cSAtari911        if ($storedNamespace === null) {
1138e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
1139e3a9f44cSAtari911            return;
1140e3a9f44cSAtari911        }
1141e3a9f44cSAtari911
1142e3a9f44cSAtari911        // Use the found namespace
1143e3a9f44cSAtari911        $namespace = $storedNamespace;
1144e3a9f44cSAtari911
114519378907SAtari911        list($year, $month, $day) = explode('-', $date);
114619378907SAtari911
114719378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
114819378907SAtari911        if ($namespace) {
114919378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
115019378907SAtari911        }
115119378907SAtari911        $dataDir .= 'calendar/';
115219378907SAtari911
115319378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
115419378907SAtari911
115519378907SAtari911        if (file_exists($eventFile)) {
115619378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
115719378907SAtari911
115819378907SAtari911            if (isset($events[$date])) {
1159*815440faSAtari911                $eventTitle = '';
116019378907SAtari911                foreach ($events[$date] as $key => $event) {
116119378907SAtari911                    if ($event['id'] === $eventId) {
116219378907SAtari911                        $events[$date][$key]['completed'] = $completed;
1163*815440faSAtari911                        $eventTitle = $event['title'] ?? '';
116419378907SAtari911                        break;
116519378907SAtari911                    }
116619378907SAtari911                }
116719378907SAtari911
1168*815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
1169*815440faSAtari911
1170*815440faSAtari911                // Audit logging
1171*815440faSAtari911                $audit = $this->getAuditLogger();
1172*815440faSAtari911                $audit->logTaskToggle($namespace, $date, $eventId, $eventTitle, $completed);
1173*815440faSAtari911
117419378907SAtari911                echo json_encode(['success' => true, 'events' => $events]);
117519378907SAtari911                return;
117619378907SAtari911            }
117719378907SAtari911        }
117819378907SAtari911
117919378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
118019378907SAtari911    }
118119378907SAtari911
1182*815440faSAtari911    // ========================================================================
1183*815440faSAtari911    // GOOGLE CALENDAR SYNC HANDLERS
1184*815440faSAtari911    // ========================================================================
1185*815440faSAtari911
1186*815440faSAtari911    /**
1187*815440faSAtari911     * Get Google OAuth authorization URL
1188*815440faSAtari911     */
1189*815440faSAtari911    private function getGoogleAuthUrl() {
1190*815440faSAtari911        if (!auth_isadmin()) {
1191*815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1192*815440faSAtari911            return;
1193*815440faSAtari911        }
1194*815440faSAtari911
1195*815440faSAtari911        $sync = $this->getGoogleSync();
1196*815440faSAtari911
1197*815440faSAtari911        if (!$sync->isConfigured()) {
1198*815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Google sync not configured. Please enter Client ID and Secret first.']);
1199*815440faSAtari911            return;
1200*815440faSAtari911        }
1201*815440faSAtari911
1202*815440faSAtari911        // Build redirect URI
1203*815440faSAtari911        $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback';
1204*815440faSAtari911
1205*815440faSAtari911        $authUrl = $sync->getAuthUrl($redirectUri);
1206*815440faSAtari911
1207*815440faSAtari911        echo json_encode(['success' => true, 'url' => $authUrl]);
1208*815440faSAtari911    }
1209*815440faSAtari911
1210*815440faSAtari911    /**
1211*815440faSAtari911     * Handle Google OAuth callback
1212*815440faSAtari911     */
1213*815440faSAtari911    private function handleGoogleCallback() {
1214*815440faSAtari911        global $INPUT;
1215*815440faSAtari911
1216*815440faSAtari911        $code = $INPUT->str('code');
1217*815440faSAtari911        $state = $INPUT->str('state');
1218*815440faSAtari911        $error = $INPUT->str('error');
1219*815440faSAtari911
1220*815440faSAtari911        // Check for OAuth error
1221*815440faSAtari911        if ($error) {
1222*815440faSAtari911            $this->showGoogleCallbackResult(false, 'Authorization denied: ' . $error);
1223*815440faSAtari911            return;
1224*815440faSAtari911        }
1225*815440faSAtari911
1226*815440faSAtari911        if (!$code) {
1227*815440faSAtari911            $this->showGoogleCallbackResult(false, 'No authorization code received');
1228*815440faSAtari911            return;
1229*815440faSAtari911        }
1230*815440faSAtari911
1231*815440faSAtari911        $sync = $this->getGoogleSync();
1232*815440faSAtari911
1233*815440faSAtari911        // Verify state for CSRF protection
1234*815440faSAtari911        if (!$sync->verifyState($state)) {
1235*815440faSAtari911            $this->showGoogleCallbackResult(false, 'Invalid state parameter');
1236*815440faSAtari911            return;
1237*815440faSAtari911        }
1238*815440faSAtari911
1239*815440faSAtari911        // Exchange code for tokens
1240*815440faSAtari911        $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback';
1241*815440faSAtari911        $result = $sync->handleCallback($code, $redirectUri);
1242*815440faSAtari911
1243*815440faSAtari911        if ($result['success']) {
1244*815440faSAtari911            $this->showGoogleCallbackResult(true, 'Successfully connected to Google Calendar!');
1245*815440faSAtari911        } else {
1246*815440faSAtari911            $this->showGoogleCallbackResult(false, $result['error']);
1247*815440faSAtari911        }
1248*815440faSAtari911    }
1249*815440faSAtari911
1250*815440faSAtari911    /**
1251*815440faSAtari911     * Show OAuth callback result page
1252*815440faSAtari911     */
1253*815440faSAtari911    private function showGoogleCallbackResult($success, $message) {
1254*815440faSAtari911        $status = $success ? 'Success!' : 'Error';
1255*815440faSAtari911        $color = $success ? '#2ecc71' : '#e74c3c';
1256*815440faSAtari911
1257*815440faSAtari911        echo '<!DOCTYPE html>
1258*815440faSAtari911<html>
1259*815440faSAtari911<head>
1260*815440faSAtari911    <title>Google Calendar - ' . $status . '</title>
1261*815440faSAtari911    <style>
1262*815440faSAtari911        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1263*815440faSAtari911               display: flex; align-items: center; justify-content: center;
1264*815440faSAtari911               min-height: 100vh; margin: 0; background: #f5f5f5; }
1265*815440faSAtari911        .card { background: white; padding: 40px; border-radius: 12px;
1266*815440faSAtari911                box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
1267*815440faSAtari911        h1 { color: ' . $color . '; margin: 0 0 16px 0; }
1268*815440faSAtari911        p { color: #666; margin: 0 0 24px 0; }
1269*815440faSAtari911        button { background: #3498db; color: white; border: none; padding: 12px 24px;
1270*815440faSAtari911                 border-radius: 6px; cursor: pointer; font-size: 14px; }
1271*815440faSAtari911        button:hover { background: #2980b9; }
1272*815440faSAtari911    </style>
1273*815440faSAtari911</head>
1274*815440faSAtari911<body>
1275*815440faSAtari911    <div class="card">
1276*815440faSAtari911        <h1>' . ($success ? '✓' : '✕') . ' ' . $status . '</h1>
1277*815440faSAtari911        <p>' . htmlspecialchars($message) . '</p>
1278*815440faSAtari911        <button onclick="window.close()">Close Window</button>
1279*815440faSAtari911    </div>
1280*815440faSAtari911    <script>
1281*815440faSAtari911        // Notify parent window
1282*815440faSAtari911        if (window.opener) {
1283*815440faSAtari911            window.opener.postMessage({ type: "google_auth_complete", success: ' . ($success ? 'true' : 'false') . ' }, "*");
1284*815440faSAtari911        }
1285*815440faSAtari911    </script>
1286*815440faSAtari911</body>
1287*815440faSAtari911</html>';
1288*815440faSAtari911    }
1289*815440faSAtari911
1290*815440faSAtari911    /**
1291*815440faSAtari911     * Get Google sync status
1292*815440faSAtari911     */
1293*815440faSAtari911    private function getGoogleStatus() {
1294*815440faSAtari911        $sync = $this->getGoogleSync();
1295*815440faSAtari911        echo json_encode(['success' => true, 'status' => $sync->getStatus()]);
1296*815440faSAtari911    }
1297*815440faSAtari911
1298*815440faSAtari911    /**
1299*815440faSAtari911     * Get list of Google calendars
1300*815440faSAtari911     */
1301*815440faSAtari911    private function getGoogleCalendars() {
1302*815440faSAtari911        if (!auth_isadmin()) {
1303*815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1304*815440faSAtari911            return;
1305*815440faSAtari911        }
1306*815440faSAtari911
1307*815440faSAtari911        $sync = $this->getGoogleSync();
1308*815440faSAtari911        $result = $sync->getCalendars();
1309*815440faSAtari911        echo json_encode($result);
1310*815440faSAtari911    }
1311*815440faSAtari911
1312*815440faSAtari911    /**
1313*815440faSAtari911     * Import events from Google Calendar
1314*815440faSAtari911     */
1315*815440faSAtari911    private function googleImport() {
1316*815440faSAtari911        global $INPUT;
1317*815440faSAtari911
1318*815440faSAtari911        if (!auth_isadmin()) {
1319*815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1320*815440faSAtari911            return;
1321*815440faSAtari911        }
1322*815440faSAtari911
1323*815440faSAtari911        $namespace = $INPUT->str('namespace', '');
1324*815440faSAtari911        $startDate = $INPUT->str('startDate', '');
1325*815440faSAtari911        $endDate = $INPUT->str('endDate', '');
1326*815440faSAtari911
1327*815440faSAtari911        $sync = $this->getGoogleSync();
1328*815440faSAtari911        $result = $sync->importEvents($namespace, $startDate ?: null, $endDate ?: null);
1329*815440faSAtari911
1330*815440faSAtari911        echo json_encode($result);
1331*815440faSAtari911    }
1332*815440faSAtari911
1333*815440faSAtari911    /**
1334*815440faSAtari911     * Export events to Google Calendar
1335*815440faSAtari911     */
1336*815440faSAtari911    private function googleExport() {
1337*815440faSAtari911        global $INPUT;
1338*815440faSAtari911
1339*815440faSAtari911        if (!auth_isadmin()) {
1340*815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1341*815440faSAtari911            return;
1342*815440faSAtari911        }
1343*815440faSAtari911
1344*815440faSAtari911        $namespace = $INPUT->str('namespace', '');
1345*815440faSAtari911        $startDate = $INPUT->str('startDate', '');
1346*815440faSAtari911        $endDate = $INPUT->str('endDate', '');
1347*815440faSAtari911
1348*815440faSAtari911        $sync = $this->getGoogleSync();
1349*815440faSAtari911        $result = $sync->exportEvents($namespace, $startDate ?: null, $endDate ?: null);
1350*815440faSAtari911
1351*815440faSAtari911        echo json_encode($result);
1352*815440faSAtari911    }
1353*815440faSAtari911
1354*815440faSAtari911    /**
1355*815440faSAtari911     * Disconnect from Google Calendar
1356*815440faSAtari911     */
1357*815440faSAtari911    private function googleDisconnect() {
1358*815440faSAtari911        if (!auth_isadmin()) {
1359*815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1360*815440faSAtari911            return;
1361*815440faSAtari911        }
1362*815440faSAtari911
1363*815440faSAtari911        $sync = $this->getGoogleSync();
1364*815440faSAtari911        $sync->disconnect();
1365*815440faSAtari911
1366*815440faSAtari911        echo json_encode(['success' => true]);
1367*815440faSAtari911    }
1368*815440faSAtari911
136996df7d3eSAtari911    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime,
137096df7d3eSAtari911                                          $description, $color, $isTask, $recurrenceType, $recurrenceInterval,
137196df7d3eSAtari911                                          $recurrenceEnd, $weekDays, $monthlyType, $monthDay,
137296df7d3eSAtari911                                          $ordinalWeek, $ordinalDay, $baseId) {
137387ac9bf3SAtari911        $dataDir = DOKU_INC . 'data/meta/';
137487ac9bf3SAtari911        if ($namespace) {
137587ac9bf3SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
137687ac9bf3SAtari911        }
137787ac9bf3SAtari911        $dataDir .= 'calendar/';
137887ac9bf3SAtari911
137987ac9bf3SAtari911        if (!is_dir($dataDir)) {
138087ac9bf3SAtari911            mkdir($dataDir, 0755, true);
138187ac9bf3SAtari911        }
138287ac9bf3SAtari911
138396df7d3eSAtari911        // Ensure interval is at least 1
138496df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
138587ac9bf3SAtari911
138687ac9bf3SAtari911        // Set maximum end date if not specified (1 year from start)
138787ac9bf3SAtari911        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
138887ac9bf3SAtari911
138987ac9bf3SAtari911        // Calculate event duration for multi-day events
139087ac9bf3SAtari911        $eventDuration = 0;
139187ac9bf3SAtari911        if ($endDate && $endDate !== $startDate) {
139287ac9bf3SAtari911            $start = new DateTime($startDate);
139387ac9bf3SAtari911            $end = new DateTime($endDate);
139487ac9bf3SAtari911            $eventDuration = $start->diff($end)->days;
139587ac9bf3SAtari911        }
139687ac9bf3SAtari911
139787ac9bf3SAtari911        // Generate recurring events
139887ac9bf3SAtari911        $currentDate = new DateTime($startDate);
139987ac9bf3SAtari911        $endLimit = new DateTime($maxEnd);
140087ac9bf3SAtari911        $counter = 0;
140196df7d3eSAtari911        $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year)
140296df7d3eSAtari911
140396df7d3eSAtari911        // For weekly with specific days, we need to track the interval counter differently
140496df7d3eSAtari911        $weekCounter = 0;
140596df7d3eSAtari911        $startWeekNumber = (int)$currentDate->format('W');
140696df7d3eSAtari911        $startYear = (int)$currentDate->format('Y');
140787ac9bf3SAtari911
140887ac9bf3SAtari911        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
140996df7d3eSAtari911            $shouldCreateEvent = false;
141096df7d3eSAtari911
141196df7d3eSAtari911            switch ($recurrenceType) {
141296df7d3eSAtari911                case 'daily':
141396df7d3eSAtari911                    // Every N days from start
141496df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
141596df7d3eSAtari911                    $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0);
141696df7d3eSAtari911                    break;
141796df7d3eSAtari911
141896df7d3eSAtari911                case 'weekly':
141996df7d3eSAtari911                    // Every N weeks, on specified days
142096df7d3eSAtari911                    $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat
142196df7d3eSAtari911
142296df7d3eSAtari911                    // Calculate weeks since start
142396df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
142496df7d3eSAtari911                    $weeksSinceStart = floor($daysSinceStart / 7);
142596df7d3eSAtari911
142696df7d3eSAtari911                    // Check if we're in the right week (every N weeks)
142796df7d3eSAtari911                    $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0);
142896df7d3eSAtari911
142996df7d3eSAtari911                    // Check if this day is selected
143096df7d3eSAtari911                    $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays);
143196df7d3eSAtari911
143296df7d3eSAtari911                    // For the first week, only include days on or after the start date
143396df7d3eSAtari911                    $isOnOrAfterStart = ($currentDate >= new DateTime($startDate));
143496df7d3eSAtari911
143596df7d3eSAtari911                    $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart;
143696df7d3eSAtari911                    break;
143796df7d3eSAtari911
143896df7d3eSAtari911                case 'monthly':
143996df7d3eSAtari911                    // Calculate months since start
144096df7d3eSAtari911                    $startDT = new DateTime($startDate);
144196df7d3eSAtari911                    $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) +
144296df7d3eSAtari911                                        ($currentDate->format('n') - $startDT->format('n'));
144396df7d3eSAtari911
144496df7d3eSAtari911                    // Check if we're in the right month (every N months)
144596df7d3eSAtari911                    $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0);
144696df7d3eSAtari911
144796df7d3eSAtari911                    if (!$isCorrectMonth) {
144896df7d3eSAtari911                        // Skip to first day of next potential month
144996df7d3eSAtari911                        $currentDate->modify('first day of next month');
145096df7d3eSAtari911                        continue 2;
145196df7d3eSAtari911                    }
145296df7d3eSAtari911
145396df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
145496df7d3eSAtari911                        // Specific day of month (e.g., 15th)
145596df7d3eSAtari911                        $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j');
145696df7d3eSAtari911                        $currentDay = (int)$currentDate->format('j');
145796df7d3eSAtari911                        $daysInMonth = (int)$currentDate->format('t');
145896df7d3eSAtari911
145996df7d3eSAtari911                        // If target day exceeds days in month, use last day
146096df7d3eSAtari911                        $effectiveTargetDay = min($targetDay, $daysInMonth);
146196df7d3eSAtari911                        $shouldCreateEvent = ($currentDay === $effectiveTargetDay);
146296df7d3eSAtari911                    } else {
146396df7d3eSAtari911                        // Ordinal weekday (e.g., 2nd Wednesday, last Friday)
146496df7d3eSAtari911                        $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay);
146596df7d3eSAtari911                    }
146696df7d3eSAtari911                    break;
146796df7d3eSAtari911
146896df7d3eSAtari911                case 'yearly':
146996df7d3eSAtari911                    // Every N years on same month/day
147096df7d3eSAtari911                    $startDT = new DateTime($startDate);
147196df7d3eSAtari911                    $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y');
147296df7d3eSAtari911
147396df7d3eSAtari911                    // Check if we're in the right year
147496df7d3eSAtari911                    $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0);
147596df7d3eSAtari911
147696df7d3eSAtari911                    // Check if it's the same month and day
147796df7d3eSAtari911                    $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d'));
147896df7d3eSAtari911
147996df7d3eSAtari911                    $shouldCreateEvent = $isCorrectYear && $sameMonthDay;
148096df7d3eSAtari911                    break;
148196df7d3eSAtari911
148296df7d3eSAtari911                default:
148396df7d3eSAtari911                    $shouldCreateEvent = false;
148496df7d3eSAtari911            }
148596df7d3eSAtari911
148696df7d3eSAtari911            if ($shouldCreateEvent) {
148787ac9bf3SAtari911                $dateKey = $currentDate->format('Y-m-d');
148887ac9bf3SAtari911                list($year, $month, $day) = explode('-', $dateKey);
148987ac9bf3SAtari911
149087ac9bf3SAtari911                // Calculate end date for this occurrence if multi-day
149187ac9bf3SAtari911                $occurrenceEndDate = '';
149287ac9bf3SAtari911                if ($eventDuration > 0) {
149387ac9bf3SAtari911                    $occurrenceEnd = clone $currentDate;
149487ac9bf3SAtari911                    $occurrenceEnd->modify('+' . $eventDuration . ' days');
149587ac9bf3SAtari911                    $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
149687ac9bf3SAtari911                }
149787ac9bf3SAtari911
149887ac9bf3SAtari911                // Load month file
149987ac9bf3SAtari911                $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
150087ac9bf3SAtari911                $events = [];
150187ac9bf3SAtari911                if (file_exists($eventFile)) {
150287ac9bf3SAtari911                    $events = json_decode(file_get_contents($eventFile), true);
150396df7d3eSAtari911                    if (!is_array($events)) $events = [];
150487ac9bf3SAtari911                }
150587ac9bf3SAtari911
150687ac9bf3SAtari911                if (!isset($events[$dateKey])) {
150787ac9bf3SAtari911                    $events[$dateKey] = [];
150887ac9bf3SAtari911                }
150987ac9bf3SAtari911
151087ac9bf3SAtari911                // Create event for this occurrence
151187ac9bf3SAtari911                $eventData = [
151287ac9bf3SAtari911                    'id' => $baseId . '-' . $counter,
151387ac9bf3SAtari911                    'title' => $title,
151487ac9bf3SAtari911                    'time' => $time,
15151d05cddcSAtari911                    'endTime' => $endTime,
151687ac9bf3SAtari911                    'description' => $description,
151787ac9bf3SAtari911                    'color' => $color,
151887ac9bf3SAtari911                    'isTask' => $isTask,
151987ac9bf3SAtari911                    'completed' => false,
152087ac9bf3SAtari911                    'endDate' => $occurrenceEndDate,
152187ac9bf3SAtari911                    'recurring' => true,
152287ac9bf3SAtari911                    'recurringId' => $baseId,
152396df7d3eSAtari911                    'recurrenceType' => $recurrenceType,
152496df7d3eSAtari911                    'recurrenceInterval' => $recurrenceInterval,
152596df7d3eSAtari911                    'namespace' => $namespace,
152687ac9bf3SAtari911                    'created' => date('Y-m-d H:i:s')
152787ac9bf3SAtari911                ];
152887ac9bf3SAtari911
152996df7d3eSAtari911                // Store additional recurrence info for reference
153096df7d3eSAtari911                if ($recurrenceType === 'weekly' && !empty($weekDays)) {
153196df7d3eSAtari911                    $eventData['weekDays'] = $weekDays;
153296df7d3eSAtari911                }
153396df7d3eSAtari911                if ($recurrenceType === 'monthly') {
153496df7d3eSAtari911                    $eventData['monthlyType'] = $monthlyType;
153596df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
153696df7d3eSAtari911                        $eventData['monthDay'] = $monthDay;
153796df7d3eSAtari911                    } else {
153896df7d3eSAtari911                        $eventData['ordinalWeek'] = $ordinalWeek;
153996df7d3eSAtari911                        $eventData['ordinalDay'] = $ordinalDay;
154096df7d3eSAtari911                    }
154196df7d3eSAtari911                }
154296df7d3eSAtari911
154387ac9bf3SAtari911                $events[$dateKey][] = $eventData;
1544*815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
154587ac9bf3SAtari911
154687ac9bf3SAtari911                $counter++;
154787ac9bf3SAtari911            }
154896df7d3eSAtari911
154996df7d3eSAtari911            // Move to next day (we check each day individually for complex patterns)
155096df7d3eSAtari911            $currentDate->modify('+1 day');
155196df7d3eSAtari911        }
155296df7d3eSAtari911    }
155396df7d3eSAtari911
155496df7d3eSAtari911    /**
155596df7d3eSAtari911     * Check if a date is the Nth occurrence of a weekday in its month
155696df7d3eSAtari911     * @param DateTime $date The date to check
155796df7d3eSAtari911     * @param int $ordinalWeek 1-5 for first-fifth, -1 for last
155896df7d3eSAtari911     * @param int $targetDayOfWeek 0=Sunday through 6=Saturday
155996df7d3eSAtari911     * @return bool
156096df7d3eSAtari911     */
156196df7d3eSAtari911    private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) {
156296df7d3eSAtari911        $currentDayOfWeek = (int)$date->format('w');
156396df7d3eSAtari911
156496df7d3eSAtari911        // First, check if it's the right day of week
156596df7d3eSAtari911        if ($currentDayOfWeek !== $targetDayOfWeek) {
156696df7d3eSAtari911            return false;
156796df7d3eSAtari911        }
156896df7d3eSAtari911
156996df7d3eSAtari911        $dayOfMonth = (int)$date->format('j');
157096df7d3eSAtari911        $daysInMonth = (int)$date->format('t');
157196df7d3eSAtari911
157296df7d3eSAtari911        if ($ordinalWeek === -1) {
157396df7d3eSAtari911            // Last occurrence: check if there's no more of this weekday in the month
157496df7d3eSAtari911            $daysRemaining = $daysInMonth - $dayOfMonth;
157596df7d3eSAtari911            return $daysRemaining < 7;
157696df7d3eSAtari911        } else {
157796df7d3eSAtari911            // Nth occurrence: check which occurrence this is
157896df7d3eSAtari911            $weekNumber = ceil($dayOfMonth / 7);
157996df7d3eSAtari911            return $weekNumber === $ordinalWeek;
158096df7d3eSAtari911        }
158187ac9bf3SAtari911    }
158287ac9bf3SAtari911
158319378907SAtari911    public function addAssets(Doku_Event $event, $param) {
158419378907SAtari911        $event->data['link'][] = array(
158519378907SAtari911            'type' => 'text/css',
158619378907SAtari911            'rel' => 'stylesheet',
158719378907SAtari911            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
158819378907SAtari911        );
158919378907SAtari911
159096df7d3eSAtari911        // Load the main calendar JavaScript
159196df7d3eSAtari911        // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues
159296df7d3eSAtari911        // The actual code is in calendar-main.js
159319378907SAtari911        $event->data['script'][] = array(
159419378907SAtari911            'type' => 'text/javascript',
159596df7d3eSAtari911            'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js'
159619378907SAtari911        );
159719378907SAtari911    }
1598e3a9f44cSAtari911    // Helper function to find an event's stored namespace
1599e3a9f44cSAtari911    private function findEventNamespace($eventId, $date, $searchNamespace) {
1600e3a9f44cSAtari911        list($year, $month, $day) = explode('-', $date);
1601e3a9f44cSAtari911
1602e3a9f44cSAtari911        // List of namespaces to check
1603e3a9f44cSAtari911        $namespacesToCheck = [''];
1604e3a9f44cSAtari911
1605e3a9f44cSAtari911        // If searchNamespace is a wildcard or multi, we need to search multiple locations
1606e3a9f44cSAtari911        if (!empty($searchNamespace)) {
1607e3a9f44cSAtari911            if (strpos($searchNamespace, ';') !== false) {
1608e3a9f44cSAtari911                // Multi-namespace - check each one
1609e3a9f44cSAtari911                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
1610e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1611e3a9f44cSAtari911            } elseif (strpos($searchNamespace, '*') !== false) {
1612e3a9f44cSAtari911                // Wildcard - need to scan directories
1613e3a9f44cSAtari911                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
1614e3a9f44cSAtari911                $namespacesToCheck = $this->findAllNamespaces($baseNs);
1615e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1616e3a9f44cSAtari911            } else {
1617e3a9f44cSAtari911                // Single namespace
1618e3a9f44cSAtari911                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
1619e3a9f44cSAtari911            }
1620e3a9f44cSAtari911        }
1621e3a9f44cSAtari911
162296df7d3eSAtari911        $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck)));
162396df7d3eSAtari911
1624e3a9f44cSAtari911        // Search for the event in all possible namespaces
1625e3a9f44cSAtari911        foreach ($namespacesToCheck as $ns) {
1626e3a9f44cSAtari911            $dataDir = DOKU_INC . 'data/meta/';
1627e3a9f44cSAtari911            if ($ns) {
1628e3a9f44cSAtari911                $dataDir .= str_replace(':', '/', $ns) . '/';
1629e3a9f44cSAtari911            }
1630e3a9f44cSAtari911            $dataDir .= 'calendar/';
1631e3a9f44cSAtari911
1632e3a9f44cSAtari911            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1633e3a9f44cSAtari911
1634e3a9f44cSAtari911            if (file_exists($eventFile)) {
1635e3a9f44cSAtari911                $events = json_decode(file_get_contents($eventFile), true);
1636e3a9f44cSAtari911                if (isset($events[$date])) {
1637e3a9f44cSAtari911                    foreach ($events[$date] as $evt) {
1638e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
163996df7d3eSAtari911                            // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace
164096df7d3eSAtari911                            // The directory is what matters for deletion - that's where the file actually is
164196df7d3eSAtari911                            $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')");
164296df7d3eSAtari911                            return $ns;
1643e3a9f44cSAtari911                        }
1644e3a9f44cSAtari911                    }
1645e3a9f44cSAtari911                }
1646e3a9f44cSAtari911            }
1647e3a9f44cSAtari911        }
1648e3a9f44cSAtari911
164996df7d3eSAtari911        $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace");
1650e3a9f44cSAtari911        return null; // Event not found
1651e3a9f44cSAtari911    }
1652e3a9f44cSAtari911
1653e3a9f44cSAtari911    // Helper to find all namespaces under a base namespace
1654e3a9f44cSAtari911    private function findAllNamespaces($baseNamespace) {
1655e3a9f44cSAtari911        $dataDir = DOKU_INC . 'data/meta/';
1656e3a9f44cSAtari911        if ($baseNamespace) {
1657e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
1658e3a9f44cSAtari911        }
1659e3a9f44cSAtari911
1660e3a9f44cSAtari911        $namespaces = [];
1661e3a9f44cSAtari911        if ($baseNamespace) {
1662e3a9f44cSAtari911            $namespaces[] = $baseNamespace;
1663e3a9f44cSAtari911        }
1664e3a9f44cSAtari911
1665e3a9f44cSAtari911        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
1666e3a9f44cSAtari911
166796df7d3eSAtari911        $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces)));
166896df7d3eSAtari911
1669e3a9f44cSAtari911        return $namespaces;
1670e3a9f44cSAtari911    }
1671e3a9f44cSAtari911
1672e3a9f44cSAtari911    // Recursive scan for namespaces
1673e3a9f44cSAtari911    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
1674e3a9f44cSAtari911        if (!is_dir($dir)) return;
1675e3a9f44cSAtari911
1676e3a9f44cSAtari911        $items = scandir($dir);
1677e3a9f44cSAtari911        foreach ($items as $item) {
1678e3a9f44cSAtari911            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
1679e3a9f44cSAtari911
1680e3a9f44cSAtari911            $path = $dir . $item;
1681e3a9f44cSAtari911            if (is_dir($path)) {
1682e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1683e3a9f44cSAtari911                $namespaces[] = $namespace;
1684e3a9f44cSAtari911                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
1685e3a9f44cSAtari911            }
1686e3a9f44cSAtari911        }
1687e3a9f44cSAtari911    }
16889ccd446eSAtari911
16899ccd446eSAtari911    /**
16909ccd446eSAtari911     * Delete all instances of a recurring event across all months
16919ccd446eSAtari911     */
16929ccd446eSAtari911    private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) {
16939ccd446eSAtari911        // Scan all JSON files in the calendar directory
16949ccd446eSAtari911        $calendarFiles = glob($dataDir . '*.json');
16959ccd446eSAtari911
16969ccd446eSAtari911        foreach ($calendarFiles as $file) {
16979ccd446eSAtari911            $modified = false;
16989ccd446eSAtari911            $events = json_decode(file_get_contents($file), true);
16999ccd446eSAtari911
17009ccd446eSAtari911            if (!$events) continue;
17019ccd446eSAtari911
17029ccd446eSAtari911            // Check each date in the file
17039ccd446eSAtari911            foreach ($events as $date => &$dayEvents) {
17049ccd446eSAtari911                // Filter out events with matching recurringId
17059ccd446eSAtari911                $originalCount = count($dayEvents);
17069ccd446eSAtari911                $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) {
17079ccd446eSAtari911                    $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
17089ccd446eSAtari911                    return $eventRecurringId !== $recurringId;
17099ccd446eSAtari911                }));
17109ccd446eSAtari911
17119ccd446eSAtari911                if (count($dayEvents) !== $originalCount) {
17129ccd446eSAtari911                    $modified = true;
17139ccd446eSAtari911                }
17149ccd446eSAtari911
17159ccd446eSAtari911                // Remove empty dates
17169ccd446eSAtari911                if (empty($dayEvents)) {
17179ccd446eSAtari911                    unset($events[$date]);
17189ccd446eSAtari911                }
17199ccd446eSAtari911            }
17209ccd446eSAtari911
17219ccd446eSAtari911            // Save if modified
17229ccd446eSAtari911            if ($modified) {
1723*815440faSAtari911                CalendarFileHandler::writeJson($file, $events);
17249ccd446eSAtari911            }
17259ccd446eSAtari911        }
17269ccd446eSAtari911    }
17279ccd446eSAtari911
17289ccd446eSAtari911    /**
17299ccd446eSAtari911     * Get existing event data for preserving unchanged fields during edit
17309ccd446eSAtari911     */
17319ccd446eSAtari911    private function getExistingEventData($eventId, $date, $namespace) {
17329ccd446eSAtari911        list($year, $month, $day) = explode('-', $date);
17339ccd446eSAtari911
17349ccd446eSAtari911        $dataDir = DOKU_INC . 'data/meta/';
17359ccd446eSAtari911        if ($namespace) {
17369ccd446eSAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
17379ccd446eSAtari911        }
17389ccd446eSAtari911        $dataDir .= 'calendar/';
17399ccd446eSAtari911
17409ccd446eSAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
17419ccd446eSAtari911
17429ccd446eSAtari911        if (!file_exists($eventFile)) {
17439ccd446eSAtari911            return null;
17449ccd446eSAtari911        }
17459ccd446eSAtari911
17469ccd446eSAtari911        $events = json_decode(file_get_contents($eventFile), true);
17479ccd446eSAtari911
17489ccd446eSAtari911        if (!isset($events[$date])) {
17499ccd446eSAtari911            return null;
17509ccd446eSAtari911        }
17519ccd446eSAtari911
17529ccd446eSAtari911        // Find the event by ID
17539ccd446eSAtari911        foreach ($events[$date] as $event) {
17549ccd446eSAtari911            if ($event['id'] === $eventId) {
17559ccd446eSAtari911                return $event;
17569ccd446eSAtari911            }
17579ccd446eSAtari911        }
17589ccd446eSAtari911
17599ccd446eSAtari911        return null;
17609ccd446eSAtari911    }
176119378907SAtari911}
1762