xref: /plugin/calendar/admin.php (revision 96df7d3e9a825dddf459ab1ee6077a9886837f17)
1<?php
2/**
3 * Calendar Plugin - Admin Interface
4 * Clean rewrite - Configuration only
5 * Version: 3.3
6 */
7
8if(!defined('DOKU_INC')) die();
9
10class admin_plugin_calendar extends DokuWiki_Admin_Plugin {
11
12    /**
13     * Get the path to the sync log file (in data directory, not plugin directory)
14     */
15    private function getSyncLogPath() {
16        $dataDir = DOKU_INC . 'data/meta/calendar/';
17        if (!is_dir($dataDir)) {
18            @mkdir($dataDir, 0755, true);
19        }
20        return $dataDir . 'sync.log';
21    }
22
23    /**
24     * Get the path to the sync state file (in data directory, not plugin directory)
25     */
26    private function getSyncStatePath() {
27        $dataDir = DOKU_INC . 'data/meta/calendar/';
28        if (!is_dir($dataDir)) {
29            mkdir($dataDir, 0755, true);
30        }
31        return $dataDir . 'sync_state.json';
32    }
33
34    public function getMenuText($language) {
35        return 'Calendar Management';
36    }
37
38    public function getMenuSort() {
39        return 100;
40    }
41
42    public function forAdminOnly() {
43        return true;
44    }
45
46    /**
47     * Public entry point for AJAX actions routed from action.php
48     */
49    public function handleAjaxAction($action) {
50        // Verify admin privileges for all admin AJAX actions
51        if (!auth_isadmin()) {
52            echo json_encode(['success' => false, 'error' => 'Admin access required']);
53            return;
54        }
55
56        switch ($action) {
57            case 'cleanup_empty_namespaces': $this->handleCleanupEmptyNamespaces(); break;
58            case 'trim_all_past_recurring': $this->handleTrimAllPastRecurring(); break;
59            case 'rescan_recurring': $this->handleRescanRecurring(); break;
60            case 'extend_recurring': $this->handleExtendRecurring(); break;
61            case 'trim_recurring': $this->handleTrimRecurring(); break;
62            case 'pause_recurring': $this->handlePauseRecurring(); break;
63            case 'resume_recurring': $this->handleResumeRecurring(); break;
64            case 'change_start_recurring': $this->handleChangeStartRecurring(); break;
65            case 'change_pattern_recurring': $this->handleChangePatternRecurring(); break;
66            default:
67                echo json_encode(['success' => false, 'error' => 'Unknown admin action']);
68        }
69    }
70
71    public function handle() {
72        global $INPUT;
73
74        $action = $INPUT->str('action');
75
76        if ($action === 'clear_cache') {
77            $this->clearCache();
78        } elseif ($action === 'save_config') {
79            $this->saveConfig();
80        } elseif ($action === 'delete_recurring_series') {
81            $this->deleteRecurringSeries();
82        } elseif ($action === 'edit_recurring_series') {
83            $this->editRecurringSeries();
84        } elseif ($action === 'move_selected_events') {
85            $this->moveEvents();
86        } elseif ($action === 'move_single_event') {
87            $this->moveSingleEvent();
88        } elseif ($action === 'delete_selected_events') {
89            $this->deleteSelectedEvents();
90        } elseif ($action === 'create_namespace') {
91            $this->createNamespace();
92        } elseif ($action === 'delete_namespace') {
93            $this->deleteNamespace();
94        } elseif ($action === 'rename_namespace') {
95            $this->renameNamespace();
96        } elseif ($action === 'run_sync') {
97            $this->runSync();
98        } elseif ($action === 'stop_sync') {
99            $this->stopSync();
100        } elseif ($action === 'upload_update') {
101            $this->uploadUpdate();
102        } elseif ($action === 'delete_backup') {
103            $this->deleteBackup();
104        } elseif ($action === 'rename_backup') {
105            $this->renameBackup();
106        } elseif ($action === 'restore_backup') {
107            $this->restoreBackup();
108        } elseif ($action === 'create_manual_backup') {
109            $this->createManualBackup();
110        } elseif ($action === 'export_config') {
111            $this->exportConfig();
112        } elseif ($action === 'import_config') {
113            $this->importConfig();
114        } elseif ($action === 'get_log') {
115            $this->getLog();
116        } elseif ($action === 'cleanup_empty_namespaces') {
117            $this->handleCleanupEmptyNamespaces();
118        } elseif ($action === 'trim_all_past_recurring') {
119            $this->handleTrimAllPastRecurring();
120        } elseif ($action === 'rescan_recurring') {
121            $this->handleRescanRecurring();
122        } elseif ($action === 'extend_recurring') {
123            $this->handleExtendRecurring();
124        } elseif ($action === 'trim_recurring') {
125            $this->handleTrimRecurring();
126        } elseif ($action === 'pause_recurring') {
127            $this->handlePauseRecurring();
128        } elseif ($action === 'resume_recurring') {
129            $this->handleResumeRecurring();
130        } elseif ($action === 'change_start_recurring') {
131            $this->handleChangeStartRecurring();
132        } elseif ($action === 'change_pattern_recurring') {
133            $this->handleChangePatternRecurring();
134        } elseif ($action === 'clear_log') {
135            $this->clearLogFile();
136        } elseif ($action === 'download_log') {
137            $this->downloadLog();
138        } elseif ($action === 'rescan_events') {
139            $this->rescanEvents();
140        } elseif ($action === 'export_all_events') {
141            $this->exportAllEvents();
142        } elseif ($action === 'import_all_events') {
143            $this->importAllEvents();
144        } elseif ($action === 'preview_cleanup') {
145            $this->previewCleanup();
146        } elseif ($action === 'cleanup_events') {
147            $this->cleanupEvents();
148        } elseif ($action === 'save_important_namespaces') {
149            $this->saveImportantNamespaces();
150        }
151    }
152
153    public function html() {
154        global $INPUT;
155
156        // Get current tab - default to 'manage' (Manage Events tab)
157        $tab = $INPUT->str('tab', 'manage');
158
159        // Get template colors
160        $colors = $this->getTemplateColors();
161        $accentColor = '#00cc07'; // Keep calendar plugin accent color
162
163        // Tab navigation (Manage Events, Update Plugin, Outlook Sync, Themes)
164        echo '<div style="border-bottom:2px solid ' . $colors['border'] . '; margin:10px 0 15px 0;">';
165        echo '<a href="?do=admin&page=calendar&tab=manage" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'manage' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'manage' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'manage' ? 'bold' : 'normal') . ';">�� Manage Events</a>';
166        echo '<a href="?do=admin&page=calendar&tab=update" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'update' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'update' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'update' ? 'bold' : 'normal') . ';">�� Update Plugin</a>';
167        echo '<a href="?do=admin&page=calendar&tab=config" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'config' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'config' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'config' ? 'bold' : 'normal') . ';">⚙️ Outlook Sync</a>';
168        echo '<a href="?do=admin&page=calendar&tab=themes" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'themes' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'themes' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'themes' ? 'bold' : 'normal') . ';">�� Themes</a>';
169        echo '</div>';
170
171        // Render appropriate tab
172        if ($tab === 'config') {
173            $this->renderConfigTab($colors);
174        } elseif ($tab === 'manage') {
175            $this->renderManageTab($colors);
176        } elseif ($tab === 'themes') {
177            $this->renderThemesTab($colors);
178        } else {
179            $this->renderUpdateTab($colors);
180        }
181    }
182
183    private function renderConfigTab($colors = null) {
184        global $INPUT;
185
186        // Use defaults if not provided
187        if ($colors === null) {
188            $colors = $this->getTemplateColors();
189        }
190
191        // Load current config
192        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
193        $config = [];
194        if (file_exists($configFile)) {
195            $config = include $configFile;
196        }
197
198        // Show message if present
199        if ($INPUT->has('msg')) {
200            $msg = hsc($INPUT->str('msg'));
201            $type = $INPUT->str('msgtype', 'success');
202            $class = ($type === 'success') ? 'msg success' : 'msg error';
203            echo "<div class=\"$class\" style=\"padding:10px; margin:10px 0; border-left:3px solid " . ($type === 'success' ? '#28a745' : '#dc3545') . "; background:" . ($type === 'success' ? '#d4edda' : '#f8d7da') . "; border-radius:3px;\">";
204            echo $msg;
205            echo "</div>";
206        }
207
208        echo '<h2 style="margin:10px 0; font-size:20px;">Outlook Sync Configuration</h2>';
209
210        // Import/Export buttons
211        echo '<div style="display:flex; gap:10px; margin-bottom:15px;">';
212        echo '<button type="button" onclick="exportConfig()" style="background:#00cc07; color:white; padding:8px 16px; border:none; border-radius:3px; cursor:pointer; font-size:13px; font-weight:bold;">�� Export Config</button>';
213        echo '<button type="button" onclick="document.getElementById(\'importFileInput\').click()" style="background:#7b1fa2; color:white; padding:8px 16px; border:none; border-radius:3px; cursor:pointer; font-size:13px; font-weight:bold;">�� Import Config</button>';
214        echo '<input type="file" id="importFileInput" accept=".enc" style="display:none;" onchange="importConfig(this)">';
215        echo '<span id="importStatus" style="margin-left:10px; font-size:12px;"></span>';
216        echo '</div>';
217
218        echo '<form method="post" action="?do=admin&page=calendar" style="max-width:900px;">';
219        echo '<input type="hidden" name="action" value="save_config">';
220
221        // Azure Credentials
222        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">';
223        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Microsoft Azure App Credentials</h3>';
224        echo '<p style="color:' . $colors['text'] . '; font-size:0.85em; margin:0 0 10px 0;">Register at <a href="https://portal.azure.com" target="_blank" style="color:#00cc07;">Azure Portal</a> → App registrations</p>';
225
226        echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Tenant ID</label>';
227        echo '<input type="text" name="tenant_id" value="' . hsc($config['tenant_id'] ?? '') . '" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">';
228
229        echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client ID (Application ID)</label>';
230        echo '<input type="text" name="client_id" value="' . hsc($config['client_id'] ?? '') . '" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required autocomplete="off" style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">';
231
232        echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client Secret</label>';
233        echo '<input type="password" name="client_secret" value="' . hsc($config['client_secret'] ?? '') . '" placeholder="Enter client secret" required autocomplete="new-password" style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">';
234        echo '<p style="color:#999; font-size:0.8em; margin:3px 0 0;">⚠️ Keep this secret safe!</p>';
235        echo '</div>';
236
237        // Outlook Settings
238        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">';
239        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Outlook Settings</h3>';
240
241        echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">';
242
243        echo '<div>';
244        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">User Email</label>';
245        echo '<input type="email" name="user_email" value="' . hsc($config['user_email'] ?? '') . '" placeholder="your.email@company.com" required autocomplete="email" style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">';
246        echo '</div>';
247
248        echo '<div>';
249        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Timezone</label>';
250        echo '<input type="text" name="timezone" value="' . hsc($config['timezone'] ?? 'America/Los_Angeles') . '" placeholder="America/Los_Angeles" style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">';
251        echo '</div>';
252
253        echo '<div>';
254        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Default Category</label>';
255        echo '<input type="text" name="default_category" value="' . hsc($config['default_category'] ?? 'Blue category') . '" placeholder="Blue category" style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">';
256        echo '</div>';
257
258        echo '<div>';
259        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Reminder (minutes)</label>';
260        echo '<input type="number" name="reminder_minutes" value="' . hsc($config['reminder_minutes'] ?? 15) . '" placeholder="15" style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">';
261        echo '</div>';
262
263        echo '</div>'; // end grid
264        echo '</div>';
265
266        // Sync Options
267        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">';
268        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Sync Options</h3>';
269
270        $syncCompleted = isset($config['sync_completed_tasks']) ? $config['sync_completed_tasks'] : false;
271        echo '<label style="display:inline-block; margin:5px 15px 5px 0; font-size:13px;"><input type="checkbox" name="sync_completed_tasks" value="1" ' . ($syncCompleted ? 'checked' : '') . '> Sync completed tasks</label>';
272
273        $deleteOutlook = isset($config['delete_outlook_events']) ? $config['delete_outlook_events'] : true;
274        echo '<label style="display:inline-block; margin:5px 15px 5px 0; font-size:13px;"><input type="checkbox" name="delete_outlook_events" value="1" ' . ($deleteOutlook ? 'checked' : '') . '> Delete from Outlook when removed</label>';
275
276        $syncAll = isset($config['sync_all_namespaces']) ? $config['sync_all_namespaces'] : true;
277        echo '<label style="display:inline-block; margin:5px 0; font-size:13px;"><input type="checkbox" name="sync_all_namespaces" value="1" onclick="toggleNamespaceSelection(this)" ' . ($syncAll ? 'checked' : '') . '> Sync all namespaces</label>';
278
279        // Namespace selection (shown when sync_all is unchecked)
280        echo '<div id="namespace_selection" style="margin-top:10px; ' . ($syncAll ? 'display:none;' : '') . '">';
281        echo '<label style="display:block; font-weight:bold; margin-bottom:5px; font-size:13px;">Select namespaces to sync:</label>';
282
283        // Get available namespaces
284        $availableNamespaces = $this->getAllNamespaces();
285        $selectedNamespaces = isset($config['sync_namespaces']) ? $config['sync_namespaces'] : [];
286
287        echo '<div style="max-height:150px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; padding:8px; background:' . $colors['bg'] . ';">';
288        echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value=""> (default)</label>';
289        foreach ($availableNamespaces as $ns) {
290            if ($ns !== '') {
291                $checked = in_array($ns, $selectedNamespaces) ? 'checked' : '';
292                echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value="' . hsc($ns) . '" ' . $checked . '> ' . hsc($ns) . '</label>';
293            }
294        }
295        echo '</div>';
296        echo '</div>';
297
298        echo '<script>
299        function toggleNamespaceSelection(checkbox) {
300            document.getElementById("namespace_selection").style.display = checkbox.checked ? "none" : "block";
301        }
302        </script>';
303
304        echo '</div>';
305
306        // Namespace and Color Mapping - Side by Side
307        echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:10px 0;">';
308
309        // Namespace Mapping
310        echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">';
311        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Namespace → Category</h3>';
312        echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 5px;">One per line: namespace=Category</p>';
313        echo '<textarea name="category_mapping" rows="6" style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-family:monospace; font-size:12px; resize:vertical;" placeholder="work=Blue category&#10;personal=Green category">';
314        if (isset($config['category_mapping']) && is_array($config['category_mapping'])) {
315            foreach ($config['category_mapping'] as $ns => $cat) {
316                echo hsc($ns) . '=' . hsc($cat) . "\n";
317            }
318        }
319        echo '</textarea>';
320        echo '</div>';
321
322        // Color Mapping with Color Picker
323        echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">';
324        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Event Color → Category</h3>';
325        echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 8px;">Map calendar colors to Outlook categories</p>';
326
327        // Define calendar colors and Outlook categories (only the main 6 colors)
328        $calendarColors = [
329            '#3498db' => 'Blue',
330            '#2ecc71' => 'Green',
331            '#e74c3c' => 'Red',
332            '#f39c12' => 'Orange',
333            '#9b59b6' => 'Purple',
334            '#1abc9c' => 'Teal'
335        ];
336
337        $outlookCategories = [
338            'Blue category',
339            'Green category',
340            'Orange category',
341            'Red category',
342            'Yellow category',
343            'Purple category'
344        ];
345
346        // Load existing color mappings
347        $existingMappings = isset($config['color_mapping']) && is_array($config['color_mapping'])
348            ? $config['color_mapping']
349            : [];
350
351        // Display color mapping rows
352        echo '<div id="colorMappings" style="max-height:200px; overflow-y:auto;">';
353
354        $rowIndex = 0;
355        foreach ($calendarColors as $hexColor => $colorName) {
356            $selectedCategory = isset($existingMappings[$hexColor]) ? $existingMappings[$hexColor] : '';
357
358            echo '<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px;">';
359
360            // Color preview box
361            echo '<div style="width:24px; height:24px; background:' . $hexColor . '; border:2px solid #ddd; border-radius:3px; flex-shrink:0;"></div>';
362
363            // Color name
364            echo '<span style="font-size:12px; min-width:90px; color:' . $colors['text'] . ';">' . $colorName . '</span>';
365
366            // Arrow
367            echo '<span style="color:#999; font-size:12px;">→</span>';
368
369            // Outlook category dropdown
370            echo '<select name="color_map_' . $rowIndex . '" style="flex:1; padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">';
371            echo '<option value="">-- None --</option>';
372            foreach ($outlookCategories as $category) {
373                $selected = ($selectedCategory === $category) ? 'selected' : '';
374                echo '<option value="' . hsc($category) . '" ' . $selected . '>' . hsc($category) . '</option>';
375            }
376            echo '</select>';
377
378            // Hidden input for the hex color
379            echo '<input type="hidden" name="color_hex_' . $rowIndex . '" value="' . $hexColor . '">';
380
381            echo '</div>';
382            $rowIndex++;
383        }
384
385        echo '</div>';
386
387        // Hidden input to track number of color mappings
388        echo '<input type="hidden" name="color_mapping_count" value="' . $rowIndex . '">';
389
390        echo '</div>';
391
392        echo '</div>'; // end grid
393
394        // Submit button
395        echo '<button type="submit" style="background:#00cc07; color:white; padding:10px 20px; border:none; border-radius:3px; cursor:pointer; font-size:14px; font-weight:bold; margin:10px 0;">�� Save Configuration</button>';
396        echo '</form>';
397
398        // JavaScript for Import/Export
399        echo '<script>
400        async function exportConfig() {
401            try {
402                const response = await fetch("?do=admin&page=calendar&action=export_config&call=ajax", {
403                    method: "POST"
404                });
405                const data = await response.json();
406
407                if (data.success) {
408                    // Create download link
409                    const blob = new Blob([data.encrypted], {type: "application/octet-stream"});
410                    const url = URL.createObjectURL(blob);
411                    const a = document.createElement("a");
412                    a.href = url;
413                    a.download = "sync_config_" + new Date().toISOString().split("T")[0] + ".enc";
414                    document.body.appendChild(a);
415                    a.click();
416                    document.body.removeChild(a);
417                    URL.revokeObjectURL(url);
418
419                    alert("✅ Config exported successfully!\\n\\n⚠️ This file contains encrypted credentials.\\nKeep it secure!");
420                } else {
421                    alert("❌ Export failed: " + data.message);
422                }
423            } catch (error) {
424                alert("❌ Error: " + error.message);
425            }
426        }
427
428        async function importConfig(input) {
429            const file = input.files[0];
430            if (!file) return;
431
432            const status = document.getElementById("importStatus");
433            status.textContent = "⏳ Importing...";
434            status.style.color = "#00cc07";
435
436            try {
437                const encrypted = await file.text();
438
439                const formData = new FormData();
440                formData.append("encrypted_config", encrypted);
441
442                const response = await fetch("?do=admin&page=calendar&action=import_config&call=ajax", {
443                    method: "POST",
444                    body: formData
445                });
446                const data = await response.json();
447
448                if (data.success) {
449                    status.textContent = "✅ Import successful! Reloading...";
450                    status.style.color = "#28a745";
451                    setTimeout(() => {
452                        window.location.reload();
453                    }, 1500);
454                } else {
455                    status.textContent = "❌ Import failed: " + data.message;
456                    status.style.color = "#dc3545";
457                }
458            } catch (error) {
459                status.textContent = "❌ Error: " + error.message;
460                status.style.color = "#dc3545";
461            }
462
463            // Reset file input
464            input.value = "";
465        }
466        </script>';
467
468        // Sync Controls Section
469        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">';
470        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Sync Controls</h3>';
471
472        // Check cron job status
473        $cronStatus = $this->getCronStatus();
474
475        // Check log file permissions
476        $logFile = $this->getSyncLogPath();
477        $logWritable = is_writable($logFile) || is_writable(dirname($logFile));
478
479        echo '<div style="display:flex; gap:10px; align-items:center; margin-bottom:10px;">';
480        echo '<button onclick="runSyncNow()" id="syncBtn" style="background:#00cc07; color:white; padding:8px 16px; border:none; border-radius:3px; cursor:pointer; font-size:13px; font-weight:bold;">▶️ Run Sync Now</button>';
481        echo '<button onclick="stopSyncNow()" id="stopBtn" style="background:#e74c3c; color:white; padding:8px 16px; border:none; border-radius:3px; cursor:pointer; font-size:13px; font-weight:bold; display:none;">⏹️ Stop Sync</button>';
482
483        if ($cronStatus['active']) {
484            echo '<span style="color:' . $colors['text'] . '; font-size:12px;">⏰ ' . hsc($cronStatus['frequency']) . '</span>';
485        } else {
486            echo '<span style="color:#999; font-size:12px;">⚠️ No cron job detected</span>';
487        }
488
489        echo '<span id="syncStatus" style="color:' . $colors['text'] . '; font-size:12px; margin-left:auto;"></span>';
490        echo '</div>';
491
492        // Show permission warning if log not writable
493        if (!$logWritable) {
494            echo '<div style="background:#fff3e0; border-left:3px solid #ff9800; padding:8px; margin:8px 0; border-radius:3px;">';
495            echo '<span style="color:#e65100; font-size:11px;">⚠️ Log file not writable. Run: <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">chmod 666 ' . $logFile . '</code></span>';
496            echo '</div>';
497        }
498
499        // Show debug info if cron detected
500        if ($cronStatus['active'] && !empty($cronStatus['full_line'])) {
501            // Check if crontab has >> redirect which will cause duplicate log entries
502            $hasRedirect = (strpos($cronStatus['full_line'], '>>') !== false || strpos($cronStatus['full_line'], '> ') !== false);
503
504            if ($hasRedirect) {
505                echo '<div style="background:#fff3e0; border-left:3px solid #ff9800; padding:8px; margin:8px 0; border-radius:3px;">';
506                echo '<span style="color:#e65100; font-size:11px;">⚠️ <strong>Duplicate log entries:</strong> Your crontab has a <code>&gt;&gt;</code> redirect. The sync script logs internally, so this causes duplicate entries. Remove the redirect from your crontab.</span>';
507                echo '</div>';
508            }
509
510            echo '<details style="margin-top:5px;">';
511            echo '<summary style="cursor:pointer; color:#999; font-size:11px;">Show cron details</summary>';
512            echo '<pre style="background:#f0f0f0; padding:8px; border-radius:3px; font-size:10px; margin:5px 0; overflow-x:auto;">' . hsc($cronStatus['full_line']) . '</pre>';
513            echo '</details>';
514        }
515
516        if (!$cronStatus['active']) {
517            echo '<p style="color:#999; font-size:11px; margin:5px 0;">To enable automatic syncing, add to crontab: <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">*/30 * * * * cd ' . DOKU_PLUGIN . 'calendar && php sync_outlook.php</code></p>';
518            echo '<p style="color:#888; font-size:10px; margin:3px 0;"><em>Note: The script logs to ' . $logFile . ' automatically. Do not use &gt;&gt; redirect.</em></p>';
519        }
520
521        echo '</div>';
522
523        // JavaScript for Run Sync Now
524        echo '<script>
525        let syncAbortController = null;
526
527        function runSyncNow() {
528            const btn = document.getElementById("syncBtn");
529            const stopBtn = document.getElementById("stopBtn");
530            const status = document.getElementById("syncStatus");
531
532            btn.disabled = true;
533            btn.style.display = "none";
534            stopBtn.style.display = "inline-block";
535            btn.textContent = "⏳ Running...";
536            btn.style.background = "#999";
537            status.textContent = "Starting sync...";
538            status.style.color = "#00cc07";
539
540            // Create abort controller for this sync
541            syncAbortController = new AbortController();
542
543            fetch("?do=admin&page=calendar&action=run_sync&call=ajax", {
544                method: "POST",
545                signal: syncAbortController.signal
546            })
547                .then(response => response.json())
548                .then(data => {
549                    if (data.success) {
550                        status.textContent = "✅ " + data.message;
551                        status.style.color = "#28a745";
552                    } else {
553                        status.textContent = "❌ " + data.message;
554                        status.style.color = "#dc3545";
555                    }
556                    btn.disabled = false;
557                    btn.style.display = "inline-block";
558                    stopBtn.style.display = "none";
559                    btn.textContent = "▶️ Run Sync Now";
560                    btn.style.background = "#00cc07";
561                    syncAbortController = null;
562
563                    // Clear status after 10 seconds
564                    setTimeout(() => {
565                        status.textContent = "";
566                    }, 10000);
567                })
568                .catch(error => {
569                    if (error.name === "AbortError") {
570                        status.textContent = "⏹️ Sync stopped by user";
571                        status.style.color = "#ff9800";
572                    } else {
573                        status.textContent = "❌ Error: " + error.message;
574                        status.style.color = "#dc3545";
575                    }
576                    btn.disabled = false;
577                    btn.style.display = "inline-block";
578                    stopBtn.style.display = "none";
579                    btn.textContent = "▶️ Run Sync Now";
580                    btn.style.background = "#00cc07";
581                    syncAbortController = null;
582                });
583        }
584
585        function stopSyncNow() {
586            const status = document.getElementById("syncStatus");
587
588            status.textContent = "⏹️ Sending stop signal...";
589            status.style.color = "#ff9800";
590
591            // First, send stop signal to server
592            fetch("?do=admin&page=calendar&action=stop_sync&call=ajax", {
593                method: "POST"
594            })
595            .then(response => response.json())
596            .then(data => {
597                if (data.success) {
598                    status.textContent = "⏹️ Stop signal sent - sync will abort soon";
599                    status.style.color = "#ff9800";
600                } else {
601                    status.textContent = "⚠️ " + data.message;
602                    status.style.color = "#ff9800";
603                }
604            })
605            .catch(error => {
606                status.textContent = "⚠️ Error sending stop signal: " + error.message;
607                status.style.color = "#ff9800";
608            });
609
610            // Also abort the fetch request
611            if (syncAbortController) {
612                syncAbortController.abort();
613                status.textContent = "⏹️ Stopping sync...";
614                status.style.color = "#ff9800";
615            }
616        }
617        </script>';
618
619        // Log Viewer Section - More Compact
620        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0 10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">';
621        echo '<h3 style="margin:0 0 5px 0; color:#00cc07; font-size:16px;">�� Live Sync Log</h3>';
622        echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 8px;">Location: <code style="font-size:10px;">' . $logFile . '</code> • Updates every 2 seconds</p>';
623
624        // Log viewer container
625        echo '<div style="background:#1e1e1e; border-radius:5px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.3);">';
626
627        // Log header - More compact
628        echo '<div style="background:#2d2d2d; padding:6px 10px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #444;">';
629        echo '<span style="color:#00cc07; font-family:monospace; font-weight:bold; font-size:12px;">sync.log</span>';
630        echo '<div>';
631        echo '<button id="pauseBtn" onclick="togglePause()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:3px; cursor:pointer; margin-right:4px; font-size:11px;">⏸ Pause</button>';
632        echo '<button onclick="clearLog()" style="background:#e74c3c; color:white; border:none; padding:4px 8px; border-radius:3px; cursor:pointer; margin-right:4px; font-size:11px;">��️ Clear</button>';
633        echo '<button onclick="downloadLog()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:3px; cursor:pointer; font-size:11px;">�� Download</button>';
634        echo '</div>';
635        echo '</div>';
636
637        // Log content - Reduced height to 250px
638        echo '<pre id="logContent" style="background:#1e1e1e; color:#00cc07; font-family:monospace; font-size:11px; padding:10px; margin:0; overflow-x:auto; white-space:pre-wrap; word-wrap:break-word; line-height:1.4; max-height:250px; overflow-y:auto;">Loading log...</pre>';
639
640        echo '</div>';
641        echo '</div>';
642
643        // JavaScript for log viewer
644        echo '<script>
645        let refreshInterval = null;
646        let isPaused = false;
647
648        function refreshLog() {
649            if (isPaused) return;
650
651            fetch("?do=admin&page=calendar&action=get_log&call=ajax")
652                .then(response => response.json())
653                .then(data => {
654                    const logContent = document.getElementById("logContent");
655                    if (logContent) {
656                        logContent.textContent = data.log || "No log data available";
657                        logContent.scrollTop = logContent.scrollHeight;
658                    }
659                })
660                .catch(error => {
661                    console.error("Error fetching log:", error);
662                });
663        }
664
665        function togglePause() {
666            isPaused = !isPaused;
667            const btn = document.getElementById("pauseBtn");
668            if (isPaused) {
669                btn.textContent = "▶ Resume";
670                btn.style.background = "#00cc07";
671            } else {
672                btn.textContent = "⏸ Pause";
673                btn.style.background = "#666";
674                refreshLog();
675            }
676        }
677
678        function clearLog() {
679            if (!confirm("Clear the sync log file?\\n\\nThis will delete all log entries.")) {
680                return;
681            }
682
683            fetch("?do=admin&page=calendar&action=clear_log&call=ajax", {
684                method: "POST"
685            })
686                .then(response => response.json())
687                .then(data => {
688                    if (data.success) {
689                        refreshLog();
690                        alert("Log cleared successfully");
691                    } else {
692                        alert("Error clearing log: " + data.message);
693                    }
694                })
695                .catch(error => {
696                    alert("Error: " + error.message);
697                });
698        }
699
700        function downloadLog() {
701            window.location.href = "?do=admin&page=calendar&action=download_log";
702        }
703
704        // Start auto-refresh
705        refreshLog();
706        refreshInterval = setInterval(refreshLog, 2000);
707
708        // Cleanup on page unload
709        window.addEventListener("beforeunload", function() {
710            if (refreshInterval) {
711                clearInterval(refreshInterval);
712            }
713        });
714        </script>';
715    }
716
717    private function renderManageTab($colors = null) {
718        global $INPUT;
719
720        // Use defaults if not provided
721        if ($colors === null) {
722            $colors = $this->getTemplateColors();
723        }
724
725        // Show message if present
726        if ($INPUT->has('msg')) {
727            $msg = hsc($INPUT->str('msg'));
728            $type = $INPUT->str('msgtype', 'success');
729            echo "<div style=\"padding:10px; margin:10px 0; border-left:3px solid " . ($type === 'success' ? '#28a745' : '#dc3545') . "; background:" . ($type === 'success' ? '#d4edda' : '#f8d7da') . "; border-radius:3px;\">";
730            echo $msg;
731            echo "</div>";
732        }
733
734        echo '<h2 style="margin:10px 0; font-size:20px;">Manage Calendar Events</h2>';
735
736        // Events Manager Section
737        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
738        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Events Manager</h3>';
739        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 10px;">Scan, export, and import all calendar events across all namespaces.</p>';
740
741        // Get event statistics
742        $stats = $this->getEventStatistics();
743
744        // Statistics display
745        echo '<div style="background:' . $colors['bg'] . '; padding:10px; border-radius:3px; margin-bottom:10px; border:1px solid ' . $colors['border'] . ';">';
746        echo '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:10px; font-size:12px;">';
747
748        echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">';
749        echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_events'] . '</div>';
750        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Total Events</div>';
751        echo '</div>';
752
753        echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">';
754        echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_namespaces'] . '</div>';
755        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Namespaces</div>';
756        echo '</div>';
757
758        echo '<div style="background:#e8f5e9; padding:8px; border-radius:3px; text-align:center;">';
759        echo '<div style="font-size:24px; font-weight:bold; color:#388e3c;">' . $stats['total_files'] . '</div>';
760        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">JSON Files</div>';
761        echo '</div>';
762
763        echo '<div style="background:#fff3e0; padding:8px; border-radius:3px; text-align:center;">';
764        echo '<div style="font-size:24px; font-weight:bold; color:#f57c00;">' . $stats['total_recurring'] . '</div>';
765        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Recurring</div>';
766        echo '</div>';
767
768        echo '</div>';
769
770        // Last scan time
771        if (!empty($stats['last_scan'])) {
772            echo '<div style="margin-top:8px; color:' . $colors['text'] . '; font-size:10px;">Last scanned: ' . hsc($stats['last_scan']) . '</div>';
773        }
774
775        echo '</div>';
776
777        // Action buttons
778        echo '<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:10px;">';
779
780        // Rescan button
781        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">';
782        echo '<input type="hidden" name="action" value="rescan_events">';
783        echo '<button type="submit" style="background:#00cc07; color:white; border:none; padding:8px 16px; border-radius:3px; cursor:pointer; font-size:12px; display:flex; align-items:center; gap:6px;">';
784        echo '<span>��</span><span>Re-scan Events</span>';
785        echo '</button>';
786        echo '</form>';
787
788        // Export button
789        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">';
790        echo '<input type="hidden" name="action" value="export_all_events">';
791        echo '<button type="submit" style="background:#7b1fa2; color:white; border:none; padding:8px 16px; border-radius:3px; cursor:pointer; font-size:12px; display:flex; align-items:center; gap:6px;">';
792        echo '<span>��</span><span>Export All Events</span>';
793        echo '</button>';
794        echo '</form>';
795
796        // Import button (with file upload)
797        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" enctype="multipart/form-data" style="display:inline;" onsubmit="return confirm(\'Import will merge with existing events. Continue?\')">';
798        echo '<input type="hidden" name="action" value="import_all_events">';
799        echo '<label style="background:#7b1fa2; color:white; border:none; padding:8px 16px; border-radius:3px; cursor:pointer; font-size:12px; display:inline-flex; align-items:center; gap:6px;">';
800        echo '<span>��</span><span>Import Events</span>';
801        echo '<input type="file" name="import_file" accept=".json,.zip" required style="display:none;" onchange="this.form.submit()">';
802        echo '</label>';
803        echo '</form>';
804
805        echo '</div>';
806
807        // Breakdown by namespace
808        if (!empty($stats['by_namespace'])) {
809            echo '<details style="margin-top:12px;">';
810            echo '<summary style="cursor:pointer; padding:6px; background:#e9e9e9; border-radius:3px; font-size:11px; font-weight:bold;">View Breakdown by Namespace</summary>';
811            echo '<div style="margin-top:8px; max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">';
812            echo '<table style="width:100%; border-collapse:collapse; font-size:11px;">';
813            echo '<thead style="position:sticky; top:0; background:#f5f5f5;">';
814            echo '<tr>';
815            echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">Namespace</th>';
816            echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">Events</th>';
817            echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">Files</th>';
818            echo '</tr></thead><tbody>';
819
820            foreach ($stats['by_namespace'] as $ns => $nsStats) {
821                echo '<tr style="border-bottom:1px solid #eee;">';
822                echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($ns ?: '(default)') . '</code></td>';
823                echo '<td style="padding:4px 6px; text-align:right;"><strong>' . $nsStats['events'] . '</strong></td>';
824                echo '<td style="padding:4px 6px; text-align:right;">' . $nsStats['files'] . '</td>';
825                echo '</tr>';
826            }
827
828            echo '</tbody></table>';
829            echo '</div>';
830            echo '</details>';
831        }
832
833        echo '</div>';
834
835        // Important Namespaces Section
836        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
837        $importantConfig = [];
838        if (file_exists($configFile)) {
839            $importantConfig = include $configFile;
840        }
841        $importantNsValue = isset($importantConfig['important_namespaces']) ? $importantConfig['important_namespaces'] : 'important';
842
843        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
844        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">⭐ Important Namespaces</h3>';
845        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">Events from these namespaces will be visually highlighted throughout the calendar:</p>';
846
847        // Effects description
848        echo '<div style="background:rgba(0,204,7,0.05); padding:8px 10px; margin:0 0 10px; border-radius:3px; font-size:10px; color:' . $colors['text'] . ';">';
849        echo '<strong style="color:#00cc07;">Visual Effects:</strong><br>';
850        echo '• <strong>Calendar Grid:</strong> ⭐ star icon on event bars<br>';
851        echo '• <strong>Event Sidebar:</strong> ⭐ star + highlighted background + accent border<br>';
852        echo '• <strong>Sidebar Widget:</strong> Dedicated "Important Events" section + highlighting<br>';
853        echo '• <strong>Day Popup:</strong> Events shown with full details';
854        echo '</div>';
855
856        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:flex; gap:8px; align-items:center;">';
857        echo '<input type="hidden" name="action" value="save_important_namespaces">';
858        echo '<input type="text" name="important_namespaces" value="' . hsc($importantNsValue) . '" style="flex:1; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;" placeholder="important,urgent,priority">';
859        echo '<button type="submit" style="background:#00cc07; color:white; padding:6px 16px; border:none; border-radius:3px; cursor:pointer; font-size:12px; font-weight:bold; white-space:nowrap;">Save</button>';
860        echo '</form>';
861        echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:4px 0 0;">Comma-separated list of namespace names (e.g., "important,urgent,bills")</p>';
862        echo '</div>';
863
864        // Cleanup Events Section
865        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
866        echo '<h3 style="margin:0 0 6px 0; color:#00cc07; font-size:16px;">�� Cleanup Old Events</h3>';
867        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 12px;">Delete events based on criteria below. Automatic backup created before deletion.</p>';
868
869        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="cleanupForm">';
870        echo '<input type="hidden" name="action" value="cleanup_events">';
871
872        // Compact options layout
873        echo '<div style="background:' . $colors['bg'] . '; padding:10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; margin-bottom:10px;">';
874
875        // Radio buttons in a row
876        echo '<div style="display:flex; gap:20px; margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid #f0f0f0;">';
877        echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">';
878        echo '<input type="radio" name="cleanup_type" value="age" checked onchange="updateCleanupOptions()">';
879        echo '<span>By Age</span>';
880        echo '</label>';
881        echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">';
882        echo '<input type="radio" name="cleanup_type" value="status" onchange="updateCleanupOptions()">';
883        echo '<span>By Status</span>';
884        echo '</label>';
885        echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">';
886        echo '<input type="radio" name="cleanup_type" value="range" onchange="updateCleanupOptions()">';
887        echo '<span>By Date Range</span>';
888        echo '</label>';
889        echo '</div>';
890
891        // Age options
892        echo '<div id="age-options" style="padding:6px 0;">';
893        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">Delete events older than:</span>';
894        echo '<select name="age_value" style="width:50px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:4px;">';
895        for ($i = 1; $i <= 24; $i++) {
896            $sel = $i === 6 ? ' selected' : '';
897            echo '<option value="' . $i . '"' . $sel . '>' . $i . '</option>';
898        }
899        echo '</select>';
900        echo '<select name="age_unit" style="width:80px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">';
901        echo '<option value="months" selected>months</option>';
902        echo '<option value="years">years</option>';
903        echo '</select>';
904        echo '</div>';
905
906        // Status options
907        echo '<div id="status-options" style="padding:6px 0; opacity:0.4;">';
908        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">Delete:</span>';
909        echo '<label style="display:inline-block; font-size:11px; margin-right:12px; cursor:pointer;"><input type="checkbox" name="delete_completed" value="1" style="margin-right:3px;"> Completed tasks</label>';
910        echo '<label style="display:inline-block; font-size:11px; cursor:pointer;"><input type="checkbox" name="delete_past" value="1" style="margin-right:3px;"> Past events</label>';
911        echo '</div>';
912
913        // Range options
914        echo '<div id="range-options" style="padding:6px 0; opacity:0.4;">';
915        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">From:</span>';
916        echo '<input type="date" name="range_start" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:10px;">';
917        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">To:</span>';
918        echo '<input type="date" name="range_end" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">';
919        echo '</div>';
920
921        echo '</div>';
922
923        // Namespace filter - compact
924        echo '<div style="background:' . $colors['bg'] . '; padding:8px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">';
925        echo '<label style="font-size:11px; font-weight:600; white-space:nowrap; color:#555;">Namespace:</label>';
926        echo '<input type="text" name="namespace_filter" placeholder="Leave empty for all, or specify: work, personal, etc." style="flex:1; padding:4px 8px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">';
927        echo '</div>';
928
929        // Action buttons - compact row
930        echo '<div style="display:flex; gap:8px; align-items:center;">';
931        echo '<button type="button" onclick="previewCleanup()" style="background:#7b1fa2; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">��️ Preview</button>';
932        echo '<button type="submit" onclick="return confirmCleanup()" style="background:#dc3545; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">��️ Delete</button>';
933        echo '<span style="font-size:10px; color:#999;">⚠️ Backup created automatically</span>';
934        echo '</div>';
935
936        echo '</form>';
937
938        // Preview results area
939        echo '<div id="cleanup-preview" style="margin-top:10px; display:none;"></div>';
940
941        echo '<script>
942        function updateCleanupOptions() {
943            const type = document.querySelector(\'input[name="cleanup_type"]:checked\').value;
944
945            // Show selected, gray out others
946            document.getElementById(\'age-options\').style.opacity = type === \'age\' ? \'1\' : \'0.4\';
947            document.getElementById(\'status-options\').style.opacity = type === \'status\' ? \'1\' : \'0.4\';
948            document.getElementById(\'range-options\').style.opacity = type === \'range\' ? \'1\' : \'0.4\';
949
950            // Enable/disable inputs
951            document.querySelectorAll(\'#age-options select\').forEach(el => el.disabled = type !== \'age\');
952            document.querySelectorAll(\'#status-options input\').forEach(el => el.disabled = type !== \'status\');
953            document.querySelectorAll(\'#range-options input\').forEach(el => el.disabled = type !== \'range\');
954        }
955
956        function previewCleanup() {
957            const form = document.getElementById(\'cleanupForm\');
958            const formData = new FormData(form);
959            formData.set(\'action\', \'preview_cleanup\');
960
961            const preview = document.getElementById(\'cleanup-preview\');
962            preview.innerHTML = \'<div style="text-align:center; padding:20px; color:' . $colors['text'] . ';">Loading preview...</div>\';
963            preview.style.display = \'block\';
964
965            fetch(\'?do=admin&page=calendar&tab=manage\', {
966                method: \'POST\',
967                body: new URLSearchParams(formData)
968            })
969            .then(r => r.json())
970            .then(data => {
971                if (data.count === 0) {
972                    let html = \'<div style="background:#d4edda; border:1px solid #c3e6cb; padding:10px; border-radius:3px; font-size:12px; color:#155724;">✅ No events match the criteria. Nothing would be deleted.</div>\';
973
974                    // Show debug info if available
975                    if (data.debug) {
976                        html += \'<details style="margin-top:8px; font-size:11px; color:' . $colors['text'] . ';">\';
977                        html += \'<summary style="cursor:pointer;">Debug Info</summary>\';
978                        html += \'<pre style="background:#f5f5f5; padding:6px; margin-top:4px; border-radius:3px; overflow-x:auto;">\' + JSON.stringify(data.debug, null, 2) + \'</pre>\';
979                        html += \'</details>\';
980                    }
981
982                    preview.innerHTML = html;
983                } else {
984                    let html = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">\';
985                    html += \'<strong>⚠️ Warning:</strong> The following \' + data.count + \' event(s) would be deleted:<br><br>\';
986                    html += \'<div style="max-height:150px; overflow-y:auto; margin-top:6px; background:' . $colors['bg'] . '; padding:6px; border-radius:3px;">\';
987                    data.events.forEach(evt => {
988                        html += \'<div style="padding:3px; border-bottom:1px solid #eee; font-size:11px;">\';
989                        html += \'\' + evt.title + \' (\' + evt.date + \')\';
990                        if (evt.namespace) html += \' <span style="background:#e3f2fd; padding:1px 4px; border-radius:2px; font-size:9px;">\' + evt.namespace + \'</span>\';
991                        html += \'</div>\';
992                    });
993                    html += \'</div></div>\';
994                    preview.innerHTML = html;
995                }
996            })
997            .catch(err => {
998                preview.innerHTML = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">Error loading preview</div>\';
999            });
1000        }
1001
1002        function confirmCleanup() {
1003            return confirm(\'Are you sure you want to delete these events? A backup will be created first, but this action cannot be easily undone.\');
1004        }
1005
1006        updateCleanupOptions();
1007        </script>';
1008
1009        echo '</div>';
1010
1011        // Recurring Events Section
1012        echo '<div id="recurring-section" style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
1013        echo '<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">';
1014        echo '<h3 style="margin:0; color:#00cc07; font-size:16px;">�� Recurring Events</h3>';
1015        echo '<div style="display:flex; gap:6px;">';
1016        echo '<button onclick="trimAllPastRecurring()" id="trim-all-past-btn" style="background:#e74c3c; color:#fff; border:none; padding:4px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600; transition:all 0.15s;" onmouseover="this.style.filter=\'brightness(1.2)\'" onmouseout="this.style.filter=\'none\'">✂️ Trim All Past</button>';
1017        echo '<button onclick="rescanRecurringEvents()" id="rescan-recurring-btn" style="background:#00cc07; color:#fff; border:none; padding:4px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600; transition:all 0.15s;" onmouseover="this.style.filter=\'brightness(1.2)\'" onmouseout="this.style.filter=\'none\'">�� Rescan</button>';
1018        echo '</div>';
1019        echo '</div>';
1020
1021        $recurringEvents = $this->findRecurringEvents();
1022
1023        echo '<div id="recurring-content">';
1024        $this->renderRecurringTable($recurringEvents, $colors);
1025        echo '</div>';
1026        echo '</div>';
1027
1028        // Compact Tree-based Namespace Manager
1029        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
1030        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Namespace Explorer</h3>';
1031        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">Select events and move between namespaces. Drag & drop also supported.</p>';
1032
1033        // Search bar
1034        echo '<div style="margin-bottom:8px;">';
1035        echo '<input type="text" id="searchEvents" onkeyup="filterEvents()" placeholder="�� Search events by title..." style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">';
1036        echo '</div>';
1037
1038        $eventsByNamespace = $this->getEventsByNamespace();
1039
1040        // Control bar
1041        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="moveForm">';
1042        echo '<input type="hidden" name="action" value="move_selected_events" id="formAction">';
1043        echo '<div style="background:#2d2d2d; color:white; padding:6px 10px; border-radius:3px; margin-bottom:8px; display:flex; gap:8px; align-items:center; font-size:12px;">';
1044        echo '<button type="button" onclick="selectAll()" style="background:#00cc07; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☑ All</button>';
1045        echo '<button type="button" onclick="deselectAll()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☐ None</button>';
1046        echo '<button type="button" onclick="deleteSelected()" style="background:#e74c3c; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px; margin-left:10px;">��️ Delete</button>';
1047        echo '<span style="margin-left:10px;">Move to:</span>';
1048        echo '<input list="namespaceList" name="target_namespace" required style="padding:3px 6px; border:1px solid ' . $colors['border'] . '; border-radius:2px; font-size:11px; min-width:150px;" placeholder="Type or select...">';
1049        echo '<datalist id="namespaceList">';
1050        echo '<option value="">(default)</option>';
1051        foreach (array_keys($eventsByNamespace) as $ns) {
1052            if ($ns !== '') {
1053                echo '<option value="' . hsc($ns) . '">' . hsc($ns) . '</option>';
1054            }
1055        }
1056        echo '</datalist>';
1057        echo '<button type="submit" style="background:#00cc07; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold;">➡️ Move</button>';
1058        echo '<button type="button" onclick="createNewNamespace()" style="background:#7b1fa2; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold; margin-left:5px;">➕ New Namespace</button>';
1059        echo '<button type="button" onclick="cleanupEmptyNamespaces()" id="cleanup-ns-btn" style="background:#e74c3c; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold; margin-left:5px;">�� Cleanup</button>';
1060        echo '<span id="selectedCount" style="margin-left:auto; color:#00cc07; font-size:11px;">0 selected</span>';
1061        echo '</div>';
1062
1063        // Cleanup status message - displayed prominently after control bar
1064        echo '<div id="cleanup-ns-status" style="font-size:12px; margin-bottom:8px; min-height:18px;"></div>';
1065
1066        echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">';
1067
1068        // Event list with checkboxes
1069        echo '<div>';
1070        echo '<div style="max-height:450px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">';
1071
1072        foreach ($eventsByNamespace as $namespace => $data) {
1073            $nsId = 'ns_' . md5($namespace);
1074            $events = isset($data['events']) && is_array($data['events']) ? $data['events'] : [];
1075            $eventCount = count($events);
1076
1077            echo '<div style="border-bottom:1px solid #ddd;">';
1078
1079            // Namespace header - ultra compact
1080            echo '<div style="background:#f5f5f5; padding:3px 6px; display:flex; justify-content:space-between; align-items:center; font-size:11px;">';
1081            echo '<div style="display:flex; align-items:center; gap:4px;">';
1082            echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; width:12px; display:inline-block; font-size:10px;"><span id="' . $nsId . '_arrow">▶</span></span>';
1083            echo '<input type="checkbox" onclick="toggleNamespaceSelect(\'' . $nsId . '\')" id="' . $nsId . '_check" style="margin:0; width:12px; height:12px;">';
1084            echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; font-weight:600; font-size:11px;">�� ' . hsc($namespace ?: '(default)') . '</span>';
1085            echo '</div>';
1086            echo '<div style="display:flex; gap:3px; align-items:center;">';
1087            echo '<span style="background:#00cc07; color:white; padding:0px 4px; border-radius:6px; font-size:9px; line-height:14px;">' . $eventCount . '</span>';
1088            echo '<button type="button" onclick="renameNamespace(\'' . hsc($namespace) . '\')" style="background:#3498db; color:white; border:none; padding:1px 4px; border-radius:2px; cursor:pointer; font-size:9px; line-height:14px;" title="Rename namespace">✏️</button>';
1089            echo '<button type="button" onclick="deleteNamespace(\'' . hsc($namespace) . '\')" style="background:#e74c3c; color:white; border:none; padding:1px 4px; border-radius:2px; cursor:pointer; font-size:9px; line-height:14px;">��️</button>';
1090            echo '</div>';
1091            echo '</div>';
1092
1093            // Events - ultra compact
1094            echo '<div id="' . $nsId . '" style="display:none; max-height:150px; overflow-y:auto;">';
1095            foreach ($events as $event) {
1096                $eventId = $event['id'] . '|' . $namespace . '|' . $event['date'] . '|' . $event['month'];
1097                $checkId = 'evt_' . md5($eventId);
1098
1099                echo '<div draggable="true" ondragstart="dragStart(event, \'' . hsc($eventId) . '\')" style="padding:2px 6px 2px 16px; border-bottom:1px solid #f8f8f8; display:flex; align-items:center; gap:4px; font-size:10px; cursor:move;" class="event-row" onmouseover="this.style.background=\'#f9f9f9\'" onmouseout="this.style.background=\'white\'">';
1100                echo '<input type="checkbox" name="events[]" value="' . hsc($eventId) . '" class="event-checkbox ' . $nsId . '_events" id="' . $checkId . '" onclick="updateCount()" style="margin:0; width:12px; height:12px;">';
1101                echo '<div style="flex:1; min-width:0;">';
1102                echo '<div style="font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:10px;">' . hsc($event['title']) . '</div>';
1103                echo '<div style="color:#999; font-size:9px;">' . hsc($event['date']) . ($event['startTime'] ? ' • ' . hsc($event['startTime']) : '') . '</div>';
1104                echo '</div>';
1105                echo '</div>';
1106            }
1107            echo '</div>';
1108            echo '</div>';
1109        }
1110
1111        echo '</div>';
1112        echo '</div>';
1113
1114        // Drop zones - ultra compact
1115        echo '<div>';
1116        echo '<div style="background:#00cc07; color:white; padding:3px 6px; border-radius:3px 3px 0 0; font-size:11px; font-weight:bold;">�� Drop Target</div>';
1117        echo '<div style="border:1px solid ' . $colors['border'] . '; border-top:none; border-radius:0 0 3px 3px; max-height:450px; overflow-y:auto; background:' . $colors['bg'] . ';">';
1118
1119        foreach (array_keys($eventsByNamespace) as $namespace) {
1120            echo '<div ondrop="drop(event, \'' . hsc($namespace) . '\')" ondragover="allowDrop(event)" style="padding:5px 6px; border-bottom:1px solid #eee; background:' . $colors['bg'] . '; min-height:28px;" onmouseover="this.style.background=\'#f0fff0\'" onmouseout="this.style.background=\'white\'">';
1121            echo '<div style="font-size:11px; font-weight:600; color:#00cc07;">�� ' . hsc($namespace ?: '(default)') . '</div>';
1122            echo '<div style="color:#999; font-size:9px; margin-top:1px;">Drop here</div>';
1123            echo '</div>';
1124        }
1125
1126        echo '</div>';
1127        echo '</div>';
1128
1129        echo '</div>'; // end grid
1130        echo '</form>';
1131
1132        echo '</div>';
1133
1134        // JavaScript
1135        echo '<script>
1136        var adminColors = {
1137            text: "' . $colors['text'] . '",
1138            bg: "' . $colors['bg'] . '",
1139            border: "' . $colors['border'] . '"
1140        };
1141        // Table sorting functionality - defined early so onclick handlers work
1142        let sortDirection = {}; // Track sort direction for each column
1143
1144        function cleanupEmptyNamespaces() {
1145            var btn = document.getElementById("cleanup-ns-btn");
1146            var status = document.getElementById("cleanup-ns-status");
1147            if (btn) { btn.textContent = "⏳ Scanning..."; btn.disabled = true; }
1148            if (status) { status.innerHTML = ""; }
1149
1150            // Dry run first
1151            fetch(DOKU_BASE + "lib/exe/ajax.php", {
1152                method: "POST",
1153                headers: {"Content-Type": "application/x-www-form-urlencoded"},
1154                body: "call=plugin_calendar&action=cleanup_empty_namespaces&dry_run=1&sectok=" + JSINFO.sectok
1155            })
1156            .then(function(r) { return r.json(); })
1157            .then(function(data) {
1158                if (btn) { btn.textContent = "�� Cleanup"; btn.disabled = false; }
1159                if (!data.success) {
1160                    if (status) { status.innerHTML = "<span style=\\\'color:#e74c3c;\\\'>❌ " + (data.error || "Failed") + "</span>"; }
1161                    return;
1162                }
1163
1164                var details = data.details || [];
1165                var totalActions = details.length;
1166
1167                if (totalActions === 0) {
1168                    if (status) { status.innerHTML = "<span style=\\\'color:#00cc07;\\\'>✅ No empty namespaces or orphan calendar folders found.</span>"; }
1169                    return;
1170                }
1171
1172                // Build detail list for confirm
1173                var msg = "Found " + totalActions + " item(s) to clean up:\\n\\n";
1174                for (var i = 0; i < details.length; i++) {
1175                    msg += "• " + details[i] + "\\n";
1176                }
1177                msg += "\\nProceed with cleanup?";
1178
1179                if (!confirm(msg)) return;
1180
1181                // Execute
1182                if (btn) { btn.textContent = "⏳ Cleaning..."; btn.disabled = true; }
1183                fetch(DOKU_BASE + "lib/exe/ajax.php", {
1184                    method: "POST",
1185                    headers: {"Content-Type": "application/x-www-form-urlencoded"},
1186                    body: "call=plugin_calendar&action=cleanup_empty_namespaces&sectok=" + JSINFO.sectok
1187                })
1188                .then(function(r) { return r.json(); })
1189                .then(function(data2) {
1190                    var msgText = data2.message || "Cleanup complete";
1191                    if (data2.details && data2.details.length > 0) {
1192                        msgText += " (" + data2.details.join(", ") + ")";
1193                    }
1194                    window.location.href = "?do=admin&page=calendar&tab=manage&msg=" + encodeURIComponent(msgText) + "&msgtype=success";
1195                });
1196            })
1197            .catch(function(err) {
1198                if (btn) { btn.textContent = "�� Cleanup"; btn.disabled = false; }
1199                if (status) { status.innerHTML = "<span style=\\\'color:#e74c3c;\\\'>❌ Error: " + err + "</span>"; }
1200            });
1201        }
1202        function trimAllPastRecurring() {
1203            var btn = document.getElementById("trim-all-past-btn");
1204            if (btn) { btn.textContent = "⏳ Counting..."; btn.disabled = true; }
1205
1206            // Step 1: dry run to get count
1207            fetch(DOKU_BASE + "lib/exe/ajax.php", {
1208                method: "POST",
1209                headers: {"Content-Type": "application/x-www-form-urlencoded"},
1210                body: "call=plugin_calendar&action=trim_all_past_recurring&dry_run=1&sectok=" + JSINFO.sectok
1211            })
1212            .then(function(r) { return r.json(); })
1213            .then(function(data) {
1214                if (btn) { btn.textContent = "✂️ Trim All Past"; btn.disabled = false; }
1215                var count = data.count || 0;
1216                if (count === 0) {
1217                    alert("No past recurring events found to remove.");
1218                    return;
1219                }
1220                if (!confirm("Found " + count + " past recurring event" + (count !== 1 ? "s" : "") + " to remove.\n\nThis cannot be undone. Proceed?")) return;
1221
1222                // Step 2: actually delete
1223                if (btn) { btn.textContent = "⏳ Trimming..."; btn.disabled = true; }
1224                fetch(DOKU_BASE + "lib/exe/ajax.php", {
1225                    method: "POST",
1226                    headers: {"Content-Type": "application/x-www-form-urlencoded"},
1227                    body: "call=plugin_calendar&action=trim_all_past_recurring&sectok=" + JSINFO.sectok
1228                })
1229                .then(function(r) { return r.json(); })
1230                .then(function(data2) {
1231                    if (btn) {
1232                        btn.textContent = data2.success ? ("✅ Removed " + (data2.count || 0)) : "❌ Failed";
1233                        btn.disabled = false;
1234                    }
1235                    setTimeout(function() { if (btn) btn.textContent = "✂️ Trim All Past"; }, 3000);
1236                    rescanRecurringEvents();
1237                });
1238            })
1239            .catch(function(err) {
1240                if (btn) { btn.textContent = "✂️ Trim All Past"; btn.disabled = false; }
1241            });
1242        }
1243
1244        function rescanRecurringEvents() {
1245            var btn = document.getElementById("rescan-recurring-btn");
1246            var content = document.getElementById("recurring-content");
1247            if (btn) { btn.textContent = "⏳ Scanning..."; btn.disabled = true; }
1248
1249            fetch(DOKU_BASE + "lib/exe/ajax.php", {
1250                method: "POST",
1251                headers: {"Content-Type": "application/x-www-form-urlencoded"},
1252                body: "call=plugin_calendar&action=rescan_recurring&sectok=" + JSINFO.sectok
1253            })
1254            .then(function(r) { return r.json(); })
1255            .then(function(data) {
1256                if (data.success && content) {
1257                    content.innerHTML = data.html;
1258                }
1259                if (btn) { btn.textContent = "�� Rescan (" + (data.count || 0) + " found)"; btn.disabled = false; }
1260                setTimeout(function() { if (btn) btn.textContent = "�� Rescan"; }, 3000);
1261            })
1262            .catch(function(err) {
1263                if (btn) { btn.textContent = "�� Rescan"; btn.disabled = false; }
1264                console.error("Rescan failed:", err);
1265            });
1266        }
1267
1268        function recurringAction(action, params, statusEl) {
1269            if (statusEl) statusEl.textContent = "⏳ Working...";
1270            var body = "call=plugin_calendar&action=" + action + "&sectok=" + JSINFO.sectok;
1271            for (var key in params) {
1272                body += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
1273            }
1274            return fetch(DOKU_BASE + "lib/exe/ajax.php", {
1275                method: "POST",
1276                headers: {"Content-Type": "application/x-www-form-urlencoded"},
1277                body: body
1278            })
1279            .then(function(r) { return r.json(); })
1280            .then(function(data) {
1281                if (statusEl) {
1282                    statusEl.textContent = data.success ? ("✅ " + data.message) : ("❌ " + (data.error || "Failed"));
1283                    statusEl.style.color = data.success ? "#00cc07" : "#e74c3c";
1284                }
1285                return data;
1286            })
1287            .catch(function(err) {
1288                if (statusEl) { statusEl.textContent = "❌ Error: " + err; statusEl.style.color = "#e74c3c"; }
1289            });
1290        }
1291
1292        function manageRecurringSeries(title, namespace, count, firstDate, lastDate, pattern, hasFlag) {
1293            var isPaused = title.indexOf("⏸") === 0;
1294            var cleanTitle = title.replace(/^⏸\s*/, "");
1295            var safeTitle = title.replace(/\x27/g, "\\\x27");
1296            var todayStr = new Date().toISOString().split("T")[0];
1297
1298            var dialog = document.createElement("div");
1299            dialog.style.cssText = "position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:10000;";
1300            dialog.addEventListener("click", function(e) { if (e.target === dialog) dialog.remove(); });
1301
1302            var h = "<div style=\"background:' . $colors['bg'] . '; padding:20px; border-radius:8px; min-width:520px; max-width:700px; max-height:90vh; overflow-y:auto; font-family:system-ui,sans-serif;\">";
1303            h += "<h3 style=\"margin:0 0 5px; color:#00cc07;\">⚙️ Manage Recurring Series</h3>";
1304            h += "<p style=\"margin:0 0 15px; color:' . $colors['text'] . '; font-size:13px;\"><strong>" + cleanTitle + "</strong><br>" + count + " occurrences · " + pattern + "<br>" + firstDate + " → " + lastDate + "</p>";
1305            h += "<div id=\"manage-status\" style=\"font-size:12px; min-height:18px; margin-bottom:10px;\"></div>";
1306
1307            // Extend
1308            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
1309            h += "<div style=\"font-weight:700; color:#00cc07; font-size:12px; margin-bottom:6px;\">�� Extend Series</div>";
1310            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
1311            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Add occurrences:</label>";
1312            h += "<input type=\"number\" id=\"manage-extend-count\" value=\"4\" min=\"1\" max=\"52\" style=\"width:60px; padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\"></div>";
1313            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Days apart:</label>";
1314            h += "<select id=\"manage-extend-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">";
1315            h += "<option value=\"1\">Daily</option><option value=\"7\" selected>Weekly</option><option value=\"14\">Bi-weekly</option><option value=\"30\">Monthly</option><option value=\"90\">Quarterly</option><option value=\"365\">Yearly</option></select></div>";
1316            h += "<button onclick=\"recurringAction(\x27extend_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, count:document.getElementById(\x27manage-extend-count\x27).value, interval_days:document.getElementById(\x27manage-extend-interval\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#00cc07; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Extend</button>";
1317            h += "</div></div>";
1318
1319            // Trim
1320            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
1321            h += "<div style=\"font-weight:700; color:#e74c3c; font-size:12px; margin-bottom:6px;\">✂️ Trim Past Events</div>";
1322            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
1323            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Remove before:</label>";
1324            h += "<input type=\"date\" id=\"manage-trim-date\" value=\"" + todayStr + "\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\"></div>";
1325            h += "<button onclick=\"if(confirm(\x27Remove all occurrences before \x27 + document.getElementById(\x27manage-trim-date\x27).value + \x27?\x27)) recurringAction(\x27trim_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, cutoff_date:document.getElementById(\x27manage-trim-date\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#e74c3c; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Trim</button>";
1326            h += "</div></div>";
1327
1328            // Change Pattern
1329            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
1330            h += "<div style=\"font-weight:700; color:#ff9800; font-size:12px; margin-bottom:6px;\">�� Change Pattern</div>";
1331            h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">Respaces future occurrences only. Past events stay in place.</p>";
1332            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
1333            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">New interval:</label>";
1334            h += "<select id=\"manage-pattern-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">";
1335            h += "<option value=\"1\">Daily</option><option value=\"7\">Weekly</option><option value=\"14\">Bi-weekly</option><option value=\"30\">Monthly</option><option value=\"90\">Quarterly</option><option value=\"365\">Yearly</option></select></div>";
1336            h += "<button onclick=\"if(confirm(\x27Respace all future occurrences?\x27)) recurringAction(\x27change_pattern_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, interval_days:document.getElementById(\x27manage-pattern-interval\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#ff9800; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Change</button>";
1337            h += "</div></div>";
1338
1339            // Change Start Date
1340            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
1341            h += "<div style=\"font-weight:700; color:#2196f3; font-size:12px; margin-bottom:6px;\">�� Change Start Date</div>";
1342            h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">Shifts ALL occurrences by the difference between old and new start date.</p>";
1343            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
1344            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Current: " + firstDate + "</label>";
1345            h += "<input type=\"date\" id=\"manage-start-date\" value=\"" + firstDate + "\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\"></div>";
1346            h += "<button onclick=\"if(confirm(\x27Shift all occurrences to new start date?\x27)) recurringAction(\x27change_start_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, new_start_date:document.getElementById(\x27manage-start-date\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#2196f3; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Shift</button>";
1347            h += "</div></div>";
1348
1349            // Pause/Resume
1350            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
1351            h += "<div style=\"font-weight:700; color:#9c27b0; font-size:12px; margin-bottom:6px;\">" + (isPaused ? "▶️ Resume Series" : "⏸ Pause Series") + "</div>";
1352            h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">" + (isPaused ? "Removes ⏸ prefix and paused flag from all occurrences." : "Adds ⏸ prefix to future occurrences. They remain in the calendar but are visually marked as paused.") + "</p>";
1353            h += "<button onclick=\"recurringAction(\x27" + (isPaused ? "resume_recurring" : "pause_recurring") + "\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27}, document.getElementById(\x27manage-status\x27))\" style=\"background:#9c27b0; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">" + (isPaused ? "▶️ Resume" : "⏸ Pause") + "</button>";
1354            h += "</div>";
1355
1356            // Close
1357            h += "<div style=\"text-align:right; margin-top:10px;\">";
1358            h += "<button onclick=\"this.closest(\x27[style*=fixed]\x27).remove(); rescanRecurringEvents();\" style=\"background:#666; color:#fff; border:none; padding:8px 20px; border-radius:3px; cursor:pointer; font-weight:600;\">Close</button>";
1359            h += "</div></div>";
1360
1361            dialog.innerHTML = h;
1362            document.body.appendChild(dialog);
1363        }
1364
1365        function sortRecurringTable(columnIndex) {
1366            const table = document.getElementById("recurringTable");
1367            const tbody = document.getElementById("recurringTableBody");
1368
1369            if (!table || !tbody) return;
1370
1371            const rows = Array.from(tbody.querySelectorAll("tr"));
1372            if (rows.length === 0) return;
1373
1374            // Toggle sort direction for this column
1375            if (!sortDirection[columnIndex]) {
1376                sortDirection[columnIndex] = "asc";
1377            } else {
1378                sortDirection[columnIndex] = sortDirection[columnIndex] === "asc" ? "desc" : "asc";
1379            }
1380
1381            const direction = sortDirection[columnIndex];
1382            const isNumeric = columnIndex === 4; // Count column
1383
1384            // Sort rows
1385            rows.sort((a, b) => {
1386                let aValue = a.cells[columnIndex].textContent.trim();
1387                let bValue = b.cells[columnIndex].textContent.trim();
1388
1389                // Extract text from code elements for namespace column
1390                if (columnIndex === 1) {
1391                    const aCode = a.cells[columnIndex].querySelector("code");
1392                    const bCode = b.cells[columnIndex].querySelector("code");
1393                    aValue = aCode ? aCode.textContent.trim() : aValue;
1394                    bValue = bCode ? bCode.textContent.trim() : bValue;
1395                }
1396
1397                // Extract number from strong elements for count column
1398                if (isNumeric) {
1399                    const aStrong = a.cells[columnIndex].querySelector("strong");
1400                    const bStrong = b.cells[columnIndex].querySelector("strong");
1401                    aValue = aStrong ? parseInt(aStrong.textContent.trim()) : 0;
1402                    bValue = bStrong ? parseInt(bStrong.textContent.trim()) : 0;
1403
1404                    return direction === "asc" ? aValue - bValue : bValue - aValue;
1405                }
1406
1407                // String comparison
1408                if (direction === "asc") {
1409                    return aValue.localeCompare(bValue);
1410                } else {
1411                    return bValue.localeCompare(aValue);
1412                }
1413            });
1414
1415            // Update arrows
1416            const headers = table.querySelectorAll("th");
1417            headers.forEach((header, index) => {
1418                const arrow = header.querySelector(".sort-arrow");
1419                if (arrow) {
1420                    if (index === columnIndex) {
1421                        arrow.textContent = direction === "asc" ? "↑" : "↓";
1422                        arrow.style.color = "#00cc07";
1423                    } else {
1424                        arrow.textContent = "⇅";
1425                        arrow.style.color = "#999";
1426                    }
1427                }
1428            });
1429
1430            // Rebuild tbody
1431            rows.forEach(row => tbody.appendChild(row));
1432        }
1433
1434        function filterRecurringEvents() {
1435            const searchInput = document.getElementById("searchRecurring");
1436            const filter = normalizeText(searchInput.value);
1437            const tbody = document.getElementById("recurringTableBody");
1438            const rows = tbody.getElementsByTagName("tr");
1439
1440            for (let i = 0; i < rows.length; i++) {
1441                const row = rows[i];
1442                const titleCell = row.getElementsByTagName("td")[0];
1443
1444                if (titleCell) {
1445                    const titleText = normalizeText(titleCell.textContent || titleCell.innerText);
1446
1447                    if (titleText.indexOf(filter) > -1) {
1448                        row.classList.remove("recurring-row-hidden");
1449                    } else {
1450                        row.classList.add("recurring-row-hidden");
1451                    }
1452                }
1453            }
1454        }
1455
1456        function normalizeText(text) {
1457            // Convert to lowercase
1458            text = text.toLowerCase();
1459
1460            // Remove apostrophes and quotes
1461            text = text.replace(/[\'\"]/g, "");
1462
1463            // Replace accented characters with regular ones
1464            text = text.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
1465
1466            // Remove special characters except spaces and alphanumeric
1467            text = text.replace(/[^a-z0-9\s]/g, "");
1468
1469            // Collapse multiple spaces
1470            text = text.replace(/\s+/g, " ");
1471
1472            return text.trim();
1473        }
1474
1475        function filterEvents() {
1476            const searchText = normalizeText(document.getElementById("searchEvents").value);
1477            const eventRows = document.querySelectorAll(".event-row");
1478            let visibleCount = 0;
1479
1480            eventRows.forEach(row => {
1481                const titleElement = row.querySelector("div div");
1482                const originalTitle = titleElement.getAttribute("data-original-title") || titleElement.textContent;
1483
1484                // Store original title if not already stored
1485                if (!titleElement.getAttribute("data-original-title")) {
1486                    titleElement.setAttribute("data-original-title", originalTitle);
1487                }
1488
1489                const normalizedTitle = normalizeText(originalTitle);
1490
1491                if (normalizedTitle.includes(searchText) || searchText === "") {
1492                    row.style.display = "flex";
1493                    visibleCount++;
1494                } else {
1495                    row.style.display = "none";
1496                }
1497            });
1498
1499            // Update namespace visibility and counts
1500            document.querySelectorAll("[id^=ns_]").forEach(nsDiv => {
1501                if (nsDiv.id.endsWith("_arrow") || nsDiv.id.endsWith("_check")) return;
1502
1503                const visibleEvents = nsDiv.querySelectorAll(".event-row[style*=\\"display: flex\\"], .event-row:not([style*=\\"display: none\\"])").length;
1504                const nsId = nsDiv.id;
1505                const arrow = document.getElementById(nsId + "_arrow");
1506
1507                // Auto-expand namespaces with matches when searching
1508                if (searchText && visibleEvents > 0) {
1509                    nsDiv.style.display = "block";
1510                    if (arrow) arrow.textContent = "▼";
1511                }
1512            });
1513        }
1514
1515        function toggleNamespace(id) {
1516            const elem = document.getElementById(id);
1517            const arrow = document.getElementById(id + "_arrow");
1518            if (elem.style.display === "none") {
1519                elem.style.display = "block";
1520                arrow.textContent = "▼";
1521            } else {
1522                elem.style.display = "none";
1523                arrow.textContent = "▶";
1524            }
1525        }
1526
1527        function toggleNamespaceSelect(nsId) {
1528            const checkbox = document.getElementById(nsId + "_check");
1529            const events = document.querySelectorAll("." + nsId + "_events");
1530
1531            // Only select visible events (not hidden by search)
1532            events.forEach(cb => {
1533                const eventRow = cb.closest(".event-row");
1534                if (eventRow && eventRow.style.display !== "none") {
1535                    cb.checked = checkbox.checked;
1536                }
1537            });
1538            updateCount();
1539        }
1540
1541        function selectAll() {
1542            // Only select visible events
1543            document.querySelectorAll(".event-checkbox").forEach(cb => {
1544                const eventRow = cb.closest(".event-row");
1545                if (eventRow && eventRow.style.display !== "none") {
1546                    cb.checked = true;
1547                }
1548            });
1549            // Update namespace checkboxes to indeterminate if partially selected
1550            document.querySelectorAll("input[id$=_check]").forEach(nsCheckbox => {
1551                const nsId = nsCheckbox.id.replace("_check", "");
1552                const events = document.querySelectorAll("." + nsId + "_events");
1553                const visibleEvents = Array.from(events).filter(cb => {
1554                    const row = cb.closest(".event-row");
1555                    return row && row.style.display !== "none";
1556                });
1557                const checkedVisible = visibleEvents.filter(cb => cb.checked);
1558
1559                if (checkedVisible.length === visibleEvents.length && visibleEvents.length > 0) {
1560                    nsCheckbox.checked = true;
1561                } else if (checkedVisible.length > 0) {
1562                    nsCheckbox.indeterminate = true;
1563                } else {
1564                    nsCheckbox.checked = false;
1565                }
1566            });
1567            updateCount();
1568        }
1569
1570        function deselectAll() {
1571            document.querySelectorAll(".event-checkbox").forEach(cb => cb.checked = false);
1572            document.querySelectorAll("input[id$=_check]").forEach(cb => {
1573                cb.checked = false;
1574                cb.indeterminate = false;
1575            });
1576            updateCount();
1577        }
1578
1579        function deleteSelected() {
1580            const checkedBoxes = document.querySelectorAll(".event-checkbox:checked");
1581            if (checkedBoxes.length === 0) {
1582                alert("No events selected");
1583                return;
1584            }
1585
1586            const count = checkedBoxes.length;
1587            if (!confirm(`Delete ${count} selected event(s)?\\n\\nThis cannot be undone!`)) {
1588                return;
1589            }
1590
1591            const form = document.createElement("form");
1592            form.method = "POST";
1593            form.action = "?do=admin&page=calendar&tab=manage";
1594
1595            const actionInput = document.createElement("input");
1596            actionInput.type = "hidden";
1597            actionInput.name = "action";
1598            actionInput.value = "delete_selected_events";
1599            form.appendChild(actionInput);
1600
1601            checkedBoxes.forEach(cb => {
1602                const eventInput = document.createElement("input");
1603                eventInput.type = "hidden";
1604                eventInput.name = "events[]";
1605                eventInput.value = cb.value;
1606                form.appendChild(eventInput);
1607            });
1608
1609            document.body.appendChild(form);
1610            form.submit();
1611        }
1612
1613        function createNewNamespace() {
1614            const namespaceName = prompt("Enter new namespace name:\\n\\nExamples:\\n- work\\n- personal\\n- projects:alpha\\n- aspen:travel:2025");
1615
1616            if (!namespaceName) {
1617                return; // Cancelled
1618            }
1619
1620            // Validate namespace name
1621            if (!/^[a-zA-Z0-9_:-]+$/.test(namespaceName)) {
1622                alert("Invalid namespace name.\\n\\nUse only letters, numbers, underscore, hyphen, and colon.\\nExample: work:projects:alpha");
1623                return;
1624            }
1625
1626            // Submit form to create namespace
1627            const form = document.createElement("form");
1628            form.method = "POST";
1629            form.action = "?do=admin&page=calendar&tab=manage";
1630
1631            const actionInput = document.createElement("input");
1632            actionInput.type = "hidden";
1633            actionInput.name = "action";
1634            actionInput.value = "create_namespace";
1635            form.appendChild(actionInput);
1636
1637            const namespaceInput = document.createElement("input");
1638            namespaceInput.type = "hidden";
1639            namespaceInput.name = "namespace_name";
1640            namespaceInput.value = namespaceName;
1641            form.appendChild(namespaceInput);
1642
1643            document.body.appendChild(form);
1644            form.submit();
1645        }
1646
1647        function updateCount() {
1648            const count = document.querySelectorAll(".event-checkbox:checked").length;
1649            document.getElementById("selectedCount").textContent = count + " selected";
1650        }
1651
1652        function deleteNamespace(namespace) {
1653            const displayName = namespace || "(default)";
1654            if (!confirm("Delete ENTIRE namespace: " + displayName + "?\\n\\nThis will delete ALL events in this namespace!\\n\\nThis cannot be undone!")) {
1655                return;
1656            }
1657            const form = document.createElement("form");
1658            form.method = "POST";
1659            form.action = "?do=admin&page=calendar&tab=manage";
1660            const actionInput = document.createElement("input");
1661            actionInput.type = "hidden";
1662            actionInput.name = "action";
1663            actionInput.value = "delete_namespace";
1664            form.appendChild(actionInput);
1665            const nsInput = document.createElement("input");
1666            nsInput.type = "hidden";
1667            nsInput.name = "namespace";
1668            nsInput.value = namespace;
1669            form.appendChild(nsInput);
1670            document.body.appendChild(form);
1671            form.submit();
1672        }
1673
1674        function renameNamespace(oldNamespace) {
1675            const displayName = oldNamespace || "(default)";
1676            const newName = prompt("Rename namespace: " + displayName + "\\n\\nEnter new name:", oldNamespace);
1677            if (newName === null || newName === oldNamespace) {
1678                return; // Cancelled or no change
1679            }
1680            const form = document.createElement("form");
1681            form.method = "POST";
1682            form.action = "?do=admin&page=calendar&tab=manage";
1683            const actionInput = document.createElement("input");
1684            actionInput.type = "hidden";
1685            actionInput.name = "action";
1686            actionInput.value = "rename_namespace";
1687            form.appendChild(actionInput);
1688            const oldInput = document.createElement("input");
1689            oldInput.type = "hidden";
1690            oldInput.name = "old_namespace";
1691            oldInput.value = oldNamespace;
1692            form.appendChild(oldInput);
1693            const newInput = document.createElement("input");
1694            newInput.type = "hidden";
1695            newInput.name = "new_namespace";
1696            newInput.value = newName;
1697            form.appendChild(newInput);
1698            document.body.appendChild(form);
1699            form.submit();
1700        }
1701
1702        let draggedEvent = null;
1703
1704        function dragStart(event, eventId) {
1705            const checkbox = event.target.closest(".event-row").querySelector(".event-checkbox");
1706
1707            // If this event is checked, drag all checked events
1708            const checkedBoxes = document.querySelectorAll(".event-checkbox:checked");
1709            if (checkbox && checkbox.checked && checkedBoxes.length > 1) {
1710                // Dragging multiple selected events
1711                draggedEvent = "MULTIPLE";
1712                event.dataTransfer.setData("text/plain", "MULTIPLE");
1713            } else {
1714                // Dragging single event
1715                draggedEvent = eventId;
1716                event.dataTransfer.setData("text/plain", eventId);
1717            }
1718            event.dataTransfer.effectAllowed = "move";
1719            event.target.style.opacity = "0.5";
1720        }
1721
1722        function allowDrop(event) {
1723            event.preventDefault();
1724            event.dataTransfer.dropEffect = "move";
1725        }
1726
1727        function drop(event, targetNamespace) {
1728            event.preventDefault();
1729
1730            if (draggedEvent === "MULTIPLE") {
1731                // Move all selected events
1732                const checkedBoxes = document.querySelectorAll(".event-checkbox:checked");
1733                if (checkedBoxes.length === 0) return;
1734
1735                const form = document.createElement("form");
1736                form.method = "POST";
1737                form.action = "?do=admin&page=calendar&tab=manage";
1738
1739                const actionInput = document.createElement("input");
1740                actionInput.type = "hidden";
1741                actionInput.name = "action";
1742                actionInput.value = "move_selected_events";
1743                form.appendChild(actionInput);
1744
1745                checkedBoxes.forEach(cb => {
1746                    const eventInput = document.createElement("input");
1747                    eventInput.type = "hidden";
1748                    eventInput.name = "events[]";
1749                    eventInput.value = cb.value;
1750                    form.appendChild(eventInput);
1751                });
1752
1753                const targetInput = document.createElement("input");
1754                targetInput.type = "hidden";
1755                targetInput.name = "target_namespace";
1756                targetInput.value = targetNamespace;
1757                form.appendChild(targetInput);
1758
1759                document.body.appendChild(form);
1760                form.submit();
1761            } else {
1762                // Move single event
1763                if (!draggedEvent) return;
1764                const parts = draggedEvent.split("|");
1765                const sourceNamespace = parts[1];
1766                if (sourceNamespace === targetNamespace) return;
1767
1768                const form = document.createElement("form");
1769                form.method = "POST";
1770                form.action = "?do=admin&page=calendar&tab=manage";
1771                const actionInput = document.createElement("input");
1772                actionInput.type = "hidden";
1773                actionInput.name = "action";
1774                actionInput.value = "move_single_event";
1775                form.appendChild(actionInput);
1776                const eventInput = document.createElement("input");
1777                eventInput.type = "hidden";
1778                eventInput.name = "event";
1779                eventInput.value = draggedEvent;
1780                form.appendChild(eventInput);
1781                const targetInput = document.createElement("input");
1782                targetInput.type = "hidden";
1783                targetInput.name = "target_namespace";
1784                targetInput.value = targetNamespace;
1785                form.appendChild(targetInput);
1786                document.body.appendChild(form);
1787                form.submit();
1788            }
1789        }
1790
1791        function editRecurringSeries(title, namespace, time, color, recurrenceType, recurrenceInterval, weekDays, monthlyType, monthDay, ordinalWeek, ordinalDay) {
1792            // Get available namespaces from the namespace explorer
1793            const namespaces = new Set();
1794
1795            // Method 1: Try to get from namespace explorer folder names
1796            document.querySelectorAll("[id^=ns_]").forEach(el => {
1797                const nsSpan = el.querySelector("span:nth-child(3)");
1798                if (nsSpan) {
1799                    let nsText = nsSpan.textContent.replace("�� ", "").trim();
1800                    if (nsText && nsText !== "(default)") {
1801                        namespaces.add(nsText);
1802                    }
1803                }
1804            });
1805
1806            // Method 2: Get from datalist if it exists
1807            document.querySelectorAll("#namespaceList option").forEach(opt => {
1808                if (opt.value && opt.value !== "") {
1809                    namespaces.add(opt.value);
1810                }
1811            });
1812
1813            // Convert to sorted array
1814            const nsArray = Array.from(namespaces).sort();
1815
1816            // Build namespace options
1817            let nsOptions = "<option value=\\"\\">(default)</option>";
1818            if (namespace && namespace !== "") {
1819                nsOptions += "<option value=\\"" + namespace + "\\" selected>" + namespace + " (current)</option>";
1820            }
1821            for (const ns of nsArray) {
1822                if (ns !== namespace) {
1823                    nsOptions += "<option value=\\"" + ns + "\\">" + ns + "</option>";
1824                }
1825            }
1826
1827            // Build weekday checkboxes - matching event editor style exactly
1828            const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1829            let weekDayChecks = "";
1830            for (let i = 0; i < 7; i++) {
1831                const checked = weekDays && weekDays.includes(i) ? " checked" : "";
1832                weekDayChecks += `<label style="display:inline-flex; align-items:center; padding:2px 6px; background:#1a1a1a; border:1px solid #333; border-radius:3px; cursor:pointer; font-size:10px;">
1833                    <input type="checkbox" name="weekDays" value="${i}"${checked} style="margin-right:3px; width:12px; height:12px;">
1834                    <span>${dayNames[i]}</span>
1835                </label>`;
1836            }
1837
1838            // Build ordinal week options
1839            let ordinalWeekOpts = "";
1840            const ordinalLabels = [[1,"First"], [2,"Second"], [3,"Third"], [4,"Fourth"], [5,"Fifth"], [-1,"Last"]];
1841            for (const [val, label] of ordinalLabels) {
1842                const selected = val === ordinalWeek ? " selected" : "";
1843                ordinalWeekOpts += `<option value="${val}"${selected}>${label}</option>`;
1844            }
1845
1846            // Build ordinal day options - full day names like event editor
1847            const fullDayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1848            let ordinalDayOpts = "";
1849            for (let i = 0; i < 7; i++) {
1850                const selected = i === ordinalDay ? " selected" : "";
1851                ordinalDayOpts += `<option value="${i}"${selected}>${fullDayNames[i]}</option>`;
1852            }
1853
1854            // Show edit dialog for recurring events
1855            const dialog = document.createElement("div");
1856            dialog.style.cssText = "position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:10000; padding:20px; box-sizing:border-box;";
1857
1858            // Close on clicking background
1859            dialog.addEventListener("click", function(e) {
1860                if (e.target === dialog) {
1861                    dialog.remove();
1862                }
1863            });
1864
1865            const monthlyDayChecked = monthlyType !== "ordinalWeekday" ? "checked" : "";
1866            const monthlyOrdinalChecked = monthlyType === "ordinalWeekday" ? "checked" : "";
1867            const weeklyDisplay = recurrenceType === "weekly" ? "block" : "none";
1868            const monthlyDisplay = recurrenceType === "monthly" ? "block" : "none";
1869
1870            // Get recurrence type selection - matching event editor labels
1871            const recTypes = [["daily","Day(s)"], ["weekly","Week(s)"], ["monthly","Month(s)"], ["yearly","Year(s)"]];
1872            let recTypeOptions = "";
1873            for (const [val, label] of recTypes) {
1874                const selected = val === recurrenceType ? " selected" : "";
1875                recTypeOptions += `<option value="${val}"${selected}>${label}</option>`;
1876            }
1877
1878            // Input/select base style matching event editor
1879            const inputStyle = "width:100%; padding:6px 8px; border:2px solid #444; border-radius:4px; font-size:12px; box-sizing:border-box; background:#2a2a2a; color:#eee;";
1880            const inputSmallStyle = "padding:4px 6px; border:2px solid #444; border-radius:4px; font-size:11px; background:#2a2a2a; color:#eee;";
1881            const labelStyle = "display:block; font-size:10px; font-weight:500; margin-bottom:4px; color:#888;";
1882
1883            dialog.innerHTML = `
1884                <div style="background:#1e1e1e; padding:0; border-radius:8px; width:100%; max-width:450px; max-height:calc(100vh - 40px); overflow:hidden; display:flex; flex-direction:column; border:1px solid #00cc07; box-shadow:0 8px 32px rgba(0,0,0,0.4);">
1885
1886                    <!-- Header - matching event editor -->
1887                    <div style="display:flex; align-items:center; justify-content:space-between; padding:10px 14px; background:#2c3e50; color:white; flex-shrink:0;">
1888                        <h3 style="margin:0; font-size:15px; font-weight:600;">✏️ Edit Recurring Event</h3>
1889                        <button type="button" onclick="closeEditDialog()" style="background:rgba(255,255,255,0.2); border:none; color:white; font-size:22px; width:28px; height:28px; border-radius:50%; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1; padding:0;">×</button>
1890                    </div>
1891
1892                    <!-- Form body - matching event editor -->
1893                    <form id="editRecurringForm" style="padding:10px 12px; overflow-y:auto; flex:1; display:flex; flex-direction:column; gap:8px;">
1894
1895                        <p style="margin:0 0 4px; color:#888; font-size:11px;">Changes apply to ALL occurrences of: <strong style="color:#00cc07;">${title}</strong></p>
1896
1897                        <!-- Title -->
1898                        <div>
1899                            <label style="${labelStyle}">�� TITLE</label>
1900                            <input type="text" name="new_title" value="${title}" style="${inputStyle}" required>
1901                        </div>
1902
1903                        <!-- Time Row -->
1904                        <div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
1905                            <div>
1906                                <label style="${labelStyle}">�� START TIME</label>
1907                                <input type="time" name="start_time" value="${time || \'\'}" style="${inputStyle}">
1908                            </div>
1909                            <div>
1910                                <label style="${labelStyle}">�� END TIME</label>
1911                                <input type="time" name="end_time" style="${inputStyle}">
1912                            </div>
1913                        </div>
1914
1915                        <!-- Recurrence Pattern Box - matching event editor exactly -->
1916                        <div style="border:1px solid #333; border-radius:4px; padding:8px; margin:4px 0; background:rgba(0,0,0,0.2);">
1917
1918                            <!-- Repeat every [N] [period] -->
1919                            <div style="display:flex; gap:8px; align-items:flex-end; margin-bottom:6px;">
1920                                <div style="flex:0 0 auto;">
1921                                    <label style="${labelStyle}">Repeat every</label>
1922                                    <input type="number" name="recurrence_interval" value="${recurrenceInterval || 1}" min="1" max="99" style="width:50px; ${inputSmallStyle}">
1923                                </div>
1924                                <div style="flex:1;">
1925                                    <label style="${labelStyle}">&nbsp;</label>
1926                                    <select name="recurrence_type" id="editRecType" onchange="toggleEditRecOptions()" style="width:100%; ${inputSmallStyle}">
1927                                        ${recTypeOptions}
1928                                    </select>
1929                                </div>
1930                            </div>
1931
1932                            <!-- Weekly options - day checkboxes -->
1933                            <div id="editWeeklyOptions" style="display:${weeklyDisplay}; margin-bottom:6px;">
1934                                <label style="${labelStyle}">On these days:</label>
1935                                <div style="display:flex; flex-wrap:wrap; gap:2px;">
1936                                    ${weekDayChecks}
1937                                </div>
1938                            </div>
1939
1940                            <!-- Monthly options -->
1941                            <div id="editMonthlyOptions" style="display:${monthlyDisplay}; margin-bottom:6px;">
1942                                <label style="${labelStyle}">Repeat on:</label>
1943
1944                                <!-- Radio: Day of month vs Ordinal weekday -->
1945                                <div style="margin-bottom:6px;">
1946                                    <label style="display:inline-flex; align-items:center; margin-right:12px; cursor:pointer; font-size:11px; color:#ccc;">
1947                                        <input type="radio" name="monthly_type" value="dayOfMonth" ${monthlyDayChecked} onchange="toggleEditMonthlyType()" style="margin-right:4px;">
1948                                        Day of month
1949                                    </label>
1950                                    <label style="display:inline-flex; align-items:center; cursor:pointer; font-size:11px; color:#ccc;">
1951                                        <input type="radio" name="monthly_type" value="ordinalWeekday" ${monthlyOrdinalChecked} onchange="toggleEditMonthlyType()" style="margin-right:4px;">
1952                                        Weekday pattern
1953                                    </label>
1954                                </div>
1955
1956                                <!-- Day of month input -->
1957                                <div id="editMonthlyDay" style="display:${monthlyType !== "ordinalWeekday" ? "flex" : "none"}; align-items:center; gap:6px;">
1958                                    <span style="font-size:11px; color:#ccc;">Day</span>
1959                                    <input type="number" name="month_day" value="${monthDay || 1}" min="1" max="31" style="width:50px; ${inputSmallStyle}">
1960                                    <span style="font-size:10px; color:#666;">of each month</span>
1961                                </div>
1962
1963                                <!-- Ordinal weekday -->
1964                                <div id="editMonthlyOrdinal" style="display:${monthlyType === "ordinalWeekday" ? "flex" : "none"}; align-items:center; gap:4px; flex-wrap:wrap;">
1965                                    <select name="ordinal_week" style="width:auto; ${inputSmallStyle}">
1966                                        ${ordinalWeekOpts}
1967                                    </select>
1968                                    <select name="ordinal_day" style="width:auto; ${inputSmallStyle}">
1969                                        ${ordinalDayOpts}
1970                                    </select>
1971                                    <span style="font-size:10px; color:#666;">of each month</span>
1972                                </div>
1973                            </div>
1974
1975                            <!-- Repeat Until -->
1976                            <div>
1977                                <label style="${labelStyle}">Repeat Until (optional)</label>
1978                                <input type="date" name="recurrence_end" style="width:100%; ${inputSmallStyle}; box-sizing:border-box;">
1979                                <div style="font-size:9px; color:#666; margin-top:2px;">Leave empty to keep existing end date</div>
1980                            </div>
1981                        </div>
1982
1983                        <!-- Namespace -->
1984                        <div>
1985                            <label style="${labelStyle}">�� NAMESPACE</label>
1986                            <select name="new_namespace" style="${inputStyle}">
1987                                ${nsOptions}
1988                            </select>
1989                        </div>
1990                    </form>
1991
1992                    <!-- Footer buttons - matching event editor -->
1993                    <div style="display:flex; gap:8px; padding:12px 14px; background:#252525; border-top:1px solid #333; flex-shrink:0;">
1994                        <button type="button" onclick="closeEditDialog()" style="flex:1; background:#444; color:#ccc; padding:8px; border:none; border-radius:4px; cursor:pointer; font-size:12px;">Cancel</button>
1995                        <button type="button" onclick="document.getElementById(\x27editRecurringForm\x27).dispatchEvent(new Event(\x27submit\x27))" style="flex:1; background:#00cc07; color:white; padding:8px; border:none; border-radius:4px; cursor:pointer; font-weight:bold; font-size:12px; box-shadow:0 2px 4px rgba(0,0,0,0.2);">�� Save Changes</button>
1996                    </div>
1997                </div>
1998            `;
1999
2000            document.body.appendChild(dialog);
2001
2002            // Toggle functions for recurrence options
2003            window.toggleEditRecOptions = function() {
2004                const type = document.getElementById("editRecType").value;
2005                document.getElementById("editWeeklyOptions").style.display = type === "weekly" ? "block" : "none";
2006                document.getElementById("editMonthlyOptions").style.display = type === "monthly" ? "block" : "none";
2007            };
2008
2009            window.toggleEditMonthlyType = function() {
2010                const radio = document.querySelector("input[name=monthly_type]:checked");
2011                if (radio) {
2012                    document.getElementById("editMonthlyDay").style.display = radio.value === "dayOfMonth" ? "flex" : "none";
2013                    document.getElementById("editMonthlyOrdinal").style.display = radio.value === "ordinalWeekday" ? "flex" : "none";
2014                }
2015            };
2016
2017            // Add close function to window
2018            window.closeEditDialog = function() {
2019                dialog.remove();
2020            };
2021
2022            // Handle form submission
2023            dialog.querySelector("#editRecurringForm").addEventListener("submit", function(e) {
2024                e.preventDefault();
2025                const formData = new FormData(this);
2026
2027                // Collect weekDays as comma-separated string
2028                const weekDaysArr = [];
2029                document.querySelectorAll("input[name=weekDays]:checked").forEach(cb => {
2030                    weekDaysArr.push(cb.value);
2031                });
2032
2033                // Submit the edit
2034                const form = document.createElement("form");
2035                form.method = "POST";
2036                form.action = "?do=admin&page=calendar&tab=manage";
2037
2038                const actionInput = document.createElement("input");
2039                actionInput.type = "hidden";
2040                actionInput.name = "action";
2041                actionInput.value = "edit_recurring_series";
2042                form.appendChild(actionInput);
2043
2044                const oldTitleInput = document.createElement("input");
2045                oldTitleInput.type = "hidden";
2046                oldTitleInput.name = "old_title";
2047                oldTitleInput.value = title;
2048                form.appendChild(oldTitleInput);
2049
2050                const oldNamespaceInput = document.createElement("input");
2051                oldNamespaceInput.type = "hidden";
2052                oldNamespaceInput.name = "old_namespace";
2053                oldNamespaceInput.value = namespace;
2054                form.appendChild(oldNamespaceInput);
2055
2056                // Add weekDays
2057                const weekDaysInput = document.createElement("input");
2058                weekDaysInput.type = "hidden";
2059                weekDaysInput.name = "week_days";
2060                weekDaysInput.value = weekDaysArr.join(",");
2061                form.appendChild(weekDaysInput);
2062
2063                // Add all form fields
2064                for (let [key, value] of formData.entries()) {
2065                    if (key === "weekDays") continue; // Skip individual checkboxes
2066                    const input = document.createElement("input");
2067                    input.type = "hidden";
2068                    input.name = key;
2069                    input.value = value;
2070                    form.appendChild(input);
2071                }
2072
2073                document.body.appendChild(form);
2074                form.submit();
2075            });
2076        }
2077
2078        function deleteRecurringSeries(title, namespace) {
2079            const displayNs = namespace || "(default)";
2080            if (!confirm("Delete ALL occurrences of: " + title + " (" + displayNs + ")?\\n\\nThis cannot be undone!")) {
2081                return;
2082            }
2083            const form = document.createElement("form");
2084            form.method = "POST";
2085            form.action = "?do=admin&page=calendar&tab=manage";
2086            const actionInput = document.createElement("input");
2087            actionInput.type = "hidden";
2088            actionInput.name = "action";
2089            actionInput.value = "delete_recurring_series";
2090            form.appendChild(actionInput);
2091            const titleInput = document.createElement("input");
2092            titleInput.type = "hidden";
2093            titleInput.name = "event_title";
2094            titleInput.value = title;
2095            form.appendChild(titleInput);
2096            const namespaceInput = document.createElement("input");
2097            namespaceInput.type = "hidden";
2098            namespaceInput.name = "namespace";
2099            namespaceInput.value = namespace;
2100            form.appendChild(namespaceInput);
2101            document.body.appendChild(form);
2102            form.submit();
2103        }
2104
2105        document.addEventListener("dragend", function(e) {
2106            if (e.target.draggable) {
2107                e.target.style.opacity = "1";
2108            }
2109        });
2110        </script>';
2111    }
2112
2113    private function renderUpdateTab($colors = null) {
2114        global $INPUT;
2115
2116        // Use defaults if not provided
2117        if ($colors === null) {
2118            $colors = $this->getTemplateColors();
2119        }
2120
2121        echo '<h2 style="margin:10px 0; font-size:20px;">�� Update Plugin</h2>';
2122
2123        // Show message if present
2124        if ($INPUT->has('msg')) {
2125            $msg = hsc($INPUT->str('msg'));
2126            $type = $INPUT->str('msgtype', 'success');
2127            $class = ($type === 'success') ? 'msg success' : 'msg error';
2128            echo "<div class=\"$class\" style=\"padding:10px; margin:10px 0; border-left:3px solid " . ($type === 'success' ? '#28a745' : '#dc3545') . "; background:" . ($type === 'success' ? '#d4edda' : '#f8d7da') . "; border-radius:3px; max-width:1200px;\">";
2129            echo $msg;
2130            echo "</div>";
2131        }
2132
2133        // Show current version FIRST (MOVED TO TOP)
2134        $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt';
2135        $info = ['version' => 'Unknown', 'date' => 'Unknown', 'name' => 'Calendar Plugin', 'author' => 'Unknown', 'email' => '', 'desc' => ''];
2136        if (file_exists($pluginInfo)) {
2137            $info = array_merge($info, confToHash($pluginInfo));
2138        }
2139
2140        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
2141        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Current Version</h3>';
2142        echo '<div style="font-size:12px; line-height:1.6;">';
2143        echo '<div style="margin:4px 0;"><strong>Version:</strong> ' . hsc($info['version']) . ' (' . hsc($info['date']) . ')</div>';
2144        echo '<div style="margin:4px 0;"><strong>Author:</strong> ' . hsc($info['author']) . ($info['email'] ? ' &lt;' . hsc($info['email']) . '&gt;' : '') . '</div>';
2145        if ($info['desc']) {
2146            echo '<div style="margin:4px 0;"><strong>Description:</strong> ' . hsc($info['desc']) . '</div>';
2147        }
2148        echo '<div style="margin:4px 0;"><strong>Location:</strong> <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">' . DOKU_PLUGIN . 'calendar/</code></div>';
2149        echo '</div>';
2150
2151        // Check permissions
2152        $pluginDir = DOKU_PLUGIN . 'calendar/';
2153        $pluginWritable = is_writable($pluginDir);
2154        $parentWritable = is_writable(DOKU_PLUGIN);
2155
2156        echo '<div style="margin-top:8px; padding-top:8px; border-top:1px solid ' . $colors['border'] . ';">';
2157        if ($pluginWritable && $parentWritable) {
2158            echo '<p style="margin:5px 0; font-size:13px; color:#28a745;"><strong>✅ Permissions:</strong> OK - ready to update</p>';
2159        } else {
2160            echo '<p style="margin:5px 0; font-size:13px; color:#dc3545;"><strong>❌ Permissions:</strong> Issues detected</p>';
2161            if (!$pluginWritable) {
2162                echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Plugin directory not writable</p>';
2163            }
2164            if (!$parentWritable) {
2165                echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Parent directory not writable</p>';
2166            }
2167            echo '<p style="margin:5px 0; font-size:12px; color:' . $colors['text'] . ';">Fix with: <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">chmod -R 755 ' . DOKU_PLUGIN . 'calendar/</code></p>';
2168            echo '<p style="margin:2px 0; font-size:12px; color:' . $colors['text'] . ';">Or: <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">chown -R www-data:www-data ' . DOKU_PLUGIN . 'calendar/</code></p>';
2169        }
2170        echo '</div>';
2171
2172        echo '</div>';
2173
2174        // Combined upload and notes section (SIDE BY SIDE)
2175        echo '<div style="display:flex; gap:15px; max-width:1200px; margin:10px 0;">';
2176
2177        // Left side - Upload form (60% width)
2178        echo '<div style="flex:1; min-width:0; background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">';
2179        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Upload New Version</h3>';
2180        echo '<p style="color:' . $colors['text'] . '; font-size:13px; margin:0 0 10px;">Upload a calendar plugin ZIP file to update. Your configuration will be preserved.</p>';
2181
2182        echo '<form method="post" action="?do=admin&page=calendar&tab=update" enctype="multipart/form-data" id="uploadForm">';
2183        echo '<input type="hidden" name="action" value="upload_update">';
2184        echo '<div style="margin:10px 0;">';
2185        echo '<input type="file" name="plugin_zip" accept=".zip" required style="padding:8px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px; width:100%;">';
2186        echo '</div>';
2187        echo '<div style="margin:10px 0;">';
2188        echo '<label style="display:flex; align-items:center; gap:8px; font-size:13px;">';
2189        echo '<input type="checkbox" name="backup_first" value="1" checked>';
2190        echo '<span>Create backup before updating (Recommended)</span>';
2191        echo '</label>';
2192        echo '</div>';
2193
2194        // Buttons side by side
2195        echo '<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">';
2196        echo '<button type="submit" onclick="return confirmUpload()" style="background:#00cc07; color:white; padding:10px 20px; border:none; border-radius:3px; cursor:pointer; font-size:14px; font-weight:bold;">�� Upload & Install</button>';
2197        echo '</form>';
2198
2199        // Clear Cache button (next to Upload button)
2200        echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="display:inline; margin:0;">';
2201        echo '<input type="hidden" name="action" value="clear_cache">';
2202        echo '<input type="hidden" name="tab" value="update">';
2203        echo '<button type="submit" onclick="return confirm(\'Clear all DokuWiki cache? This will refresh all plugin files.\')" style="background:#ff9800; color:white; padding:10px 20px; border:none; border-radius:3px; cursor:pointer; font-size:14px; font-weight:bold;">��️ Clear Cache</button>';
2204        echo '</form>';
2205        echo '</div>';
2206
2207        echo '<p style="margin:8px 0 0 0; font-size:12px; color:' . $colors['text'] . ';">Clear the DokuWiki cache if changes aren\'t appearing or after updating the plugin.</p>';
2208        echo '</div>';
2209
2210        // Right side - Important Notes (40% width)
2211        echo '<div style="flex:0 0 350px; min-width:0; background:#fff3e0; border-left:3px solid #ff9800; padding:12px; border-radius:3px;">';
2212        echo '<h4 style="margin:0 0 5px 0; color:#e65100; font-size:14px;">⚠️ Important Notes</h4>';
2213        echo '<ul style="margin:5px 0; padding-left:20px; font-size:12px; color:#e65100; line-height:1.6;">';
2214        echo '<li>This will replace all plugin files</li>';
2215        echo '<li>Configuration files (sync_config.php) will be preserved</li>';
2216        echo '<li>Event data will not be affected</li>';
2217        echo '<li>Backup will be saved to: <code style="font-size:10px;">calendar.backup.vX.X.X.YYYY-MM-DD_HH-MM-SS.zip</code></li>';
2218        echo '<li>Make sure the ZIP file is a valid calendar plugin</li>';
2219        echo '</ul>';
2220        echo '</div>';
2221
2222        echo '</div>'; // End flex container
2223
2224        // Changelog section - Timeline viewer
2225        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
2226        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Version History</h3>';
2227
2228        $changelogFile = DOKU_PLUGIN . 'calendar/CHANGELOG.md';
2229        if (file_exists($changelogFile)) {
2230            $changelog = file_get_contents($changelogFile);
2231
2232            // Parse ALL versions into structured data
2233            $lines = explode("\n", $changelog);
2234            $versions = [];
2235            $currentVersion = null;
2236            $currentSubsection = '';
2237
2238            foreach ($lines as $line) {
2239                $trimmed = trim($line);
2240
2241                // Version header (## Version X.X.X or ## Version X.X.X (date) - title)
2242                if (preg_match('/^## Version (.+?)(?:\s*\(([^)]+)\))?\s*(?:-\s*(.+))?$/', $trimmed, $matches)) {
2243                    if ($currentVersion !== null) {
2244                        $versions[] = $currentVersion;
2245                    }
2246                    $currentVersion = [
2247                        'number' => trim($matches[1]),
2248                        'date' => isset($matches[2]) ? trim($matches[2]) : '',
2249                        'title' => isset($matches[3]) ? trim($matches[3]) : '',
2250                        'items' => []
2251                    ];
2252                    $currentSubsection = '';
2253                }
2254                // Subsection header (### Something)
2255                elseif ($currentVersion !== null && preg_match('/^### (.+)$/', $trimmed, $matches)) {
2256                    $currentSubsection = trim($matches[1]);
2257                    $currentVersion['items'][] = [
2258                        'type' => 'section',
2259                        'desc' => $currentSubsection
2260                    ];
2261                }
2262                // Formatted item (- **Type:** description)
2263                elseif ($currentVersion !== null && preg_match('/^- \*\*(.+?):\*\*\s*(.+)$/', $trimmed, $matches)) {
2264                    $currentVersion['items'][] = [
2265                        'type' => trim($matches[1]),
2266                        'desc' => trim($matches[2])
2267                    ];
2268                }
2269                // Plain bullet item (- something)
2270                elseif ($currentVersion !== null && preg_match('/^- (.+)$/', $trimmed, $matches)) {
2271                    $currentVersion['items'][] = [
2272                        'type' => $currentSubsection ?: 'Changed',
2273                        'desc' => trim($matches[1])
2274                    ];
2275                }
2276            }
2277            // Don't forget last version
2278            if ($currentVersion !== null) {
2279                $versions[] = $currentVersion;
2280            }
2281
2282            $totalVersions = count($versions);
2283            $uniqueId = 'changelog_' . substr(md5(microtime()), 0, 6);
2284
2285            // Find the index of the currently running version
2286            $runningVersion = trim($info['version']);
2287            $runningIndex = 0;
2288            foreach ($versions as $idx => $ver) {
2289                if (trim($ver['number']) === $runningVersion) {
2290                    $runningIndex = $idx;
2291                    break;
2292                }
2293            }
2294
2295            if ($totalVersions > 0) {
2296                // Timeline navigation bar
2297                echo '<div id="' . $uniqueId . '_wrap" style="position:relative;">';
2298
2299                // Nav controls
2300                echo '<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">';
2301                echo '<button id="' . $uniqueId . '_prev" onclick="changelogNav(\'' . $uniqueId . '\', -1)" style="background:none; border:1px solid ' . $colors['border'] . '; color:' . $colors['text'] . '; width:32px; height:32px; border-radius:50%; cursor:pointer; font-size:16px; display:flex; align-items:center; justify-content:center; transition:all 0.15s;" onmouseover="this.style.borderColor=\'#00cc07\'; this.style.color=\'#00cc07\'" onmouseout="this.style.borderColor=\'' . $colors['border'] . '\'; this.style.color=\'' . $colors['text'] . '\'">‹</button>';
2302                echo '<div style="flex:1; text-align:center; display:flex; align-items:center; justify-content:center; gap:10px;">';
2303                echo '<span id="' . $uniqueId . '_counter" style="font-size:11px; color:' . $colors['text'] . '; opacity:0.7;">1 of ' . $totalVersions . '</span>';
2304                echo '<button id="' . $uniqueId . '_current" onclick="changelogJumpTo(\'' . $uniqueId . '\', ' . $runningIndex . ')" style="background:#00cc07; border:none; color:#fff; padding:3px 10px; border-radius:3px; cursor:pointer; font-size:10px; font-weight:600; letter-spacing:0.3px; transition:all 0.15s;" onmouseover="this.style.filter=\'brightness(1.2)\'" onmouseout="this.style.filter=\'none\'">Current Release</button>';
2305                echo '</div>';
2306                echo '<button id="' . $uniqueId . '_next" onclick="changelogNav(\'' . $uniqueId . '\', 1)" style="background:none; border:1px solid ' . $colors['border'] . '; color:' . $colors['text'] . '; width:32px; height:32px; border-radius:50%; cursor:pointer; font-size:16px; display:flex; align-items:center; justify-content:center; transition:all 0.15s;" onmouseover="this.style.borderColor=\'#00cc07\'; this.style.color=\'#00cc07\'" onmouseout="this.style.borderColor=\'' . $colors['border'] . '\'; this.style.color=\'' . $colors['text'] . '\'">›</button>';
2307                echo '</div>';
2308
2309                // Version cards (one per version, only first visible)
2310                foreach ($versions as $i => $ver) {
2311                    $display = ($i === 0) ? 'block' : 'none';
2312                    $isRunning = (trim($ver['number']) === $runningVersion);
2313                    $cardBorder = $isRunning ? '2px solid #00cc07' : '1px solid ' . $colors['border'];
2314                    echo '<div class="' . $uniqueId . '_card" id="' . $uniqueId . '_card_' . $i . '" style="display:' . $display . '; padding:10px; background:' . $colors['bg'] . '; border:' . $cardBorder . '; border-left:3px solid #00cc07; border-radius:4px; transition:opacity 0.2s;">';
2315
2316                    // Version header
2317                    echo '<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px;">';
2318                    echo '<span style="font-weight:bold; color:#00cc07; font-size:14px;">v' . hsc($ver['number']) . '</span>';
2319                    if ($isRunning) {
2320                        echo '<span style="background:#00cc07; color:#fff; padding:1px 6px; border-radius:3px; font-size:9px; font-weight:700; letter-spacing:0.3px;">RUNNING</span>';
2321                    }
2322                    if ($ver['date']) {
2323                        echo '<span style="font-size:11px; color:' . $colors['text'] . '; opacity:0.6;">' . hsc($ver['date']) . '</span>';
2324                    }
2325                    echo '</div>';
2326                    if ($ver['title']) {
2327                        echo '<div style="font-size:12px; font-weight:600; color:' . $colors['text'] . '; margin-bottom:8px;">' . hsc($ver['title']) . '</div>';
2328                    }
2329
2330                    // Change items
2331                    if (!empty($ver['items'])) {
2332                        echo '<div style="font-size:12px; line-height:1.7;">';
2333                        foreach ($ver['items'] as $item) {
2334                            if ($item['type'] === 'section') {
2335                                echo '<div style="margin:6px 0 2px 0; font-weight:700; color:#00cc07; font-size:11px; letter-spacing:0.3px;">' . hsc($item['desc']) . '</div>';
2336                                continue;
2337                            }
2338                            $color = '#666'; $icon = '•';
2339                            $t = $item['type'];
2340                            if ($t === 'Added' || $t === 'New') { $color = '#28a745'; $icon = '✨'; }
2341                            elseif ($t === 'Fixed' || $t === 'Fix' || $t === 'Bug Fix') { $color = '#dc3545'; $icon = '��'; }
2342                            elseif ($t === 'Changed' || $t === 'Change') { $color = '#00cc07'; $icon = '��'; }
2343                            elseif ($t === 'Improved' || $t === 'Enhancement') { $color = '#ff9800'; $icon = '⚡'; }
2344                            elseif ($t === 'Removed') { $color = '#e91e63'; $icon = '��️'; }
2345                            elseif ($t === 'Development' || $t === 'Refactored') { $color = '#6c757d'; $icon = '��️'; }
2346                            elseif ($t === 'Result') { $color = '#2196f3'; $icon = '✅'; }
2347                            else { $color = $colors['text']; $icon = '•'; }
2348
2349                            echo '<div style="margin:2px 0; padding-left:4px;">';
2350                            echo '<span style="color:' . $color . '; font-weight:600;">' . $icon . ' ' . hsc($item['type']) . ':</span> ';
2351                            echo '<span style="color:' . $colors['text'] . ';">' . hsc($item['desc']) . '</span>';
2352                            echo '</div>';
2353                        }
2354                        echo '</div>';
2355                    } else {
2356                        echo '<div style="font-size:11px; color:' . $colors['text'] . '; opacity:0.5; font-style:italic;">No detailed changes recorded</div>';
2357                    }
2358
2359                    echo '</div>';
2360                }
2361
2362                echo '</div>'; // wrap
2363
2364                // JavaScript for navigation
2365                echo '<script>
2366                (function() {
2367                    var id = "' . $uniqueId . '";
2368                    var total = ' . $totalVersions . ';
2369                    var current = 0;
2370
2371                    function showCard(idx) {
2372                        // Hide current
2373                        var curCard = document.getElementById(id + "_card_" + current);
2374                        if (curCard) curCard.style.display = "none";
2375
2376                        // Show target
2377                        current = idx;
2378                        var nextCard = document.getElementById(id + "_card_" + current);
2379                        if (nextCard) nextCard.style.display = "block";
2380
2381                        // Update counter
2382                        var counter = document.getElementById(id + "_counter");
2383                        if (counter) counter.textContent = (current + 1) + " of " + total;
2384
2385                        // Update button states
2386                        var prevBtn = document.getElementById(id + "_prev");
2387                        var nextBtn = document.getElementById(id + "_next");
2388                        if (prevBtn) prevBtn.style.opacity = (current === 0) ? "0.3" : "1";
2389                        if (nextBtn) nextBtn.style.opacity = (current === total - 1) ? "0.3" : "1";
2390                    }
2391
2392                    window.changelogNav = function(uid, dir) {
2393                        if (uid !== id) return;
2394                        var next = current + dir;
2395                        if (next < 0 || next >= total) return;
2396                        showCard(next);
2397                    };
2398
2399                    window.changelogJumpTo = function(uid, idx) {
2400                        if (uid !== id) return;
2401                        if (idx < 0 || idx >= total) return;
2402                        showCard(idx);
2403                    };
2404
2405                    // Initialize button states
2406                    var prevBtn = document.getElementById(id + "_prev");
2407                    if (prevBtn) prevBtn.style.opacity = "0.3";
2408                })();
2409                </script>';
2410
2411            } else {
2412                echo '<p style="color:#999; font-size:13px; font-style:italic;">No versions found in changelog</p>';
2413            }
2414        } else {
2415            echo '<p style="color:#999; font-size:13px; font-style:italic;">Changelog not available</p>';
2416        }
2417
2418        echo '</div>';
2419
2420        // Backup list or manual backup section
2421        $backupDir = DOKU_PLUGIN;
2422        $backups = glob($backupDir . 'calendar*.zip');
2423
2424        // Filter to only show files that look like backups (not the uploaded plugin files)
2425        $backups = array_filter($backups, function($file) {
2426            $name = basename($file);
2427            // Include files that start with "calendar" but exclude files that are just "calendar.zip" (uploaded plugin)
2428            return $name !== 'calendar.zip';
2429        });
2430
2431        // Always show backup section (even if no backups yet)
2432        echo '<div id="backupSection" style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">';
2433        echo '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">';
2434        echo '<h3 style="margin:0; color:#00cc07; font-size:16px;">�� Backups</h3>';
2435
2436        // Manual backup button
2437        echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="margin:0;">';
2438        echo '<input type="hidden" name="action" value="create_manual_backup">';
2439        echo '<button type="submit" onclick="return confirm(\'Create a backup of the current plugin version?\')" style="background:#00cc07; color:white; padding:6px 12px; border:none; border-radius:3px; cursor:pointer; font-size:12px; font-weight:bold;">�� Create Backup Now</button>';
2440        echo '</form>';
2441        echo '</div>';
2442
2443        // Restore instructions note
2444        echo '<div style="background:#1a2d1a; border:1px solid #00cc07; border-radius:3px; padding:8px 12px; margin-bottom:10px;">';
2445        echo '<p style="margin:0; color:#00cc07; font-size:12px;"><strong>�� Restore:</strong> Click the �� Restore button to reinstall from a backup. This uses DokuWiki\'s Extension Manager for safe installation. Alternatively, download the ZIP and upload via <strong>Admin → Extension Manager → Install</strong>.</p>';
2446        echo '</div>';
2447
2448        if (!empty($backups)) {
2449            rsort($backups); // Newest first
2450
2451            // Bulk action bar
2452            echo '<div id="bulkActionBar" style="display:flex; align-items:center; gap:10px; margin-bottom:8px; padding:6px 10px; background:#333; border-radius:3px;">';
2453            echo '<label style="display:flex; align-items:center; gap:5px; color:#ccc; font-size:12px; cursor:pointer;">';
2454            echo '<input type="checkbox" id="selectAllBackups" onchange="toggleAllBackups(this)" style="width:16px; height:16px;">';
2455            echo 'Select All</label>';
2456            echo '<span id="selectedCount" style="color:#888; font-size:11px;">(0 selected)</span>';
2457            echo '<button onclick="deleteSelectedBackups()" id="bulkDeleteBtn" style="background:#e74c3c; color:white; border:none; padding:4px 10px; border-radius:3px; cursor:pointer; font-size:11px; margin-left:auto; display:none;">��️ Delete Selected</button>';
2458            echo '</div>';
2459
2460            echo '<div style="max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">';
2461            echo '<table id="backupTable" style="width:100%; border-collapse:collapse; font-size:12px;">';
2462            echo '<thead style="position:sticky; top:0; background:#e9e9e9;">';
2463            echo '<tr>';
2464            echo '<th style="padding:6px; text-align:center; border-bottom:2px solid ' . $colors['border'] . '; width:30px;"></th>';
2465            echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Backup File</th>';
2466            echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Size</th>';
2467            echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Actions</th>';
2468            echo '</tr></thead><tbody>';
2469
2470            foreach ($backups as $backup) {
2471                $filename = basename($backup);
2472                $size = $this->formatBytes(filesize($backup));
2473                echo '<tr style="border-bottom:1px solid #eee;" data-filename="' . hsc($filename) . '">';
2474                echo '<td style="padding:6px; text-align:center;"><input type="checkbox" class="backup-checkbox" value="' . hsc($filename) . '" onchange="updateSelectedCount()" style="width:16px; height:16px;"></td>';
2475                echo '<td style="padding:6px;"><code style="font-size:11px;">' . hsc($filename) . '</code></td>';
2476                echo '<td style="padding:6px;">' . $size . '</td>';
2477                echo '<td style="padding:6px; white-space:nowrap;">';
2478                echo '<a href="' . DOKU_BASE . 'lib/plugins/' . hsc($filename) . '" download style="color:#00cc07; text-decoration:none; font-size:11px; margin-right:10px;">�� Download</a>';
2479                echo '<button onclick="restoreBackup(\'' . hsc(addslashes($filename)) . '\')" style="background:#7b1fa2; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px; margin-right:5px;">�� Restore</button>';
2480                echo '<button onclick="renameBackup(\'' . hsc(addslashes($filename)) . '\')" style="background:#f39c12; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px;">✏️ Rename</button>';
2481                echo '</td>';
2482                echo '</tr>';
2483            }
2484
2485            echo '</tbody></table>';
2486            echo '</div>';
2487        } else {
2488            echo '<p style="color:' . $colors['text'] . '; font-size:13px; margin:8px 0;">No backups yet. Click "Create Backup Now" to create your first backup.</p>';
2489        }
2490        echo '</div>';
2491
2492        echo '<script>
2493        function confirmUpload() {
2494            const fileInput = document.querySelector(\'input[name="plugin_zip"]\');
2495            if (!fileInput.files[0]) {
2496                alert("Please select a ZIP file");
2497                return false;
2498            }
2499
2500            const fileName = fileInput.files[0].name;
2501            if (!fileName.endsWith(".zip")) {
2502                alert("Please select a ZIP file");
2503                return false;
2504            }
2505
2506            return confirm("Upload and install: " + fileName + "?\\n\\nThis will replace all plugin files.\\nYour configuration and data will be preserved.\\n\\nContinue?");
2507        }
2508
2509        // Toggle all backup checkboxes
2510        function toggleAllBackups(selectAllCheckbox) {
2511            const checkboxes = document.querySelectorAll(\'.backup-checkbox\');
2512            checkboxes.forEach(cb => cb.checked = selectAllCheckbox.checked);
2513            updateSelectedCount();
2514        }
2515
2516        // Update the selected count display
2517        function updateSelectedCount() {
2518            const checkboxes = document.querySelectorAll(\'.backup-checkbox:checked\');
2519            const count = checkboxes.length;
2520            const countSpan = document.getElementById(\'selectedCount\');
2521            const bulkDeleteBtn = document.getElementById(\'bulkDeleteBtn\');
2522            const selectAllCheckbox = document.getElementById(\'selectAllBackups\');
2523            const totalCheckboxes = document.querySelectorAll(\'.backup-checkbox\').length;
2524
2525            if (countSpan) countSpan.textContent = \'(\' + count + \' selected)\';
2526            if (bulkDeleteBtn) bulkDeleteBtn.style.display = count > 0 ? \'block\' : \'none\';
2527            if (selectAllCheckbox) selectAllCheckbox.checked = (count === totalCheckboxes && count > 0);
2528        }
2529
2530        // Delete selected backups
2531        function deleteSelectedBackups() {
2532            const checkboxes = document.querySelectorAll(\'.backup-checkbox:checked\');
2533            const filenames = Array.from(checkboxes).map(cb => cb.value);
2534
2535            if (filenames.length === 0) {
2536                alert(\'No backups selected\');
2537                return;
2538            }
2539
2540            if (!confirm(\'Delete \' + filenames.length + \' selected backup(s)?\\n\\n\' + filenames.join(\'\\n\') + \'\\n\\nThis cannot be undone!\')) {
2541                return;
2542            }
2543
2544            // Delete each backup sequentially
2545            let deleted = 0;
2546            let errors = [];
2547
2548            function deleteNext(index) {
2549                if (index >= filenames.length) {
2550                    // All done
2551                    if (errors.length > 0) {
2552                        alert(\'Deleted \' + deleted + \' backups. Errors: \' + errors.join(\', \'));
2553                    }
2554                    updateSelectedCount();
2555
2556                    // Check if table is now empty
2557                    const tbody = document.querySelector(\'#backupTable tbody\');
2558                    if (tbody && tbody.children.length === 0) {
2559                        location.reload();
2560                    }
2561                    return;
2562                }
2563
2564                const filename = filenames[index];
2565                const formData = new FormData();
2566                formData.append(\'action\', \'delete_backup\');
2567                formData.append(\'backup_file\', filename);
2568
2569                fetch(\'?do=admin&page=calendar&tab=update\', {
2570                    method: \'POST\',
2571                    body: formData
2572                })
2573                .then(response => response.text())
2574                .then(data => {
2575                    // Remove the row from the table
2576                    const row = document.querySelector(\'tr[data-filename="\' + filename + \'"]\');
2577                    if (row) {
2578                        row.style.transition = \'opacity 0.2s\';
2579                        row.style.opacity = \'0\';
2580                        setTimeout(() => row.remove(), 200);
2581                    }
2582                    deleted++;
2583                    deleteNext(index + 1);
2584                })
2585                .catch(error => {
2586                    errors.push(filename);
2587                    deleteNext(index + 1);
2588                });
2589            }
2590
2591            deleteNext(0);
2592        }
2593
2594        function restoreBackup(filename) {
2595            if (!confirm("Restore from backup: " + filename + "?\\n\\nThis will use DokuWiki\'s Extension Manager to reinstall the plugin from the backup.\\nYour current plugin files will be replaced.\\n\\nContinue?")) {
2596                return;
2597            }
2598
2599            const form = document.createElement("form");
2600            form.method = "POST";
2601            form.action = "?do=admin&page=calendar&tab=update";
2602
2603            const actionInput = document.createElement("input");
2604            actionInput.type = "hidden";
2605            actionInput.name = "action";
2606            actionInput.value = "restore_backup";
2607            form.appendChild(actionInput);
2608
2609            const filenameInput = document.createElement("input");
2610            filenameInput.type = "hidden";
2611            filenameInput.name = "backup_file";
2612            filenameInput.value = filename;
2613            form.appendChild(filenameInput);
2614
2615            document.body.appendChild(form);
2616            form.submit();
2617        }
2618
2619        function renameBackup(filename) {
2620            const newName = prompt("Enter new backup name (without .zip extension):\\n\\nCurrent: " + filename.replace(/\\.zip$/, ""), filename.replace(/\\.zip$/, ""));
2621            if (!newName || newName === filename.replace(/\\.zip$/, "")) {
2622                return;
2623            }
2624
2625            // Add .zip if not present
2626            const newFilename = newName.endsWith(".zip") ? newName : newName + ".zip";
2627
2628            // Basic validation
2629            if (!/^[a-zA-Z0-9._-]+$/.test(newFilename.replace(/\\.zip$/, ""))) {
2630                alert("Invalid filename. Use only letters, numbers, dots, dashes, and underscores.");
2631                return;
2632            }
2633
2634            const form = document.createElement("form");
2635            form.method = "POST";
2636            form.action = "?do=admin&page=calendar&tab=update";
2637
2638            const actionInput = document.createElement("input");
2639            actionInput.type = "hidden";
2640            actionInput.name = "action";
2641            actionInput.value = "rename_backup";
2642            form.appendChild(actionInput);
2643
2644            const oldNameInput = document.createElement("input");
2645            oldNameInput.type = "hidden";
2646            oldNameInput.name = "old_name";
2647            oldNameInput.value = filename;
2648            form.appendChild(oldNameInput);
2649
2650            const newNameInput = document.createElement("input");
2651            newNameInput.type = "hidden";
2652            newNameInput.name = "new_name";
2653            newNameInput.value = newFilename;
2654            form.appendChild(newNameInput);
2655
2656            document.body.appendChild(form);
2657            form.submit();
2658        }
2659        </script>';
2660    }
2661
2662    private function saveConfig() {
2663        global $INPUT;
2664
2665        // Load existing config to preserve all settings
2666        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
2667        $existingConfig = [];
2668        if (file_exists($configFile)) {
2669            $existingConfig = include $configFile;
2670        }
2671
2672        // Update only the fields from the form - preserve everything else
2673        $config = $existingConfig;
2674
2675        // Update basic fields
2676        $config['tenant_id'] = $INPUT->str('tenant_id');
2677        $config['client_id'] = $INPUT->str('client_id');
2678        $config['client_secret'] = $INPUT->str('client_secret');
2679        $config['user_email'] = $INPUT->str('user_email');
2680        $config['timezone'] = $INPUT->str('timezone', 'America/Los_Angeles');
2681        $config['default_category'] = $INPUT->str('default_category', 'Blue category');
2682        $config['reminder_minutes'] = $INPUT->int('reminder_minutes', 15);
2683        $config['sync_completed_tasks'] = $INPUT->bool('sync_completed_tasks');
2684        $config['delete_outlook_events'] = $INPUT->bool('delete_outlook_events');
2685        $config['sync_all_namespaces'] = $INPUT->bool('sync_all_namespaces');
2686        $config['sync_namespaces'] = $INPUT->arr('sync_namespaces');
2687        // important_namespaces is managed from the Manage tab, preserve existing value
2688        if (!isset($config['important_namespaces'])) {
2689            $config['important_namespaces'] = 'important';
2690        }
2691
2692        // Parse category mapping
2693        $config['category_mapping'] = [];
2694        $mappingText = $INPUT->str('category_mapping');
2695        if ($mappingText) {
2696            $lines = explode("\n", $mappingText);
2697            foreach ($lines as $line) {
2698                $line = trim($line);
2699                if (empty($line)) continue;
2700                $parts = explode('=', $line, 2);
2701                if (count($parts) === 2) {
2702                    $config['category_mapping'][trim($parts[0])] = trim($parts[1]);
2703                }
2704            }
2705        }
2706
2707        // Parse color mapping from dropdown selections
2708        $config['color_mapping'] = [];
2709        $colorMappingCount = $INPUT->int('color_mapping_count', 0);
2710        for ($i = 0; $i < $colorMappingCount; $i++) {
2711            $hexColor = $INPUT->str('color_hex_' . $i);
2712            $category = $INPUT->str('color_map_' . $i);
2713
2714            if (!empty($hexColor) && !empty($category)) {
2715                $config['color_mapping'][$hexColor] = $category;
2716            }
2717        }
2718
2719        // Build file content using return format
2720        $content = "<?php\n";
2721        $content .= "/**\n";
2722        $content .= " * DokuWiki Calendar → Outlook Sync - Configuration\n";
2723        $content .= " * \n";
2724        $content .= " * SECURITY: Add this file to .gitignore!\n";
2725        $content .= " * Never commit credentials to version control.\n";
2726        $content .= " */\n\n";
2727        $content .= "return " . var_export($config, true) . ";\n";
2728
2729        // Save file
2730        if (file_put_contents($configFile, $content)) {
2731            $this->redirect('Configuration saved successfully!', 'success');
2732        } else {
2733            $this->redirect('Error: Could not save configuration file', 'error');
2734        }
2735    }
2736
2737    private function clearCache() {
2738        // Clear DokuWiki cache
2739        $cacheDir = DOKU_INC . 'data/cache';
2740
2741        if (is_dir($cacheDir)) {
2742            $this->recursiveDelete($cacheDir, false);
2743            $this->redirect('Cache cleared successfully!', 'success', 'update');
2744        } else {
2745            $this->redirect('Cache directory not found', 'error', 'update');
2746        }
2747    }
2748
2749    private function recursiveDelete($dir, $deleteRoot = true) {
2750        if (!is_dir($dir)) return;
2751
2752        $files = array_diff(scandir($dir), array('.', '..'));
2753        foreach ($files as $file) {
2754            $path = $dir . '/' . $file;
2755            if (is_dir($path)) {
2756                $this->recursiveDelete($path, true);
2757            } else {
2758                @unlink($path);
2759            }
2760        }
2761
2762        if ($deleteRoot) {
2763            @rmdir($dir);
2764        }
2765    }
2766
2767    private function findRecurringEvents() {
2768        $dataDir = DOKU_INC . 'data/meta/';
2769        $recurring = [];
2770        $allEvents = []; // Track all events to detect patterns
2771        $flaggedSeries = []; // Track events with recurring flag by recurringId
2772
2773        // Helper to process events from a calendar directory
2774        $processCalendarDir = function($calDir, $fallbackNamespace) use (&$allEvents, &$flaggedSeries) {
2775            if (!is_dir($calDir)) return;
2776
2777            foreach (glob($calDir . '/*.json') as $file) {
2778                $data = json_decode(file_get_contents($file), true);
2779                if (!$data || !is_array($data)) continue;
2780
2781                foreach ($data as $dateKey => $events) {
2782                    // Skip non-date keys (like "mapping" or other metadata)
2783                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
2784
2785                    if (!is_array($events)) continue;
2786                    foreach ($events as $event) {
2787                        if (!isset($event['title']) || empty(trim($event['title']))) continue;
2788
2789                        $ns = isset($event['namespace']) ? $event['namespace'] : $fallbackNamespace;
2790
2791                        // If event has recurring flag, group by recurringId
2792                        if (!empty($event['recurring']) && !empty($event['recurringId'])) {
2793                            $rid = $event['recurringId'];
2794                            if (!isset($flaggedSeries[$rid])) {
2795                                $flaggedSeries[$rid] = [
2796                                    'title' => $event['title'],
2797                                    'namespace' => $ns,
2798                                    'dates' => [],
2799                                    'events' => [],
2800                                    // Capture recurrence metadata from first event
2801                                    'recurrenceType' => $event['recurrenceType'] ?? null,
2802                                    'recurrenceInterval' => $event['recurrenceInterval'] ?? 1,
2803                                    'weekDays' => $event['weekDays'] ?? [],
2804                                    'monthlyType' => $event['monthlyType'] ?? null,
2805                                    'monthDay' => $event['monthDay'] ?? null,
2806                                    'ordinalWeek' => $event['ordinalWeek'] ?? null,
2807                                    'ordinalDay' => $event['ordinalDay'] ?? null,
2808                                    'time' => $event['time'] ?? null,
2809                                    'endTime' => $event['endTime'] ?? null,
2810                                    'color' => $event['color'] ?? null
2811                                ];
2812                            }
2813                            $flaggedSeries[$rid]['dates'][] = $dateKey;
2814                            $flaggedSeries[$rid]['events'][] = $event;
2815                        }
2816
2817                        // Also group by title+namespace for pattern detection
2818                        $groupKey = strtolower(trim($event['title'])) . '|' . $ns;
2819
2820                        if (!isset($allEvents[$groupKey])) {
2821                            $allEvents[$groupKey] = [
2822                                'title' => $event['title'],
2823                                'namespace' => $ns,
2824                                'dates' => [],
2825                                'events' => [],
2826                                'hasFlag' => false,
2827                                'time' => $event['time'] ?? null,
2828                                'color' => $event['color'] ?? null
2829                            ];
2830                        }
2831                        $allEvents[$groupKey]['dates'][] = $dateKey;
2832                        $allEvents[$groupKey]['events'][] = $event;
2833                        if (!empty($event['recurring'])) {
2834                            $allEvents[$groupKey]['hasFlag'] = true;
2835                        }
2836                    }
2837                }
2838            }
2839        };
2840
2841        // Check root calendar directory (blank/default namespace)
2842        $processCalendarDir($dataDir . 'calendar', '');
2843
2844        // Scan all namespace directories (including nested)
2845        $this->scanNamespaceDirs($dataDir, $processCalendarDir);
2846
2847        // Deduplicate: remove from allEvents groups that are fully covered by flaggedSeries
2848        $flaggedTitleNs = [];
2849        foreach ($flaggedSeries as $rid => $series) {
2850            $key = strtolower(trim($series['title'])) . '|' . $series['namespace'];
2851            $flaggedTitleNs[$key] = $rid;
2852        }
2853
2854        // Build results from flaggedSeries first (known recurring)
2855        $seen = [];
2856        foreach ($flaggedSeries as $rid => $series) {
2857            sort($series['dates']);
2858            $dedupDates = array_unique($series['dates']);
2859
2860            // Use stored recurrence metadata if available, otherwise detect pattern
2861            $pattern = $this->formatRecurrencePattern($series);
2862            if (!$pattern) {
2863                $pattern = $this->detectRecurrencePattern($dedupDates);
2864            }
2865
2866            $recurring[] = [
2867                'baseId' => $rid,
2868                'title' => $series['title'],
2869                'namespace' => $series['namespace'],
2870                'pattern' => $pattern,
2871                'count' => count($dedupDates),
2872                'firstDate' => $dedupDates[0],
2873                'lastDate' => end($dedupDates),
2874                'hasFlag' => true,
2875                'time' => $series['time'],
2876                'endTime' => $series['endTime'],
2877                'color' => $series['color'],
2878                'recurrenceType' => $series['recurrenceType'],
2879                'recurrenceInterval' => $series['recurrenceInterval'],
2880                'weekDays' => $series['weekDays'],
2881                'monthlyType' => $series['monthlyType'],
2882                'monthDay' => $series['monthDay'],
2883                'ordinalWeek' => $series['ordinalWeek'],
2884                'ordinalDay' => $series['ordinalDay']
2885            ];
2886            $seen[strtolower(trim($series['title'])) . '|' . $series['namespace']] = true;
2887        }
2888
2889        // Add pattern-detected recurring (3+ occurrences, not already in flaggedSeries)
2890        foreach ($allEvents as $groupKey => $group) {
2891            if (isset($seen[$groupKey])) continue;
2892
2893            $dedupDates = array_unique($group['dates']);
2894            sort($dedupDates);
2895
2896            if (count($dedupDates) < 3) continue;
2897
2898            $pattern = $this->detectRecurrencePattern($dedupDates);
2899
2900            $baseId = isset($group['events'][0]['recurringId'])
2901                ? $group['events'][0]['recurringId']
2902                : md5($group['title'] . $group['namespace']);
2903
2904            $recurring[] = [
2905                'baseId' => $baseId,
2906                'title' => $group['title'],
2907                'namespace' => $group['namespace'],
2908                'pattern' => $pattern,
2909                'count' => count($dedupDates),
2910                'firstDate' => $dedupDates[0],
2911                'lastDate' => end($dedupDates),
2912                'hasFlag' => $group['hasFlag'],
2913                'time' => $group['time'],
2914                'color' => $group['color'],
2915                'recurrenceType' => null,
2916                'recurrenceInterval' => null,
2917                'weekDays' => null,
2918                'monthlyType' => null,
2919                'monthDay' => null,
2920                'ordinalWeek' => null,
2921                'ordinalDay' => null
2922            ];
2923        }
2924
2925        // Sort by title
2926        usort($recurring, function($a, $b) {
2927            return strcasecmp($a['title'], $b['title']);
2928        });
2929
2930        return $recurring;
2931    }
2932
2933    /**
2934     * Format a human-readable recurrence pattern from stored metadata
2935     */
2936    private function formatRecurrencePattern($series) {
2937        $type = $series['recurrenceType'] ?? null;
2938        $interval = $series['recurrenceInterval'] ?? 1;
2939
2940        if (!$type) return null;
2941
2942        $result = '';
2943
2944        switch ($type) {
2945            case 'daily':
2946                if ($interval == 1) {
2947                    $result = 'Daily';
2948                } else {
2949                    $result = "Every $interval days";
2950                }
2951                break;
2952
2953            case 'weekly':
2954                $weekDays = $series['weekDays'] ?? [];
2955                $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
2956
2957                if ($interval == 1) {
2958                    $result = 'Weekly';
2959                } elseif ($interval == 2) {
2960                    $result = 'Bi-weekly';
2961                } else {
2962                    $result = "Every $interval weeks";
2963                }
2964
2965                if (!empty($weekDays) && count($weekDays) < 7) {
2966                    $dayLabels = array_map(function($d) use ($dayNames) {
2967                        return $dayNames[$d] ?? '';
2968                    }, $weekDays);
2969                    $result .= ' (' . implode(', ', $dayLabels) . ')';
2970                }
2971                break;
2972
2973            case 'monthly':
2974                $monthlyType = $series['monthlyType'] ?? 'dayOfMonth';
2975
2976                if ($interval == 1) {
2977                    $prefix = 'Monthly';
2978                } elseif ($interval == 3) {
2979                    $prefix = 'Quarterly';
2980                } elseif ($interval == 6) {
2981                    $prefix = 'Semi-annual';
2982                } else {
2983                    $prefix = "Every $interval months";
2984                }
2985
2986                if ($monthlyType === 'dayOfMonth') {
2987                    $day = $series['monthDay'] ?? '?';
2988                    $result = "$prefix (day $day)";
2989                } else {
2990                    $ordinalNames = [1 => '1st', 2 => '2nd', 3 => '3rd', 4 => '4th', 5 => '5th', -1 => 'Last'];
2991                    $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
2992                    $ordinal = $ordinalNames[$series['ordinalWeek']] ?? '';
2993                    $dayName = $dayNames[$series['ordinalDay']] ?? '';
2994                    $result = "$prefix ($ordinal $dayName)";
2995                }
2996                break;
2997
2998            case 'yearly':
2999                if ($interval == 1) {
3000                    $result = 'Yearly';
3001                } else {
3002                    $result = "Every $interval years";
3003                }
3004                break;
3005
3006            default:
3007                $result = ucfirst($type);
3008        }
3009
3010        return $result;
3011    }
3012
3013    /**
3014     * Recursively scan namespace directories for calendar data
3015     */
3016    private function scanNamespaceDirs($baseDir, $callback) {
3017        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
3018            $namespace = basename($nsDir);
3019
3020            // Skip the root 'calendar' dir (already processed)
3021            if ($namespace === 'calendar') continue;
3022
3023            $calendarDir = $nsDir . '/calendar';
3024            if (is_dir($calendarDir)) {
3025                // Derive namespace from path relative to meta dir
3026                $metaDir = DOKU_INC . 'data/meta/';
3027                $relPath = str_replace($metaDir, '', $nsDir);
3028                $ns = str_replace('/', ':', trim($relPath, '/'));
3029                $callback($calendarDir, $ns);
3030            }
3031
3032            // Recurse into subdirectories for nested namespaces
3033            $this->scanNamespaceDirs($nsDir . '/', $callback);
3034        }
3035    }
3036
3037    /**
3038     * Detect recurrence pattern from sorted dates using median interval
3039     */
3040    private function detectRecurrencePattern($dates) {
3041        if (count($dates) < 2) return 'Single';
3042
3043        // Calculate all intervals between consecutive dates
3044        $intervals = [];
3045        for ($i = 1; $i < count($dates); $i++) {
3046            try {
3047                $d1 = new DateTime($dates[$i - 1]);
3048                $d2 = new DateTime($dates[$i]);
3049                $intervals[] = $d1->diff($d2)->days;
3050            } catch (Exception $e) {
3051                continue;
3052            }
3053        }
3054
3055        if (empty($intervals)) return 'Custom';
3056
3057        // Check if all intervals are the same (or very close)
3058        $uniqueIntervals = array_unique($intervals);
3059        $isConsistent = (count($uniqueIntervals) === 1) ||
3060                        (max($intervals) - min($intervals) <= 1); // Allow 1 day variance
3061
3062        // Use median interval (more robust than first pair)
3063        sort($intervals);
3064        $mid = floor(count($intervals) / 2);
3065        $median = (count($intervals) % 2 === 0)
3066            ? ($intervals[$mid - 1] + $intervals[$mid]) / 2
3067            : $intervals[$mid];
3068
3069        // Check for specific day-based patterns first
3070        if ($median <= 1) return 'Daily';
3071
3072        // Check for every N days (2-6 days)
3073        if ($median >= 2 && $median <= 6 && $isConsistent) {
3074            return 'Every ' . round($median) . ' days';
3075        }
3076
3077        // Weekly patterns
3078        if ($median >= 6 && $median <= 8) return 'Weekly';
3079
3080        // Check for every N weeks
3081        if ($median >= 13 && $median <= 16) return 'Bi-weekly';
3082        if ($median >= 20 && $median <= 23) return 'Every 3 weeks';
3083
3084        // Monthly patterns
3085        if ($median >= 27 && $median <= 32) return 'Monthly';
3086
3087        // Check for every N months by looking at month differences
3088        if ($median >= 55 && $median <= 65) return 'Every 2 months';
3089        if ($median >= 89 && $median <= 93) return 'Quarterly';
3090        if ($median >= 115 && $median <= 125) return 'Every 4 months';
3091        if ($median >= 175 && $median <= 190) return 'Semi-annual';
3092
3093        // Yearly
3094        if ($median >= 363 && $median <= 368) return 'Yearly';
3095
3096        // For other intervals, calculate weeks if appropriate
3097        if ($median >= 7 && $median < 28) {
3098            $weeks = round($median / 7);
3099            if (abs($median - ($weeks * 7)) <= 1) {
3100                return "Every $weeks weeks";
3101            }
3102        }
3103
3104        // For monthly-ish intervals
3105        if ($median >= 28 && $median < 365) {
3106            $months = round($median / 30);
3107            if ($months >= 2 && abs($median - ($months * 30)) <= 3) {
3108                return "Every $months months";
3109            }
3110        }
3111
3112        return 'Every ~' . round($median) . ' days';
3113    }
3114
3115    /**
3116     * Render the recurring events table HTML
3117     */
3118    private function renderRecurringTable($recurringEvents, $colors) {
3119        if (empty($recurringEvents)) {
3120            echo '<p style="color:' . $colors['text'] . '; font-size:13px; margin:5px 0;">No recurring events found.</p>';
3121            return;
3122        }
3123
3124        // Search bar
3125        echo '<div style="margin-bottom:8px;">';
3126        echo '<input type="text" id="searchRecurring" onkeyup="filterRecurringEvents()" placeholder="�� Search recurring events..." style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">';
3127        echo '</div>';
3128
3129        echo '<style>
3130            .sort-arrow {
3131                color: #999;
3132                font-size: 10px;
3133                margin-left: 3px;
3134                display: inline-block;
3135            }
3136            #recurringTable th:hover {
3137                background: #ddd;
3138            }
3139            #recurringTable th:hover .sort-arrow {
3140                color: #00cc07;
3141            }
3142            .recurring-row-hidden {
3143                display: none;
3144            }
3145            .pattern-badge {
3146                display: inline-block;
3147                padding: 1px 4px;
3148                border-radius: 3px;
3149                font-size: 9px;
3150                font-weight: bold;
3151            }
3152            .pattern-daily { background: #e3f2fd; color: #1565c0; }
3153            .pattern-weekly { background: #e8f5e9; color: #2e7d32; }
3154            .pattern-monthly { background: #fff3e0; color: #ef6c00; }
3155            .pattern-yearly { background: #fce4ec; color: #c2185b; }
3156            .pattern-custom { background: #f3e5f5; color: #7b1fa2; }
3157        </style>';
3158        echo '<div style="max-height:250px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">';
3159        echo '<table id="recurringTable" style="width:100%; border-collapse:collapse; font-size:11px;">';
3160        echo '<thead style="position:sticky; top:0; background:#e9e9e9;">';
3161        echo '<tr>';
3162        echo '<th onclick="sortRecurringTable(0)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Title <span class="sort-arrow">⇅</span></th>';
3163        echo '<th onclick="sortRecurringTable(1)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Namespace <span class="sort-arrow">⇅</span></th>';
3164        echo '<th onclick="sortRecurringTable(2)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Pattern <span class="sort-arrow">⇅</span></th>';
3165        echo '<th onclick="sortRecurringTable(3)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Range <span class="sort-arrow">⇅</span></th>';
3166        echo '<th onclick="sortRecurringTable(4)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Count <span class="sort-arrow">⇅</span></th>';
3167        echo '<th onclick="sortRecurringTable(5)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Source <span class="sort-arrow">⇅</span></th>';
3168        echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">Actions</th>';
3169        echo '</tr></thead><tbody id="recurringTableBody">';
3170
3171        foreach ($recurringEvents as $series) {
3172            $sourceLabel = $series['hasFlag'] ? '��️ Flagged' : '�� Detected';
3173            $sourceColor = $series['hasFlag'] ? '#00cc07' : '#ff9800';
3174
3175            // Determine pattern badge class
3176            $pattern = strtolower($series['pattern']);
3177            if (strpos($pattern, 'daily') !== false || strpos($pattern, 'day') !== false) {
3178                $patternClass = 'pattern-daily';
3179            } elseif (strpos($pattern, 'weekly') !== false || strpos($pattern, 'week') !== false) {
3180                $patternClass = 'pattern-weekly';
3181            } elseif (strpos($pattern, 'monthly') !== false || strpos($pattern, 'month') !== false ||
3182                      strpos($pattern, 'quarterly') !== false || strpos($pattern, 'semi') !== false) {
3183                $patternClass = 'pattern-monthly';
3184            } elseif (strpos($pattern, 'yearly') !== false || strpos($pattern, 'year') !== false) {
3185                $patternClass = 'pattern-yearly';
3186            } else {
3187                $patternClass = 'pattern-custom';
3188            }
3189
3190            // Format date range
3191            $firstDate = date('M j, Y', strtotime($series['firstDate']));
3192            $lastDate = isset($series['lastDate']) ? date('M j, Y', strtotime($series['lastDate'])) : $firstDate;
3193            $dateRange = ($firstDate === $lastDate) ? $firstDate : "$firstDate$lastDate";
3194
3195            echo '<tr style="border-bottom:1px solid #eee;">';
3196            echo '<td style="padding:4px 6px;">' . hsc($series['title']) . '</td>';
3197            echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($series['namespace'] ?: '(default)') . '</code></td>';
3198            echo '<td style="padding:4px 6px;"><span class="pattern-badge ' . $patternClass . '">' . hsc($series['pattern']) . '</span></td>';
3199            echo '<td style="padding:4px 6px; font-size:10px;">' . $dateRange . '</td>';
3200            echo '<td style="padding:4px 6px;"><strong>' . $series['count'] . '</strong></td>';
3201            echo '<td style="padding:4px 6px;"><span style="color:' . $sourceColor . '; font-size:10px;">' . $sourceLabel . '</span></td>';
3202            echo '<td style="padding:4px 6px; white-space:nowrap;">';
3203
3204            // Prepare JS data - include recurrence metadata
3205            $jsTitle = hsc(addslashes($series['title']));
3206            $jsNs = hsc($series['namespace']);
3207            $jsCount = $series['count'];
3208            $jsFirst = hsc($series['firstDate']);
3209            $jsLast = hsc($series['lastDate'] ?? $series['firstDate']);
3210            $jsPattern = hsc($series['pattern']);
3211            $jsHasFlag = $series['hasFlag'] ? 'true' : 'false';
3212            $jsTime = hsc($series['time'] ?? '');
3213            $jsEndTime = hsc($series['endTime'] ?? '');
3214            $jsColor = hsc($series['color'] ?? '');
3215
3216            // Recurrence metadata for edit dialog
3217            $jsRecurrenceType = hsc($series['recurrenceType'] ?? '');
3218            $jsRecurrenceInterval = intval($series['recurrenceInterval'] ?? 1);
3219            $jsWeekDays = json_encode($series['weekDays'] ?? []);
3220            $jsMonthlyType = hsc($series['monthlyType'] ?? '');
3221            $jsMonthDay = intval($series['monthDay'] ?? 0);
3222            $jsOrdinalWeek = intval($series['ordinalWeek'] ?? 1);
3223            $jsOrdinalDay = intval($series['ordinalDay'] ?? 0);
3224
3225            echo '<button onclick="editRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\', \'' . $jsTime . '\', \'' . $jsColor . '\', \'' . $jsRecurrenceType . '\', ' . $jsRecurrenceInterval . ', ' . $jsWeekDays . ', \'' . $jsMonthlyType . '\', ' . $jsMonthDay . ', ' . $jsOrdinalWeek . ', ' . $jsOrdinalDay . ')" style="background:#00cc07; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px; margin-right:2px;" title="Edit title, time, namespace, pattern">Edit</button>';
3226            echo '<button onclick="manageRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\', ' . $jsCount . ', \'' . $jsFirst . '\', \'' . $jsLast . '\', \'' . $jsPattern . '\', ' . $jsHasFlag . ')" style="background:#ff9800; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px; margin-right:2px;" title="Extend, trim, pause, change dates">Manage</button>';
3227            echo '<button onclick="deleteRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\')" style="background:#e74c3c; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px;" title="Delete all occurrences">Del</button>';
3228            echo '</td>';
3229            echo '</tr>';
3230        }
3231
3232        echo '</tbody></table>';
3233        echo '</div>';
3234        echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:5px 0 0;">Total: ' . count($recurringEvents) . ' series</p>';
3235    }
3236
3237    /**
3238     * AJAX handler: rescan recurring events and return HTML
3239     */
3240    private function handleCleanupEmptyNamespaces() {
3241        global $INPUT;
3242        $dryRun = $INPUT->bool('dry_run', false);
3243
3244        $metaDir = DOKU_INC . 'data/meta/';
3245        $details = [];
3246        $removedDirs = 0;
3247        $removedCalDirs = 0;
3248
3249        // 1. Find all calendar/ subdirectories anywhere under data/meta/
3250        $allCalDirs = [];
3251        $this->findAllCalendarDirsRecursive($metaDir, $allCalDirs);
3252
3253        // 2. Check each calendar dir for empty JSON files
3254        foreach ($allCalDirs as $calDir) {
3255            $jsonFiles = glob($calDir . '/*.json');
3256            $hasEvents = false;
3257
3258            foreach ($jsonFiles as $jsonFile) {
3259                $data = json_decode(file_get_contents($jsonFile), true);
3260                if ($data && is_array($data)) {
3261                    // Check if any date key has actual events
3262                    foreach ($data as $dateKey => $events) {
3263                        if (is_array($events) && !empty($events)) {
3264                            $hasEvents = true;
3265                            break 2;
3266                        }
3267                    }
3268                    // JSON file has data but all dates are empty — remove it
3269                    if (!$dryRun) unlink($jsonFile);
3270                }
3271            }
3272
3273            // Re-check after cleaning empty JSON files
3274            if (!$dryRun) {
3275                $jsonFiles = glob($calDir . '/*.json');
3276            }
3277
3278            // Derive display name from path
3279            $relPath = str_replace($metaDir, '', $calDir);
3280            $relPath = rtrim(str_replace('/calendar', '', $relPath), '/');
3281            $displayName = $relPath ?: '(root)';
3282
3283            if ($displayName === '(root)') continue; // Never remove root calendar dir
3284
3285            if (!$hasEvents || empty($jsonFiles)) {
3286                $removedCalDirs++;
3287                $details[] = "Remove empty calendar folder: " . $displayName . "/calendar/ (0 events)";
3288
3289                if (!$dryRun) {
3290                    // Remove all remaining files in calendar dir
3291                    foreach (glob($calDir . '/*') as $f) {
3292                        if (is_file($f)) unlink($f);
3293                    }
3294                    @rmdir($calDir);
3295
3296                    // Check if parent namespace dir is now empty too
3297                    $parentDir = dirname($calDir);
3298                    if ($parentDir !== $metaDir && is_dir($parentDir)) {
3299                        $remaining = array_diff(scandir($parentDir), ['.', '..']);
3300                        if (empty($remaining)) {
3301                            @rmdir($parentDir);
3302                            $removedDirs++;
3303                            $details[] = "Removed empty namespace directory: " . $displayName . "/";
3304                        }
3305                    }
3306                }
3307            }
3308        }
3309
3310        // 3. Also scan for namespace dirs that have a calendar/ subdir with 0 json files
3311        //    (already covered above, but also check for namespace dirs without calendar/ at all
3312        //    that are tracked in the event system)
3313
3314        $total = $removedCalDirs + $removedDirs;
3315        $message = $dryRun
3316            ? "Found $total item(s) to clean up"
3317            : "Cleaned up $removedCalDirs empty calendar folder(s)" . ($removedDirs > 0 ? " and $removedDirs empty namespace directory(ies)" : "");
3318
3319        if (!$dryRun) $this->clearStatsCache();
3320
3321        echo json_encode([
3322            'success' => true,
3323            'count' => $total,
3324            'message' => $message,
3325            'details' => $details
3326        ]);
3327    }
3328
3329    /**
3330     * Recursively find all 'calendar' directories under a base path
3331     */
3332    private function findAllCalendarDirsRecursive($baseDir, &$results) {
3333        $entries = glob($baseDir . '*', GLOB_ONLYDIR);
3334        if (!$entries) return;
3335
3336        foreach ($entries as $dir) {
3337            $name = basename($dir);
3338            if ($name === 'calendar') {
3339                $results[] = $dir;
3340            } else {
3341                // Check for calendar subdir
3342                if (is_dir($dir . '/calendar')) {
3343                    $results[] = $dir . '/calendar';
3344                }
3345                // Recurse into subdirectories for nested namespaces
3346                $this->findAllCalendarDirsRecursive($dir . '/', $results);
3347            }
3348        }
3349    }
3350
3351    private function handleTrimAllPastRecurring() {
3352        global $INPUT;
3353        $dryRun = $INPUT->bool('dry_run', false);
3354        $today = date('Y-m-d');
3355        $dataDir = DOKU_INC . 'data/meta/';
3356        $calendarDirs = [];
3357
3358        if (is_dir($dataDir . 'calendar')) {
3359            $calendarDirs[] = $dataDir . 'calendar';
3360        }
3361        $this->findCalendarDirs($dataDir, $calendarDirs);
3362
3363        $removed = 0;
3364
3365        foreach ($calendarDirs as $calDir) {
3366            foreach (glob($calDir . '/*.json') as $file) {
3367                $data = json_decode(file_get_contents($file), true);
3368                if (!$data || !is_array($data)) continue;
3369
3370                $modified = false;
3371                foreach ($data as $dateKey => &$dayEvents) {
3372                    // Skip non-date keys (like "mapping" or other metadata)
3373                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3374
3375                    if ($dateKey >= $today) continue;
3376                    if (!is_array($dayEvents)) continue;
3377
3378                    $filtered = [];
3379                    foreach ($dayEvents as $event) {
3380                        if (!empty($event['recurring']) || !empty($event['recurringId'])) {
3381                            $removed++;
3382                            if (!$dryRun) $modified = true;
3383                        } else {
3384                            $filtered[] = $event;
3385                        }
3386                    }
3387                    if (!$dryRun) $dayEvents = $filtered;
3388                }
3389                unset($dayEvents);
3390
3391                if (!$dryRun && $modified) {
3392                    foreach ($data as $dk => $evts) {
3393                        if (empty($evts)) unset($data[$dk]);
3394                    }
3395                    if (empty($data)) {
3396                        unlink($file);
3397                    } else {
3398                        file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
3399                    }
3400                }
3401            }
3402        }
3403
3404        if (!$dryRun) $this->clearStatsCache();
3405        echo json_encode(['success' => true, 'count' => $removed, 'message' => "Removed $removed past recurring occurrences"]);
3406    }
3407
3408    private function handleRescanRecurring() {
3409        $colors = $this->getTemplateColors();
3410        $recurringEvents = $this->findRecurringEvents();
3411
3412        ob_start();
3413        $this->renderRecurringTable($recurringEvents, $colors);
3414        $html = ob_get_clean();
3415
3416        echo json_encode([
3417            'success' => true,
3418            'html' => $html,
3419            'count' => count($recurringEvents)
3420        ]);
3421    }
3422
3423    /**
3424     * Helper: find all events matching a title in a namespace's calendar dir
3425     */
3426    private function getRecurringSeriesEvents($title, $namespace) {
3427        $dataDir = DOKU_INC . 'data/meta/';
3428        if ($namespace !== '') {
3429            $dataDir .= str_replace(':', '/', $namespace) . '/';
3430        }
3431        $dataDir .= 'calendar/';
3432
3433        $events = []; // ['date' => dateKey, 'file' => filepath, 'event' => eventData, 'index' => idx]
3434
3435        if (!is_dir($dataDir)) return $events;
3436
3437        foreach (glob($dataDir . '*.json') as $file) {
3438            $data = json_decode(file_get_contents($file), true);
3439            if (!$data || !is_array($data)) continue;
3440
3441            foreach ($data as $dateKey => $dayEvents) {
3442                // Skip non-date keys (like "mapping" or other metadata)
3443                if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3444
3445                if (!is_array($dayEvents)) continue;
3446                foreach ($dayEvents as $idx => $event) {
3447                    if (!isset($event['title'])) continue;
3448                    if (strtolower(trim($event['title'])) === strtolower(trim($title))) {
3449                        $events[] = [
3450                            'date' => $dateKey,
3451                            'file' => $file,
3452                            'event' => $event,
3453                            'index' => $idx
3454                        ];
3455                    }
3456                }
3457            }
3458        }
3459
3460        // Sort by date
3461        usort($events, function($a, $b) {
3462            return strcmp($a['date'], $b['date']);
3463        });
3464
3465        return $events;
3466    }
3467
3468    /**
3469     * Extend series: add more future occurrences
3470     */
3471    private function handleExtendRecurring() {
3472        global $INPUT;
3473        $title = $INPUT->str('title');
3474        $namespace = $INPUT->str('namespace');
3475        $count = $INPUT->int('count', 4);
3476        $intervalDays = $INPUT->int('interval_days', 7);
3477
3478        $events = $this->getRecurringSeriesEvents($title, $namespace);
3479        if (empty($events)) {
3480            echo json_encode(['success' => false, 'error' => 'Series not found']);
3481            return;
3482        }
3483
3484        // Use last event as template
3485        $lastEvent = end($events);
3486        $lastDate = new DateTime($lastEvent['date']);
3487        $template = $lastEvent['event'];
3488
3489        $dataDir = DOKU_INC . 'data/meta/';
3490        if ($namespace !== '') {
3491            $dataDir .= str_replace(':', '/', $namespace) . '/';
3492        }
3493        $dataDir .= 'calendar/';
3494
3495        if (!is_dir($dataDir)) mkdir($dataDir, 0755, true);
3496
3497        $added = 0;
3498        $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace);
3499        $maxExistingIdx = 0;
3500        foreach ($events as $e) {
3501            if (isset($e['event']['id']) && preg_match('/-(\d+)$/', $e['event']['id'], $m)) {
3502                $maxExistingIdx = max($maxExistingIdx, (int)$m[1]);
3503            }
3504        }
3505
3506        for ($i = 1; $i <= $count; $i++) {
3507            $newDate = clone $lastDate;
3508            $newDate->modify('+' . ($i * $intervalDays) . ' days');
3509            $dateKey = $newDate->format('Y-m-d');
3510            list($year, $month) = explode('-', $dateKey);
3511
3512            $file = $dataDir . sprintf('%04d-%02d.json', $year, $month);
3513            $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
3514            if (!is_array($fileData)) $fileData = [];
3515
3516            if (!isset($fileData[$dateKey])) $fileData[$dateKey] = [];
3517
3518            $newEvent = $template;
3519            $newEvent['id'] = $baseId . '-' . ($maxExistingIdx + $i);
3520            $newEvent['recurring'] = true;
3521            $newEvent['recurringId'] = $baseId;
3522            $newEvent['created'] = date('Y-m-d H:i:s');
3523            unset($newEvent['completed']);
3524            $newEvent['completed'] = false;
3525
3526            $fileData[$dateKey][] = $newEvent;
3527            file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT));
3528            $added++;
3529        }
3530
3531        $this->clearStatsCache();
3532        echo json_encode(['success' => true, 'message' => "Added $added new occurrences"]);
3533    }
3534
3535    /**
3536     * Trim series: remove past occurrences before a cutoff date
3537     */
3538    private function handleTrimRecurring() {
3539        global $INPUT;
3540        $title = $INPUT->str('title');
3541        $namespace = $INPUT->str('namespace');
3542        $cutoffDate = $INPUT->str('cutoff_date', date('Y-m-d'));
3543
3544        $events = $this->getRecurringSeriesEvents($title, $namespace);
3545        $removed = 0;
3546
3547        foreach ($events as $entry) {
3548            if ($entry['date'] < $cutoffDate) {
3549                // Remove this event from its file
3550                $data = json_decode(file_get_contents($entry['file']), true);
3551                if (!$data || !isset($data[$entry['date']])) continue;
3552
3553                // Find and remove by matching title
3554                foreach ($data[$entry['date']] as $k => $evt) {
3555                    if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
3556                        unset($data[$entry['date']][$k]);
3557                        $data[$entry['date']] = array_values($data[$entry['date']]);
3558                        $removed++;
3559                        break;
3560                    }
3561                }
3562
3563                // Clean up empty dates
3564                if (empty($data[$entry['date']])) unset($data[$entry['date']]);
3565
3566                if (empty($data)) {
3567                    unlink($entry['file']);
3568                } else {
3569                    file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
3570                }
3571            }
3572        }
3573
3574        $this->clearStatsCache();
3575        echo json_encode(['success' => true, 'message' => "Removed $removed past occurrences before $cutoffDate"]);
3576    }
3577
3578    /**
3579     * Pause series: mark all future occurrences as paused
3580     */
3581    private function handlePauseRecurring() {
3582        global $INPUT;
3583        $title = $INPUT->str('title');
3584        $namespace = $INPUT->str('namespace');
3585        $today = date('Y-m-d');
3586
3587        $events = $this->getRecurringSeriesEvents($title, $namespace);
3588        $paused = 0;
3589
3590        foreach ($events as $entry) {
3591            if ($entry['date'] >= $today) {
3592                $data = json_decode(file_get_contents($entry['file']), true);
3593                if (!$data || !isset($data[$entry['date']])) continue;
3594
3595                foreach ($data[$entry['date']] as $k => &$evt) {
3596                    if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
3597                        $evt['paused'] = true;
3598                        $evt['title'] = '⏸ ' . preg_replace('/^⏸\s*/', '', $evt['title']);
3599                        $paused++;
3600                        break;
3601                    }
3602                }
3603                unset($evt);
3604
3605                file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
3606            }
3607        }
3608
3609        $this->clearStatsCache();
3610        echo json_encode(['success' => true, 'message' => "Paused $paused future occurrences"]);
3611    }
3612
3613    /**
3614     * Resume series: unmark paused occurrences
3615     */
3616    private function handleResumeRecurring() {
3617        global $INPUT;
3618        $title = $INPUT->str('title');
3619        $namespace = $INPUT->str('namespace');
3620
3621        // Search for both paused and non-paused versions
3622        $dataDir = DOKU_INC . 'data/meta/';
3623        if ($namespace !== '') {
3624            $dataDir .= str_replace(':', '/', $namespace) . '/';
3625        }
3626        $dataDir .= 'calendar/';
3627
3628        $resumed = 0;
3629        $cleanTitle = preg_replace('/^⏸\s*/', '', $title);
3630
3631        if (!is_dir($dataDir)) {
3632            echo json_encode(['success' => false, 'error' => 'Directory not found']);
3633            return;
3634        }
3635
3636        foreach (glob($dataDir . '*.json') as $file) {
3637            $data = json_decode(file_get_contents($file), true);
3638            if (!$data) continue;
3639
3640            $modified = false;
3641            foreach ($data as $dateKey => &$dayEvents) {
3642                // Skip non-date keys (like "mapping" or other metadata)
3643                if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3644                if (!is_array($dayEvents)) continue;
3645
3646                foreach ($dayEvents as $k => &$evt) {
3647                    if (!isset($evt['title'])) continue;
3648                    $evtCleanTitle = preg_replace('/^⏸\s*/', '', $evt['title']);
3649                    if (strtolower(trim($evtCleanTitle)) === strtolower(trim($cleanTitle)) &&
3650                        (!empty($evt['paused']) || strpos($evt['title'], '⏸') === 0)) {
3651                        $evt['paused'] = false;
3652                        $evt['title'] = $cleanTitle;
3653                        $resumed++;
3654                        $modified = true;
3655                    }
3656                }
3657                unset($evt);
3658            }
3659            unset($dayEvents);
3660
3661            if ($modified) {
3662                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
3663            }
3664        }
3665
3666        $this->clearStatsCache();
3667        echo json_encode(['success' => true, 'message' => "Resumed $resumed occurrences"]);
3668    }
3669
3670    /**
3671     * Change start date: shift all occurrences by an offset
3672     */
3673    private function handleChangeStartRecurring() {
3674        global $INPUT;
3675        $title = $INPUT->str('title');
3676        $namespace = $INPUT->str('namespace');
3677        $newStartDate = $INPUT->str('new_start_date');
3678
3679        if (empty($newStartDate)) {
3680            echo json_encode(['success' => false, 'error' => 'No start date provided']);
3681            return;
3682        }
3683
3684        $events = $this->getRecurringSeriesEvents($title, $namespace);
3685        if (empty($events)) {
3686            echo json_encode(['success' => false, 'error' => 'Series not found']);
3687            return;
3688        }
3689
3690        // Calculate offset from old first date to new first date
3691        $oldFirst = new DateTime($events[0]['date']);
3692        $newFirst = new DateTime($newStartDate);
3693        $offsetDays = (int)$oldFirst->diff($newFirst)->format('%r%a');
3694
3695        if ($offsetDays === 0) {
3696            echo json_encode(['success' => true, 'message' => 'Start date unchanged']);
3697            return;
3698        }
3699
3700        $dataDir = DOKU_INC . 'data/meta/';
3701        if ($namespace !== '') {
3702            $dataDir .= str_replace(':', '/', $namespace) . '/';
3703        }
3704        $dataDir .= 'calendar/';
3705
3706        // Collect all events to move
3707        $toMove = [];
3708        foreach ($events as $entry) {
3709            $oldDate = new DateTime($entry['date']);
3710            $newDate = clone $oldDate;
3711            $newDate->modify(($offsetDays > 0 ? '+' : '') . $offsetDays . ' days');
3712
3713            $toMove[] = [
3714                'oldDate' => $entry['date'],
3715                'newDate' => $newDate->format('Y-m-d'),
3716                'event' => $entry['event'],
3717                'file' => $entry['file']
3718            ];
3719        }
3720
3721        // Remove all from old positions
3722        foreach ($toMove as $move) {
3723            $data = json_decode(file_get_contents($move['file']), true);
3724            if (!$data || !isset($data[$move['oldDate']])) continue;
3725
3726            foreach ($data[$move['oldDate']] as $k => $evt) {
3727                if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
3728                    unset($data[$move['oldDate']][$k]);
3729                    $data[$move['oldDate']] = array_values($data[$move['oldDate']]);
3730                    break;
3731                }
3732            }
3733            if (empty($data[$move['oldDate']])) unset($data[$move['oldDate']]);
3734            if (empty($data)) {
3735                unlink($move['file']);
3736            } else {
3737                file_put_contents($move['file'], json_encode($data, JSON_PRETTY_PRINT));
3738            }
3739        }
3740
3741        // Add to new positions
3742        $moved = 0;
3743        foreach ($toMove as $move) {
3744            list($year, $month) = explode('-', $move['newDate']);
3745            $file = $dataDir . sprintf('%04d-%02d.json', $year, $month);
3746            $data = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
3747            if (!is_array($data)) $data = [];
3748
3749            if (!isset($data[$move['newDate']])) $data[$move['newDate']] = [];
3750            $data[$move['newDate']][] = $move['event'];
3751            file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
3752            $moved++;
3753        }
3754
3755        $dir = $offsetDays > 0 ? 'forward' : 'back';
3756        $this->clearStatsCache();
3757        echo json_encode(['success' => true, 'message' => "Shifted $moved occurrences $dir by " . abs($offsetDays) . " days"]);
3758    }
3759
3760    /**
3761     * Change pattern: re-space all future events with a new interval
3762     */
3763    private function handleChangePatternRecurring() {
3764        global $INPUT;
3765        $title = $INPUT->str('title');
3766        $namespace = $INPUT->str('namespace');
3767        $newIntervalDays = $INPUT->int('interval_days', 7);
3768
3769        $events = $this->getRecurringSeriesEvents($title, $namespace);
3770        $today = date('Y-m-d');
3771
3772        // Split into past and future
3773        $pastEvents = [];
3774        $futureEvents = [];
3775        foreach ($events as $e) {
3776            if ($e['date'] < $today) {
3777                $pastEvents[] = $e;
3778            } else {
3779                $futureEvents[] = $e;
3780            }
3781        }
3782
3783        if (empty($futureEvents)) {
3784            echo json_encode(['success' => false, 'error' => 'No future occurrences to respace']);
3785            return;
3786        }
3787
3788        $dataDir = DOKU_INC . 'data/meta/';
3789        if ($namespace !== '') {
3790            $dataDir .= str_replace(':', '/', $namespace) . '/';
3791        }
3792        $dataDir .= 'calendar/';
3793
3794        // Use first future event as anchor
3795        $anchorDate = new DateTime($futureEvents[0]['date']);
3796
3797        // Remove all future events from files
3798        foreach ($futureEvents as $entry) {
3799            $data = json_decode(file_get_contents($entry['file']), true);
3800            if (!$data || !isset($data[$entry['date']])) continue;
3801
3802            foreach ($data[$entry['date']] as $k => $evt) {
3803                if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
3804                    unset($data[$entry['date']][$k]);
3805                    $data[$entry['date']] = array_values($data[$entry['date']]);
3806                    break;
3807                }
3808            }
3809            if (empty($data[$entry['date']])) unset($data[$entry['date']]);
3810            if (empty($data)) {
3811                unlink($entry['file']);
3812            } else {
3813                file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
3814            }
3815        }
3816
3817        // Re-create with new spacing
3818        $template = $futureEvents[0]['event'];
3819        $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace);
3820        $count = count($futureEvents);
3821        $created = 0;
3822
3823        for ($i = 0; $i < $count; $i++) {
3824            $newDate = clone $anchorDate;
3825            $newDate->modify('+' . ($i * $newIntervalDays) . ' days');
3826            $dateKey = $newDate->format('Y-m-d');
3827            list($year, $month) = explode('-', $dateKey);
3828
3829            $file = $dataDir . sprintf('%04d-%02d.json', $year, $month);
3830            $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
3831            if (!is_array($fileData)) $fileData = [];
3832
3833            if (!isset($fileData[$dateKey])) $fileData[$dateKey] = [];
3834
3835            $newEvent = $template;
3836            $newEvent['id'] = $baseId . '-respace-' . $i;
3837            $newEvent['recurring'] = true;
3838            $newEvent['recurringId'] = $baseId;
3839
3840            $fileData[$dateKey][] = $newEvent;
3841            file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT));
3842            $created++;
3843        }
3844
3845        $this->clearStatsCache();
3846        $patternName = $this->intervalToPattern($newIntervalDays);
3847        echo json_encode(['success' => true, 'message' => "Respaced $created future occurrences to $patternName ($newIntervalDays days)"]);
3848    }
3849
3850    private function intervalToPattern($days) {
3851        if ($days == 1) return 'Daily';
3852        if ($days == 7) return 'Weekly';
3853        if ($days == 14) return 'Bi-weekly';
3854        if ($days >= 28 && $days <= 31) return 'Monthly';
3855        if ($days >= 89 && $days <= 93) return 'Quarterly';
3856        if ($days >= 363 && $days <= 368) return 'Yearly';
3857        return "Every $days days";
3858    }
3859
3860    private function getEventsByNamespace() {
3861        $dataDir = DOKU_INC . 'data/meta/';
3862        $result = [];
3863
3864        // Check root calendar directory first (blank/default namespace)
3865        $rootCalendarDir = $dataDir . 'calendar';
3866        if (is_dir($rootCalendarDir)) {
3867            $hasFiles = false;
3868            $events = [];
3869
3870            foreach (glob($rootCalendarDir . '/*.json') as $file) {
3871                $hasFiles = true;
3872                $month = basename($file, '.json');
3873                $data = json_decode(file_get_contents($file), true);
3874                if (!$data) continue;
3875
3876                foreach ($data as $dateKey => $eventList) {
3877                    // Skip non-date keys (like "mapping" or other metadata)
3878                    // Date keys should be in YYYY-MM-DD format
3879                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3880
3881                    // Skip if eventList is not an array (corrupted data)
3882                    if (!is_array($eventList)) continue;
3883
3884                    foreach ($eventList as $event) {
3885                        // Skip if event is not an array
3886                        if (!is_array($event)) continue;
3887
3888                        // Skip if event doesn't have required fields
3889                        if (empty($event['id']) || empty($event['title'])) continue;
3890
3891                        $events[] = [
3892                            'id' => $event['id'],
3893                            'title' => $event['title'],
3894                            'date' => $dateKey,
3895                            'startTime' => $event['startTime'] ?? null,
3896                            'month' => $month
3897                        ];
3898                    }
3899                }
3900            }
3901
3902            // Add if it has JSON files (even if empty)
3903            if ($hasFiles) {
3904                $result[''] = ['events' => $events];
3905            }
3906        }
3907
3908        // Recursively scan all namespace directories including sub-namespaces
3909        $this->scanNamespaceRecursive($dataDir, '', $result);
3910
3911        // Sort namespaces, but keep '' (default) first
3912        uksort($result, function($a, $b) {
3913            if ($a === '') return -1;
3914            if ($b === '') return 1;
3915            return strcmp($a, $b);
3916        });
3917
3918        return $result;
3919    }
3920
3921    private function scanNamespaceRecursive($baseDir, $parentNamespace, &$result) {
3922        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
3923            $dirName = basename($nsDir);
3924
3925            // Skip the root 'calendar' dir
3926            if ($dirName === 'calendar' && empty($parentNamespace)) continue;
3927
3928            // Build namespace path
3929            $namespace = empty($parentNamespace) ? $dirName : $parentNamespace . ':' . $dirName;
3930
3931            // Check for calendar directory
3932            $calendarDir = $nsDir . '/calendar';
3933            if (is_dir($calendarDir)) {
3934                $hasFiles = false;
3935                $events = [];
3936
3937                // Scan all calendar files
3938                foreach (glob($calendarDir . '/*.json') as $file) {
3939                    $hasFiles = true;
3940                    $month = basename($file, '.json');
3941                    $data = json_decode(file_get_contents($file), true);
3942                    if (!$data) continue;
3943
3944                    foreach ($data as $dateKey => $eventList) {
3945                        // Skip non-date keys (like "mapping" or other metadata)
3946                        // Date keys should be in YYYY-MM-DD format
3947                        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3948
3949                        // Skip if eventList is not an array (corrupted data)
3950                        if (!is_array($eventList)) continue;
3951
3952                        foreach ($eventList as $event) {
3953                            // Skip if event is not an array
3954                            if (!is_array($event)) continue;
3955
3956                            // Skip if event doesn't have required fields
3957                            if (empty($event['id']) || empty($event['title'])) continue;
3958
3959                            $events[] = [
3960                                'id' => $event['id'],
3961                                'title' => $event['title'],
3962                                'date' => $dateKey,
3963                                'startTime' => $event['startTime'] ?? null,
3964                                'month' => $month
3965                            ];
3966                        }
3967                    }
3968                }
3969
3970                // Add namespace if it has JSON files (even if empty)
3971                if ($hasFiles) {
3972                    $result[$namespace] = ['events' => $events];
3973                }
3974            }
3975
3976            // Recursively scan sub-directories
3977            $this->scanNamespaceRecursive($nsDir . '/', $namespace, $result);
3978        }
3979    }
3980
3981    private function getAllNamespaces() {
3982        $dataDir = DOKU_INC . 'data/meta/';
3983        $namespaces = [];
3984
3985        // Check root calendar directory first
3986        $rootCalendarDir = $dataDir . 'calendar';
3987        if (is_dir($rootCalendarDir)) {
3988            $namespaces[] = '';  // Blank/default namespace
3989        }
3990
3991        // Check all other namespace directories
3992        foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) {
3993            $namespace = basename($nsDir);
3994
3995            // Skip the root 'calendar' dir (already added as '')
3996            if ($namespace === 'calendar') continue;
3997
3998            $calendarDir = $nsDir . '/calendar';
3999            if (is_dir($calendarDir)) {
4000                $namespaces[] = $namespace;
4001            }
4002        }
4003
4004        return $namespaces;
4005    }
4006
4007    private function searchEvents($search, $filterNamespace) {
4008        $dataDir = DOKU_INC . 'data/meta/';
4009        $results = [];
4010
4011        $search = strtolower(trim($search));
4012
4013        foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) {
4014            $namespace = basename($nsDir);
4015            $calendarDir = $nsDir . '/calendar';
4016
4017            if (!is_dir($calendarDir)) continue;
4018            if ($filterNamespace !== '' && $namespace !== $filterNamespace) continue;
4019
4020            foreach (glob($calendarDir . '/*.json') as $file) {
4021                $month = basename($file, '.json');
4022                $data = json_decode(file_get_contents($file), true);
4023                if (!$data) continue;
4024
4025                foreach ($data as $dateKey => $events) {
4026                    // Skip non-date keys (like "mapping" or other metadata)
4027                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
4028                    if (!is_array($events)) continue;
4029
4030                    foreach ($events as $event) {
4031                        if (!isset($event['title']) || !isset($event['id'])) continue;
4032                        if ($search === '' || strpos(strtolower($event['title']), $search) !== false) {
4033                            $results[] = [
4034                                'id' => $event['id'],
4035                                'title' => $event['title'],
4036                                'date' => $dateKey,
4037                                'startTime' => $event['startTime'] ?? null,
4038                                'namespace' => $event['namespace'] ?? '',
4039                                'month' => $month
4040                            ];
4041                        }
4042                    }
4043                }
4044            }
4045        }
4046
4047        return $results;
4048    }
4049
4050    private function deleteRecurringSeries() {
4051        global $INPUT;
4052
4053        $eventTitle = $INPUT->str('event_title');
4054        $namespace = $INPUT->str('namespace');
4055
4056        // Collect ALL calendar directories
4057        $dataDir = DOKU_INC . 'data/meta/';
4058        $calendarDirs = [];
4059        if (is_dir($dataDir . 'calendar')) {
4060            $calendarDirs[] = $dataDir . 'calendar';
4061        }
4062        $this->findCalendarDirs($dataDir, $calendarDirs);
4063
4064        $count = 0;
4065
4066        foreach ($calendarDirs as $calDir) {
4067            foreach (glob($calDir . '/*.json') as $file) {
4068                $data = json_decode(file_get_contents($file), true);
4069                if (!$data || !is_array($data)) continue;
4070
4071                $modified = false;
4072                foreach ($data as $dateKey => $events) {
4073                    // Skip non-date keys (like "mapping" or other metadata)
4074                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
4075                    if (!is_array($events)) continue;
4076
4077                    $filtered = [];
4078                    foreach ($events as $event) {
4079                        if (!isset($event['title'])) {
4080                            $filtered[] = $event;
4081                            continue;
4082                        }
4083                        $eventNs = isset($event['namespace']) ? $event['namespace'] : '';
4084                        // Match by title AND namespace field
4085                        if (strtolower(trim($event['title'])) === strtolower(trim($eventTitle)) &&
4086                            strtolower(trim($eventNs)) === strtolower(trim($namespace))) {
4087                            $count++;
4088                            $modified = true;
4089                        } else {
4090                            $filtered[] = $event;
4091                        }
4092                    }
4093                    $data[$dateKey] = $filtered;
4094                }
4095
4096                if ($modified) {
4097                    foreach ($data as $dk => $evts) {
4098                        if (empty($evts)) unset($data[$dk]);
4099                    }
4100
4101                    if (empty($data)) {
4102                        unlink($file);
4103                    } else {
4104                        file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
4105                    }
4106                }
4107            }
4108        }
4109
4110        $this->clearStatsCache();
4111        $this->redirect("Deleted $count occurrences of recurring event: " . $eventTitle, 'success', 'manage');
4112    }
4113
4114    private function editRecurringSeries() {
4115        global $INPUT;
4116
4117        $oldTitle = $INPUT->str('old_title');
4118        $oldNamespace = $INPUT->str('old_namespace');
4119        $newTitle = $INPUT->str('new_title');
4120        $startTime = $INPUT->str('start_time');
4121        $endTime = $INPUT->str('end_time');
4122        $newNamespace = $INPUT->str('new_namespace');
4123
4124        // New recurrence parameters
4125        $recurrenceType = $INPUT->str('recurrence_type', '');
4126        $recurrenceInterval = $INPUT->int('recurrence_interval', 0);
4127        $weekDaysStr = $INPUT->str('week_days', '');
4128        $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : [];
4129        $monthlyType = $INPUT->str('monthly_type', '');
4130        $monthDay = $INPUT->int('month_day', 0);
4131        $ordinalWeek = $INPUT->int('ordinal_week', 0);
4132        $ordinalDay = $INPUT->int('ordinal_day', 0);
4133
4134        // Use old namespace if new namespace is empty (keep current)
4135        if (empty($newNamespace) && !isset($_POST['new_namespace'])) {
4136            $newNamespace = $oldNamespace;
4137        }
4138
4139        // Collect ALL calendar directories to search
4140        $dataDir = DOKU_INC . 'data/meta/';
4141        $calendarDirs = [];
4142
4143        // Root calendar dir
4144        if (is_dir($dataDir . 'calendar')) {
4145            $calendarDirs[] = $dataDir . 'calendar';
4146        }
4147
4148        // All namespace dirs
4149        $this->findCalendarDirs($dataDir, $calendarDirs);
4150
4151        $count = 0;
4152
4153        // Pass 1: Rename title, update time, update namespace field and recurrence metadata in ALL matching events
4154        foreach ($calendarDirs as $calDir) {
4155            if (is_string($calDir)) {
4156                $dir = $calDir;
4157            } else {
4158                $dir = $calDir['dir'];
4159            }
4160
4161            foreach (glob($dir . '/*.json') as $file) {
4162                $data = json_decode(file_get_contents($file), true);
4163                if (!$data || !is_array($data)) continue;
4164
4165                $modified = false;
4166                foreach ($data as $dateKey => &$dayEvents) {
4167                    // Skip non-date keys (like "mapping" or other metadata)
4168                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
4169                    if (!is_array($dayEvents)) continue;
4170
4171                    foreach ($dayEvents as $key => &$event) {
4172                        if (!isset($event['title'])) continue;
4173                        // Match by old title (case-insensitive) AND namespace field
4174                        $eventNs = isset($event['namespace']) ? $event['namespace'] : '';
4175                        if (strtolower(trim($event['title'])) !== strtolower(trim($oldTitle))) continue;
4176                        if (strtolower(trim($eventNs)) !== strtolower(trim($oldNamespace))) continue;
4177
4178                        // Update title
4179                        $event['title'] = $newTitle;
4180
4181                        // Update start time if provided
4182                        if (!empty($startTime)) {
4183                            $event['time'] = $startTime;
4184                        }
4185
4186                        // Update end time if provided
4187                        if (!empty($endTime)) {
4188                            $event['endTime'] = $endTime;
4189                        }
4190
4191                        // Update namespace field
4192                        $event['namespace'] = $newNamespace;
4193
4194                        // Update recurrence metadata if provided
4195                        if (!empty($recurrenceType)) {
4196                            $event['recurrenceType'] = $recurrenceType;
4197                        }
4198                        if ($recurrenceInterval > 0) {
4199                            $event['recurrenceInterval'] = $recurrenceInterval;
4200                        }
4201                        if (!empty($weekDays)) {
4202                            $event['weekDays'] = $weekDays;
4203                        }
4204                        if (!empty($monthlyType)) {
4205                            $event['monthlyType'] = $monthlyType;
4206                            if ($monthlyType === 'dayOfMonth' && $monthDay > 0) {
4207                                $event['monthDay'] = $monthDay;
4208                                unset($event['ordinalWeek']);
4209                                unset($event['ordinalDay']);
4210                            } elseif ($monthlyType === 'ordinalWeekday') {
4211                                $event['ordinalWeek'] = $ordinalWeek;
4212                                $event['ordinalDay'] = $ordinalDay;
4213                                unset($event['monthDay']);
4214                            }
4215                        }
4216
4217                        $count++;
4218                        $modified = true;
4219                    }
4220                    unset($event);
4221                }
4222                unset($dayEvents);
4223
4224                if ($modified) {
4225                    file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
4226                }
4227            }
4228        }
4229
4230        // Pass 2: Handle recurrence pattern changes - reschedule future events
4231        $needsReschedule = !empty($recurrenceType) && $recurrenceInterval > 0;
4232
4233        if ($needsReschedule && $count > 0) {
4234            // Get all events with the NEW title
4235            $allEvents = $this->getRecurringSeriesEvents($newTitle, $newNamespace);
4236
4237            if (count($allEvents) > 1) {
4238                // Sort by date
4239                usort($allEvents, function($a, $b) {
4240                    return strcmp($a['date'], $b['date']);
4241                });
4242
4243                $firstDate = new DateTime($allEvents[0]['date']);
4244                $today = new DateTime();
4245                $today->setTime(0, 0, 0);
4246
4247                // Find the anchor date - either first date or first future date
4248                $anchorDate = $firstDate;
4249                $anchorIndex = 0;
4250                for ($i = 0; $i < count($allEvents); $i++) {
4251                    $eventDate = new DateTime($allEvents[$i]['date']);
4252                    if ($eventDate >= $today) {
4253                        $anchorDate = $eventDate;
4254                        $anchorIndex = $i;
4255                        break;
4256                    }
4257                }
4258
4259                // Get template from anchor event
4260                $template = $allEvents[$anchorIndex]['event'];
4261
4262                // Remove all future events (we'll recreate them)
4263                for ($i = $anchorIndex + 1; $i < count($allEvents); $i++) {
4264                    $entry = $allEvents[$i];
4265                    $data = json_decode(file_get_contents($entry['file']), true);
4266                    if (!$data || !isset($data[$entry['date']])) continue;
4267
4268                    foreach ($data[$entry['date']] as $k => $evt) {
4269                        if (strtolower(trim($evt['title'])) === strtolower(trim($newTitle))) {
4270                            unset($data[$entry['date']][$k]);
4271                            $data[$entry['date']] = array_values($data[$entry['date']]);
4272                            break;
4273                        }
4274                    }
4275                    if (empty($data[$entry['date']])) unset($data[$entry['date']]);
4276                    if (empty($data)) {
4277                        unlink($entry['file']);
4278                    } else {
4279                        file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
4280                    }
4281                }
4282
4283                // Recreate with new pattern
4284                $targetDir = ($newNamespace === '')
4285                    ? DOKU_INC . 'data/meta/calendar'
4286                    : DOKU_INC . 'data/meta/' . str_replace(':', '/', $newNamespace) . '/calendar';
4287                if (!is_dir($targetDir)) mkdir($targetDir, 0755, true);
4288
4289                $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($newTitle . $newNamespace);
4290
4291                // Calculate how many future events we need (use same count as before)
4292                $futureCount = count($allEvents) - $anchorIndex - 1;
4293                if ($futureCount < 1) $futureCount = 12; // Default to 12 future occurrences
4294
4295                // Generate new dates based on recurrence pattern
4296                $newDates = $this->generateRecurrenceDates(
4297                    $anchorDate->format('Y-m-d'),
4298                    $recurrenceType,
4299                    $recurrenceInterval,
4300                    $weekDays,
4301                    $monthlyType,
4302                    $monthDay,
4303                    $ordinalWeek,
4304                    $ordinalDay,
4305                    $futureCount
4306                );
4307
4308                // Create events for new dates (skip first since it's the anchor)
4309                for ($i = 1; $i < count($newDates); $i++) {
4310                    $dateKey = $newDates[$i];
4311                    list($year, $month) = explode('-', $dateKey);
4312
4313                    $file = $targetDir . '/' . sprintf('%04d-%02d.json', $year, $month);
4314                    $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
4315                    if (!is_array($fileData)) $fileData = [];
4316                    if (!isset($fileData[$dateKey])) $fileData[$dateKey] = [];
4317
4318                    $newEvent = $template;
4319                    $newEvent['id'] = $baseId . '-respace-' . $i;
4320                    $newEvent['recurrenceType'] = $recurrenceType;
4321                    $newEvent['recurrenceInterval'] = $recurrenceInterval;
4322                    if (!empty($weekDays)) $newEvent['weekDays'] = $weekDays;
4323                    if (!empty($monthlyType)) $newEvent['monthlyType'] = $monthlyType;
4324                    if ($monthlyType === 'dayOfMonth' && $monthDay > 0) $newEvent['monthDay'] = $monthDay;
4325                    if ($monthlyType === 'ordinalWeekday') {
4326                        $newEvent['ordinalWeek'] = $ordinalWeek;
4327                        $newEvent['ordinalDay'] = $ordinalDay;
4328                    }
4329
4330                    $fileData[$dateKey][] = $newEvent;
4331                    file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT));
4332                }
4333            }
4334        }
4335
4336        $changes = [];
4337        if ($oldTitle !== $newTitle) $changes[] = "title";
4338        if (!empty($startTime) || !empty($endTime)) $changes[] = "time";
4339        if (!empty($recurrenceType)) $changes[] = "pattern";
4340        if ($newNamespace !== $oldNamespace) $changes[] = "namespace";
4341
4342        $changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : "";
4343        $this->clearStatsCache();
4344        $this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage');
4345    }
4346
4347    /**
4348     * Generate dates for a recurrence pattern
4349     */
4350    private function generateRecurrenceDates($startDate, $type, $interval, $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $count) {
4351        $dates = [$startDate];
4352        $currentDate = new DateTime($startDate);
4353        $maxIterations = $count * 100; // Safety limit
4354        $iterations = 0;
4355
4356        while (count($dates) < $count + 1 && $iterations < $maxIterations) {
4357            $iterations++;
4358            $currentDate->modify('+1 day');
4359            $shouldInclude = false;
4360
4361            switch ($type) {
4362                case 'daily':
4363                    $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days;
4364                    $shouldInclude = ($daysSinceStart % $interval === 0);
4365                    break;
4366
4367                case 'weekly':
4368                    $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days;
4369                    $weeksSinceStart = floor($daysSinceStart / 7);
4370                    $isCorrectWeek = ($weeksSinceStart % $interval === 0);
4371                    $currentDayOfWeek = (int)$currentDate->format('w');
4372                    $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays);
4373                    $shouldInclude = $isCorrectWeek && $isDaySelected;
4374                    break;
4375
4376                case 'monthly':
4377                    $startDT = new DateTime($startDate);
4378                    $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) +
4379                                        ($currentDate->format('n') - $startDT->format('n'));
4380                    $isCorrectMonth = ($monthsSinceStart > 0 && $monthsSinceStart % $interval === 0);
4381
4382                    if (!$isCorrectMonth) break;
4383
4384                    if ($monthlyType === 'dayOfMonth' || empty($monthlyType)) {
4385                        $targetDay = $monthDay ?: (int)$startDT->format('j');
4386                        $currentDay = (int)$currentDate->format('j');
4387                        $daysInMonth = (int)$currentDate->format('t');
4388                        $effectiveTargetDay = min($targetDay, $daysInMonth);
4389                        $shouldInclude = ($currentDay === $effectiveTargetDay);
4390                    } else {
4391                        $shouldInclude = $this->isOrdinalWeekdayAdmin($currentDate, $ordinalWeek, $ordinalDay);
4392                    }
4393                    break;
4394
4395                case 'yearly':
4396                    $startDT = new DateTime($startDate);
4397                    $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y');
4398                    $isCorrectYear = ($yearsSinceStart > 0 && $yearsSinceStart % $interval === 0);
4399                    $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d'));
4400                    $shouldInclude = $isCorrectYear && $sameMonthDay;
4401                    break;
4402            }
4403
4404            if ($shouldInclude) {
4405                $dates[] = $currentDate->format('Y-m-d');
4406            }
4407        }
4408
4409        return $dates;
4410    }
4411
4412    /**
4413     * Check if a date is the Nth occurrence of a weekday in its month (admin version)
4414     */
4415    private function isOrdinalWeekdayAdmin($date, $ordinalWeek, $targetDayOfWeek) {
4416        $currentDayOfWeek = (int)$date->format('w');
4417        if ($currentDayOfWeek !== $targetDayOfWeek) return false;
4418
4419        $dayOfMonth = (int)$date->format('j');
4420        $daysInMonth = (int)$date->format('t');
4421
4422        if ($ordinalWeek === -1) {
4423            $daysRemaining = $daysInMonth - $dayOfMonth;
4424            return $daysRemaining < 7;
4425        } else {
4426            $weekNumber = ceil($dayOfMonth / 7);
4427            return $weekNumber === $ordinalWeek;
4428        }
4429    }
4430
4431    /**
4432     * Find all calendar directories recursively
4433     */
4434    private function findCalendarDirs($baseDir, &$dirs) {
4435        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
4436            $name = basename($nsDir);
4437            if ($name === 'calendar') continue; // Skip root calendar (added separately)
4438
4439            $calDir = $nsDir . '/calendar';
4440            if (is_dir($calDir)) {
4441                $dirs[] = $calDir;
4442            }
4443
4444            // Recurse
4445            $this->findCalendarDirs($nsDir . '/', $dirs);
4446        }
4447    }
4448
4449    private function moveEvents() {
4450        global $INPUT;
4451
4452        $events = $INPUT->arr('events');
4453        $targetNamespace = $INPUT->str('target_namespace');
4454
4455        if (empty($events)) {
4456            $this->redirect('No events selected', 'error', 'manage');
4457        }
4458
4459        $moved = 0;
4460
4461        foreach ($events as $eventData) {
4462            list($id, $namespace, $date, $month) = explode('|', $eventData);
4463
4464            // Determine old file path
4465            if ($namespace === '') {
4466                $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
4467            } else {
4468                $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
4469            }
4470
4471            if (!file_exists($oldFile)) continue;
4472
4473            $oldData = json_decode(file_get_contents($oldFile), true);
4474            if (!$oldData) continue;
4475
4476            // Find and remove event from old file
4477            $event = null;
4478            if (isset($oldData[$date])) {
4479                foreach ($oldData[$date] as $key => $evt) {
4480                    if ($evt['id'] === $id) {
4481                        $event = $evt;
4482                        unset($oldData[$date][$key]);
4483                        $oldData[$date] = array_values($oldData[$date]);
4484                        break;
4485                    }
4486                }
4487
4488                // Remove empty date arrays
4489                if (empty($oldData[$date])) {
4490                    unset($oldData[$date]);
4491                }
4492            }
4493
4494            if (!$event) continue;
4495
4496            // Save old file
4497            file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT));
4498
4499            // Update event namespace
4500            $event['namespace'] = $targetNamespace;
4501
4502            // Determine new file path
4503            if ($targetNamespace === '') {
4504                $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
4505                $newDir = dirname($newFile);
4506            } else {
4507                $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json';
4508                $newDir = dirname($newFile);
4509            }
4510
4511            if (!is_dir($newDir)) {
4512                mkdir($newDir, 0755, true);
4513            }
4514
4515            $newData = [];
4516            if (file_exists($newFile)) {
4517                $newData = json_decode(file_get_contents($newFile), true) ?: [];
4518            }
4519
4520            if (!isset($newData[$date])) {
4521                $newData[$date] = [];
4522            }
4523            $newData[$date][] = $event;
4524
4525            file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT));
4526            $moved++;
4527        }
4528
4529        $displayTarget = $targetNamespace ?: '(default)';
4530        $this->clearStatsCache();
4531        $this->redirect("Moved $moved event(s) to namespace: " . $displayTarget, 'success', 'manage');
4532    }
4533
4534    private function moveSingleEvent() {
4535        global $INPUT;
4536
4537        $eventData = $INPUT->str('event');
4538        $targetNamespace = $INPUT->str('target_namespace');
4539
4540        list($id, $namespace, $date, $month) = explode('|', $eventData);
4541
4542        // Determine old file path
4543        if ($namespace === '') {
4544            $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
4545        } else {
4546            $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
4547        }
4548
4549        if (!file_exists($oldFile)) {
4550            $this->redirect('Event file not found', 'error', 'manage');
4551        }
4552
4553        $oldData = json_decode(file_get_contents($oldFile), true);
4554        if (!$oldData) {
4555            $this->redirect('Could not read event file', 'error', 'manage');
4556        }
4557
4558        // Find and remove event from old file
4559        $event = null;
4560        if (isset($oldData[$date])) {
4561            foreach ($oldData[$date] as $key => $evt) {
4562                if ($evt['id'] === $id) {
4563                    $event = $evt;
4564                    unset($oldData[$date][$key]);
4565                    $oldData[$date] = array_values($oldData[$date]);
4566                    break;
4567                }
4568            }
4569
4570            // Remove empty date arrays
4571            if (empty($oldData[$date])) {
4572                unset($oldData[$date]);
4573            }
4574        }
4575
4576        if (!$event) {
4577            $this->redirect('Event not found', 'error', 'manage');
4578        }
4579
4580        // Save old file (or delete if empty)
4581        if (empty($oldData)) {
4582            unlink($oldFile);
4583        } else {
4584            file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT));
4585        }
4586
4587        // Update event namespace
4588        $event['namespace'] = $targetNamespace;
4589
4590        // Determine new file path
4591        if ($targetNamespace === '') {
4592            $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
4593            $newDir = dirname($newFile);
4594        } else {
4595            $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json';
4596            $newDir = dirname($newFile);
4597        }
4598
4599        if (!is_dir($newDir)) {
4600            mkdir($newDir, 0755, true);
4601        }
4602
4603        $newData = [];
4604        if (file_exists($newFile)) {
4605            $newData = json_decode(file_get_contents($newFile), true) ?: [];
4606        }
4607
4608        if (!isset($newData[$date])) {
4609            $newData[$date] = [];
4610        }
4611        $newData[$date][] = $event;
4612
4613        file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT));
4614
4615        $displayTarget = $targetNamespace ?: '(default)';
4616        $this->clearStatsCache();
4617        $this->redirect('Moved "' . $event['title'] . '" to ' . $displayTarget, 'success', 'manage');
4618    }
4619
4620    private function createNamespace() {
4621        global $INPUT;
4622
4623        $namespaceName = $INPUT->str('namespace_name');
4624
4625        // Validate namespace name
4626        if (empty($namespaceName)) {
4627            $this->redirect('Namespace name cannot be empty', 'error', 'manage');
4628        }
4629
4630        if (!preg_match('/^[a-zA-Z0-9_:-]+$/', $namespaceName)) {
4631            $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
4632        }
4633
4634        // Convert namespace to directory path
4635        $namespacePath = str_replace(':', '/', $namespaceName);
4636        $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar';
4637
4638        // Check if already exists
4639        if (is_dir($calendarDir)) {
4640            // Check if it has any JSON files
4641            $hasFiles = !empty(glob($calendarDir . '/*.json'));
4642            if ($hasFiles) {
4643                $this->redirect("Namespace '$namespaceName' already exists with events", 'info', 'manage');
4644            }
4645            // If directory exists but empty, continue to create placeholder
4646        }
4647
4648        // Create the directory
4649        if (!is_dir($calendarDir)) {
4650            if (!mkdir($calendarDir, 0755, true)) {
4651                $this->redirect("Failed to create namespace directory", 'error', 'manage');
4652            }
4653        }
4654
4655        // Create a placeholder JSON file with an empty structure for current month
4656        // This ensures the namespace appears in the list immediately
4657        $currentMonth = date('Y-m');
4658        $placeholderFile = $calendarDir . '/' . $currentMonth . '.json';
4659
4660        if (!file_exists($placeholderFile)) {
4661            file_put_contents($placeholderFile, json_encode([], JSON_PRETTY_PRINT));
4662        }
4663
4664        $this->redirect("Created namespace: $namespaceName", 'success', 'manage');
4665    }
4666
4667    private function deleteNamespace() {
4668        global $INPUT;
4669
4670        $namespace = $INPUT->str('namespace');
4671
4672        // Validate namespace name to prevent path traversal
4673        if ($namespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $namespace)) {
4674            $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
4675            return;
4676        }
4677
4678        // Additional safety: ensure no path traversal sequences
4679        if (strpos($namespace, '..') !== false || strpos($namespace, '/') !== false || strpos($namespace, '\\') !== false) {
4680            $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage');
4681            return;
4682        }
4683
4684        // Convert namespace to directory path (e.g., "work:projects" → "work/projects")
4685        $namespacePath = str_replace(':', '/', $namespace);
4686
4687        // Determine calendar directory
4688        if ($namespace === '') {
4689            $calendarDir = DOKU_INC . 'data/meta/calendar';
4690            $namespaceDir = null; // Don't delete root
4691        } else {
4692            $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar';
4693            $namespaceDir = DOKU_INC . 'data/meta/' . $namespacePath;
4694        }
4695
4696        // Check if directory exists
4697        if (!is_dir($calendarDir)) {
4698            // Maybe it was never created or already deleted
4699            $this->redirect("Namespace directory not found: $calendarDir", 'error', 'manage');
4700            return;
4701        }
4702
4703        $filesDeleted = 0;
4704        $eventsDeleted = 0;
4705
4706        // Delete all calendar JSON files (including empty ones)
4707        foreach (glob($calendarDir . '/*.json') as $file) {
4708            $data = json_decode(file_get_contents($file), true);
4709            if ($data) {
4710                foreach ($data as $events) {
4711                    if (is_array($events)) {
4712                        $eventsDeleted += count($events);
4713                    }
4714                }
4715            }
4716            unlink($file);
4717            $filesDeleted++;
4718        }
4719
4720        // Delete any other files in calendar directory
4721        foreach (glob($calendarDir . '/*') as $file) {
4722            if (is_file($file)) {
4723                unlink($file);
4724            }
4725        }
4726
4727        // Remove the calendar directory
4728        if ($namespace !== '') {
4729            @rmdir($calendarDir);
4730
4731            // Try to remove parent directories if they're empty
4732            // This handles nested namespaces like work:projects:alpha
4733            $currentDir = dirname($calendarDir);
4734            $metaDir = DOKU_INC . 'data/meta';
4735
4736            while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) {
4737                if (is_dir($currentDir)) {
4738                    // Check if directory is empty
4739                    $contents = scandir($currentDir);
4740                    $isEmpty = count($contents) === 2; // Only . and ..
4741
4742                    if ($isEmpty) {
4743                        @rmdir($currentDir);
4744                        $currentDir = dirname($currentDir);
4745                    } else {
4746                        break; // Directory not empty, stop
4747                    }
4748                } else {
4749                    break;
4750                }
4751            }
4752        }
4753
4754        $displayName = $namespace ?: '(default)';
4755        $this->clearStatsCache();
4756        $this->redirect("Deleted namespace '$displayName': $eventsDeleted events in $filesDeleted files", 'success', 'manage');
4757    }
4758
4759    private function renameNamespace() {
4760        global $INPUT;
4761
4762        $oldNamespace = $INPUT->str('old_namespace');
4763        $newNamespace = $INPUT->str('new_namespace');
4764
4765        // Validate namespace names to prevent path traversal
4766        if ($oldNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $oldNamespace)) {
4767            $this->redirect('Invalid old namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
4768            return;
4769        }
4770
4771        if ($newNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $newNamespace)) {
4772            $this->redirect('Invalid new namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
4773            return;
4774        }
4775
4776        // Additional safety: ensure no path traversal sequences
4777        if (strpos($oldNamespace, '..') !== false || strpos($oldNamespace, '/') !== false || strpos($oldNamespace, '\\') !== false ||
4778            strpos($newNamespace, '..') !== false || strpos($newNamespace, '/') !== false || strpos($newNamespace, '\\') !== false) {
4779            $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage');
4780            return;
4781        }
4782
4783        // Validate new namespace name
4784        if ($newNamespace === '') {
4785            $this->redirect("Cannot rename to empty namespace", 'error', 'manage');
4786            return;
4787        }
4788
4789        // Convert namespaces to directory paths
4790        $oldPath = str_replace(':', '/', $oldNamespace);
4791        $newPath = str_replace(':', '/', $newNamespace);
4792
4793        // Determine source and destination directories
4794        if ($oldNamespace === '') {
4795            $sourceDir = DOKU_INC . 'data/meta/calendar';
4796        } else {
4797            $sourceDir = DOKU_INC . 'data/meta/' . $oldPath . '/calendar';
4798        }
4799
4800        if ($newNamespace === '') {
4801            $targetDir = DOKU_INC . 'data/meta/calendar';
4802        } else {
4803            $targetDir = DOKU_INC . 'data/meta/' . $newPath . '/calendar';
4804        }
4805
4806        // Check if source exists
4807        if (!is_dir($sourceDir)) {
4808            $this->redirect("Source namespace not found: $oldNamespace", 'error', 'manage');
4809            return;
4810        }
4811
4812        // Check if target already exists
4813        if (is_dir($targetDir)) {
4814            $this->redirect("Target namespace already exists: $newNamespace", 'error', 'manage');
4815            return;
4816        }
4817
4818        // Create target directory
4819        if (!file_exists(dirname($targetDir))) {
4820            mkdir(dirname($targetDir), 0755, true);
4821        }
4822
4823        // Rename directory
4824        if (!rename($sourceDir, $targetDir)) {
4825            $this->redirect("Failed to rename namespace", 'error', 'manage');
4826            return;
4827        }
4828
4829        // Update event namespace field in all JSON files
4830        $eventsUpdated = 0;
4831        foreach (glob($targetDir . '/*.json') as $file) {
4832            $data = json_decode(file_get_contents($file), true);
4833            if ($data) {
4834                foreach ($data as $date => &$events) {
4835                    foreach ($events as &$event) {
4836                        if (isset($event['namespace']) && $event['namespace'] === $oldNamespace) {
4837                            $event['namespace'] = $newNamespace;
4838                            $eventsUpdated++;
4839                        }
4840                    }
4841                }
4842                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
4843            }
4844        }
4845
4846        // Clean up old directory structure if empty
4847        if ($oldNamespace !== '') {
4848            $currentDir = dirname($sourceDir);
4849            $metaDir = DOKU_INC . 'data/meta';
4850
4851            while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) {
4852                if (is_dir($currentDir)) {
4853                    $contents = scandir($currentDir);
4854                    $isEmpty = count($contents) === 2; // Only . and ..
4855
4856                    if ($isEmpty) {
4857                        @rmdir($currentDir);
4858                        $currentDir = dirname($currentDir);
4859                    } else {
4860                        break;
4861                    }
4862                } else {
4863                    break;
4864                }
4865            }
4866        }
4867
4868        $this->clearStatsCache();
4869        $this->redirect("Renamed namespace from '$oldNamespace' to '$newNamespace' ($eventsUpdated events updated)", 'success', 'manage');
4870    }
4871
4872    private function deleteSelectedEvents() {
4873        global $INPUT;
4874
4875        $events = $INPUT->arr('events');
4876
4877        if (empty($events)) {
4878            $this->redirect('No events selected', 'error', 'manage');
4879        }
4880
4881        $deletedCount = 0;
4882
4883        foreach ($events as $eventData) {
4884            list($id, $namespace, $date, $month) = explode('|', $eventData);
4885
4886            // Determine file path
4887            if ($namespace === '') {
4888                $file = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
4889            } else {
4890                $file = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
4891            }
4892
4893            if (!file_exists($file)) continue;
4894
4895            $data = json_decode(file_get_contents($file), true);
4896            if (!$data) continue;
4897
4898            // Find and remove event
4899            if (isset($data[$date])) {
4900                foreach ($data[$date] as $key => $evt) {
4901                    if ($evt['id'] === $id) {
4902                        unset($data[$date][$key]);
4903                        $data[$date] = array_values($data[$date]);
4904                        $deletedCount++;
4905                        break;
4906                    }
4907                }
4908
4909                // Remove empty date arrays
4910                if (empty($data[$date])) {
4911                    unset($data[$date]);
4912                }
4913
4914                // Save file
4915                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
4916            }
4917        }
4918
4919        $this->clearStatsCache();
4920        $this->redirect("Deleted $deletedCount event(s)", 'success', 'manage');
4921    }
4922
4923    /**
4924     * Clear the event statistics cache so counts refresh after mutations
4925     */
4926    private function saveImportantNamespaces() {
4927        global $INPUT;
4928
4929        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
4930        $config = [];
4931        if (file_exists($configFile)) {
4932            $config = include $configFile;
4933        }
4934
4935        $config['important_namespaces'] = $INPUT->str('important_namespaces', 'important');
4936
4937        $content = "<?php\nreturn " . var_export($config, true) . ";\n";
4938        if (file_put_contents($configFile, $content)) {
4939            $this->redirect('Important namespaces saved', 'success', 'manage');
4940        } else {
4941            $this->redirect('Error: Could not save configuration', 'error', 'manage');
4942        }
4943    }
4944
4945    private function clearStatsCache() {
4946        $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
4947        if (file_exists($cacheFile)) {
4948            unlink($cacheFile);
4949        }
4950    }
4951
4952    private function getCronStatus() {
4953        // Try to read root's crontab first, then current user
4954        $output = [];
4955        exec('sudo crontab -l 2>/dev/null', $output);
4956
4957        // If sudo doesn't work, try current user
4958        if (empty($output)) {
4959            exec('crontab -l 2>/dev/null', $output);
4960        }
4961
4962        // Also check system crontab files
4963        if (empty($output)) {
4964            $cronFiles = [
4965                '/etc/crontab',
4966                '/etc/cron.d/calendar',
4967                '/var/spool/cron/root',
4968                '/var/spool/cron/crontabs/root'
4969            ];
4970
4971            foreach ($cronFiles as $file) {
4972                if (file_exists($file) && is_readable($file)) {
4973                    $content = file_get_contents($file);
4974                    $output = explode("\n", $content);
4975                    break;
4976                }
4977            }
4978        }
4979
4980        // Look for sync_outlook.php in the cron entries
4981        foreach ($output as $line) {
4982            $line = trim($line);
4983
4984            // Skip empty lines and comments
4985            if (empty($line) || $line[0] === '#') continue;
4986
4987            // Check if line contains sync_outlook.php
4988            if (strpos($line, 'sync_outlook.php') !== false) {
4989                // Parse cron expression
4990                // Format: minute hour day month weekday [user] command
4991                $parts = preg_split('/\s+/', $line, 7);
4992
4993                if (count($parts) >= 5) {
4994                    // Determine if this has a user field (system crontab format)
4995                    $hasUser = (count($parts) >= 6 && !preg_match('/^[\/\*]/', $parts[5]));
4996                    $offset = $hasUser ? 1 : 0;
4997
4998                    $frequency = $this->parseCronExpression($parts[0], $parts[1], $parts[2], $parts[3], $parts[4]);
4999                    return [
5000                        'active' => true,
5001                        'frequency' => $frequency,
5002                        'expression' => implode(' ', array_slice($parts, 0, 5)),
5003                        'full_line' => $line
5004                    ];
5005                }
5006            }
5007        }
5008
5009        return ['active' => false, 'frequency' => '', 'expression' => '', 'full_line' => ''];
5010    }
5011
5012    private function parseCronExpression($minute, $hour, $day, $month, $weekday) {
5013        // Parse minute field
5014        if ($minute === '*') {
5015            return 'Runs every minute';
5016        } elseif (strpos($minute, '*/') === 0) {
5017            $interval = substr($minute, 2);
5018            if ($interval == 1) {
5019                return 'Runs every minute';
5020            } elseif ($interval == 5) {
5021                return 'Runs every 5 minutes';
5022            } elseif ($interval == 8) {
5023                return 'Runs every 8 minutes';
5024            } elseif ($interval == 10) {
5025                return 'Runs every 10 minutes';
5026            } elseif ($interval == 15) {
5027                return 'Runs every 15 minutes';
5028            } elseif ($interval == 30) {
5029                return 'Runs every 30 minutes';
5030            } else {
5031                return "Runs every $interval minutes";
5032            }
5033        }
5034
5035        // Parse hour field
5036        if ($hour === '*' && $minute !== '*') {
5037            return 'Runs hourly';
5038        } elseif (strpos($hour, '*/') === 0 && $minute !== '*') {
5039            $interval = substr($hour, 2);
5040            if ($interval == 1) {
5041                return 'Runs every hour';
5042            } else {
5043                return "Runs every $interval hours";
5044            }
5045        }
5046
5047        // Parse day field
5048        if ($day === '*' && $hour !== '*' && $minute !== '*') {
5049            return 'Runs daily';
5050        }
5051
5052        // Default
5053        return 'Custom schedule';
5054    }
5055
5056    private function runSync() {
5057        global $INPUT;
5058
5059        if ($INPUT->str('call') === 'ajax') {
5060            header('Content-Type: application/json');
5061
5062            $syncScript = DOKU_PLUGIN . 'calendar/sync_outlook.php';
5063            $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort';
5064
5065            // Remove any existing abort flag
5066            if (file_exists($abortFile)) {
5067                @unlink($abortFile);
5068            }
5069
5070            if (!file_exists($syncScript)) {
5071                echo json_encode(['success' => false, 'message' => 'Sync script not found at: ' . $syncScript]);
5072                exit;
5073            }
5074
5075            // Get log file from data directory (writable)
5076            $logFile = $this->getSyncLogPath();
5077            $logDir = dirname($logFile);
5078
5079            // Ensure log directory exists
5080            if (!is_dir($logDir)) {
5081                if (!@mkdir($logDir, 0755, true)) {
5082                    echo json_encode(['success' => false, 'message' => 'Cannot create log directory: ' . $logDir]);
5083                    exit;
5084                }
5085            }
5086
5087            // Ensure log file exists and is writable
5088            if (!file_exists($logFile)) {
5089                if (!@touch($logFile)) {
5090                    echo json_encode(['success' => false, 'message' => 'Cannot create log file: ' . $logFile]);
5091                    exit;
5092                }
5093                @chmod($logFile, 0666);
5094            }
5095
5096            // Check if we can write to the log
5097            if (!is_writable($logFile)) {
5098                echo json_encode(['success' => false, 'message' => 'Log file not writable: ' . $logFile . ' - Run: chmod 666 ' . $logFile]);
5099                exit;
5100            }
5101
5102            // Find PHP binary
5103            $phpPath = $this->findPhpBinary();
5104            if (!$phpPath) {
5105                echo json_encode(['success' => false, 'message' => 'Cannot find PHP binary']);
5106                exit;
5107            }
5108
5109            // Get plugin directory for cd command
5110            $pluginDir = DOKU_PLUGIN . 'calendar';
5111
5112            // Build command - NO --verbose flag because the script logs internally
5113            // The script writes directly to the log file, so we don't need to capture stdout
5114            $command = sprintf(
5115                'cd %s && %s sync_outlook.php 2>&1',
5116                escapeshellarg($pluginDir),
5117                $phpPath
5118            );
5119
5120            // Log that we're starting
5121            $tz = new DateTimeZone('America/Los_Angeles');
5122            $now = new DateTime('now', $tz);
5123            $timestamp = $now->format('Y-m-d H:i:s');
5124            @file_put_contents($logFile, "[$timestamp] [ADMIN] Manual sync triggered via admin panel\n", FILE_APPEND);
5125            @file_put_contents($logFile, "[$timestamp] [ADMIN] Command: $command\n", FILE_APPEND);
5126
5127            // Execute sync
5128            $output = [];
5129            $returnCode = 0;
5130            exec($command, $output, $returnCode);
5131
5132            // Only log output if there was an error (the script logs its own progress)
5133            if ($returnCode !== 0 && !empty($output)) {
5134                @file_put_contents($logFile, "[$timestamp] [ADMIN] Error output:\n" . implode("\n", $output) . "\n", FILE_APPEND);
5135            }
5136
5137            // Check results
5138            if ($returnCode === 0) {
5139                echo json_encode([
5140                    'success' => true,
5141                    'message' => 'Sync completed! Check log for details.'
5142                ]);
5143            } else {
5144                $errorMsg = 'Sync failed (exit code: ' . $returnCode . ')';
5145                if (!empty($output)) {
5146                    $lastLines = array_slice($output, -3);
5147                    $errorMsg .= ' - ' . implode(' | ', $lastLines);
5148                }
5149                echo json_encode([
5150                    'success' => false,
5151                    'message' => $errorMsg
5152                ]);
5153            }
5154            exit;
5155        }
5156    }
5157
5158    private function stopSync() {
5159        global $INPUT;
5160
5161        if ($INPUT->str('call') === 'ajax') {
5162            header('Content-Type: application/json');
5163
5164            $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort';
5165
5166            // Create abort flag file
5167            if (file_put_contents($abortFile, date('Y-m-d H:i:s')) !== false) {
5168                echo json_encode([
5169                    'success' => true,
5170                    'message' => 'Stop signal sent to sync process'
5171                ]);
5172            } else {
5173                echo json_encode([
5174                    'success' => false,
5175                    'message' => 'Failed to create abort flag'
5176                ]);
5177            }
5178            exit;
5179        }
5180    }
5181
5182    private function uploadUpdate() {
5183        if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) {
5184            $this->redirect('Upload failed: ' . ($_FILES['plugin_zip']['error'] ?? 'No file uploaded'), 'error', 'update');
5185            return;
5186        }
5187
5188        $uploadedFile = $_FILES['plugin_zip']['tmp_name'];
5189        $pluginDir = DOKU_PLUGIN . 'calendar/';
5190        $backupFirst = isset($_POST['backup_first']);
5191
5192        // Check if plugin directory is writable
5193        if (!is_writable($pluginDir)) {
5194            $this->redirect('Plugin directory is not writable. Please check permissions: ' . $pluginDir, 'error', 'update');
5195            return;
5196        }
5197
5198        // Check if parent directory is writable (for backup and temp files)
5199        if (!is_writable(DOKU_PLUGIN)) {
5200            $this->redirect('Plugin parent directory is not writable. Please check permissions: ' . DOKU_PLUGIN, 'error', 'update');
5201            return;
5202        }
5203
5204        // Verify it's a ZIP file
5205        $finfo = finfo_open(FILEINFO_MIME_TYPE);
5206        $mimeType = finfo_file($finfo, $uploadedFile);
5207        finfo_close($finfo);
5208
5209        if ($mimeType !== 'application/zip' && $mimeType !== 'application/x-zip-compressed') {
5210            $this->redirect('Invalid file type. Please upload a ZIP file.', 'error', 'update');
5211            return;
5212        }
5213
5214        // Create backup if requested
5215        if ($backupFirst) {
5216            // Get current version
5217            $pluginInfo = $pluginDir . 'plugin.info.txt';
5218            $version = 'unknown';
5219            if (file_exists($pluginInfo)) {
5220                $info = confToHash($pluginInfo);
5221                $version = $info['version'] ?? ($info['date'] ?? 'unknown');
5222            }
5223
5224            $backupName = 'calendar.backup.v' . $version . '.' . date('Y-m-d_H-i-s') . '.zip';
5225            $backupPath = DOKU_PLUGIN . $backupName;
5226
5227            try {
5228                $zip = new ZipArchive();
5229                if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) {
5230                    $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/');
5231                    $zip->close();
5232
5233                    // Verify backup was created and has content
5234                    if (!file_exists($backupPath)) {
5235                        $this->redirect('Backup file was not created', 'error', 'update');
5236                        return;
5237                    }
5238
5239                    $backupSize = filesize($backupPath);
5240                    if ($backupSize < 1000) { // Backup should be at least 1KB
5241                        @unlink($backupPath);
5242                        $this->redirect('Backup file is too small (' . $backupSize . ' bytes). Only ' . $fileCount . ' files were added. Backup aborted.', 'error', 'update');
5243                        return;
5244                    }
5245
5246                    if ($fileCount < 10) { // Should have at least 10 files
5247                        @unlink($backupPath);
5248                        $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup aborted.', 'error', 'update');
5249                        return;
5250                    }
5251                } else {
5252                    $this->redirect('Failed to create backup ZIP file', 'error', 'update');
5253                    return;
5254                }
5255            } catch (Exception $e) {
5256                if (file_exists($backupPath)) {
5257                    @unlink($backupPath);
5258                }
5259                $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update');
5260                return;
5261            }
5262        }
5263
5264        // Extract uploaded ZIP
5265        $zip = new ZipArchive();
5266        if ($zip->open($uploadedFile) !== TRUE) {
5267            $this->redirect('Failed to open ZIP file', 'error', 'update');
5268            return;
5269        }
5270
5271        // Check if ZIP contains calendar folder
5272        $hasCalendarFolder = false;
5273        for ($i = 0; $i < $zip->numFiles; $i++) {
5274            $filename = $zip->getNameIndex($i);
5275            if (strpos($filename, 'calendar/') === 0) {
5276                $hasCalendarFolder = true;
5277                break;
5278            }
5279        }
5280
5281        // Extract to temp directory first
5282        $tempDir = DOKU_PLUGIN . 'calendar_update_temp/';
5283        if (is_dir($tempDir)) {
5284            $this->deleteDirectory($tempDir);
5285        }
5286        mkdir($tempDir);
5287
5288        $zip->extractTo($tempDir);
5289        $zip->close();
5290
5291        // Determine source directory
5292        if ($hasCalendarFolder) {
5293            $sourceDir = $tempDir . 'calendar/';
5294        } else {
5295            $sourceDir = $tempDir;
5296        }
5297
5298        // Preserve configuration files (sync_state.json and sync.log are now in data/meta/calendar/)
5299        $preserveFiles = ['sync_config.php'];
5300        $preserved = [];
5301        foreach ($preserveFiles as $file) {
5302            $oldFile = $pluginDir . $file;
5303            if (file_exists($oldFile)) {
5304                $preserved[$file] = file_get_contents($oldFile);
5305            }
5306        }
5307
5308        // Delete old plugin files (except data files)
5309        $this->deleteDirectoryContents($pluginDir, $preserveFiles);
5310
5311        // Copy new files
5312        $this->recursiveCopy($sourceDir, $pluginDir);
5313
5314        // Restore preserved files
5315        foreach ($preserved as $file => $content) {
5316            file_put_contents($pluginDir . $file, $content);
5317        }
5318
5319        // Update version and date in plugin.info.txt
5320        $pluginInfo = $pluginDir . 'plugin.info.txt';
5321        if (file_exists($pluginInfo)) {
5322            $info = confToHash($pluginInfo);
5323
5324            // Get new version from uploaded plugin
5325            $newVersion = $info['version'] ?? 'unknown';
5326
5327            // Update date to current
5328            $info['date'] = date('Y-m-d');
5329
5330            // Write updated info back
5331            $lines = [];
5332            foreach ($info as $key => $value) {
5333                $lines[] = str_pad($key, 8) . ' ' . $value;
5334            }
5335            file_put_contents($pluginInfo, implode("\n", $lines) . "\n");
5336        }
5337
5338        // Cleanup temp directory
5339        $this->deleteDirectory($tempDir);
5340
5341        $message = 'Plugin updated successfully!';
5342        if ($backupFirst) {
5343            $message .= ' Backup saved as: ' . $backupName;
5344        }
5345        $this->redirect($message, 'success', 'update');
5346    }
5347
5348    private function deleteBackup() {
5349        global $INPUT;
5350
5351        $filename = $INPUT->str('backup_file');
5352
5353        if (empty($filename)) {
5354            $this->redirect('No backup file specified', 'error', 'update');
5355            return;
5356        }
5357
5358        // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal
5359        if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) {
5360            $this->redirect('Invalid backup filename', 'error', 'update');
5361            return;
5362        }
5363
5364        $backupPath = DOKU_PLUGIN . $filename;
5365
5366        if (!file_exists($backupPath)) {
5367            $this->redirect('Backup file not found', 'error', 'update');
5368            return;
5369        }
5370
5371        if (@unlink($backupPath)) {
5372            $this->redirect('Backup deleted: ' . $filename, 'success', 'update');
5373        } else {
5374            $this->redirect('Failed to delete backup. Check file permissions.', 'error', 'update');
5375        }
5376    }
5377
5378    private function renameBackup() {
5379        global $INPUT;
5380
5381        $oldName = $INPUT->str('old_name');
5382        $newName = $INPUT->str('new_name');
5383
5384        if (empty($oldName) || empty($newName)) {
5385            $this->redirect('Missing filename(s)', 'error', 'update');
5386            return;
5387        }
5388
5389        // Security: validate filenames
5390        if (!preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $oldName) || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $newName)) {
5391            $this->redirect('Invalid filename format', 'error', 'update');
5392            return;
5393        }
5394
5395        $oldPath = DOKU_PLUGIN . $oldName;
5396        $newPath = DOKU_PLUGIN . $newName;
5397
5398        if (!file_exists($oldPath)) {
5399            $this->redirect('Backup file not found', 'error', 'update');
5400            return;
5401        }
5402
5403        if (file_exists($newPath)) {
5404            $this->redirect('A file with the new name already exists', 'error', 'update');
5405            return;
5406        }
5407
5408        if (@rename($oldPath, $newPath)) {
5409            $this->redirect('Backup renamed: ' . $oldName . ' → ' . $newName, 'success', 'update');
5410        } else {
5411            $this->redirect('Failed to rename backup. Check file permissions.', 'error', 'update');
5412        }
5413    }
5414
5415    /**
5416     * Restore a backup using DokuWiki's extension manager
5417     * This ensures proper permissions and follows DokuWiki's standard installation process
5418     */
5419    private function restoreBackup() {
5420        global $INPUT;
5421
5422        $filename = $INPUT->str('backup_file');
5423
5424        if (empty($filename)) {
5425            $this->redirect('No backup file specified', 'error', 'update');
5426            return;
5427        }
5428
5429        // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal
5430        if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) {
5431            $this->redirect('Invalid backup filename', 'error', 'update');
5432            return;
5433        }
5434
5435        $backupPath = DOKU_PLUGIN . $filename;
5436
5437        if (!file_exists($backupPath)) {
5438            $this->redirect('Backup file not found', 'error', 'update');
5439            return;
5440        }
5441
5442        // Try to use DokuWiki's extension manager helper
5443        $extensionHelper = plugin_load('helper', 'extension_extension');
5444
5445        if (!$extensionHelper) {
5446            // Extension manager not available - provide manual instructions
5447            $this->redirect('DokuWiki Extension Manager not available. Please install manually: Download the backup, go to Admin → Extension Manager → Install, and upload the ZIP file.', 'error', 'update');
5448            return;
5449        }
5450
5451        try {
5452            // Set the extension we're working with
5453            $extensionHelper->setExtension('calendar');
5454
5455            // Use DokuWiki's extension manager to install from the local file
5456            // This handles all permissions and file operations properly
5457            $installed = $extensionHelper->installFromLocal($backupPath, true); // true = overwrite
5458
5459            if ($installed) {
5460                $this->redirect('Plugin restored from backup: ' . $filename . ' (via Extension Manager)', 'success', 'update');
5461            } else {
5462                // Get any error message from the extension helper
5463                $errors = $extensionHelper->getErrors();
5464                $errorMsg = !empty($errors) ? implode(', ', $errors) : 'Unknown error';
5465                $this->redirect('Restore failed: ' . $errorMsg, 'error', 'update');
5466            }
5467        } catch (Exception $e) {
5468            $this->redirect('Restore failed: ' . $e->getMessage(), 'error', 'update');
5469        }
5470    }
5471
5472    private function createManualBackup() {
5473        $pluginDir = DOKU_PLUGIN . 'calendar/';
5474
5475        // Check if plugin directory is readable
5476        if (!is_readable($pluginDir)) {
5477            $this->redirect('Plugin directory is not readable. Please check permissions.', 'error', 'update');
5478            return;
5479        }
5480
5481        // Check if parent directory is writable (for saving backup)
5482        if (!is_writable(DOKU_PLUGIN)) {
5483            $this->redirect('Plugin parent directory is not writable. Cannot save backup.', 'error', 'update');
5484            return;
5485        }
5486
5487        // Get current version
5488        $pluginInfo = $pluginDir . 'plugin.info.txt';
5489        $version = 'unknown';
5490        if (file_exists($pluginInfo)) {
5491            $info = confToHash($pluginInfo);
5492            $version = $info['version'] ?? ($info['date'] ?? 'unknown');
5493        }
5494
5495        $backupName = 'calendar.backup.v' . $version . '.manual.' . date('Y-m-d_H-i-s') . '.zip';
5496        $backupPath = DOKU_PLUGIN . $backupName;
5497
5498        try {
5499            $zip = new ZipArchive();
5500            if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) {
5501                $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/');
5502                $zip->close();
5503
5504                // Verify backup was created and has content
5505                if (!file_exists($backupPath)) {
5506                    $this->redirect('Backup file was not created', 'error', 'update');
5507                    return;
5508                }
5509
5510                $backupSize = filesize($backupPath);
5511                if ($backupSize < 1000) { // Backup should be at least 1KB
5512                    @unlink($backupPath);
5513                    $this->redirect('Backup file is too small (' . $this->formatBytes($backupSize) . '). Only ' . $fileCount . ' files were added. Backup failed.', 'error', 'update');
5514                    return;
5515                }
5516
5517                if ($fileCount < 10) { // Should have at least 10 files
5518                    @unlink($backupPath);
5519                    $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup failed.', 'error', 'update');
5520                    return;
5521                }
5522
5523                // Success!
5524                $this->redirect('✓ Manual backup created successfully: ' . $backupName . ' (' . $this->formatBytes($backupSize) . ', ' . $fileCount . ' files)', 'success', 'update');
5525
5526            } else {
5527                $this->redirect('Failed to create backup ZIP file', 'error', 'update');
5528                return;
5529            }
5530        } catch (Exception $e) {
5531            if (file_exists($backupPath)) {
5532                @unlink($backupPath);
5533            }
5534            $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update');
5535            return;
5536        }
5537    }
5538
5539    private function addDirectoryToZip($zip, $dir, $zipPath = '') {
5540        $fileCount = 0;
5541        $errors = [];
5542
5543        // Ensure dir has trailing slash
5544        $dir = rtrim($dir, '/') . '/';
5545
5546        if (!is_dir($dir)) {
5547            throw new Exception("Directory does not exist: $dir");
5548        }
5549
5550        if (!is_readable($dir)) {
5551            throw new Exception("Directory is not readable: $dir");
5552        }
5553
5554        try {
5555            // First, add all directories to preserve structure (including empty ones)
5556            $dirs = new RecursiveIteratorIterator(
5557                new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
5558                RecursiveIteratorIterator::SELF_FIRST  // Process directories before their contents
5559            );
5560
5561            foreach ($dirs as $item) {
5562                $itemPath = $item->getRealPath();
5563                if (!$itemPath) continue;
5564
5565                // Calculate relative path from the source directory
5566                $relativePath = $zipPath . substr($itemPath, strlen($dir));
5567
5568                if ($item->isDir()) {
5569                    // Add directory to ZIP (preserves empty directories and structure)
5570                    $dirInZip = rtrim($relativePath, '/') . '/';
5571                    $zip->addEmptyDir($dirInZip);
5572                } else {
5573                    // Add file to ZIP
5574                    if (is_readable($itemPath)) {
5575                        if ($zip->addFile($itemPath, $relativePath)) {
5576                            $fileCount++;
5577                        } else {
5578                            $errors[] = "Failed to add: " . basename($itemPath);
5579                        }
5580                    } else {
5581                        $errors[] = "Cannot read: " . basename($itemPath);
5582                    }
5583                }
5584            }
5585
5586            // Log any errors but don't fail if we got most files
5587            if (!empty($errors) && count($errors) < 5) {
5588                foreach ($errors as $error) {
5589                    error_log('Calendar plugin backup warning: ' . $error);
5590                }
5591            }
5592
5593            // If too many errors, fail
5594            if (count($errors) > 5) {
5595                throw new Exception("Too many errors adding files to backup: " . implode(', ', array_slice($errors, 0, 5)));
5596            }
5597
5598        } catch (Exception $e) {
5599            error_log('Calendar plugin backup error: ' . $e->getMessage());
5600            throw $e;
5601        }
5602
5603        return $fileCount;
5604    }
5605
5606    private function deleteDirectory($dir) {
5607        if (!is_dir($dir)) return;
5608
5609        try {
5610            $files = new RecursiveIteratorIterator(
5611                new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
5612                RecursiveIteratorIterator::CHILD_FIRST
5613            );
5614
5615            foreach ($files as $file) {
5616                if ($file->isDir()) {
5617                    @rmdir($file->getRealPath());
5618                } else {
5619                    @unlink($file->getRealPath());
5620                }
5621            }
5622
5623            @rmdir($dir);
5624        } catch (Exception $e) {
5625            error_log('Calendar plugin delete directory error: ' . $e->getMessage());
5626        }
5627    }
5628
5629    private function deleteDirectoryContents($dir, $preserve = []) {
5630        if (!is_dir($dir)) return;
5631
5632        $items = scandir($dir);
5633        foreach ($items as $item) {
5634            if ($item === '.' || $item === '..') continue;
5635            if (in_array($item, $preserve)) continue;
5636
5637            $path = $dir . $item;
5638            if (is_dir($path)) {
5639                $this->deleteDirectory($path);
5640            } else {
5641                unlink($path);
5642            }
5643        }
5644    }
5645
5646    private function recursiveCopy($src, $dst) {
5647        if (!is_dir($src)) {
5648            return false;
5649        }
5650
5651        $dir = opendir($src);
5652        if (!$dir) {
5653            return false;
5654        }
5655
5656        // Create destination directory with proper permissions (0755)
5657        if (!is_dir($dst)) {
5658            mkdir($dst, 0755, true);
5659        }
5660
5661        while (($file = readdir($dir)) !== false) {
5662            if ($file !== '.' && $file !== '..') {
5663                $srcPath = $src . '/' . $file;
5664                $dstPath = $dst . '/' . $file;
5665
5666                if (is_dir($srcPath)) {
5667                    // Recursively copy subdirectory
5668                    $this->recursiveCopy($srcPath, $dstPath);
5669                } else {
5670                    // Copy file and preserve permissions
5671                    if (copy($srcPath, $dstPath)) {
5672                        // Try to preserve file permissions from source, fallback to 0644
5673                        $perms = @fileperms($srcPath);
5674                        if ($perms !== false) {
5675                            @chmod($dstPath, $perms);
5676                        } else {
5677                            @chmod($dstPath, 0644);
5678                        }
5679                    }
5680                }
5681            }
5682        }
5683
5684        closedir($dir);
5685        return true;
5686    }
5687
5688    private function formatBytes($bytes) {
5689        if ($bytes >= 1073741824) {
5690            return number_format($bytes / 1073741824, 2) . ' GB';
5691        } elseif ($bytes >= 1048576) {
5692            return number_format($bytes / 1048576, 2) . ' MB';
5693        } elseif ($bytes >= 1024) {
5694            return number_format($bytes / 1024, 2) . ' KB';
5695        } else {
5696            return $bytes . ' bytes';
5697        }
5698    }
5699
5700    private function findPhpBinary() {
5701        // Try PHP_BINARY constant first (most reliable if available)
5702        if (defined('PHP_BINARY') && !empty(PHP_BINARY) && is_executable(PHP_BINARY)) {
5703            return PHP_BINARY;
5704        }
5705
5706        // Try common PHP binary locations
5707        $possiblePaths = [
5708            '/usr/bin/php',
5709            '/usr/bin/php8.1',
5710            '/usr/bin/php8.2',
5711            '/usr/bin/php8.3',
5712            '/usr/bin/php7.4',
5713            '/usr/local/bin/php',
5714        ];
5715
5716        foreach ($possiblePaths as $path) {
5717            if (is_executable($path)) {
5718                return $path;
5719            }
5720        }
5721
5722        // Try using 'which' to find php
5723        $which = trim(shell_exec('which php 2>/dev/null') ?? '');
5724        if (!empty($which) && is_executable($which)) {
5725            return $which;
5726        }
5727
5728        // Fallback to 'php' and hope it's in PATH
5729        return 'php';
5730    }
5731
5732    private function redirect($message, $type = 'success', $tab = null) {
5733        $url = '?do=admin&page=calendar';
5734        if ($tab) {
5735            $url .= '&tab=' . $tab;
5736        }
5737        $url .= '&msg=' . urlencode($message) . '&msgtype=' . $type;
5738        header('Location: ' . $url);
5739        exit;
5740    }
5741
5742    private function getLog() {
5743        global $INPUT;
5744
5745        if ($INPUT->str('call') === 'ajax') {
5746            header('Content-Type: application/json');
5747
5748            $logFile = $this->getSyncLogPath();
5749            $log = '';
5750
5751            if (file_exists($logFile)) {
5752                // Get last 500 lines
5753                $lines = file($logFile);
5754                if ($lines !== false) {
5755                    $lines = array_slice($lines, -500);
5756                    $log = implode('', $lines);
5757                }
5758            } else {
5759                $log = "No log file found. Sync hasn't run yet.";
5760            }
5761
5762            echo json_encode(['log' => $log]);
5763            exit;
5764        }
5765    }
5766
5767    private function exportConfig() {
5768        global $INPUT;
5769
5770        if ($INPUT->str('call') === 'ajax') {
5771            header('Content-Type: application/json');
5772
5773            try {
5774                $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
5775
5776                if (!file_exists($configFile)) {
5777                    echo json_encode([
5778                        'success' => false,
5779                        'message' => 'Config file not found'
5780                    ]);
5781                    exit;
5782                }
5783
5784                // Read config file
5785                $configContent = file_get_contents($configFile);
5786
5787                // Generate encryption key from DokuWiki secret
5788                $key = $this->getEncryptionKey();
5789
5790                // Encrypt config
5791                $encrypted = $this->encryptData($configContent, $key);
5792
5793                echo json_encode([
5794                    'success' => true,
5795                    'encrypted' => $encrypted,
5796                    'message' => 'Config exported successfully'
5797                ]);
5798                exit;
5799
5800            } catch (Exception $e) {
5801                echo json_encode([
5802                    'success' => false,
5803                    'message' => $e->getMessage()
5804                ]);
5805                exit;
5806            }
5807        }
5808    }
5809
5810    private function importConfig() {
5811        global $INPUT;
5812
5813        if ($INPUT->str('call') === 'ajax') {
5814            header('Content-Type: application/json');
5815
5816            try {
5817                $encrypted = $_POST['encrypted_config'] ?? '';
5818
5819                if (empty($encrypted)) {
5820                    echo json_encode([
5821                        'success' => false,
5822                        'message' => 'No config data provided'
5823                    ]);
5824                    exit;
5825                }
5826
5827                // Generate encryption key from DokuWiki secret
5828                $key = $this->getEncryptionKey();
5829
5830                // Decrypt config
5831                $configContent = $this->decryptData($encrypted, $key);
5832
5833                if ($configContent === false) {
5834                    echo json_encode([
5835                        'success' => false,
5836                        'message' => 'Decryption failed. Invalid key or corrupted file.'
5837                    ]);
5838                    exit;
5839                }
5840
5841                // Validate PHP config file structure (without using eval)
5842                // Check that it starts with <?php and contains a return statement with array
5843                if (strpos($configContent, '<?php') === false) {
5844                    echo json_encode([
5845                        'success' => false,
5846                        'message' => 'Invalid config file: missing PHP opening tag'
5847                    ]);
5848                    exit;
5849                }
5850
5851                // Check for dangerous patterns that shouldn't be in a config file
5852                $dangerousPatterns = [
5853                    '/\b(exec|shell_exec|system|passthru|popen|proc_open)\s*\(/i',
5854                    '/\b(eval|assert|create_function)\s*\(/i',
5855                    '/\b(file_get_contents|file_put_contents|fopen|fwrite|unlink|rmdir)\s*\(/i',
5856                    '/\$_(GET|POST|REQUEST|SERVER|FILES|COOKIE|SESSION)\s*\[/i',
5857                    '/`[^`]+`/',  // Backtick execution
5858                ];
5859
5860                foreach ($dangerousPatterns as $pattern) {
5861                    if (preg_match($pattern, $configContent)) {
5862                        echo json_encode([
5863                            'success' => false,
5864                            'message' => 'Invalid config file: contains prohibited code patterns'
5865                        ]);
5866                        exit;
5867                    }
5868                }
5869
5870                // Verify it looks like a valid config (has return array structure)
5871                if (!preg_match('/return\s*\[/', $configContent)) {
5872                    echo json_encode([
5873                        'success' => false,
5874                        'message' => 'Invalid config file: must contain a return array statement'
5875                    ]);
5876                    exit;
5877                }
5878
5879                // Write to config file
5880                $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
5881
5882                // Backup existing config
5883                if (file_exists($configFile)) {
5884                    $backupFile = $configFile . '.backup.' . date('Y-m-d_H-i-s');
5885                    copy($configFile, $backupFile);
5886                }
5887
5888                // Write new config
5889                if (file_put_contents($configFile, $configContent) === false) {
5890                    echo json_encode([
5891                        'success' => false,
5892                        'message' => 'Failed to write config file'
5893                    ]);
5894                    exit;
5895                }
5896
5897                echo json_encode([
5898                    'success' => true,
5899                    'message' => 'Config imported successfully'
5900                ]);
5901                exit;
5902
5903            } catch (Exception $e) {
5904                echo json_encode([
5905                    'success' => false,
5906                    'message' => $e->getMessage()
5907                ]);
5908                exit;
5909            }
5910        }
5911    }
5912
5913    private function getEncryptionKey() {
5914        global $conf;
5915        // Use DokuWiki's secret as the base for encryption
5916        // This ensures the key is unique per installation
5917        return hash('sha256', $conf['secret'] . 'calendar_config_encryption', true);
5918    }
5919
5920    private function encryptData($data, $key) {
5921        // Use AES-256-CBC encryption
5922        $ivLength = openssl_cipher_iv_length('aes-256-cbc');
5923        $iv = openssl_random_pseudo_bytes($ivLength);
5924
5925        $encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv);
5926
5927        // Combine IV and encrypted data, then base64 encode
5928        return base64_encode($iv . $encrypted);
5929    }
5930
5931    private function decryptData($encryptedData, $key) {
5932        // Decode base64
5933        $data = base64_decode($encryptedData);
5934
5935        if ($data === false) {
5936            return false;
5937        }
5938
5939        // Extract IV and encrypted content
5940        $ivLength = openssl_cipher_iv_length('aes-256-cbc');
5941        $iv = substr($data, 0, $ivLength);
5942        $encrypted = substr($data, $ivLength);
5943
5944        // Decrypt
5945        $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv);
5946
5947        return $decrypted;
5948    }
5949
5950    private function clearLogFile() {
5951        global $INPUT;
5952
5953        if ($INPUT->str('call') === 'ajax') {
5954            header('Content-Type: application/json');
5955
5956            $logFile = $this->getSyncLogPath();
5957
5958            // Check if file exists
5959            if (!file_exists($logFile)) {
5960                // Try to create empty file
5961                if (@touch($logFile)) {
5962                    echo json_encode(['success' => true, 'message' => 'Log file created']);
5963                } else {
5964                    echo json_encode(['success' => false, 'message' => 'Log file does not exist and cannot be created: ' . $logFile]);
5965                }
5966                exit;
5967            }
5968
5969            // Check if writable
5970            if (!is_writable($logFile)) {
5971                echo json_encode(['success' => false, 'message' => 'Log file not writable. Run: sudo chmod 666 ' . $logFile]);
5972                exit;
5973            }
5974
5975            // Try to clear it
5976            $result = file_put_contents($logFile, '');
5977            if ($result !== false) {
5978                echo json_encode(['success' => true]);
5979            } else {
5980                echo json_encode(['success' => false, 'message' => 'file_put_contents failed on: ' . $logFile]);
5981            }
5982            exit;
5983        }
5984    }
5985
5986    private function downloadLog() {
5987        $logFile = $this->getSyncLogPath();
5988
5989        if (file_exists($logFile)) {
5990            header('Content-Type: text/plain');
5991            header('Content-Disposition: attachment; filename="calendar-sync-' . date('Y-m-d-His') . '.log"');
5992            readfile($logFile);
5993            exit;
5994        } else {
5995            echo 'No log file found';
5996            exit;
5997        }
5998    }
5999
6000    private function getEventStatistics() {
6001        $stats = [
6002            'total_events' => 0,
6003            'total_namespaces' => 0,
6004            'total_files' => 0,
6005            'total_recurring' => 0,
6006            'by_namespace' => [],
6007            'last_scan' => ''
6008        ];
6009
6010        $metaDir = DOKU_INC . 'data/meta/';
6011        $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
6012
6013        // Check if we have cached stats (less than 5 minutes old)
6014        if (file_exists($cacheFile)) {
6015            $cacheData = json_decode(file_get_contents($cacheFile), true);
6016            if ($cacheData && (time() - $cacheData['timestamp']) < 300) {
6017                return $cacheData['stats'];
6018            }
6019        }
6020
6021        // Scan for events
6022        $this->scanDirectoryForStats($metaDir, '', $stats);
6023
6024        // Count recurring events
6025        $recurringEvents = $this->findRecurringEvents();
6026        $stats['total_recurring'] = count($recurringEvents);
6027
6028        $stats['total_namespaces'] = count($stats['by_namespace']);
6029        $stats['last_scan'] = date('Y-m-d H:i:s');
6030
6031        // Cache the results
6032        file_put_contents($cacheFile, json_encode([
6033            'timestamp' => time(),
6034            'stats' => $stats
6035        ]));
6036
6037        return $stats;
6038    }
6039
6040    private function scanDirectoryForStats($dir, $namespace, &$stats) {
6041        if (!is_dir($dir)) return;
6042
6043        $items = scandir($dir);
6044        foreach ($items as $item) {
6045            if ($item === '.' || $item === '..') continue;
6046
6047            $path = $dir . $item;
6048
6049            // Check if this is a calendar directory
6050            if ($item === 'calendar' && is_dir($path)) {
6051                $jsonFiles = glob($path . '/*.json');
6052                $eventCount = 0;
6053
6054                foreach ($jsonFiles as $file) {
6055                    $stats['total_files']++;
6056                    $data = json_decode(file_get_contents($file), true);
6057                    if ($data) {
6058                        foreach ($data as $dateKey => $dateEvents) {
6059                            // Skip non-date keys (like "mapping" or other metadata)
6060                            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
6061
6062                            if (is_array($dateEvents)) {
6063                                // Only count events that have id and title
6064                                foreach ($dateEvents as $event) {
6065                                    if (is_array($event) && !empty($event['id']) && !empty($event['title'])) {
6066                                        $eventCount++;
6067                                    }
6068                                }
6069                            }
6070                        }
6071                    }
6072                }
6073
6074                $stats['total_events'] += $eventCount;
6075
6076                if ($eventCount > 0) {
6077                    $stats['by_namespace'][$namespace] = [
6078                        'events' => $eventCount,
6079                        'files' => count($jsonFiles)
6080                    ];
6081                }
6082            } elseif (is_dir($path)) {
6083                // Recurse into subdirectories
6084                $newNamespace = $namespace ? $namespace . ':' . $item : $item;
6085                $this->scanDirectoryForStats($path . '/', $newNamespace, $stats);
6086            }
6087        }
6088    }
6089
6090    private function rescanEvents() {
6091        // Clear the cache to force a rescan
6092        $this->clearStatsCache();
6093
6094        // Get fresh statistics
6095        $stats = $this->getEventStatistics();
6096
6097        // Build absolute redirect URL
6098        $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Events rescanned! Found ' . $stats['total_events'] . ' events in ' . $stats['total_namespaces'] . ' namespaces.') . '&msgtype=success';
6099
6100        // Redirect with success message using absolute URL
6101        header('Location: ' . $redirectUrl, true, 303);
6102        exit;
6103    }
6104
6105    private function exportAllEvents() {
6106        $metaDir = DOKU_INC . 'data/meta/';
6107        $allEvents = [];
6108
6109        // Collect all events
6110        $this->collectAllEvents($metaDir, '', $allEvents);
6111
6112        // Create export package
6113        // Get current version
6114        $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt';
6115        $info = file_exists($pluginInfo) ? confToHash($pluginInfo) : [];
6116        $currentVersion = isset($info['version']) ? trim($info['version']) : 'unknown';
6117
6118        $exportData = [
6119            'export_date' => date('Y-m-d H:i:s'),
6120            'version' => $currentVersion,
6121            'total_events' => 0,
6122            'namespaces' => []
6123        ];
6124
6125        foreach ($allEvents as $namespace => $files) {
6126            $exportData['namespaces'][$namespace] = [];
6127            foreach ($files as $filename => $events) {
6128                $exportData['namespaces'][$namespace][$filename] = $events;
6129                foreach ($events as $dateEvents) {
6130                    if (is_array($dateEvents)) {
6131                        $exportData['total_events'] += count($dateEvents);
6132                    }
6133                }
6134            }
6135        }
6136
6137        // Send as download
6138        header('Content-Type: application/json');
6139        header('Content-Disposition: attachment; filename="calendar-events-export-' . date('Y-m-d-His') . '.json"');
6140        echo json_encode($exportData, JSON_PRETTY_PRINT);
6141        exit;
6142    }
6143
6144    private function collectAllEvents($dir, $namespace, &$allEvents) {
6145        if (!is_dir($dir)) return;
6146
6147        $items = scandir($dir);
6148        foreach ($items as $item) {
6149            if ($item === '.' || $item === '..') continue;
6150
6151            $path = $dir . $item;
6152
6153            // Check if this is a calendar directory
6154            if ($item === 'calendar' && is_dir($path)) {
6155                $jsonFiles = glob($path . '/*.json');
6156
6157                if (!isset($allEvents[$namespace])) {
6158                    $allEvents[$namespace] = [];
6159                }
6160
6161                foreach ($jsonFiles as $file) {
6162                    $filename = basename($file);
6163                    $data = json_decode(file_get_contents($file), true);
6164                    if ($data) {
6165                        $allEvents[$namespace][$filename] = $data;
6166                    }
6167                }
6168            } elseif (is_dir($path)) {
6169                // Recurse into subdirectories
6170                $newNamespace = $namespace ? $namespace . ':' . $item : $item;
6171                $this->collectAllEvents($path . '/', $newNamespace, $allEvents);
6172            }
6173        }
6174    }
6175
6176    private function importAllEvents() {
6177        global $INPUT;
6178
6179        if (!isset($_FILES['import_file'])) {
6180            $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('No file uploaded') . '&msgtype=error';
6181            header('Location: ' . $redirectUrl, true, 303);
6182            exit;
6183        }
6184
6185        $file = $_FILES['import_file'];
6186
6187        if ($file['error'] !== UPLOAD_ERR_OK) {
6188            $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Upload error: ' . $file['error']) . '&msgtype=error';
6189            header('Location: ' . $redirectUrl, true, 303);
6190            exit;
6191        }
6192
6193        // Read and decode the import file
6194        $importData = json_decode(file_get_contents($file['tmp_name']), true);
6195
6196        if (!$importData || !isset($importData['namespaces'])) {
6197            $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Invalid import file format') . '&msgtype=error';
6198            header('Location: ' . $redirectUrl, true, 303);
6199            exit;
6200        }
6201
6202        $importedCount = 0;
6203        $mergedCount = 0;
6204
6205        // Import events
6206        foreach ($importData['namespaces'] as $namespace => $files) {
6207            $metaDir = DOKU_INC . 'data/meta/';
6208            if ($namespace) {
6209                $metaDir .= str_replace(':', '/', $namespace) . '/';
6210            }
6211            $calendarDir = $metaDir . 'calendar/';
6212
6213            // Create directory if needed
6214            if (!is_dir($calendarDir)) {
6215                mkdir($calendarDir, 0755, true);
6216            }
6217
6218            foreach ($files as $filename => $events) {
6219                $targetFile = $calendarDir . $filename;
6220
6221                // If file exists, merge events
6222                if (file_exists($targetFile)) {
6223                    $existing = json_decode(file_get_contents($targetFile), true);
6224                    if ($existing) {
6225                        foreach ($events as $date => $dateEvents) {
6226                            if (!isset($existing[$date])) {
6227                                $existing[$date] = [];
6228                            }
6229                            foreach ($dateEvents as $event) {
6230                                // Check if event with same ID exists
6231                                $found = false;
6232                                foreach ($existing[$date] as $existingEvent) {
6233                                    if ($existingEvent['id'] === $event['id']) {
6234                                        $found = true;
6235                                        break;
6236                                    }
6237                                }
6238                                if (!$found) {
6239                                    $existing[$date][] = $event;
6240                                    $importedCount++;
6241                                } else {
6242                                    $mergedCount++;
6243                                }
6244                            }
6245                        }
6246                        file_put_contents($targetFile, json_encode($existing, JSON_PRETTY_PRINT));
6247                    }
6248                } else {
6249                    // New file
6250                    file_put_contents($targetFile, json_encode($events, JSON_PRETTY_PRINT));
6251                    foreach ($events as $dateEvents) {
6252                        if (is_array($dateEvents)) {
6253                            $importedCount += count($dateEvents);
6254                        }
6255                    }
6256                }
6257            }
6258        }
6259
6260        // Clear cache
6261        $this->clearStatsCache();
6262
6263        $message = "Import complete! Imported $importedCount new events";
6264        if ($mergedCount > 0) {
6265            $message .= ", skipped $mergedCount duplicates";
6266        }
6267
6268        $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success';
6269        header('Location: ' . $redirectUrl, true, 303);
6270        exit;
6271    }
6272
6273    private function previewCleanup() {
6274        global $INPUT;
6275
6276        $cleanupType = $INPUT->str('cleanup_type', 'age');
6277        $namespaceFilter = $INPUT->str('namespace_filter', '');
6278
6279        // Debug info
6280        $debug = [];
6281        $debug['cleanup_type'] = $cleanupType;
6282        $debug['namespace_filter'] = $namespaceFilter;
6283        $debug['age_value'] = $INPUT->int('age_value', 6);
6284        $debug['age_unit'] = $INPUT->str('age_unit', 'months');
6285        $debug['range_start'] = $INPUT->str('range_start', '');
6286        $debug['range_end'] = $INPUT->str('range_end', '');
6287        $debug['delete_completed'] = $INPUT->bool('delete_completed', false);
6288        $debug['delete_past'] = $INPUT->bool('delete_past', false);
6289
6290        $dataDir = DOKU_INC . 'data/meta/';
6291        $debug['data_dir'] = $dataDir;
6292        $debug['data_dir_exists'] = is_dir($dataDir);
6293
6294        $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter);
6295
6296        // Merge with scan debug info
6297        if (isset($this->_cleanupDebug)) {
6298            $debug = array_merge($debug, $this->_cleanupDebug);
6299        }
6300
6301        // Return JSON for preview with debug info
6302        header('Content-Type: application/json');
6303        echo json_encode([
6304            'count' => count($eventsToDelete),
6305            'events' => array_slice($eventsToDelete, 0, 50), // Limit to 50 for preview
6306            'debug' => $debug
6307        ]);
6308        exit;
6309    }
6310
6311    private function cleanupEvents() {
6312        global $INPUT;
6313
6314        $cleanupType = $INPUT->str('cleanup_type', 'age');
6315        $namespaceFilter = $INPUT->str('namespace_filter', '');
6316
6317        // Create backup first
6318        $backupDir = DOKU_PLUGIN . 'calendar/backups/';
6319        if (!is_dir($backupDir)) {
6320            mkdir($backupDir, 0755, true);
6321        }
6322
6323        $backupFile = $backupDir . 'before-cleanup-' . date('Y-m-d-His') . '.zip';
6324        $this->createBackup($backupFile);
6325
6326        // Find events to delete
6327        $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter);
6328        $deletedCount = 0;
6329
6330        // Group by file
6331        $fileGroups = [];
6332        foreach ($eventsToDelete as $evt) {
6333            $fileGroups[$evt['file']][] = $evt;
6334        }
6335
6336        // Delete from each file
6337        foreach ($fileGroups as $file => $events) {
6338            if (!file_exists($file)) continue;
6339
6340            $json = file_get_contents($file);
6341            $data = json_decode($json, true);
6342
6343            if (!$data) continue;
6344
6345            // Remove events
6346            foreach ($events as $evt) {
6347                if (isset($data[$evt['date']])) {
6348                    $data[$evt['date']] = array_filter($data[$evt['date']], function($e) use ($evt) {
6349                        return $e['id'] !== $evt['id'];
6350                    });
6351
6352                    // Remove date key if empty
6353                    if (empty($data[$evt['date']])) {
6354                        unset($data[$evt['date']]);
6355                    }
6356
6357                    $deletedCount++;
6358                }
6359            }
6360
6361            // Save file or delete if empty
6362            if (empty($data)) {
6363                unlink($file);
6364            } else {
6365                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
6366            }
6367        }
6368
6369        // Clear cache
6370        $this->clearStatsCache();
6371
6372        $message = "Cleanup complete! Deleted $deletedCount event(s). Backup created: " . basename($backupFile);
6373        $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success';
6374        header('Location: ' . $redirectUrl, true, 303);
6375        exit;
6376    }
6377
6378    private function findEventsToCleanup($cleanupType, $namespaceFilter) {
6379        global $INPUT;
6380
6381        $eventsToDelete = [];
6382        $dataDir = DOKU_INC . 'data/meta/';
6383
6384        $debug = [];
6385        $debug['scanned_dirs'] = [];
6386        $debug['found_files'] = [];
6387
6388        // Calculate cutoff date for age-based cleanup
6389        $cutoffDate = null;
6390        if ($cleanupType === 'age') {
6391            $ageValue = $INPUT->int('age_value', 6);
6392            $ageUnit = $INPUT->str('age_unit', 'months');
6393
6394            if ($ageUnit === 'years') {
6395                $ageValue *= 12; // Convert to months
6396            }
6397
6398            $cutoffDate = date('Y-m-d', strtotime("-$ageValue months"));
6399            $debug['cutoff_date'] = $cutoffDate;
6400        }
6401
6402        // Get date range for range-based cleanup
6403        $rangeStart = $cleanupType === 'range' ? $INPUT->str('range_start', '') : null;
6404        $rangeEnd = $cleanupType === 'range' ? $INPUT->str('range_end', '') : null;
6405
6406        // Get status filters
6407        $deleteCompleted = $cleanupType === 'status' && $INPUT->bool('delete_completed', false);
6408        $deletePast = $cleanupType === 'status' && $INPUT->bool('delete_past', false);
6409
6410        // Check root calendar directory first (blank/default namespace)
6411        $rootCalendarDir = $dataDir . 'calendar';
6412        $debug['root_calendar_dir'] = $rootCalendarDir;
6413        $debug['root_exists'] = is_dir($rootCalendarDir);
6414
6415        if (is_dir($rootCalendarDir)) {
6416            if (!$namespaceFilter || $namespaceFilter === '' || $namespaceFilter === 'default') {
6417                $debug['scanned_dirs'][] = $rootCalendarDir;
6418                $files = glob($rootCalendarDir . '/*.json');
6419                $debug['found_files'] = array_merge($debug['found_files'], $files);
6420                $this->processCalendarFiles($rootCalendarDir, '', $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast);
6421            }
6422        }
6423
6424        // Scan all namespace directories
6425        $namespaceDirs = glob($dataDir . '*', GLOB_ONLYDIR);
6426        $debug['namespace_dirs_found'] = $namespaceDirs;
6427
6428        foreach ($namespaceDirs as $nsDir) {
6429            $namespace = basename($nsDir);
6430
6431            // Skip the root 'calendar' dir (already processed above)
6432            if ($namespace === 'calendar') continue;
6433
6434            // Check namespace filter
6435            if ($namespaceFilter && strpos($namespace, $namespaceFilter) === false) {
6436                continue;
6437            }
6438
6439            $calendarDir = $nsDir . '/calendar';
6440            $debug['checked_calendar_dirs'][] = $calendarDir;
6441
6442            if (!is_dir($calendarDir)) {
6443                $debug['missing_calendar_dirs'][] = $calendarDir;
6444                continue;
6445            }
6446
6447            $debug['scanned_dirs'][] = $calendarDir;
6448            $files = glob($calendarDir . '/*.json');
6449            $debug['found_files'] = array_merge($debug['found_files'], $files);
6450            $this->processCalendarFiles($calendarDir, $namespace, $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast);
6451        }
6452
6453        // Store debug info globally for preview
6454        $this->_cleanupDebug = $debug;
6455
6456        return $eventsToDelete;
6457    }
6458
6459    private function processCalendarFiles($calendarDir, $namespace, &$eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast) {
6460        foreach (glob($calendarDir . '/*.json') as $file) {
6461            $json = file_get_contents($file);
6462            $data = json_decode($json, true);
6463
6464            if (!$data) continue;
6465
6466            foreach ($data as $date => $dateEvents) {
6467                foreach ($dateEvents as $event) {
6468                    $shouldDelete = false;
6469
6470                    // Age-based
6471                    if ($cleanupType === 'age' && $cutoffDate && $date < $cutoffDate) {
6472                        $shouldDelete = true;
6473                    }
6474
6475                    // Range-based
6476                    if ($cleanupType === 'range' && $rangeStart && $rangeEnd) {
6477                        if ($date >= $rangeStart && $date <= $rangeEnd) {
6478                            $shouldDelete = true;
6479                        }
6480                    }
6481
6482                    // Status-based
6483                    if ($cleanupType === 'status') {
6484                        $isTask = isset($event['isTask']) && $event['isTask'];
6485                        $isCompleted = isset($event['completed']) && $event['completed'];
6486                        $isPast = $date < date('Y-m-d');
6487
6488                        if ($deleteCompleted && $isTask && $isCompleted) {
6489                            $shouldDelete = true;
6490                        }
6491                        if ($deletePast && !$isTask && $isPast) {
6492                            $shouldDelete = true;
6493                        }
6494                    }
6495
6496                    if ($shouldDelete) {
6497                        $eventsToDelete[] = [
6498                            'id' => $event['id'],
6499                            'title' => $event['title'],
6500                            'date' => $date,
6501                            'namespace' => $namespace ?: 'default',
6502                            'file' => $file
6503                        ];
6504                    }
6505                }
6506            }
6507        }
6508    }
6509
6510    /**
6511     * Render Themes tab for sidebar widget theme selection
6512     */
6513    private function renderThemesTab($colors = null) {
6514        global $INPUT;
6515
6516        // Use defaults if not provided
6517        if ($colors === null) {
6518            $colors = $this->getTemplateColors();
6519        }
6520
6521        // Handle theme save
6522        if ($INPUT->str('action') === 'save_theme') {
6523            $theme = $INPUT->str('theme', 'matrix');
6524            $weekStart = $INPUT->str('week_start', 'monday');
6525            $itineraryCollapsed = $INPUT->str('itinerary_collapsed', 'no');
6526            $this->saveSidebarTheme($theme);
6527            $this->saveWeekStartDay($weekStart);
6528            $this->saveItineraryCollapsed($itineraryCollapsed === 'yes');
6529            echo '<div style="background:#d4edda; border:1px solid #c3e6cb; color:#155724; padding:12px; border-radius:4px; margin-bottom:20px;">';
6530            echo '✓ Theme and settings saved successfully! Refresh any page with the sidebar to see changes.';
6531            echo '</div>';
6532        }
6533
6534        $currentTheme = $this->getSidebarTheme();
6535        $currentWeekStart = $this->getWeekStartDay();
6536        $currentItineraryCollapsed = $this->getItineraryCollapsed();
6537
6538        echo '<h2 style="margin:0 0 20px 0; color:' . $colors['text'] . ';">�� Sidebar Widget Settings</h2>';
6539        echo '<p style="color:' . $colors['text'] . '; margin-bottom:20px;">Customize the appearance and behavior of the sidebar calendar widget.</p>';
6540
6541        echo '<form method="post" action="?do=admin&page=calendar&tab=themes">';
6542        echo '<input type="hidden" name="action" value="save_theme">';
6543
6544        // Week Start Day Section
6545        echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">';
6546        echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;">�� Week Start Day</h3>';
6547        echo '<p style="color:' . $colors['text'] . '; margin-bottom:15px; font-size:13px;">Choose which day the week calendar grid starts with:</p>';
6548
6549        echo '<div style="display:flex; gap:15px;">';
6550        echo '<label style="flex:1; padding:12px; border:2px solid ' . ($currentWeekStart === 'monday' ? '#00cc07' : $colors['border']) . '; border-radius:4px; background:' . ($currentWeekStart === 'monday' ? 'rgba(0, 204, 7, 0.05)' : $colors['bg']) . '; cursor:pointer; display:flex; align-items:center;">';
6551        echo '<input type="radio" name="week_start" value="monday" ' . ($currentWeekStart === 'monday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
6552        echo '<div>';
6553        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Monday</div>';
6554        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Monday (ISO standard)</div>';
6555        echo '</div>';
6556        echo '</label>';
6557
6558        echo '<label style="flex:1; padding:12px; border:2px solid ' . ($currentWeekStart === 'sunday' ? '#00cc07' : $colors['border']) . '; border-radius:4px; background:' . ($currentWeekStart === 'sunday' ? 'rgba(0, 204, 7, 0.05)' : $colors['bg']) . '; cursor:pointer; display:flex; align-items:center;">';
6559        echo '<input type="radio" name="week_start" value="sunday" ' . ($currentWeekStart === 'sunday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
6560        echo '<div>';
6561        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Sunday</div>';
6562        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Sunday (US/Canada standard)</div>';
6563        echo '</div>';
6564        echo '</label>';
6565        echo '</div>';
6566        echo '</div>';
6567
6568        // Itinerary Default State Section
6569        echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">';
6570        echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;">�� Itinerary Section</h3>';
6571        echo '<p style="color:' . $colors['text'] . '; margin-bottom:15px; font-size:13px;">Choose whether the Today/Tomorrow/Important Events sections are expanded or collapsed by default:</p>';
6572
6573        echo '<div style="display:flex; gap:15px;">';
6574        echo '<label style="flex:1; padding:12px; border:2px solid ' . (!$currentItineraryCollapsed ? '#00cc07' : $colors['border']) . '; border-radius:4px; background:' . (!$currentItineraryCollapsed ? 'rgba(0, 204, 7, 0.05)' : $colors['bg']) . '; cursor:pointer; display:flex; align-items:center;">';
6575        echo '<input type="radio" name="itinerary_collapsed" value="no" ' . (!$currentItineraryCollapsed ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
6576        echo '<div>';
6577        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Expanded</div>';
6578        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Show itinerary sections by default</div>';
6579        echo '</div>';
6580        echo '</label>';
6581
6582        echo '<label style="flex:1; padding:12px; border:2px solid ' . ($currentItineraryCollapsed ? '#00cc07' : $colors['border']) . '; border-radius:4px; background:' . ($currentItineraryCollapsed ? 'rgba(0, 204, 7, 0.05)' : $colors['bg']) . '; cursor:pointer; display:flex; align-items:center;">';
6583        echo '<input type="radio" name="itinerary_collapsed" value="yes" ' . ($currentItineraryCollapsed ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
6584        echo '<div>';
6585        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Collapsed</div>';
6586        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Hide itinerary sections by default (click bar to expand)</div>';
6587        echo '</div>';
6588        echo '</label>';
6589        echo '</div>';
6590        echo '</div>';
6591
6592        // Visual Theme Section
6593        echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;">�� Visual Theme</h3>';
6594
6595        // Matrix Theme
6596        echo '<div style="border:2px solid ' . ($currentTheme === 'matrix' ? '#00cc07' : $colors['border']) . '; border-radius:6px; padding:20px; margin-bottom:20px; background:' . ($currentTheme === 'matrix' ? 'rgba(0, 204, 7, 0.05)' : $colors['bg']) . ';">';
6597        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
6598        echo '<input type="radio" name="theme" value="matrix" ' . ($currentTheme === 'matrix' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
6599        echo '<div style="flex:1;">';
6600        echo '<div style="font-size:18px; font-weight:bold; color:#00cc07; margin-bottom:8px;">�� Matrix Edition</div>';
6601        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Dark green theme with Matrix-style glow effects and neon accents</div>';
6602        echo '<div style="display:inline-block; background:#242424; border:2px solid #00cc07; padding:8px 12px; border-radius:4px; font-size:11px; font-family:monospace; color:#00cc07; box-shadow:0 0 10px rgba(0, 204, 7, 0.3);">Preview: Matrix Theme</div>';
6603        echo '</div>';
6604        echo '</label>';
6605        echo '</div>';
6606
6607        // Purple Theme
6608        echo '<div style="border:2px solid ' . ($currentTheme === 'purple' ? '#9b59b6' : $colors['border']) . '; border-radius:6px; padding:20px; margin-bottom:20px; background:' . ($currentTheme === 'purple' ? 'rgba(155, 89, 182, 0.05)' : $colors['bg']) . ';">';
6609        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
6610        echo '<input type="radio" name="theme" value="purple" ' . ($currentTheme === 'purple' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
6611        echo '<div style="flex:1;">';
6612        echo '<div style="font-size:18px; font-weight:bold; color:#9b59b6; margin-bottom:8px;">�� Purple Dream</div>';
6613        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Rich purple theme with elegant violet accents and soft glow</div>';
6614        echo '<div style="display:inline-block; background:#2a2030; border:2px solid #9b59b6; padding:8px 12px; border-radius:4px; font-size:11px; font-family:monospace; color:#b19cd9; box-shadow:0 0 10px rgba(155, 89, 182, 0.3);">Preview: Purple Theme</div>';
6615        echo '</div>';
6616        echo '</label>';
6617        echo '</div>';
6618
6619        // Professional Blue Theme
6620        echo '<div style="border:2px solid ' . ($currentTheme === 'professional' ? '#4a90e2' : $colors['border']) . '; border-radius:6px; padding:20px; margin-bottom:20px; background:' . ($currentTheme === 'professional' ? 'rgba(74, 144, 226, 0.05)' : $colors['bg']) . ';">';
6621        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
6622        echo '<input type="radio" name="theme" value="professional" ' . ($currentTheme === 'professional' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
6623        echo '<div style="flex:1;">';
6624        echo '<div style="font-size:18px; font-weight:bold; color:#4a90e2; margin-bottom:8px;">�� Professional Blue</div>';
6625        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Clean blue and grey theme with modern professional styling, no glow effects</div>';
6626        echo '<div style="display:inline-block; background:#f5f7fa; border:2px solid #4a90e2; padding:8px 12px; border-radius:4px; font-size:11px; font-family:sans-serif; color:#2c3e50; box-shadow:0 2px 4px rgba(0, 0, 0, 0.1);">Preview: Professional Theme</div>';
6627        echo '</div>';
6628        echo '</label>';
6629        echo '</div>';
6630
6631        // Pink Bling Theme
6632        echo '<div style="border:2px solid ' . ($currentTheme === 'pink' ? '#ff1493' : $colors['border']) . '; border-radius:6px; padding:20px; margin-bottom:20px; background:' . ($currentTheme === 'pink' ? 'rgba(255, 20, 147, 0.05)' : $colors['bg']) . ';">';
6633        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
6634        echo '<input type="radio" name="theme" value="pink" ' . ($currentTheme === 'pink' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
6635        echo '<div style="flex:1;">';
6636        echo '<div style="font-size:18px; font-weight:bold; color:#ff1493; margin-bottom:8px;">�� Pink Bling</div>';
6637        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Glamorous hot pink theme with maximum sparkle, hearts, and diamonds ✨</div>';
6638        echo '<div style="display:inline-block; background:#1a0d14; border:2px solid #ff1493; padding:8px 12px; border-radius:4px; font-size:11px; font-family:monospace; color:#ff69b4; box-shadow:0 0 12px rgba(255, 20, 147, 0.6);">Preview: Pink Bling Theme ��</div>';
6639        echo '</div>';
6640        echo '</label>';
6641        echo '</div>';
6642
6643        // Wiki Default Theme
6644        echo '<div style="border:2px solid ' . ($currentTheme === 'wiki' ? '#2b73b7' : $colors['border']) . '; border-radius:6px; padding:20px; margin-bottom:20px; background:' . ($currentTheme === 'wiki' ? 'rgba(43, 115, 183, 0.05)' : $colors['bg']) . ';">';
6645        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
6646        echo '<input type="radio" name="theme" value="wiki" ' . ($currentTheme === 'wiki' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
6647        echo '<div style="flex:1;">';
6648        echo '<div style="font-size:18px; font-weight:bold; color:#2b73b7; margin-bottom:8px;">�� Wiki Default</div>';
6649        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Automatically matches your DokuWiki template theme using CSS variables - adapts to light and dark themes</div>';
6650        echo '<div style="display:inline-block; background:#f5f5f5; border:2px solid #ccc; padding:8px 12px; border-radius:4px; font-size:11px; font-family:sans-serif; color:' . $colors['text'] . '; box-shadow:0 1px 2px rgba(0, 0, 0, 0.1);">Preview: Matches Your Wiki Theme</div>';
6651        echo '</div>';
6652        echo '</label>';
6653        echo '</div>';
6654
6655        echo '<button type="submit" style="background:#00cc07; color:#fff; border:none; padding:12px 24px; border-radius:4px; font-size:14px; font-weight:bold; cursor:pointer; box-shadow:0 2px 4px rgba(0,0,0,0.2);">Save Settings</button>';
6656        echo '</form>';
6657    }
6658
6659    /**
6660     * Get current sidebar theme
6661     */
6662    private function getSidebarTheme() {
6663        $configFile = DOKU_INC . 'data/meta/calendar_theme.txt';
6664        if (file_exists($configFile)) {
6665            return trim(file_get_contents($configFile));
6666        }
6667        return 'matrix'; // Default
6668    }
6669
6670    /**
6671     * Save sidebar theme
6672     */
6673    private function saveSidebarTheme($theme) {
6674        $configFile = DOKU_INC . 'data/meta/calendar_theme.txt';
6675        $validThemes = ['matrix', 'purple', 'professional', 'pink', 'wiki'];
6676
6677        if (in_array($theme, $validThemes)) {
6678            file_put_contents($configFile, $theme);
6679            return true;
6680        }
6681        return false;
6682    }
6683
6684    /**
6685     * Get week start day
6686     */
6687    private function getWeekStartDay() {
6688        $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt';
6689        if (file_exists($configFile)) {
6690            $start = trim(file_get_contents($configFile));
6691            if (in_array($start, ['monday', 'sunday'])) {
6692                return $start;
6693            }
6694        }
6695        return 'sunday'; // Default to Sunday (US/Canada standard)
6696    }
6697
6698    /**
6699     * Save week start day
6700     */
6701    private function saveWeekStartDay($weekStart) {
6702        $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt';
6703        $validStarts = ['monday', 'sunday'];
6704
6705        if (in_array($weekStart, $validStarts)) {
6706            file_put_contents($configFile, $weekStart);
6707            return true;
6708        }
6709        return false;
6710    }
6711
6712    /**
6713     * Get itinerary collapsed default state
6714     */
6715    private function getItineraryCollapsed() {
6716        $configFile = DOKU_INC . 'data/meta/calendar_itinerary_collapsed.txt';
6717        if (file_exists($configFile)) {
6718            return trim(file_get_contents($configFile)) === 'yes';
6719        }
6720        return false; // Default to expanded
6721    }
6722
6723    /**
6724     * Save itinerary collapsed default state
6725     */
6726    private function saveItineraryCollapsed($collapsed) {
6727        $configFile = DOKU_INC . 'data/meta/calendar_itinerary_collapsed.txt';
6728        file_put_contents($configFile, $collapsed ? 'yes' : 'no');
6729        return true;
6730    }
6731
6732    /**
6733     * Get colors from DokuWiki template's style.ini file
6734     */
6735    private function getTemplateColors() {
6736        global $conf;
6737
6738        // Get current template name
6739        $template = $conf['template'];
6740
6741        // Try multiple possible locations for style.ini
6742        $possiblePaths = [
6743            DOKU_INC . 'conf/tpl/' . $template . '/style.ini',
6744            DOKU_INC . 'lib/tpl/' . $template . '/style.ini',
6745        ];
6746
6747        $styleIni = null;
6748        foreach ($possiblePaths as $path) {
6749            if (file_exists($path)) {
6750                $styleIni = parse_ini_file($path, true);
6751                break;
6752            }
6753        }
6754
6755        if (!$styleIni || !isset($styleIni['replacements'])) {
6756            // Return defaults
6757            return [
6758                'bg' => '#fff',
6759                'bg_alt' => '#e8e8e8',
6760                'text' => '#333',
6761                'border' => '#ccc',
6762                'link' => '#2b73b7',
6763            ];
6764        }
6765
6766        $r = $styleIni['replacements'];
6767
6768        return [
6769            'bg' => isset($r['__background__']) ? $r['__background__'] : '#fff',
6770            'bg_alt' => isset($r['__background_alt__']) ? $r['__background_alt__'] : '#e8e8e8',
6771            'text' => isset($r['__text__']) ? $r['__text__'] : '#333',
6772            'border' => isset($r['__border__']) ? $r['__border__'] : '#ccc',
6773            'link' => isset($r['__link__']) ? $r['__link__'] : '#2b73b7',
6774        ];
6775    }
6776}
6777