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