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