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