xref: /plugin/calendar/action.php (revision 7e8ea635dd19058d6f7c428adbbe02d9702096d7)
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 'toggle_task':
93                $this->toggleTaskComplete();
94                break;
95            case 'cleanup_empty_namespaces':
96            case 'trim_all_past_recurring':
97            case 'rescan_recurring':
98            case 'extend_recurring':
99            case 'trim_recurring':
100            case 'pause_recurring':
101            case 'resume_recurring':
102            case 'change_start_recurring':
103            case 'change_pattern_recurring':
104                $this->routeToAdmin($action);
105                break;
106            default:
107                echo json_encode(['success' => false, 'error' => 'Unknown action']);
108        }
109    }
110
111    /**
112     * Route AJAX actions to admin plugin methods
113     */
114    private function routeToAdmin($action) {
115        $admin = plugin_load('admin', 'calendar');
116        if ($admin && method_exists($admin, 'handleAjaxAction')) {
117            $admin->handleAjaxAction($action);
118        } else {
119            echo json_encode(['success' => false, 'error' => 'Admin handler not available']);
120        }
121    }
122
123    private function saveEvent() {
124        global $INPUT;
125
126        $namespace = $INPUT->str('namespace', '');
127        $date = $INPUT->str('date');
128        $eventId = $INPUT->str('eventId', '');
129        $title = $INPUT->str('title');
130        $time = $INPUT->str('time', '');
131        $endTime = $INPUT->str('endTime', '');
132        $description = $INPUT->str('description', '');
133        $color = $INPUT->str('color', '#3498db');
134        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
135        $isTask = $INPUT->bool('isTask', false);
136        $completed = $INPUT->bool('completed', false);
137        $endDate = $INPUT->str('endDate', '');
138        $isRecurring = $INPUT->bool('isRecurring', false);
139        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
140        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
141
142        if (!$date || !$title) {
143            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
144            return;
145        }
146
147        // Validate date format (YYYY-MM-DD)
148        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
149            echo json_encode(['success' => false, 'error' => 'Invalid date format']);
150            return;
151        }
152
153        // Validate oldDate if provided
154        if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) {
155            echo json_encode(['success' => false, 'error' => 'Invalid old date format']);
156            return;
157        }
158
159        // Validate endDate if provided
160        if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) {
161            echo json_encode(['success' => false, 'error' => 'Invalid end date format']);
162            return;
163        }
164
165        // Validate time format (HH:MM) if provided
166        if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) {
167            echo json_encode(['success' => false, 'error' => 'Invalid time format']);
168            return;
169        }
170
171        // Validate endTime format if provided
172        if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) {
173            echo json_encode(['success' => false, 'error' => 'Invalid end time format']);
174            return;
175        }
176
177        // Validate color format (hex color)
178        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
179            $color = '#3498db'; // Reset to default if invalid
180        }
181
182        // Validate namespace (prevent path traversal)
183        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
184            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
185            return;
186        }
187
188        // Validate recurrence type
189        $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
190        if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) {
191            $recurrenceType = 'weekly';
192        }
193
194        // Validate recurrenceEnd if provided
195        if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) {
196            echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']);
197            return;
198        }
199
200        // Sanitize title length
201        $title = substr(trim($title), 0, 500);
202
203        // Sanitize description length
204        $description = substr($description, 0, 10000);
205
206        // If editing, find the event's stored namespace (for finding/deleting old event)
207        $storedNamespace = '';
208        $oldNamespace = '';
209        if ($eventId) {
210            // Use oldDate if available (date was changed), otherwise use current date
211            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
212            $storedNamespace = $this->findEventNamespace($eventId, $searchDate, $namespace);
213
214            // Store the old namespace for deletion purposes
215            if ($storedNamespace !== null) {
216                $oldNamespace = $storedNamespace;
217                $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'");
218            }
219        }
220
221        // Use the namespace provided by the user (allow namespace changes!)
222        // But normalize wildcards and multi-namespace to empty for NEW events
223        if (!$eventId) {
224            $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'");
225            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
226            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
227                $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty");
228                $namespace = '';
229            } else {
230                $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'");
231            }
232        } else {
233            $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'");
234        }
235
236        // Generate event ID if new
237        $generatedId = $eventId ?: uniqid();
238
239        // If editing a recurring event, load existing data to preserve unchanged fields
240        $existingEventData = null;
241        if ($eventId && $isRecurring) {
242            $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date;
243            $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?: $namespace);
244            if ($existingEventData) {
245                $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'");
246            }
247        }
248
249        // If recurring, generate multiple events
250        if ($isRecurring) {
251            // Merge with existing data if editing (preserve values that weren't changed)
252            if ($existingEventData) {
253                $title = $title ?: $existingEventData['title'];
254                $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : '');
255                $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : '');
256                $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : '');
257                // Only use existing color if new color is default
258                if ($color === '#3498db' && isset($existingEventData['color'])) {
259                    $color = $existingEventData['color'];
260                }
261
262                // Preserve namespace in these cases:
263                // 1. Namespace field is empty (user didn't select anything)
264                // 2. Namespace contains wildcards (like "personal;work" or "work*")
265                // 3. Namespace is the same as what was passed (no change intended)
266                $receivedNamespace = $namespace;
267                if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) {
268                    if (isset($existingEventData['namespace'])) {
269                        $namespace = $existingEventData['namespace'];
270                        $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')");
271                    } else {
272                        $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')");
273                    }
274                } else {
275                    $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')");
276                }
277            } else {
278                $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'");
279            }
280
281            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $description,
282                                        $color, $isTask, $recurrenceType, $recurrenceEnd, $generatedId);
283            echo json_encode(['success' => true]);
284            return;
285        }
286
287        list($year, $month, $day) = explode('-', $date);
288
289        // NEW namespace directory (where we'll save)
290        $dataDir = DOKU_INC . 'data/meta/';
291        if ($namespace) {
292            $dataDir .= str_replace(':', '/', $namespace) . '/';
293        }
294        $dataDir .= 'calendar/';
295
296        if (!is_dir($dataDir)) {
297            mkdir($dataDir, 0755, true);
298        }
299
300        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
301
302        $events = [];
303        if (file_exists($eventFile)) {
304            $events = json_decode(file_get_contents($eventFile), true);
305        }
306
307        // If editing and (date changed OR namespace changed), remove from old location first
308        $namespaceChanged = ($eventId && $oldNamespace !== '' && $oldNamespace !== $namespace);
309        $dateChanged = ($eventId && $oldDate && $oldDate !== $date);
310
311        if ($namespaceChanged || $dateChanged) {
312            // Construct OLD data directory using OLD namespace
313            $oldDataDir = DOKU_INC . 'data/meta/';
314            if ($oldNamespace) {
315                $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/';
316            }
317            $oldDataDir .= 'calendar/';
318
319            $deleteDate = $dateChanged ? $oldDate : $date;
320            list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate);
321            $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
322
323            if (file_exists($oldEventFile)) {
324                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
325                if (isset($oldEvents[$deleteDate])) {
326                    $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) {
327                        return $evt['id'] !== $eventId;
328                    }));
329
330                    if (empty($oldEvents[$deleteDate])) {
331                        unset($oldEvents[$deleteDate]);
332                    }
333
334                    file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT));
335                    $this->debugLog("Calendar saveEvent: Deleted event from old location - namespace:'$oldNamespace', date:'$deleteDate'");
336                }
337            }
338        }
339
340        if (!isset($events[$date])) {
341            $events[$date] = [];
342        } elseif (!is_array($events[$date])) {
343            // Fix corrupted data - ensure it's an array
344            $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
345            $events[$date] = [];
346        }
347
348        // Store the namespace with the event
349        $eventData = [
350            'id' => $generatedId,
351            'title' => $title,
352            'time' => $time,
353            'endTime' => $endTime,
354            'description' => $description,
355            'color' => $color,
356            'isTask' => $isTask,
357            'completed' => $completed,
358            'endDate' => $endDate,
359            'namespace' => $namespace, // Store namespace with event
360            'created' => date('Y-m-d H:i:s')
361        ];
362
363        // Debug logging
364        $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile");
365
366        // If editing, replace existing event
367        if ($eventId) {
368            $found = false;
369            foreach ($events[$date] as $key => $evt) {
370                if ($evt['id'] === $eventId) {
371                    $events[$date][$key] = $eventData;
372                    $found = true;
373                    break;
374                }
375            }
376            if (!$found) {
377                $events[$date][] = $eventData;
378            }
379        } else {
380            $events[$date][] = $eventData;
381        }
382
383        file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
384
385        // If event spans multiple months, add it to the first day of each subsequent month
386        if ($endDate && $endDate !== $date) {
387            $startDateObj = new DateTime($date);
388            $endDateObj = new DateTime($endDate);
389
390            // Get the month/year of the start date
391            $startMonth = $startDateObj->format('Y-m');
392
393            // Iterate through each month the event spans
394            $currentDate = clone $startDateObj;
395            $currentDate->modify('first day of next month'); // Jump to first of next month
396
397            while ($currentDate <= $endDateObj) {
398                $currentMonth = $currentDate->format('Y-m');
399                $firstDayOfMonth = $currentDate->format('Y-m-01');
400
401                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
402
403                // Get the file for this month
404                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
405
406                $currentEvents = [];
407                if (file_exists($currentEventFile)) {
408                    $contents = file_get_contents($currentEventFile);
409                    $decoded = json_decode($contents, true);
410                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
411                        $currentEvents = $decoded;
412                    } else {
413                        $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
414                    }
415                }
416
417                // Add entry for the first day of this month
418                if (!isset($currentEvents[$firstDayOfMonth])) {
419                    $currentEvents[$firstDayOfMonth] = [];
420                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
421                    // Fix corrupted data - ensure it's an array
422                    $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
423                    $currentEvents[$firstDayOfMonth] = [];
424                }
425
426                // Create a copy with the original start date preserved
427                $eventDataForMonth = $eventData;
428                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
429
430                // Check if event already exists (when editing)
431                $found = false;
432                if ($eventId) {
433                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
434                        if ($evt['id'] === $eventId) {
435                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
436                            $found = true;
437                            break;
438                        }
439                    }
440                }
441
442                if (!$found) {
443                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
444                }
445
446                file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
447
448                // Move to next month
449                $currentDate->modify('first day of next month');
450            }
451        }
452
453        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
454    }
455
456    private function deleteEvent() {
457        global $INPUT;
458
459        $namespace = $INPUT->str('namespace', '');
460        $date = $INPUT->str('date');
461        $eventId = $INPUT->str('eventId');
462
463        // Find where the event actually lives
464        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
465
466        if ($storedNamespace === null) {
467            echo json_encode(['success' => false, 'error' => 'Event not found']);
468            return;
469        }
470
471        // Use the found namespace
472        $namespace = $storedNamespace;
473
474        list($year, $month, $day) = explode('-', $date);
475
476        $dataDir = DOKU_INC . 'data/meta/';
477        if ($namespace) {
478            $dataDir .= str_replace(':', '/', $namespace) . '/';
479        }
480        $dataDir .= 'calendar/';
481
482        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
483
484        // First, get the event to check if it spans multiple months or is recurring
485        $eventToDelete = null;
486        $isRecurring = false;
487        $recurringId = null;
488
489        if (file_exists($eventFile)) {
490            $events = json_decode(file_get_contents($eventFile), true);
491
492            if (isset($events[$date])) {
493                foreach ($events[$date] as $event) {
494                    if ($event['id'] === $eventId) {
495                        $eventToDelete = $event;
496                        $isRecurring = isset($event['recurring']) && $event['recurring'];
497                        $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
498                        break;
499                    }
500                }
501
502                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
503                    return $event['id'] !== $eventId;
504                }));
505
506                if (empty($events[$date])) {
507                    unset($events[$date]);
508                }
509
510                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
511            }
512        }
513
514        // If this is a recurring event, delete ALL occurrences with the same recurringId
515        if ($isRecurring && $recurringId) {
516            $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir);
517        }
518
519        // If event spans multiple months, delete it from the first day of each subsequent month
520        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
521            $startDateObj = new DateTime($date);
522            $endDateObj = new DateTime($eventToDelete['endDate']);
523
524            // Iterate through each month the event spans
525            $currentDate = clone $startDateObj;
526            $currentDate->modify('first day of next month'); // Jump to first of next month
527
528            while ($currentDate <= $endDateObj) {
529                $firstDayOfMonth = $currentDate->format('Y-m-01');
530                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
531
532                // Get the file for this month
533                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
534
535                if (file_exists($currentEventFile)) {
536                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
537
538                    if (isset($currentEvents[$firstDayOfMonth])) {
539                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
540                            return $event['id'] !== $eventId;
541                        }));
542
543                        if (empty($currentEvents[$firstDayOfMonth])) {
544                            unset($currentEvents[$firstDayOfMonth]);
545                        }
546
547                        file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
548                    }
549                }
550
551                // Move to next month
552                $currentDate->modify('first day of next month');
553            }
554        }
555
556        echo json_encode(['success' => true]);
557    }
558
559    private function getEvent() {
560        global $INPUT;
561
562        $namespace = $INPUT->str('namespace', '');
563        $date = $INPUT->str('date');
564        $eventId = $INPUT->str('eventId');
565
566        // Find where the event actually lives
567        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
568
569        if ($storedNamespace === null) {
570            echo json_encode(['success' => false, 'error' => 'Event not found']);
571            return;
572        }
573
574        // Use the found namespace
575        $namespace = $storedNamespace;
576
577        list($year, $month, $day) = explode('-', $date);
578
579        $dataDir = DOKU_INC . 'data/meta/';
580        if ($namespace) {
581            $dataDir .= str_replace(':', '/', $namespace) . '/';
582        }
583        $dataDir .= 'calendar/';
584
585        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
586
587        if (file_exists($eventFile)) {
588            $events = json_decode(file_get_contents($eventFile), true);
589
590            if (isset($events[$date])) {
591                foreach ($events[$date] as $event) {
592                    if ($event['id'] === $eventId) {
593                        // Include the namespace so JavaScript knows where this event actually lives
594                        $event['namespace'] = $namespace;
595                        echo json_encode(['success' => true, 'event' => $event]);
596                        return;
597                    }
598                }
599            }
600        }
601
602        echo json_encode(['success' => false, 'error' => 'Event not found']);
603    }
604
605    private function loadMonth() {
606        global $INPUT;
607
608        // Prevent caching of AJAX responses
609        header('Cache-Control: no-cache, no-store, must-revalidate');
610        header('Pragma: no-cache');
611        header('Expires: 0');
612
613        $namespace = $INPUT->str('namespace', '');
614        $year = $INPUT->int('year');
615        $month = $INPUT->int('month');
616
617        // Validate year (reasonable range: 1970-2100)
618        if ($year < 1970 || $year > 2100) {
619            $year = (int)date('Y');
620        }
621
622        // Validate month (1-12)
623        if ($month < 1 || $month > 12) {
624            $month = (int)date('n');
625        }
626
627        // Validate namespace format
628        if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) {
629            echo json_encode(['success' => false, 'error' => 'Invalid namespace format']);
630            return;
631        }
632
633        $this->debugLog("=== Calendar loadMonth DEBUG ===");
634        $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'");
635
636        // Check if multi-namespace or wildcard
637        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
638
639        $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
640
641        if ($isMultiNamespace) {
642            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
643        } else {
644            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
645        }
646
647        $this->debugLog("Returning " . count($events) . " date keys");
648        foreach ($events as $dateKey => $dayEvents) {
649            $this->debugLog("  dateKey=$dateKey has " . count($dayEvents) . " events");
650        }
651
652        echo json_encode([
653            'success' => true,
654            'year' => $year,
655            'month' => $month,
656            'events' => $events
657        ]);
658    }
659
660    private function loadEventsSingleNamespace($namespace, $year, $month) {
661        $dataDir = DOKU_INC . 'data/meta/';
662        if ($namespace) {
663            $dataDir .= str_replace(':', '/', $namespace) . '/';
664        }
665        $dataDir .= 'calendar/';
666
667        // Load ONLY current month
668        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
669        $events = [];
670        if (file_exists($eventFile)) {
671            $contents = file_get_contents($eventFile);
672            $decoded = json_decode($contents, true);
673            if (json_last_error() === JSON_ERROR_NONE) {
674                $events = $decoded;
675            }
676        }
677
678        return $events;
679    }
680
681    private function loadEventsMultiNamespace($namespaces, $year, $month) {
682        // Check for wildcard pattern
683        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
684            $baseNamespace = $matches[1];
685            return $this->loadEventsWildcard($baseNamespace, $year, $month);
686        }
687
688        // Check for root wildcard
689        if ($namespaces === '*') {
690            return $this->loadEventsWildcard('', $year, $month);
691        }
692
693        // Parse namespace list (semicolon separated)
694        $namespaceList = array_map('trim', explode(';', $namespaces));
695
696        // Load events from all namespaces
697        $allEvents = [];
698        foreach ($namespaceList as $ns) {
699            $ns = trim($ns);
700            if (empty($ns)) continue;
701
702            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
703
704            // Add namespace tag to each event
705            foreach ($events as $dateKey => $dayEvents) {
706                if (!isset($allEvents[$dateKey])) {
707                    $allEvents[$dateKey] = [];
708                }
709                foreach ($dayEvents as $event) {
710                    $event['_namespace'] = $ns;
711                    $allEvents[$dateKey][] = $event;
712                }
713            }
714        }
715
716        return $allEvents;
717    }
718
719    private function loadEventsWildcard($baseNamespace, $year, $month) {
720        $dataDir = DOKU_INC . 'data/meta/';
721        if ($baseNamespace) {
722            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
723        }
724
725        $allEvents = [];
726
727        // First, load events from the base namespace itself
728        $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
729
730        foreach ($events as $dateKey => $dayEvents) {
731            if (!isset($allEvents[$dateKey])) {
732                $allEvents[$dateKey] = [];
733            }
734            foreach ($dayEvents as $event) {
735                $event['_namespace'] = $baseNamespace;
736                $allEvents[$dateKey][] = $event;
737            }
738        }
739
740        // Recursively find all subdirectories
741        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
742
743        return $allEvents;
744    }
745
746    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
747        if (!is_dir($dir)) return;
748
749        $items = scandir($dir);
750        foreach ($items as $item) {
751            if ($item === '.' || $item === '..') continue;
752
753            $path = $dir . $item;
754            if (is_dir($path) && $item !== 'calendar') {
755                // This is a namespace directory
756                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
757
758                // Load events from this namespace
759                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
760                foreach ($events as $dateKey => $dayEvents) {
761                    if (!isset($allEvents[$dateKey])) {
762                        $allEvents[$dateKey] = [];
763                    }
764                    foreach ($dayEvents as $event) {
765                        $event['_namespace'] = $namespace;
766                        $allEvents[$dateKey][] = $event;
767                    }
768                }
769
770                // Recurse into subdirectories
771                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
772            }
773        }
774    }
775
776    private function toggleTaskComplete() {
777        global $INPUT;
778
779        $namespace = $INPUT->str('namespace', '');
780        $date = $INPUT->str('date');
781        $eventId = $INPUT->str('eventId');
782        $completed = $INPUT->bool('completed', false);
783
784        // Find where the event actually lives
785        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
786
787        if ($storedNamespace === null) {
788            echo json_encode(['success' => false, 'error' => 'Event not found']);
789            return;
790        }
791
792        // Use the found namespace
793        $namespace = $storedNamespace;
794
795        list($year, $month, $day) = explode('-', $date);
796
797        $dataDir = DOKU_INC . 'data/meta/';
798        if ($namespace) {
799            $dataDir .= str_replace(':', '/', $namespace) . '/';
800        }
801        $dataDir .= 'calendar/';
802
803        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
804
805        if (file_exists($eventFile)) {
806            $events = json_decode(file_get_contents($eventFile), true);
807
808            if (isset($events[$date])) {
809                foreach ($events[$date] as $key => $event) {
810                    if ($event['id'] === $eventId) {
811                        $events[$date][$key]['completed'] = $completed;
812                        break;
813                    }
814                }
815
816                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
817                echo json_encode(['success' => true, 'events' => $events]);
818                return;
819            }
820        }
821
822        echo json_encode(['success' => false, 'error' => 'Event not found']);
823    }
824
825    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time,
826                                          $description, $color, $isTask, $recurrenceType,
827                                          $recurrenceEnd, $baseId) {
828        $dataDir = DOKU_INC . 'data/meta/';
829        if ($namespace) {
830            $dataDir .= str_replace(':', '/', $namespace) . '/';
831        }
832        $dataDir .= 'calendar/';
833
834        if (!is_dir($dataDir)) {
835            mkdir($dataDir, 0755, true);
836        }
837
838        // Calculate recurrence interval
839        $interval = '';
840        switch ($recurrenceType) {
841            case 'daily': $interval = '+1 day'; break;
842            case 'weekly': $interval = '+1 week'; break;
843            case 'monthly': $interval = '+1 month'; break;
844            case 'yearly': $interval = '+1 year'; break;
845            default: $interval = '+1 week';
846        }
847
848        // Set maximum end date if not specified (1 year from start)
849        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
850
851        // Calculate event duration for multi-day events
852        $eventDuration = 0;
853        if ($endDate && $endDate !== $startDate) {
854            $start = new DateTime($startDate);
855            $end = new DateTime($endDate);
856            $eventDuration = $start->diff($end)->days;
857        }
858
859        // Generate recurring events
860        $currentDate = new DateTime($startDate);
861        $endLimit = new DateTime($maxEnd);
862        $counter = 0;
863        $maxOccurrences = 100; // Prevent infinite loops
864
865        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
866            $dateKey = $currentDate->format('Y-m-d');
867            list($year, $month, $day) = explode('-', $dateKey);
868
869            // Calculate end date for this occurrence if multi-day
870            $occurrenceEndDate = '';
871            if ($eventDuration > 0) {
872                $occurrenceEnd = clone $currentDate;
873                $occurrenceEnd->modify('+' . $eventDuration . ' days');
874                $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
875            }
876
877            // Load month file
878            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
879            $events = [];
880            if (file_exists($eventFile)) {
881                $events = json_decode(file_get_contents($eventFile), true);
882            }
883
884            if (!isset($events[$dateKey])) {
885                $events[$dateKey] = [];
886            }
887
888            // Create event for this occurrence
889            $eventData = [
890                'id' => $baseId . '-' . $counter,
891                'title' => $title,
892                'time' => $time,
893                'endTime' => $endTime,
894                'description' => $description,
895                'color' => $color,
896                'isTask' => $isTask,
897                'completed' => false,
898                'endDate' => $occurrenceEndDate,
899                'recurring' => true,
900                'recurringId' => $baseId,
901                'namespace' => $namespace,  // Add namespace!
902                'created' => date('Y-m-d H:i:s')
903            ];
904
905            $events[$dateKey][] = $eventData;
906            file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
907
908            // Move to next occurrence
909            $currentDate->modify($interval);
910            $counter++;
911        }
912    }
913
914    public function addAssets(Doku_Event $event, $param) {
915        $event->data['link'][] = array(
916            'type' => 'text/css',
917            'rel' => 'stylesheet',
918            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
919        );
920
921        $event->data['script'][] = array(
922            'type' => 'text/javascript',
923            'src' => DOKU_BASE . 'lib/plugins/calendar/script.js'
924        );
925    }
926    // Helper function to find an event's stored namespace
927    private function findEventNamespace($eventId, $date, $searchNamespace) {
928        list($year, $month, $day) = explode('-', $date);
929
930        // List of namespaces to check
931        $namespacesToCheck = [''];
932
933        // If searchNamespace is a wildcard or multi, we need to search multiple locations
934        if (!empty($searchNamespace)) {
935            if (strpos($searchNamespace, ';') !== false) {
936                // Multi-namespace - check each one
937                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
938                $namespacesToCheck[] = ''; // Also check default
939            } elseif (strpos($searchNamespace, '*') !== false) {
940                // Wildcard - need to scan directories
941                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
942                $namespacesToCheck = $this->findAllNamespaces($baseNs);
943                $namespacesToCheck[] = ''; // Also check default
944            } else {
945                // Single namespace
946                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
947            }
948        }
949
950        // Search for the event in all possible namespaces
951        foreach ($namespacesToCheck as $ns) {
952            $dataDir = DOKU_INC . 'data/meta/';
953            if ($ns) {
954                $dataDir .= str_replace(':', '/', $ns) . '/';
955            }
956            $dataDir .= 'calendar/';
957
958            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
959
960            if (file_exists($eventFile)) {
961                $events = json_decode(file_get_contents($eventFile), true);
962                if (isset($events[$date])) {
963                    foreach ($events[$date] as $evt) {
964                        if ($evt['id'] === $eventId) {
965                            // Found the event! Return its stored namespace
966                            return isset($evt['namespace']) ? $evt['namespace'] : $ns;
967                        }
968                    }
969                }
970            }
971        }
972
973        return null; // Event not found
974    }
975
976    // Helper to find all namespaces under a base namespace
977    private function findAllNamespaces($baseNamespace) {
978        $dataDir = DOKU_INC . 'data/meta/';
979        if ($baseNamespace) {
980            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
981        }
982
983        $namespaces = [];
984        if ($baseNamespace) {
985            $namespaces[] = $baseNamespace;
986        }
987
988        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
989
990        return $namespaces;
991    }
992
993    // Recursive scan for namespaces
994    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
995        if (!is_dir($dir)) return;
996
997        $items = scandir($dir);
998        foreach ($items as $item) {
999            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
1000
1001            $path = $dir . $item;
1002            if (is_dir($path)) {
1003                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1004                $namespaces[] = $namespace;
1005                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
1006            }
1007        }
1008    }
1009
1010    /**
1011     * Delete all instances of a recurring event across all months
1012     */
1013    private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) {
1014        // Scan all JSON files in the calendar directory
1015        $calendarFiles = glob($dataDir . '*.json');
1016
1017        foreach ($calendarFiles as $file) {
1018            $modified = false;
1019            $events = json_decode(file_get_contents($file), true);
1020
1021            if (!$events) continue;
1022
1023            // Check each date in the file
1024            foreach ($events as $date => &$dayEvents) {
1025                // Filter out events with matching recurringId
1026                $originalCount = count($dayEvents);
1027                $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) {
1028                    $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null;
1029                    return $eventRecurringId !== $recurringId;
1030                }));
1031
1032                if (count($dayEvents) !== $originalCount) {
1033                    $modified = true;
1034                }
1035
1036                // Remove empty dates
1037                if (empty($dayEvents)) {
1038                    unset($events[$date]);
1039                }
1040            }
1041
1042            // Save if modified
1043            if ($modified) {
1044                file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT));
1045            }
1046        }
1047    }
1048
1049    /**
1050     * Get existing event data for preserving unchanged fields during edit
1051     */
1052    private function getExistingEventData($eventId, $date, $namespace) {
1053        list($year, $month, $day) = explode('-', $date);
1054
1055        $dataDir = DOKU_INC . 'data/meta/';
1056        if ($namespace) {
1057            $dataDir .= str_replace(':', '/', $namespace) . '/';
1058        }
1059        $dataDir .= 'calendar/';
1060
1061        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1062
1063        if (!file_exists($eventFile)) {
1064            return null;
1065        }
1066
1067        $events = json_decode(file_get_contents($eventFile), true);
1068
1069        if (!isset($events[$date])) {
1070            return null;
1071        }
1072
1073        // Find the event by ID
1074        foreach ($events[$date] as $event) {
1075            if ($event['id'] === $eventId) {
1076                return $event;
1077            }
1078        }
1079
1080        return null;
1081    }
1082}
1083