xref: /plugin/calendar/action.php (revision fb563d5f1ab751bc5683ff2e367f04c46c6e895e)
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*fb563d5fSAtari911 * @version 7.6.0
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
17815440faSAtari911// Load new class dependencies
18815440faSAtari911require_once __DIR__ . '/classes/FileHandler.php';
19815440faSAtari911require_once __DIR__ . '/classes/EventCache.php';
20815440faSAtari911require_once __DIR__ . '/classes/RateLimiter.php';
21815440faSAtari911require_once __DIR__ . '/classes/EventManager.php';
22815440faSAtari911require_once __DIR__ . '/classes/AuditLogger.php';
23815440faSAtari911require_once __DIR__ . '/classes/GoogleCalendarSync.php';
24815440faSAtari911
2519378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin {
2619378907SAtari911
272866e827SAtari911    /**
282866e827SAtari911     * Get the meta directory path (farm-safe)
292866e827SAtari911     * Uses $conf['metadir'] instead of hardcoded DOKU_INC . 'data/meta/'
302866e827SAtari911     */
312866e827SAtari911    private function metaDir() {
322866e827SAtari911        global $conf;
332866e827SAtari911        return rtrim($conf['metadir'], '/') . '/';
342866e827SAtari911    }
352866e827SAtari911
362866e827SAtari911    /**
372866e827SAtari911     * Check if the current user has read access to a namespace
382866e827SAtari911     * Uses DokuWiki's ACL system for farm-safe permission checks
392866e827SAtari911     *
402866e827SAtari911     * @param string $namespace Namespace to check (empty = root)
412866e827SAtari911     * @return bool True if user has at least AUTH_READ
422866e827SAtari911     */
432866e827SAtari911    private function checkNamespaceRead($namespace) {
442866e827SAtari911        if (empty($namespace) || $namespace === '*') return true;
452866e827SAtari911        // Strip wildcards and semicolons for ACL check
462866e827SAtari911        $ns = str_replace(['*', ';'], '', $namespace);
472866e827SAtari911        if (empty($ns)) return true;
482866e827SAtari911        $perm = auth_quickaclcheck($ns . ':*');
492866e827SAtari911        return ($perm >= AUTH_READ);
502866e827SAtari911    }
512866e827SAtari911
522866e827SAtari911    /**
532866e827SAtari911     * Check if the current user has edit access to a namespace
542866e827SAtari911     *
552866e827SAtari911     * @param string $namespace Namespace to check (empty = root)
562866e827SAtari911     * @return bool True if user has at least AUTH_EDIT
572866e827SAtari911     */
582866e827SAtari911    private function checkNamespaceEdit($namespace) {
592866e827SAtari911        if (empty($namespace)) return true;
602866e827SAtari911        $perm = auth_quickaclcheck($namespace . ':*');
612866e827SAtari911        return ($perm >= AUTH_EDIT);
622866e827SAtari911    }
632866e827SAtari911
64815440faSAtari911    /** @var CalendarAuditLogger */
65815440faSAtari911    private $auditLogger = null;
66815440faSAtari911
67815440faSAtari911    /** @var GoogleCalendarSync */
68815440faSAtari911    private $googleSync = null;
69815440faSAtari911
70815440faSAtari911    /**
71815440faSAtari911     * Get the audit logger instance
72815440faSAtari911     */
73815440faSAtari911    private function getAuditLogger() {
74815440faSAtari911        if ($this->auditLogger === null) {
75815440faSAtari911            $this->auditLogger = new CalendarAuditLogger();
76815440faSAtari911        }
77815440faSAtari911        return $this->auditLogger;
78815440faSAtari911    }
79815440faSAtari911
80815440faSAtari911    /**
81815440faSAtari911     * Get the Google Calendar sync instance
82815440faSAtari911     */
83815440faSAtari911    private function getGoogleSync() {
84815440faSAtari911        if ($this->googleSync === null) {
85815440faSAtari911            $this->googleSync = new GoogleCalendarSync();
86815440faSAtari911        }
87815440faSAtari911        return $this->googleSync;
88815440faSAtari911    }
89815440faSAtari911
907e8ea635SAtari911    /**
917e8ea635SAtari911     * Log debug message only if CALENDAR_DEBUG is enabled
927e8ea635SAtari911     */
937e8ea635SAtari911    private function debugLog($message) {
947e8ea635SAtari911        if (CALENDAR_DEBUG) {
957e8ea635SAtari911            error_log($message);
967e8ea635SAtari911        }
977e8ea635SAtari911    }
987e8ea635SAtari911
997e8ea635SAtari911    /**
1007e8ea635SAtari911     * Safely read and decode a JSON file with error handling
101815440faSAtari911     * Uses the new CalendarFileHandler for atomic reads with locking
1027e8ea635SAtari911     * @param string $filepath Path to JSON file
1037e8ea635SAtari911     * @return array Decoded array or empty array on error
1047e8ea635SAtari911     */
1057e8ea635SAtari911    private function safeJsonRead($filepath) {
106815440faSAtari911        return CalendarFileHandler::readJson($filepath);
1077e8ea635SAtari911    }
1087e8ea635SAtari911
109815440faSAtari911    /**
110815440faSAtari911     * Safely write JSON data to file with atomic writes
111815440faSAtari911     * Uses the new CalendarFileHandler for atomic writes with locking
112815440faSAtari911     * @param string $filepath Path to JSON file
113815440faSAtari911     * @param array $data Data to write
114815440faSAtari911     * @return bool Success status
115815440faSAtari911     */
116815440faSAtari911    private function safeJsonWrite($filepath, array $data) {
117815440faSAtari911        return CalendarFileHandler::writeJson($filepath, $data);
1187e8ea635SAtari911    }
1197e8ea635SAtari911
12019378907SAtari911    public function register(Doku_Event_Handler $controller) {
12119378907SAtari911        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax');
12219378907SAtari911        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets');
12319378907SAtari911    }
12419378907SAtari911
12519378907SAtari911    public function handleAjax(Doku_Event $event, $param) {
12619378907SAtari911        if ($event->data !== 'plugin_calendar') return;
12719378907SAtari911        $event->preventDefault();
12819378907SAtari911        $event->stopPropagation();
12919378907SAtari911
13019378907SAtari911        $action = $_REQUEST['action'] ?? '';
13119378907SAtari911
132b498f308SAtari911        // Actions that modify data require authentication and CSRF token verification
1337e8ea635SAtari911        $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces',
1347e8ea635SAtari911                         'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring',
1357e8ea635SAtari911                         'trim_recurring', 'pause_recurring', 'resume_recurring',
1368e9c470bSAtari911                         'change_start_recurring', 'change_pattern_recurring',
1378e9c470bSAtari911                         'google_disconnect', 'google_import', 'google_export'];
1387e8ea635SAtari911
139815440faSAtari911        $isWriteAction = in_array($action, $writeActions);
140815440faSAtari911
141815440faSAtari911        // Rate limiting check - apply to all AJAX actions
142815440faSAtari911        if (!CalendarRateLimiter::check($action, $isWriteAction)) {
143815440faSAtari911            CalendarRateLimiter::addHeaders($action, $isWriteAction);
144815440faSAtari911            http_response_code(429);
145815440faSAtari911            echo json_encode([
146815440faSAtari911                'success' => false,
147815440faSAtari911                'error' => 'Rate limit exceeded. Please wait before making more requests.',
148815440faSAtari911                'retry_after' => CalendarRateLimiter::getRemaining($action, $isWriteAction)['reset']
149815440faSAtari911            ]);
150815440faSAtari911            return;
151815440faSAtari911        }
152815440faSAtari911
153815440faSAtari911        // Add rate limit headers to all responses
154815440faSAtari911        CalendarRateLimiter::addHeaders($action, $isWriteAction);
155815440faSAtari911
156815440faSAtari911        if ($isWriteAction) {
157b498f308SAtari911            global $INPUT, $INFO;
158b498f308SAtari911
159b498f308SAtari911            // Check if user is logged in (at minimum)
160b498f308SAtari911            if (empty($_SERVER['REMOTE_USER'])) {
161b498f308SAtari911                echo json_encode(['success' => false, 'error' => 'Authentication required. Please log in.']);
162b498f308SAtari911                return;
163b498f308SAtari911            }
164b498f308SAtari911
165b498f308SAtari911            // Check for valid security token - try multiple sources
166b498f308SAtari911            $sectok = $INPUT->str('sectok', '');
167b498f308SAtari911            if (empty($sectok)) {
1687e8ea635SAtari911                $sectok = $_REQUEST['sectok'] ?? '';
169b498f308SAtari911            }
170b498f308SAtari911
171b498f308SAtari911            // Use DokuWiki's built-in check
1727e8ea635SAtari911            if (!checkSecurityToken($sectok)) {
173b498f308SAtari911                // Log for debugging
174b498f308SAtari911                $this->debugLog("Security token check failed. Received: '$sectok'");
1757e8ea635SAtari911                echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']);
1767e8ea635SAtari911                return;
1777e8ea635SAtari911            }
1787e8ea635SAtari911        }
1797e8ea635SAtari911
18019378907SAtari911        switch ($action) {
18119378907SAtari911            case 'save_event':
18219378907SAtari911                $this->saveEvent();
18319378907SAtari911                break;
18419378907SAtari911            case 'delete_event':
18519378907SAtari911                $this->deleteEvent();
18619378907SAtari911                break;
18719378907SAtari911            case 'get_event':
18819378907SAtari911                $this->getEvent();
18919378907SAtari911                break;
19019378907SAtari911            case 'load_month':
19119378907SAtari911                $this->loadMonth();
19219378907SAtari911                break;
193da206178SAtari911            case 'get_static_calendar':
194da206178SAtari911                $this->getStaticCalendar();
195da206178SAtari911                break;
19696df7d3eSAtari911            case 'search_all':
19796df7d3eSAtari911                $this->searchAllDates();
19896df7d3eSAtari911                break;
19919378907SAtari911            case 'toggle_task':
20019378907SAtari911                $this->toggleTaskComplete();
20119378907SAtari911                break;
202815440faSAtari911            case 'google_auth_url':
203815440faSAtari911                $this->getGoogleAuthUrl();
204815440faSAtari911                break;
205815440faSAtari911            case 'google_callback':
206815440faSAtari911                $this->handleGoogleCallback();
207815440faSAtari911                break;
208815440faSAtari911            case 'google_status':
209815440faSAtari911                $this->getGoogleStatus();
210815440faSAtari911                break;
211815440faSAtari911            case 'google_calendars':
212815440faSAtari911                $this->getGoogleCalendars();
213815440faSAtari911                break;
214815440faSAtari911            case 'google_import':
215815440faSAtari911                $this->googleImport();
216815440faSAtari911                break;
217815440faSAtari911            case 'google_export':
218815440faSAtari911                $this->googleExport();
219815440faSAtari911                break;
220815440faSAtari911            case 'google_disconnect':
221815440faSAtari911                $this->googleDisconnect();
222815440faSAtari911                break;
2237e8ea635SAtari911            case 'cleanup_empty_namespaces':
2247e8ea635SAtari911            case 'trim_all_past_recurring':
2257e8ea635SAtari911            case 'rescan_recurring':
2267e8ea635SAtari911            case 'extend_recurring':
2277e8ea635SAtari911            case 'trim_recurring':
2287e8ea635SAtari911            case 'pause_recurring':
2297e8ea635SAtari911            case 'resume_recurring':
2307e8ea635SAtari911            case 'change_start_recurring':
2317e8ea635SAtari911            case 'change_pattern_recurring':
2327e8ea635SAtari911                $this->routeToAdmin($action);
2337e8ea635SAtari911                break;
23419378907SAtari911            default:
23519378907SAtari911                echo json_encode(['success' => false, 'error' => 'Unknown action']);
23619378907SAtari911        }
23719378907SAtari911    }
23819378907SAtari911
2397e8ea635SAtari911    /**
2407e8ea635SAtari911     * Route AJAX actions to admin plugin methods
2417e8ea635SAtari911     */
2427e8ea635SAtari911    private function routeToAdmin($action) {
2437e8ea635SAtari911        $admin = plugin_load('admin', 'calendar');
2447e8ea635SAtari911        if ($admin && method_exists($admin, 'handleAjaxAction')) {
2457e8ea635SAtari911            $admin->handleAjaxAction($action);
2467e8ea635SAtari911        } else {
2477e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Admin handler not available']);
2487e8ea635SAtari911        }
2497e8ea635SAtari911    }
2507e8ea635SAtari911
25119378907SAtari911    private function saveEvent() {
25219378907SAtari911        global $INPUT;
25319378907SAtari911
25419378907SAtari911        $namespace = $INPUT->str('namespace', '');
25519378907SAtari911        $date = $INPUT->str('date');
25619378907SAtari911        $eventId = $INPUT->str('eventId', '');
25719378907SAtari911        $title = $INPUT->str('title');
25819378907SAtari911        $time = $INPUT->str('time', '');
2591d05cddcSAtari911        $endTime = $INPUT->str('endTime', '');
26019378907SAtari911        $description = $INPUT->str('description', '');
26119378907SAtari911        $color = $INPUT->str('color', '#3498db');
26219378907SAtari911        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
26319378907SAtari911        $isTask = $INPUT->bool('isTask', false);
26419378907SAtari911        $completed = $INPUT->bool('completed', false);
26519378907SAtari911        $endDate = $INPUT->str('endDate', '');
26687ac9bf3SAtari911        $isRecurring = $INPUT->bool('isRecurring', false);
26787ac9bf3SAtari911        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
26887ac9bf3SAtari911        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
26919378907SAtari911
27096df7d3eSAtari911        // New recurrence options
27196df7d3eSAtari911        $recurrenceInterval = $INPUT->int('recurrenceInterval', 1);
27296df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
27396df7d3eSAtari911        if ($recurrenceInterval > 99) $recurrenceInterval = 99;
27496df7d3eSAtari911
27596df7d3eSAtari911        $weekDaysStr = $INPUT->str('weekDays', '');
2762866e827SAtari911        $weekDays = ($weekDaysStr !== '') ? array_map('intval', explode(',', $weekDaysStr)) : [];
27796df7d3eSAtari911
27896df7d3eSAtari911        $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth');
27996df7d3eSAtari911        $monthDay = $INPUT->int('monthDay', 0);
28096df7d3eSAtari911        $ordinalWeek = $INPUT->int('ordinalWeek', 1);
28196df7d3eSAtari911        $ordinalDay = $INPUT->int('ordinalDay', 0);
28296df7d3eSAtari911
28396df7d3eSAtari911        $this->debugLog("=== Calendar saveEvent START ===");
28496df7d3eSAtari911        $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'");
28596df7d3eSAtari911        $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'");
28696df7d3eSAtari911
28719378907SAtari911        if (!$date || !$title) {
28819378907SAtari911            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
28919378907SAtari911            return;
29019378907SAtari911        }
29119378907SAtari911
2927e8ea635SAtari911        // Validate date format (YYYY-MM-DD)
2937e8ea635SAtari911        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
2947e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid date format']);
2957e8ea635SAtari911            return;
2967e8ea635SAtari911        }
2977e8ea635SAtari911
2987e8ea635SAtari911        // Validate oldDate if provided
2997e8ea635SAtari911        if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) {
3007e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid old date format']);
3017e8ea635SAtari911            return;
3027e8ea635SAtari911        }
3037e8ea635SAtari911
3047e8ea635SAtari911        // Validate endDate if provided
3057e8ea635SAtari911        if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) {
3067e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end date format']);
3077e8ea635SAtari911            return;
3087e8ea635SAtari911        }
3097e8ea635SAtari911
3107e8ea635SAtari911        // Validate time format (HH:MM) if provided
3117e8ea635SAtari911        if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) {
3127e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid time format']);
3137e8ea635SAtari911            return;
3147e8ea635SAtari911        }
3157e8ea635SAtari911
3167e8ea635SAtari911        // Validate endTime format if provided
3177e8ea635SAtari911        if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) {
3187e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end time format']);
3197e8ea635SAtari911            return;
3207e8ea635SAtari911        }
3217e8ea635SAtari911
3227e8ea635SAtari911        // Validate color format (hex color)
3237e8ea635SAtari911        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
3247e8ea635SAtari911            $color = '#3498db'; // Reset to default if invalid
3257e8ea635SAtari911        }
3267e8ea635SAtari911
3277e8ea635SAtari911        // Validate namespace (prevent path traversal)
3287e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
3297e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
3307e8ea635SAtari911            return;
3317e8ea635SAtari911        }
3327e8ea635SAtari911
3332866e827SAtari911        // ACL check: verify edit access to the target namespace
3342866e827SAtari911        if (!$this->checkNamespaceEdit($namespace)) {
3352866e827SAtari911            echo json_encode(['success' => false, 'error' => 'You do not have permission to edit events in this namespace']);
3362866e827SAtari911            return;
3372866e827SAtari911        }
3382866e827SAtari911
3397e8ea635SAtari911        // Validate recurrence type
3407e8ea635SAtari911        $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
3417e8ea635SAtari911        if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) {
3427e8ea635SAtari911            $recurrenceType = 'weekly';
3437e8ea635SAtari911        }
3447e8ea635SAtari911
3457e8ea635SAtari911        // Validate recurrenceEnd if provided
3467e8ea635SAtari911        if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) {
3477e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']);
3487e8ea635SAtari911            return;
3497e8ea635SAtari911        }
3507e8ea635SAtari911
3517e8ea635SAtari911        // Sanitize title length
3527e8ea635SAtari911        $title = substr(trim($title), 0, 500);
3537e8ea635SAtari911
3547e8ea635SAtari911        // Sanitize description length
3557e8ea635SAtari911        $description = substr($description, 0, 10000);
3567e8ea635SAtari911
35796df7d3eSAtari911        // If editing, find the event's ACTUAL namespace (for finding/deleting old event)
35896df7d3eSAtari911        // We need to search ALL namespaces because user may be changing namespace
35996df7d3eSAtari911        $oldNamespace = null;  // null means "not found yet"
360e3a9f44cSAtari911        if ($eventId) {
3611d05cddcSAtari911            // Use oldDate if available (date was changed), otherwise use current date
3621d05cddcSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
3631d05cddcSAtari911
36496df7d3eSAtari911            // Search using wildcard to find event in ANY namespace
36596df7d3eSAtari911            $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*');
36696df7d3eSAtari911
36796df7d3eSAtari911            if ($foundNamespace !== null) {
36896df7d3eSAtari911                $oldNamespace = $foundNamespace;  // Could be '' for default namespace
3697e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'");
37096df7d3eSAtari911            } else {
37196df7d3eSAtari911                $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace");
3721d05cddcSAtari911            }
373e3a9f44cSAtari911        }
374e3a9f44cSAtari911
3751d05cddcSAtari911        // Use the namespace provided by the user (allow namespace changes!)
3761d05cddcSAtari911        // But normalize wildcards and multi-namespace to empty for NEW events
3771d05cddcSAtari911        if (!$eventId) {
3787e8ea635SAtari911            $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'");
379e3a9f44cSAtari911            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
380e3a9f44cSAtari911            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
3817e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty");
382e3a9f44cSAtari911                $namespace = '';
3831d05cddcSAtari911            } else {
3847e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'");
385e3a9f44cSAtari911            }
3861d05cddcSAtari911        } else {
3877e8ea635SAtari911            $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'");
388e3a9f44cSAtari911        }
389e3a9f44cSAtari911
39087ac9bf3SAtari911        // Generate event ID if new
39187ac9bf3SAtari911        $generatedId = $eventId ?: uniqid();
39287ac9bf3SAtari911
3939ccd446eSAtari911        // If editing a recurring event, load existing data to preserve unchanged fields
3949ccd446eSAtari911        $existingEventData = null;
3959ccd446eSAtari911        if ($eventId && $isRecurring) {
3969ccd446eSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
39796df7d3eSAtari911            // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use ''
39896df7d3eSAtari911            $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace);
3999ccd446eSAtari911            if ($existingEventData) {
4007e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'");
4019ccd446eSAtari911            }
4029ccd446eSAtari911        }
4039ccd446eSAtari911
40487ac9bf3SAtari911        // If recurring, generate multiple events
40587ac9bf3SAtari911        if ($isRecurring) {
4069ccd446eSAtari911            // Merge with existing data if editing (preserve values that weren't changed)
4079ccd446eSAtari911            if ($existingEventData) {
4089ccd446eSAtari911                $title = $title ?: $existingEventData['title'];
4099ccd446eSAtari911                $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : '');
4109ccd446eSAtari911                $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : '');
4119ccd446eSAtari911                $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : '');
4129ccd446eSAtari911                // Only use existing color if new color is default
4139ccd446eSAtari911                if ($color === '#3498db' && isset($existingEventData['color'])) {
4149ccd446eSAtari911                    $color = $existingEventData['color'];
4159ccd446eSAtari911                }
4169ccd446eSAtari911
4179ccd446eSAtari911                // Preserve namespace in these cases:
4189ccd446eSAtari911                // 1. Namespace field is empty (user didn't select anything)
4199ccd446eSAtari911                // 2. Namespace contains wildcards (like "personal;work" or "work*")
4209ccd446eSAtari911                // 3. Namespace is the same as what was passed (no change intended)
4219ccd446eSAtari911                $receivedNamespace = $namespace;
4229ccd446eSAtari911                if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) {
4239ccd446eSAtari911                    if (isset($existingEventData['namespace'])) {
4249ccd446eSAtari911                        $namespace = $existingEventData['namespace'];
4257e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')");
4269ccd446eSAtari911                    } else {
4277e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')");
4289ccd446eSAtari911                    }
4299ccd446eSAtari911                } else {
4307e8ea635SAtari911                    $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')");
4319ccd446eSAtari911                }
4329ccd446eSAtari911            } else {
4337e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'");
4349ccd446eSAtari911            }
4359ccd446eSAtari911
43696df7d3eSAtari911            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description,
43796df7d3eSAtari911                                        $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd,
43896df7d3eSAtari911                                        $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId);
43987ac9bf3SAtari911            echo json_encode(['success' => true]);
44087ac9bf3SAtari911            return;
44187ac9bf3SAtari911        }
44287ac9bf3SAtari911
44319378907SAtari911        list($year, $month, $day) = explode('-', $date);
44419378907SAtari911
4451d05cddcSAtari911        // NEW namespace directory (where we'll save)
4462866e827SAtari911        $dataDir = $this->metaDir();
44719378907SAtari911        if ($namespace) {
44819378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
44919378907SAtari911        }
45019378907SAtari911        $dataDir .= 'calendar/';
45119378907SAtari911
45219378907SAtari911        if (!is_dir($dataDir)) {
45319378907SAtari911            mkdir($dataDir, 0755, true);
45419378907SAtari911        }
45519378907SAtari911
45619378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
45719378907SAtari911
45896df7d3eSAtari911        $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'");
45996df7d3eSAtari911
46019378907SAtari911        $events = [];
46119378907SAtari911        if (file_exists($eventFile)) {
46219378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
46396df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location");
46496df7d3eSAtari911        } else {
46596df7d3eSAtari911            $this->debugLog("Calendar saveEvent: New location file does not exist yet");
46619378907SAtari911        }
46719378907SAtari911
4681d05cddcSAtari911        // If editing and (date changed OR namespace changed), remove from old location first
46996df7d3eSAtari911        // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace
47096df7d3eSAtari911        $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace);
4711d05cddcSAtari911        $dateChanged = ($eventId && $oldDate && $oldDate !== $date);
4721d05cddcSAtari911
47396df7d3eSAtari911        $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO'));
47496df7d3eSAtari911
4751d05cddcSAtari911        if ($namespaceChanged || $dateChanged) {
4761d05cddcSAtari911            // Construct OLD data directory using OLD namespace
4772866e827SAtari911            $oldDataDir = $this->metaDir();
4781d05cddcSAtari911            if ($oldNamespace) {
4791d05cddcSAtari911                $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/';
4801d05cddcSAtari911            }
4811d05cddcSAtari911            $oldDataDir .= 'calendar/';
4821d05cddcSAtari911
4831d05cddcSAtari911            $deleteDate = $dateChanged ? $oldDate : $date;
4841d05cddcSAtari911            list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate);
4851d05cddcSAtari911            $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
48619378907SAtari911
48796df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'");
48896df7d3eSAtari911
48919378907SAtari911            if (file_exists($oldEventFile)) {
49019378907SAtari911                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
49196df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates");
49296df7d3eSAtari911
4931d05cddcSAtari911                if (isset($oldEvents[$deleteDate])) {
49496df7d3eSAtari911                    $countBefore = count($oldEvents[$deleteDate]);
4951d05cddcSAtari911                    $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) {
49619378907SAtari911                        return $evt['id'] !== $eventId;
497e3a9f44cSAtari911                    }));
49896df7d3eSAtari911                    $countAfter = count($oldEvents[$deleteDate]);
49996df7d3eSAtari911
50096df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter");
50119378907SAtari911
5021d05cddcSAtari911                    if (empty($oldEvents[$deleteDate])) {
5031d05cddcSAtari911                        unset($oldEvents[$deleteDate]);
50419378907SAtari911                    }
50519378907SAtari911
506815440faSAtari911                    CalendarFileHandler::writeJson($oldEventFile, $oldEvents);
50796df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'");
50896df7d3eSAtari911                } else {
50996df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file");
51019378907SAtari911                }
51196df7d3eSAtari911            } else {
51296df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile");
51319378907SAtari911            }
51496df7d3eSAtari911        } else {
51596df7d3eSAtari911            $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location");
51619378907SAtari911        }
51719378907SAtari911
51819378907SAtari911        if (!isset($events[$date])) {
51919378907SAtari911            $events[$date] = [];
520e3a9f44cSAtari911        } elseif (!is_array($events[$date])) {
521e3a9f44cSAtari911            // Fix corrupted data - ensure it's an array
5227e8ea635SAtari911            $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
523e3a9f44cSAtari911            $events[$date] = [];
52419378907SAtari911        }
52519378907SAtari911
526e3a9f44cSAtari911        // Store the namespace with the event
52719378907SAtari911        $eventData = [
52887ac9bf3SAtari911            'id' => $generatedId,
52919378907SAtari911            'title' => $title,
53019378907SAtari911            'time' => $time,
5311d05cddcSAtari911            'endTime' => $endTime,
53219378907SAtari911            'description' => $description,
53319378907SAtari911            'color' => $color,
53419378907SAtari911            'isTask' => $isTask,
53519378907SAtari911            'completed' => $completed,
53619378907SAtari911            'endDate' => $endDate,
537e3a9f44cSAtari911            'namespace' => $namespace, // Store namespace with event
53819378907SAtari911            'created' => date('Y-m-d H:i:s')
53919378907SAtari911        ];
54019378907SAtari911
5411d05cddcSAtari911        // Debug logging
5427e8ea635SAtari911        $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile");
5431d05cddcSAtari911
54419378907SAtari911        // If editing, replace existing event
54519378907SAtari911        if ($eventId) {
54619378907SAtari911            $found = false;
54719378907SAtari911            foreach ($events[$date] as $key => $evt) {
54819378907SAtari911                if ($evt['id'] === $eventId) {
54919378907SAtari911                    $events[$date][$key] = $eventData;
55019378907SAtari911                    $found = true;
55119378907SAtari911                    break;
55219378907SAtari911                }
55319378907SAtari911            }
55419378907SAtari911            if (!$found) {
55519378907SAtari911                $events[$date][] = $eventData;
55619378907SAtari911            }
55719378907SAtari911        } else {
55819378907SAtari911            $events[$date][] = $eventData;
55919378907SAtari911        }
56019378907SAtari911
561815440faSAtari911        CalendarFileHandler::writeJson($eventFile, $events);
56219378907SAtari911
563e3a9f44cSAtari911        // If event spans multiple months, add it to the first day of each subsequent month
564e3a9f44cSAtari911        if ($endDate && $endDate !== $date) {
565e3a9f44cSAtari911            $startDateObj = new DateTime($date);
566e3a9f44cSAtari911            $endDateObj = new DateTime($endDate);
567e3a9f44cSAtari911
568e3a9f44cSAtari911            // Get the month/year of the start date
569e3a9f44cSAtari911            $startMonth = $startDateObj->format('Y-m');
570e3a9f44cSAtari911
571e3a9f44cSAtari911            // Iterate through each month the event spans
572e3a9f44cSAtari911            $currentDate = clone $startDateObj;
573e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
574e3a9f44cSAtari911
575e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
576e3a9f44cSAtari911                $currentMonth = $currentDate->format('Y-m');
577e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
578e3a9f44cSAtari911
579e3a9f44cSAtari911                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
580e3a9f44cSAtari911
581e3a9f44cSAtari911                // Get the file for this month
582e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
583e3a9f44cSAtari911
584e3a9f44cSAtari911                $currentEvents = [];
585e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
586e3a9f44cSAtari911                    $contents = file_get_contents($currentEventFile);
587e3a9f44cSAtari911                    $decoded = json_decode($contents, true);
588e3a9f44cSAtari911                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
589e3a9f44cSAtari911                        $currentEvents = $decoded;
590e3a9f44cSAtari911                    } else {
5917e8ea635SAtari911                        $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
592e3a9f44cSAtari911                    }
593e3a9f44cSAtari911                }
594e3a9f44cSAtari911
595e3a9f44cSAtari911                // Add entry for the first day of this month
596e3a9f44cSAtari911                if (!isset($currentEvents[$firstDayOfMonth])) {
597e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
598e3a9f44cSAtari911                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
599e3a9f44cSAtari911                    // Fix corrupted data - ensure it's an array
6007e8ea635SAtari911                    $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
601e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
602e3a9f44cSAtari911                }
603e3a9f44cSAtari911
604e3a9f44cSAtari911                // Create a copy with the original start date preserved
605e3a9f44cSAtari911                $eventDataForMonth = $eventData;
606e3a9f44cSAtari911                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
607e3a9f44cSAtari911
608e3a9f44cSAtari911                // Check if event already exists (when editing)
609e3a9f44cSAtari911                $found = false;
610e3a9f44cSAtari911                if ($eventId) {
611e3a9f44cSAtari911                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
612e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
613e3a9f44cSAtari911                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
614e3a9f44cSAtari911                            $found = true;
615e3a9f44cSAtari911                            break;
616e3a9f44cSAtari911                        }
617e3a9f44cSAtari911                    }
618e3a9f44cSAtari911                }
619e3a9f44cSAtari911
620e3a9f44cSAtari911                if (!$found) {
621e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
622e3a9f44cSAtari911                }
623e3a9f44cSAtari911
624815440faSAtari911                CalendarFileHandler::writeJson($currentEventFile, $currentEvents);
625e3a9f44cSAtari911
626e3a9f44cSAtari911                // Move to next month
627e3a9f44cSAtari911                $currentDate->modify('first day of next month');
628e3a9f44cSAtari911            }
629e3a9f44cSAtari911        }
630e3a9f44cSAtari911
631815440faSAtari911        // Audit logging
632815440faSAtari911        $audit = $this->getAuditLogger();
633815440faSAtari911        if ($eventId && ($dateChanged || $namespaceChanged)) {
634815440faSAtari911            // Event was moved
635815440faSAtari911            $audit->logMove($namespace, $oldDate ?: $date, $date, $generatedId, $title);
636815440faSAtari911        } elseif ($eventId) {
637815440faSAtari911            // Event was updated
638815440faSAtari911            $audit->logUpdate($namespace, $date, $generatedId, $title);
639815440faSAtari911        } else {
640815440faSAtari911            // New event created
641815440faSAtari911            $audit->logCreate($namespace, $date, $generatedId, $title);
642815440faSAtari911        }
643815440faSAtari911
64419378907SAtari911        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
64519378907SAtari911    }
64619378907SAtari911
64719378907SAtari911    private function deleteEvent() {
64819378907SAtari911        global $INPUT;
64919378907SAtari911
65019378907SAtari911        $namespace = $INPUT->str('namespace', '');
65119378907SAtari911        $date = $INPUT->str('date');
65219378907SAtari911        $eventId = $INPUT->str('eventId');
65319378907SAtari911
654e3a9f44cSAtari911        // Find where the event actually lives
655e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
656e3a9f44cSAtari911
657e3a9f44cSAtari911        if ($storedNamespace === null) {
658e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
659e3a9f44cSAtari911            return;
660e3a9f44cSAtari911        }
661e3a9f44cSAtari911
662e3a9f44cSAtari911        // Use the found namespace
663e3a9f44cSAtari911        $namespace = $storedNamespace;
664e3a9f44cSAtari911
6652866e827SAtari911        // ACL check: verify edit access to delete events
6662866e827SAtari911        if (!$this->checkNamespaceEdit($namespace)) {
6672866e827SAtari911            echo json_encode(['success' => false, 'error' => 'You do not have permission to delete events in this namespace']);
6682866e827SAtari911            return;
6692866e827SAtari911        }
6702866e827SAtari911
67119378907SAtari911        list($year, $month, $day) = explode('-', $date);
67219378907SAtari911
6732866e827SAtari911        $dataDir = $this->metaDir();
67419378907SAtari911        if ($namespace) {
67519378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
67619378907SAtari911        }
67719378907SAtari911        $dataDir .= 'calendar/';
67819378907SAtari911
67919378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
68019378907SAtari911
6819ccd446eSAtari911        // First, get the event to check if it spans multiple months or is recurring
682e3a9f44cSAtari911        $eventToDelete = null;
6839ccd446eSAtari911        $isRecurring = false;
6849ccd446eSAtari911        $recurringId = null;
6859ccd446eSAtari911
68619378907SAtari911        if (file_exists($eventFile)) {
68719378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
68819378907SAtari911
68919378907SAtari911            if (isset($events[$date])) {
690e3a9f44cSAtari911                foreach ($events[$date] as $event) {
691e3a9f44cSAtari911                    if ($event['id'] === $eventId) {
692e3a9f44cSAtari911                        $eventToDelete = $event;
6939ccd446eSAtari911                        $isRecurring = isset($event['recurring']) && $event['recurring'];
6949ccd446eSAtari911                        $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
695e3a9f44cSAtari911                        break;
696e3a9f44cSAtari911                    }
697e3a9f44cSAtari911                }
698e3a9f44cSAtari911
699e3a9f44cSAtari911                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
70019378907SAtari911                    return $event['id'] !== $eventId;
701e3a9f44cSAtari911                }));
70219378907SAtari911
70319378907SAtari911                if (empty($events[$date])) {
70419378907SAtari911                    unset($events[$date]);
70519378907SAtari911                }
70619378907SAtari911
707815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
70819378907SAtari911            }
70919378907SAtari911        }
71019378907SAtari911
7119ccd446eSAtari911        // If this is a recurring event, delete ALL occurrences with the same recurringId
7129ccd446eSAtari911        if ($isRecurring && $recurringId) {
7139ccd446eSAtari911            $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir);
7149ccd446eSAtari911        }
7159ccd446eSAtari911
716e3a9f44cSAtari911        // If event spans multiple months, delete it from the first day of each subsequent month
717e3a9f44cSAtari911        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
718e3a9f44cSAtari911            $startDateObj = new DateTime($date);
719e3a9f44cSAtari911            $endDateObj = new DateTime($eventToDelete['endDate']);
720e3a9f44cSAtari911
721e3a9f44cSAtari911            // Iterate through each month the event spans
722e3a9f44cSAtari911            $currentDate = clone $startDateObj;
723e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
724e3a9f44cSAtari911
725e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
726e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
727e3a9f44cSAtari911                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
728e3a9f44cSAtari911
729e3a9f44cSAtari911                // Get the file for this month
730e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
731e3a9f44cSAtari911
732e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
733e3a9f44cSAtari911                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
734e3a9f44cSAtari911
735e3a9f44cSAtari911                    if (isset($currentEvents[$firstDayOfMonth])) {
736e3a9f44cSAtari911                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
737e3a9f44cSAtari911                            return $event['id'] !== $eventId;
738e3a9f44cSAtari911                        }));
739e3a9f44cSAtari911
740e3a9f44cSAtari911                        if (empty($currentEvents[$firstDayOfMonth])) {
741e3a9f44cSAtari911                            unset($currentEvents[$firstDayOfMonth]);
742e3a9f44cSAtari911                        }
743e3a9f44cSAtari911
744815440faSAtari911                        CalendarFileHandler::writeJson($currentEventFile, $currentEvents);
745e3a9f44cSAtari911                    }
746e3a9f44cSAtari911                }
747e3a9f44cSAtari911
748e3a9f44cSAtari911                // Move to next month
749e3a9f44cSAtari911                $currentDate->modify('first day of next month');
750e3a9f44cSAtari911            }
751e3a9f44cSAtari911        }
752e3a9f44cSAtari911
753815440faSAtari911        // Audit logging
754815440faSAtari911        $audit = $this->getAuditLogger();
755815440faSAtari911        $eventTitle = $eventToDelete ? ($eventToDelete['title'] ?? '') : '';
756815440faSAtari911        $audit->logDelete($namespace, $date, $eventId, $eventTitle);
757815440faSAtari911
75819378907SAtari911        echo json_encode(['success' => true]);
75919378907SAtari911    }
76019378907SAtari911
76119378907SAtari911    private function getEvent() {
76219378907SAtari911        global $INPUT;
76319378907SAtari911
76419378907SAtari911        $namespace = $INPUT->str('namespace', '');
76519378907SAtari911        $date = $INPUT->str('date');
76619378907SAtari911        $eventId = $INPUT->str('eventId');
76719378907SAtari911
768e3a9f44cSAtari911        // Find where the event actually lives
769e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
770e3a9f44cSAtari911
771e3a9f44cSAtari911        if ($storedNamespace === null) {
772e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
773e3a9f44cSAtari911            return;
774e3a9f44cSAtari911        }
775e3a9f44cSAtari911
776e3a9f44cSAtari911        // Use the found namespace
777e3a9f44cSAtari911        $namespace = $storedNamespace;
778e3a9f44cSAtari911
7792866e827SAtari911        // ACL check: verify read access to the event's namespace
7802866e827SAtari911        if (!$this->checkNamespaceRead($namespace)) {
7812866e827SAtari911            echo json_encode(['success' => false, 'error' => 'Access denied']);
7822866e827SAtari911            return;
7832866e827SAtari911        }
7842866e827SAtari911
78519378907SAtari911        list($year, $month, $day) = explode('-', $date);
78619378907SAtari911
7872866e827SAtari911        $dataDir = $this->metaDir();
78819378907SAtari911        if ($namespace) {
78919378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
79019378907SAtari911        }
79119378907SAtari911        $dataDir .= 'calendar/';
79219378907SAtari911
79319378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
79419378907SAtari911
79519378907SAtari911        if (file_exists($eventFile)) {
79619378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
79719378907SAtari911
79819378907SAtari911            if (isset($events[$date])) {
79919378907SAtari911                foreach ($events[$date] as $event) {
80019378907SAtari911                    if ($event['id'] === $eventId) {
8011d05cddcSAtari911                        // Include the namespace so JavaScript knows where this event actually lives
8021d05cddcSAtari911                        $event['namespace'] = $namespace;
80319378907SAtari911                        echo json_encode(['success' => true, 'event' => $event]);
80419378907SAtari911                        return;
80519378907SAtari911                    }
80619378907SAtari911                }
80719378907SAtari911            }
80819378907SAtari911        }
80919378907SAtari911
81019378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
81119378907SAtari911    }
81219378907SAtari911
81319378907SAtari911    private function loadMonth() {
81419378907SAtari911        global $INPUT;
81519378907SAtari911
816e3a9f44cSAtari911        // Prevent caching of AJAX responses
817e3a9f44cSAtari911        header('Cache-Control: no-cache, no-store, must-revalidate');
818e3a9f44cSAtari911        header('Pragma: no-cache');
819e3a9f44cSAtari911        header('Expires: 0');
820e3a9f44cSAtari911
82119378907SAtari911        $namespace = $INPUT->str('namespace', '');
82219378907SAtari911        $year = $INPUT->int('year');
82319378907SAtari911        $month = $INPUT->int('month');
8242866e827SAtari911        $exclude = $INPUT->str('exclude', '');
8252866e827SAtari911        $excludeList = $this->parseExcludeList($exclude);
82619378907SAtari911
8277e8ea635SAtari911        // Validate year (reasonable range: 1970-2100)
8287e8ea635SAtari911        if ($year < 1970 || $year > 2100) {
8297e8ea635SAtari911            $year = (int)date('Y');
8307e8ea635SAtari911        }
8317e8ea635SAtari911
8327e8ea635SAtari911        // Validate month (1-12)
8337e8ea635SAtari911        if ($month < 1 || $month > 12) {
8347e8ea635SAtari911            $month = (int)date('n');
8357e8ea635SAtari911        }
8367e8ea635SAtari911
8377e8ea635SAtari911        // Validate namespace format
8387e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
8397e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
8407e8ea635SAtari911            return;
8417e8ea635SAtari911        }
8427e8ea635SAtari911
8432866e827SAtari911        // ACL check: for single namespace, verify read access
8442866e827SAtari911        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
8452866e827SAtari911        if (!$isMultiNamespace && !$this->checkNamespaceRead($namespace)) {
8462866e827SAtari911            echo json_encode(['success' => false, 'error' => 'Access denied']);
8472866e827SAtari911            return;
8482866e827SAtari911        }
8492866e827SAtari911
8507e8ea635SAtari911        $this->debugLog("=== Calendar loadMonth DEBUG ===");
8512866e827SAtari911        $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace', exclude='$exclude'");
852e3a9f44cSAtari911
853e3a9f44cSAtari911        // Check if multi-namespace or wildcard
854e3a9f44cSAtari911
8557e8ea635SAtari911        $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
856e3a9f44cSAtari911
857e3a9f44cSAtari911        if ($isMultiNamespace) {
8582866e827SAtari911            $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList);
859e3a9f44cSAtari911        } else {
860e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
861e3a9f44cSAtari911        }
862e3a9f44cSAtari911
8637e8ea635SAtari911        $this->debugLog("Returning " . count($events) . " date keys");
864e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
8657e8ea635SAtari911            $this->debugLog("  dateKey=$dateKey has " . count($dayEvents) . " events");
866e3a9f44cSAtari911        }
867e3a9f44cSAtari911
868e3a9f44cSAtari911        echo json_encode([
869e3a9f44cSAtari911            'success' => true,
870e3a9f44cSAtari911            'year' => $year,
871e3a9f44cSAtari911            'month' => $month,
872e3a9f44cSAtari911            'events' => $events
873e3a9f44cSAtari911        ]);
874e3a9f44cSAtari911    }
875e3a9f44cSAtari911
876da206178SAtari911    /**
877da206178SAtari911     * Get static calendar HTML via AJAX for navigation
878da206178SAtari911     */
879da206178SAtari911    private function getStaticCalendar() {
880da206178SAtari911        global $INPUT;
881da206178SAtari911
882da206178SAtari911        $namespace = $INPUT->str('namespace', '');
883da206178SAtari911        $year = $INPUT->int('year');
884da206178SAtari911        $month = $INPUT->int('month');
885da206178SAtari911
886da206178SAtari911        // Validate
887da206178SAtari911        if ($year < 1970 || $year > 2100) {
888da206178SAtari911            $year = (int)date('Y');
889da206178SAtari911        }
890da206178SAtari911        if ($month < 1 || $month > 12) {
891da206178SAtari911            $month = (int)date('n');
892da206178SAtari911        }
893da206178SAtari911
8942866e827SAtari911        // ACL check: verify read access for single namespace
8952866e827SAtari911        $isMulti = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
8962866e827SAtari911        if (!$isMulti && !$this->checkNamespaceRead($namespace)) {
8972866e827SAtari911            echo json_encode(['success' => false, 'error' => 'Access denied']);
8982866e827SAtari911            return;
8992866e827SAtari911        }
9002866e827SAtari911
901da206178SAtari911        // Get syntax plugin to render the static calendar
902da206178SAtari911        $syntax = plugin_load('syntax', 'calendar');
903da206178SAtari911        if (!$syntax) {
904da206178SAtari911            echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']);
905da206178SAtari911            return;
906da206178SAtari911        }
907da206178SAtari911
908da206178SAtari911        // Build data array for render
909da206178SAtari911        $data = [
910da206178SAtari911            'year' => $year,
911da206178SAtari911            'month' => $month,
912da206178SAtari911            'namespace' => $namespace,
913da206178SAtari911            'static' => true
914da206178SAtari911        ];
915da206178SAtari911
916da206178SAtari911        // Call the render method via reflection (since renderStaticCalendar is private)
917da206178SAtari911        $reflector = new \ReflectionClass($syntax);
918da206178SAtari911        $method = $reflector->getMethod('renderStaticCalendar');
919da206178SAtari911        $method->setAccessible(true);
920da206178SAtari911        $html = $method->invoke($syntax, $data);
921da206178SAtari911
922da206178SAtari911        echo json_encode([
923da206178SAtari911            'success' => true,
924da206178SAtari911            'html' => $html
925da206178SAtari911        ]);
926da206178SAtari911    }
927da206178SAtari911
928e3a9f44cSAtari911    private function loadEventsSingleNamespace($namespace, $year, $month) {
9292866e827SAtari911        $dataDir = $this->metaDir();
93019378907SAtari911        if ($namespace) {
93119378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
93219378907SAtari911        }
93319378907SAtari911        $dataDir .= 'calendar/';
93419378907SAtari911
935e3a9f44cSAtari911        // Load ONLY current month
93687ac9bf3SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
93719378907SAtari911        $events = [];
93819378907SAtari911        if (file_exists($eventFile)) {
93987ac9bf3SAtari911            $contents = file_get_contents($eventFile);
94087ac9bf3SAtari911            $decoded = json_decode($contents, true);
94187ac9bf3SAtari911            if (json_last_error() === JSON_ERROR_NONE) {
94287ac9bf3SAtari911                $events = $decoded;
94387ac9bf3SAtari911            }
94487ac9bf3SAtari911        }
94587ac9bf3SAtari911
946e3a9f44cSAtari911        return $events;
94787ac9bf3SAtari911    }
948e3a9f44cSAtari911
9492866e827SAtari911    private function loadEventsMultiNamespace($namespaces, $year, $month, $excludeList = []) {
950e3a9f44cSAtari911        // Check for wildcard pattern
951e3a9f44cSAtari911        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
952e3a9f44cSAtari911            $baseNamespace = $matches[1];
9532866e827SAtari911            return $this->loadEventsWildcard($baseNamespace, $year, $month, $excludeList);
954e3a9f44cSAtari911        }
955e3a9f44cSAtari911
956e3a9f44cSAtari911        // Check for root wildcard
957e3a9f44cSAtari911        if ($namespaces === '*') {
9582866e827SAtari911            return $this->loadEventsWildcard('', $year, $month, $excludeList);
959e3a9f44cSAtari911        }
960e3a9f44cSAtari911
961e3a9f44cSAtari911        // Parse namespace list (semicolon separated)
962e3a9f44cSAtari911        $namespaceList = array_map('trim', explode(';', $namespaces));
963e3a9f44cSAtari911
964e3a9f44cSAtari911        // Load events from all namespaces
965e3a9f44cSAtari911        $allEvents = [];
966e3a9f44cSAtari911        foreach ($namespaceList as $ns) {
967e3a9f44cSAtari911            $ns = trim($ns);
968e3a9f44cSAtari911            if (empty($ns)) continue;
969e3a9f44cSAtari911
9702866e827SAtari911            // Skip excluded namespaces
9712866e827SAtari911            if ($this->isNamespaceExcluded($ns, $excludeList)) continue;
9722866e827SAtari911
9732866e827SAtari911            // ACL check: skip namespaces user cannot read
9742866e827SAtari911            if (!$this->checkNamespaceRead($ns)) continue;
9752866e827SAtari911
976e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
977e3a9f44cSAtari911
978e3a9f44cSAtari911            // Add namespace tag to each event
979e3a9f44cSAtari911            foreach ($events as $dateKey => $dayEvents) {
980e3a9f44cSAtari911                if (!isset($allEvents[$dateKey])) {
981e3a9f44cSAtari911                    $allEvents[$dateKey] = [];
982e3a9f44cSAtari911                }
983e3a9f44cSAtari911                foreach ($dayEvents as $event) {
984e3a9f44cSAtari911                    $event['_namespace'] = $ns;
985e3a9f44cSAtari911                    $allEvents[$dateKey][] = $event;
986e3a9f44cSAtari911                }
98787ac9bf3SAtari911            }
98887ac9bf3SAtari911        }
98987ac9bf3SAtari911
990e3a9f44cSAtari911        return $allEvents;
991e3a9f44cSAtari911    }
99219378907SAtari911
9932866e827SAtari911    private function loadEventsWildcard($baseNamespace, $year, $month, $excludeList = []) {
9942866e827SAtari911        $metaDir = $this->metaDir();
9952866e827SAtari911        $dataDir = $metaDir;
996e3a9f44cSAtari911        if ($baseNamespace) {
997e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
998e3a9f44cSAtari911        }
999e3a9f44cSAtari911
1000e3a9f44cSAtari911        $allEvents = [];
1001e3a9f44cSAtari911
10022866e827SAtari911        // Load events from the base namespace itself
10032866e827SAtari911        if (!$this->isNamespaceExcluded($baseNamespace, $excludeList) && $this->checkNamespaceRead($baseNamespace)) {
1004e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
1005e3a9f44cSAtari911
1006e3a9f44cSAtari911            foreach ($events as $dateKey => $dayEvents) {
1007e3a9f44cSAtari911                if (!isset($allEvents[$dateKey])) {
1008e3a9f44cSAtari911                    $allEvents[$dateKey] = [];
1009e3a9f44cSAtari911                }
1010e3a9f44cSAtari911                foreach ($dayEvents as $event) {
1011e3a9f44cSAtari911                    $event['_namespace'] = $baseNamespace;
1012e3a9f44cSAtari911                    $allEvents[$dateKey][] = $event;
1013e3a9f44cSAtari911                }
1014e3a9f44cSAtari911            }
10152866e827SAtari911        }
1016e3a9f44cSAtari911
10172866e827SAtari911        // Find all calendar directories efficiently using iterative glob
10182866e827SAtari911        $this->findCalendarNamespaces($dataDir, $metaDir, $year, $month, $allEvents, $excludeList);
1019e3a9f44cSAtari911
1020e3a9f44cSAtari911        return $allEvents;
1021e3a9f44cSAtari911    }
1022e3a9f44cSAtari911
10232866e827SAtari911    /**
10242866e827SAtari911     * Find namespaces with calendar data using iterative glob
10252866e827SAtari911     * Searches for 'calendar/' directories at increasing depth without
10262866e827SAtari911     * scanning every directory in data/meta
10272866e827SAtari911     */
10282866e827SAtari911    private function findCalendarNamespaces($baseDir, $metaDir, $year, $month, &$allEvents, $excludeList = []) {
10292866e827SAtari911        if (!is_dir($baseDir)) return;
1030e3a9f44cSAtari911
10312866e827SAtari911        $maxDepth = 10;
10322866e827SAtari911        $metaDirLen = strlen($metaDir);
1033e3a9f44cSAtari911
10342866e827SAtari911        for ($depth = 1; $depth <= $maxDepth; $depth++) {
10352866e827SAtari911            $pattern = $baseDir . str_repeat('*/', $depth) . 'calendar';
10362866e827SAtari911            $calDirs = glob($pattern, GLOB_ONLYDIR);
1037e3a9f44cSAtari911
10382866e827SAtari911            if (empty($calDirs)) {
10392866e827SAtari911                if ($depth > 3) break;
10402866e827SAtari911                continue;
10412866e827SAtari911            }
10422866e827SAtari911
10432866e827SAtari911            foreach ($calDirs as $calDir) {
10442866e827SAtari911                $nsDir = dirname($calDir);
10452866e827SAtari911                $relPath = substr($nsDir, $metaDirLen);
10462866e827SAtari911                $namespace = str_replace('/', ':', trim($relPath, '/'));
10472866e827SAtari911
10482866e827SAtari911                if (empty($namespace)) continue;
10492866e827SAtari911                if ($this->isNamespaceExcluded($namespace, $excludeList)) continue;
10502866e827SAtari911                if (!$this->checkNamespaceRead($namespace)) continue;
10512866e827SAtari911
1052e3a9f44cSAtari911                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
1053e3a9f44cSAtari911                foreach ($events as $dateKey => $dayEvents) {
1054e3a9f44cSAtari911                    if (!isset($allEvents[$dateKey])) {
1055e3a9f44cSAtari911                        $allEvents[$dateKey] = [];
1056e3a9f44cSAtari911                    }
1057e3a9f44cSAtari911                    foreach ($dayEvents as $event) {
1058e3a9f44cSAtari911                        $event['_namespace'] = $namespace;
1059e3a9f44cSAtari911                        $allEvents[$dateKey][] = $event;
1060e3a9f44cSAtari911                    }
1061e3a9f44cSAtari911                }
1062e3a9f44cSAtari911            }
1063e3a9f44cSAtari911        }
106419378907SAtari911    }
106519378907SAtari911
106696df7d3eSAtari911    /**
106796df7d3eSAtari911     * Search all dates for events matching the search term
106896df7d3eSAtari911     */
106996df7d3eSAtari911    private function searchAllDates() {
107096df7d3eSAtari911        global $INPUT;
107196df7d3eSAtari911
107296df7d3eSAtari911        $searchTerm = strtolower(trim($INPUT->str('search', '')));
107396df7d3eSAtari911        $namespace = $INPUT->str('namespace', '');
10742866e827SAtari911        $exclude = $INPUT->str('exclude', '');
10752866e827SAtari911        $excludeList = $this->parseExcludeList($exclude);
107696df7d3eSAtari911
107796df7d3eSAtari911        if (strlen($searchTerm) < 2) {
107896df7d3eSAtari911            echo json_encode(['success' => false, 'error' => 'Search term too short']);
107996df7d3eSAtari911            return;
108096df7d3eSAtari911        }
108196df7d3eSAtari911
108296df7d3eSAtari911        // Normalize search term for fuzzy matching
108396df7d3eSAtari911        $normalizedSearch = $this->normalizeForSearch($searchTerm);
108496df7d3eSAtari911
108596df7d3eSAtari911        $results = [];
10862866e827SAtari911        $dataDir = $this->metaDir();
108796df7d3eSAtari911
108896df7d3eSAtari911        // Helper to search calendar directory
10892866e827SAtari911        $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results, $excludeList) {
109096df7d3eSAtari911            if (!is_dir($calDir)) return;
109196df7d3eSAtari911
10922866e827SAtari911            // Skip excluded namespaces
10932866e827SAtari911            if ($this->isNamespaceExcluded($eventNamespace, $excludeList)) return;
10942866e827SAtari911
10952866e827SAtari911            // ACL check: skip namespaces user cannot read
10962866e827SAtari911            if (!$this->checkNamespaceRead($eventNamespace)) return;
10972866e827SAtari911
109896df7d3eSAtari911            foreach (glob($calDir . '/*.json') as $file) {
109996df7d3eSAtari911                $data = @json_decode(file_get_contents($file), true);
110096df7d3eSAtari911                if (!$data || !is_array($data)) continue;
110196df7d3eSAtari911
110296df7d3eSAtari911                foreach ($data as $dateKey => $dayEvents) {
110396df7d3eSAtari911                    // Skip non-date keys
110496df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
110596df7d3eSAtari911                    if (!is_array($dayEvents)) continue;
110696df7d3eSAtari911
110796df7d3eSAtari911                    foreach ($dayEvents as $event) {
110896df7d3eSAtari911                        if (!isset($event['title'])) continue;
110996df7d3eSAtari911
111096df7d3eSAtari911                        // Build searchable text
111196df7d3eSAtari911                        $searchableText = strtolower($event['title']);
111296df7d3eSAtari911                        if (isset($event['description'])) {
111396df7d3eSAtari911                            $searchableText .= ' ' . strtolower($event['description']);
111496df7d3eSAtari911                        }
111596df7d3eSAtari911
111696df7d3eSAtari911                        // Normalize for fuzzy matching
111796df7d3eSAtari911                        $normalizedText = $this->normalizeForSearch($searchableText);
111896df7d3eSAtari911
111996df7d3eSAtari911                        // Check if matches using fuzzy match
112096df7d3eSAtari911                        if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) {
112196df7d3eSAtari911                            $results[] = [
112296df7d3eSAtari911                                'date' => $dateKey,
112396df7d3eSAtari911                                'title' => $event['title'],
112496df7d3eSAtari911                                'time' => isset($event['time']) ? $event['time'] : '',
112596df7d3eSAtari911                                'endTime' => isset($event['endTime']) ? $event['endTime'] : '',
112696df7d3eSAtari911                                'color' => isset($event['color']) ? $event['color'] : '',
112796df7d3eSAtari911                                'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace,
112896df7d3eSAtari911                                'id' => isset($event['id']) ? $event['id'] : ''
112996df7d3eSAtari911                            ];
113096df7d3eSAtari911                        }
113196df7d3eSAtari911                    }
113296df7d3eSAtari911                }
113396df7d3eSAtari911            }
113496df7d3eSAtari911        };
113596df7d3eSAtari911
113696df7d3eSAtari911        // Search root calendar directory
113796df7d3eSAtari911        $searchCalendarDir($dataDir . 'calendar', '');
113896df7d3eSAtari911
113996df7d3eSAtari911        // Search namespace directories
114096df7d3eSAtari911        $this->searchNamespaceDirs($dataDir, $searchCalendarDir);
114196df7d3eSAtari911
114296df7d3eSAtari911        // Sort results by date (newest first for past, oldest first for future)
114396df7d3eSAtari911        usort($results, function($a, $b) {
114496df7d3eSAtari911            return strcmp($a['date'], $b['date']);
114596df7d3eSAtari911        });
114696df7d3eSAtari911
114796df7d3eSAtari911        // Limit results
114896df7d3eSAtari911        $results = array_slice($results, 0, 50);
114996df7d3eSAtari911
115096df7d3eSAtari911        echo json_encode([
115196df7d3eSAtari911            'success' => true,
115296df7d3eSAtari911            'results' => $results,
115396df7d3eSAtari911            'total' => count($results)
115496df7d3eSAtari911        ]);
115596df7d3eSAtari911    }
115696df7d3eSAtari911
115796df7d3eSAtari911    /**
115896df7d3eSAtari911     * Check if normalized text matches normalized search term
115996df7d3eSAtari911     * Supports multi-word search where all words must be present
116096df7d3eSAtari911     */
116196df7d3eSAtari911    private function fuzzyMatchText($normalizedText, $normalizedSearch) {
116296df7d3eSAtari911        // Direct substring match
116396df7d3eSAtari911        if (strpos($normalizedText, $normalizedSearch) !== false) {
116496df7d3eSAtari911            return true;
116596df7d3eSAtari911        }
116696df7d3eSAtari911
116796df7d3eSAtari911        // Multi-word search: all words must be present
116896df7d3eSAtari911        $searchWords = array_filter(explode(' ', $normalizedSearch));
116996df7d3eSAtari911        if (count($searchWords) > 1) {
117096df7d3eSAtari911            foreach ($searchWords as $word) {
117196df7d3eSAtari911                if (strlen($word) > 0 && strpos($normalizedText, $word) === false) {
117296df7d3eSAtari911                    return false;
117396df7d3eSAtari911                }
117496df7d3eSAtari911            }
117596df7d3eSAtari911            return true;
117696df7d3eSAtari911        }
117796df7d3eSAtari911
117896df7d3eSAtari911        return false;
117996df7d3eSAtari911    }
118096df7d3eSAtari911
118196df7d3eSAtari911    /**
118296df7d3eSAtari911     * Normalize text for fuzzy search matching
118396df7d3eSAtari911     * Removes apostrophes, extra spaces, and common variations
118496df7d3eSAtari911     */
118596df7d3eSAtari911    private function normalizeForSearch($text) {
118696df7d3eSAtari911        // Convert to lowercase
118796df7d3eSAtari911        $text = strtolower($text);
118896df7d3eSAtari911
118996df7d3eSAtari911        // Remove apostrophes and quotes (father's -> fathers)
119096df7d3eSAtari911        $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text);
119196df7d3eSAtari911
119296df7d3eSAtari911        // Normalize dashes and underscores to spaces
119396df7d3eSAtari911        $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text);
119496df7d3eSAtari911
119596df7d3eSAtari911        // Remove other punctuation but keep letters, numbers, spaces
119696df7d3eSAtari911        $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text);
119796df7d3eSAtari911
119896df7d3eSAtari911        // Normalize multiple spaces to single space
119996df7d3eSAtari911        $text = preg_replace('/\s+/', ' ', $text);
120096df7d3eSAtari911
120196df7d3eSAtari911        // Trim
120296df7d3eSAtari911        $text = trim($text);
120396df7d3eSAtari911
120496df7d3eSAtari911        return $text;
120596df7d3eSAtari911    }
120696df7d3eSAtari911
120796df7d3eSAtari911    /**
12082866e827SAtari911     * Parse exclude parameter into an array of namespace strings
12092866e827SAtari911     * Supports semicolon-separated list: "journal;drafts;personal:private"
12102866e827SAtari911     */
12112866e827SAtari911    private function parseExcludeList($exclude) {
12122866e827SAtari911        if (empty($exclude)) return [];
12132866e827SAtari911        return array_filter(array_map('trim', explode(';', $exclude)), function($v) {
12142866e827SAtari911            return $v !== '';
12152866e827SAtari911        });
12162866e827SAtari911    }
12172866e827SAtari911
12182866e827SAtari911    /**
12192866e827SAtari911     * Check if a namespace should be excluded
12202866e827SAtari911     * Matches exact names and prefixes (e.g., exclude "journal" also excludes "journal:sub")
12212866e827SAtari911     */
12222866e827SAtari911    private function isNamespaceExcluded($namespace, $excludeList) {
12232866e827SAtari911        if (empty($excludeList) || $namespace === '') return false;
12242866e827SAtari911        foreach ($excludeList as $excluded) {
12252866e827SAtari911            if ($namespace === $excluded) return true;
12262866e827SAtari911            if (strpos($namespace, $excluded . ':') === 0) return true;
12272866e827SAtari911        }
12282866e827SAtari911        return false;
12292866e827SAtari911    }
12302866e827SAtari911
12312866e827SAtari911    /**
123296df7d3eSAtari911     * Recursively search namespace directories for calendar data
123396df7d3eSAtari911     */
123496df7d3eSAtari911    private function searchNamespaceDirs($baseDir, $callback) {
123596df7d3eSAtari911        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
123696df7d3eSAtari911            $name = basename($nsDir);
123796df7d3eSAtari911            if ($name === 'calendar') continue;
123896df7d3eSAtari911
123996df7d3eSAtari911            $calDir = $nsDir . '/calendar';
124096df7d3eSAtari911            if (is_dir($calDir)) {
12412866e827SAtari911                $relPath = str_replace($this->metaDir(), '', $nsDir);
124296df7d3eSAtari911                $namespace = str_replace('/', ':', $relPath);
124396df7d3eSAtari911                $callback($calDir, $namespace);
124496df7d3eSAtari911            }
124596df7d3eSAtari911
124696df7d3eSAtari911            // Recurse
124796df7d3eSAtari911            $this->searchNamespaceDirs($nsDir . '/', $callback);
124896df7d3eSAtari911        }
124996df7d3eSAtari911    }
125096df7d3eSAtari911
125119378907SAtari911    private function toggleTaskComplete() {
125219378907SAtari911        global $INPUT;
125319378907SAtari911
125419378907SAtari911        $namespace = $INPUT->str('namespace', '');
125519378907SAtari911        $date = $INPUT->str('date');
125619378907SAtari911        $eventId = $INPUT->str('eventId');
125719378907SAtari911        $completed = $INPUT->bool('completed', false);
125819378907SAtari911
1259e3a9f44cSAtari911        // Find where the event actually lives
1260e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
1261e3a9f44cSAtari911
1262e3a9f44cSAtari911        if ($storedNamespace === null) {
1263e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
1264e3a9f44cSAtari911            return;
1265e3a9f44cSAtari911        }
1266e3a9f44cSAtari911
1267e3a9f44cSAtari911        // Use the found namespace
1268e3a9f44cSAtari911        $namespace = $storedNamespace;
1269e3a9f44cSAtari911
12702866e827SAtari911        // ACL check: verify edit access to toggle tasks
12712866e827SAtari911        if (!$this->checkNamespaceEdit($namespace)) {
12722866e827SAtari911            echo json_encode(['success' => false, 'error' => 'You do not have permission to edit events in this namespace']);
12732866e827SAtari911            return;
12742866e827SAtari911        }
12752866e827SAtari911
127619378907SAtari911        list($year, $month, $day) = explode('-', $date);
127719378907SAtari911
12782866e827SAtari911        $dataDir = $this->metaDir();
127919378907SAtari911        if ($namespace) {
128019378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
128119378907SAtari911        }
128219378907SAtari911        $dataDir .= 'calendar/';
128319378907SAtari911
128419378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
128519378907SAtari911
128619378907SAtari911        if (file_exists($eventFile)) {
128719378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
128819378907SAtari911
128919378907SAtari911            if (isset($events[$date])) {
1290815440faSAtari911                $eventTitle = '';
129119378907SAtari911                foreach ($events[$date] as $key => $event) {
129219378907SAtari911                    if ($event['id'] === $eventId) {
129319378907SAtari911                        $events[$date][$key]['completed'] = $completed;
1294815440faSAtari911                        $eventTitle = $event['title'] ?? '';
129519378907SAtari911                        break;
129619378907SAtari911                    }
129719378907SAtari911                }
129819378907SAtari911
1299815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
1300815440faSAtari911
1301815440faSAtari911                // Audit logging
1302815440faSAtari911                $audit = $this->getAuditLogger();
1303815440faSAtari911                $audit->logTaskToggle($namespace, $date, $eventId, $eventTitle, $completed);
1304815440faSAtari911
130519378907SAtari911                echo json_encode(['success' => true, 'events' => $events]);
130619378907SAtari911                return;
130719378907SAtari911            }
130819378907SAtari911        }
130919378907SAtari911
131019378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
131119378907SAtari911    }
131219378907SAtari911
1313815440faSAtari911    // ========================================================================
1314815440faSAtari911    // GOOGLE CALENDAR SYNC HANDLERS
1315815440faSAtari911    // ========================================================================
1316815440faSAtari911
1317815440faSAtari911    /**
1318815440faSAtari911     * Get Google OAuth authorization URL
1319815440faSAtari911     */
1320815440faSAtari911    private function getGoogleAuthUrl() {
1321815440faSAtari911        if (!auth_isadmin()) {
1322815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1323815440faSAtari911            return;
1324815440faSAtari911        }
1325815440faSAtari911
1326815440faSAtari911        $sync = $this->getGoogleSync();
1327815440faSAtari911
1328815440faSAtari911        if (!$sync->isConfigured()) {
1329815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Google sync not configured. Please enter Client ID and Secret first.']);
1330815440faSAtari911            return;
1331815440faSAtari911        }
1332815440faSAtari911
1333815440faSAtari911        // Build redirect URI
1334815440faSAtari911        $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback';
1335815440faSAtari911
1336815440faSAtari911        $authUrl = $sync->getAuthUrl($redirectUri);
1337815440faSAtari911
1338815440faSAtari911        echo json_encode(['success' => true, 'url' => $authUrl]);
1339815440faSAtari911    }
1340815440faSAtari911
1341815440faSAtari911    /**
1342815440faSAtari911     * Handle Google OAuth callback
1343815440faSAtari911     */
1344815440faSAtari911    private function handleGoogleCallback() {
1345815440faSAtari911        global $INPUT;
1346815440faSAtari911
1347815440faSAtari911        $code = $INPUT->str('code');
1348815440faSAtari911        $state = $INPUT->str('state');
1349815440faSAtari911        $error = $INPUT->str('error');
1350815440faSAtari911
1351815440faSAtari911        // Check for OAuth error
1352815440faSAtari911        if ($error) {
1353815440faSAtari911            $this->showGoogleCallbackResult(false, 'Authorization denied: ' . $error);
1354815440faSAtari911            return;
1355815440faSAtari911        }
1356815440faSAtari911
1357815440faSAtari911        if (!$code) {
1358815440faSAtari911            $this->showGoogleCallbackResult(false, 'No authorization code received');
1359815440faSAtari911            return;
1360815440faSAtari911        }
1361815440faSAtari911
1362815440faSAtari911        $sync = $this->getGoogleSync();
1363815440faSAtari911
1364815440faSAtari911        // Verify state for CSRF protection
1365815440faSAtari911        if (!$sync->verifyState($state)) {
1366815440faSAtari911            $this->showGoogleCallbackResult(false, 'Invalid state parameter');
1367815440faSAtari911            return;
1368815440faSAtari911        }
1369815440faSAtari911
1370815440faSAtari911        // Exchange code for tokens
1371815440faSAtari911        $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback';
1372815440faSAtari911        $result = $sync->handleCallback($code, $redirectUri);
1373815440faSAtari911
1374815440faSAtari911        if ($result['success']) {
1375815440faSAtari911            $this->showGoogleCallbackResult(true, 'Successfully connected to Google Calendar!');
1376815440faSAtari911        } else {
1377815440faSAtari911            $this->showGoogleCallbackResult(false, $result['error']);
1378815440faSAtari911        }
1379815440faSAtari911    }
1380815440faSAtari911
1381815440faSAtari911    /**
1382815440faSAtari911     * Show OAuth callback result page
1383815440faSAtari911     */
1384815440faSAtari911    private function showGoogleCallbackResult($success, $message) {
1385815440faSAtari911        $status = $success ? 'Success!' : 'Error';
1386815440faSAtari911        $color = $success ? '#2ecc71' : '#e74c3c';
1387815440faSAtari911
1388815440faSAtari911        echo '<!DOCTYPE html>
1389815440faSAtari911<html>
1390815440faSAtari911<head>
1391815440faSAtari911    <title>Google Calendar - ' . $status . '</title>
1392815440faSAtari911    <style>
1393815440faSAtari911        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1394815440faSAtari911               display: flex; align-items: center; justify-content: center;
1395815440faSAtari911               min-height: 100vh; margin: 0; background: #f5f5f5; }
1396815440faSAtari911        .card { background: white; padding: 40px; border-radius: 12px;
1397815440faSAtari911                box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
1398815440faSAtari911        h1 { color: ' . $color . '; margin: 0 0 16px 0; }
1399815440faSAtari911        p { color: #666; margin: 0 0 24px 0; }
1400815440faSAtari911        button { background: #3498db; color: white; border: none; padding: 12px 24px;
1401815440faSAtari911                 border-radius: 6px; cursor: pointer; font-size: 14px; }
1402815440faSAtari911        button:hover { background: #2980b9; }
1403815440faSAtari911    </style>
1404815440faSAtari911</head>
1405815440faSAtari911<body>
1406815440faSAtari911    <div class="card">
1407815440faSAtari911        <h1>' . ($success ? '✓' : '✕') . ' ' . $status . '</h1>
1408815440faSAtari911        <p>' . htmlspecialchars($message) . '</p>
1409815440faSAtari911        <button onclick="window.close()">Close Window</button>
1410815440faSAtari911    </div>
1411815440faSAtari911    <script>
1412815440faSAtari911        // Notify parent window
1413815440faSAtari911        if (window.opener) {
1414815440faSAtari911            window.opener.postMessage({ type: "google_auth_complete", success: ' . ($success ? 'true' : 'false') . ' }, "*");
1415815440faSAtari911        }
1416815440faSAtari911    </script>
1417815440faSAtari911</body>
1418815440faSAtari911</html>';
1419815440faSAtari911    }
1420815440faSAtari911
1421815440faSAtari911    /**
1422815440faSAtari911     * Get Google sync status
1423815440faSAtari911     */
1424815440faSAtari911    private function getGoogleStatus() {
1425815440faSAtari911        $sync = $this->getGoogleSync();
1426815440faSAtari911        echo json_encode(['success' => true, 'status' => $sync->getStatus()]);
1427815440faSAtari911    }
1428815440faSAtari911
1429815440faSAtari911    /**
1430815440faSAtari911     * Get list of Google calendars
1431815440faSAtari911     */
1432815440faSAtari911    private function getGoogleCalendars() {
1433815440faSAtari911        if (!auth_isadmin()) {
1434815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1435815440faSAtari911            return;
1436815440faSAtari911        }
1437815440faSAtari911
1438815440faSAtari911        $sync = $this->getGoogleSync();
1439815440faSAtari911        $result = $sync->getCalendars();
1440815440faSAtari911        echo json_encode($result);
1441815440faSAtari911    }
1442815440faSAtari911
1443815440faSAtari911    /**
1444815440faSAtari911     * Import events from Google Calendar
1445815440faSAtari911     */
1446815440faSAtari911    private function googleImport() {
1447815440faSAtari911        global $INPUT;
1448815440faSAtari911
1449815440faSAtari911        if (!auth_isadmin()) {
1450815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1451815440faSAtari911            return;
1452815440faSAtari911        }
1453815440faSAtari911
1454815440faSAtari911        $namespace = $INPUT->str('namespace', '');
1455815440faSAtari911        $startDate = $INPUT->str('startDate', '');
1456815440faSAtari911        $endDate = $INPUT->str('endDate', '');
1457815440faSAtari911
1458815440faSAtari911        $sync = $this->getGoogleSync();
1459815440faSAtari911        $result = $sync->importEvents($namespace, $startDate ?: null, $endDate ?: null);
1460815440faSAtari911
1461815440faSAtari911        echo json_encode($result);
1462815440faSAtari911    }
1463815440faSAtari911
1464815440faSAtari911    /**
1465815440faSAtari911     * Export events to Google Calendar
1466815440faSAtari911     */
1467815440faSAtari911    private function googleExport() {
1468815440faSAtari911        global $INPUT;
1469815440faSAtari911
1470815440faSAtari911        if (!auth_isadmin()) {
1471815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1472815440faSAtari911            return;
1473815440faSAtari911        }
1474815440faSAtari911
1475815440faSAtari911        $namespace = $INPUT->str('namespace', '');
1476815440faSAtari911        $startDate = $INPUT->str('startDate', '');
1477815440faSAtari911        $endDate = $INPUT->str('endDate', '');
1478815440faSAtari911
1479815440faSAtari911        $sync = $this->getGoogleSync();
1480815440faSAtari911        $result = $sync->exportEvents($namespace, $startDate ?: null, $endDate ?: null);
1481815440faSAtari911
1482815440faSAtari911        echo json_encode($result);
1483815440faSAtari911    }
1484815440faSAtari911
1485815440faSAtari911    /**
1486815440faSAtari911     * Disconnect from Google Calendar
1487815440faSAtari911     */
1488815440faSAtari911    private function googleDisconnect() {
1489815440faSAtari911        if (!auth_isadmin()) {
1490815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1491815440faSAtari911            return;
1492815440faSAtari911        }
1493815440faSAtari911
1494815440faSAtari911        $sync = $this->getGoogleSync();
1495815440faSAtari911        $sync->disconnect();
1496815440faSAtari911
1497815440faSAtari911        echo json_encode(['success' => true]);
1498815440faSAtari911    }
1499815440faSAtari911
150096df7d3eSAtari911    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime,
150196df7d3eSAtari911                                          $description, $color, $isTask, $recurrenceType, $recurrenceInterval,
150296df7d3eSAtari911                                          $recurrenceEnd, $weekDays, $monthlyType, $monthDay,
150396df7d3eSAtari911                                          $ordinalWeek, $ordinalDay, $baseId) {
15042866e827SAtari911        $dataDir = $this->metaDir();
150587ac9bf3SAtari911        if ($namespace) {
150687ac9bf3SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
150787ac9bf3SAtari911        }
150887ac9bf3SAtari911        $dataDir .= 'calendar/';
150987ac9bf3SAtari911
151087ac9bf3SAtari911        if (!is_dir($dataDir)) {
151187ac9bf3SAtari911            mkdir($dataDir, 0755, true);
151287ac9bf3SAtari911        }
151387ac9bf3SAtari911
151496df7d3eSAtari911        // Ensure interval is at least 1
151596df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
151687ac9bf3SAtari911
151787ac9bf3SAtari911        // Set maximum end date if not specified (1 year from start)
151887ac9bf3SAtari911        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
151987ac9bf3SAtari911
152087ac9bf3SAtari911        // Calculate event duration for multi-day events
152187ac9bf3SAtari911        $eventDuration = 0;
152287ac9bf3SAtari911        if ($endDate && $endDate !== $startDate) {
152387ac9bf3SAtari911            $start = new DateTime($startDate);
152487ac9bf3SAtari911            $end = new DateTime($endDate);
152587ac9bf3SAtari911            $eventDuration = $start->diff($end)->days;
152687ac9bf3SAtari911        }
152787ac9bf3SAtari911
152887ac9bf3SAtari911        // Generate recurring events
152987ac9bf3SAtari911        $currentDate = new DateTime($startDate);
153087ac9bf3SAtari911        $endLimit = new DateTime($maxEnd);
153187ac9bf3SAtari911        $counter = 0;
153296df7d3eSAtari911        $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year)
153396df7d3eSAtari911
153496df7d3eSAtari911        // For weekly with specific days, we need to track the interval counter differently
153596df7d3eSAtari911        $weekCounter = 0;
153696df7d3eSAtari911        $startWeekNumber = (int)$currentDate->format('W');
153796df7d3eSAtari911        $startYear = (int)$currentDate->format('Y');
153887ac9bf3SAtari911
153987ac9bf3SAtari911        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
154096df7d3eSAtari911            $shouldCreateEvent = false;
154196df7d3eSAtari911
154296df7d3eSAtari911            switch ($recurrenceType) {
154396df7d3eSAtari911                case 'daily':
154496df7d3eSAtari911                    // Every N days from start
154596df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
154696df7d3eSAtari911                    $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0);
154796df7d3eSAtari911                    break;
154896df7d3eSAtari911
154996df7d3eSAtari911                case 'weekly':
155096df7d3eSAtari911                    // Every N weeks, on specified days
155196df7d3eSAtari911                    $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat
155296df7d3eSAtari911
155396df7d3eSAtari911                    // Calculate weeks since start
155496df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
155596df7d3eSAtari911                    $weeksSinceStart = floor($daysSinceStart / 7);
155696df7d3eSAtari911
155796df7d3eSAtari911                    // Check if we're in the right week (every N weeks)
155896df7d3eSAtari911                    $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0);
155996df7d3eSAtari911
156096df7d3eSAtari911                    // Check if this day is selected
156196df7d3eSAtari911                    $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays);
156296df7d3eSAtari911
156396df7d3eSAtari911                    // For the first week, only include days on or after the start date
156496df7d3eSAtari911                    $isOnOrAfterStart = ($currentDate >= new DateTime($startDate));
156596df7d3eSAtari911
156696df7d3eSAtari911                    $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart;
156796df7d3eSAtari911                    break;
156896df7d3eSAtari911
156996df7d3eSAtari911                case 'monthly':
157096df7d3eSAtari911                    // Calculate months since start
157196df7d3eSAtari911                    $startDT = new DateTime($startDate);
157296df7d3eSAtari911                    $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) +
157396df7d3eSAtari911                                        ($currentDate->format('n') - $startDT->format('n'));
157496df7d3eSAtari911
157596df7d3eSAtari911                    // Check if we're in the right month (every N months)
157696df7d3eSAtari911                    $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0);
157796df7d3eSAtari911
157896df7d3eSAtari911                    if (!$isCorrectMonth) {
157996df7d3eSAtari911                        // Skip to first day of next potential month
158096df7d3eSAtari911                        $currentDate->modify('first day of next month');
158196df7d3eSAtari911                        continue 2;
158296df7d3eSAtari911                    }
158396df7d3eSAtari911
158496df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
158596df7d3eSAtari911                        // Specific day of month (e.g., 15th)
158696df7d3eSAtari911                        $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j');
158796df7d3eSAtari911                        $currentDay = (int)$currentDate->format('j');
158896df7d3eSAtari911                        $daysInMonth = (int)$currentDate->format('t');
158996df7d3eSAtari911
159096df7d3eSAtari911                        // If target day exceeds days in month, use last day
159196df7d3eSAtari911                        $effectiveTargetDay = min($targetDay, $daysInMonth);
159296df7d3eSAtari911                        $shouldCreateEvent = ($currentDay === $effectiveTargetDay);
159396df7d3eSAtari911                    } else {
159496df7d3eSAtari911                        // Ordinal weekday (e.g., 2nd Wednesday, last Friday)
159596df7d3eSAtari911                        $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay);
159696df7d3eSAtari911                    }
159796df7d3eSAtari911                    break;
159896df7d3eSAtari911
159996df7d3eSAtari911                case 'yearly':
160096df7d3eSAtari911                    // Every N years on same month/day
160196df7d3eSAtari911                    $startDT = new DateTime($startDate);
160296df7d3eSAtari911                    $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y');
160396df7d3eSAtari911
160496df7d3eSAtari911                    // Check if we're in the right year
160596df7d3eSAtari911                    $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0);
160696df7d3eSAtari911
160796df7d3eSAtari911                    // Check if it's the same month and day
160896df7d3eSAtari911                    $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d'));
160996df7d3eSAtari911
161096df7d3eSAtari911                    $shouldCreateEvent = $isCorrectYear && $sameMonthDay;
161196df7d3eSAtari911                    break;
161296df7d3eSAtari911
161396df7d3eSAtari911                default:
161496df7d3eSAtari911                    $shouldCreateEvent = false;
161596df7d3eSAtari911            }
161696df7d3eSAtari911
161796df7d3eSAtari911            if ($shouldCreateEvent) {
161887ac9bf3SAtari911                $dateKey = $currentDate->format('Y-m-d');
161987ac9bf3SAtari911                list($year, $month, $day) = explode('-', $dateKey);
162087ac9bf3SAtari911
162187ac9bf3SAtari911                // Calculate end date for this occurrence if multi-day
162287ac9bf3SAtari911                $occurrenceEndDate = '';
162387ac9bf3SAtari911                if ($eventDuration > 0) {
162487ac9bf3SAtari911                    $occurrenceEnd = clone $currentDate;
162587ac9bf3SAtari911                    $occurrenceEnd->modify('+' . $eventDuration . ' days');
162687ac9bf3SAtari911                    $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
162787ac9bf3SAtari911                }
162887ac9bf3SAtari911
162987ac9bf3SAtari911                // Load month file
163087ac9bf3SAtari911                $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
163187ac9bf3SAtari911                $events = [];
163287ac9bf3SAtari911                if (file_exists($eventFile)) {
163387ac9bf3SAtari911                    $events = json_decode(file_get_contents($eventFile), true);
163496df7d3eSAtari911                    if (!is_array($events)) $events = [];
163587ac9bf3SAtari911                }
163687ac9bf3SAtari911
163787ac9bf3SAtari911                if (!isset($events[$dateKey])) {
163887ac9bf3SAtari911                    $events[$dateKey] = [];
163987ac9bf3SAtari911                }
164087ac9bf3SAtari911
164187ac9bf3SAtari911                // Create event for this occurrence
164287ac9bf3SAtari911                $eventData = [
164387ac9bf3SAtari911                    'id' => $baseId . '-' . $counter,
164487ac9bf3SAtari911                    'title' => $title,
164587ac9bf3SAtari911                    'time' => $time,
16461d05cddcSAtari911                    'endTime' => $endTime,
164787ac9bf3SAtari911                    'description' => $description,
164887ac9bf3SAtari911                    'color' => $color,
164987ac9bf3SAtari911                    'isTask' => $isTask,
165087ac9bf3SAtari911                    'completed' => false,
165187ac9bf3SAtari911                    'endDate' => $occurrenceEndDate,
165287ac9bf3SAtari911                    'recurring' => true,
165387ac9bf3SAtari911                    'recurringId' => $baseId,
165496df7d3eSAtari911                    'recurrenceType' => $recurrenceType,
165596df7d3eSAtari911                    'recurrenceInterval' => $recurrenceInterval,
165696df7d3eSAtari911                    'namespace' => $namespace,
165787ac9bf3SAtari911                    'created' => date('Y-m-d H:i:s')
165887ac9bf3SAtari911                ];
165987ac9bf3SAtari911
166096df7d3eSAtari911                // Store additional recurrence info for reference
166196df7d3eSAtari911                if ($recurrenceType === 'weekly' && !empty($weekDays)) {
166296df7d3eSAtari911                    $eventData['weekDays'] = $weekDays;
166396df7d3eSAtari911                }
166496df7d3eSAtari911                if ($recurrenceType === 'monthly') {
166596df7d3eSAtari911                    $eventData['monthlyType'] = $monthlyType;
166696df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
166796df7d3eSAtari911                        $eventData['monthDay'] = $monthDay;
166896df7d3eSAtari911                    } else {
166996df7d3eSAtari911                        $eventData['ordinalWeek'] = $ordinalWeek;
167096df7d3eSAtari911                        $eventData['ordinalDay'] = $ordinalDay;
167196df7d3eSAtari911                    }
167296df7d3eSAtari911                }
167396df7d3eSAtari911
167487ac9bf3SAtari911                $events[$dateKey][] = $eventData;
1675815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
167687ac9bf3SAtari911
167787ac9bf3SAtari911                $counter++;
167887ac9bf3SAtari911            }
167996df7d3eSAtari911
168096df7d3eSAtari911            // Move to next day (we check each day individually for complex patterns)
168196df7d3eSAtari911            $currentDate->modify('+1 day');
168296df7d3eSAtari911        }
168396df7d3eSAtari911    }
168496df7d3eSAtari911
168596df7d3eSAtari911    /**
168696df7d3eSAtari911     * Check if a date is the Nth occurrence of a weekday in its month
168796df7d3eSAtari911     * @param DateTime $date The date to check
168896df7d3eSAtari911     * @param int $ordinalWeek 1-5 for first-fifth, -1 for last
168996df7d3eSAtari911     * @param int $targetDayOfWeek 0=Sunday through 6=Saturday
169096df7d3eSAtari911     * @return bool
169196df7d3eSAtari911     */
169296df7d3eSAtari911    private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) {
169396df7d3eSAtari911        $currentDayOfWeek = (int)$date->format('w');
169496df7d3eSAtari911
169596df7d3eSAtari911        // First, check if it's the right day of week
169696df7d3eSAtari911        if ($currentDayOfWeek !== $targetDayOfWeek) {
169796df7d3eSAtari911            return false;
169896df7d3eSAtari911        }
169996df7d3eSAtari911
170096df7d3eSAtari911        $dayOfMonth = (int)$date->format('j');
170196df7d3eSAtari911        $daysInMonth = (int)$date->format('t');
170296df7d3eSAtari911
170396df7d3eSAtari911        if ($ordinalWeek === -1) {
170496df7d3eSAtari911            // Last occurrence: check if there's no more of this weekday in the month
170596df7d3eSAtari911            $daysRemaining = $daysInMonth - $dayOfMonth;
170696df7d3eSAtari911            return $daysRemaining < 7;
170796df7d3eSAtari911        } else {
170896df7d3eSAtari911            // Nth occurrence: check which occurrence this is
170996df7d3eSAtari911            $weekNumber = ceil($dayOfMonth / 7);
171096df7d3eSAtari911            return $weekNumber === $ordinalWeek;
171196df7d3eSAtari911        }
171287ac9bf3SAtari911    }
171387ac9bf3SAtari911
171419378907SAtari911    public function addAssets(Doku_Event $event, $param) {
171519378907SAtari911        $event->data['link'][] = array(
171619378907SAtari911            'type' => 'text/css',
171719378907SAtari911            'rel' => 'stylesheet',
171819378907SAtari911            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
171919378907SAtari911        );
172019378907SAtari911
1721eba540a1SAtari911        // Ensure JSINFO.sectok is available for CSRF protection
1722eba540a1SAtari911        // Not all DokuWiki versions/templates populate this automatically
1723eba540a1SAtari911        $event->data['script'][] = array(
1724eba540a1SAtari911            'type' => 'text/javascript',
1725eba540a1SAtari911            '_data' => 'if(typeof JSINFO !== "undefined"){ JSINFO.sectok = "' . getSecurityToken() . '"; }'
1726eba540a1SAtari911        );
1727eba540a1SAtari911
172896df7d3eSAtari911        // Load the main calendar JavaScript
172996df7d3eSAtari911        // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues
173096df7d3eSAtari911        // The actual code is in calendar-main.js
173119378907SAtari911        $event->data['script'][] = array(
173219378907SAtari911            'type' => 'text/javascript',
173396df7d3eSAtari911            'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js'
173419378907SAtari911        );
173519378907SAtari911    }
1736e3a9f44cSAtari911    // Helper function to find an event's stored namespace
1737e3a9f44cSAtari911    private function findEventNamespace($eventId, $date, $searchNamespace) {
1738e3a9f44cSAtari911        list($year, $month, $day) = explode('-', $date);
1739e3a9f44cSAtari911
1740e3a9f44cSAtari911        // List of namespaces to check
1741e3a9f44cSAtari911        $namespacesToCheck = [''];
1742e3a9f44cSAtari911
1743e3a9f44cSAtari911        // If searchNamespace is a wildcard or multi, we need to search multiple locations
1744e3a9f44cSAtari911        if (!empty($searchNamespace)) {
1745e3a9f44cSAtari911            if (strpos($searchNamespace, ';') !== false) {
1746e3a9f44cSAtari911                // Multi-namespace - check each one
1747e3a9f44cSAtari911                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
1748e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1749e3a9f44cSAtari911            } elseif (strpos($searchNamespace, '*') !== false) {
1750e3a9f44cSAtari911                // Wildcard - need to scan directories
1751e3a9f44cSAtari911                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
1752e3a9f44cSAtari911                $namespacesToCheck = $this->findAllNamespaces($baseNs);
1753e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1754e3a9f44cSAtari911            } else {
1755e3a9f44cSAtari911                // Single namespace
1756e3a9f44cSAtari911                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
1757e3a9f44cSAtari911            }
1758e3a9f44cSAtari911        }
1759e3a9f44cSAtari911
176096df7d3eSAtari911        $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck)));
176196df7d3eSAtari911
1762e3a9f44cSAtari911        // Search for the event in all possible namespaces
1763e3a9f44cSAtari911        foreach ($namespacesToCheck as $ns) {
17642866e827SAtari911            $dataDir = $this->metaDir();
1765e3a9f44cSAtari911            if ($ns) {
1766e3a9f44cSAtari911                $dataDir .= str_replace(':', '/', $ns) . '/';
1767e3a9f44cSAtari911            }
1768e3a9f44cSAtari911            $dataDir .= 'calendar/';
1769e3a9f44cSAtari911
1770e3a9f44cSAtari911            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1771e3a9f44cSAtari911
1772e3a9f44cSAtari911            if (file_exists($eventFile)) {
1773e3a9f44cSAtari911                $events = json_decode(file_get_contents($eventFile), true);
1774e3a9f44cSAtari911                if (isset($events[$date])) {
1775e3a9f44cSAtari911                    foreach ($events[$date] as $evt) {
1776e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
177796df7d3eSAtari911                            // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace
177896df7d3eSAtari911                            // The directory is what matters for deletion - that's where the file actually is
177996df7d3eSAtari911                            $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')");
178096df7d3eSAtari911                            return $ns;
1781e3a9f44cSAtari911                        }
1782e3a9f44cSAtari911                    }
1783e3a9f44cSAtari911                }
1784e3a9f44cSAtari911            }
1785e3a9f44cSAtari911        }
1786e3a9f44cSAtari911
178796df7d3eSAtari911        $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace");
1788e3a9f44cSAtari911        return null; // Event not found
1789e3a9f44cSAtari911    }
1790e3a9f44cSAtari911
1791e3a9f44cSAtari911    // Helper to find all namespaces under a base namespace
1792e3a9f44cSAtari911    private function findAllNamespaces($baseNamespace) {
17932866e827SAtari911        $dataDir = $this->metaDir();
1794e3a9f44cSAtari911        if ($baseNamespace) {
1795e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
1796e3a9f44cSAtari911        }
1797e3a9f44cSAtari911
1798e3a9f44cSAtari911        $namespaces = [];
1799e3a9f44cSAtari911        if ($baseNamespace) {
1800e3a9f44cSAtari911            $namespaces[] = $baseNamespace;
1801e3a9f44cSAtari911        }
1802e3a9f44cSAtari911
1803e3a9f44cSAtari911        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
1804e3a9f44cSAtari911
180596df7d3eSAtari911        $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces)));
180696df7d3eSAtari911
1807e3a9f44cSAtari911        return $namespaces;
1808e3a9f44cSAtari911    }
1809e3a9f44cSAtari911
1810e3a9f44cSAtari911    // Recursive scan for namespaces
1811e3a9f44cSAtari911    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
1812e3a9f44cSAtari911        if (!is_dir($dir)) return;
1813e3a9f44cSAtari911
1814e3a9f44cSAtari911        $items = scandir($dir);
1815e3a9f44cSAtari911        foreach ($items as $item) {
1816e3a9f44cSAtari911            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
1817e3a9f44cSAtari911
1818e3a9f44cSAtari911            $path = $dir . $item;
1819e3a9f44cSAtari911            if (is_dir($path)) {
1820e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1821e3a9f44cSAtari911                $namespaces[] = $namespace;
1822e3a9f44cSAtari911                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
1823e3a9f44cSAtari911            }
1824e3a9f44cSAtari911        }
1825e3a9f44cSAtari911    }
18269ccd446eSAtari911
18279ccd446eSAtari911    /**
18289ccd446eSAtari911     * Delete all instances of a recurring event across all months
18299ccd446eSAtari911     */
18309ccd446eSAtari911    private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) {
18319ccd446eSAtari911        // Scan all JSON files in the calendar directory
18329ccd446eSAtari911        $calendarFiles = glob($dataDir . '*.json');
18339ccd446eSAtari911
18349ccd446eSAtari911        foreach ($calendarFiles as $file) {
18359ccd446eSAtari911            $modified = false;
18369ccd446eSAtari911            $events = json_decode(file_get_contents($file), true);
18379ccd446eSAtari911
18389ccd446eSAtari911            if (!$events) continue;
18399ccd446eSAtari911
18409ccd446eSAtari911            // Check each date in the file
18419ccd446eSAtari911            foreach ($events as $date => &$dayEvents) {
18429ccd446eSAtari911                // Filter out events with matching recurringId
18439ccd446eSAtari911                $originalCount = count($dayEvents);
18449ccd446eSAtari911                $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) {
18459ccd446eSAtari911                    $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
18469ccd446eSAtari911                    return $eventRecurringId !== $recurringId;
18479ccd446eSAtari911                }));
18489ccd446eSAtari911
18499ccd446eSAtari911                if (count($dayEvents) !== $originalCount) {
18509ccd446eSAtari911                    $modified = true;
18519ccd446eSAtari911                }
18529ccd446eSAtari911
18539ccd446eSAtari911                // Remove empty dates
18549ccd446eSAtari911                if (empty($dayEvents)) {
18559ccd446eSAtari911                    unset($events[$date]);
18569ccd446eSAtari911                }
18579ccd446eSAtari911            }
18589ccd446eSAtari911
18599ccd446eSAtari911            // Save if modified
18609ccd446eSAtari911            if ($modified) {
1861815440faSAtari911                CalendarFileHandler::writeJson($file, $events);
18629ccd446eSAtari911            }
18639ccd446eSAtari911        }
18649ccd446eSAtari911    }
18659ccd446eSAtari911
18669ccd446eSAtari911    /**
18679ccd446eSAtari911     * Get existing event data for preserving unchanged fields during edit
18689ccd446eSAtari911     */
18699ccd446eSAtari911    private function getExistingEventData($eventId, $date, $namespace) {
18709ccd446eSAtari911        list($year, $month, $day) = explode('-', $date);
18719ccd446eSAtari911
18722866e827SAtari911        $dataDir = $this->metaDir();
18739ccd446eSAtari911        if ($namespace) {
18749ccd446eSAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
18759ccd446eSAtari911        }
18769ccd446eSAtari911        $dataDir .= 'calendar/';
18779ccd446eSAtari911
18789ccd446eSAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
18799ccd446eSAtari911
18809ccd446eSAtari911        if (!file_exists($eventFile)) {
18819ccd446eSAtari911            return null;
18829ccd446eSAtari911        }
18839ccd446eSAtari911
18849ccd446eSAtari911        $events = json_decode(file_get_contents($eventFile), true);
18859ccd446eSAtari911
18869ccd446eSAtari911        if (!isset($events[$date])) {
18879ccd446eSAtari911            return null;
18889ccd446eSAtari911        }
18899ccd446eSAtari911
18909ccd446eSAtari911        // Find the event by ID
18919ccd446eSAtari911        foreach ($events[$date] as $event) {
18929ccd446eSAtari911            if ($event['id'] === $eventId) {
18939ccd446eSAtari911                return $event;
18949ccd446eSAtari911            }
18959ccd446eSAtari911        }
18969ccd446eSAtari911
18979ccd446eSAtari911        return null;
18989ccd446eSAtari911    }
189919378907SAtari911}
1900