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