xref: /plugin/calendar/action.php (revision da2061786a295965aec1e3343408c72b19fbbb6c)
119378907SAtari911<?php
219378907SAtari911/**
319378907SAtari911 * DokuWiki Plugin calendar (Action Component)
419378907SAtari911 *
519378907SAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
619378907SAtari911 * @author  DokuWiki Community
719378907SAtari911 */
819378907SAtari911
919378907SAtari911if (!defined('DOKU_INC')) die();
1019378907SAtari911
117e8ea635SAtari911// Set to true to enable verbose debug logging (should be false in production)
127e8ea635SAtari911if (!defined('CALENDAR_DEBUG')) {
137e8ea635SAtari911    define('CALENDAR_DEBUG', false);
147e8ea635SAtari911}
157e8ea635SAtari911
1619378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin {
1719378907SAtari911
187e8ea635SAtari911    /**
197e8ea635SAtari911     * Log debug message only if CALENDAR_DEBUG is enabled
207e8ea635SAtari911     */
217e8ea635SAtari911    private function debugLog($message) {
227e8ea635SAtari911        if (CALENDAR_DEBUG) {
237e8ea635SAtari911            error_log($message);
247e8ea635SAtari911        }
257e8ea635SAtari911    }
267e8ea635SAtari911
277e8ea635SAtari911    /**
287e8ea635SAtari911     * Safely read and decode a JSON file with error handling
297e8ea635SAtari911     * @param string $filepath Path to JSON file
307e8ea635SAtari911     * @return array Decoded array or empty array on error
317e8ea635SAtari911     */
327e8ea635SAtari911    private function safeJsonRead($filepath) {
337e8ea635SAtari911        if (!file_exists($filepath)) {
347e8ea635SAtari911            return [];
357e8ea635SAtari911        }
367e8ea635SAtari911
377e8ea635SAtari911        $contents = @file_get_contents($filepath);
387e8ea635SAtari911        if ($contents === false) {
397e8ea635SAtari911            $this->debugLog("Failed to read file: $filepath");
407e8ea635SAtari911            return [];
417e8ea635SAtari911        }
427e8ea635SAtari911
437e8ea635SAtari911        $decoded = json_decode($contents, true);
447e8ea635SAtari911        if (json_last_error() !== JSON_ERROR_NONE) {
457e8ea635SAtari911            $this->debugLog("JSON decode error in $filepath: " . json_last_error_msg());
467e8ea635SAtari911            return [];
477e8ea635SAtari911        }
487e8ea635SAtari911
497e8ea635SAtari911        return is_array($decoded) ? $decoded : [];
507e8ea635SAtari911    }
517e8ea635SAtari911
5219378907SAtari911    public function register(Doku_Event_Handler $controller) {
5319378907SAtari911        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax');
5419378907SAtari911        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets');
5519378907SAtari911    }
5619378907SAtari911
5719378907SAtari911    public function handleAjax(Doku_Event $event, $param) {
5819378907SAtari911        if ($event->data !== 'plugin_calendar') return;
5919378907SAtari911        $event->preventDefault();
6019378907SAtari911        $event->stopPropagation();
6119378907SAtari911
6219378907SAtari911        $action = $_REQUEST['action'] ?? '';
6319378907SAtari911
647e8ea635SAtari911        // Actions that modify data require CSRF token verification
657e8ea635SAtari911        $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces',
667e8ea635SAtari911                         'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring',
677e8ea635SAtari911                         'trim_recurring', 'pause_recurring', 'resume_recurring',
687e8ea635SAtari911                         'change_start_recurring', 'change_pattern_recurring'];
697e8ea635SAtari911
707e8ea635SAtari911        if (in_array($action, $writeActions)) {
717e8ea635SAtari911            // Check for valid security token
727e8ea635SAtari911            $sectok = $_REQUEST['sectok'] ?? '';
737e8ea635SAtari911            if (!checkSecurityToken($sectok)) {
747e8ea635SAtari911                echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']);
757e8ea635SAtari911                return;
767e8ea635SAtari911            }
777e8ea635SAtari911        }
787e8ea635SAtari911
7919378907SAtari911        switch ($action) {
8019378907SAtari911            case 'save_event':
8119378907SAtari911                $this->saveEvent();
8219378907SAtari911                break;
8319378907SAtari911            case 'delete_event':
8419378907SAtari911                $this->deleteEvent();
8519378907SAtari911                break;
8619378907SAtari911            case 'get_event':
8719378907SAtari911                $this->getEvent();
8819378907SAtari911                break;
8919378907SAtari911            case 'load_month':
9019378907SAtari911                $this->loadMonth();
9119378907SAtari911                break;
92*da206178SAtari911            case 'get_static_calendar':
93*da206178SAtari911                $this->getStaticCalendar();
94*da206178SAtari911                break;
9596df7d3eSAtari911            case 'search_all':
9696df7d3eSAtari911                $this->searchAllDates();
9796df7d3eSAtari911                break;
9819378907SAtari911            case 'toggle_task':
9919378907SAtari911                $this->toggleTaskComplete();
10019378907SAtari911                break;
1017e8ea635SAtari911            case 'cleanup_empty_namespaces':
1027e8ea635SAtari911            case 'trim_all_past_recurring':
1037e8ea635SAtari911            case 'rescan_recurring':
1047e8ea635SAtari911            case 'extend_recurring':
1057e8ea635SAtari911            case 'trim_recurring':
1067e8ea635SAtari911            case 'pause_recurring':
1077e8ea635SAtari911            case 'resume_recurring':
1087e8ea635SAtari911            case 'change_start_recurring':
1097e8ea635SAtari911            case 'change_pattern_recurring':
1107e8ea635SAtari911                $this->routeToAdmin($action);
1117e8ea635SAtari911                break;
11219378907SAtari911            default:
11319378907SAtari911                echo json_encode(['success' => false, 'error' => 'Unknown action']);
11419378907SAtari911        }
11519378907SAtari911    }
11619378907SAtari911
1177e8ea635SAtari911    /**
1187e8ea635SAtari911     * Route AJAX actions to admin plugin methods
1197e8ea635SAtari911     */
1207e8ea635SAtari911    private function routeToAdmin($action) {
1217e8ea635SAtari911        $admin = plugin_load('admin', 'calendar');
1227e8ea635SAtari911        if ($admin && method_exists($admin, 'handleAjaxAction')) {
1237e8ea635SAtari911            $admin->handleAjaxAction($action);
1247e8ea635SAtari911        } else {
1257e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Admin handler not available']);
1267e8ea635SAtari911        }
1277e8ea635SAtari911    }
1287e8ea635SAtari911
12919378907SAtari911    private function saveEvent() {
13019378907SAtari911        global $INPUT;
13119378907SAtari911
13219378907SAtari911        $namespace = $INPUT->str('namespace', '');
13319378907SAtari911        $date = $INPUT->str('date');
13419378907SAtari911        $eventId = $INPUT->str('eventId', '');
13519378907SAtari911        $title = $INPUT->str('title');
13619378907SAtari911        $time = $INPUT->str('time', '');
1371d05cddcSAtari911        $endTime = $INPUT->str('endTime', '');
13819378907SAtari911        $description = $INPUT->str('description', '');
13919378907SAtari911        $color = $INPUT->str('color', '#3498db');
14019378907SAtari911        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
14119378907SAtari911        $isTask = $INPUT->bool('isTask', false);
14219378907SAtari911        $completed = $INPUT->bool('completed', false);
14319378907SAtari911        $endDate = $INPUT->str('endDate', '');
14487ac9bf3SAtari911        $isRecurring = $INPUT->bool('isRecurring', false);
14587ac9bf3SAtari911        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
14687ac9bf3SAtari911        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
14719378907SAtari911
14896df7d3eSAtari911        // New recurrence options
14996df7d3eSAtari911        $recurrenceInterval = $INPUT->int('recurrenceInterval', 1);
15096df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
15196df7d3eSAtari911        if ($recurrenceInterval > 99) $recurrenceInterval = 99;
15296df7d3eSAtari911
15396df7d3eSAtari911        $weekDaysStr = $INPUT->str('weekDays', '');
15496df7d3eSAtari911        $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : [];
15596df7d3eSAtari911
15696df7d3eSAtari911        $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth');
15796df7d3eSAtari911        $monthDay = $INPUT->int('monthDay', 0);
15896df7d3eSAtari911        $ordinalWeek = $INPUT->int('ordinalWeek', 1);
15996df7d3eSAtari911        $ordinalDay = $INPUT->int('ordinalDay', 0);
16096df7d3eSAtari911
16196df7d3eSAtari911        $this->debugLog("=== Calendar saveEvent START ===");
16296df7d3eSAtari911        $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'");
16396df7d3eSAtari911        $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'");
16496df7d3eSAtari911
16519378907SAtari911        if (!$date || !$title) {
16619378907SAtari911            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
16719378907SAtari911            return;
16819378907SAtari911        }
16919378907SAtari911
1707e8ea635SAtari911        // Validate date format (YYYY-MM-DD)
1717e8ea635SAtari911        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
1727e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid date format']);
1737e8ea635SAtari911            return;
1747e8ea635SAtari911        }
1757e8ea635SAtari911
1767e8ea635SAtari911        // Validate oldDate if provided
1777e8ea635SAtari911        if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) {
1787e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid old date format']);
1797e8ea635SAtari911            return;
1807e8ea635SAtari911        }
1817e8ea635SAtari911
1827e8ea635SAtari911        // Validate endDate if provided
1837e8ea635SAtari911        if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) {
1847e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end date format']);
1857e8ea635SAtari911            return;
1867e8ea635SAtari911        }
1877e8ea635SAtari911
1887e8ea635SAtari911        // Validate time format (HH:MM) if provided
1897e8ea635SAtari911        if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) {
1907e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid time format']);
1917e8ea635SAtari911            return;
1927e8ea635SAtari911        }
1937e8ea635SAtari911
1947e8ea635SAtari911        // Validate endTime format if provided
1957e8ea635SAtari911        if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) {
1967e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid end time format']);
1977e8ea635SAtari911            return;
1987e8ea635SAtari911        }
1997e8ea635SAtari911
2007e8ea635SAtari911        // Validate color format (hex color)
2017e8ea635SAtari911        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
2027e8ea635SAtari911            $color = '#3498db'; // Reset to default if invalid
2037e8ea635SAtari911        }
2047e8ea635SAtari911
2057e8ea635SAtari911        // Validate namespace (prevent path traversal)
2067e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
2077e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
2087e8ea635SAtari911            return;
2097e8ea635SAtari911        }
2107e8ea635SAtari911
2117e8ea635SAtari911        // Validate recurrence type
2127e8ea635SAtari911        $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
2137e8ea635SAtari911        if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) {
2147e8ea635SAtari911            $recurrenceType = 'weekly';
2157e8ea635SAtari911        }
2167e8ea635SAtari911
2177e8ea635SAtari911        // Validate recurrenceEnd if provided
2187e8ea635SAtari911        if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) {
2197e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']);
2207e8ea635SAtari911            return;
2217e8ea635SAtari911        }
2227e8ea635SAtari911
2237e8ea635SAtari911        // Sanitize title length
2247e8ea635SAtari911        $title = substr(trim($title), 0, 500);
2257e8ea635SAtari911
2267e8ea635SAtari911        // Sanitize description length
2277e8ea635SAtari911        $description = substr($description, 0, 10000);
2287e8ea635SAtari911
22996df7d3eSAtari911        // If editing, find the event's ACTUAL namespace (for finding/deleting old event)
23096df7d3eSAtari911        // We need to search ALL namespaces because user may be changing namespace
23196df7d3eSAtari911        $oldNamespace = null;  // null means "not found yet"
232e3a9f44cSAtari911        if ($eventId) {
2331d05cddcSAtari911            // Use oldDate if available (date was changed), otherwise use current date
2341d05cddcSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
2351d05cddcSAtari911
23696df7d3eSAtari911            // Search using wildcard to find event in ANY namespace
23796df7d3eSAtari911            $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*');
23896df7d3eSAtari911
23996df7d3eSAtari911            if ($foundNamespace !== null) {
24096df7d3eSAtari911                $oldNamespace = $foundNamespace;  // Could be '' for default namespace
2417e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'");
24296df7d3eSAtari911            } else {
24396df7d3eSAtari911                $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace");
2441d05cddcSAtari911            }
245e3a9f44cSAtari911        }
246e3a9f44cSAtari911
2471d05cddcSAtari911        // Use the namespace provided by the user (allow namespace changes!)
2481d05cddcSAtari911        // But normalize wildcards and multi-namespace to empty for NEW events
2491d05cddcSAtari911        if (!$eventId) {
2507e8ea635SAtari911            $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'");
251e3a9f44cSAtari911            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
252e3a9f44cSAtari911            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
2537e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty");
254e3a9f44cSAtari911                $namespace = '';
2551d05cddcSAtari911            } else {
2567e8ea635SAtari911                $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'");
257e3a9f44cSAtari911            }
2581d05cddcSAtari911        } else {
2597e8ea635SAtari911            $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'");
260e3a9f44cSAtari911        }
261e3a9f44cSAtari911
26287ac9bf3SAtari911        // Generate event ID if new
26387ac9bf3SAtari911        $generatedId = $eventId ?: uniqid();
26487ac9bf3SAtari911
2659ccd446eSAtari911        // If editing a recurring event, load existing data to preserve unchanged fields
2669ccd446eSAtari911        $existingEventData = null;
2679ccd446eSAtari911        if ($eventId && $isRecurring) {
2689ccd446eSAtari911            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
26996df7d3eSAtari911            // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use ''
27096df7d3eSAtari911            $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace);
2719ccd446eSAtari911            if ($existingEventData) {
2727e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'");
2739ccd446eSAtari911            }
2749ccd446eSAtari911        }
2759ccd446eSAtari911
27687ac9bf3SAtari911        // If recurring, generate multiple events
27787ac9bf3SAtari911        if ($isRecurring) {
2789ccd446eSAtari911            // Merge with existing data if editing (preserve values that weren't changed)
2799ccd446eSAtari911            if ($existingEventData) {
2809ccd446eSAtari911                $title = $title ?: $existingEventData['title'];
2819ccd446eSAtari911                $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : '');
2829ccd446eSAtari911                $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : '');
2839ccd446eSAtari911                $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : '');
2849ccd446eSAtari911                // Only use existing color if new color is default
2859ccd446eSAtari911                if ($color === '#3498db' && isset($existingEventData['color'])) {
2869ccd446eSAtari911                    $color = $existingEventData['color'];
2879ccd446eSAtari911                }
2889ccd446eSAtari911
2899ccd446eSAtari911                // Preserve namespace in these cases:
2909ccd446eSAtari911                // 1. Namespace field is empty (user didn't select anything)
2919ccd446eSAtari911                // 2. Namespace contains wildcards (like "personal;work" or "work*")
2929ccd446eSAtari911                // 3. Namespace is the same as what was passed (no change intended)
2939ccd446eSAtari911                $receivedNamespace = $namespace;
2949ccd446eSAtari911                if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) {
2959ccd446eSAtari911                    if (isset($existingEventData['namespace'])) {
2969ccd446eSAtari911                        $namespace = $existingEventData['namespace'];
2977e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')");
2989ccd446eSAtari911                    } else {
2997e8ea635SAtari911                        $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')");
3009ccd446eSAtari911                    }
3019ccd446eSAtari911                } else {
3027e8ea635SAtari911                    $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')");
3039ccd446eSAtari911                }
3049ccd446eSAtari911            } else {
3057e8ea635SAtari911                $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'");
3069ccd446eSAtari911            }
3079ccd446eSAtari911
30896df7d3eSAtari911            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description,
30996df7d3eSAtari911                                        $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd,
31096df7d3eSAtari911                                        $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId);
31187ac9bf3SAtari911            echo json_encode(['success' => true]);
31287ac9bf3SAtari911            return;
31387ac9bf3SAtari911        }
31487ac9bf3SAtari911
31519378907SAtari911        list($year, $month, $day) = explode('-', $date);
31619378907SAtari911
3171d05cddcSAtari911        // NEW namespace directory (where we'll save)
31819378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
31919378907SAtari911        if ($namespace) {
32019378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
32119378907SAtari911        }
32219378907SAtari911        $dataDir .= 'calendar/';
32319378907SAtari911
32419378907SAtari911        if (!is_dir($dataDir)) {
32519378907SAtari911            mkdir($dataDir, 0755, true);
32619378907SAtari911        }
32719378907SAtari911
32819378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
32919378907SAtari911
33096df7d3eSAtari911        $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'");
33196df7d3eSAtari911
33219378907SAtari911        $events = [];
33319378907SAtari911        if (file_exists($eventFile)) {
33419378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
33596df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location");
33696df7d3eSAtari911        } else {
33796df7d3eSAtari911            $this->debugLog("Calendar saveEvent: New location file does not exist yet");
33819378907SAtari911        }
33919378907SAtari911
3401d05cddcSAtari911        // If editing and (date changed OR namespace changed), remove from old location first
34196df7d3eSAtari911        // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace
34296df7d3eSAtari911        $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace);
3431d05cddcSAtari911        $dateChanged = ($eventId && $oldDate && $oldDate !== $date);
3441d05cddcSAtari911
34596df7d3eSAtari911        $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO'));
34696df7d3eSAtari911
3471d05cddcSAtari911        if ($namespaceChanged || $dateChanged) {
3481d05cddcSAtari911            // Construct OLD data directory using OLD namespace
3491d05cddcSAtari911            $oldDataDir = DOKU_INC . 'data/meta/';
3501d05cddcSAtari911            if ($oldNamespace) {
3511d05cddcSAtari911                $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/';
3521d05cddcSAtari911            }
3531d05cddcSAtari911            $oldDataDir .= 'calendar/';
3541d05cddcSAtari911
3551d05cddcSAtari911            $deleteDate = $dateChanged ? $oldDate : $date;
3561d05cddcSAtari911            list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate);
3571d05cddcSAtari911            $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
35819378907SAtari911
35996df7d3eSAtari911            $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'");
36096df7d3eSAtari911
36119378907SAtari911            if (file_exists($oldEventFile)) {
36219378907SAtari911                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
36396df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates");
36496df7d3eSAtari911
3651d05cddcSAtari911                if (isset($oldEvents[$deleteDate])) {
36696df7d3eSAtari911                    $countBefore = count($oldEvents[$deleteDate]);
3671d05cddcSAtari911                    $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) {
36819378907SAtari911                        return $evt['id'] !== $eventId;
369e3a9f44cSAtari911                    }));
37096df7d3eSAtari911                    $countAfter = count($oldEvents[$deleteDate]);
37196df7d3eSAtari911
37296df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter");
37319378907SAtari911
3741d05cddcSAtari911                    if (empty($oldEvents[$deleteDate])) {
3751d05cddcSAtari911                        unset($oldEvents[$deleteDate]);
37619378907SAtari911                    }
37719378907SAtari911
37819378907SAtari911                    file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT));
37996df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'");
38096df7d3eSAtari911                } else {
38196df7d3eSAtari911                    $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file");
38219378907SAtari911                }
38396df7d3eSAtari911            } else {
38496df7d3eSAtari911                $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile");
38519378907SAtari911            }
38696df7d3eSAtari911        } else {
38796df7d3eSAtari911            $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location");
38819378907SAtari911        }
38919378907SAtari911
39019378907SAtari911        if (!isset($events[$date])) {
39119378907SAtari911            $events[$date] = [];
392e3a9f44cSAtari911        } elseif (!is_array($events[$date])) {
393e3a9f44cSAtari911            // Fix corrupted data - ensure it's an array
3947e8ea635SAtari911            $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
395e3a9f44cSAtari911            $events[$date] = [];
39619378907SAtari911        }
39719378907SAtari911
398e3a9f44cSAtari911        // Store the namespace with the event
39919378907SAtari911        $eventData = [
40087ac9bf3SAtari911            'id' => $generatedId,
40119378907SAtari911            'title' => $title,
40219378907SAtari911            'time' => $time,
4031d05cddcSAtari911            'endTime' => $endTime,
40419378907SAtari911            'description' => $description,
40519378907SAtari911            'color' => $color,
40619378907SAtari911            'isTask' => $isTask,
40719378907SAtari911            'completed' => $completed,
40819378907SAtari911            'endDate' => $endDate,
409e3a9f44cSAtari911            'namespace' => $namespace, // Store namespace with event
41019378907SAtari911            'created' => date('Y-m-d H:i:s')
41119378907SAtari911        ];
41219378907SAtari911
4131d05cddcSAtari911        // Debug logging
4147e8ea635SAtari911        $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile");
4151d05cddcSAtari911
41619378907SAtari911        // If editing, replace existing event
41719378907SAtari911        if ($eventId) {
41819378907SAtari911            $found = false;
41919378907SAtari911            foreach ($events[$date] as $key => $evt) {
42019378907SAtari911                if ($evt['id'] === $eventId) {
42119378907SAtari911                    $events[$date][$key] = $eventData;
42219378907SAtari911                    $found = true;
42319378907SAtari911                    break;
42419378907SAtari911                }
42519378907SAtari911            }
42619378907SAtari911            if (!$found) {
42719378907SAtari911                $events[$date][] = $eventData;
42819378907SAtari911            }
42919378907SAtari911        } else {
43019378907SAtari911            $events[$date][] = $eventData;
43119378907SAtari911        }
43219378907SAtari911
43319378907SAtari911        file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
43419378907SAtari911
435e3a9f44cSAtari911        // If event spans multiple months, add it to the first day of each subsequent month
436e3a9f44cSAtari911        if ($endDate && $endDate !== $date) {
437e3a9f44cSAtari911            $startDateObj = new DateTime($date);
438e3a9f44cSAtari911            $endDateObj = new DateTime($endDate);
439e3a9f44cSAtari911
440e3a9f44cSAtari911            // Get the month/year of the start date
441e3a9f44cSAtari911            $startMonth = $startDateObj->format('Y-m');
442e3a9f44cSAtari911
443e3a9f44cSAtari911            // Iterate through each month the event spans
444e3a9f44cSAtari911            $currentDate = clone $startDateObj;
445e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
446e3a9f44cSAtari911
447e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
448e3a9f44cSAtari911                $currentMonth = $currentDate->format('Y-m');
449e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
450e3a9f44cSAtari911
451e3a9f44cSAtari911                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
452e3a9f44cSAtari911
453e3a9f44cSAtari911                // Get the file for this month
454e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
455e3a9f44cSAtari911
456e3a9f44cSAtari911                $currentEvents = [];
457e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
458e3a9f44cSAtari911                    $contents = file_get_contents($currentEventFile);
459e3a9f44cSAtari911                    $decoded = json_decode($contents, true);
460e3a9f44cSAtari911                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
461e3a9f44cSAtari911                        $currentEvents = $decoded;
462e3a9f44cSAtari911                    } else {
4637e8ea635SAtari911                        $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
464e3a9f44cSAtari911                    }
465e3a9f44cSAtari911                }
466e3a9f44cSAtari911
467e3a9f44cSAtari911                // Add entry for the first day of this month
468e3a9f44cSAtari911                if (!isset($currentEvents[$firstDayOfMonth])) {
469e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
470e3a9f44cSAtari911                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
471e3a9f44cSAtari911                    // Fix corrupted data - ensure it's an array
4727e8ea635SAtari911                    $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
473e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
474e3a9f44cSAtari911                }
475e3a9f44cSAtari911
476e3a9f44cSAtari911                // Create a copy with the original start date preserved
477e3a9f44cSAtari911                $eventDataForMonth = $eventData;
478e3a9f44cSAtari911                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
479e3a9f44cSAtari911
480e3a9f44cSAtari911                // Check if event already exists (when editing)
481e3a9f44cSAtari911                $found = false;
482e3a9f44cSAtari911                if ($eventId) {
483e3a9f44cSAtari911                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
484e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
485e3a9f44cSAtari911                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
486e3a9f44cSAtari911                            $found = true;
487e3a9f44cSAtari911                            break;
488e3a9f44cSAtari911                        }
489e3a9f44cSAtari911                    }
490e3a9f44cSAtari911                }
491e3a9f44cSAtari911
492e3a9f44cSAtari911                if (!$found) {
493e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
494e3a9f44cSAtari911                }
495e3a9f44cSAtari911
496e3a9f44cSAtari911                file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
497e3a9f44cSAtari911
498e3a9f44cSAtari911                // Move to next month
499e3a9f44cSAtari911                $currentDate->modify('first day of next month');
500e3a9f44cSAtari911            }
501e3a9f44cSAtari911        }
502e3a9f44cSAtari911
50319378907SAtari911        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
50419378907SAtari911    }
50519378907SAtari911
50619378907SAtari911    private function deleteEvent() {
50719378907SAtari911        global $INPUT;
50819378907SAtari911
50919378907SAtari911        $namespace = $INPUT->str('namespace', '');
51019378907SAtari911        $date = $INPUT->str('date');
51119378907SAtari911        $eventId = $INPUT->str('eventId');
51219378907SAtari911
513e3a9f44cSAtari911        // Find where the event actually lives
514e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
515e3a9f44cSAtari911
516e3a9f44cSAtari911        if ($storedNamespace === null) {
517e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
518e3a9f44cSAtari911            return;
519e3a9f44cSAtari911        }
520e3a9f44cSAtari911
521e3a9f44cSAtari911        // Use the found namespace
522e3a9f44cSAtari911        $namespace = $storedNamespace;
523e3a9f44cSAtari911
52419378907SAtari911        list($year, $month, $day) = explode('-', $date);
52519378907SAtari911
52619378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
52719378907SAtari911        if ($namespace) {
52819378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
52919378907SAtari911        }
53019378907SAtari911        $dataDir .= 'calendar/';
53119378907SAtari911
53219378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
53319378907SAtari911
5349ccd446eSAtari911        // First, get the event to check if it spans multiple months or is recurring
535e3a9f44cSAtari911        $eventToDelete = null;
5369ccd446eSAtari911        $isRecurring = false;
5379ccd446eSAtari911        $recurringId = null;
5389ccd446eSAtari911
53919378907SAtari911        if (file_exists($eventFile)) {
54019378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
54119378907SAtari911
54219378907SAtari911            if (isset($events[$date])) {
543e3a9f44cSAtari911                foreach ($events[$date] as $event) {
544e3a9f44cSAtari911                    if ($event['id'] === $eventId) {
545e3a9f44cSAtari911                        $eventToDelete = $event;
5469ccd446eSAtari911                        $isRecurring = isset($event['recurring']) && $event['recurring'];
5479ccd446eSAtari911                        $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
548e3a9f44cSAtari911                        break;
549e3a9f44cSAtari911                    }
550e3a9f44cSAtari911                }
551e3a9f44cSAtari911
552e3a9f44cSAtari911                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
55319378907SAtari911                    return $event['id'] !== $eventId;
554e3a9f44cSAtari911                }));
55519378907SAtari911
55619378907SAtari911                if (empty($events[$date])) {
55719378907SAtari911                    unset($events[$date]);
55819378907SAtari911                }
55919378907SAtari911
56019378907SAtari911                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
56119378907SAtari911            }
56219378907SAtari911        }
56319378907SAtari911
5649ccd446eSAtari911        // If this is a recurring event, delete ALL occurrences with the same recurringId
5659ccd446eSAtari911        if ($isRecurring && $recurringId) {
5669ccd446eSAtari911            $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir);
5679ccd446eSAtari911        }
5689ccd446eSAtari911
569e3a9f44cSAtari911        // If event spans multiple months, delete it from the first day of each subsequent month
570e3a9f44cSAtari911        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
571e3a9f44cSAtari911            $startDateObj = new DateTime($date);
572e3a9f44cSAtari911            $endDateObj = new DateTime($eventToDelete['endDate']);
573e3a9f44cSAtari911
574e3a9f44cSAtari911            // Iterate through each month the event spans
575e3a9f44cSAtari911            $currentDate = clone $startDateObj;
576e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
577e3a9f44cSAtari911
578e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
579e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
580e3a9f44cSAtari911                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
581e3a9f44cSAtari911
582e3a9f44cSAtari911                // Get the file for this month
583e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
584e3a9f44cSAtari911
585e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
586e3a9f44cSAtari911                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
587e3a9f44cSAtari911
588e3a9f44cSAtari911                    if (isset($currentEvents[$firstDayOfMonth])) {
589e3a9f44cSAtari911                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
590e3a9f44cSAtari911                            return $event['id'] !== $eventId;
591e3a9f44cSAtari911                        }));
592e3a9f44cSAtari911
593e3a9f44cSAtari911                        if (empty($currentEvents[$firstDayOfMonth])) {
594e3a9f44cSAtari911                            unset($currentEvents[$firstDayOfMonth]);
595e3a9f44cSAtari911                        }
596e3a9f44cSAtari911
597e3a9f44cSAtari911                        file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
598e3a9f44cSAtari911                    }
599e3a9f44cSAtari911                }
600e3a9f44cSAtari911
601e3a9f44cSAtari911                // Move to next month
602e3a9f44cSAtari911                $currentDate->modify('first day of next month');
603e3a9f44cSAtari911            }
604e3a9f44cSAtari911        }
605e3a9f44cSAtari911
60619378907SAtari911        echo json_encode(['success' => true]);
60719378907SAtari911    }
60819378907SAtari911
60919378907SAtari911    private function getEvent() {
61019378907SAtari911        global $INPUT;
61119378907SAtari911
61219378907SAtari911        $namespace = $INPUT->str('namespace', '');
61319378907SAtari911        $date = $INPUT->str('date');
61419378907SAtari911        $eventId = $INPUT->str('eventId');
61519378907SAtari911
616e3a9f44cSAtari911        // Find where the event actually lives
617e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
618e3a9f44cSAtari911
619e3a9f44cSAtari911        if ($storedNamespace === null) {
620e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
621e3a9f44cSAtari911            return;
622e3a9f44cSAtari911        }
623e3a9f44cSAtari911
624e3a9f44cSAtari911        // Use the found namespace
625e3a9f44cSAtari911        $namespace = $storedNamespace;
626e3a9f44cSAtari911
62719378907SAtari911        list($year, $month, $day) = explode('-', $date);
62819378907SAtari911
62919378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
63019378907SAtari911        if ($namespace) {
63119378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
63219378907SAtari911        }
63319378907SAtari911        $dataDir .= 'calendar/';
63419378907SAtari911
63519378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
63619378907SAtari911
63719378907SAtari911        if (file_exists($eventFile)) {
63819378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
63919378907SAtari911
64019378907SAtari911            if (isset($events[$date])) {
64119378907SAtari911                foreach ($events[$date] as $event) {
64219378907SAtari911                    if ($event['id'] === $eventId) {
6431d05cddcSAtari911                        // Include the namespace so JavaScript knows where this event actually lives
6441d05cddcSAtari911                        $event['namespace'] = $namespace;
64519378907SAtari911                        echo json_encode(['success' => true, 'event' => $event]);
64619378907SAtari911                        return;
64719378907SAtari911                    }
64819378907SAtari911                }
64919378907SAtari911            }
65019378907SAtari911        }
65119378907SAtari911
65219378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
65319378907SAtari911    }
65419378907SAtari911
65519378907SAtari911    private function loadMonth() {
65619378907SAtari911        global $INPUT;
65719378907SAtari911
658e3a9f44cSAtari911        // Prevent caching of AJAX responses
659e3a9f44cSAtari911        header('Cache-Control: no-cache, no-store, must-revalidate');
660e3a9f44cSAtari911        header('Pragma: no-cache');
661e3a9f44cSAtari911        header('Expires: 0');
662e3a9f44cSAtari911
66319378907SAtari911        $namespace = $INPUT->str('namespace', '');
66419378907SAtari911        $year = $INPUT->int('year');
66519378907SAtari911        $month = $INPUT->int('month');
66619378907SAtari911
6677e8ea635SAtari911        // Validate year (reasonable range: 1970-2100)
6687e8ea635SAtari911        if ($year < 1970 || $year > 2100) {
6697e8ea635SAtari911            $year = (int)date('Y');
6707e8ea635SAtari911        }
6717e8ea635SAtari911
6727e8ea635SAtari911        // Validate month (1-12)
6737e8ea635SAtari911        if ($month < 1 || $month > 12) {
6747e8ea635SAtari911            $month = (int)date('n');
6757e8ea635SAtari911        }
6767e8ea635SAtari911
6777e8ea635SAtari911        // Validate namespace format
6787e8ea635SAtari911        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
6797e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
6807e8ea635SAtari911            return;
6817e8ea635SAtari911        }
6827e8ea635SAtari911
6837e8ea635SAtari911        $this->debugLog("=== Calendar loadMonth DEBUG ===");
6847e8ea635SAtari911        $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'");
685e3a9f44cSAtari911
686e3a9f44cSAtari911        // Check if multi-namespace or wildcard
687e3a9f44cSAtari911        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
688e3a9f44cSAtari911
6897e8ea635SAtari911        $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
690e3a9f44cSAtari911
691e3a9f44cSAtari911        if ($isMultiNamespace) {
692e3a9f44cSAtari911            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
693e3a9f44cSAtari911        } else {
694e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
695e3a9f44cSAtari911        }
696e3a9f44cSAtari911
6977e8ea635SAtari911        $this->debugLog("Returning " . count($events) . " date keys");
698e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
6997e8ea635SAtari911            $this->debugLog("  dateKey=$dateKey has " . count($dayEvents) . " events");
700e3a9f44cSAtari911        }
701e3a9f44cSAtari911
702e3a9f44cSAtari911        echo json_encode([
703e3a9f44cSAtari911            'success' => true,
704e3a9f44cSAtari911            'year' => $year,
705e3a9f44cSAtari911            'month' => $month,
706e3a9f44cSAtari911            'events' => $events
707e3a9f44cSAtari911        ]);
708e3a9f44cSAtari911    }
709e3a9f44cSAtari911
710*da206178SAtari911    /**
711*da206178SAtari911     * Get static calendar HTML via AJAX for navigation
712*da206178SAtari911     */
713*da206178SAtari911    private function getStaticCalendar() {
714*da206178SAtari911        global $INPUT;
715*da206178SAtari911
716*da206178SAtari911        $namespace = $INPUT->str('namespace', '');
717*da206178SAtari911        $year = $INPUT->int('year');
718*da206178SAtari911        $month = $INPUT->int('month');
719*da206178SAtari911
720*da206178SAtari911        // Validate
721*da206178SAtari911        if ($year < 1970 || $year > 2100) {
722*da206178SAtari911            $year = (int)date('Y');
723*da206178SAtari911        }
724*da206178SAtari911        if ($month < 1 || $month > 12) {
725*da206178SAtari911            $month = (int)date('n');
726*da206178SAtari911        }
727*da206178SAtari911
728*da206178SAtari911        // Get syntax plugin to render the static calendar
729*da206178SAtari911        $syntax = plugin_load('syntax', 'calendar');
730*da206178SAtari911        if (!$syntax) {
731*da206178SAtari911            echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']);
732*da206178SAtari911            return;
733*da206178SAtari911        }
734*da206178SAtari911
735*da206178SAtari911        // Build data array for render
736*da206178SAtari911        $data = [
737*da206178SAtari911            'year' => $year,
738*da206178SAtari911            'month' => $month,
739*da206178SAtari911            'namespace' => $namespace,
740*da206178SAtari911            'static' => true
741*da206178SAtari911        ];
742*da206178SAtari911
743*da206178SAtari911        // Call the render method via reflection (since renderStaticCalendar is private)
744*da206178SAtari911        $reflector = new \ReflectionClass($syntax);
745*da206178SAtari911        $method = $reflector->getMethod('renderStaticCalendar');
746*da206178SAtari911        $method->setAccessible(true);
747*da206178SAtari911        $html = $method->invoke($syntax, $data);
748*da206178SAtari911
749*da206178SAtari911        echo json_encode([
750*da206178SAtari911            'success' => true,
751*da206178SAtari911            'html' => $html
752*da206178SAtari911        ]);
753*da206178SAtari911    }
754*da206178SAtari911
755e3a9f44cSAtari911    private function loadEventsSingleNamespace($namespace, $year, $month) {
75619378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
75719378907SAtari911        if ($namespace) {
75819378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
75919378907SAtari911        }
76019378907SAtari911        $dataDir .= 'calendar/';
76119378907SAtari911
762e3a9f44cSAtari911        // Load ONLY current month
76387ac9bf3SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
76419378907SAtari911        $events = [];
76519378907SAtari911        if (file_exists($eventFile)) {
76687ac9bf3SAtari911            $contents = file_get_contents($eventFile);
76787ac9bf3SAtari911            $decoded = json_decode($contents, true);
76887ac9bf3SAtari911            if (json_last_error() === JSON_ERROR_NONE) {
76987ac9bf3SAtari911                $events = $decoded;
77087ac9bf3SAtari911            }
77187ac9bf3SAtari911        }
77287ac9bf3SAtari911
773e3a9f44cSAtari911        return $events;
77487ac9bf3SAtari911    }
775e3a9f44cSAtari911
776e3a9f44cSAtari911    private function loadEventsMultiNamespace($namespaces, $year, $month) {
777e3a9f44cSAtari911        // Check for wildcard pattern
778e3a9f44cSAtari911        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
779e3a9f44cSAtari911            $baseNamespace = $matches[1];
780e3a9f44cSAtari911            return $this->loadEventsWildcard($baseNamespace, $year, $month);
781e3a9f44cSAtari911        }
782e3a9f44cSAtari911
783e3a9f44cSAtari911        // Check for root wildcard
784e3a9f44cSAtari911        if ($namespaces === '*') {
785e3a9f44cSAtari911            return $this->loadEventsWildcard('', $year, $month);
786e3a9f44cSAtari911        }
787e3a9f44cSAtari911
788e3a9f44cSAtari911        // Parse namespace list (semicolon separated)
789e3a9f44cSAtari911        $namespaceList = array_map('trim', explode(';', $namespaces));
790e3a9f44cSAtari911
791e3a9f44cSAtari911        // Load events from all namespaces
792e3a9f44cSAtari911        $allEvents = [];
793e3a9f44cSAtari911        foreach ($namespaceList as $ns) {
794e3a9f44cSAtari911            $ns = trim($ns);
795e3a9f44cSAtari911            if (empty($ns)) continue;
796e3a9f44cSAtari911
797e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
798e3a9f44cSAtari911
799e3a9f44cSAtari911            // Add namespace tag to each event
800e3a9f44cSAtari911            foreach ($events as $dateKey => $dayEvents) {
801e3a9f44cSAtari911                if (!isset($allEvents[$dateKey])) {
802e3a9f44cSAtari911                    $allEvents[$dateKey] = [];
803e3a9f44cSAtari911                }
804e3a9f44cSAtari911                foreach ($dayEvents as $event) {
805e3a9f44cSAtari911                    $event['_namespace'] = $ns;
806e3a9f44cSAtari911                    $allEvents[$dateKey][] = $event;
807e3a9f44cSAtari911                }
80887ac9bf3SAtari911            }
80987ac9bf3SAtari911        }
81087ac9bf3SAtari911
811e3a9f44cSAtari911        return $allEvents;
812e3a9f44cSAtari911    }
81319378907SAtari911
814e3a9f44cSAtari911    private function loadEventsWildcard($baseNamespace, $year, $month) {
815e3a9f44cSAtari911        $dataDir = DOKU_INC . 'data/meta/';
816e3a9f44cSAtari911        if ($baseNamespace) {
817e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
818e3a9f44cSAtari911        }
819e3a9f44cSAtari911
820e3a9f44cSAtari911        $allEvents = [];
821e3a9f44cSAtari911
822e3a9f44cSAtari911        // First, load events from the base namespace itself
823e3a9f44cSAtari911        $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
824e3a9f44cSAtari911
825e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
826e3a9f44cSAtari911            if (!isset($allEvents[$dateKey])) {
827e3a9f44cSAtari911                $allEvents[$dateKey] = [];
828e3a9f44cSAtari911            }
829e3a9f44cSAtari911            foreach ($dayEvents as $event) {
830e3a9f44cSAtari911                $event['_namespace'] = $baseNamespace;
831e3a9f44cSAtari911                $allEvents[$dateKey][] = $event;
832e3a9f44cSAtari911            }
833e3a9f44cSAtari911        }
834e3a9f44cSAtari911
835e3a9f44cSAtari911        // Recursively find all subdirectories
836e3a9f44cSAtari911        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
837e3a9f44cSAtari911
838e3a9f44cSAtari911        return $allEvents;
839e3a9f44cSAtari911    }
840e3a9f44cSAtari911
841e3a9f44cSAtari911    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
842e3a9f44cSAtari911        if (!is_dir($dir)) return;
843e3a9f44cSAtari911
844e3a9f44cSAtari911        $items = scandir($dir);
845e3a9f44cSAtari911        foreach ($items as $item) {
846e3a9f44cSAtari911            if ($item === '.' || $item === '..') continue;
847e3a9f44cSAtari911
848e3a9f44cSAtari911            $path = $dir . $item;
849e3a9f44cSAtari911            if (is_dir($path) && $item !== 'calendar') {
850e3a9f44cSAtari911                // This is a namespace directory
851e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
852e3a9f44cSAtari911
853e3a9f44cSAtari911                // Load events from this namespace
854e3a9f44cSAtari911                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
855e3a9f44cSAtari911                foreach ($events as $dateKey => $dayEvents) {
856e3a9f44cSAtari911                    if (!isset($allEvents[$dateKey])) {
857e3a9f44cSAtari911                        $allEvents[$dateKey] = [];
858e3a9f44cSAtari911                    }
859e3a9f44cSAtari911                    foreach ($dayEvents as $event) {
860e3a9f44cSAtari911                        $event['_namespace'] = $namespace;
861e3a9f44cSAtari911                        $allEvents[$dateKey][] = $event;
862e3a9f44cSAtari911                    }
863e3a9f44cSAtari911                }
864e3a9f44cSAtari911
865e3a9f44cSAtari911                // Recurse into subdirectories
866e3a9f44cSAtari911                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
867e3a9f44cSAtari911            }
868e3a9f44cSAtari911        }
86919378907SAtari911    }
87019378907SAtari911
87196df7d3eSAtari911    /**
87296df7d3eSAtari911     * Search all dates for events matching the search term
87396df7d3eSAtari911     */
87496df7d3eSAtari911    private function searchAllDates() {
87596df7d3eSAtari911        global $INPUT;
87696df7d3eSAtari911
87796df7d3eSAtari911        $searchTerm = strtolower(trim($INPUT->str('search', '')));
87896df7d3eSAtari911        $namespace = $INPUT->str('namespace', '');
87996df7d3eSAtari911
88096df7d3eSAtari911        if (strlen($searchTerm) < 2) {
88196df7d3eSAtari911            echo json_encode(['success' => false, 'error' => 'Search term too short']);
88296df7d3eSAtari911            return;
88396df7d3eSAtari911        }
88496df7d3eSAtari911
88596df7d3eSAtari911        // Normalize search term for fuzzy matching
88696df7d3eSAtari911        $normalizedSearch = $this->normalizeForSearch($searchTerm);
88796df7d3eSAtari911
88896df7d3eSAtari911        $results = [];
88996df7d3eSAtari911        $dataDir = DOKU_INC . 'data/meta/';
89096df7d3eSAtari911
89196df7d3eSAtari911        // Helper to search calendar directory
89296df7d3eSAtari911        $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results) {
89396df7d3eSAtari911            if (!is_dir($calDir)) return;
89496df7d3eSAtari911
89596df7d3eSAtari911            foreach (glob($calDir . '/*.json') as $file) {
89696df7d3eSAtari911                $data = @json_decode(file_get_contents($file), true);
89796df7d3eSAtari911                if (!$data || !is_array($data)) continue;
89896df7d3eSAtari911
89996df7d3eSAtari911                foreach ($data as $dateKey => $dayEvents) {
90096df7d3eSAtari911                    // Skip non-date keys
90196df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
90296df7d3eSAtari911                    if (!is_array($dayEvents)) continue;
90396df7d3eSAtari911
90496df7d3eSAtari911                    foreach ($dayEvents as $event) {
90596df7d3eSAtari911                        if (!isset($event['title'])) continue;
90696df7d3eSAtari911
90796df7d3eSAtari911                        // Build searchable text
90896df7d3eSAtari911                        $searchableText = strtolower($event['title']);
90996df7d3eSAtari911                        if (isset($event['description'])) {
91096df7d3eSAtari911                            $searchableText .= ' ' . strtolower($event['description']);
91196df7d3eSAtari911                        }
91296df7d3eSAtari911
91396df7d3eSAtari911                        // Normalize for fuzzy matching
91496df7d3eSAtari911                        $normalizedText = $this->normalizeForSearch($searchableText);
91596df7d3eSAtari911
91696df7d3eSAtari911                        // Check if matches using fuzzy match
91796df7d3eSAtari911                        if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) {
91896df7d3eSAtari911                            $results[] = [
91996df7d3eSAtari911                                'date' => $dateKey,
92096df7d3eSAtari911                                'title' => $event['title'],
92196df7d3eSAtari911                                'time' => isset($event['time']) ? $event['time'] : '',
92296df7d3eSAtari911                                'endTime' => isset($event['endTime']) ? $event['endTime'] : '',
92396df7d3eSAtari911                                'color' => isset($event['color']) ? $event['color'] : '',
92496df7d3eSAtari911                                'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace,
92596df7d3eSAtari911                                'id' => isset($event['id']) ? $event['id'] : ''
92696df7d3eSAtari911                            ];
92796df7d3eSAtari911                        }
92896df7d3eSAtari911                    }
92996df7d3eSAtari911                }
93096df7d3eSAtari911            }
93196df7d3eSAtari911        };
93296df7d3eSAtari911
93396df7d3eSAtari911        // Search root calendar directory
93496df7d3eSAtari911        $searchCalendarDir($dataDir . 'calendar', '');
93596df7d3eSAtari911
93696df7d3eSAtari911        // Search namespace directories
93796df7d3eSAtari911        $this->searchNamespaceDirs($dataDir, $searchCalendarDir);
93896df7d3eSAtari911
93996df7d3eSAtari911        // Sort results by date (newest first for past, oldest first for future)
94096df7d3eSAtari911        usort($results, function($a, $b) {
94196df7d3eSAtari911            return strcmp($a['date'], $b['date']);
94296df7d3eSAtari911        });
94396df7d3eSAtari911
94496df7d3eSAtari911        // Limit results
94596df7d3eSAtari911        $results = array_slice($results, 0, 50);
94696df7d3eSAtari911
94796df7d3eSAtari911        echo json_encode([
94896df7d3eSAtari911            'success' => true,
94996df7d3eSAtari911            'results' => $results,
95096df7d3eSAtari911            'total' => count($results)
95196df7d3eSAtari911        ]);
95296df7d3eSAtari911    }
95396df7d3eSAtari911
95496df7d3eSAtari911    /**
95596df7d3eSAtari911     * Check if normalized text matches normalized search term
95696df7d3eSAtari911     * Supports multi-word search where all words must be present
95796df7d3eSAtari911     */
95896df7d3eSAtari911    private function fuzzyMatchText($normalizedText, $normalizedSearch) {
95996df7d3eSAtari911        // Direct substring match
96096df7d3eSAtari911        if (strpos($normalizedText, $normalizedSearch) !== false) {
96196df7d3eSAtari911            return true;
96296df7d3eSAtari911        }
96396df7d3eSAtari911
96496df7d3eSAtari911        // Multi-word search: all words must be present
96596df7d3eSAtari911        $searchWords = array_filter(explode(' ', $normalizedSearch));
96696df7d3eSAtari911        if (count($searchWords) > 1) {
96796df7d3eSAtari911            foreach ($searchWords as $word) {
96896df7d3eSAtari911                if (strlen($word) > 0 && strpos($normalizedText, $word) === false) {
96996df7d3eSAtari911                    return false;
97096df7d3eSAtari911                }
97196df7d3eSAtari911            }
97296df7d3eSAtari911            return true;
97396df7d3eSAtari911        }
97496df7d3eSAtari911
97596df7d3eSAtari911        return false;
97696df7d3eSAtari911    }
97796df7d3eSAtari911
97896df7d3eSAtari911    /**
97996df7d3eSAtari911     * Normalize text for fuzzy search matching
98096df7d3eSAtari911     * Removes apostrophes, extra spaces, and common variations
98196df7d3eSAtari911     */
98296df7d3eSAtari911    private function normalizeForSearch($text) {
98396df7d3eSAtari911        // Convert to lowercase
98496df7d3eSAtari911        $text = strtolower($text);
98596df7d3eSAtari911
98696df7d3eSAtari911        // Remove apostrophes and quotes (father's -> fathers)
98796df7d3eSAtari911        $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text);
98896df7d3eSAtari911
98996df7d3eSAtari911        // Normalize dashes and underscores to spaces
99096df7d3eSAtari911        $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text);
99196df7d3eSAtari911
99296df7d3eSAtari911        // Remove other punctuation but keep letters, numbers, spaces
99396df7d3eSAtari911        $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text);
99496df7d3eSAtari911
99596df7d3eSAtari911        // Normalize multiple spaces to single space
99696df7d3eSAtari911        $text = preg_replace('/\s+/', ' ', $text);
99796df7d3eSAtari911
99896df7d3eSAtari911        // Trim
99996df7d3eSAtari911        $text = trim($text);
100096df7d3eSAtari911
100196df7d3eSAtari911        return $text;
100296df7d3eSAtari911    }
100396df7d3eSAtari911
100496df7d3eSAtari911    /**
100596df7d3eSAtari911     * Recursively search namespace directories for calendar data
100696df7d3eSAtari911     */
100796df7d3eSAtari911    private function searchNamespaceDirs($baseDir, $callback) {
100896df7d3eSAtari911        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
100996df7d3eSAtari911            $name = basename($nsDir);
101096df7d3eSAtari911            if ($name === 'calendar') continue;
101196df7d3eSAtari911
101296df7d3eSAtari911            $calDir = $nsDir . '/calendar';
101396df7d3eSAtari911            if (is_dir($calDir)) {
101496df7d3eSAtari911                $relPath = str_replace(DOKU_INC . 'data/meta/', '', $nsDir);
101596df7d3eSAtari911                $namespace = str_replace('/', ':', $relPath);
101696df7d3eSAtari911                $callback($calDir, $namespace);
101796df7d3eSAtari911            }
101896df7d3eSAtari911
101996df7d3eSAtari911            // Recurse
102096df7d3eSAtari911            $this->searchNamespaceDirs($nsDir . '/', $callback);
102196df7d3eSAtari911        }
102296df7d3eSAtari911    }
102396df7d3eSAtari911
102419378907SAtari911    private function toggleTaskComplete() {
102519378907SAtari911        global $INPUT;
102619378907SAtari911
102719378907SAtari911        $namespace = $INPUT->str('namespace', '');
102819378907SAtari911        $date = $INPUT->str('date');
102919378907SAtari911        $eventId = $INPUT->str('eventId');
103019378907SAtari911        $completed = $INPUT->bool('completed', false);
103119378907SAtari911
1032e3a9f44cSAtari911        // Find where the event actually lives
1033e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
1034e3a9f44cSAtari911
1035e3a9f44cSAtari911        if ($storedNamespace === null) {
1036e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
1037e3a9f44cSAtari911            return;
1038e3a9f44cSAtari911        }
1039e3a9f44cSAtari911
1040e3a9f44cSAtari911        // Use the found namespace
1041e3a9f44cSAtari911        $namespace = $storedNamespace;
1042e3a9f44cSAtari911
104319378907SAtari911        list($year, $month, $day) = explode('-', $date);
104419378907SAtari911
104519378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
104619378907SAtari911        if ($namespace) {
104719378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
104819378907SAtari911        }
104919378907SAtari911        $dataDir .= 'calendar/';
105019378907SAtari911
105119378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
105219378907SAtari911
105319378907SAtari911        if (file_exists($eventFile)) {
105419378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
105519378907SAtari911
105619378907SAtari911            if (isset($events[$date])) {
105719378907SAtari911                foreach ($events[$date] as $key => $event) {
105819378907SAtari911                    if ($event['id'] === $eventId) {
105919378907SAtari911                        $events[$date][$key]['completed'] = $completed;
106019378907SAtari911                        break;
106119378907SAtari911                    }
106219378907SAtari911                }
106319378907SAtari911
106419378907SAtari911                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
106519378907SAtari911                echo json_encode(['success' => true, 'events' => $events]);
106619378907SAtari911                return;
106719378907SAtari911            }
106819378907SAtari911        }
106919378907SAtari911
107019378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
107119378907SAtari911    }
107219378907SAtari911
107396df7d3eSAtari911    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime,
107496df7d3eSAtari911                                          $description, $color, $isTask, $recurrenceType, $recurrenceInterval,
107596df7d3eSAtari911                                          $recurrenceEnd, $weekDays, $monthlyType, $monthDay,
107696df7d3eSAtari911                                          $ordinalWeek, $ordinalDay, $baseId) {
107787ac9bf3SAtari911        $dataDir = DOKU_INC . 'data/meta/';
107887ac9bf3SAtari911        if ($namespace) {
107987ac9bf3SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
108087ac9bf3SAtari911        }
108187ac9bf3SAtari911        $dataDir .= 'calendar/';
108287ac9bf3SAtari911
108387ac9bf3SAtari911        if (!is_dir($dataDir)) {
108487ac9bf3SAtari911            mkdir($dataDir, 0755, true);
108587ac9bf3SAtari911        }
108687ac9bf3SAtari911
108796df7d3eSAtari911        // Ensure interval is at least 1
108896df7d3eSAtari911        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
108987ac9bf3SAtari911
109087ac9bf3SAtari911        // Set maximum end date if not specified (1 year from start)
109187ac9bf3SAtari911        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
109287ac9bf3SAtari911
109387ac9bf3SAtari911        // Calculate event duration for multi-day events
109487ac9bf3SAtari911        $eventDuration = 0;
109587ac9bf3SAtari911        if ($endDate && $endDate !== $startDate) {
109687ac9bf3SAtari911            $start = new DateTime($startDate);
109787ac9bf3SAtari911            $end = new DateTime($endDate);
109887ac9bf3SAtari911            $eventDuration = $start->diff($end)->days;
109987ac9bf3SAtari911        }
110087ac9bf3SAtari911
110187ac9bf3SAtari911        // Generate recurring events
110287ac9bf3SAtari911        $currentDate = new DateTime($startDate);
110387ac9bf3SAtari911        $endLimit = new DateTime($maxEnd);
110487ac9bf3SAtari911        $counter = 0;
110596df7d3eSAtari911        $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year)
110696df7d3eSAtari911
110796df7d3eSAtari911        // For weekly with specific days, we need to track the interval counter differently
110896df7d3eSAtari911        $weekCounter = 0;
110996df7d3eSAtari911        $startWeekNumber = (int)$currentDate->format('W');
111096df7d3eSAtari911        $startYear = (int)$currentDate->format('Y');
111187ac9bf3SAtari911
111287ac9bf3SAtari911        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
111396df7d3eSAtari911            $shouldCreateEvent = false;
111496df7d3eSAtari911
111596df7d3eSAtari911            switch ($recurrenceType) {
111696df7d3eSAtari911                case 'daily':
111796df7d3eSAtari911                    // Every N days from start
111896df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
111996df7d3eSAtari911                    $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0);
112096df7d3eSAtari911                    break;
112196df7d3eSAtari911
112296df7d3eSAtari911                case 'weekly':
112396df7d3eSAtari911                    // Every N weeks, on specified days
112496df7d3eSAtari911                    $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat
112596df7d3eSAtari911
112696df7d3eSAtari911                    // Calculate weeks since start
112796df7d3eSAtari911                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
112896df7d3eSAtari911                    $weeksSinceStart = floor($daysSinceStart / 7);
112996df7d3eSAtari911
113096df7d3eSAtari911                    // Check if we're in the right week (every N weeks)
113196df7d3eSAtari911                    $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0);
113296df7d3eSAtari911
113396df7d3eSAtari911                    // Check if this day is selected
113496df7d3eSAtari911                    $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays);
113596df7d3eSAtari911
113696df7d3eSAtari911                    // For the first week, only include days on or after the start date
113796df7d3eSAtari911                    $isOnOrAfterStart = ($currentDate >= new DateTime($startDate));
113896df7d3eSAtari911
113996df7d3eSAtari911                    $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart;
114096df7d3eSAtari911                    break;
114196df7d3eSAtari911
114296df7d3eSAtari911                case 'monthly':
114396df7d3eSAtari911                    // Calculate months since start
114496df7d3eSAtari911                    $startDT = new DateTime($startDate);
114596df7d3eSAtari911                    $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) +
114696df7d3eSAtari911                                        ($currentDate->format('n') - $startDT->format('n'));
114796df7d3eSAtari911
114896df7d3eSAtari911                    // Check if we're in the right month (every N months)
114996df7d3eSAtari911                    $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0);
115096df7d3eSAtari911
115196df7d3eSAtari911                    if (!$isCorrectMonth) {
115296df7d3eSAtari911                        // Skip to first day of next potential month
115396df7d3eSAtari911                        $currentDate->modify('first day of next month');
115496df7d3eSAtari911                        continue 2;
115596df7d3eSAtari911                    }
115696df7d3eSAtari911
115796df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
115896df7d3eSAtari911                        // Specific day of month (e.g., 15th)
115996df7d3eSAtari911                        $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j');
116096df7d3eSAtari911                        $currentDay = (int)$currentDate->format('j');
116196df7d3eSAtari911                        $daysInMonth = (int)$currentDate->format('t');
116296df7d3eSAtari911
116396df7d3eSAtari911                        // If target day exceeds days in month, use last day
116496df7d3eSAtari911                        $effectiveTargetDay = min($targetDay, $daysInMonth);
116596df7d3eSAtari911                        $shouldCreateEvent = ($currentDay === $effectiveTargetDay);
116696df7d3eSAtari911                    } else {
116796df7d3eSAtari911                        // Ordinal weekday (e.g., 2nd Wednesday, last Friday)
116896df7d3eSAtari911                        $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay);
116996df7d3eSAtari911                    }
117096df7d3eSAtari911                    break;
117196df7d3eSAtari911
117296df7d3eSAtari911                case 'yearly':
117396df7d3eSAtari911                    // Every N years on same month/day
117496df7d3eSAtari911                    $startDT = new DateTime($startDate);
117596df7d3eSAtari911                    $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y');
117696df7d3eSAtari911
117796df7d3eSAtari911                    // Check if we're in the right year
117896df7d3eSAtari911                    $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0);
117996df7d3eSAtari911
118096df7d3eSAtari911                    // Check if it's the same month and day
118196df7d3eSAtari911                    $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d'));
118296df7d3eSAtari911
118396df7d3eSAtari911                    $shouldCreateEvent = $isCorrectYear && $sameMonthDay;
118496df7d3eSAtari911                    break;
118596df7d3eSAtari911
118696df7d3eSAtari911                default:
118796df7d3eSAtari911                    $shouldCreateEvent = false;
118896df7d3eSAtari911            }
118996df7d3eSAtari911
119096df7d3eSAtari911            if ($shouldCreateEvent) {
119187ac9bf3SAtari911                $dateKey = $currentDate->format('Y-m-d');
119287ac9bf3SAtari911                list($year, $month, $day) = explode('-', $dateKey);
119387ac9bf3SAtari911
119487ac9bf3SAtari911                // Calculate end date for this occurrence if multi-day
119587ac9bf3SAtari911                $occurrenceEndDate = '';
119687ac9bf3SAtari911                if ($eventDuration > 0) {
119787ac9bf3SAtari911                    $occurrenceEnd = clone $currentDate;
119887ac9bf3SAtari911                    $occurrenceEnd->modify('+' . $eventDuration . ' days');
119987ac9bf3SAtari911                    $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
120087ac9bf3SAtari911                }
120187ac9bf3SAtari911
120287ac9bf3SAtari911                // Load month file
120387ac9bf3SAtari911                $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
120487ac9bf3SAtari911                $events = [];
120587ac9bf3SAtari911                if (file_exists($eventFile)) {
120687ac9bf3SAtari911                    $events = json_decode(file_get_contents($eventFile), true);
120796df7d3eSAtari911                    if (!is_array($events)) $events = [];
120887ac9bf3SAtari911                }
120987ac9bf3SAtari911
121087ac9bf3SAtari911                if (!isset($events[$dateKey])) {
121187ac9bf3SAtari911                    $events[$dateKey] = [];
121287ac9bf3SAtari911                }
121387ac9bf3SAtari911
121487ac9bf3SAtari911                // Create event for this occurrence
121587ac9bf3SAtari911                $eventData = [
121687ac9bf3SAtari911                    'id' => $baseId . '-' . $counter,
121787ac9bf3SAtari911                    'title' => $title,
121887ac9bf3SAtari911                    'time' => $time,
12191d05cddcSAtari911                    'endTime' => $endTime,
122087ac9bf3SAtari911                    'description' => $description,
122187ac9bf3SAtari911                    'color' => $color,
122287ac9bf3SAtari911                    'isTask' => $isTask,
122387ac9bf3SAtari911                    'completed' => false,
122487ac9bf3SAtari911                    'endDate' => $occurrenceEndDate,
122587ac9bf3SAtari911                    'recurring' => true,
122687ac9bf3SAtari911                    'recurringId' => $baseId,
122796df7d3eSAtari911                    'recurrenceType' => $recurrenceType,
122896df7d3eSAtari911                    'recurrenceInterval' => $recurrenceInterval,
122996df7d3eSAtari911                    'namespace' => $namespace,
123087ac9bf3SAtari911                    'created' => date('Y-m-d H:i:s')
123187ac9bf3SAtari911                ];
123287ac9bf3SAtari911
123396df7d3eSAtari911                // Store additional recurrence info for reference
123496df7d3eSAtari911                if ($recurrenceType === 'weekly' && !empty($weekDays)) {
123596df7d3eSAtari911                    $eventData['weekDays'] = $weekDays;
123696df7d3eSAtari911                }
123796df7d3eSAtari911                if ($recurrenceType === 'monthly') {
123896df7d3eSAtari911                    $eventData['monthlyType'] = $monthlyType;
123996df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth') {
124096df7d3eSAtari911                        $eventData['monthDay'] = $monthDay;
124196df7d3eSAtari911                    } else {
124296df7d3eSAtari911                        $eventData['ordinalWeek'] = $ordinalWeek;
124396df7d3eSAtari911                        $eventData['ordinalDay'] = $ordinalDay;
124496df7d3eSAtari911                    }
124596df7d3eSAtari911                }
124696df7d3eSAtari911
124787ac9bf3SAtari911                $events[$dateKey][] = $eventData;
124887ac9bf3SAtari911                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
124987ac9bf3SAtari911
125087ac9bf3SAtari911                $counter++;
125187ac9bf3SAtari911            }
125296df7d3eSAtari911
125396df7d3eSAtari911            // Move to next day (we check each day individually for complex patterns)
125496df7d3eSAtari911            $currentDate->modify('+1 day');
125596df7d3eSAtari911        }
125696df7d3eSAtari911    }
125796df7d3eSAtari911
125896df7d3eSAtari911    /**
125996df7d3eSAtari911     * Check if a date is the Nth occurrence of a weekday in its month
126096df7d3eSAtari911     * @param DateTime $date The date to check
126196df7d3eSAtari911     * @param int $ordinalWeek 1-5 for first-fifth, -1 for last
126296df7d3eSAtari911     * @param int $targetDayOfWeek 0=Sunday through 6=Saturday
126396df7d3eSAtari911     * @return bool
126496df7d3eSAtari911     */
126596df7d3eSAtari911    private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) {
126696df7d3eSAtari911        $currentDayOfWeek = (int)$date->format('w');
126796df7d3eSAtari911
126896df7d3eSAtari911        // First, check if it's the right day of week
126996df7d3eSAtari911        if ($currentDayOfWeek !== $targetDayOfWeek) {
127096df7d3eSAtari911            return false;
127196df7d3eSAtari911        }
127296df7d3eSAtari911
127396df7d3eSAtari911        $dayOfMonth = (int)$date->format('j');
127496df7d3eSAtari911        $daysInMonth = (int)$date->format('t');
127596df7d3eSAtari911
127696df7d3eSAtari911        if ($ordinalWeek === -1) {
127796df7d3eSAtari911            // Last occurrence: check if there's no more of this weekday in the month
127896df7d3eSAtari911            $daysRemaining = $daysInMonth - $dayOfMonth;
127996df7d3eSAtari911            return $daysRemaining < 7;
128096df7d3eSAtari911        } else {
128196df7d3eSAtari911            // Nth occurrence: check which occurrence this is
128296df7d3eSAtari911            $weekNumber = ceil($dayOfMonth / 7);
128396df7d3eSAtari911            return $weekNumber === $ordinalWeek;
128496df7d3eSAtari911        }
128587ac9bf3SAtari911    }
128687ac9bf3SAtari911
128719378907SAtari911    public function addAssets(Doku_Event $event, $param) {
128819378907SAtari911        $event->data['link'][] = array(
128919378907SAtari911            'type' => 'text/css',
129019378907SAtari911            'rel' => 'stylesheet',
129119378907SAtari911            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
129219378907SAtari911        );
129319378907SAtari911
129496df7d3eSAtari911        // Load the main calendar JavaScript
129596df7d3eSAtari911        // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues
129696df7d3eSAtari911        // The actual code is in calendar-main.js
129719378907SAtari911        $event->data['script'][] = array(
129819378907SAtari911            'type' => 'text/javascript',
129996df7d3eSAtari911            'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js'
130019378907SAtari911        );
130119378907SAtari911    }
1302e3a9f44cSAtari911    // Helper function to find an event's stored namespace
1303e3a9f44cSAtari911    private function findEventNamespace($eventId, $date, $searchNamespace) {
1304e3a9f44cSAtari911        list($year, $month, $day) = explode('-', $date);
1305e3a9f44cSAtari911
1306e3a9f44cSAtari911        // List of namespaces to check
1307e3a9f44cSAtari911        $namespacesToCheck = [''];
1308e3a9f44cSAtari911
1309e3a9f44cSAtari911        // If searchNamespace is a wildcard or multi, we need to search multiple locations
1310e3a9f44cSAtari911        if (!empty($searchNamespace)) {
1311e3a9f44cSAtari911            if (strpos($searchNamespace, ';') !== false) {
1312e3a9f44cSAtari911                // Multi-namespace - check each one
1313e3a9f44cSAtari911                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
1314e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1315e3a9f44cSAtari911            } elseif (strpos($searchNamespace, '*') !== false) {
1316e3a9f44cSAtari911                // Wildcard - need to scan directories
1317e3a9f44cSAtari911                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
1318e3a9f44cSAtari911                $namespacesToCheck = $this->findAllNamespaces($baseNs);
1319e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
1320e3a9f44cSAtari911            } else {
1321e3a9f44cSAtari911                // Single namespace
1322e3a9f44cSAtari911                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
1323e3a9f44cSAtari911            }
1324e3a9f44cSAtari911        }
1325e3a9f44cSAtari911
132696df7d3eSAtari911        $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck)));
132796df7d3eSAtari911
1328e3a9f44cSAtari911        // Search for the event in all possible namespaces
1329e3a9f44cSAtari911        foreach ($namespacesToCheck as $ns) {
1330e3a9f44cSAtari911            $dataDir = DOKU_INC . 'data/meta/';
1331e3a9f44cSAtari911            if ($ns) {
1332e3a9f44cSAtari911                $dataDir .= str_replace(':', '/', $ns) . '/';
1333e3a9f44cSAtari911            }
1334e3a9f44cSAtari911            $dataDir .= 'calendar/';
1335e3a9f44cSAtari911
1336e3a9f44cSAtari911            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1337e3a9f44cSAtari911
1338e3a9f44cSAtari911            if (file_exists($eventFile)) {
1339e3a9f44cSAtari911                $events = json_decode(file_get_contents($eventFile), true);
1340e3a9f44cSAtari911                if (isset($events[$date])) {
1341e3a9f44cSAtari911                    foreach ($events[$date] as $evt) {
1342e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
134396df7d3eSAtari911                            // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace
134496df7d3eSAtari911                            // The directory is what matters for deletion - that's where the file actually is
134596df7d3eSAtari911                            $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')");
134696df7d3eSAtari911                            return $ns;
1347e3a9f44cSAtari911                        }
1348e3a9f44cSAtari911                    }
1349e3a9f44cSAtari911                }
1350e3a9f44cSAtari911            }
1351e3a9f44cSAtari911        }
1352e3a9f44cSAtari911
135396df7d3eSAtari911        $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace");
1354e3a9f44cSAtari911        return null; // Event not found
1355e3a9f44cSAtari911    }
1356e3a9f44cSAtari911
1357e3a9f44cSAtari911    // Helper to find all namespaces under a base namespace
1358e3a9f44cSAtari911    private function findAllNamespaces($baseNamespace) {
1359e3a9f44cSAtari911        $dataDir = DOKU_INC . 'data/meta/';
1360e3a9f44cSAtari911        if ($baseNamespace) {
1361e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
1362e3a9f44cSAtari911        }
1363e3a9f44cSAtari911
1364e3a9f44cSAtari911        $namespaces = [];
1365e3a9f44cSAtari911        if ($baseNamespace) {
1366e3a9f44cSAtari911            $namespaces[] = $baseNamespace;
1367e3a9f44cSAtari911        }
1368e3a9f44cSAtari911
1369e3a9f44cSAtari911        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
1370e3a9f44cSAtari911
137196df7d3eSAtari911        $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces)));
137296df7d3eSAtari911
1373e3a9f44cSAtari911        return $namespaces;
1374e3a9f44cSAtari911    }
1375e3a9f44cSAtari911
1376e3a9f44cSAtari911    // Recursive scan for namespaces
1377e3a9f44cSAtari911    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
1378e3a9f44cSAtari911        if (!is_dir($dir)) return;
1379e3a9f44cSAtari911
1380e3a9f44cSAtari911        $items = scandir($dir);
1381e3a9f44cSAtari911        foreach ($items as $item) {
1382e3a9f44cSAtari911            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
1383e3a9f44cSAtari911
1384e3a9f44cSAtari911            $path = $dir . $item;
1385e3a9f44cSAtari911            if (is_dir($path)) {
1386e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1387e3a9f44cSAtari911                $namespaces[] = $namespace;
1388e3a9f44cSAtari911                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
1389e3a9f44cSAtari911            }
1390e3a9f44cSAtari911        }
1391e3a9f44cSAtari911    }
13929ccd446eSAtari911
13939ccd446eSAtari911    /**
13949ccd446eSAtari911     * Delete all instances of a recurring event across all months
13959ccd446eSAtari911     */
13969ccd446eSAtari911    private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) {
13979ccd446eSAtari911        // Scan all JSON files in the calendar directory
13989ccd446eSAtari911        $calendarFiles = glob($dataDir . '*.json');
13999ccd446eSAtari911
14009ccd446eSAtari911        foreach ($calendarFiles as $file) {
14019ccd446eSAtari911            $modified = false;
14029ccd446eSAtari911            $events = json_decode(file_get_contents($file), true);
14039ccd446eSAtari911
14049ccd446eSAtari911            if (!$events) continue;
14059ccd446eSAtari911
14069ccd446eSAtari911            // Check each date in the file
14079ccd446eSAtari911            foreach ($events as $date => &$dayEvents) {
14089ccd446eSAtari911                // Filter out events with matching recurringId
14099ccd446eSAtari911                $originalCount = count($dayEvents);
14109ccd446eSAtari911                $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) {
14119ccd446eSAtari911                    $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
14129ccd446eSAtari911                    return $eventRecurringId !== $recurringId;
14139ccd446eSAtari911                }));
14149ccd446eSAtari911
14159ccd446eSAtari911                if (count($dayEvents) !== $originalCount) {
14169ccd446eSAtari911                    $modified = true;
14179ccd446eSAtari911                }
14189ccd446eSAtari911
14199ccd446eSAtari911                // Remove empty dates
14209ccd446eSAtari911                if (empty($dayEvents)) {
14219ccd446eSAtari911                    unset($events[$date]);
14229ccd446eSAtari911                }
14239ccd446eSAtari911            }
14249ccd446eSAtari911
14259ccd446eSAtari911            // Save if modified
14269ccd446eSAtari911            if ($modified) {
14279ccd446eSAtari911                file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT));
14289ccd446eSAtari911            }
14299ccd446eSAtari911        }
14309ccd446eSAtari911    }
14319ccd446eSAtari911
14329ccd446eSAtari911    /**
14339ccd446eSAtari911     * Get existing event data for preserving unchanged fields during edit
14349ccd446eSAtari911     */
14359ccd446eSAtari911    private function getExistingEventData($eventId, $date, $namespace) {
14369ccd446eSAtari911        list($year, $month, $day) = explode('-', $date);
14379ccd446eSAtari911
14389ccd446eSAtari911        $dataDir = DOKU_INC . 'data/meta/';
14399ccd446eSAtari911        if ($namespace) {
14409ccd446eSAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
14419ccd446eSAtari911        }
14429ccd446eSAtari911        $dataDir .= 'calendar/';
14439ccd446eSAtari911
14449ccd446eSAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
14459ccd446eSAtari911
14469ccd446eSAtari911        if (!file_exists($eventFile)) {
14479ccd446eSAtari911            return null;
14489ccd446eSAtari911        }
14499ccd446eSAtari911
14509ccd446eSAtari911        $events = json_decode(file_get_contents($eventFile), true);
14519ccd446eSAtari911
14529ccd446eSAtari911        if (!isset($events[$date])) {
14539ccd446eSAtari911            return null;
14549ccd446eSAtari911        }
14559ccd446eSAtari911
14569ccd446eSAtari911        // Find the event by ID
14579ccd446eSAtari911        foreach ($events[$date] as $event) {
14589ccd446eSAtari911            if ($event['id'] === $eventId) {
14599ccd446eSAtari911                return $event;
14609ccd446eSAtari911            }
14619ccd446eSAtari911        }
14629ccd446eSAtari911
14639ccd446eSAtari911        return null;
14649ccd446eSAtari911    }
146519378907SAtari911}
1466