xref: /plugin/calendar/action.php (revision e3a9f44ce79ec1754946340aa2b4e60f3e5583ec)
119378907SAtari911<?php
219378907SAtari911/**
319378907SAtari911 * DokuWiki Plugin calendar (Action Component)
419378907SAtari911 *
519378907SAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
619378907SAtari911 * @author  DokuWiki Community
719378907SAtari911 */
819378907SAtari911
919378907SAtari911if (!defined('DOKU_INC')) die();
1019378907SAtari911
1119378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin {
1219378907SAtari911
1319378907SAtari911    public function register(Doku_Event_Handler $controller) {
1419378907SAtari911        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax');
1519378907SAtari911        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets');
1619378907SAtari911    }
1719378907SAtari911
1819378907SAtari911    public function handleAjax(Doku_Event $event, $param) {
1919378907SAtari911        if ($event->data !== 'plugin_calendar') return;
2019378907SAtari911        $event->preventDefault();
2119378907SAtari911        $event->stopPropagation();
2219378907SAtari911
2319378907SAtari911        $action = $_REQUEST['action'] ?? '';
2419378907SAtari911
2519378907SAtari911        switch ($action) {
2619378907SAtari911            case 'save_event':
2719378907SAtari911                $this->saveEvent();
2819378907SAtari911                break;
2919378907SAtari911            case 'delete_event':
3019378907SAtari911                $this->deleteEvent();
3119378907SAtari911                break;
3219378907SAtari911            case 'get_event':
3319378907SAtari911                $this->getEvent();
3419378907SAtari911                break;
3519378907SAtari911            case 'load_month':
3619378907SAtari911                $this->loadMonth();
3719378907SAtari911                break;
3819378907SAtari911            case 'toggle_task':
3919378907SAtari911                $this->toggleTaskComplete();
4019378907SAtari911                break;
4119378907SAtari911            default:
4219378907SAtari911                echo json_encode(['success' => false, 'error' => 'Unknown action']);
4319378907SAtari911        }
4419378907SAtari911    }
4519378907SAtari911
4619378907SAtari911    private function saveEvent() {
4719378907SAtari911        global $INPUT;
4819378907SAtari911
4919378907SAtari911        $namespace = $INPUT->str('namespace', '');
5019378907SAtari911        $date = $INPUT->str('date');
5119378907SAtari911        $eventId = $INPUT->str('eventId', '');
5219378907SAtari911        $title = $INPUT->str('title');
5319378907SAtari911        $time = $INPUT->str('time', '');
5419378907SAtari911        $description = $INPUT->str('description', '');
5519378907SAtari911        $color = $INPUT->str('color', '#3498db');
5619378907SAtari911        $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves
5719378907SAtari911        $isTask = $INPUT->bool('isTask', false);
5819378907SAtari911        $completed = $INPUT->bool('completed', false);
5919378907SAtari911        $endDate = $INPUT->str('endDate', '');
6087ac9bf3SAtari911        $isRecurring = $INPUT->bool('isRecurring', false);
6187ac9bf3SAtari911        $recurrenceType = $INPUT->str('recurrenceType', 'weekly');
6287ac9bf3SAtari911        $recurrenceEnd = $INPUT->str('recurrenceEnd', '');
6319378907SAtari911
6419378907SAtari911        if (!$date || !$title) {
6519378907SAtari911            echo json_encode(['success' => false, 'error' => 'Missing required fields']);
6619378907SAtari911            return;
6719378907SAtari911        }
6819378907SAtari911
69*e3a9f44cSAtari911        // If editing, find the event's stored namespace first (before normalizing)
70*e3a9f44cSAtari911        $storedNamespace = '';
71*e3a9f44cSAtari911        if ($eventId) {
72*e3a9f44cSAtari911            $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
73*e3a9f44cSAtari911        }
74*e3a9f44cSAtari911
75*e3a9f44cSAtari911        // Use stored namespace if editing, otherwise normalize wildcards to empty
76*e3a9f44cSAtari911        if ($eventId && $storedNamespace !== null) {
77*e3a9f44cSAtari911            $namespace = $storedNamespace;
78*e3a9f44cSAtari911        } else {
79*e3a9f44cSAtari911            // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events
80*e3a9f44cSAtari911            if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) {
81*e3a9f44cSAtari911                $namespace = '';
82*e3a9f44cSAtari911            }
83*e3a9f44cSAtari911        }
84*e3a9f44cSAtari911
8587ac9bf3SAtari911        // Generate event ID if new
8687ac9bf3SAtari911        $generatedId = $eventId ?: uniqid();
8787ac9bf3SAtari911
8887ac9bf3SAtari911        // If recurring, generate multiple events
8987ac9bf3SAtari911        if ($isRecurring) {
9087ac9bf3SAtari911            $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $description,
9187ac9bf3SAtari911                                        $color, $isTask, $recurrenceType, $recurrenceEnd, $generatedId);
9287ac9bf3SAtari911            echo json_encode(['success' => true]);
9387ac9bf3SAtari911            return;
9487ac9bf3SAtari911        }
9587ac9bf3SAtari911
9619378907SAtari911        list($year, $month, $day) = explode('-', $date);
9719378907SAtari911
9819378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
9919378907SAtari911        if ($namespace) {
10019378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
10119378907SAtari911        }
10219378907SAtari911        $dataDir .= 'calendar/';
10319378907SAtari911
10419378907SAtari911        if (!is_dir($dataDir)) {
10519378907SAtari911            mkdir($dataDir, 0755, true);
10619378907SAtari911        }
10719378907SAtari911
10819378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
10919378907SAtari911
11019378907SAtari911        $events = [];
11119378907SAtari911        if (file_exists($eventFile)) {
11219378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
11319378907SAtari911        }
11419378907SAtari911
11519378907SAtari911        // If editing and date changed, remove from old date first
11619378907SAtari911        if ($eventId && $oldDate && $oldDate !== $date) {
11719378907SAtari911            list($oldYear, $oldMonth, $oldDay) = explode('-', $oldDate);
11819378907SAtari911            $oldEventFile = $dataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth);
11919378907SAtari911
12019378907SAtari911            if (file_exists($oldEventFile)) {
12119378907SAtari911                $oldEvents = json_decode(file_get_contents($oldEventFile), true);
12219378907SAtari911                if (isset($oldEvents[$oldDate])) {
123*e3a9f44cSAtari911                    $oldEvents[$oldDate] = array_values(array_filter($oldEvents[$oldDate], function($evt) use ($eventId) {
12419378907SAtari911                        return $evt['id'] !== $eventId;
125*e3a9f44cSAtari911                    }));
12619378907SAtari911
12719378907SAtari911                    if (empty($oldEvents[$oldDate])) {
12819378907SAtari911                        unset($oldEvents[$oldDate]);
12919378907SAtari911                    }
13019378907SAtari911
13119378907SAtari911                    file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT));
13219378907SAtari911                }
13319378907SAtari911            }
13419378907SAtari911        }
13519378907SAtari911
13619378907SAtari911        if (!isset($events[$date])) {
13719378907SAtari911            $events[$date] = [];
138*e3a9f44cSAtari911        } elseif (!is_array($events[$date])) {
139*e3a9f44cSAtari911            // Fix corrupted data - ensure it's an array
140*e3a9f44cSAtari911            error_log("Calendar saveEvent: Fixing corrupted data at $date - was not an array");
141*e3a9f44cSAtari911            $events[$date] = [];
14219378907SAtari911        }
14319378907SAtari911
144*e3a9f44cSAtari911        // Store the namespace with the event
14519378907SAtari911        $eventData = [
14687ac9bf3SAtari911            'id' => $generatedId,
14719378907SAtari911            'title' => $title,
14819378907SAtari911            'time' => $time,
14919378907SAtari911            'description' => $description,
15019378907SAtari911            'color' => $color,
15119378907SAtari911            'isTask' => $isTask,
15219378907SAtari911            'completed' => $completed,
15319378907SAtari911            'endDate' => $endDate,
154*e3a9f44cSAtari911            'namespace' => $namespace, // Store namespace with event
15519378907SAtari911            'created' => date('Y-m-d H:i:s')
15619378907SAtari911        ];
15719378907SAtari911
15819378907SAtari911        // If editing, replace existing event
15919378907SAtari911        if ($eventId) {
16019378907SAtari911            $found = false;
16119378907SAtari911            foreach ($events[$date] as $key => $evt) {
16219378907SAtari911                if ($evt['id'] === $eventId) {
16319378907SAtari911                    $events[$date][$key] = $eventData;
16419378907SAtari911                    $found = true;
16519378907SAtari911                    break;
16619378907SAtari911                }
16719378907SAtari911            }
16819378907SAtari911            if (!$found) {
16919378907SAtari911                $events[$date][] = $eventData;
17019378907SAtari911            }
17119378907SAtari911        } else {
17219378907SAtari911            $events[$date][] = $eventData;
17319378907SAtari911        }
17419378907SAtari911
17519378907SAtari911        file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
17619378907SAtari911
177*e3a9f44cSAtari911        // If event spans multiple months, add it to the first day of each subsequent month
178*e3a9f44cSAtari911        if ($endDate && $endDate !== $date) {
179*e3a9f44cSAtari911            $startDateObj = new DateTime($date);
180*e3a9f44cSAtari911            $endDateObj = new DateTime($endDate);
181*e3a9f44cSAtari911
182*e3a9f44cSAtari911            // Get the month/year of the start date
183*e3a9f44cSAtari911            $startMonth = $startDateObj->format('Y-m');
184*e3a9f44cSAtari911
185*e3a9f44cSAtari911            // Iterate through each month the event spans
186*e3a9f44cSAtari911            $currentDate = clone $startDateObj;
187*e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
188*e3a9f44cSAtari911
189*e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
190*e3a9f44cSAtari911                $currentMonth = $currentDate->format('Y-m');
191*e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
192*e3a9f44cSAtari911
193*e3a9f44cSAtari911                list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth);
194*e3a9f44cSAtari911
195*e3a9f44cSAtari911                // Get the file for this month
196*e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum);
197*e3a9f44cSAtari911
198*e3a9f44cSAtari911                $currentEvents = [];
199*e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
200*e3a9f44cSAtari911                    $contents = file_get_contents($currentEventFile);
201*e3a9f44cSAtari911                    $decoded = json_decode($contents, true);
202*e3a9f44cSAtari911                    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
203*e3a9f44cSAtari911                        $currentEvents = $decoded;
204*e3a9f44cSAtari911                    } else {
205*e3a9f44cSAtari911                        error_log("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg());
206*e3a9f44cSAtari911                    }
207*e3a9f44cSAtari911                }
208*e3a9f44cSAtari911
209*e3a9f44cSAtari911                // Add entry for the first day of this month
210*e3a9f44cSAtari911                if (!isset($currentEvents[$firstDayOfMonth])) {
211*e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
212*e3a9f44cSAtari911                } elseif (!is_array($currentEvents[$firstDayOfMonth])) {
213*e3a9f44cSAtari911                    // Fix corrupted data - ensure it's an array
214*e3a9f44cSAtari911                    error_log("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array");
215*e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth] = [];
216*e3a9f44cSAtari911                }
217*e3a9f44cSAtari911
218*e3a9f44cSAtari911                // Create a copy with the original start date preserved
219*e3a9f44cSAtari911                $eventDataForMonth = $eventData;
220*e3a9f44cSAtari911                $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date
221*e3a9f44cSAtari911
222*e3a9f44cSAtari911                // Check if event already exists (when editing)
223*e3a9f44cSAtari911                $found = false;
224*e3a9f44cSAtari911                if ($eventId) {
225*e3a9f44cSAtari911                    foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) {
226*e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
227*e3a9f44cSAtari911                            $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth;
228*e3a9f44cSAtari911                            $found = true;
229*e3a9f44cSAtari911                            break;
230*e3a9f44cSAtari911                        }
231*e3a9f44cSAtari911                    }
232*e3a9f44cSAtari911                }
233*e3a9f44cSAtari911
234*e3a9f44cSAtari911                if (!$found) {
235*e3a9f44cSAtari911                    $currentEvents[$firstDayOfMonth][] = $eventDataForMonth;
236*e3a9f44cSAtari911                }
237*e3a9f44cSAtari911
238*e3a9f44cSAtari911                file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
239*e3a9f44cSAtari911
240*e3a9f44cSAtari911                // Move to next month
241*e3a9f44cSAtari911                $currentDate->modify('first day of next month');
242*e3a9f44cSAtari911            }
243*e3a9f44cSAtari911        }
244*e3a9f44cSAtari911
24519378907SAtari911        echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]);
24619378907SAtari911    }
24719378907SAtari911
24819378907SAtari911    private function deleteEvent() {
24919378907SAtari911        global $INPUT;
25019378907SAtari911
25119378907SAtari911        $namespace = $INPUT->str('namespace', '');
25219378907SAtari911        $date = $INPUT->str('date');
25319378907SAtari911        $eventId = $INPUT->str('eventId');
25419378907SAtari911
255*e3a9f44cSAtari911        // Find where the event actually lives
256*e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
257*e3a9f44cSAtari911
258*e3a9f44cSAtari911        if ($storedNamespace === null) {
259*e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
260*e3a9f44cSAtari911            return;
261*e3a9f44cSAtari911        }
262*e3a9f44cSAtari911
263*e3a9f44cSAtari911        // Use the found namespace
264*e3a9f44cSAtari911        $namespace = $storedNamespace;
265*e3a9f44cSAtari911
26619378907SAtari911        list($year, $month, $day) = explode('-', $date);
26719378907SAtari911
26819378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
26919378907SAtari911        if ($namespace) {
27019378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
27119378907SAtari911        }
27219378907SAtari911        $dataDir .= 'calendar/';
27319378907SAtari911
27419378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
27519378907SAtari911
276*e3a9f44cSAtari911        // First, get the event to check if it spans multiple months
277*e3a9f44cSAtari911        $eventToDelete = null;
27819378907SAtari911        if (file_exists($eventFile)) {
27919378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
28019378907SAtari911
28119378907SAtari911            if (isset($events[$date])) {
282*e3a9f44cSAtari911                foreach ($events[$date] as $event) {
283*e3a9f44cSAtari911                    if ($event['id'] === $eventId) {
284*e3a9f44cSAtari911                        $eventToDelete = $event;
285*e3a9f44cSAtari911                        break;
286*e3a9f44cSAtari911                    }
287*e3a9f44cSAtari911                }
288*e3a9f44cSAtari911
289*e3a9f44cSAtari911                $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) {
29019378907SAtari911                    return $event['id'] !== $eventId;
291*e3a9f44cSAtari911                }));
29219378907SAtari911
29319378907SAtari911                if (empty($events[$date])) {
29419378907SAtari911                    unset($events[$date]);
29519378907SAtari911                }
29619378907SAtari911
29719378907SAtari911                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
29819378907SAtari911            }
29919378907SAtari911        }
30019378907SAtari911
301*e3a9f44cSAtari911        // If event spans multiple months, delete it from the first day of each subsequent month
302*e3a9f44cSAtari911        if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) {
303*e3a9f44cSAtari911            $startDateObj = new DateTime($date);
304*e3a9f44cSAtari911            $endDateObj = new DateTime($eventToDelete['endDate']);
305*e3a9f44cSAtari911
306*e3a9f44cSAtari911            // Iterate through each month the event spans
307*e3a9f44cSAtari911            $currentDate = clone $startDateObj;
308*e3a9f44cSAtari911            $currentDate->modify('first day of next month'); // Jump to first of next month
309*e3a9f44cSAtari911
310*e3a9f44cSAtari911            while ($currentDate <= $endDateObj) {
311*e3a9f44cSAtari911                $firstDayOfMonth = $currentDate->format('Y-m-01');
312*e3a9f44cSAtari911                list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth);
313*e3a9f44cSAtari911
314*e3a9f44cSAtari911                // Get the file for this month
315*e3a9f44cSAtari911                $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth);
316*e3a9f44cSAtari911
317*e3a9f44cSAtari911                if (file_exists($currentEventFile)) {
318*e3a9f44cSAtari911                    $currentEvents = json_decode(file_get_contents($currentEventFile), true);
319*e3a9f44cSAtari911
320*e3a9f44cSAtari911                    if (isset($currentEvents[$firstDayOfMonth])) {
321*e3a9f44cSAtari911                        $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) {
322*e3a9f44cSAtari911                            return $event['id'] !== $eventId;
323*e3a9f44cSAtari911                        }));
324*e3a9f44cSAtari911
325*e3a9f44cSAtari911                        if (empty($currentEvents[$firstDayOfMonth])) {
326*e3a9f44cSAtari911                            unset($currentEvents[$firstDayOfMonth]);
327*e3a9f44cSAtari911                        }
328*e3a9f44cSAtari911
329*e3a9f44cSAtari911                        file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT));
330*e3a9f44cSAtari911                    }
331*e3a9f44cSAtari911                }
332*e3a9f44cSAtari911
333*e3a9f44cSAtari911                // Move to next month
334*e3a9f44cSAtari911                $currentDate->modify('first day of next month');
335*e3a9f44cSAtari911            }
336*e3a9f44cSAtari911        }
337*e3a9f44cSAtari911
33819378907SAtari911        echo json_encode(['success' => true]);
33919378907SAtari911    }
34019378907SAtari911
34119378907SAtari911    private function getEvent() {
34219378907SAtari911        global $INPUT;
34319378907SAtari911
34419378907SAtari911        $namespace = $INPUT->str('namespace', '');
34519378907SAtari911        $date = $INPUT->str('date');
34619378907SAtari911        $eventId = $INPUT->str('eventId');
34719378907SAtari911
348*e3a9f44cSAtari911        // Find where the event actually lives
349*e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
350*e3a9f44cSAtari911
351*e3a9f44cSAtari911        if ($storedNamespace === null) {
352*e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
353*e3a9f44cSAtari911            return;
354*e3a9f44cSAtari911        }
355*e3a9f44cSAtari911
356*e3a9f44cSAtari911        // Use the found namespace
357*e3a9f44cSAtari911        $namespace = $storedNamespace;
358*e3a9f44cSAtari911
35919378907SAtari911        list($year, $month, $day) = explode('-', $date);
36019378907SAtari911
36119378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
36219378907SAtari911        if ($namespace) {
36319378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
36419378907SAtari911        }
36519378907SAtari911        $dataDir .= 'calendar/';
36619378907SAtari911
36719378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
36819378907SAtari911
36919378907SAtari911        if (file_exists($eventFile)) {
37019378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
37119378907SAtari911
37219378907SAtari911            if (isset($events[$date])) {
37319378907SAtari911                foreach ($events[$date] as $event) {
37419378907SAtari911                    if ($event['id'] === $eventId) {
37519378907SAtari911                        echo json_encode(['success' => true, 'event' => $event]);
37619378907SAtari911                        return;
37719378907SAtari911                    }
37819378907SAtari911                }
37919378907SAtari911            }
38019378907SAtari911        }
38119378907SAtari911
38219378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
38319378907SAtari911    }
38419378907SAtari911
38519378907SAtari911    private function loadMonth() {
38619378907SAtari911        global $INPUT;
38719378907SAtari911
388*e3a9f44cSAtari911        // Prevent caching of AJAX responses
389*e3a9f44cSAtari911        header('Cache-Control: no-cache, no-store, must-revalidate');
390*e3a9f44cSAtari911        header('Pragma: no-cache');
391*e3a9f44cSAtari911        header('Expires: 0');
392*e3a9f44cSAtari911
39319378907SAtari911        $namespace = $INPUT->str('namespace', '');
39419378907SAtari911        $year = $INPUT->int('year');
39519378907SAtari911        $month = $INPUT->int('month');
39619378907SAtari911
397*e3a9f44cSAtari911        error_log("=== Calendar loadMonth DEBUG ===");
398*e3a9f44cSAtari911        error_log("Requested: year=$year, month=$month, namespace='$namespace'");
399*e3a9f44cSAtari911
400*e3a9f44cSAtari911        // Check if multi-namespace or wildcard
401*e3a9f44cSAtari911        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
402*e3a9f44cSAtari911
403*e3a9f44cSAtari911        error_log("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false'));
404*e3a9f44cSAtari911
405*e3a9f44cSAtari911        if ($isMultiNamespace) {
406*e3a9f44cSAtari911            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
407*e3a9f44cSAtari911        } else {
408*e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
409*e3a9f44cSAtari911        }
410*e3a9f44cSAtari911
411*e3a9f44cSAtari911        error_log("Returning " . count($events) . " date keys");
412*e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
413*e3a9f44cSAtari911            error_log("  dateKey=$dateKey has " . count($dayEvents) . " events");
414*e3a9f44cSAtari911        }
415*e3a9f44cSAtari911
416*e3a9f44cSAtari911        echo json_encode([
417*e3a9f44cSAtari911            'success' => true,
418*e3a9f44cSAtari911            'year' => $year,
419*e3a9f44cSAtari911            'month' => $month,
420*e3a9f44cSAtari911            'events' => $events
421*e3a9f44cSAtari911        ]);
422*e3a9f44cSAtari911    }
423*e3a9f44cSAtari911
424*e3a9f44cSAtari911    private function loadEventsSingleNamespace($namespace, $year, $month) {
42519378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
42619378907SAtari911        if ($namespace) {
42719378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
42819378907SAtari911        }
42919378907SAtari911        $dataDir .= 'calendar/';
43019378907SAtari911
431*e3a9f44cSAtari911        // Load ONLY current month
43287ac9bf3SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
43319378907SAtari911        $events = [];
43419378907SAtari911        if (file_exists($eventFile)) {
43587ac9bf3SAtari911            $contents = file_get_contents($eventFile);
43687ac9bf3SAtari911            $decoded = json_decode($contents, true);
43787ac9bf3SAtari911            if (json_last_error() === JSON_ERROR_NONE) {
43887ac9bf3SAtari911                $events = $decoded;
43987ac9bf3SAtari911            }
44087ac9bf3SAtari911        }
44187ac9bf3SAtari911
442*e3a9f44cSAtari911        return $events;
44387ac9bf3SAtari911    }
444*e3a9f44cSAtari911
445*e3a9f44cSAtari911    private function loadEventsMultiNamespace($namespaces, $year, $month) {
446*e3a9f44cSAtari911        // Check for wildcard pattern
447*e3a9f44cSAtari911        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
448*e3a9f44cSAtari911            $baseNamespace = $matches[1];
449*e3a9f44cSAtari911            return $this->loadEventsWildcard($baseNamespace, $year, $month);
450*e3a9f44cSAtari911        }
451*e3a9f44cSAtari911
452*e3a9f44cSAtari911        // Check for root wildcard
453*e3a9f44cSAtari911        if ($namespaces === '*') {
454*e3a9f44cSAtari911            return $this->loadEventsWildcard('', $year, $month);
455*e3a9f44cSAtari911        }
456*e3a9f44cSAtari911
457*e3a9f44cSAtari911        // Parse namespace list (semicolon separated)
458*e3a9f44cSAtari911        $namespaceList = array_map('trim', explode(';', $namespaces));
459*e3a9f44cSAtari911
460*e3a9f44cSAtari911        // Load events from all namespaces
461*e3a9f44cSAtari911        $allEvents = [];
462*e3a9f44cSAtari911        foreach ($namespaceList as $ns) {
463*e3a9f44cSAtari911            $ns = trim($ns);
464*e3a9f44cSAtari911            if (empty($ns)) continue;
465*e3a9f44cSAtari911
466*e3a9f44cSAtari911            $events = $this->loadEventsSingleNamespace($ns, $year, $month);
467*e3a9f44cSAtari911
468*e3a9f44cSAtari911            // Add namespace tag to each event
469*e3a9f44cSAtari911            foreach ($events as $dateKey => $dayEvents) {
470*e3a9f44cSAtari911                if (!isset($allEvents[$dateKey])) {
471*e3a9f44cSAtari911                    $allEvents[$dateKey] = [];
472*e3a9f44cSAtari911                }
473*e3a9f44cSAtari911                foreach ($dayEvents as $event) {
474*e3a9f44cSAtari911                    $event['_namespace'] = $ns;
475*e3a9f44cSAtari911                    $allEvents[$dateKey][] = $event;
476*e3a9f44cSAtari911                }
47787ac9bf3SAtari911            }
47887ac9bf3SAtari911        }
47987ac9bf3SAtari911
480*e3a9f44cSAtari911        return $allEvents;
481*e3a9f44cSAtari911    }
48219378907SAtari911
483*e3a9f44cSAtari911    private function loadEventsWildcard($baseNamespace, $year, $month) {
484*e3a9f44cSAtari911        $dataDir = DOKU_INC . 'data/meta/';
485*e3a9f44cSAtari911        if ($baseNamespace) {
486*e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
487*e3a9f44cSAtari911        }
488*e3a9f44cSAtari911
489*e3a9f44cSAtari911        $allEvents = [];
490*e3a9f44cSAtari911
491*e3a9f44cSAtari911        // First, load events from the base namespace itself
492*e3a9f44cSAtari911        $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month);
493*e3a9f44cSAtari911
494*e3a9f44cSAtari911        foreach ($events as $dateKey => $dayEvents) {
495*e3a9f44cSAtari911            if (!isset($allEvents[$dateKey])) {
496*e3a9f44cSAtari911                $allEvents[$dateKey] = [];
497*e3a9f44cSAtari911            }
498*e3a9f44cSAtari911            foreach ($dayEvents as $event) {
499*e3a9f44cSAtari911                $event['_namespace'] = $baseNamespace;
500*e3a9f44cSAtari911                $allEvents[$dateKey][] = $event;
501*e3a9f44cSAtari911            }
502*e3a9f44cSAtari911        }
503*e3a9f44cSAtari911
504*e3a9f44cSAtari911        // Recursively find all subdirectories
505*e3a9f44cSAtari911        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
506*e3a9f44cSAtari911
507*e3a9f44cSAtari911        return $allEvents;
508*e3a9f44cSAtari911    }
509*e3a9f44cSAtari911
510*e3a9f44cSAtari911    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
511*e3a9f44cSAtari911        if (!is_dir($dir)) return;
512*e3a9f44cSAtari911
513*e3a9f44cSAtari911        $items = scandir($dir);
514*e3a9f44cSAtari911        foreach ($items as $item) {
515*e3a9f44cSAtari911            if ($item === '.' || $item === '..') continue;
516*e3a9f44cSAtari911
517*e3a9f44cSAtari911            $path = $dir . $item;
518*e3a9f44cSAtari911            if (is_dir($path) && $item !== 'calendar') {
519*e3a9f44cSAtari911                // This is a namespace directory
520*e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
521*e3a9f44cSAtari911
522*e3a9f44cSAtari911                // Load events from this namespace
523*e3a9f44cSAtari911                $events = $this->loadEventsSingleNamespace($namespace, $year, $month);
524*e3a9f44cSAtari911                foreach ($events as $dateKey => $dayEvents) {
525*e3a9f44cSAtari911                    if (!isset($allEvents[$dateKey])) {
526*e3a9f44cSAtari911                        $allEvents[$dateKey] = [];
527*e3a9f44cSAtari911                    }
528*e3a9f44cSAtari911                    foreach ($dayEvents as $event) {
529*e3a9f44cSAtari911                        $event['_namespace'] = $namespace;
530*e3a9f44cSAtari911                        $allEvents[$dateKey][] = $event;
531*e3a9f44cSAtari911                    }
532*e3a9f44cSAtari911                }
533*e3a9f44cSAtari911
534*e3a9f44cSAtari911                // Recurse into subdirectories
535*e3a9f44cSAtari911                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
536*e3a9f44cSAtari911            }
537*e3a9f44cSAtari911        }
53819378907SAtari911    }
53919378907SAtari911
54019378907SAtari911    private function toggleTaskComplete() {
54119378907SAtari911        global $INPUT;
54219378907SAtari911
54319378907SAtari911        $namespace = $INPUT->str('namespace', '');
54419378907SAtari911        $date = $INPUT->str('date');
54519378907SAtari911        $eventId = $INPUT->str('eventId');
54619378907SAtari911        $completed = $INPUT->bool('completed', false);
54719378907SAtari911
548*e3a9f44cSAtari911        // Find where the event actually lives
549*e3a9f44cSAtari911        $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace);
550*e3a9f44cSAtari911
551*e3a9f44cSAtari911        if ($storedNamespace === null) {
552*e3a9f44cSAtari911            echo json_encode(['success' => false, 'error' => 'Event not found']);
553*e3a9f44cSAtari911            return;
554*e3a9f44cSAtari911        }
555*e3a9f44cSAtari911
556*e3a9f44cSAtari911        // Use the found namespace
557*e3a9f44cSAtari911        $namespace = $storedNamespace;
558*e3a9f44cSAtari911
55919378907SAtari911        list($year, $month, $day) = explode('-', $date);
56019378907SAtari911
56119378907SAtari911        $dataDir = DOKU_INC . 'data/meta/';
56219378907SAtari911        if ($namespace) {
56319378907SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
56419378907SAtari911        }
56519378907SAtari911        $dataDir .= 'calendar/';
56619378907SAtari911
56719378907SAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
56819378907SAtari911
56919378907SAtari911        if (file_exists($eventFile)) {
57019378907SAtari911            $events = json_decode(file_get_contents($eventFile), true);
57119378907SAtari911
57219378907SAtari911            if (isset($events[$date])) {
57319378907SAtari911                foreach ($events[$date] as $key => $event) {
57419378907SAtari911                    if ($event['id'] === $eventId) {
57519378907SAtari911                        $events[$date][$key]['completed'] = $completed;
57619378907SAtari911                        break;
57719378907SAtari911                    }
57819378907SAtari911                }
57919378907SAtari911
58019378907SAtari911                file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
58119378907SAtari911                echo json_encode(['success' => true, 'events' => $events]);
58219378907SAtari911                return;
58319378907SAtari911            }
58419378907SAtari911        }
58519378907SAtari911
58619378907SAtari911        echo json_encode(['success' => false, 'error' => 'Event not found']);
58719378907SAtari911    }
58819378907SAtari911
58987ac9bf3SAtari911    private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time,
59087ac9bf3SAtari911                                          $description, $color, $isTask, $recurrenceType,
59187ac9bf3SAtari911                                          $recurrenceEnd, $baseId) {
59287ac9bf3SAtari911        $dataDir = DOKU_INC . 'data/meta/';
59387ac9bf3SAtari911        if ($namespace) {
59487ac9bf3SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
59587ac9bf3SAtari911        }
59687ac9bf3SAtari911        $dataDir .= 'calendar/';
59787ac9bf3SAtari911
59887ac9bf3SAtari911        if (!is_dir($dataDir)) {
59987ac9bf3SAtari911            mkdir($dataDir, 0755, true);
60087ac9bf3SAtari911        }
60187ac9bf3SAtari911
60287ac9bf3SAtari911        // Calculate recurrence interval
60387ac9bf3SAtari911        $interval = '';
60487ac9bf3SAtari911        switch ($recurrenceType) {
60587ac9bf3SAtari911            case 'daily': $interval = '+1 day'; break;
60687ac9bf3SAtari911            case 'weekly': $interval = '+1 week'; break;
60787ac9bf3SAtari911            case 'monthly': $interval = '+1 month'; break;
60887ac9bf3SAtari911            case 'yearly': $interval = '+1 year'; break;
60987ac9bf3SAtari911            default: $interval = '+1 week';
61087ac9bf3SAtari911        }
61187ac9bf3SAtari911
61287ac9bf3SAtari911        // Set maximum end date if not specified (1 year from start)
61387ac9bf3SAtari911        $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year'));
61487ac9bf3SAtari911
61587ac9bf3SAtari911        // Calculate event duration for multi-day events
61687ac9bf3SAtari911        $eventDuration = 0;
61787ac9bf3SAtari911        if ($endDate && $endDate !== $startDate) {
61887ac9bf3SAtari911            $start = new DateTime($startDate);
61987ac9bf3SAtari911            $end = new DateTime($endDate);
62087ac9bf3SAtari911            $eventDuration = $start->diff($end)->days;
62187ac9bf3SAtari911        }
62287ac9bf3SAtari911
62387ac9bf3SAtari911        // Generate recurring events
62487ac9bf3SAtari911        $currentDate = new DateTime($startDate);
62587ac9bf3SAtari911        $endLimit = new DateTime($maxEnd);
62687ac9bf3SAtari911        $counter = 0;
62787ac9bf3SAtari911        $maxOccurrences = 100; // Prevent infinite loops
62887ac9bf3SAtari911
62987ac9bf3SAtari911        while ($currentDate <= $endLimit && $counter < $maxOccurrences) {
63087ac9bf3SAtari911            $dateKey = $currentDate->format('Y-m-d');
63187ac9bf3SAtari911            list($year, $month, $day) = explode('-', $dateKey);
63287ac9bf3SAtari911
63387ac9bf3SAtari911            // Calculate end date for this occurrence if multi-day
63487ac9bf3SAtari911            $occurrenceEndDate = '';
63587ac9bf3SAtari911            if ($eventDuration > 0) {
63687ac9bf3SAtari911                $occurrenceEnd = clone $currentDate;
63787ac9bf3SAtari911                $occurrenceEnd->modify('+' . $eventDuration . ' days');
63887ac9bf3SAtari911                $occurrenceEndDate = $occurrenceEnd->format('Y-m-d');
63987ac9bf3SAtari911            }
64087ac9bf3SAtari911
64187ac9bf3SAtari911            // Load month file
64287ac9bf3SAtari911            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
64387ac9bf3SAtari911            $events = [];
64487ac9bf3SAtari911            if (file_exists($eventFile)) {
64587ac9bf3SAtari911                $events = json_decode(file_get_contents($eventFile), true);
64687ac9bf3SAtari911            }
64787ac9bf3SAtari911
64887ac9bf3SAtari911            if (!isset($events[$dateKey])) {
64987ac9bf3SAtari911                $events[$dateKey] = [];
65087ac9bf3SAtari911            }
65187ac9bf3SAtari911
65287ac9bf3SAtari911            // Create event for this occurrence
65387ac9bf3SAtari911            $eventData = [
65487ac9bf3SAtari911                'id' => $baseId . '-' . $counter,
65587ac9bf3SAtari911                'title' => $title,
65687ac9bf3SAtari911                'time' => $time,
65787ac9bf3SAtari911                'description' => $description,
65887ac9bf3SAtari911                'color' => $color,
65987ac9bf3SAtari911                'isTask' => $isTask,
66087ac9bf3SAtari911                'completed' => false,
66187ac9bf3SAtari911                'endDate' => $occurrenceEndDate,
66287ac9bf3SAtari911                'recurring' => true,
66387ac9bf3SAtari911                'recurringId' => $baseId,
66487ac9bf3SAtari911                'created' => date('Y-m-d H:i:s')
66587ac9bf3SAtari911            ];
66687ac9bf3SAtari911
66787ac9bf3SAtari911            $events[$dateKey][] = $eventData;
66887ac9bf3SAtari911            file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
66987ac9bf3SAtari911
67087ac9bf3SAtari911            // Move to next occurrence
67187ac9bf3SAtari911            $currentDate->modify($interval);
67287ac9bf3SAtari911            $counter++;
67387ac9bf3SAtari911        }
67487ac9bf3SAtari911    }
67587ac9bf3SAtari911
67619378907SAtari911    public function addAssets(Doku_Event $event, $param) {
67719378907SAtari911        $event->data['link'][] = array(
67819378907SAtari911            'type' => 'text/css',
67919378907SAtari911            'rel' => 'stylesheet',
68019378907SAtari911            'href' => DOKU_BASE . 'lib/plugins/calendar/style.css'
68119378907SAtari911        );
68219378907SAtari911
68319378907SAtari911        $event->data['script'][] = array(
68419378907SAtari911            'type' => 'text/javascript',
68519378907SAtari911            'src' => DOKU_BASE . 'lib/plugins/calendar/script.js'
68619378907SAtari911        );
68719378907SAtari911    }
688*e3a9f44cSAtari911    // Helper function to find an event's stored namespace
689*e3a9f44cSAtari911    private function findEventNamespace($eventId, $date, $searchNamespace) {
690*e3a9f44cSAtari911        list($year, $month, $day) = explode('-', $date);
691*e3a9f44cSAtari911
692*e3a9f44cSAtari911        // List of namespaces to check
693*e3a9f44cSAtari911        $namespacesToCheck = [''];
694*e3a9f44cSAtari911
695*e3a9f44cSAtari911        // If searchNamespace is a wildcard or multi, we need to search multiple locations
696*e3a9f44cSAtari911        if (!empty($searchNamespace)) {
697*e3a9f44cSAtari911            if (strpos($searchNamespace, ';') !== false) {
698*e3a9f44cSAtari911                // Multi-namespace - check each one
699*e3a9f44cSAtari911                $namespacesToCheck = array_map('trim', explode(';', $searchNamespace));
700*e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
701*e3a9f44cSAtari911            } elseif (strpos($searchNamespace, '*') !== false) {
702*e3a9f44cSAtari911                // Wildcard - need to scan directories
703*e3a9f44cSAtari911                $baseNs = trim(str_replace('*', '', $searchNamespace), ':');
704*e3a9f44cSAtari911                $namespacesToCheck = $this->findAllNamespaces($baseNs);
705*e3a9f44cSAtari911                $namespacesToCheck[] = ''; // Also check default
706*e3a9f44cSAtari911            } else {
707*e3a9f44cSAtari911                // Single namespace
708*e3a9f44cSAtari911                $namespacesToCheck = [$searchNamespace, '']; // Check specified and default
709*e3a9f44cSAtari911            }
710*e3a9f44cSAtari911        }
711*e3a9f44cSAtari911
712*e3a9f44cSAtari911        // Search for the event in all possible namespaces
713*e3a9f44cSAtari911        foreach ($namespacesToCheck as $ns) {
714*e3a9f44cSAtari911            $dataDir = DOKU_INC . 'data/meta/';
715*e3a9f44cSAtari911            if ($ns) {
716*e3a9f44cSAtari911                $dataDir .= str_replace(':', '/', $ns) . '/';
717*e3a9f44cSAtari911            }
718*e3a9f44cSAtari911            $dataDir .= 'calendar/';
719*e3a9f44cSAtari911
720*e3a9f44cSAtari911            $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
721*e3a9f44cSAtari911
722*e3a9f44cSAtari911            if (file_exists($eventFile)) {
723*e3a9f44cSAtari911                $events = json_decode(file_get_contents($eventFile), true);
724*e3a9f44cSAtari911                if (isset($events[$date])) {
725*e3a9f44cSAtari911                    foreach ($events[$date] as $evt) {
726*e3a9f44cSAtari911                        if ($evt['id'] === $eventId) {
727*e3a9f44cSAtari911                            // Found the event! Return its stored namespace
728*e3a9f44cSAtari911                            return isset($evt['namespace']) ? $evt['namespace'] : $ns;
729*e3a9f44cSAtari911                        }
730*e3a9f44cSAtari911                    }
731*e3a9f44cSAtari911                }
732*e3a9f44cSAtari911            }
733*e3a9f44cSAtari911        }
734*e3a9f44cSAtari911
735*e3a9f44cSAtari911        return null; // Event not found
736*e3a9f44cSAtari911    }
737*e3a9f44cSAtari911
738*e3a9f44cSAtari911    // Helper to find all namespaces under a base namespace
739*e3a9f44cSAtari911    private function findAllNamespaces($baseNamespace) {
740*e3a9f44cSAtari911        $dataDir = DOKU_INC . 'data/meta/';
741*e3a9f44cSAtari911        if ($baseNamespace) {
742*e3a9f44cSAtari911            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
743*e3a9f44cSAtari911        }
744*e3a9f44cSAtari911
745*e3a9f44cSAtari911        $namespaces = [];
746*e3a9f44cSAtari911        if ($baseNamespace) {
747*e3a9f44cSAtari911            $namespaces[] = $baseNamespace;
748*e3a9f44cSAtari911        }
749*e3a9f44cSAtari911
750*e3a9f44cSAtari911        $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces);
751*e3a9f44cSAtari911
752*e3a9f44cSAtari911        return $namespaces;
753*e3a9f44cSAtari911    }
754*e3a9f44cSAtari911
755*e3a9f44cSAtari911    // Recursive scan for namespaces
756*e3a9f44cSAtari911    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
757*e3a9f44cSAtari911        if (!is_dir($dir)) return;
758*e3a9f44cSAtari911
759*e3a9f44cSAtari911        $items = scandir($dir);
760*e3a9f44cSAtari911        foreach ($items as $item) {
761*e3a9f44cSAtari911            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
762*e3a9f44cSAtari911
763*e3a9f44cSAtari911            $path = $dir . $item;
764*e3a9f44cSAtari911            if (is_dir($path)) {
765*e3a9f44cSAtari911                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
766*e3a9f44cSAtari911                $namespaces[] = $namespace;
767*e3a9f44cSAtari911                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
768*e3a9f44cSAtari911            }
769*e3a9f44cSAtari911        }
770*e3a9f44cSAtari911    }
77119378907SAtari911}
772