xref: /plugin/calendar/action.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
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*2866e827SAtari911 * @version 7.2.6
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
27*2866e827SAtari911    /**
28*2866e827SAtari911     * Get the meta directory path (farm-safe)
29*2866e827SAtari911     * Uses $conf['metadir'] instead of hardcoded DOKU_INC . 'data/meta/'
30*2866e827SAtari911     */
31*2866e827SAtari911    private function metaDir() {
32*2866e827SAtari911        global $conf;
33*2866e827SAtari911        return rtrim($conf['metadir'], '/') . '/';
34*2866e827SAtari911    }
35*2866e827SAtari911
36*2866e827SAtari911    /**
37*2866e827SAtari911     * Check if the current user has read access to a namespace
38*2866e827SAtari911     * Uses DokuWiki's ACL system for farm-safe permission checks
39*2866e827SAtari911     *
40*2866e827SAtari911     * @param string $namespace Namespace to check (empty = root)
41*2866e827SAtari911     * @return bool True if user has at least AUTH_READ
42*2866e827SAtari911     */
43*2866e827SAtari911    private function checkNamespaceRead($namespace) {
44*2866e827SAtari911        if (empty($namespace) || $namespace === '*') return true;
45*2866e827SAtari911        // Strip wildcards and semicolons for ACL check
46*2866e827SAtari911        $ns = str_replace(['*', ';'], '', $namespace);
47*2866e827SAtari911        if (empty($ns)) return true;
48*2866e827SAtari911        $perm = auth_quickaclcheck($ns . ':*');
49*2866e827SAtari911        return ($perm >= AUTH_READ);
50*2866e827SAtari911    }
51*2866e827SAtari911
52*2866e827SAtari911    /**
53*2866e827SAtari911     * Check if the current user has edit access to a namespace
54*2866e827SAtari911     *
55*2866e827SAtari911     * @param string $namespace Namespace to check (empty = root)
56*2866e827SAtari911     * @return bool True if user has at least AUTH_EDIT
57*2866e827SAtari911     */
58*2866e827SAtari911    private function checkNamespaceEdit($namespace) {
59*2866e827SAtari911        if (empty($namespace)) return true;
60*2866e827SAtari911        $perm = auth_quickaclcheck($namespace . ':*');
61*2866e827SAtari911        return ($perm >= AUTH_EDIT);
62*2866e827SAtari911    }
63*2866e827SAtari911
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',
1367e8ea635SAtari911                         'change_start_recurring', 'change_pattern_recurring'];
1377e8ea635SAtari911
138815440faSAtari911        $isWriteAction = in_array($action, $writeActions);
139815440faSAtari911
140815440faSAtari911        // Rate limiting check - apply to all AJAX actions
141815440faSAtari911        if (!CalendarRateLimiter::check($action, $isWriteAction)) {
142815440faSAtari911            CalendarRateLimiter::addHeaders($action, $isWriteAction);
143815440faSAtari911            http_response_code(429);
144815440faSAtari911            echo json_encode([
145815440faSAtari911                'success' => false,
146815440faSAtari911                'error' => 'Rate limit exceeded. Please wait before making more requests.',
147815440faSAtari911                'retry_after' => CalendarRateLimiter::getRemaining($action, $isWriteAction)['reset']
148815440faSAtari911            ]);
149815440faSAtari911            return;
150815440faSAtari911        }
151815440faSAtari911
152815440faSAtari911        // Add rate limit headers to all responses
153815440faSAtari911        CalendarRateLimiter::addHeaders($action, $isWriteAction);
154815440faSAtari911
155815440faSAtari911        if ($isWriteAction) {
156b498f308SAtari911            global $INPUT, $INFO;
157b498f308SAtari911
158b498f308SAtari911            // Check if user is logged in (at minimum)
159b498f308SAtari911            if (empty($_SERVER['REMOTE_USER'])) {
160b498f308SAtari911                echo json_encode(['success' => false, 'error' => 'Authentication required. Please log in.']);
161b498f308SAtari911                return;
162b498f308SAtari911            }
163b498f308SAtari911
164b498f308SAtari911            // Check for valid security token - try multiple sources
165b498f308SAtari911            $sectok = $INPUT->str('sectok', '');
166b498f308SAtari911            if (empty($sectok)) {
1677e8ea635SAtari911                $sectok = $_REQUEST['sectok'] ?? '';
168b498f308SAtari911            }
169b498f308SAtari911
170b498f308SAtari911            // Use DokuWiki's built-in check
1717e8ea635SAtari911            if (!checkSecurityToken($sectok)) {
172b498f308SAtari911                // Log for debugging
173b498f308SAtari911                $this->debugLog("Security token check failed. Received: '$sectok'");
1747e8ea635SAtari911                echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']);
1757e8ea635SAtari911                return;
1767e8ea635SAtari911            }
1777e8ea635SAtari911        }
1787e8ea635SAtari911
17919378907SAtari911        switch ($action) {
18019378907SAtari911            case 'save_event':
18119378907SAtari911                $this->saveEvent();
18219378907SAtari911                break;
18319378907SAtari911            case 'delete_event':
18419378907SAtari911                $this->deleteEvent();
18519378907SAtari911                break;
18619378907SAtari911            case 'get_event':
18719378907SAtari911                $this->getEvent();
18819378907SAtari911                break;
18919378907SAtari911            case 'load_month':
19019378907SAtari911                $this->loadMonth();
19119378907SAtari911                break;
192da206178SAtari911            case 'get_static_calendar':
193da206178SAtari911                $this->getStaticCalendar();
194da206178SAtari911                break;
19596df7d3eSAtari911            case 'search_all':
19696df7d3eSAtari911                $this->searchAllDates();
19796df7d3eSAtari911                break;
19819378907SAtari911            case 'toggle_task':
19919378907SAtari911                $this->toggleTaskComplete();
20019378907SAtari911                break;
201815440faSAtari911            case 'google_auth_url':
202815440faSAtari911                $this->getGoogleAuthUrl();
203815440faSAtari911                break;
204815440faSAtari911            case 'google_callback':
205815440faSAtari911                $this->handleGoogleCallback();
206815440faSAtari911                break;
207815440faSAtari911            case 'google_status':
208815440faSAtari911                $this->getGoogleStatus();
209815440faSAtari911                break;
210815440faSAtari911            case 'google_calendars':
211815440faSAtari911                $this->getGoogleCalendars();
212815440faSAtari911                break;
213815440faSAtari911            case 'google_import':
214815440faSAtari911                $this->googleImport();
215815440faSAtari911                break;
216815440faSAtari911            case 'google_export':
217815440faSAtari911                $this->googleExport();
218815440faSAtari911                break;
219815440faSAtari911            case 'google_disconnect':
220815440faSAtari911                $this->googleDisconnect();
221815440faSAtari911                break;
2227e8ea635SAtari911            case 'cleanup_empty_namespaces':
2237e8ea635SAtari911            case 'trim_all_past_recurring':
2247e8ea635SAtari911            case 'rescan_recurring':
2257e8ea635SAtari911            case 'extend_recurring':
2267e8ea635SAtari911            case 'trim_recurring':
2277e8ea635SAtari911            case 'pause_recurring':
2287e8ea635SAtari911            case 'resume_recurring':
2297e8ea635SAtari911            case 'change_start_recurring':
2307e8ea635SAtari911            case 'change_pattern_recurring':
2317e8ea635SAtari911                $this->routeToAdmin($action);
2327e8ea635SAtari911                break;
23319378907SAtari911            default:
23419378907SAtari911                echo json_encode(['success' => false, 'error' => 'Unknown action']);
23519378907SAtari911        }
23619378907SAtari911    }
23719378907SAtari911
2387e8ea635SAtari911    /**
2397e8ea635SAtari911     * Route AJAX actions to admin plugin methods
2407e8ea635SAtari911     */
2417e8ea635SAtari911    private function routeToAdmin($action) {
2427e8ea635SAtari911        $admin = plugin_load('admin', 'calendar');
2437e8ea635SAtari911        if ($admin && method_exists($admin, 'handleAjaxAction')) {
2447e8ea635SAtari911            $admin->handleAjaxAction($action);
2457e8ea635SAtari911        } else {
2467e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Admin handler not available']);
2477e8ea635SAtari911        }
2487e8ea635SAtari911    }
2497e8ea635SAtari911
25019378907SAtari911    private function saveEvent() {
25119378907SAtari911        global $INPUT;
25219378907SAtari911
25319378907SAtari911        $namespace = $INPUT->str('namespace', '');
25419378907SAtari911        $date = $INPUT->str('date');
25519378907SAtari911        $eventId = $INPUT->str('eventId', '');
25619378907SAtari911        $title = $INPUT->str('title');
25719378907SAtari911        $time = $INPUT->str('time', '');
2581d05cddcSAtari911        $endTime = $INPUT->str('endTime', '');
25919378907SAtari911        $description = $INPUT->str('description', '');
26019378907SAtari911        $color = $INPUT->str('color', '#3498db');
26119378907SAtari911        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
26219378907SAtari911        $isTask = $INPUT->bool('isTask', false);
26319378907SAtari911        $completed = $INPUT->bool('completed', false);
26419378907SAtari911        $endDate = $INPUT->str('endDate', '');
26587ac9bf3SAtari911        $isRecurring = $INPUT->bool('isRecurring', false);
26687ac9bf3SAtari911        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
26787ac9bf3SAtari911        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
26819378907SAtari911
26996df7d3eSAtari911        // New recurrence options
27096df7d3eSAtari911        $recurrenceInterval = $INPUT->int('recurrenceInterval', 1);
27196df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
27296df7d3eSAtari911        if ($recurrenceInterval > 99) $recurrenceInterval = 99;
27396df7d3eSAtari911
27496df7d3eSAtari911        $weekDaysStr = $INPUT->str('weekDays', '');
275*2866e827SAtari911        $weekDays = ($weekDaysStr !== '') ? array_map('intval', explode(',', $weekDaysStr)) : [];
27696df7d3eSAtari911
27796df7d3eSAtari911        $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth');
27896df7d3eSAtari911        $monthDay = $INPUT->int('monthDay', 0);
27996df7d3eSAtari911        $ordinalWeek = $INPUT->int('ordinalWeek', 1);
28096df7d3eSAtari911        $ordinalDay = $INPUT->int('ordinalDay', 0);
28196df7d3eSAtari911
28296df7d3eSAtari911        $this->debugLog("=== Calendar saveEvent START ===");
28396df7d3eSAtari911        $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'");
28496df7d3eSAtari911        $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'");
28596df7d3eSAtari911
28619378907SAtari911        if (!$date || !$title) {
28719378907SAtari911            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
28819378907SAtari911            return;
28919378907SAtari911        }
29019378907SAtari911
2917e8ea635SAtari911        // Validate date format (YYYY-MM-DD)
2927e8ea635SAtari911        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
2937e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid date format']);
2947e8ea635SAtari911            return;
2957e8ea635SAtari911        }
2967e8ea635SAtari911
2977e8ea635SAtari911        // Validate oldDate if provided
2987e8ea635SAtari911        if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) {
2997e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid old date format']);
3007e8ea635SAtari911            return;
3017e8ea635SAtari911        }
3027e8ea635SAtari911
3037e8ea635SAtari911        // Validate endDate if provided
3047e8ea635SAtari911        if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) {
3057e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end date format']);
3067e8ea635SAtari911            return;
3077e8ea635SAtari911        }
3087e8ea635SAtari911
3097e8ea635SAtari911        // Validate time format (HH:MM) if provided
3107e8ea635SAtari911        if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) {
3117e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid time format']);
3127e8ea635SAtari911            return;
3137e8ea635SAtari911        }
3147e8ea635SAtari911
3157e8ea635SAtari911        // Validate endTime format if provided
3167e8ea635SAtari911        if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) {
3177e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end time format']);
3187e8ea635SAtari911            return;
3197e8ea635SAtari911        }
3207e8ea635SAtari911
3217e8ea635SAtari911        // Validate color format (hex color)
3227e8ea635SAtari911        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
3237e8ea635SAtari911            $color = '#3498db'; // Reset to default if invalid
3247e8ea635SAtari911        }
3257e8ea635SAtari911
3267e8ea635SAtari911        // Validate namespace (prevent path traversal)
3277e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
3287e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
3297e8ea635SAtari911            return;
3307e8ea635SAtari911        }
3317e8ea635SAtari911
332*2866e827SAtari911        // ACL check: verify edit access to the target namespace
333*2866e827SAtari911        if (!$this->checkNamespaceEdit($namespace)) {
334*2866e827SAtari911            echo json_encode(['success' => false, 'error' => 'You do not have permission to edit events in this namespace']);
335*2866e827SAtari911            return;
336*2866e827SAtari911        }
337*2866e827SAtari911
3387e8ea635SAtari911        // Validate recurrence type
3397e8ea635SAtari911        $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
3407e8ea635SAtari911        if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) {
3417e8ea635SAtari911            $recurrenceType = 'weekly';
3427e8ea635SAtari911        }
3437e8ea635SAtari911
3447e8ea635SAtari911        // Validate recurrenceEnd if provided
3457e8ea635SAtari911        if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) {
3467e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']);
3477e8ea635SAtari911            return;
3487e8ea635SAtari911        }
3497e8ea635SAtari911
3507e8ea635SAtari911        // Sanitize title length
3517e8ea635SAtari911        $title = substr(trim($title), 0, 500);
3527e8ea635SAtari911
3537e8ea635SAtari911        // Sanitize description length
3547e8ea635SAtari911        $description = substr($description, 0, 10000);
3557e8ea635SAtari911
35696df7d3eSAtari911        // If editing, find the event's ACTUAL namespace (for finding/deleting old event)
35796df7d3eSAtari911        // We need to search ALL namespaces because user may be changing namespace
35896df7d3eSAtari911        $oldNamespace = null;  // null means "not found yet"
359e3a9f44cSAtari911        if ($eventId) {
3601d05cddcSAtari911            // Use oldDate if available (date was changed), otherwise use current date
3611d05cddcSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
3621d05cddcSAtari911
36396df7d3eSAtari911            // Search using wildcard to find event in ANY namespace
36496df7d3eSAtari911            $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*');
36596df7d3eSAtari911
36696df7d3eSAtari911            if ($foundNamespace !== null) {
36796df7d3eSAtari911                $oldNamespace = $foundNamespace;  // Could be '' for default namespace
3687e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'");
36996df7d3eSAtari911            } else {
37096df7d3eSAtari911                $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace");
3711d05cddcSAtari911            }
372e3a9f44cSAtari911        }
373e3a9f44cSAtari911
3741d05cddcSAtari911        // Use the namespace provided by the user (allow namespace changes!)
3751d05cddcSAtari911        // But normalize wildcards and multi-namespace to empty for NEW events
3761d05cddcSAtari911        if (!$eventId) {
3777e8ea635SAtari911            $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'");
378e3a9f44cSAtari911            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
379e3a9f44cSAtari911            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
3807e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty");
381e3a9f44cSAtari911                $namespace = '';
3821d05cddcSAtari911            } else {
3837e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'");
384e3a9f44cSAtari911            }
3851d05cddcSAtari911        } else {
3867e8ea635SAtari911            $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'");
387e3a9f44cSAtari911        }
388e3a9f44cSAtari911
38987ac9bf3SAtari911        // Generate event ID if new
39087ac9bf3SAtari911        $generatedId = $eventId ?: uniqid();
39187ac9bf3SAtari911
3929ccd446eSAtari911        // If editing a recurring event, load existing data to preserve unchanged fields
3939ccd446eSAtari911        $existingEventData = null;
3949ccd446eSAtari911        if ($eventId && $isRecurring) {
3959ccd446eSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
39696df7d3eSAtari911            // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use ''
39796df7d3eSAtari911            $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace);
3989ccd446eSAtari911            if ($existingEventData) {
3997e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'");
4009ccd446eSAtari911            }
4019ccd446eSAtari911        }
4029ccd446eSAtari911
40387ac9bf3SAtari911        // If recurring, generate multiple events
40487ac9bf3SAtari911        if ($isRecurring) {
4059ccd446eSAtari911            // Merge with existing data if editing (preserve values that weren't changed)
4069ccd446eSAtari911            if ($existingEventData) {
4079ccd446eSAtari911                $title = $title ?: $existingEventData['title'];
4089ccd446eSAtari911                $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : '');
4099ccd446eSAtari911                $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : '');
4109ccd446eSAtari911                $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : '');
4119ccd446eSAtari911                // Only use existing color if new color is default
4129ccd446eSAtari911                if ($color === '#3498db' && isset($existingEventData['color'])) {
4139ccd446eSAtari911                    $color = $existingEventData['color'];
4149ccd446eSAtari911                }
4159ccd446eSAtari911
4169ccd446eSAtari911                // Preserve namespace in these cases:
4179ccd446eSAtari911                // 1. Namespace field is empty (user didn't select anything)
4189ccd446eSAtari911                // 2. Namespace contains wildcards (like "personal;work" or "work*")
4199ccd446eSAtari911                // 3. Namespace is the same as what was passed (no change intended)
4209ccd446eSAtari911                $receivedNamespace = $namespace;
4219ccd446eSAtari911                if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) {
4229ccd446eSAtari911                    if (isset($existingEventData['namespace'])) {
4239ccd446eSAtari911                        $namespace = $existingEventData['namespace'];
4247e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')");
4259ccd446eSAtari911                    } else {
4267e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')");
4279ccd446eSAtari911                    }
4289ccd446eSAtari911                } else {
4297e8ea635SAtari911                    $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')");
4309ccd446eSAtari911                }
4319ccd446eSAtari911            } else {
4327e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'");
4339ccd446eSAtari911            }
4349ccd446eSAtari911
43596df7d3eSAtari911            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description,
43696df7d3eSAtari911                                        $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd,
43796df7d3eSAtari911                                        $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId);
43887ac9bf3SAtari911            echo json_encode(['success' => true]);
43987ac9bf3SAtari911            return;
44087ac9bf3SAtari911        }
44187ac9bf3SAtari911
44219378907SAtari911        list($year, $month, $day) = explode('-', $date);
44319378907SAtari911
4441d05cddcSAtari911        // NEW namespace directory (where we'll save)
445*2866e827SAtari911        $dataDir = $this->metaDir();
44619378907SAtari911        if ($namespace) {
44719378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
44819378907SAtari911        }
44919378907SAtari911        $dataDir .= 'calendar/';
45019378907SAtari911
45119378907SAtari911        if (!is_dir($dataDir)) {
45219378907SAtari911            mkdir($dataDir, 0755, true);
45319378907SAtari911        }
45419378907SAtari911
45519378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
45619378907SAtari911
45796df7d3eSAtari911        $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'");
45896df7d3eSAtari911
45919378907SAtari911        $events = [];
46019378907SAtari911        if (file_exists($eventFile)) {
46119378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
46296df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location");
46396df7d3eSAtari911        } else {
46496df7d3eSAtari911            $this->debugLog("Calendar saveEvent: New location file does not exist yet");
46519378907SAtari911        }
46619378907SAtari911
4671d05cddcSAtari911        // If editing and (date changed OR namespace changed), remove from old location first
46896df7d3eSAtari911        // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace
46996df7d3eSAtari911        $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace);
4701d05cddcSAtari911        $dateChanged = ($eventId && $oldDate && $oldDate !== $date);
4711d05cddcSAtari911
47296df7d3eSAtari911        $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO'));
47396df7d3eSAtari911
4741d05cddcSAtari911        if ($namespaceChanged || $dateChanged) {
4751d05cddcSAtari911            // Construct OLD data directory using OLD namespace
476*2866e827SAtari911            $oldDataDir = $this->metaDir();
4771d05cddcSAtari911            if ($oldNamespace) {
4781d05cddcSAtari911                $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/';
4791d05cddcSAtari911            }
4801d05cddcSAtari911            $oldDataDir .= 'calendar/';
4811d05cddcSAtari911
4821d05cddcSAtari911            $deleteDate = $dateChanged ? $oldDate : $date;
4831d05cddcSAtari911            list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate);
4841d05cddcSAtari911            $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
48519378907SAtari911
48696df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'");
48796df7d3eSAtari911
48819378907SAtari911            if (file_exists($oldEventFile)) {
48919378907SAtari911                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
49096df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates");
49196df7d3eSAtari911
4921d05cddcSAtari911                if (isset($oldEvents[$deleteDate])) {
49396df7d3eSAtari911                    $countBefore = count($oldEvents[$deleteDate]);
4941d05cddcSAtari911                    $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) {
49519378907SAtari911                        return $evt['id'] !== $eventId;
496e3a9f44cSAtari911                    }));
49796df7d3eSAtari911                    $countAfter = count($oldEvents[$deleteDate]);
49896df7d3eSAtari911
49996df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter");
50019378907SAtari911
5011d05cddcSAtari911                    if (empty($oldEvents[$deleteDate])) {
5021d05cddcSAtari911                        unset($oldEvents[$deleteDate]);
50319378907SAtari911                    }
50419378907SAtari911
505815440faSAtari911                    CalendarFileHandler::writeJson($oldEventFile, $oldEvents);
50696df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'");
50796df7d3eSAtari911                } else {
50896df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file");
50919378907SAtari911                }
51096df7d3eSAtari911            } else {
51196df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile");
51219378907SAtari911            }
51396df7d3eSAtari911        } else {
51496df7d3eSAtari911            $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location");
51519378907SAtari911        }
51619378907SAtari911
51719378907SAtari911        if (!isset($events[$date])) {
51819378907SAtari911            $events[$date] = [];
519e3a9f44cSAtari911        } elseif (!is_array($events[$date])) {
520e3a9f44cSAtari911            // Fix corrupted data - ensure it's an array
5217e8ea635SAtari911            $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
522e3a9f44cSAtari911            $events[$date] = [];
52319378907SAtari911        }
52419378907SAtari911
525e3a9f44cSAtari911        // Store the namespace with the event
52619378907SAtari911        $eventData = [
52787ac9bf3SAtari911            'id' => $generatedId,
52819378907SAtari911            'title' => $title,
52919378907SAtari911            'time' => $time,
5301d05cddcSAtari911            'endTime' => $endTime,
53119378907SAtari911            'description' => $description,
53219378907SAtari911            'color' => $color,
53319378907SAtari911            'isTask' => $isTask,
53419378907SAtari911            'completed' => $completed,
53519378907SAtari911            'endDate' => $endDate,
536e3a9f44cSAtari911            'namespace' => $namespace, // Store namespace with event
53719378907SAtari911            'created' => date('Y-m-d H:i:s')
53819378907SAtari911        ];
53919378907SAtari911
5401d05cddcSAtari911        // Debug logging
5417e8ea635SAtari911        $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile");
5421d05cddcSAtari911
54319378907SAtari911        // If editing, replace existing event
54419378907SAtari911        if ($eventId) {
54519378907SAtari911            $found = false;
54619378907SAtari911            foreach ($events[$date] as $key => $evt) {
54719378907SAtari911                if ($evt['id'] === $eventId) {
54819378907SAtari911                    $events[$date][$key] = $eventData;
54919378907SAtari911                    $found = true;
55019378907SAtari911                    break;
55119378907SAtari911                }
55219378907SAtari911            }
55319378907SAtari911            if (!$found) {
55419378907SAtari911                $events[$date][] = $eventData;
55519378907SAtari911            }
55619378907SAtari911        } else {
55719378907SAtari911            $events[$date][] = $eventData;
55819378907SAtari911        }
55919378907SAtari911
560815440faSAtari911        CalendarFileHandler::writeJson($eventFile, $events);
56119378907SAtari911
562e3a9f44cSAtari911        // If event spans multiple months, add it to the first day of each subsequent month
563e3a9f44cSAtari911        if ($endDate && $endDate !== $date) {
564e3a9f44cSAtari911            $startDateObj = new DateTime($date);
565e3a9f44cSAtari911            $endDateObj = new DateTime($endDate);
566e3a9f44cSAtari911
567e3a9f44cSAtari911            // Get the month/year of the start date
568e3a9f44cSAtari911            $startMonth = $startDateObj->format('Y-m');
569e3a9f44cSAtari911
570e3a9f44cSAtari911            // Iterate through each month the event spans
571e3a9f44cSAtari911            $currentDate = clone $startDateObj;
572e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
573e3a9f44cSAtari911
574e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
575e3a9f44cSAtari911                $currentMonth = $currentDate->format('Y-m');
576e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
577e3a9f44cSAtari911
578e3a9f44cSAtari911                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
579e3a9f44cSAtari911
580e3a9f44cSAtari911                // Get the file for this month
581e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
582e3a9f44cSAtari911
583e3a9f44cSAtari911                $currentEvents = [];
584e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
585e3a9f44cSAtari911                    $contents = file_get_contents($currentEventFile);
586e3a9f44cSAtari911                    $decoded = json_decode($contents, true);
587e3a9f44cSAtari911                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
588e3a9f44cSAtari911                        $currentEvents = $decoded;
589e3a9f44cSAtari911                    } else {
5907e8ea635SAtari911                        $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
591e3a9f44cSAtari911                    }
592e3a9f44cSAtari911                }
593e3a9f44cSAtari911
594e3a9f44cSAtari911                // Add entry for the first day of this month
595e3a9f44cSAtari911                if (!isset($currentEvents[$firstDayOfMonth])) {
596e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
597e3a9f44cSAtari911                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
598e3a9f44cSAtari911                    // Fix corrupted data - ensure it's an array
5997e8ea635SAtari911                    $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
600e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
601e3a9f44cSAtari911                }
602e3a9f44cSAtari911
603e3a9f44cSAtari911                // Create a copy with the original start date preserved
604e3a9f44cSAtari911                $eventDataForMonth = $eventData;
605e3a9f44cSAtari911                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
606e3a9f44cSAtari911
607e3a9f44cSAtari911                // Check if event already exists (when editing)
608e3a9f44cSAtari911                $found = false;
609e3a9f44cSAtari911                if ($eventId) {
610e3a9f44cSAtari911                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
611e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
612e3a9f44cSAtari911                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
613e3a9f44cSAtari911                            $found = true;
614e3a9f44cSAtari911                            break;
615e3a9f44cSAtari911                        }
616e3a9f44cSAtari911                    }
617e3a9f44cSAtari911                }
618e3a9f44cSAtari911
619e3a9f44cSAtari911                if (!$found) {
620e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
621e3a9f44cSAtari911                }
622e3a9f44cSAtari911
623815440faSAtari911                CalendarFileHandler::writeJson($currentEventFile, $currentEvents);
624e3a9f44cSAtari911
625e3a9f44cSAtari911                // Move to next month
626e3a9f44cSAtari911                $currentDate->modify('first day of next month');
627e3a9f44cSAtari911            }
628e3a9f44cSAtari911        }
629e3a9f44cSAtari911
630815440faSAtari911        // Audit logging
631815440faSAtari911        $audit = $this->getAuditLogger();
632815440faSAtari911        if ($eventId && ($dateChanged || $namespaceChanged)) {
633815440faSAtari911            // Event was moved
634815440faSAtari911            $audit->logMove($namespace, $oldDate ?: $date, $date, $generatedId, $title);
635815440faSAtari911        } elseif ($eventId) {
636815440faSAtari911            // Event was updated
637815440faSAtari911            $audit->logUpdate($namespace, $date, $generatedId, $title);
638815440faSAtari911        } else {
639815440faSAtari911            // New event created
640815440faSAtari911            $audit->logCreate($namespace, $date, $generatedId, $title);
641815440faSAtari911        }
642815440faSAtari911
64319378907SAtari911        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
64419378907SAtari911    }
64519378907SAtari911
64619378907SAtari911    private function deleteEvent() {
64719378907SAtari911        global $INPUT;
64819378907SAtari911
64919378907SAtari911        $namespace = $INPUT->str('namespace', '');
65019378907SAtari911        $date = $INPUT->str('date');
65119378907SAtari911        $eventId = $INPUT->str('eventId');
65219378907SAtari911
653e3a9f44cSAtari911        // Find where the event actually lives
654e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
655e3a9f44cSAtari911
656e3a9f44cSAtari911        if ($storedNamespace === null) {
657e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
658e3a9f44cSAtari911            return;
659e3a9f44cSAtari911        }
660e3a9f44cSAtari911
661e3a9f44cSAtari911        // Use the found namespace
662e3a9f44cSAtari911        $namespace = $storedNamespace;
663e3a9f44cSAtari911
664*2866e827SAtari911        // ACL check: verify edit access to delete events
665*2866e827SAtari911        if (!$this->checkNamespaceEdit($namespace)) {
666*2866e827SAtari911            echo json_encode(['success' => false, 'error' => 'You do not have permission to delete events in this namespace']);
667*2866e827SAtari911            return;
668*2866e827SAtari911        }
669*2866e827SAtari911
67019378907SAtari911        list($year, $month, $day) = explode('-', $date);
67119378907SAtari911
672*2866e827SAtari911        $dataDir = $this->metaDir();
67319378907SAtari911        if ($namespace) {
67419378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
67519378907SAtari911        }
67619378907SAtari911        $dataDir .= 'calendar/';
67719378907SAtari911
67819378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
67919378907SAtari911
6809ccd446eSAtari911        // First, get the event to check if it spans multiple months or is recurring
681e3a9f44cSAtari911        $eventToDelete = null;
6829ccd446eSAtari911        $isRecurring = false;
6839ccd446eSAtari911        $recurringId = null;
6849ccd446eSAtari911
68519378907SAtari911        if (file_exists($eventFile)) {
68619378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
68719378907SAtari911
68819378907SAtari911            if (isset($events[$date])) {
689e3a9f44cSAtari911                foreach ($events[$date] as $event) {
690e3a9f44cSAtari911                    if ($event['id'] === $eventId) {
691e3a9f44cSAtari911                        $eventToDelete = $event;
6929ccd446eSAtari911                        $isRecurring = isset($event['recurring']) && $event['recurring'];
6939ccd446eSAtari911                        $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
694e3a9f44cSAtari911                        break;
695e3a9f44cSAtari911                    }
696e3a9f44cSAtari911                }
697e3a9f44cSAtari911
698e3a9f44cSAtari911                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
69919378907SAtari911                    return $event['id'] !== $eventId;
700e3a9f44cSAtari911                }));
70119378907SAtari911
70219378907SAtari911                if (empty($events[$date])) {
70319378907SAtari911                    unset($events[$date]);
70419378907SAtari911                }
70519378907SAtari911
706815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
70719378907SAtari911            }
70819378907SAtari911        }
70919378907SAtari911
7109ccd446eSAtari911        // If this is a recurring event, delete ALL occurrences with the same recurringId
7119ccd446eSAtari911        if ($isRecurring && $recurringId) {
7129ccd446eSAtari911            $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir);
7139ccd446eSAtari911        }
7149ccd446eSAtari911
715e3a9f44cSAtari911        // If event spans multiple months, delete it from the first day of each subsequent month
716e3a9f44cSAtari911        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
717e3a9f44cSAtari911            $startDateObj = new DateTime($date);
718e3a9f44cSAtari911            $endDateObj = new DateTime($eventToDelete['endDate']);
719e3a9f44cSAtari911
720e3a9f44cSAtari911            // Iterate through each month the event spans
721e3a9f44cSAtari911            $currentDate = clone $startDateObj;
722e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
723e3a9f44cSAtari911
724e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
725e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
726e3a9f44cSAtari911                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
727e3a9f44cSAtari911
728e3a9f44cSAtari911                // Get the file for this month
729e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
730e3a9f44cSAtari911
731e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
732e3a9f44cSAtari911                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
733e3a9f44cSAtari911
734e3a9f44cSAtari911                    if (isset($currentEvents[$firstDayOfMonth])) {
735e3a9f44cSAtari911                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
736e3a9f44cSAtari911                            return $event['id'] !== $eventId;
737e3a9f44cSAtari911                        }));
738e3a9f44cSAtari911
739e3a9f44cSAtari911                        if (empty($currentEvents[$firstDayOfMonth])) {
740e3a9f44cSAtari911                            unset($currentEvents[$firstDayOfMonth]);
741e3a9f44cSAtari911                        }
742e3a9f44cSAtari911
743815440faSAtari911                        CalendarFileHandler::writeJson($currentEventFile, $currentEvents);
744e3a9f44cSAtari911                    }
745e3a9f44cSAtari911                }
746e3a9f44cSAtari911
747e3a9f44cSAtari911                // Move to next month
748e3a9f44cSAtari911                $currentDate->modify('first day of next month');
749e3a9f44cSAtari911            }
750e3a9f44cSAtari911        }
751e3a9f44cSAtari911
752815440faSAtari911        // Audit logging
753815440faSAtari911        $audit = $this->getAuditLogger();
754815440faSAtari911        $eventTitle = $eventToDelete ? ($eventToDelete['title'] ?? '') : '';
755815440faSAtari911        $audit->logDelete($namespace, $date, $eventId, $eventTitle);
756815440faSAtari911
75719378907SAtari911        echo json_encode(['success' => true]);
75819378907SAtari911    }
75919378907SAtari911
76019378907SAtari911    private function getEvent() {
76119378907SAtari911        global $INPUT;
76219378907SAtari911
76319378907SAtari911        $namespace = $INPUT->str('namespace', '');
76419378907SAtari911        $date = $INPUT->str('date');
76519378907SAtari911        $eventId = $INPUT->str('eventId');
76619378907SAtari911
767e3a9f44cSAtari911        // Find where the event actually lives
768e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
769e3a9f44cSAtari911
770e3a9f44cSAtari911        if ($storedNamespace === null) {
771e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
772e3a9f44cSAtari911            return;
773e3a9f44cSAtari911        }
774e3a9f44cSAtari911
775e3a9f44cSAtari911        // Use the found namespace
776e3a9f44cSAtari911        $namespace = $storedNamespace;
777e3a9f44cSAtari911
778*2866e827SAtari911        // ACL check: verify read access to the event's namespace
779*2866e827SAtari911        if (!$this->checkNamespaceRead($namespace)) {
780*2866e827SAtari911            echo json_encode(['success' => false, 'error' => 'Access denied']);
781*2866e827SAtari911            return;
782*2866e827SAtari911        }
783*2866e827SAtari911
78419378907SAtari911        list($year, $month, $day) = explode('-', $date);
78519378907SAtari911
786*2866e827SAtari911        $dataDir = $this->metaDir();
78719378907SAtari911        if ($namespace) {
78819378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
78919378907SAtari911        }
79019378907SAtari911        $dataDir .= 'calendar/';
79119378907SAtari911
79219378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
79319378907SAtari911
79419378907SAtari911        if (file_exists($eventFile)) {
79519378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
79619378907SAtari911
79719378907SAtari911            if (isset($events[$date])) {
79819378907SAtari911                foreach ($events[$date] as $event) {
79919378907SAtari911                    if ($event['id'] === $eventId) {
8001d05cddcSAtari911                        // Include the namespace so JavaScript knows where this event actually lives
8011d05cddcSAtari911                        $event['namespace'] = $namespace;
80219378907SAtari911                        echo json_encode(['success' => true, 'event' => $event]);
80319378907SAtari911                        return;
80419378907SAtari911                    }
80519378907SAtari911                }
80619378907SAtari911            }
80719378907SAtari911        }
80819378907SAtari911
80919378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
81019378907SAtari911    }
81119378907SAtari911
81219378907SAtari911    private function loadMonth() {
81319378907SAtari911        global $INPUT;
81419378907SAtari911
815e3a9f44cSAtari911        // Prevent caching of AJAX responses
816e3a9f44cSAtari911        header('Cache-Control: no-cache, no-store, must-revalidate');
817e3a9f44cSAtari911        header('Pragma: no-cache');
818e3a9f44cSAtari911        header('Expires: 0');
819e3a9f44cSAtari911
82019378907SAtari911        $namespace = $INPUT->str('namespace', '');
82119378907SAtari911        $year = $INPUT->int('year');
82219378907SAtari911        $month = $INPUT->int('month');
823*2866e827SAtari911        $exclude = $INPUT->str('exclude', '');
824*2866e827SAtari911        $excludeList = $this->parseExcludeList($exclude);
82519378907SAtari911
8267e8ea635SAtari911        // Validate year (reasonable range: 1970-2100)
8277e8ea635SAtari911        if ($year < 1970 || $year > 2100) {
8287e8ea635SAtari911            $year = (int)date('Y');
8297e8ea635SAtari911        }
8307e8ea635SAtari911
8317e8ea635SAtari911        // Validate month (1-12)
8327e8ea635SAtari911        if ($month < 1 || $month > 12) {
8337e8ea635SAtari911            $month = (int)date('n');
8347e8ea635SAtari911        }
8357e8ea635SAtari911
8367e8ea635SAtari911        // Validate namespace format
8377e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
8387e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
8397e8ea635SAtari911            return;
8407e8ea635SAtari911        }
8417e8ea635SAtari911
842*2866e827SAtari911        // ACL check: for single namespace, verify read access
843*2866e827SAtari911        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
844*2866e827SAtari911        if (!$isMultiNamespace && !$this->checkNamespaceRead($namespace)) {
845*2866e827SAtari911            echo json_encode(['success' => false, 'error' => 'Access denied']);
846*2866e827SAtari911            return;
847*2866e827SAtari911        }
848*2866e827SAtari911
8497e8ea635SAtari911        $this->debugLog("=== Calendar loadMonth DEBUG ===");
850*2866e827SAtari911        $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace', exclude='$exclude'");
851e3a9f44cSAtari911
852e3a9f44cSAtari911        // Check if multi-namespace or wildcard
853e3a9f44cSAtari911
8547e8ea635SAtari911        $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
855e3a9f44cSAtari911
856e3a9f44cSAtari911        if ($isMultiNamespace) {
857*2866e827SAtari911            $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList);
858e3a9f44cSAtari911        } else {
859e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
860e3a9f44cSAtari911        }
861e3a9f44cSAtari911
8627e8ea635SAtari911        $this->debugLog("Returning " . count($events) . " date keys");
863e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
8647e8ea635SAtari911            $this->debugLog("  dateKey=$dateKey has " . count($dayEvents) . " events");
865e3a9f44cSAtari911        }
866e3a9f44cSAtari911
867e3a9f44cSAtari911        echo json_encode([
868e3a9f44cSAtari911            'success' => true,
869e3a9f44cSAtari911            'year' => $year,
870e3a9f44cSAtari911            'month' => $month,
871e3a9f44cSAtari911            'events' => $events
872e3a9f44cSAtari911        ]);
873e3a9f44cSAtari911    }
874e3a9f44cSAtari911
875da206178SAtari911    /**
876da206178SAtari911     * Get static calendar HTML via AJAX for navigation
877da206178SAtari911     */
878da206178SAtari911    private function getStaticCalendar() {
879da206178SAtari911        global $INPUT;
880da206178SAtari911
881da206178SAtari911        $namespace = $INPUT->str('namespace', '');
882da206178SAtari911        $year = $INPUT->int('year');
883da206178SAtari911        $month = $INPUT->int('month');
884da206178SAtari911
885da206178SAtari911        // Validate
886da206178SAtari911        if ($year < 1970 || $year > 2100) {
887da206178SAtari911            $year = (int)date('Y');
888da206178SAtari911        }
889da206178SAtari911        if ($month < 1 || $month > 12) {
890da206178SAtari911            $month = (int)date('n');
891da206178SAtari911        }
892da206178SAtari911
893*2866e827SAtari911        // ACL check: verify read access for single namespace
894*2866e827SAtari911        $isMulti = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
895*2866e827SAtari911        if (!$isMulti && !$this->checkNamespaceRead($namespace)) {
896*2866e827SAtari911            echo json_encode(['success' => false, 'error' => 'Access denied']);
897*2866e827SAtari911            return;
898*2866e827SAtari911        }
899*2866e827SAtari911
900da206178SAtari911        // Get syntax plugin to render the static calendar
901da206178SAtari911        $syntax = plugin_load('syntax', 'calendar');
902da206178SAtari911        if (!$syntax) {
903da206178SAtari911            echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']);
904da206178SAtari911            return;
905da206178SAtari911        }
906da206178SAtari911
907da206178SAtari911        // Build data array for render
908da206178SAtari911        $data = [
909da206178SAtari911            'year' => $year,
910da206178SAtari911            'month' => $month,
911da206178SAtari911            'namespace' => $namespace,
912da206178SAtari911            'static' => true
913da206178SAtari911        ];
914da206178SAtari911
915da206178SAtari911        // Call the render method via reflection (since renderStaticCalendar is private)
916da206178SAtari911        $reflector = new \ReflectionClass($syntax);
917da206178SAtari911        $method = $reflector->getMethod('renderStaticCalendar');
918da206178SAtari911        $method->setAccessible(true);
919da206178SAtari911        $html = $method->invoke($syntax, $data);
920da206178SAtari911
921da206178SAtari911        echo json_encode([
922da206178SAtari911            'success' => true,
923da206178SAtari911            'html' => $html
924da206178SAtari911        ]);
925da206178SAtari911    }
926da206178SAtari911
927e3a9f44cSAtari911    private function loadEventsSingleNamespace($namespace, $year, $month) {
928*2866e827SAtari911        $dataDir = $this->metaDir();
92919378907SAtari911        if ($namespace) {
93019378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
93119378907SAtari911        }
93219378907SAtari911        $dataDir .= 'calendar/';
93319378907SAtari911
934e3a9f44cSAtari911        // Load ONLY current month
93587ac9bf3SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
93619378907SAtari911        $events = [];
93719378907SAtari911        if (file_exists($eventFile)) {
93887ac9bf3SAtari911            $contents = file_get_contents($eventFile);
93987ac9bf3SAtari911            $decoded = json_decode($contents, true);
94087ac9bf3SAtari911            if (json_last_error() === JSON_ERROR_NONE) {
94187ac9bf3SAtari911                $events = $decoded;
94287ac9bf3SAtari911            }
94387ac9bf3SAtari911        }
94487ac9bf3SAtari911
945e3a9f44cSAtari911        return $events;
94687ac9bf3SAtari911    }
947e3a9f44cSAtari911
948*2866e827SAtari911    private function loadEventsMultiNamespace($namespaces, $year, $month, $excludeList = []) {
949e3a9f44cSAtari911        // Check for wildcard pattern
950e3a9f44cSAtari911        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
951e3a9f44cSAtari911            $baseNamespace = $matches[1];
952*2866e827SAtari911            return $this->loadEventsWildcard($baseNamespace, $year, $month, $excludeList);
953e3a9f44cSAtari911        }
954e3a9f44cSAtari911
955e3a9f44cSAtari911        // Check for root wildcard
956e3a9f44cSAtari911        if ($namespaces === '*') {
957*2866e827SAtari911            return $this->loadEventsWildcard('', $year, $month, $excludeList);
958e3a9f44cSAtari911        }
959e3a9f44cSAtari911
960e3a9f44cSAtari911        // Parse namespace list (semicolon separated)
961e3a9f44cSAtari911        $namespaceList = array_map('trim', explode(';', $namespaces));
962e3a9f44cSAtari911
963e3a9f44cSAtari911        // Load events from all namespaces
964e3a9f44cSAtari911        $allEvents = [];
965e3a9f44cSAtari911        foreach ($namespaceList as $ns) {
966e3a9f44cSAtari911            $ns = trim($ns);
967e3a9f44cSAtari911            if (empty($ns)) continue;
968e3a9f44cSAtari911
969*2866e827SAtari911            // Skip excluded namespaces
970*2866e827SAtari911            if ($this->isNamespaceExcluded($ns, $excludeList)) continue;
971*2866e827SAtari911
972*2866e827SAtari911            // ACL check: skip namespaces user cannot read
973*2866e827SAtari911            if (!$this->checkNamespaceRead($ns)) continue;
974*2866e827SAtari911
975e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
976e3a9f44cSAtari911
977e3a9f44cSAtari911            // Add namespace tag to each event
978e3a9f44cSAtari911            foreach ($events as $dateKey => $dayEvents) {
979e3a9f44cSAtari911                if (!isset($allEvents[$dateKey])) {
980e3a9f44cSAtari911                    $allEvents[$dateKey] = [];
981e3a9f44cSAtari911                }
982e3a9f44cSAtari911                foreach ($dayEvents as $event) {
983e3a9f44cSAtari911                    $event['_namespace'] = $ns;
984e3a9f44cSAtari911                    $allEvents[$dateKey][] = $event;
985e3a9f44cSAtari911                }
98687ac9bf3SAtari911            }
98787ac9bf3SAtari911        }
98887ac9bf3SAtari911
989e3a9f44cSAtari911        return $allEvents;
990e3a9f44cSAtari911    }
99119378907SAtari911
992*2866e827SAtari911    private function loadEventsWildcard($baseNamespace, $year, $month, $excludeList = []) {
993*2866e827SAtari911        $metaDir = $this->metaDir();
994*2866e827SAtari911        $dataDir = $metaDir;
995e3a9f44cSAtari911        if ($baseNamespace) {
996e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
997e3a9f44cSAtari911        }
998e3a9f44cSAtari911
999e3a9f44cSAtari911        $allEvents = [];
1000e3a9f44cSAtari911
1001*2866e827SAtari911        // Load events from the base namespace itself
1002*2866e827SAtari911        if (!$this->isNamespaceExcluded($baseNamespace, $excludeList) && $this->checkNamespaceRead($baseNamespace)) {
1003e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
1004e3a9f44cSAtari911
1005e3a9f44cSAtari911            foreach ($events as $dateKey => $dayEvents) {
1006e3a9f44cSAtari911                if (!isset($allEvents[$dateKey])) {
1007e3a9f44cSAtari911                    $allEvents[$dateKey] = [];
1008e3a9f44cSAtari911                }
1009e3a9f44cSAtari911                foreach ($dayEvents as $event) {
1010e3a9f44cSAtari911                    $event['_namespace'] = $baseNamespace;
1011e3a9f44cSAtari911                    $allEvents[$dateKey][] = $event;
1012e3a9f44cSAtari911                }
1013e3a9f44cSAtari911            }
1014*2866e827SAtari911        }
1015e3a9f44cSAtari911
1016*2866e827SAtari911        // Find all calendar directories efficiently using iterative glob
1017*2866e827SAtari911        $this->findCalendarNamespaces($dataDir, $metaDir, $year, $month, $allEvents, $excludeList);
1018e3a9f44cSAtari911
1019e3a9f44cSAtari911        return $allEvents;
1020e3a9f44cSAtari911    }
1021e3a9f44cSAtari911
1022*2866e827SAtari911    /**
1023*2866e827SAtari911     * Find namespaces with calendar data using iterative glob
1024*2866e827SAtari911     * Searches for 'calendar/' directories at increasing depth without
1025*2866e827SAtari911     * scanning every directory in data/meta
1026*2866e827SAtari911     */
1027*2866e827SAtari911    private function findCalendarNamespaces($baseDir, $metaDir, $year, $month, &$allEvents, $excludeList = []) {
1028*2866e827SAtari911        if (!is_dir($baseDir)) return;
1029e3a9f44cSAtari911
1030*2866e827SAtari911        $maxDepth = 10;
1031*2866e827SAtari911        $metaDirLen = strlen($metaDir);
1032e3a9f44cSAtari911
1033*2866e827SAtari911        for ($depth = 1; $depth <= $maxDepth; $depth++) {
1034*2866e827SAtari911            $pattern = $baseDir . str_repeat('*/', $depth) . 'calendar';
1035*2866e827SAtari911            $calDirs = glob($pattern, GLOB_ONLYDIR);
1036e3a9f44cSAtari911
1037*2866e827SAtari911            if (empty($calDirs)) {
1038*2866e827SAtari911                if ($depth > 3) break;
1039*2866e827SAtari911                continue;
1040*2866e827SAtari911            }
1041*2866e827SAtari911
1042*2866e827SAtari911            foreach ($calDirs as $calDir) {
1043*2866e827SAtari911                $nsDir = dirname($calDir);
1044*2866e827SAtari911                $relPath = substr($nsDir, $metaDirLen);
1045*2866e827SAtari911                $namespace = str_replace('/', ':', trim($relPath, '/'));
1046*2866e827SAtari911
1047*2866e827SAtari911                if (empty($namespace)) continue;
1048*2866e827SAtari911                if ($this->isNamespaceExcluded($namespace, $excludeList)) continue;
1049*2866e827SAtari911                if (!$this->checkNamespaceRead($namespace)) continue;
1050*2866e827SAtari911
1051e3a9f44cSAtari911                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
1052e3a9f44cSAtari911                foreach ($events as $dateKey => $dayEvents) {
1053e3a9f44cSAtari911                    if (!isset($allEvents[$dateKey])) {
1054e3a9f44cSAtari911                        $allEvents[$dateKey] = [];
1055e3a9f44cSAtari911                    }
1056e3a9f44cSAtari911                    foreach ($dayEvents as $event) {
1057e3a9f44cSAtari911                        $event['_namespace'] = $namespace;
1058e3a9f44cSAtari911                        $allEvents[$dateKey][] = $event;
1059e3a9f44cSAtari911                    }
1060e3a9f44cSAtari911                }
1061e3a9f44cSAtari911            }
1062e3a9f44cSAtari911        }
106319378907SAtari911    }
106419378907SAtari911
106596df7d3eSAtari911    /**
106696df7d3eSAtari911     * Search all dates for events matching the search term
106796df7d3eSAtari911     */
106896df7d3eSAtari911    private function searchAllDates() {
106996df7d3eSAtari911        global $INPUT;
107096df7d3eSAtari911
107196df7d3eSAtari911        $searchTerm = strtolower(trim($INPUT->str('search', '')));
107296df7d3eSAtari911        $namespace = $INPUT->str('namespace', '');
1073*2866e827SAtari911        $exclude = $INPUT->str('exclude', '');
1074*2866e827SAtari911        $excludeList = $this->parseExcludeList($exclude);
107596df7d3eSAtari911
107696df7d3eSAtari911        if (strlen($searchTerm) < 2) {
107796df7d3eSAtari911            echo json_encode(['success' => false, 'error' => 'Search term too short']);
107896df7d3eSAtari911            return;
107996df7d3eSAtari911        }
108096df7d3eSAtari911
108196df7d3eSAtari911        // Normalize search term for fuzzy matching
108296df7d3eSAtari911        $normalizedSearch = $this->normalizeForSearch($searchTerm);
108396df7d3eSAtari911
108496df7d3eSAtari911        $results = [];
1085*2866e827SAtari911        $dataDir = $this->metaDir();
108696df7d3eSAtari911
108796df7d3eSAtari911        // Helper to search calendar directory
1088*2866e827SAtari911        $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results, $excludeList) {
108996df7d3eSAtari911            if (!is_dir($calDir)) return;
109096df7d3eSAtari911
1091*2866e827SAtari911            // Skip excluded namespaces
1092*2866e827SAtari911            if ($this->isNamespaceExcluded($eventNamespace, $excludeList)) return;
1093*2866e827SAtari911
1094*2866e827SAtari911            // ACL check: skip namespaces user cannot read
1095*2866e827SAtari911            if (!$this->checkNamespaceRead($eventNamespace)) return;
1096*2866e827SAtari911
109796df7d3eSAtari911            foreach (glob($calDir . '/*.json') as $file) {
109896df7d3eSAtari911                $data = @json_decode(file_get_contents($file), true);
109996df7d3eSAtari911                if (!$data || !is_array($data)) continue;
110096df7d3eSAtari911
110196df7d3eSAtari911                foreach ($data as $dateKey => $dayEvents) {
110296df7d3eSAtari911                    // Skip non-date keys
110396df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
110496df7d3eSAtari911                    if (!is_array($dayEvents)) continue;
110596df7d3eSAtari911
110696df7d3eSAtari911                    foreach ($dayEvents as $event) {
110796df7d3eSAtari911                        if (!isset($event['title'])) continue;
110896df7d3eSAtari911
110996df7d3eSAtari911                        // Build searchable text
111096df7d3eSAtari911                        $searchableText = strtolower($event['title']);
111196df7d3eSAtari911                        if (isset($event['description'])) {
111296df7d3eSAtari911                            $searchableText .= ' ' . strtolower($event['description']);
111396df7d3eSAtari911                        }
111496df7d3eSAtari911
111596df7d3eSAtari911                        // Normalize for fuzzy matching
111696df7d3eSAtari911                        $normalizedText = $this->normalizeForSearch($searchableText);
111796df7d3eSAtari911
111896df7d3eSAtari911                        // Check if matches using fuzzy match
111996df7d3eSAtari911                        if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) {
112096df7d3eSAtari911                            $results[] = [
112196df7d3eSAtari911                                'date' => $dateKey,
112296df7d3eSAtari911                                'title' => $event['title'],
112396df7d3eSAtari911                                'time' => isset($event['time']) ? $event['time'] : '',
112496df7d3eSAtari911                                'endTime' => isset($event['endTime']) ? $event['endTime'] : '',
112596df7d3eSAtari911                                'color' => isset($event['color']) ? $event['color'] : '',
112696df7d3eSAtari911                                'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace,
112796df7d3eSAtari911                                'id' => isset($event['id']) ? $event['id'] : ''
112896df7d3eSAtari911                            ];
112996df7d3eSAtari911                        }
113096df7d3eSAtari911                    }
113196df7d3eSAtari911                }
113296df7d3eSAtari911            }
113396df7d3eSAtari911        };
113496df7d3eSAtari911
113596df7d3eSAtari911        // Search root calendar directory
113696df7d3eSAtari911        $searchCalendarDir($dataDir . 'calendar', '');
113796df7d3eSAtari911
113896df7d3eSAtari911        // Search namespace directories
113996df7d3eSAtari911        $this->searchNamespaceDirs($dataDir, $searchCalendarDir);
114096df7d3eSAtari911
114196df7d3eSAtari911        // Sort results by date (newest first for past, oldest first for future)
114296df7d3eSAtari911        usort($results, function($a, $b) {
114396df7d3eSAtari911            return strcmp($a['date'], $b['date']);
114496df7d3eSAtari911        });
114596df7d3eSAtari911
114696df7d3eSAtari911        // Limit results
114796df7d3eSAtari911        $results = array_slice($results, 0, 50);
114896df7d3eSAtari911
114996df7d3eSAtari911        echo json_encode([
115096df7d3eSAtari911            'success' => true,
115196df7d3eSAtari911            'results' => $results,
115296df7d3eSAtari911            'total' => count($results)
115396df7d3eSAtari911        ]);
115496df7d3eSAtari911    }
115596df7d3eSAtari911
115696df7d3eSAtari911    /**
115796df7d3eSAtari911     * Check if normalized text matches normalized search term
115896df7d3eSAtari911     * Supports multi-word search where all words must be present
115996df7d3eSAtari911     */
116096df7d3eSAtari911    private function fuzzyMatchText($normalizedText, $normalizedSearch) {
116196df7d3eSAtari911        // Direct substring match
116296df7d3eSAtari911        if (strpos($normalizedText, $normalizedSearch) !== false) {
116396df7d3eSAtari911            return true;
116496df7d3eSAtari911        }
116596df7d3eSAtari911
116696df7d3eSAtari911        // Multi-word search: all words must be present
116796df7d3eSAtari911        $searchWords = array_filter(explode(' ', $normalizedSearch));
116896df7d3eSAtari911        if (count($searchWords) > 1) {
116996df7d3eSAtari911            foreach ($searchWords as $word) {
117096df7d3eSAtari911                if (strlen($word) > 0 && strpos($normalizedText, $word) === false) {
117196df7d3eSAtari911                    return false;
117296df7d3eSAtari911                }
117396df7d3eSAtari911            }
117496df7d3eSAtari911            return true;
117596df7d3eSAtari911        }
117696df7d3eSAtari911
117796df7d3eSAtari911        return false;
117896df7d3eSAtari911    }
117996df7d3eSAtari911
118096df7d3eSAtari911    /**
118196df7d3eSAtari911     * Normalize text for fuzzy search matching
118296df7d3eSAtari911     * Removes apostrophes, extra spaces, and common variations
118396df7d3eSAtari911     */
118496df7d3eSAtari911    private function normalizeForSearch($text) {
118596df7d3eSAtari911        // Convert to lowercase
118696df7d3eSAtari911        $text = strtolower($text);
118796df7d3eSAtari911
118896df7d3eSAtari911        // Remove apostrophes and quotes (father's -> fathers)
118996df7d3eSAtari911        $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text);
119096df7d3eSAtari911
119196df7d3eSAtari911        // Normalize dashes and underscores to spaces
119296df7d3eSAtari911        $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text);
119396df7d3eSAtari911
119496df7d3eSAtari911        // Remove other punctuation but keep letters, numbers, spaces
119596df7d3eSAtari911        $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text);
119696df7d3eSAtari911
119796df7d3eSAtari911        // Normalize multiple spaces to single space
119896df7d3eSAtari911        $text = preg_replace('/\s+/', ' ', $text);
119996df7d3eSAtari911
120096df7d3eSAtari911        // Trim
120196df7d3eSAtari911        $text = trim($text);
120296df7d3eSAtari911
120396df7d3eSAtari911        return $text;
120496df7d3eSAtari911    }
120596df7d3eSAtari911
120696df7d3eSAtari911    /**
1207*2866e827SAtari911     * Parse exclude parameter into an array of namespace strings
1208*2866e827SAtari911     * Supports semicolon-separated list: "journal;drafts;personal:private"
1209*2866e827SAtari911     */
1210*2866e827SAtari911    private function parseExcludeList($exclude) {
1211*2866e827SAtari911        if (empty($exclude)) return [];
1212*2866e827SAtari911        return array_filter(array_map('trim', explode(';', $exclude)), function($v) {
1213*2866e827SAtari911            return $v !== '';
1214*2866e827SAtari911        });
1215*2866e827SAtari911    }
1216*2866e827SAtari911
1217*2866e827SAtari911    /**
1218*2866e827SAtari911     * Check if a namespace should be excluded
1219*2866e827SAtari911     * Matches exact names and prefixes (e.g., exclude "journal" also excludes "journal:sub")
1220*2866e827SAtari911     */
1221*2866e827SAtari911    private function isNamespaceExcluded($namespace, $excludeList) {
1222*2866e827SAtari911        if (empty($excludeList) || $namespace === '') return false;
1223*2866e827SAtari911        foreach ($excludeList as $excluded) {
1224*2866e827SAtari911            if ($namespace === $excluded) return true;
1225*2866e827SAtari911            if (strpos($namespace, $excluded . ':') === 0) return true;
1226*2866e827SAtari911        }
1227*2866e827SAtari911        return false;
1228*2866e827SAtari911    }
1229*2866e827SAtari911
1230*2866e827SAtari911    /**
123196df7d3eSAtari911     * Recursively search namespace directories for calendar data
123296df7d3eSAtari911     */
123396df7d3eSAtari911    private function searchNamespaceDirs($baseDir, $callback) {
123496df7d3eSAtari911        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
123596df7d3eSAtari911            $name = basename($nsDir);
123696df7d3eSAtari911            if ($name === 'calendar') continue;
123796df7d3eSAtari911
123896df7d3eSAtari911            $calDir = $nsDir . '/calendar';
123996df7d3eSAtari911            if (is_dir($calDir)) {
1240*2866e827SAtari911                $relPath = str_replace($this->metaDir(), '', $nsDir);
124196df7d3eSAtari911                $namespace = str_replace('/', ':', $relPath);
124296df7d3eSAtari911                $callback($calDir, $namespace);
124396df7d3eSAtari911            }
124496df7d3eSAtari911
124596df7d3eSAtari911            // Recurse
124696df7d3eSAtari911            $this->searchNamespaceDirs($nsDir . '/', $callback);
124796df7d3eSAtari911        }
124896df7d3eSAtari911    }
124996df7d3eSAtari911
125019378907SAtari911    private function toggleTaskComplete() {
125119378907SAtari911        global $INPUT;
125219378907SAtari911
125319378907SAtari911        $namespace = $INPUT->str('namespace', '');
125419378907SAtari911        $date = $INPUT->str('date');
125519378907SAtari911        $eventId = $INPUT->str('eventId');
125619378907SAtari911        $completed = $INPUT->bool('completed', false);
125719378907SAtari911
1258e3a9f44cSAtari911        // Find where the event actually lives
1259e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
1260e3a9f44cSAtari911
1261e3a9f44cSAtari911        if ($storedNamespace === null) {
1262e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
1263e3a9f44cSAtari911            return;
1264e3a9f44cSAtari911        }
1265e3a9f44cSAtari911
1266e3a9f44cSAtari911        // Use the found namespace
1267e3a9f44cSAtari911        $namespace = $storedNamespace;
1268e3a9f44cSAtari911
1269*2866e827SAtari911        // ACL check: verify edit access to toggle tasks
1270*2866e827SAtari911        if (!$this->checkNamespaceEdit($namespace)) {
1271*2866e827SAtari911            echo json_encode(['success' => false, 'error' => 'You do not have permission to edit events in this namespace']);
1272*2866e827SAtari911            return;
1273*2866e827SAtari911        }
1274*2866e827SAtari911
127519378907SAtari911        list($year, $month, $day) = explode('-', $date);
127619378907SAtari911
1277*2866e827SAtari911        $dataDir = $this->metaDir();
127819378907SAtari911        if ($namespace) {
127919378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
128019378907SAtari911        }
128119378907SAtari911        $dataDir .= 'calendar/';
128219378907SAtari911
128319378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
128419378907SAtari911
128519378907SAtari911        if (file_exists($eventFile)) {
128619378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
128719378907SAtari911
128819378907SAtari911            if (isset($events[$date])) {
1289815440faSAtari911                $eventTitle = '';
129019378907SAtari911                foreach ($events[$date] as $key => $event) {
129119378907SAtari911                    if ($event['id'] === $eventId) {
129219378907SAtari911                        $events[$date][$key]['completed'] = $completed;
1293815440faSAtari911                        $eventTitle = $event['title'] ?? '';
129419378907SAtari911                        break;
129519378907SAtari911                    }
129619378907SAtari911                }
129719378907SAtari911
1298815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
1299815440faSAtari911
1300815440faSAtari911                // Audit logging
1301815440faSAtari911                $audit = $this->getAuditLogger();
1302815440faSAtari911                $audit->logTaskToggle($namespace, $date, $eventId, $eventTitle, $completed);
1303815440faSAtari911
130419378907SAtari911                echo json_encode(['success' => true, 'events' => $events]);
130519378907SAtari911                return;
130619378907SAtari911            }
130719378907SAtari911        }
130819378907SAtari911
130919378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
131019378907SAtari911    }
131119378907SAtari911
1312815440faSAtari911    // ========================================================================
1313815440faSAtari911    // GOOGLE CALENDAR SYNC HANDLERS
1314815440faSAtari911    // ========================================================================
1315815440faSAtari911
1316815440faSAtari911    /**
1317815440faSAtari911     * Get Google OAuth authorization URL
1318815440faSAtari911     */
1319815440faSAtari911    private function getGoogleAuthUrl() {
1320815440faSAtari911        if (!auth_isadmin()) {
1321815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1322815440faSAtari911            return;
1323815440faSAtari911        }
1324815440faSAtari911
1325815440faSAtari911        $sync = $this->getGoogleSync();
1326815440faSAtari911
1327815440faSAtari911        if (!$sync->isConfigured()) {
1328815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Google sync not configured. Please enter Client ID and Secret first.']);
1329815440faSAtari911            return;
1330815440faSAtari911        }
1331815440faSAtari911
1332815440faSAtari911        // Build redirect URI
1333815440faSAtari911        $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback';
1334815440faSAtari911
1335815440faSAtari911        $authUrl = $sync->getAuthUrl($redirectUri);
1336815440faSAtari911
1337815440faSAtari911        echo json_encode(['success' => true, 'url' => $authUrl]);
1338815440faSAtari911    }
1339815440faSAtari911
1340815440faSAtari911    /**
1341815440faSAtari911     * Handle Google OAuth callback
1342815440faSAtari911     */
1343815440faSAtari911    private function handleGoogleCallback() {
1344815440faSAtari911        global $INPUT;
1345815440faSAtari911
1346815440faSAtari911        $code = $INPUT->str('code');
1347815440faSAtari911        $state = $INPUT->str('state');
1348815440faSAtari911        $error = $INPUT->str('error');
1349815440faSAtari911
1350815440faSAtari911        // Check for OAuth error
1351815440faSAtari911        if ($error) {
1352815440faSAtari911            $this->showGoogleCallbackResult(false, 'Authorization denied: ' . $error);
1353815440faSAtari911            return;
1354815440faSAtari911        }
1355815440faSAtari911
1356815440faSAtari911        if (!$code) {
1357815440faSAtari911            $this->showGoogleCallbackResult(false, 'No authorization code received');
1358815440faSAtari911            return;
1359815440faSAtari911        }
1360815440faSAtari911
1361815440faSAtari911        $sync = $this->getGoogleSync();
1362815440faSAtari911
1363815440faSAtari911        // Verify state for CSRF protection
1364815440faSAtari911        if (!$sync->verifyState($state)) {
1365815440faSAtari911            $this->showGoogleCallbackResult(false, 'Invalid state parameter');
1366815440faSAtari911            return;
1367815440faSAtari911        }
1368815440faSAtari911
1369815440faSAtari911        // Exchange code for tokens
1370815440faSAtari911        $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback';
1371815440faSAtari911        $result = $sync->handleCallback($code, $redirectUri);
1372815440faSAtari911
1373815440faSAtari911        if ($result['success']) {
1374815440faSAtari911            $this->showGoogleCallbackResult(true, 'Successfully connected to Google Calendar!');
1375815440faSAtari911        } else {
1376815440faSAtari911            $this->showGoogleCallbackResult(false, $result['error']);
1377815440faSAtari911        }
1378815440faSAtari911    }
1379815440faSAtari911
1380815440faSAtari911    /**
1381815440faSAtari911     * Show OAuth callback result page
1382815440faSAtari911     */
1383815440faSAtari911    private function showGoogleCallbackResult($success, $message) {
1384815440faSAtari911        $status = $success ? 'Success!' : 'Error';
1385815440faSAtari911        $color = $success ? '#2ecc71' : '#e74c3c';
1386815440faSAtari911
1387815440faSAtari911        echo '<!DOCTYPE html>
1388815440faSAtari911<html>
1389815440faSAtari911<head>
1390815440faSAtari911    <title>Google Calendar - ' . $status . '</title>
1391815440faSAtari911    <style>
1392815440faSAtari911        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1393815440faSAtari911               display: flex; align-items: center; justify-content: center;
1394815440faSAtari911               min-height: 100vh; margin: 0; background: #f5f5f5; }
1395815440faSAtari911        .card { background: white; padding: 40px; border-radius: 12px;
1396815440faSAtari911                box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
1397815440faSAtari911        h1 { color: ' . $color . '; margin: 0 0 16px 0; }
1398815440faSAtari911        p { color: #666; margin: 0 0 24px 0; }
1399815440faSAtari911        button { background: #3498db; color: white; border: none; padding: 12px 24px;
1400815440faSAtari911                 border-radius: 6px; cursor: pointer; font-size: 14px; }
1401815440faSAtari911        button:hover { background: #2980b9; }
1402815440faSAtari911    </style>
1403815440faSAtari911</head>
1404815440faSAtari911<body>
1405815440faSAtari911    <div class="card">
1406815440faSAtari911        <h1>' . ($success ? '✓' : '✕') . ' ' . $status . '</h1>
1407815440faSAtari911        <p>' . htmlspecialchars($message) . '</p>
1408815440faSAtari911        <button onclick="window.close()">Close Window</button>
1409815440faSAtari911    </div>
1410815440faSAtari911    <script>
1411815440faSAtari911        // Notify parent window
1412815440faSAtari911        if (window.opener) {
1413815440faSAtari911            window.opener.postMessage({ type: "google_auth_complete", success: ' . ($success ? 'true' : 'false') . ' }, "*");
1414815440faSAtari911        }
1415815440faSAtari911    </script>
1416815440faSAtari911</body>
1417815440faSAtari911</html>';
1418815440faSAtari911    }
1419815440faSAtari911
1420815440faSAtari911    /**
1421815440faSAtari911     * Get Google sync status
1422815440faSAtari911     */
1423815440faSAtari911    private function getGoogleStatus() {
1424815440faSAtari911        $sync = $this->getGoogleSync();
1425815440faSAtari911        echo json_encode(['success' => true, 'status' => $sync->getStatus()]);
1426815440faSAtari911    }
1427815440faSAtari911
1428815440faSAtari911    /**
1429815440faSAtari911     * Get list of Google calendars
1430815440faSAtari911     */
1431815440faSAtari911    private function getGoogleCalendars() {
1432815440faSAtari911        if (!auth_isadmin()) {
1433815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1434815440faSAtari911            return;
1435815440faSAtari911        }
1436815440faSAtari911
1437815440faSAtari911        $sync = $this->getGoogleSync();
1438815440faSAtari911        $result = $sync->getCalendars();
1439815440faSAtari911        echo json_encode($result);
1440815440faSAtari911    }
1441815440faSAtari911
1442815440faSAtari911    /**
1443815440faSAtari911     * Import events from Google Calendar
1444815440faSAtari911     */
1445815440faSAtari911    private function googleImport() {
1446815440faSAtari911        global $INPUT;
1447815440faSAtari911
1448815440faSAtari911        if (!auth_isadmin()) {
1449815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1450815440faSAtari911            return;
1451815440faSAtari911        }
1452815440faSAtari911
1453815440faSAtari911        $namespace = $INPUT->str('namespace', '');
1454815440faSAtari911        $startDate = $INPUT->str('startDate', '');
1455815440faSAtari911        $endDate = $INPUT->str('endDate', '');
1456815440faSAtari911
1457815440faSAtari911        $sync = $this->getGoogleSync();
1458815440faSAtari911        $result = $sync->importEvents($namespace, $startDate ?: null, $endDate ?: null);
1459815440faSAtari911
1460815440faSAtari911        echo json_encode($result);
1461815440faSAtari911    }
1462815440faSAtari911
1463815440faSAtari911    /**
1464815440faSAtari911     * Export events to Google Calendar
1465815440faSAtari911     */
1466815440faSAtari911    private function googleExport() {
1467815440faSAtari911        global $INPUT;
1468815440faSAtari911
1469815440faSAtari911        if (!auth_isadmin()) {
1470815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1471815440faSAtari911            return;
1472815440faSAtari911        }
1473815440faSAtari911
1474815440faSAtari911        $namespace = $INPUT->str('namespace', '');
1475815440faSAtari911        $startDate = $INPUT->str('startDate', '');
1476815440faSAtari911        $endDate = $INPUT->str('endDate', '');
1477815440faSAtari911
1478815440faSAtari911        $sync = $this->getGoogleSync();
1479815440faSAtari911        $result = $sync->exportEvents($namespace, $startDate ?: null, $endDate ?: null);
1480815440faSAtari911
1481815440faSAtari911        echo json_encode($result);
1482815440faSAtari911    }
1483815440faSAtari911
1484815440faSAtari911    /**
1485815440faSAtari911     * Disconnect from Google Calendar
1486815440faSAtari911     */
1487815440faSAtari911    private function googleDisconnect() {
1488815440faSAtari911        if (!auth_isadmin()) {
1489815440faSAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
1490815440faSAtari911            return;
1491815440faSAtari911        }
1492815440faSAtari911
1493815440faSAtari911        $sync = $this->getGoogleSync();
1494815440faSAtari911        $sync->disconnect();
1495815440faSAtari911
1496815440faSAtari911        echo json_encode(['success' => true]);
1497815440faSAtari911    }
1498815440faSAtari911
149996df7d3eSAtari911    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime,
150096df7d3eSAtari911                                          $description, $color, $isTask, $recurrenceType, $recurrenceInterval,
150196df7d3eSAtari911                                          $recurrenceEnd, $weekDays, $monthlyType, $monthDay,
150296df7d3eSAtari911                                          $ordinalWeek, $ordinalDay, $baseId) {
1503*2866e827SAtari911        $dataDir = $this->metaDir();
150487ac9bf3SAtari911        if ($namespace) {
150587ac9bf3SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
150687ac9bf3SAtari911        }
150787ac9bf3SAtari911        $dataDir .= 'calendar/';
150887ac9bf3SAtari911
150987ac9bf3SAtari911        if (!is_dir($dataDir)) {
151087ac9bf3SAtari911            mkdir($dataDir, 0755, true);
151187ac9bf3SAtari911        }
151287ac9bf3SAtari911
151396df7d3eSAtari911        // Ensure interval is at least 1
151496df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
151587ac9bf3SAtari911
151687ac9bf3SAtari911        // Set maximum end date if not specified (1 year from start)
151787ac9bf3SAtari911        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
151887ac9bf3SAtari911
151987ac9bf3SAtari911        // Calculate event duration for multi-day events
152087ac9bf3SAtari911        $eventDuration = 0;
152187ac9bf3SAtari911        if ($endDate && $endDate !== $startDate) {
152287ac9bf3SAtari911            $start = new DateTime($startDate);
152387ac9bf3SAtari911            $end = new DateTime($endDate);
152487ac9bf3SAtari911            $eventDuration = $start->diff($end)->days;
152587ac9bf3SAtari911        }
152687ac9bf3SAtari911
152787ac9bf3SAtari911        // Generate recurring events
152887ac9bf3SAtari911        $currentDate = new DateTime($startDate);
152987ac9bf3SAtari911        $endLimit = new DateTime($maxEnd);
153087ac9bf3SAtari911        $counter = 0;
153196df7d3eSAtari911        $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year)
153296df7d3eSAtari911
153396df7d3eSAtari911        // For weekly with specific days, we need to track the interval counter differently
153496df7d3eSAtari911        $weekCounter = 0;
153596df7d3eSAtari911        $startWeekNumber = (int)$currentDate->format('W');
153696df7d3eSAtari911        $startYear = (int)$currentDate->format('Y');
153787ac9bf3SAtari911
153887ac9bf3SAtari911        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
153996df7d3eSAtari911            $shouldCreateEvent = false;
154096df7d3eSAtari911
154196df7d3eSAtari911            switch ($recurrenceType) {
154296df7d3eSAtari911                case 'daily':
154396df7d3eSAtari911                    // Every N days from start
154496df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
154596df7d3eSAtari911                    $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0);
154696df7d3eSAtari911                    break;
154796df7d3eSAtari911
154896df7d3eSAtari911                case 'weekly':
154996df7d3eSAtari911                    // Every N weeks, on specified days
155096df7d3eSAtari911                    $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat
155196df7d3eSAtari911
155296df7d3eSAtari911                    // Calculate weeks since start
155396df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
155496df7d3eSAtari911                    $weeksSinceStart = floor($daysSinceStart / 7);
155596df7d3eSAtari911
155696df7d3eSAtari911                    // Check if we're in the right week (every N weeks)
155796df7d3eSAtari911                    $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0);
155896df7d3eSAtari911
155996df7d3eSAtari911                    // Check if this day is selected
156096df7d3eSAtari911                    $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays);
156196df7d3eSAtari911
156296df7d3eSAtari911                    // For the first week, only include days on or after the start date
156396df7d3eSAtari911                    $isOnOrAfterStart = ($currentDate >= new DateTime($startDate));
156496df7d3eSAtari911
156596df7d3eSAtari911                    $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart;
156696df7d3eSAtari911                    break;
156796df7d3eSAtari911
156896df7d3eSAtari911                case 'monthly':
156996df7d3eSAtari911                    // Calculate months since start
157096df7d3eSAtari911                    $startDT = new DateTime($startDate);
157196df7d3eSAtari911                    $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) +
157296df7d3eSAtari911                                        ($currentDate->format('n') - $startDT->format('n'));
157396df7d3eSAtari911
157496df7d3eSAtari911                    // Check if we're in the right month (every N months)
157596df7d3eSAtari911                    $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0);
157696df7d3eSAtari911
157796df7d3eSAtari911                    if (!$isCorrectMonth) {
157896df7d3eSAtari911                        // Skip to first day of next potential month
157996df7d3eSAtari911                        $currentDate->modify('first day of next month');
158096df7d3eSAtari911                        continue 2;
158196df7d3eSAtari911                    }
158296df7d3eSAtari911
158396df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
158496df7d3eSAtari911                        // Specific day of month (e.g., 15th)
158596df7d3eSAtari911                        $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j');
158696df7d3eSAtari911                        $currentDay = (int)$currentDate->format('j');
158796df7d3eSAtari911                        $daysInMonth = (int)$currentDate->format('t');
158896df7d3eSAtari911
158996df7d3eSAtari911                        // If target day exceeds days in month, use last day
159096df7d3eSAtari911                        $effectiveTargetDay = min($targetDay, $daysInMonth);
159196df7d3eSAtari911                        $shouldCreateEvent = ($currentDay === $effectiveTargetDay);
159296df7d3eSAtari911                    } else {
159396df7d3eSAtari911                        // Ordinal weekday (e.g., 2nd Wednesday, last Friday)
159496df7d3eSAtari911                        $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay);
159596df7d3eSAtari911                    }
159696df7d3eSAtari911                    break;
159796df7d3eSAtari911
159896df7d3eSAtari911                case 'yearly':
159996df7d3eSAtari911                    // Every N years on same month/day
160096df7d3eSAtari911                    $startDT = new DateTime($startDate);
160196df7d3eSAtari911                    $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y');
160296df7d3eSAtari911
160396df7d3eSAtari911                    // Check if we're in the right year
160496df7d3eSAtari911                    $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0);
160596df7d3eSAtari911
160696df7d3eSAtari911                    // Check if it's the same month and day
160796df7d3eSAtari911                    $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d'));
160896df7d3eSAtari911
160996df7d3eSAtari911                    $shouldCreateEvent = $isCorrectYear && $sameMonthDay;
161096df7d3eSAtari911                    break;
161196df7d3eSAtari911
161296df7d3eSAtari911                default:
161396df7d3eSAtari911                    $shouldCreateEvent = false;
161496df7d3eSAtari911            }
161596df7d3eSAtari911
161696df7d3eSAtari911            if ($shouldCreateEvent) {
161787ac9bf3SAtari911                $dateKey = $currentDate->format('Y-m-d');
161887ac9bf3SAtari911                list($year, $month, $day) = explode('-', $dateKey);
161987ac9bf3SAtari911
162087ac9bf3SAtari911                // Calculate end date for this occurrence if multi-day
162187ac9bf3SAtari911                $occurrenceEndDate = '';
162287ac9bf3SAtari911                if ($eventDuration > 0) {
162387ac9bf3SAtari911                    $occurrenceEnd = clone $currentDate;
162487ac9bf3SAtari911                    $occurrenceEnd->modify('+' . $eventDuration . ' days');
162587ac9bf3SAtari911                    $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
162687ac9bf3SAtari911                }
162787ac9bf3SAtari911
162887ac9bf3SAtari911                // Load month file
162987ac9bf3SAtari911                $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
163087ac9bf3SAtari911                $events = [];
163187ac9bf3SAtari911                if (file_exists($eventFile)) {
163287ac9bf3SAtari911                    $events = json_decode(file_get_contents($eventFile), true);
163396df7d3eSAtari911                    if (!is_array($events)) $events = [];
163487ac9bf3SAtari911                }
163587ac9bf3SAtari911
163687ac9bf3SAtari911                if (!isset($events[$dateKey])) {
163787ac9bf3SAtari911                    $events[$dateKey] = [];
163887ac9bf3SAtari911                }
163987ac9bf3SAtari911
164087ac9bf3SAtari911                // Create event for this occurrence
164187ac9bf3SAtari911                $eventData = [
164287ac9bf3SAtari911                    'id' => $baseId . '-' . $counter,
164387ac9bf3SAtari911                    'title' => $title,
164487ac9bf3SAtari911                    'time' => $time,
16451d05cddcSAtari911                    'endTime' => $endTime,
164687ac9bf3SAtari911                    'description' => $description,
164787ac9bf3SAtari911                    'color' => $color,
164887ac9bf3SAtari911                    'isTask' => $isTask,
164987ac9bf3SAtari911                    'completed' => false,
165087ac9bf3SAtari911                    'endDate' => $occurrenceEndDate,
165187ac9bf3SAtari911                    'recurring' => true,
165287ac9bf3SAtari911                    'recurringId' => $baseId,
165396df7d3eSAtari911                    'recurrenceType' => $recurrenceType,
165496df7d3eSAtari911                    'recurrenceInterval' => $recurrenceInterval,
165596df7d3eSAtari911                    'namespace' => $namespace,
165687ac9bf3SAtari911                    'created' => date('Y-m-d H:i:s')
165787ac9bf3SAtari911                ];
165887ac9bf3SAtari911
165996df7d3eSAtari911                // Store additional recurrence info for reference
166096df7d3eSAtari911                if ($recurrenceType === 'weekly' && !empty($weekDays)) {
166196df7d3eSAtari911                    $eventData['weekDays'] = $weekDays;
166296df7d3eSAtari911                }
166396df7d3eSAtari911                if ($recurrenceType === 'monthly') {
166496df7d3eSAtari911                    $eventData['monthlyType'] = $monthlyType;
166596df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
166696df7d3eSAtari911                        $eventData['monthDay'] = $monthDay;
166796df7d3eSAtari911                    } else {
166896df7d3eSAtari911                        $eventData['ordinalWeek'] = $ordinalWeek;
166996df7d3eSAtari911                        $eventData['ordinalDay'] = $ordinalDay;
167096df7d3eSAtari911                    }
167196df7d3eSAtari911                }
167296df7d3eSAtari911
167387ac9bf3SAtari911                $events[$dateKey][] = $eventData;
1674815440faSAtari911                CalendarFileHandler::writeJson($eventFile, $events);
167587ac9bf3SAtari911
167687ac9bf3SAtari911                $counter++;
167787ac9bf3SAtari911            }
167896df7d3eSAtari911
167996df7d3eSAtari911            // Move to next day (we check each day individually for complex patterns)
168096df7d3eSAtari911            $currentDate->modify('+1 day');
168196df7d3eSAtari911        }
168296df7d3eSAtari911    }
168396df7d3eSAtari911
168496df7d3eSAtari911    /**
168596df7d3eSAtari911     * Check if a date is the Nth occurrence of a weekday in its month
168696df7d3eSAtari911     * @param DateTime $date The date to check
168796df7d3eSAtari911     * @param int $ordinalWeek 1-5 for first-fifth, -1 for last
168896df7d3eSAtari911     * @param int $targetDayOfWeek 0=Sunday through 6=Saturday
168996df7d3eSAtari911     * @return bool
169096df7d3eSAtari911     */
169196df7d3eSAtari911    private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) {
169296df7d3eSAtari911        $currentDayOfWeek = (int)$date->format('w');
169396df7d3eSAtari911
169496df7d3eSAtari911        // First, check if it's the right day of week
169596df7d3eSAtari911        if ($currentDayOfWeek !== $targetDayOfWeek) {
169696df7d3eSAtari911            return false;
169796df7d3eSAtari911        }
169896df7d3eSAtari911
169996df7d3eSAtari911        $dayOfMonth = (int)$date->format('j');
170096df7d3eSAtari911        $daysInMonth = (int)$date->format('t');
170196df7d3eSAtari911
170296df7d3eSAtari911        if ($ordinalWeek === -1) {
170396df7d3eSAtari911            // Last occurrence: check if there's no more of this weekday in the month
170496df7d3eSAtari911            $daysRemaining = $daysInMonth - $dayOfMonth;
170596df7d3eSAtari911            return $daysRemaining < 7;
170696df7d3eSAtari911        } else {
170796df7d3eSAtari911            // Nth occurrence: check which occurrence this is
170896df7d3eSAtari911            $weekNumber = ceil($dayOfMonth / 7);
170996df7d3eSAtari911            return $weekNumber === $ordinalWeek;
171096df7d3eSAtari911        }
171187ac9bf3SAtari911    }
171287ac9bf3SAtari911
171319378907SAtari911    public function addAssets(Doku_Event $event, $param) {
171419378907SAtari911        $event->data['link'][] = array(
171519378907SAtari911            'type' => 'text/css',
171619378907SAtari911            'rel' => 'stylesheet',
171719378907SAtari911            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
171819378907SAtari911        );
171919378907SAtari911
172096df7d3eSAtari911        // Load the main calendar JavaScript
172196df7d3eSAtari911        // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues
172296df7d3eSAtari911        // The actual code is in calendar-main.js
172319378907SAtari911        $event->data['script'][] = array(
172419378907SAtari911            'type' => 'text/javascript',
172596df7d3eSAtari911            'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js'
172619378907SAtari911        );
172719378907SAtari911    }
1728e3a9f44cSAtari911    // Helper function to find an event's stored namespace
1729e3a9f44cSAtari911    private function findEventNamespace($eventId, $date, $searchNamespace) {
1730e3a9f44cSAtari911        list($year, $month, $day) = explode('-', $date);
1731e3a9f44cSAtari911
1732e3a9f44cSAtari911        // List of namespaces to check
1733e3a9f44cSAtari911        $namespacesToCheck = [''];
1734e3a9f44cSAtari911
1735e3a9f44cSAtari911        // If searchNamespace is a wildcard or multi, we need to search multiple locations
1736e3a9f44cSAtari911        if (!empty($searchNamespace)) {
1737e3a9f44cSAtari911            if (strpos($searchNamespace, ';') !== false) {
1738e3a9f44cSAtari911                // Multi-namespace - check each one
1739e3a9f44cSAtari911                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
1740e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1741e3a9f44cSAtari911            } elseif (strpos($searchNamespace, '*') !== false) {
1742e3a9f44cSAtari911                // Wildcard - need to scan directories
1743e3a9f44cSAtari911                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
1744e3a9f44cSAtari911                $namespacesToCheck = $this->findAllNamespaces($baseNs);
1745e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1746e3a9f44cSAtari911            } else {
1747e3a9f44cSAtari911                // Single namespace
1748e3a9f44cSAtari911                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
1749e3a9f44cSAtari911            }
1750e3a9f44cSAtari911        }
1751e3a9f44cSAtari911
175296df7d3eSAtari911        $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck)));
175396df7d3eSAtari911
1754e3a9f44cSAtari911        // Search for the event in all possible namespaces
1755e3a9f44cSAtari911        foreach ($namespacesToCheck as $ns) {
1756*2866e827SAtari911            $dataDir = $this->metaDir();
1757e3a9f44cSAtari911            if ($ns) {
1758e3a9f44cSAtari911                $dataDir .= str_replace(':', '/', $ns) . '/';
1759e3a9f44cSAtari911            }
1760e3a9f44cSAtari911            $dataDir .= 'calendar/';
1761e3a9f44cSAtari911
1762e3a9f44cSAtari911            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1763e3a9f44cSAtari911
1764e3a9f44cSAtari911            if (file_exists($eventFile)) {
1765e3a9f44cSAtari911                $events = json_decode(file_get_contents($eventFile), true);
1766e3a9f44cSAtari911                if (isset($events[$date])) {
1767e3a9f44cSAtari911                    foreach ($events[$date] as $evt) {
1768e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
176996df7d3eSAtari911                            // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace
177096df7d3eSAtari911                            // The directory is what matters for deletion - that's where the file actually is
177196df7d3eSAtari911                            $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')");
177296df7d3eSAtari911                            return $ns;
1773e3a9f44cSAtari911                        }
1774e3a9f44cSAtari911                    }
1775e3a9f44cSAtari911                }
1776e3a9f44cSAtari911            }
1777e3a9f44cSAtari911        }
1778e3a9f44cSAtari911
177996df7d3eSAtari911        $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace");
1780e3a9f44cSAtari911        return null; // Event not found
1781e3a9f44cSAtari911    }
1782e3a9f44cSAtari911
1783e3a9f44cSAtari911    // Helper to find all namespaces under a base namespace
1784e3a9f44cSAtari911    private function findAllNamespaces($baseNamespace) {
1785*2866e827SAtari911        $dataDir = $this->metaDir();
1786e3a9f44cSAtari911        if ($baseNamespace) {
1787e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
1788e3a9f44cSAtari911        }
1789e3a9f44cSAtari911
1790e3a9f44cSAtari911        $namespaces = [];
1791e3a9f44cSAtari911        if ($baseNamespace) {
1792e3a9f44cSAtari911            $namespaces[] = $baseNamespace;
1793e3a9f44cSAtari911        }
1794e3a9f44cSAtari911
1795e3a9f44cSAtari911        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
1796e3a9f44cSAtari911
179796df7d3eSAtari911        $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces)));
179896df7d3eSAtari911
1799e3a9f44cSAtari911        return $namespaces;
1800e3a9f44cSAtari911    }
1801e3a9f44cSAtari911
1802e3a9f44cSAtari911    // Recursive scan for namespaces
1803e3a9f44cSAtari911    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
1804e3a9f44cSAtari911        if (!is_dir($dir)) return;
1805e3a9f44cSAtari911
1806e3a9f44cSAtari911        $items = scandir($dir);
1807e3a9f44cSAtari911        foreach ($items as $item) {
1808e3a9f44cSAtari911            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
1809e3a9f44cSAtari911
1810e3a9f44cSAtari911            $path = $dir . $item;
1811e3a9f44cSAtari911            if (is_dir($path)) {
1812e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1813e3a9f44cSAtari911                $namespaces[] = $namespace;
1814e3a9f44cSAtari911                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
1815e3a9f44cSAtari911            }
1816e3a9f44cSAtari911        }
1817e3a9f44cSAtari911    }
18189ccd446eSAtari911
18199ccd446eSAtari911    /**
18209ccd446eSAtari911     * Delete all instances of a recurring event across all months
18219ccd446eSAtari911     */
18229ccd446eSAtari911    private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) {
18239ccd446eSAtari911        // Scan all JSON files in the calendar directory
18249ccd446eSAtari911        $calendarFiles = glob($dataDir . '*.json');
18259ccd446eSAtari911
18269ccd446eSAtari911        foreach ($calendarFiles as $file) {
18279ccd446eSAtari911            $modified = false;
18289ccd446eSAtari911            $events = json_decode(file_get_contents($file), true);
18299ccd446eSAtari911
18309ccd446eSAtari911            if (!$events) continue;
18319ccd446eSAtari911
18329ccd446eSAtari911            // Check each date in the file
18339ccd446eSAtari911            foreach ($events as $date => &$dayEvents) {
18349ccd446eSAtari911                // Filter out events with matching recurringId
18359ccd446eSAtari911                $originalCount = count($dayEvents);
18369ccd446eSAtari911                $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) {
18379ccd446eSAtari911                    $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
18389ccd446eSAtari911                    return $eventRecurringId !== $recurringId;
18399ccd446eSAtari911                }));
18409ccd446eSAtari911
18419ccd446eSAtari911                if (count($dayEvents) !== $originalCount) {
18429ccd446eSAtari911                    $modified = true;
18439ccd446eSAtari911                }
18449ccd446eSAtari911
18459ccd446eSAtari911                // Remove empty dates
18469ccd446eSAtari911                if (empty($dayEvents)) {
18479ccd446eSAtari911                    unset($events[$date]);
18489ccd446eSAtari911                }
18499ccd446eSAtari911            }
18509ccd446eSAtari911
18519ccd446eSAtari911            // Save if modified
18529ccd446eSAtari911            if ($modified) {
1853815440faSAtari911                CalendarFileHandler::writeJson($file, $events);
18549ccd446eSAtari911            }
18559ccd446eSAtari911        }
18569ccd446eSAtari911    }
18579ccd446eSAtari911
18589ccd446eSAtari911    /**
18599ccd446eSAtari911     * Get existing event data for preserving unchanged fields during edit
18609ccd446eSAtari911     */
18619ccd446eSAtari911    private function getExistingEventData($eventId, $date, $namespace) {
18629ccd446eSAtari911        list($year, $month, $day) = explode('-', $date);
18639ccd446eSAtari911
1864*2866e827SAtari911        $dataDir = $this->metaDir();
18659ccd446eSAtari911        if ($namespace) {
18669ccd446eSAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
18679ccd446eSAtari911        }
18689ccd446eSAtari911        $dataDir .= 'calendar/';
18699ccd446eSAtari911
18709ccd446eSAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
18719ccd446eSAtari911
18729ccd446eSAtari911        if (!file_exists($eventFile)) {
18739ccd446eSAtari911            return null;
18749ccd446eSAtari911        }
18759ccd446eSAtari911
18769ccd446eSAtari911        $events = json_decode(file_get_contents($eventFile), true);
18779ccd446eSAtari911
18789ccd446eSAtari911        if (!isset($events[$date])) {
18799ccd446eSAtari911            return null;
18809ccd446eSAtari911        }
18819ccd446eSAtari911
18829ccd446eSAtari911        // Find the event by ID
18839ccd446eSAtari911        foreach ($events[$date] as $event) {
18849ccd446eSAtari911            if ($event['id'] === $eventId) {
18859ccd446eSAtari911                return $event;
18869ccd446eSAtari911            }
18879ccd446eSAtari911        }
18889ccd446eSAtari911
18899ccd446eSAtari911        return null;
18909ccd446eSAtari911    }
189119378907SAtari911}
1892