xref: /plugin/calendar/action.php (revision 96df7d3e9a825dddf459ab1ee6077a9886837f17)
1<?php
2/**
3 * DokuWiki Plugin calendar (Action Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  DokuWiki Community
7 */
8
9if (!defined('DOKU_INC')) die();
10
11// Set to true to enable verbose debug logging (should be false in production)
12if (!defined('CALENDAR_DEBUG')) {
13    define('CALENDAR_DEBUG', false);
14}
15
16class action_plugin_calendar extends DokuWiki_Action_Plugin {
17
18    /**
19     * Log debug message only if CALENDAR_DEBUG is enabled
20     */
21    private function debugLog($message) {
22        if (CALENDAR_DEBUG) {
23            error_log($message);
24        }
25    }
26
27    /**
28     * Safely read and decode a JSON file with error handling
29     * @param string $filepath Path to JSON file
30     * @return array Decoded array or empty array on error
31     */
32    private function safeJsonRead($filepath) {
33        if (!file_exists($filepath)) {
34            return [];
35        }
36
37        $contents = @file_get_contents($filepath);
38        if ($contents === false) {
39            $this->debugLog("Failed to read file: $filepath");
40            return [];
41        }
42
43        $decoded = json_decode($contents, true);
44        if (json_last_error() !== JSON_ERROR_NONE) {
45            $this->debugLog("JSON decode error in $filepath: " . json_last_error_msg());
46            return [];
47        }
48
49        return is_array($decoded) ? $decoded : [];
50    }
51
52    public function register(Doku_Event_Handler $controller) {
53        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax');
54        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets');
55    }
56
57    public function handleAjax(Doku_Event $event, $param) {
58        if ($event->data !== 'plugin_calendar') return;
59        $event->preventDefault();
60        $event->stopPropagation();
61
62        $action = $_REQUEST['action'] ?? '';
63
64        // Actions that modify data require CSRF token verification
65        $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces',
66                         'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring',
67                         'trim_recurring', 'pause_recurring', 'resume_recurring',
68                         'change_start_recurring', 'change_pattern_recurring'];
69
70        if (in_array($action, $writeActions)) {
71            // Check for valid security token
72            $sectok = $_REQUEST['sectok'] ?? '';
73            if (!checkSecurityToken($sectok)) {
74                echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']);
75                return;
76            }
77        }
78
79        switch ($action) {
80            case 'save_event':
81                $this->saveEvent();
82                break;
83            case 'delete_event':
84                $this->deleteEvent();
85                break;
86            case 'get_event':
87                $this->getEvent();
88                break;
89            case 'load_month':
90                $this->loadMonth();
91                break;
92            case 'search_all':
93                $this->searchAllDates();
94                break;
95            case 'toggle_task':
96                $this->toggleTaskComplete();
97                break;
98            case 'cleanup_empty_namespaces':
99            case 'trim_all_past_recurring':
100            case 'rescan_recurring':
101            case 'extend_recurring':
102            case 'trim_recurring':
103            case 'pause_recurring':
104            case 'resume_recurring':
105            case 'change_start_recurring':
106            case 'change_pattern_recurring':
107                $this->routeToAdmin($action);
108                break;
109            default:
110                echo json_encode(['success' => false, 'error' => 'Unknown action']);
111        }
112    }
113
114    /**
115     * Route AJAX actions to admin plugin methods
116     */
117    private function routeToAdmin($action) {
118        $admin = plugin_load('admin', 'calendar');
119        if ($admin && method_exists($admin, 'handleAjaxAction')) {
120            $admin->handleAjaxAction($action);
121        } else {
122            echo json_encode(['success' => false, 'error' => 'Admin handler not available']);
123        }
124    }
125
126    private function saveEvent() {
127        global $INPUT;
128
129        $namespace = $INPUT->str('namespace', '');
130        $date = $INPUT->str('date');
131        $eventId = $INPUT->str('eventId', '');
132        $title = $INPUT->str('title');
133        $time = $INPUT->str('time', '');
134        $endTime = $INPUT->str('endTime', '');
135        $description = $INPUT->str('description', '');
136        $color = $INPUT->str('color', '#3498db');
137        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
138        $isTask = $INPUT->bool('isTask', false);
139        $completed = $INPUT->bool('completed', false);
140        $endDate = $INPUT->str('endDate', '');
141        $isRecurring = $INPUT->bool('isRecurring', false);
142        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
143        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
144
145        // New recurrence options
146        $recurrenceInterval = $INPUT->int('recurrenceInterval', 1);
147        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
148        if ($recurrenceInterval > 99) $recurrenceInterval = 99;
149
150        $weekDaysStr = $INPUT->str('weekDays', '');
151        $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : [];
152
153        $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth');
154        $monthDay = $INPUT->int('monthDay', 0);
155        $ordinalWeek = $INPUT->int('ordinalWeek', 1);
156        $ordinalDay = $INPUT->int('ordinalDay', 0);
157
158        $this->debugLog("=== Calendar saveEvent START ===");
159        $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'");
160        $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'");
161
162        if (!$date || !$title) {
163            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
164            return;
165        }
166
167        // Validate date format (YYYY-MM-DD)
168        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
169            echo json_encode(['success' => false, 'error' => 'Invalid date format']);
170            return;
171        }
172
173        // Validate oldDate if provided
174        if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) {
175            echo json_encode(['success' => false, 'error' => 'Invalid old date format']);
176            return;
177        }
178
179        // Validate endDate if provided
180        if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) {
181            echo json_encode(['success' => false, 'error' => 'Invalid end date format']);
182            return;
183        }
184
185        // Validate time format (HH:MM) if provided
186        if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) {
187            echo json_encode(['success' => false, 'error' => 'Invalid time format']);
188            return;
189        }
190
191        // Validate endTime format if provided
192        if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) {
193            echo json_encode(['success' => false, 'error' => 'Invalid end time format']);
194            return;
195        }
196
197        // Validate color format (hex color)
198        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
199            $color = '#3498db'; // Reset to default if invalid
200        }
201
202        // Validate namespace (prevent path traversal)
203        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
204            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
205            return;
206        }
207
208        // Validate recurrence type
209        $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
210        if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) {
211            $recurrenceType = 'weekly';
212        }
213
214        // Validate recurrenceEnd if provided
215        if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) {
216            echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']);
217            return;
218        }
219
220        // Sanitize title length
221        $title = substr(trim($title), 0, 500);
222
223        // Sanitize description length
224        $description = substr($description, 0, 10000);
225
226        // If editing, find the event's ACTUAL namespace (for finding/deleting old event)
227        // We need to search ALL namespaces because user may be changing namespace
228        $oldNamespace = null;  // null means "not found yet"
229        if ($eventId) {
230            // Use oldDate if available (date was changed), otherwise use current date
231            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
232
233            // Search using wildcard to find event in ANY namespace
234            $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*');
235
236            if ($foundNamespace !== null) {
237                $oldNamespace = $foundNamespace;  // Could be '' for default namespace
238                $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'");
239            } else {
240                $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace");
241            }
242        }
243
244        // Use the namespace provided by the user (allow namespace changes!)
245        // But normalize wildcards and multi-namespace to empty for NEW events
246        if (!$eventId) {
247            $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'");
248            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
249            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
250                $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty");
251                $namespace = '';
252            } else {
253                $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'");
254            }
255        } else {
256            $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'");
257        }
258
259        // Generate event ID if new
260        $generatedId = $eventId ?: uniqid();
261
262        // If editing a recurring event, load existing data to preserve unchanged fields
263        $existingEventData = null;
264        if ($eventId && $isRecurring) {
265            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
266            // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use ''
267            $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace);
268            if ($existingEventData) {
269                $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'");
270            }
271        }
272
273        // If recurring, generate multiple events
274        if ($isRecurring) {
275            // Merge with existing data if editing (preserve values that weren't changed)
276            if ($existingEventData) {
277                $title = $title ?: $existingEventData['title'];
278                $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : '');
279                $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : '');
280                $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : '');
281                // Only use existing color if new color is default
282                if ($color === '#3498db' && isset($existingEventData['color'])) {
283                    $color = $existingEventData['color'];
284                }
285
286                // Preserve namespace in these cases:
287                // 1. Namespace field is empty (user didn't select anything)
288                // 2. Namespace contains wildcards (like "personal;work" or "work*")
289                // 3. Namespace is the same as what was passed (no change intended)
290                $receivedNamespace = $namespace;
291                if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) {
292                    if (isset($existingEventData['namespace'])) {
293                        $namespace = $existingEventData['namespace'];
294                        $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')");
295                    } else {
296                        $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')");
297                    }
298                } else {
299                    $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')");
300                }
301            } else {
302                $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'");
303            }
304
305            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description,
306                                        $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd,
307                                        $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId);
308            echo json_encode(['success' => true]);
309            return;
310        }
311
312        list($year, $month, $day) = explode('-', $date);
313
314        // NEW namespace directory (where we'll save)
315        $dataDir = DOKU_INC . 'data/meta/';
316        if ($namespace) {
317            $dataDir .= str_replace(':', '/', $namespace) . '/';
318        }
319        $dataDir .= 'calendar/';
320
321        if (!is_dir($dataDir)) {
322            mkdir($dataDir, 0755, true);
323        }
324
325        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
326
327        $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'");
328
329        $events = [];
330        if (file_exists($eventFile)) {
331            $events = json_decode(file_get_contents($eventFile), true);
332            $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location");
333        } else {
334            $this->debugLog("Calendar saveEvent: New location file does not exist yet");
335        }
336
337        // If editing and (date changed OR namespace changed), remove from old location first
338        // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace
339        $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace);
340        $dateChanged = ($eventId && $oldDate && $oldDate !== $date);
341
342        $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO'));
343
344        if ($namespaceChanged || $dateChanged) {
345            // Construct OLD data directory using OLD namespace
346            $oldDataDir = DOKU_INC . 'data/meta/';
347            if ($oldNamespace) {
348                $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/';
349            }
350            $oldDataDir .= 'calendar/';
351
352            $deleteDate = $dateChanged ? $oldDate : $date;
353            list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate);
354            $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
355
356            $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'");
357
358            if (file_exists($oldEventFile)) {
359                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
360                $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates");
361
362                if (isset($oldEvents[$deleteDate])) {
363                    $countBefore = count($oldEvents[$deleteDate]);
364                    $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) {
365                        return $evt['id'] !== $eventId;
366                    }));
367                    $countAfter = count($oldEvents[$deleteDate]);
368
369                    $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter");
370
371                    if (empty($oldEvents[$deleteDate])) {
372                        unset($oldEvents[$deleteDate]);
373                    }
374
375                    file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT));
376                    $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'");
377                } else {
378                    $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file");
379                }
380            } else {
381                $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile");
382            }
383        } else {
384            $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location");
385        }
386
387        if (!isset($events[$date])) {
388            $events[$date] = [];
389        } elseif (!is_array($events[$date])) {
390            // Fix corrupted data - ensure it's an array
391            $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
392            $events[$date] = [];
393        }
394
395        // Store the namespace with the event
396        $eventData = [
397            'id' => $generatedId,
398            'title' => $title,
399            'time' => $time,
400            'endTime' => $endTime,
401            'description' => $description,
402            'color' => $color,
403            'isTask' => $isTask,
404            'completed' => $completed,
405            'endDate' => $endDate,
406            'namespace' => $namespace, // Store namespace with event
407            'created' => date('Y-m-d H:i:s')
408        ];
409
410        // Debug logging
411        $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile");
412
413        // If editing, replace existing event
414        if ($eventId) {
415            $found = false;
416            foreach ($events[$date] as $key => $evt) {
417                if ($evt['id'] === $eventId) {
418                    $events[$date][$key] = $eventData;
419                    $found = true;
420                    break;
421                }
422            }
423            if (!$found) {
424                $events[$date][] = $eventData;
425            }
426        } else {
427            $events[$date][] = $eventData;
428        }
429
430        file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
431
432        // If event spans multiple months, add it to the first day of each subsequent month
433        if ($endDate && $endDate !== $date) {
434            $startDateObj = new DateTime($date);
435            $endDateObj = new DateTime($endDate);
436
437            // Get the month/year of the start date
438            $startMonth = $startDateObj->format('Y-m');
439
440            // Iterate through each month the event spans
441            $currentDate = clone $startDateObj;
442            $currentDate->modify('first day of next month'); // Jump to first of next month
443
444            while ($currentDate <= $endDateObj) {
445                $currentMonth = $currentDate->format('Y-m');
446                $firstDayOfMonth = $currentDate->format('Y-m-01');
447
448                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
449
450                // Get the file for this month
451                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
452
453                $currentEvents = [];
454                if (file_exists($currentEventFile)) {
455                    $contents = file_get_contents($currentEventFile);
456                    $decoded = json_decode($contents, true);
457                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
458                        $currentEvents = $decoded;
459                    } else {
460                        $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
461                    }
462                }
463
464                // Add entry for the first day of this month
465                if (!isset($currentEvents[$firstDayOfMonth])) {
466                    $currentEvents[$firstDayOfMonth] = [];
467                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
468                    // Fix corrupted data - ensure it's an array
469                    $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
470                    $currentEvents[$firstDayOfMonth] = [];
471                }
472
473                // Create a copy with the original start date preserved
474                $eventDataForMonth = $eventData;
475                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
476
477                // Check if event already exists (when editing)
478                $found = false;
479                if ($eventId) {
480                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
481                        if ($evt['id'] === $eventId) {
482                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
483                            $found = true;
484                            break;
485                        }
486                    }
487                }
488
489                if (!$found) {
490                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
491                }
492
493                file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
494
495                // Move to next month
496                $currentDate->modify('first day of next month');
497            }
498        }
499
500        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
501    }
502
503    private function deleteEvent() {
504        global $INPUT;
505
506        $namespace = $INPUT->str('namespace', '');
507        $date = $INPUT->str('date');
508        $eventId = $INPUT->str('eventId');
509
510        // Find where the event actually lives
511        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
512
513        if ($storedNamespace === null) {
514            echo json_encode(['success' => false, 'error' => 'Event not found']);
515            return;
516        }
517
518        // Use the found namespace
519        $namespace = $storedNamespace;
520
521        list($year, $month, $day) = explode('-', $date);
522
523        $dataDir = DOKU_INC . 'data/meta/';
524        if ($namespace) {
525            $dataDir .= str_replace(':', '/', $namespace) . '/';
526        }
527        $dataDir .= 'calendar/';
528
529        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
530
531        // First, get the event to check if it spans multiple months or is recurring
532        $eventToDelete = null;
533        $isRecurring = false;
534        $recurringId = null;
535
536        if (file_exists($eventFile)) {
537            $events = json_decode(file_get_contents($eventFile), true);
538
539            if (isset($events[$date])) {
540                foreach ($events[$date] as $event) {
541                    if ($event['id'] === $eventId) {
542                        $eventToDelete = $event;
543                        $isRecurring = isset($event['recurring']) && $event['recurring'];
544                        $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
545                        break;
546                    }
547                }
548
549                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
550                    return $event['id'] !== $eventId;
551                }));
552
553                if (empty($events[$date])) {
554                    unset($events[$date]);
555                }
556
557                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
558            }
559        }
560
561        // If this is a recurring event, delete ALL occurrences with the same recurringId
562        if ($isRecurring && $recurringId) {
563            $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir);
564        }
565
566        // If event spans multiple months, delete it from the first day of each subsequent month
567        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
568            $startDateObj = new DateTime($date);
569            $endDateObj = new DateTime($eventToDelete['endDate']);
570
571            // Iterate through each month the event spans
572            $currentDate = clone $startDateObj;
573            $currentDate->modify('first day of next month'); // Jump to first of next month
574
575            while ($currentDate <= $endDateObj) {
576                $firstDayOfMonth = $currentDate->format('Y-m-01');
577                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
578
579                // Get the file for this month
580                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
581
582                if (file_exists($currentEventFile)) {
583                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
584
585                    if (isset($currentEvents[$firstDayOfMonth])) {
586                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
587                            return $event['id'] !== $eventId;
588                        }));
589
590                        if (empty($currentEvents[$firstDayOfMonth])) {
591                            unset($currentEvents[$firstDayOfMonth]);
592                        }
593
594                        file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
595                    }
596                }
597
598                // Move to next month
599                $currentDate->modify('first day of next month');
600            }
601        }
602
603        echo json_encode(['success' => true]);
604    }
605
606    private function getEvent() {
607        global $INPUT;
608
609        $namespace = $INPUT->str('namespace', '');
610        $date = $INPUT->str('date');
611        $eventId = $INPUT->str('eventId');
612
613        // Find where the event actually lives
614        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
615
616        if ($storedNamespace === null) {
617            echo json_encode(['success' => false, 'error' => 'Event not found']);
618            return;
619        }
620
621        // Use the found namespace
622        $namespace = $storedNamespace;
623
624        list($year, $month, $day) = explode('-', $date);
625
626        $dataDir = DOKU_INC . 'data/meta/';
627        if ($namespace) {
628            $dataDir .= str_replace(':', '/', $namespace) . '/';
629        }
630        $dataDir .= 'calendar/';
631
632        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
633
634        if (file_exists($eventFile)) {
635            $events = json_decode(file_get_contents($eventFile), true);
636
637            if (isset($events[$date])) {
638                foreach ($events[$date] as $event) {
639                    if ($event['id'] === $eventId) {
640                        // Include the namespace so JavaScript knows where this event actually lives
641                        $event['namespace'] = $namespace;
642                        echo json_encode(['success' => true, 'event' => $event]);
643                        return;
644                    }
645                }
646            }
647        }
648
649        echo json_encode(['success' => false, 'error' => 'Event not found']);
650    }
651
652    private function loadMonth() {
653        global $INPUT;
654
655        // Prevent caching of AJAX responses
656        header('Cache-Control: no-cache, no-store, must-revalidate');
657        header('Pragma: no-cache');
658        header('Expires: 0');
659
660        $namespace = $INPUT->str('namespace', '');
661        $year = $INPUT->int('year');
662        $month = $INPUT->int('month');
663
664        // Validate year (reasonable range: 1970-2100)
665        if ($year < 1970 || $year > 2100) {
666            $year = (int)date('Y');
667        }
668
669        // Validate month (1-12)
670        if ($month < 1 || $month > 12) {
671            $month = (int)date('n');
672        }
673
674        // Validate namespace format
675        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
676            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
677            return;
678        }
679
680        $this->debugLog("=== Calendar loadMonth DEBUG ===");
681        $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'");
682
683        // Check if multi-namespace or wildcard
684        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
685
686        $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
687
688        if ($isMultiNamespace) {
689            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
690        } else {
691            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
692        }
693
694        $this->debugLog("Returning " . count($events) . " date keys");
695        foreach ($events as $dateKey => $dayEvents) {
696            $this->debugLog("  dateKey=$dateKey has " . count($dayEvents) . " events");
697        }
698
699        echo json_encode([
700            'success' => true,
701            'year' => $year,
702            'month' => $month,
703            'events' => $events
704        ]);
705    }
706
707    private function loadEventsSingleNamespace($namespace, $year, $month) {
708        $dataDir = DOKU_INC . 'data/meta/';
709        if ($namespace) {
710            $dataDir .= str_replace(':', '/', $namespace) . '/';
711        }
712        $dataDir .= 'calendar/';
713
714        // Load ONLY current month
715        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
716        $events = [];
717        if (file_exists($eventFile)) {
718            $contents = file_get_contents($eventFile);
719            $decoded = json_decode($contents, true);
720            if (json_last_error() === JSON_ERROR_NONE) {
721                $events = $decoded;
722            }
723        }
724
725        return $events;
726    }
727
728    private function loadEventsMultiNamespace($namespaces, $year, $month) {
729        // Check for wildcard pattern
730        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
731            $baseNamespace = $matches[1];
732            return $this->loadEventsWildcard($baseNamespace, $year, $month);
733        }
734
735        // Check for root wildcard
736        if ($namespaces === '*') {
737            return $this->loadEventsWildcard('', $year, $month);
738        }
739
740        // Parse namespace list (semicolon separated)
741        $namespaceList = array_map('trim', explode(';', $namespaces));
742
743        // Load events from all namespaces
744        $allEvents = [];
745        foreach ($namespaceList as $ns) {
746            $ns = trim($ns);
747            if (empty($ns)) continue;
748
749            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
750
751            // Add namespace tag to each event
752            foreach ($events as $dateKey => $dayEvents) {
753                if (!isset($allEvents[$dateKey])) {
754                    $allEvents[$dateKey] = [];
755                }
756                foreach ($dayEvents as $event) {
757                    $event['_namespace'] = $ns;
758                    $allEvents[$dateKey][] = $event;
759                }
760            }
761        }
762
763        return $allEvents;
764    }
765
766    private function loadEventsWildcard($baseNamespace, $year, $month) {
767        $dataDir = DOKU_INC . 'data/meta/';
768        if ($baseNamespace) {
769            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
770        }
771
772        $allEvents = [];
773
774        // First, load events from the base namespace itself
775        $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
776
777        foreach ($events as $dateKey => $dayEvents) {
778            if (!isset($allEvents[$dateKey])) {
779                $allEvents[$dateKey] = [];
780            }
781            foreach ($dayEvents as $event) {
782                $event['_namespace'] = $baseNamespace;
783                $allEvents[$dateKey][] = $event;
784            }
785        }
786
787        // Recursively find all subdirectories
788        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
789
790        return $allEvents;
791    }
792
793    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
794        if (!is_dir($dir)) return;
795
796        $items = scandir($dir);
797        foreach ($items as $item) {
798            if ($item === '.' || $item === '..') continue;
799
800            $path = $dir . $item;
801            if (is_dir($path) && $item !== 'calendar') {
802                // This is a namespace directory
803                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
804
805                // Load events from this namespace
806                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
807                foreach ($events as $dateKey => $dayEvents) {
808                    if (!isset($allEvents[$dateKey])) {
809                        $allEvents[$dateKey] = [];
810                    }
811                    foreach ($dayEvents as $event) {
812                        $event['_namespace'] = $namespace;
813                        $allEvents[$dateKey][] = $event;
814                    }
815                }
816
817                // Recurse into subdirectories
818                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
819            }
820        }
821    }
822
823    /**
824     * Search all dates for events matching the search term
825     */
826    private function searchAllDates() {
827        global $INPUT;
828
829        $searchTerm = strtolower(trim($INPUT->str('search', '')));
830        $namespace = $INPUT->str('namespace', '');
831
832        if (strlen($searchTerm) < 2) {
833            echo json_encode(['success' => false, 'error' => 'Search term too short']);
834            return;
835        }
836
837        // Normalize search term for fuzzy matching
838        $normalizedSearch = $this->normalizeForSearch($searchTerm);
839
840        $results = [];
841        $dataDir = DOKU_INC . 'data/meta/';
842
843        // Helper to search calendar directory
844        $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results) {
845            if (!is_dir($calDir)) return;
846
847            foreach (glob($calDir . '/*.json') as $file) {
848                $data = @json_decode(file_get_contents($file), true);
849                if (!$data || !is_array($data)) continue;
850
851                foreach ($data as $dateKey => $dayEvents) {
852                    // Skip non-date keys
853                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
854                    if (!is_array($dayEvents)) continue;
855
856                    foreach ($dayEvents as $event) {
857                        if (!isset($event['title'])) continue;
858
859                        // Build searchable text
860                        $searchableText = strtolower($event['title']);
861                        if (isset($event['description'])) {
862                            $searchableText .= ' ' . strtolower($event['description']);
863                        }
864
865                        // Normalize for fuzzy matching
866                        $normalizedText = $this->normalizeForSearch($searchableText);
867
868                        // Check if matches using fuzzy match
869                        if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) {
870                            $results[] = [
871                                'date' => $dateKey,
872                                'title' => $event['title'],
873                                'time' => isset($event['time']) ? $event['time'] : '',
874                                'endTime' => isset($event['endTime']) ? $event['endTime'] : '',
875                                'color' => isset($event['color']) ? $event['color'] : '',
876                                'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace,
877                                'id' => isset($event['id']) ? $event['id'] : ''
878                            ];
879                        }
880                    }
881                }
882            }
883        };
884
885        // Search root calendar directory
886        $searchCalendarDir($dataDir . 'calendar', '');
887
888        // Search namespace directories
889        $this->searchNamespaceDirs($dataDir, $searchCalendarDir);
890
891        // Sort results by date (newest first for past, oldest first for future)
892        usort($results, function($a, $b) {
893            return strcmp($a['date'], $b['date']);
894        });
895
896        // Limit results
897        $results = array_slice($results, 0, 50);
898
899        echo json_encode([
900            'success' => true,
901            'results' => $results,
902            'total' => count($results)
903        ]);
904    }
905
906    /**
907     * Check if normalized text matches normalized search term
908     * Supports multi-word search where all words must be present
909     */
910    private function fuzzyMatchText($normalizedText, $normalizedSearch) {
911        // Direct substring match
912        if (strpos($normalizedText, $normalizedSearch) !== false) {
913            return true;
914        }
915
916        // Multi-word search: all words must be present
917        $searchWords = array_filter(explode(' ', $normalizedSearch));
918        if (count($searchWords) > 1) {
919            foreach ($searchWords as $word) {
920                if (strlen($word) > 0 && strpos($normalizedText, $word) === false) {
921                    return false;
922                }
923            }
924            return true;
925        }
926
927        return false;
928    }
929
930    /**
931     * Normalize text for fuzzy search matching
932     * Removes apostrophes, extra spaces, and common variations
933     */
934    private function normalizeForSearch($text) {
935        // Convert to lowercase
936        $text = strtolower($text);
937
938        // Remove apostrophes and quotes (father's -> fathers)
939        $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text);
940
941        // Normalize dashes and underscores to spaces
942        $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text);
943
944        // Remove other punctuation but keep letters, numbers, spaces
945        $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text);
946
947        // Normalize multiple spaces to single space
948        $text = preg_replace('/\s+/', ' ', $text);
949
950        // Trim
951        $text = trim($text);
952
953        return $text;
954    }
955
956    /**
957     * Recursively search namespace directories for calendar data
958     */
959    private function searchNamespaceDirs($baseDir, $callback) {
960        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
961            $name = basename($nsDir);
962            if ($name === 'calendar') continue;
963
964            $calDir = $nsDir . '/calendar';
965            if (is_dir($calDir)) {
966                $relPath = str_replace(DOKU_INC . 'data/meta/', '', $nsDir);
967                $namespace = str_replace('/', ':', $relPath);
968                $callback($calDir, $namespace);
969            }
970
971            // Recurse
972            $this->searchNamespaceDirs($nsDir . '/', $callback);
973        }
974    }
975
976    private function toggleTaskComplete() {
977        global $INPUT;
978
979        $namespace = $INPUT->str('namespace', '');
980        $date = $INPUT->str('date');
981        $eventId = $INPUT->str('eventId');
982        $completed = $INPUT->bool('completed', false);
983
984        // Find where the event actually lives
985        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
986
987        if ($storedNamespace === null) {
988            echo json_encode(['success' => false, 'error' => 'Event not found']);
989            return;
990        }
991
992        // Use the found namespace
993        $namespace = $storedNamespace;
994
995        list($year, $month, $day) = explode('-', $date);
996
997        $dataDir = DOKU_INC . 'data/meta/';
998        if ($namespace) {
999            $dataDir .= str_replace(':', '/', $namespace) . '/';
1000        }
1001        $dataDir .= 'calendar/';
1002
1003        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1004
1005        if (file_exists($eventFile)) {
1006            $events = json_decode(file_get_contents($eventFile), true);
1007
1008            if (isset($events[$date])) {
1009                foreach ($events[$date] as $key => $event) {
1010                    if ($event['id'] === $eventId) {
1011                        $events[$date][$key]['completed'] = $completed;
1012                        break;
1013                    }
1014                }
1015
1016                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
1017                echo json_encode(['success' => true, 'events' => $events]);
1018                return;
1019            }
1020        }
1021
1022        echo json_encode(['success' => false, 'error' => 'Event not found']);
1023    }
1024
1025    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime,
1026                                          $description, $color, $isTask, $recurrenceType, $recurrenceInterval,
1027                                          $recurrenceEnd, $weekDays, $monthlyType, $monthDay,
1028                                          $ordinalWeek, $ordinalDay, $baseId) {
1029        $dataDir = DOKU_INC . 'data/meta/';
1030        if ($namespace) {
1031            $dataDir .= str_replace(':', '/', $namespace) . '/';
1032        }
1033        $dataDir .= 'calendar/';
1034
1035        if (!is_dir($dataDir)) {
1036            mkdir($dataDir, 0755, true);
1037        }
1038
1039        // Ensure interval is at least 1
1040        if ($recurrenceInterval < 1) $recurrenceInterval = 1;
1041
1042        // Set maximum end date if not specified (1 year from start)
1043        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
1044
1045        // Calculate event duration for multi-day events
1046        $eventDuration = 0;
1047        if ($endDate && $endDate !== $startDate) {
1048            $start = new DateTime($startDate);
1049            $end = new DateTime($endDate);
1050            $eventDuration = $start->diff($end)->days;
1051        }
1052
1053        // Generate recurring events
1054        $currentDate = new DateTime($startDate);
1055        $endLimit = new DateTime($maxEnd);
1056        $counter = 0;
1057        $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year)
1058
1059        // For weekly with specific days, we need to track the interval counter differently
1060        $weekCounter = 0;
1061        $startWeekNumber = (int)$currentDate->format('W');
1062        $startYear = (int)$currentDate->format('Y');
1063
1064        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
1065            $shouldCreateEvent = false;
1066
1067            switch ($recurrenceType) {
1068                case 'daily':
1069                    // Every N days from start
1070                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
1071                    $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0);
1072                    break;
1073
1074                case 'weekly':
1075                    // Every N weeks, on specified days
1076                    $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat
1077
1078                    // Calculate weeks since start
1079                    $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days;
1080                    $weeksSinceStart = floor($daysSinceStart / 7);
1081
1082                    // Check if we're in the right week (every N weeks)
1083                    $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0);
1084
1085                    // Check if this day is selected
1086                    $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays);
1087
1088                    // For the first week, only include days on or after the start date
1089                    $isOnOrAfterStart = ($currentDate >= new DateTime($startDate));
1090
1091                    $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart;
1092                    break;
1093
1094                case 'monthly':
1095                    // Calculate months since start
1096                    $startDT = new DateTime($startDate);
1097                    $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) +
1098                                        ($currentDate->format('n') - $startDT->format('n'));
1099
1100                    // Check if we're in the right month (every N months)
1101                    $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0);
1102
1103                    if (!$isCorrectMonth) {
1104                        // Skip to first day of next potential month
1105                        $currentDate->modify('first day of next month');
1106                        continue 2;
1107                    }
1108
1109                    if ($monthlyType === 'dayOfMonth') {
1110                        // Specific day of month (e.g., 15th)
1111                        $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j');
1112                        $currentDay = (int)$currentDate->format('j');
1113                        $daysInMonth = (int)$currentDate->format('t');
1114
1115                        // If target day exceeds days in month, use last day
1116                        $effectiveTargetDay = min($targetDay, $daysInMonth);
1117                        $shouldCreateEvent = ($currentDay === $effectiveTargetDay);
1118                    } else {
1119                        // Ordinal weekday (e.g., 2nd Wednesday, last Friday)
1120                        $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay);
1121                    }
1122                    break;
1123
1124                case 'yearly':
1125                    // Every N years on same month/day
1126                    $startDT = new DateTime($startDate);
1127                    $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y');
1128
1129                    // Check if we're in the right year
1130                    $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0);
1131
1132                    // Check if it's the same month and day
1133                    $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d'));
1134
1135                    $shouldCreateEvent = $isCorrectYear && $sameMonthDay;
1136                    break;
1137
1138                default:
1139                    $shouldCreateEvent = false;
1140            }
1141
1142            if ($shouldCreateEvent) {
1143                $dateKey = $currentDate->format('Y-m-d');
1144                list($year, $month, $day) = explode('-', $dateKey);
1145
1146                // Calculate end date for this occurrence if multi-day
1147                $occurrenceEndDate = '';
1148                if ($eventDuration > 0) {
1149                    $occurrenceEnd = clone $currentDate;
1150                    $occurrenceEnd->modify('+' . $eventDuration . ' days');
1151                    $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
1152                }
1153
1154                // Load month file
1155                $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1156                $events = [];
1157                if (file_exists($eventFile)) {
1158                    $events = json_decode(file_get_contents($eventFile), true);
1159                    if (!is_array($events)) $events = [];
1160                }
1161
1162                if (!isset($events[$dateKey])) {
1163                    $events[$dateKey] = [];
1164                }
1165
1166                // Create event for this occurrence
1167                $eventData = [
1168                    'id' => $baseId . '-' . $counter,
1169                    'title' => $title,
1170                    'time' => $time,
1171                    'endTime' => $endTime,
1172                    'description' => $description,
1173                    'color' => $color,
1174                    'isTask' => $isTask,
1175                    'completed' => false,
1176                    'endDate' => $occurrenceEndDate,
1177                    'recurring' => true,
1178                    'recurringId' => $baseId,
1179                    'recurrenceType' => $recurrenceType,
1180                    'recurrenceInterval' => $recurrenceInterval,
1181                    'namespace' => $namespace,
1182                    'created' => date('Y-m-d H:i:s')
1183                ];
1184
1185                // Store additional recurrence info for reference
1186                if ($recurrenceType === 'weekly' && !empty($weekDays)) {
1187                    $eventData['weekDays'] = $weekDays;
1188                }
1189                if ($recurrenceType === 'monthly') {
1190                    $eventData['monthlyType'] = $monthlyType;
1191                    if ($monthlyType === 'dayOfMonth') {
1192                        $eventData['monthDay'] = $monthDay;
1193                    } else {
1194                        $eventData['ordinalWeek'] = $ordinalWeek;
1195                        $eventData['ordinalDay'] = $ordinalDay;
1196                    }
1197                }
1198
1199                $events[$dateKey][] = $eventData;
1200                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
1201
1202                $counter++;
1203            }
1204
1205            // Move to next day (we check each day individually for complex patterns)
1206            $currentDate->modify('+1 day');
1207        }
1208    }
1209
1210    /**
1211     * Check if a date is the Nth occurrence of a weekday in its month
1212     * @param DateTime $date The date to check
1213     * @param int $ordinalWeek 1-5 for first-fifth, -1 for last
1214     * @param int $targetDayOfWeek 0=Sunday through 6=Saturday
1215     * @return bool
1216     */
1217    private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) {
1218        $currentDayOfWeek = (int)$date->format('w');
1219
1220        // First, check if it's the right day of week
1221        if ($currentDayOfWeek !== $targetDayOfWeek) {
1222            return false;
1223        }
1224
1225        $dayOfMonth = (int)$date->format('j');
1226        $daysInMonth = (int)$date->format('t');
1227
1228        if ($ordinalWeek === -1) {
1229            // Last occurrence: check if there's no more of this weekday in the month
1230            $daysRemaining = $daysInMonth - $dayOfMonth;
1231            return $daysRemaining < 7;
1232        } else {
1233            // Nth occurrence: check which occurrence this is
1234            $weekNumber = ceil($dayOfMonth / 7);
1235            return $weekNumber === $ordinalWeek;
1236        }
1237    }
1238
1239    public function addAssets(Doku_Event $event, $param) {
1240        $event->data['link'][] = array(
1241            'type' => 'text/css',
1242            'rel' => 'stylesheet',
1243            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
1244        );
1245
1246        // Load the main calendar JavaScript
1247        // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues
1248        // The actual code is in calendar-main.js
1249        $event->data['script'][] = array(
1250            'type' => 'text/javascript',
1251            'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js'
1252        );
1253    }
1254    // Helper function to find an event's stored namespace
1255    private function findEventNamespace($eventId, $date, $searchNamespace) {
1256        list($year, $month, $day) = explode('-', $date);
1257
1258        // List of namespaces to check
1259        $namespacesToCheck = [''];
1260
1261        // If searchNamespace is a wildcard or multi, we need to search multiple locations
1262        if (!empty($searchNamespace)) {
1263            if (strpos($searchNamespace, ';') !== false) {
1264                // Multi-namespace - check each one
1265                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
1266                $namespacesToCheck[] = ''; // Also check default
1267            } elseif (strpos($searchNamespace, '*') !== false) {
1268                // Wildcard - need to scan directories
1269                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
1270                $namespacesToCheck = $this->findAllNamespaces($baseNs);
1271                $namespacesToCheck[] = ''; // Also check default
1272            } else {
1273                // Single namespace
1274                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
1275            }
1276        }
1277
1278        $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck)));
1279
1280        // Search for the event in all possible namespaces
1281        foreach ($namespacesToCheck as $ns) {
1282            $dataDir = DOKU_INC . 'data/meta/';
1283            if ($ns) {
1284                $dataDir .= str_replace(':', '/', $ns) . '/';
1285            }
1286            $dataDir .= 'calendar/';
1287
1288            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1289
1290            if (file_exists($eventFile)) {
1291                $events = json_decode(file_get_contents($eventFile), true);
1292                if (isset($events[$date])) {
1293                    foreach ($events[$date] as $evt) {
1294                        if ($evt['id'] === $eventId) {
1295                            // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace
1296                            // The directory is what matters for deletion - that's where the file actually is
1297                            $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')");
1298                            return $ns;
1299                        }
1300                    }
1301                }
1302            }
1303        }
1304
1305        $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace");
1306        return null; // Event not found
1307    }
1308
1309    // Helper to find all namespaces under a base namespace
1310    private function findAllNamespaces($baseNamespace) {
1311        $dataDir = DOKU_INC . 'data/meta/';
1312        if ($baseNamespace) {
1313            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
1314        }
1315
1316        $namespaces = [];
1317        if ($baseNamespace) {
1318            $namespaces[] = $baseNamespace;
1319        }
1320
1321        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
1322
1323        $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces)));
1324
1325        return $namespaces;
1326    }
1327
1328    // Recursive scan for namespaces
1329    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
1330        if (!is_dir($dir)) return;
1331
1332        $items = scandir($dir);
1333        foreach ($items as $item) {
1334            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
1335
1336            $path = $dir . $item;
1337            if (is_dir($path)) {
1338                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1339                $namespaces[] = $namespace;
1340                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
1341            }
1342        }
1343    }
1344
1345    /**
1346     * Delete all instances of a recurring event across all months
1347     */
1348    private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) {
1349        // Scan all JSON files in the calendar directory
1350        $calendarFiles = glob($dataDir . '*.json');
1351
1352        foreach ($calendarFiles as $file) {
1353            $modified = false;
1354            $events = json_decode(file_get_contents($file), true);
1355
1356            if (!$events) continue;
1357
1358            // Check each date in the file
1359            foreach ($events as $date => &$dayEvents) {
1360                // Filter out events with matching recurringId
1361                $originalCount = count($dayEvents);
1362                $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) {
1363                    $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
1364                    return $eventRecurringId !== $recurringId;
1365                }));
1366
1367                if (count($dayEvents) !== $originalCount) {
1368                    $modified = true;
1369                }
1370
1371                // Remove empty dates
1372                if (empty($dayEvents)) {
1373                    unset($events[$date]);
1374                }
1375            }
1376
1377            // Save if modified
1378            if ($modified) {
1379                file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT));
1380            }
1381        }
1382    }
1383
1384    /**
1385     * Get existing event data for preserving unchanged fields during edit
1386     */
1387    private function getExistingEventData($eventId, $date, $namespace) {
1388        list($year, $month, $day) = explode('-', $date);
1389
1390        $dataDir = DOKU_INC . 'data/meta/';
1391        if ($namespace) {
1392            $dataDir .= str_replace(':', '/', $namespace) . '/';
1393        }
1394        $dataDir .= 'calendar/';
1395
1396        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1397
1398        if (!file_exists($eventFile)) {
1399            return null;
1400        }
1401
1402        $events = json_decode(file_get_contents($eventFile), true);
1403
1404        if (!isset($events[$date])) {
1405            return null;
1406        }
1407
1408        // Find the event by ID
1409        foreach ($events[$date] as $event) {
1410            if ($event['id'] === $eventId) {
1411                return $event;
1412            }
1413        }
1414
1415        return null;
1416    }
1417}
1418