xref: /plugin/calendar/action.php (revision e3a9f44ce79ec1754946340aa2b4e60f3e5583ec)
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
11class action_plugin_calendar extends DokuWiki_Action_Plugin {
12
13    public function register(Doku_Event_Handler $controller) {
14        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax');
15        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets');
16    }
17
18    public function handleAjax(Doku_Event $event, $param) {
19        if ($event->data !== 'plugin_calendar') return;
20        $event->preventDefault();
21        $event->stopPropagation();
22
23        $action = $_REQUEST['action'] ?? '';
24
25        switch ($action) {
26            case 'save_event':
27                $this->saveEvent();
28                break;
29            case 'delete_event':
30                $this->deleteEvent();
31                break;
32            case 'get_event':
33                $this->getEvent();
34                break;
35            case 'load_month':
36                $this->loadMonth();
37                break;
38            case 'toggle_task':
39                $this->toggleTaskComplete();
40                break;
41            default:
42                echo json_encode(['success' => false, 'error' => 'Unknown action']);
43        }
44    }
45
46    private function saveEvent() {
47        global $INPUT;
48
49        $namespace = $INPUT->str('namespace', '');
50        $date = $INPUT->str('date');
51        $eventId = $INPUT->str('eventId', '');
52        $title = $INPUT->str('title');
53        $time = $INPUT->str('time', '');
54        $description = $INPUT->str('description', '');
55        $color = $INPUT->str('color', '#3498db');
56        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
57        $isTask = $INPUT->bool('isTask', false);
58        $completed = $INPUT->bool('completed', false);
59        $endDate = $INPUT->str('endDate', '');
60        $isRecurring = $INPUT->bool('isRecurring', false);
61        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
62        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
63
64        if (!$date || !$title) {
65            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
66            return;
67        }
68
69        // If editing, find the event's stored namespace first (before normalizing)
70        $storedNamespace = '';
71        if ($eventId) {
72            $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
73        }
74
75        // Use stored namespace if editing, otherwise normalize wildcards to empty
76        if ($eventId && $storedNamespace !== null) {
77            $namespace = $storedNamespace;
78        } else {
79            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
80            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
81                $namespace = '';
82            }
83        }
84
85        // Generate event ID if new
86        $generatedId = $eventId ?: uniqid();
87
88        // If recurring, generate multiple events
89        if ($isRecurring) {
90            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $description,
91                                        $color, $isTask, $recurrenceType, $recurrenceEnd, $generatedId);
92            echo json_encode(['success' => true]);
93            return;
94        }
95
96        list($year, $month, $day) = explode('-', $date);
97
98        $dataDir = DOKU_INC . 'data/meta/';
99        if ($namespace) {
100            $dataDir .= str_replace(':', '/', $namespace) . '/';
101        }
102        $dataDir .= 'calendar/';
103
104        if (!is_dir($dataDir)) {
105            mkdir($dataDir, 0755, true);
106        }
107
108        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
109
110        $events = [];
111        if (file_exists($eventFile)) {
112            $events = json_decode(file_get_contents($eventFile), true);
113        }
114
115        // If editing and date changed, remove from old date first
116        if ($eventId && $oldDate && $oldDate !== $date) {
117            list($oldYear, $oldMonth, $oldDay) = explode('-', $oldDate);
118            $oldEventFile = $dataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
119
120            if (file_exists($oldEventFile)) {
121                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
122                if (isset($oldEvents[$oldDate])) {
123                    $oldEvents[$oldDate] = array_values(array_filter($oldEvents[$oldDate], function($evt) use ($eventId) {
124                        return $evt['id'] !== $eventId;
125                    }));
126
127                    if (empty($oldEvents[$oldDate])) {
128                        unset($oldEvents[$oldDate]);
129                    }
130
131                    file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT));
132                }
133            }
134        }
135
136        if (!isset($events[$date])) {
137            $events[$date] = [];
138        } elseif (!is_array($events[$date])) {
139            // Fix corrupted data - ensure it's an array
140            error_log("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
141            $events[$date] = [];
142        }
143
144        // Store the namespace with the event
145        $eventData = [
146            'id' => $generatedId,
147            'title' => $title,
148            'time' => $time,
149            'description' => $description,
150            'color' => $color,
151            'isTask' => $isTask,
152            'completed' => $completed,
153            'endDate' => $endDate,
154            'namespace' => $namespace, // Store namespace with event
155            'created' => date('Y-m-d H:i:s')
156        ];
157
158        // If editing, replace existing event
159        if ($eventId) {
160            $found = false;
161            foreach ($events[$date] as $key => $evt) {
162                if ($evt['id'] === $eventId) {
163                    $events[$date][$key] = $eventData;
164                    $found = true;
165                    break;
166                }
167            }
168            if (!$found) {
169                $events[$date][] = $eventData;
170            }
171        } else {
172            $events[$date][] = $eventData;
173        }
174
175        file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
176
177        // If event spans multiple months, add it to the first day of each subsequent month
178        if ($endDate && $endDate !== $date) {
179            $startDateObj = new DateTime($date);
180            $endDateObj = new DateTime($endDate);
181
182            // Get the month/year of the start date
183            $startMonth = $startDateObj->format('Y-m');
184
185            // Iterate through each month the event spans
186            $currentDate = clone $startDateObj;
187            $currentDate->modify('first day of next month'); // Jump to first of next month
188
189            while ($currentDate <= $endDateObj) {
190                $currentMonth = $currentDate->format('Y-m');
191                $firstDayOfMonth = $currentDate->format('Y-m-01');
192
193                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
194
195                // Get the file for this month
196                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
197
198                $currentEvents = [];
199                if (file_exists($currentEventFile)) {
200                    $contents = file_get_contents($currentEventFile);
201                    $decoded = json_decode($contents, true);
202                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
203                        $currentEvents = $decoded;
204                    } else {
205                        error_log("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
206                    }
207                }
208
209                // Add entry for the first day of this month
210                if (!isset($currentEvents[$firstDayOfMonth])) {
211                    $currentEvents[$firstDayOfMonth] = [];
212                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
213                    // Fix corrupted data - ensure it's an array
214                    error_log("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
215                    $currentEvents[$firstDayOfMonth] = [];
216                }
217
218                // Create a copy with the original start date preserved
219                $eventDataForMonth = $eventData;
220                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
221
222                // Check if event already exists (when editing)
223                $found = false;
224                if ($eventId) {
225                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
226                        if ($evt['id'] === $eventId) {
227                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
228                            $found = true;
229                            break;
230                        }
231                    }
232                }
233
234                if (!$found) {
235                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
236                }
237
238                file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
239
240                // Move to next month
241                $currentDate->modify('first day of next month');
242            }
243        }
244
245        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
246    }
247
248    private function deleteEvent() {
249        global $INPUT;
250
251        $namespace = $INPUT->str('namespace', '');
252        $date = $INPUT->str('date');
253        $eventId = $INPUT->str('eventId');
254
255        // Find where the event actually lives
256        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
257
258        if ($storedNamespace === null) {
259            echo json_encode(['success' => false, 'error' => 'Event not found']);
260            return;
261        }
262
263        // Use the found namespace
264        $namespace = $storedNamespace;
265
266        list($year, $month, $day) = explode('-', $date);
267
268        $dataDir = DOKU_INC . 'data/meta/';
269        if ($namespace) {
270            $dataDir .= str_replace(':', '/', $namespace) . '/';
271        }
272        $dataDir .= 'calendar/';
273
274        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
275
276        // First, get the event to check if it spans multiple months
277        $eventToDelete = null;
278        if (file_exists($eventFile)) {
279            $events = json_decode(file_get_contents($eventFile), true);
280
281            if (isset($events[$date])) {
282                foreach ($events[$date] as $event) {
283                    if ($event['id'] === $eventId) {
284                        $eventToDelete = $event;
285                        break;
286                    }
287                }
288
289                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
290                    return $event['id'] !== $eventId;
291                }));
292
293                if (empty($events[$date])) {
294                    unset($events[$date]);
295                }
296
297                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
298            }
299        }
300
301        // If event spans multiple months, delete it from the first day of each subsequent month
302        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
303            $startDateObj = new DateTime($date);
304            $endDateObj = new DateTime($eventToDelete['endDate']);
305
306            // Iterate through each month the event spans
307            $currentDate = clone $startDateObj;
308            $currentDate->modify('first day of next month'); // Jump to first of next month
309
310            while ($currentDate <= $endDateObj) {
311                $firstDayOfMonth = $currentDate->format('Y-m-01');
312                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
313
314                // Get the file for this month
315                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
316
317                if (file_exists($currentEventFile)) {
318                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
319
320                    if (isset($currentEvents[$firstDayOfMonth])) {
321                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
322                            return $event['id'] !== $eventId;
323                        }));
324
325                        if (empty($currentEvents[$firstDayOfMonth])) {
326                            unset($currentEvents[$firstDayOfMonth]);
327                        }
328
329                        file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
330                    }
331                }
332
333                // Move to next month
334                $currentDate->modify('first day of next month');
335            }
336        }
337
338        echo json_encode(['success' => true]);
339    }
340
341    private function getEvent() {
342        global $INPUT;
343
344        $namespace = $INPUT->str('namespace', '');
345        $date = $INPUT->str('date');
346        $eventId = $INPUT->str('eventId');
347
348        // Find where the event actually lives
349        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
350
351        if ($storedNamespace === null) {
352            echo json_encode(['success' => false, 'error' => 'Event not found']);
353            return;
354        }
355
356        // Use the found namespace
357        $namespace = $storedNamespace;
358
359        list($year, $month, $day) = explode('-', $date);
360
361        $dataDir = DOKU_INC . 'data/meta/';
362        if ($namespace) {
363            $dataDir .= str_replace(':', '/', $namespace) . '/';
364        }
365        $dataDir .= 'calendar/';
366
367        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
368
369        if (file_exists($eventFile)) {
370            $events = json_decode(file_get_contents($eventFile), true);
371
372            if (isset($events[$date])) {
373                foreach ($events[$date] as $event) {
374                    if ($event['id'] === $eventId) {
375                        echo json_encode(['success' => true, 'event' => $event]);
376                        return;
377                    }
378                }
379            }
380        }
381
382        echo json_encode(['success' => false, 'error' => 'Event not found']);
383    }
384
385    private function loadMonth() {
386        global $INPUT;
387
388        // Prevent caching of AJAX responses
389        header('Cache-Control: no-cache, no-store, must-revalidate');
390        header('Pragma: no-cache');
391        header('Expires: 0');
392
393        $namespace = $INPUT->str('namespace', '');
394        $year = $INPUT->int('year');
395        $month = $INPUT->int('month');
396
397        error_log("=== Calendar loadMonth DEBUG ===");
398        error_log("Requested: year=$year, month=$month, namespace='$namespace'");
399
400        // Check if multi-namespace or wildcard
401        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
402
403        error_log("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
404
405        if ($isMultiNamespace) {
406            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
407        } else {
408            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
409        }
410
411        error_log("Returning " . count($events) . " date keys");
412        foreach ($events as $dateKey => $dayEvents) {
413            error_log("  dateKey=$dateKey has " . count($dayEvents) . " events");
414        }
415
416        echo json_encode([
417            'success' => true,
418            'year' => $year,
419            'month' => $month,
420            'events' => $events
421        ]);
422    }
423
424    private function loadEventsSingleNamespace($namespace, $year, $month) {
425        $dataDir = DOKU_INC . 'data/meta/';
426        if ($namespace) {
427            $dataDir .= str_replace(':', '/', $namespace) . '/';
428        }
429        $dataDir .= 'calendar/';
430
431        // Load ONLY current month
432        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
433        $events = [];
434        if (file_exists($eventFile)) {
435            $contents = file_get_contents($eventFile);
436            $decoded = json_decode($contents, true);
437            if (json_last_error() === JSON_ERROR_NONE) {
438                $events = $decoded;
439            }
440        }
441
442        return $events;
443    }
444
445    private function loadEventsMultiNamespace($namespaces, $year, $month) {
446        // Check for wildcard pattern
447        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
448            $baseNamespace = $matches[1];
449            return $this->loadEventsWildcard($baseNamespace, $year, $month);
450        }
451
452        // Check for root wildcard
453        if ($namespaces === '*') {
454            return $this->loadEventsWildcard('', $year, $month);
455        }
456
457        // Parse namespace list (semicolon separated)
458        $namespaceList = array_map('trim', explode(';', $namespaces));
459
460        // Load events from all namespaces
461        $allEvents = [];
462        foreach ($namespaceList as $ns) {
463            $ns = trim($ns);
464            if (empty($ns)) continue;
465
466            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
467
468            // Add namespace tag to each event
469            foreach ($events as $dateKey => $dayEvents) {
470                if (!isset($allEvents[$dateKey])) {
471                    $allEvents[$dateKey] = [];
472                }
473                foreach ($dayEvents as $event) {
474                    $event['_namespace'] = $ns;
475                    $allEvents[$dateKey][] = $event;
476                }
477            }
478        }
479
480        return $allEvents;
481    }
482
483    private function loadEventsWildcard($baseNamespace, $year, $month) {
484        $dataDir = DOKU_INC . 'data/meta/';
485        if ($baseNamespace) {
486            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
487        }
488
489        $allEvents = [];
490
491        // First, load events from the base namespace itself
492        $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
493
494        foreach ($events as $dateKey => $dayEvents) {
495            if (!isset($allEvents[$dateKey])) {
496                $allEvents[$dateKey] = [];
497            }
498            foreach ($dayEvents as $event) {
499                $event['_namespace'] = $baseNamespace;
500                $allEvents[$dateKey][] = $event;
501            }
502        }
503
504        // Recursively find all subdirectories
505        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
506
507        return $allEvents;
508    }
509
510    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
511        if (!is_dir($dir)) return;
512
513        $items = scandir($dir);
514        foreach ($items as $item) {
515            if ($item === '.' || $item === '..') continue;
516
517            $path = $dir . $item;
518            if (is_dir($path) && $item !== 'calendar') {
519                // This is a namespace directory
520                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
521
522                // Load events from this namespace
523                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
524                foreach ($events as $dateKey => $dayEvents) {
525                    if (!isset($allEvents[$dateKey])) {
526                        $allEvents[$dateKey] = [];
527                    }
528                    foreach ($dayEvents as $event) {
529                        $event['_namespace'] = $namespace;
530                        $allEvents[$dateKey][] = $event;
531                    }
532                }
533
534                // Recurse into subdirectories
535                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
536            }
537        }
538    }
539
540    private function toggleTaskComplete() {
541        global $INPUT;
542
543        $namespace = $INPUT->str('namespace', '');
544        $date = $INPUT->str('date');
545        $eventId = $INPUT->str('eventId');
546        $completed = $INPUT->bool('completed', false);
547
548        // Find where the event actually lives
549        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
550
551        if ($storedNamespace === null) {
552            echo json_encode(['success' => false, 'error' => 'Event not found']);
553            return;
554        }
555
556        // Use the found namespace
557        $namespace = $storedNamespace;
558
559        list($year, $month, $day) = explode('-', $date);
560
561        $dataDir = DOKU_INC . 'data/meta/';
562        if ($namespace) {
563            $dataDir .= str_replace(':', '/', $namespace) . '/';
564        }
565        $dataDir .= 'calendar/';
566
567        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
568
569        if (file_exists($eventFile)) {
570            $events = json_decode(file_get_contents($eventFile), true);
571
572            if (isset($events[$date])) {
573                foreach ($events[$date] as $key => $event) {
574                    if ($event['id'] === $eventId) {
575                        $events[$date][$key]['completed'] = $completed;
576                        break;
577                    }
578                }
579
580                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
581                echo json_encode(['success' => true, 'events' => $events]);
582                return;
583            }
584        }
585
586        echo json_encode(['success' => false, 'error' => 'Event not found']);
587    }
588
589    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time,
590                                          $description, $color, $isTask, $recurrenceType,
591                                          $recurrenceEnd, $baseId) {
592        $dataDir = DOKU_INC . 'data/meta/';
593        if ($namespace) {
594            $dataDir .= str_replace(':', '/', $namespace) . '/';
595        }
596        $dataDir .= 'calendar/';
597
598        if (!is_dir($dataDir)) {
599            mkdir($dataDir, 0755, true);
600        }
601
602        // Calculate recurrence interval
603        $interval = '';
604        switch ($recurrenceType) {
605            case 'daily': $interval = '+1 day'; break;
606            case 'weekly': $interval = '+1 week'; break;
607            case 'monthly': $interval = '+1 month'; break;
608            case 'yearly': $interval = '+1 year'; break;
609            default: $interval = '+1 week';
610        }
611
612        // Set maximum end date if not specified (1 year from start)
613        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
614
615        // Calculate event duration for multi-day events
616        $eventDuration = 0;
617        if ($endDate && $endDate !== $startDate) {
618            $start = new DateTime($startDate);
619            $end = new DateTime($endDate);
620            $eventDuration = $start->diff($end)->days;
621        }
622
623        // Generate recurring events
624        $currentDate = new DateTime($startDate);
625        $endLimit = new DateTime($maxEnd);
626        $counter = 0;
627        $maxOccurrences = 100; // Prevent infinite loops
628
629        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
630            $dateKey = $currentDate->format('Y-m-d');
631            list($year, $month, $day) = explode('-', $dateKey);
632
633            // Calculate end date for this occurrence if multi-day
634            $occurrenceEndDate = '';
635            if ($eventDuration > 0) {
636                $occurrenceEnd = clone $currentDate;
637                $occurrenceEnd->modify('+' . $eventDuration . ' days');
638                $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
639            }
640
641            // Load month file
642            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
643            $events = [];
644            if (file_exists($eventFile)) {
645                $events = json_decode(file_get_contents($eventFile), true);
646            }
647
648            if (!isset($events[$dateKey])) {
649                $events[$dateKey] = [];
650            }
651
652            // Create event for this occurrence
653            $eventData = [
654                'id' => $baseId . '-' . $counter,
655                'title' => $title,
656                'time' => $time,
657                'description' => $description,
658                'color' => $color,
659                'isTask' => $isTask,
660                'completed' => false,
661                'endDate' => $occurrenceEndDate,
662                'recurring' => true,
663                'recurringId' => $baseId,
664                'created' => date('Y-m-d H:i:s')
665            ];
666
667            $events[$dateKey][] = $eventData;
668            file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
669
670            // Move to next occurrence
671            $currentDate->modify($interval);
672            $counter++;
673        }
674    }
675
676    public function addAssets(Doku_Event $event, $param) {
677        $event->data['link'][] = array(
678            'type' => 'text/css',
679            'rel' => 'stylesheet',
680            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
681        );
682
683        $event->data['script'][] = array(
684            'type' => 'text/javascript',
685            'src' => DOKU_BASE . 'lib/plugins/calendar/script.js'
686        );
687    }
688    // Helper function to find an event's stored namespace
689    private function findEventNamespace($eventId, $date, $searchNamespace) {
690        list($year, $month, $day) = explode('-', $date);
691
692        // List of namespaces to check
693        $namespacesToCheck = [''];
694
695        // If searchNamespace is a wildcard or multi, we need to search multiple locations
696        if (!empty($searchNamespace)) {
697            if (strpos($searchNamespace, ';') !== false) {
698                // Multi-namespace - check each one
699                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
700                $namespacesToCheck[] = ''; // Also check default
701            } elseif (strpos($searchNamespace, '*') !== false) {
702                // Wildcard - need to scan directories
703                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
704                $namespacesToCheck = $this->findAllNamespaces($baseNs);
705                $namespacesToCheck[] = ''; // Also check default
706            } else {
707                // Single namespace
708                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
709            }
710        }
711
712        // Search for the event in all possible namespaces
713        foreach ($namespacesToCheck as $ns) {
714            $dataDir = DOKU_INC . 'data/meta/';
715            if ($ns) {
716                $dataDir .= str_replace(':', '/', $ns) . '/';
717            }
718            $dataDir .= 'calendar/';
719
720            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
721
722            if (file_exists($eventFile)) {
723                $events = json_decode(file_get_contents($eventFile), true);
724                if (isset($events[$date])) {
725                    foreach ($events[$date] as $evt) {
726                        if ($evt['id'] === $eventId) {
727                            // Found the event! Return its stored namespace
728                            return isset($evt['namespace']) ? $evt['namespace'] : $ns;
729                        }
730                    }
731                }
732            }
733        }
734
735        return null; // Event not found
736    }
737
738    // Helper to find all namespaces under a base namespace
739    private function findAllNamespaces($baseNamespace) {
740        $dataDir = DOKU_INC . 'data/meta/';
741        if ($baseNamespace) {
742            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
743        }
744
745        $namespaces = [];
746        if ($baseNamespace) {
747            $namespaces[] = $baseNamespace;
748        }
749
750        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
751
752        return $namespaces;
753    }
754
755    // Recursive scan for namespaces
756    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
757        if (!is_dir($dir)) return;
758
759        $items = scandir($dir);
760        foreach ($items as $item) {
761            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
762
763            $path = $dir . $item;
764            if (is_dir($path)) {
765                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
766                $namespaces[] = $namespace;
767                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
768            }
769        }
770    }
771}
772