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