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