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