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