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