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