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