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