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