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