xref: /plugin/calendar/admin.php (revision 96df7d3e9a825dddf459ab1ee6077a9886837f17)
11d05cddcSAtari911<?php
21d05cddcSAtari911/**
31d05cddcSAtari911 * Calendar Plugin - Admin Interface
41d05cddcSAtari911 * Clean rewrite - Configuration only
51d05cddcSAtari911 * Version: 3.3
61d05cddcSAtari911 */
71d05cddcSAtari911
81d05cddcSAtari911if(!defined('DOKU_INC')) die();
91d05cddcSAtari911
101d05cddcSAtari911class admin_plugin_calendar extends DokuWiki_Admin_Plugin {
111d05cddcSAtari911
12*96df7d3eSAtari911    /**
13*96df7d3eSAtari911     * Get the path to the sync log file (in data directory, not plugin directory)
14*96df7d3eSAtari911     */
15*96df7d3eSAtari911    private function getSyncLogPath() {
16*96df7d3eSAtari911        $dataDir = DOKU_INC . 'data/meta/calendar/';
17*96df7d3eSAtari911        if (!is_dir($dataDir)) {
18*96df7d3eSAtari911            @mkdir($dataDir, 0755, true);
19*96df7d3eSAtari911        }
20*96df7d3eSAtari911        return $dataDir . 'sync.log';
21*96df7d3eSAtari911    }
22*96df7d3eSAtari911
23*96df7d3eSAtari911    /**
24*96df7d3eSAtari911     * Get the path to the sync state file (in data directory, not plugin directory)
25*96df7d3eSAtari911     */
26*96df7d3eSAtari911    private function getSyncStatePath() {
27*96df7d3eSAtari911        $dataDir = DOKU_INC . 'data/meta/calendar/';
28*96df7d3eSAtari911        if (!is_dir($dataDir)) {
29*96df7d3eSAtari911            mkdir($dataDir, 0755, true);
30*96df7d3eSAtari911        }
31*96df7d3eSAtari911        return $dataDir . 'sync_state.json';
32*96df7d3eSAtari911    }
33*96df7d3eSAtari911
341d05cddcSAtari911    public function getMenuText($language) {
351d05cddcSAtari911        return 'Calendar Management';
361d05cddcSAtari911    }
371d05cddcSAtari911
381d05cddcSAtari911    public function getMenuSort() {
391d05cddcSAtari911        return 100;
401d05cddcSAtari911    }
411d05cddcSAtari911
421d05cddcSAtari911    public function forAdminOnly() {
431d05cddcSAtari911        return true;
441d05cddcSAtari911    }
451d05cddcSAtari911
467e8ea635SAtari911    /**
477e8ea635SAtari911     * Public entry point for AJAX actions routed from action.php
487e8ea635SAtari911     */
497e8ea635SAtari911    public function handleAjaxAction($action) {
507e8ea635SAtari911        // Verify admin privileges for all admin AJAX actions
517e8ea635SAtari911        if (!auth_isadmin()) {
527e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Admin access required']);
537e8ea635SAtari911            return;
547e8ea635SAtari911        }
557e8ea635SAtari911
567e8ea635SAtari911        switch ($action) {
577e8ea635SAtari911            case 'cleanup_empty_namespaces': $this->handleCleanupEmptyNamespaces(); break;
587e8ea635SAtari911            case 'trim_all_past_recurring': $this->handleTrimAllPastRecurring(); break;
597e8ea635SAtari911            case 'rescan_recurring': $this->handleRescanRecurring(); break;
607e8ea635SAtari911            case 'extend_recurring': $this->handleExtendRecurring(); break;
617e8ea635SAtari911            case 'trim_recurring': $this->handleTrimRecurring(); break;
627e8ea635SAtari911            case 'pause_recurring': $this->handlePauseRecurring(); break;
637e8ea635SAtari911            case 'resume_recurring': $this->handleResumeRecurring(); break;
647e8ea635SAtari911            case 'change_start_recurring': $this->handleChangeStartRecurring(); break;
657e8ea635SAtari911            case 'change_pattern_recurring': $this->handleChangePatternRecurring(); break;
667e8ea635SAtari911            default:
677e8ea635SAtari911                echo json_encode(['success' => false, 'error' => 'Unknown admin action']);
687e8ea635SAtari911        }
697e8ea635SAtari911    }
707e8ea635SAtari911
711d05cddcSAtari911    public function handle() {
721d05cddcSAtari911        global $INPUT;
731d05cddcSAtari911
741d05cddcSAtari911        $action = $INPUT->str('action');
751d05cddcSAtari911
761d05cddcSAtari911        if ($action === 'clear_cache') {
771d05cddcSAtari911            $this->clearCache();
781d05cddcSAtari911        } elseif ($action === 'save_config') {
791d05cddcSAtari911            $this->saveConfig();
801d05cddcSAtari911        } elseif ($action === 'delete_recurring_series') {
811d05cddcSAtari911            $this->deleteRecurringSeries();
821d05cddcSAtari911        } elseif ($action === 'edit_recurring_series') {
831d05cddcSAtari911            $this->editRecurringSeries();
841d05cddcSAtari911        } elseif ($action === 'move_selected_events') {
851d05cddcSAtari911            $this->moveEvents();
861d05cddcSAtari911        } elseif ($action === 'move_single_event') {
871d05cddcSAtari911            $this->moveSingleEvent();
881d05cddcSAtari911        } elseif ($action === 'delete_selected_events') {
891d05cddcSAtari911            $this->deleteSelectedEvents();
901d05cddcSAtari911        } elseif ($action === 'create_namespace') {
911d05cddcSAtari911            $this->createNamespace();
921d05cddcSAtari911        } elseif ($action === 'delete_namespace') {
931d05cddcSAtari911            $this->deleteNamespace();
949ccd446eSAtari911        } elseif ($action === 'rename_namespace') {
959ccd446eSAtari911            $this->renameNamespace();
961d05cddcSAtari911        } elseif ($action === 'run_sync') {
971d05cddcSAtari911            $this->runSync();
981d05cddcSAtari911        } elseif ($action === 'stop_sync') {
991d05cddcSAtari911            $this->stopSync();
1001d05cddcSAtari911        } elseif ($action === 'upload_update') {
1011d05cddcSAtari911            $this->uploadUpdate();
1021d05cddcSAtari911        } elseif ($action === 'delete_backup') {
1031d05cddcSAtari911            $this->deleteBackup();
1041d05cddcSAtari911        } elseif ($action === 'rename_backup') {
1051d05cddcSAtari911            $this->renameBackup();
1061d05cddcSAtari911        } elseif ($action === 'restore_backup') {
1071d05cddcSAtari911            $this->restoreBackup();
1089ccd446eSAtari911        } elseif ($action === 'create_manual_backup') {
1099ccd446eSAtari911            $this->createManualBackup();
1101d05cddcSAtari911        } elseif ($action === 'export_config') {
1111d05cddcSAtari911            $this->exportConfig();
1121d05cddcSAtari911        } elseif ($action === 'import_config') {
1131d05cddcSAtari911            $this->importConfig();
1141d05cddcSAtari911        } elseif ($action === 'get_log') {
1151d05cddcSAtari911            $this->getLog();
1167e8ea635SAtari911        } elseif ($action === 'cleanup_empty_namespaces') {
1177e8ea635SAtari911            $this->handleCleanupEmptyNamespaces();
1187e8ea635SAtari911        } elseif ($action === 'trim_all_past_recurring') {
1197e8ea635SAtari911            $this->handleTrimAllPastRecurring();
1207e8ea635SAtari911        } elseif ($action === 'rescan_recurring') {
1217e8ea635SAtari911            $this->handleRescanRecurring();
1227e8ea635SAtari911        } elseif ($action === 'extend_recurring') {
1237e8ea635SAtari911            $this->handleExtendRecurring();
1247e8ea635SAtari911        } elseif ($action === 'trim_recurring') {
1257e8ea635SAtari911            $this->handleTrimRecurring();
1267e8ea635SAtari911        } elseif ($action === 'pause_recurring') {
1277e8ea635SAtari911            $this->handlePauseRecurring();
1287e8ea635SAtari911        } elseif ($action === 'resume_recurring') {
1297e8ea635SAtari911            $this->handleResumeRecurring();
1307e8ea635SAtari911        } elseif ($action === 'change_start_recurring') {
1317e8ea635SAtari911            $this->handleChangeStartRecurring();
1327e8ea635SAtari911        } elseif ($action === 'change_pattern_recurring') {
1337e8ea635SAtari911            $this->handleChangePatternRecurring();
1341d05cddcSAtari911        } elseif ($action === 'clear_log') {
1351d05cddcSAtari911            $this->clearLogFile();
1361d05cddcSAtari911        } elseif ($action === 'download_log') {
1371d05cddcSAtari911            $this->downloadLog();
1381d05cddcSAtari911        } elseif ($action === 'rescan_events') {
1391d05cddcSAtari911            $this->rescanEvents();
1401d05cddcSAtari911        } elseif ($action === 'export_all_events') {
1411d05cddcSAtari911            $this->exportAllEvents();
1421d05cddcSAtari911        } elseif ($action === 'import_all_events') {
1431d05cddcSAtari911            $this->importAllEvents();
1441d05cddcSAtari911        } elseif ($action === 'preview_cleanup') {
1451d05cddcSAtari911            $this->previewCleanup();
1461d05cddcSAtari911        } elseif ($action === 'cleanup_events') {
1471d05cddcSAtari911            $this->cleanupEvents();
1484590242dSAtari911        } elseif ($action === 'save_important_namespaces') {
1494590242dSAtari911            $this->saveImportantNamespaces();
1501d05cddcSAtari911        }
1511d05cddcSAtari911    }
1521d05cddcSAtari911
1531d05cddcSAtari911    public function html() {
1541d05cddcSAtari911        global $INPUT;
1551d05cddcSAtari911
1569ccd446eSAtari911        // Get current tab - default to 'manage' (Manage Events tab)
1579ccd446eSAtari911        $tab = $INPUT->str('tab', 'manage');
1581d05cddcSAtari911
1599ccd446eSAtari911        // Get template colors
1609ccd446eSAtari911        $colors = $this->getTemplateColors();
1619ccd446eSAtari911        $accentColor = '#00cc07'; // Keep calendar plugin accent color
1629ccd446eSAtari911
1639ccd446eSAtari911        // Tab navigation (Manage Events, Update Plugin, Outlook Sync, Themes)
1649ccd446eSAtari911        echo '<div style="border-bottom:2px solid ' . $colors['border'] . '; margin:10px 0 15px 0;">';
1659ccd446eSAtari911        echo '<a href="?do=admin&page=calendar&tab=manage" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'manage' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'manage' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'manage' ? 'bold' : 'normal') . ';">�� Manage Events</a>';
1669ccd446eSAtari911        echo '<a href="?do=admin&page=calendar&tab=update" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'update' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'update' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'update' ? 'bold' : 'normal') . ';">�� Update Plugin</a>';
1679ccd446eSAtari911        echo '<a href="?do=admin&page=calendar&tab=config" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'config' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'config' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'config' ? 'bold' : 'normal') . ';">⚙️ Outlook Sync</a>';
1689ccd446eSAtari911        echo '<a href="?do=admin&page=calendar&tab=themes" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'themes' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'themes' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'themes' ? 'bold' : 'normal') . ';">�� Themes</a>';
1691d05cddcSAtari911        echo '</div>';
1701d05cddcSAtari911
1711d05cddcSAtari911        // Render appropriate tab
1721d05cddcSAtari911        if ($tab === 'config') {
1739ccd446eSAtari911            $this->renderConfigTab($colors);
1741d05cddcSAtari911        } elseif ($tab === 'manage') {
1759ccd446eSAtari911            $this->renderManageTab($colors);
1769ccd446eSAtari911        } elseif ($tab === 'themes') {
1779ccd446eSAtari911            $this->renderThemesTab($colors);
1781d05cddcSAtari911        } else {
1799ccd446eSAtari911            $this->renderUpdateTab($colors);
1801d05cddcSAtari911        }
1811d05cddcSAtari911    }
1821d05cddcSAtari911
1839ccd446eSAtari911    private function renderConfigTab($colors = null) {
1841d05cddcSAtari911        global $INPUT;
1851d05cddcSAtari911
1869ccd446eSAtari911        // Use defaults if not provided
1879ccd446eSAtari911        if ($colors === null) {
1889ccd446eSAtari911            $colors = $this->getTemplateColors();
1899ccd446eSAtari911        }
1909ccd446eSAtari911
1911d05cddcSAtari911        // Load current config
1921d05cddcSAtari911        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
1931d05cddcSAtari911        $config = [];
1941d05cddcSAtari911        if (file_exists($configFile)) {
1951d05cddcSAtari911            $config = include $configFile;
1961d05cddcSAtari911        }
1971d05cddcSAtari911
1981d05cddcSAtari911        // Show message if present
1991d05cddcSAtari911        if ($INPUT->has('msg')) {
2001d05cddcSAtari911            $msg = hsc($INPUT->str('msg'));
2011d05cddcSAtari911            $type = $INPUT->str('msgtype', 'success');
2021d05cddcSAtari911            $class = ($type === 'success') ? 'msg success' : 'msg error';
2031d05cddcSAtari911            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;\">";
2041d05cddcSAtari911            echo $msg;
2051d05cddcSAtari911            echo "</div>";
2061d05cddcSAtari911        }
2071d05cddcSAtari911
2081d05cddcSAtari911        echo '<h2 style="margin:10px 0; font-size:20px;">Outlook Sync Configuration</h2>';
2091d05cddcSAtari911
2101d05cddcSAtari911        // Import/Export buttons
2111d05cddcSAtari911        echo '<div style="display:flex; gap:10px; margin-bottom:15px;">';
2121d05cddcSAtari911        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>';
2131d05cddcSAtari911        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>';
2141d05cddcSAtari911        echo '<input type="file" id="importFileInput" accept=".enc" style="display:none;" onchange="importConfig(this)">';
2151d05cddcSAtari911        echo '<span id="importStatus" style="margin-left:10px; font-size:12px;"></span>';
2161d05cddcSAtari911        echo '</div>';
2171d05cddcSAtari911
2181d05cddcSAtari911        echo '<form method="post" action="?do=admin&page=calendar" style="max-width:900px;">';
2191d05cddcSAtari911        echo '<input type="hidden" name="action" value="save_config">';
2201d05cddcSAtari911
2211d05cddcSAtari911        // Azure Credentials
2229ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">';
2231d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Microsoft Azure App Credentials</h3>';
2249ccd446eSAtari911        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>';
2251d05cddcSAtari911
2261d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Tenant ID</label>';
2279ccd446eSAtari911        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;">';
2281d05cddcSAtari911
2291d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client ID (Application ID)</label>';
230*96df7d3eSAtari911        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;">';
2311d05cddcSAtari911
2321d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client Secret</label>';
233*96df7d3eSAtari911        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;">';
2341d05cddcSAtari911        echo '<p style="color:#999; font-size:0.8em; margin:3px 0 0;">⚠️ Keep this secret safe!</p>';
2351d05cddcSAtari911        echo '</div>';
2361d05cddcSAtari911
2371d05cddcSAtari911        // Outlook Settings
2389ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">';
2391d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Outlook Settings</h3>';
2401d05cddcSAtari911
2411d05cddcSAtari911        echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">';
2421d05cddcSAtari911
2431d05cddcSAtari911        echo '<div>';
2441d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">User Email</label>';
245*96df7d3eSAtari911        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;">';
2461d05cddcSAtari911        echo '</div>';
2471d05cddcSAtari911
2481d05cddcSAtari911        echo '<div>';
2491d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Timezone</label>';
2509ccd446eSAtari911        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;">';
2511d05cddcSAtari911        echo '</div>';
2521d05cddcSAtari911
2531d05cddcSAtari911        echo '<div>';
2541d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Default Category</label>';
2559ccd446eSAtari911        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;">';
2561d05cddcSAtari911        echo '</div>';
2571d05cddcSAtari911
2581d05cddcSAtari911        echo '<div>';
2591d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Reminder (minutes)</label>';
2609ccd446eSAtari911        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;">';
2611d05cddcSAtari911        echo '</div>';
2621d05cddcSAtari911
2631d05cddcSAtari911        echo '</div>'; // end grid
2641d05cddcSAtari911        echo '</div>';
2651d05cddcSAtari911
2661d05cddcSAtari911        // Sync Options
2679ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">';
2681d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Sync Options</h3>';
2691d05cddcSAtari911
2701d05cddcSAtari911        $syncCompleted = isset($config['sync_completed_tasks']) ? $config['sync_completed_tasks'] : false;
2711d05cddcSAtari911        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>';
2721d05cddcSAtari911
2731d05cddcSAtari911        $deleteOutlook = isset($config['delete_outlook_events']) ? $config['delete_outlook_events'] : true;
2741d05cddcSAtari911        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>';
2751d05cddcSAtari911
2761d05cddcSAtari911        $syncAll = isset($config['sync_all_namespaces']) ? $config['sync_all_namespaces'] : true;
2771d05cddcSAtari911        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>';
2781d05cddcSAtari911
2791d05cddcSAtari911        // Namespace selection (shown when sync_all is unchecked)
2801d05cddcSAtari911        echo '<div id="namespace_selection" style="margin-top:10px; ' . ($syncAll ? 'display:none;' : '') . '">';
2811d05cddcSAtari911        echo '<label style="display:block; font-weight:bold; margin-bottom:5px; font-size:13px;">Select namespaces to sync:</label>';
2821d05cddcSAtari911
2831d05cddcSAtari911        // Get available namespaces
2841d05cddcSAtari911        $availableNamespaces = $this->getAllNamespaces();
2851d05cddcSAtari911        $selectedNamespaces = isset($config['sync_namespaces']) ? $config['sync_namespaces'] : [];
2861d05cddcSAtari911
2879ccd446eSAtari911        echo '<div style="max-height:150px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; padding:8px; background:' . $colors['bg'] . ';">';
2881d05cddcSAtari911        echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value=""> (default)</label>';
2891d05cddcSAtari911        foreach ($availableNamespaces as $ns) {
2901d05cddcSAtari911            if ($ns !== '') {
2911d05cddcSAtari911                $checked = in_array($ns, $selectedNamespaces) ? 'checked' : '';
2921d05cddcSAtari911                echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value="' . hsc($ns) . '" ' . $checked . '> ' . hsc($ns) . '</label>';
2931d05cddcSAtari911            }
2941d05cddcSAtari911        }
2951d05cddcSAtari911        echo '</div>';
2961d05cddcSAtari911        echo '</div>';
2971d05cddcSAtari911
2981d05cddcSAtari911        echo '<script>
2991d05cddcSAtari911        function toggleNamespaceSelection(checkbox) {
3001d05cddcSAtari911            document.getElementById("namespace_selection").style.display = checkbox.checked ? "none" : "block";
3011d05cddcSAtari911        }
3021d05cddcSAtari911        </script>';
3031d05cddcSAtari911
3041d05cddcSAtari911        echo '</div>';
3051d05cddcSAtari911
3061d05cddcSAtari911        // Namespace and Color Mapping - Side by Side
3071d05cddcSAtari911        echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:10px 0;">';
3081d05cddcSAtari911
3091d05cddcSAtari911        // Namespace Mapping
3109ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">';
3111d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Namespace → Category</h3>';
3129ccd446eSAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 5px;">One per line: namespace=Category</p>';
3139ccd446eSAtari911        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">';
3141d05cddcSAtari911        if (isset($config['category_mapping']) && is_array($config['category_mapping'])) {
3151d05cddcSAtari911            foreach ($config['category_mapping'] as $ns => $cat) {
3161d05cddcSAtari911                echo hsc($ns) . '=' . hsc($cat) . "\n";
3171d05cddcSAtari911            }
3181d05cddcSAtari911        }
3191d05cddcSAtari911        echo '</textarea>';
3201d05cddcSAtari911        echo '</div>';
3211d05cddcSAtari911
3221d05cddcSAtari911        // Color Mapping with Color Picker
3239ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">';
3241d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Event Color → Category</h3>';
3259ccd446eSAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 8px;">Map calendar colors to Outlook categories</p>';
3261d05cddcSAtari911
3271d05cddcSAtari911        // Define calendar colors and Outlook categories (only the main 6 colors)
3281d05cddcSAtari911        $calendarColors = [
3291d05cddcSAtari911            '#3498db' => 'Blue',
3301d05cddcSAtari911            '#2ecc71' => 'Green',
3311d05cddcSAtari911            '#e74c3c' => 'Red',
3321d05cddcSAtari911            '#f39c12' => 'Orange',
3331d05cddcSAtari911            '#9b59b6' => 'Purple',
3341d05cddcSAtari911            '#1abc9c' => 'Teal'
3351d05cddcSAtari911        ];
3361d05cddcSAtari911
3371d05cddcSAtari911        $outlookCategories = [
3381d05cddcSAtari911            'Blue category',
3391d05cddcSAtari911            'Green category',
3401d05cddcSAtari911            'Orange category',
3411d05cddcSAtari911            'Red category',
3421d05cddcSAtari911            'Yellow category',
3431d05cddcSAtari911            'Purple category'
3441d05cddcSAtari911        ];
3451d05cddcSAtari911
3461d05cddcSAtari911        // Load existing color mappings
3471d05cddcSAtari911        $existingMappings = isset($config['color_mapping']) && is_array($config['color_mapping'])
3481d05cddcSAtari911            ? $config['color_mapping']
3491d05cddcSAtari911            : [];
3501d05cddcSAtari911
3511d05cddcSAtari911        // Display color mapping rows
3521d05cddcSAtari911        echo '<div id="colorMappings" style="max-height:200px; overflow-y:auto;">';
3531d05cddcSAtari911
3541d05cddcSAtari911        $rowIndex = 0;
3551d05cddcSAtari911        foreach ($calendarColors as $hexColor => $colorName) {
3561d05cddcSAtari911            $selectedCategory = isset($existingMappings[$hexColor]) ? $existingMappings[$hexColor] : '';
3571d05cddcSAtari911
3581d05cddcSAtari911            echo '<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px;">';
3591d05cddcSAtari911
3601d05cddcSAtari911            // Color preview box
3611d05cddcSAtari911            echo '<div style="width:24px; height:24px; background:' . $hexColor . '; border:2px solid #ddd; border-radius:3px; flex-shrink:0;"></div>';
3621d05cddcSAtari911
3631d05cddcSAtari911            // Color name
3649ccd446eSAtari911            echo '<span style="font-size:12px; min-width:90px; color:' . $colors['text'] . ';">' . $colorName . '</span>';
3651d05cddcSAtari911
3661d05cddcSAtari911            // Arrow
3671d05cddcSAtari911            echo '<span style="color:#999; font-size:12px;">→</span>';
3681d05cddcSAtari911
3691d05cddcSAtari911            // Outlook category dropdown
3709ccd446eSAtari911            echo '<select name="color_map_' . $rowIndex . '" style="flex:1; padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">';
3711d05cddcSAtari911            echo '<option value="">-- None --</option>';
3721d05cddcSAtari911            foreach ($outlookCategories as $category) {
3731d05cddcSAtari911                $selected = ($selectedCategory === $category) ? 'selected' : '';
3741d05cddcSAtari911                echo '<option value="' . hsc($category) . '" ' . $selected . '>' . hsc($category) . '</option>';
3751d05cddcSAtari911            }
3761d05cddcSAtari911            echo '</select>';
3771d05cddcSAtari911
3781d05cddcSAtari911            // Hidden input for the hex color
3791d05cddcSAtari911            echo '<input type="hidden" name="color_hex_' . $rowIndex . '" value="' . $hexColor . '">';
3801d05cddcSAtari911
3811d05cddcSAtari911            echo '</div>';
3821d05cddcSAtari911            $rowIndex++;
3831d05cddcSAtari911        }
3841d05cddcSAtari911
3851d05cddcSAtari911        echo '</div>';
3861d05cddcSAtari911
3871d05cddcSAtari911        // Hidden input to track number of color mappings
3881d05cddcSAtari911        echo '<input type="hidden" name="color_mapping_count" value="' . $rowIndex . '">';
3891d05cddcSAtari911
3901d05cddcSAtari911        echo '</div>';
3911d05cddcSAtari911
3921d05cddcSAtari911        echo '</div>'; // end grid
3931d05cddcSAtari911
3941d05cddcSAtari911        // Submit button
3951d05cddcSAtari911        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>';
3961d05cddcSAtari911        echo '</form>';
3971d05cddcSAtari911
3981d05cddcSAtari911        // JavaScript for Import/Export
3991d05cddcSAtari911        echo '<script>
4001d05cddcSAtari911        async function exportConfig() {
4011d05cddcSAtari911            try {
4021d05cddcSAtari911                const response = await fetch("?do=admin&page=calendar&action=export_config&call=ajax", {
4031d05cddcSAtari911                    method: "POST"
4041d05cddcSAtari911                });
4051d05cddcSAtari911                const data = await response.json();
4061d05cddcSAtari911
4071d05cddcSAtari911                if (data.success) {
4081d05cddcSAtari911                    // Create download link
4091d05cddcSAtari911                    const blob = new Blob([data.encrypted], {type: "application/octet-stream"});
4101d05cddcSAtari911                    const url = URL.createObjectURL(blob);
4111d05cddcSAtari911                    const a = document.createElement("a");
4121d05cddcSAtari911                    a.href = url;
4131d05cddcSAtari911                    a.download = "sync_config_" + new Date().toISOString().split("T")[0] + ".enc";
4141d05cddcSAtari911                    document.body.appendChild(a);
4151d05cddcSAtari911                    a.click();
4161d05cddcSAtari911                    document.body.removeChild(a);
4171d05cddcSAtari911                    URL.revokeObjectURL(url);
4181d05cddcSAtari911
4191d05cddcSAtari911                    alert("✅ Config exported successfully!\\n\\n⚠️ This file contains encrypted credentials.\\nKeep it secure!");
4201d05cddcSAtari911                } else {
4211d05cddcSAtari911                    alert("❌ Export failed: " + data.message);
4221d05cddcSAtari911                }
4231d05cddcSAtari911            } catch (error) {
4241d05cddcSAtari911                alert("❌ Error: " + error.message);
4251d05cddcSAtari911            }
4261d05cddcSAtari911        }
4271d05cddcSAtari911
4281d05cddcSAtari911        async function importConfig(input) {
4291d05cddcSAtari911            const file = input.files[0];
4301d05cddcSAtari911            if (!file) return;
4311d05cddcSAtari911
4321d05cddcSAtari911            const status = document.getElementById("importStatus");
4331d05cddcSAtari911            status.textContent = "⏳ Importing...";
4341d05cddcSAtari911            status.style.color = "#00cc07";
4351d05cddcSAtari911
4361d05cddcSAtari911            try {
4371d05cddcSAtari911                const encrypted = await file.text();
4381d05cddcSAtari911
4391d05cddcSAtari911                const formData = new FormData();
4401d05cddcSAtari911                formData.append("encrypted_config", encrypted);
4411d05cddcSAtari911
4421d05cddcSAtari911                const response = await fetch("?do=admin&page=calendar&action=import_config&call=ajax", {
4431d05cddcSAtari911                    method: "POST",
4441d05cddcSAtari911                    body: formData
4451d05cddcSAtari911                });
4461d05cddcSAtari911                const data = await response.json();
4471d05cddcSAtari911
4481d05cddcSAtari911                if (data.success) {
4491d05cddcSAtari911                    status.textContent = "✅ Import successful! Reloading...";
4501d05cddcSAtari911                    status.style.color = "#28a745";
4511d05cddcSAtari911                    setTimeout(() => {
4521d05cddcSAtari911                        window.location.reload();
4531d05cddcSAtari911                    }, 1500);
4541d05cddcSAtari911                } else {
4551d05cddcSAtari911                    status.textContent = "❌ Import failed: " + data.message;
4561d05cddcSAtari911                    status.style.color = "#dc3545";
4571d05cddcSAtari911                }
4581d05cddcSAtari911            } catch (error) {
4591d05cddcSAtari911                status.textContent = "❌ Error: " + error.message;
4601d05cddcSAtari911                status.style.color = "#dc3545";
4611d05cddcSAtari911            }
4621d05cddcSAtari911
4631d05cddcSAtari911            // Reset file input
4641d05cddcSAtari911            input.value = "";
4651d05cddcSAtari911        }
4661d05cddcSAtari911        </script>';
4671d05cddcSAtari911
4681d05cddcSAtari911        // Sync Controls Section
4699ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">';
4701d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Sync Controls</h3>';
4711d05cddcSAtari911
4721d05cddcSAtari911        // Check cron job status
4731d05cddcSAtari911        $cronStatus = $this->getCronStatus();
4741d05cddcSAtari911
4751d05cddcSAtari911        // Check log file permissions
476*96df7d3eSAtari911        $logFile = $this->getSyncLogPath();
4771d05cddcSAtari911        $logWritable = is_writable($logFile) || is_writable(dirname($logFile));
4781d05cddcSAtari911
4791d05cddcSAtari911        echo '<div style="display:flex; gap:10px; align-items:center; margin-bottom:10px;">';
4801d05cddcSAtari911        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>';
4811d05cddcSAtari911        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>';
4821d05cddcSAtari911
4831d05cddcSAtari911        if ($cronStatus['active']) {
4849ccd446eSAtari911            echo '<span style="color:' . $colors['text'] . '; font-size:12px;">⏰ ' . hsc($cronStatus['frequency']) . '</span>';
4851d05cddcSAtari911        } else {
4861d05cddcSAtari911            echo '<span style="color:#999; font-size:12px;">⚠️ No cron job detected</span>';
4871d05cddcSAtari911        }
4881d05cddcSAtari911
4899ccd446eSAtari911        echo '<span id="syncStatus" style="color:' . $colors['text'] . '; font-size:12px; margin-left:auto;"></span>';
4901d05cddcSAtari911        echo '</div>';
4911d05cddcSAtari911
4921d05cddcSAtari911        // Show permission warning if log not writable
4931d05cddcSAtari911        if (!$logWritable) {
4941d05cddcSAtari911            echo '<div style="background:#fff3e0; border-left:3px solid #ff9800; padding:8px; margin:8px 0; border-radius:3px;">';
4951d05cddcSAtari911            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>';
4961d05cddcSAtari911            echo '</div>';
4971d05cddcSAtari911        }
4981d05cddcSAtari911
4991d05cddcSAtari911        // Show debug info if cron detected
5001d05cddcSAtari911        if ($cronStatus['active'] && !empty($cronStatus['full_line'])) {
501*96df7d3eSAtari911            // Check if crontab has >> redirect which will cause duplicate log entries
502*96df7d3eSAtari911            $hasRedirect = (strpos($cronStatus['full_line'], '>>') !== false || strpos($cronStatus['full_line'], '> ') !== false);
503*96df7d3eSAtari911
504*96df7d3eSAtari911            if ($hasRedirect) {
505*96df7d3eSAtari911                echo '<div style="background:#fff3e0; border-left:3px solid #ff9800; padding:8px; margin:8px 0; border-radius:3px;">';
506*96df7d3eSAtari911                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>';
507*96df7d3eSAtari911                echo '</div>';
508*96df7d3eSAtari911            }
509*96df7d3eSAtari911
5101d05cddcSAtari911            echo '<details style="margin-top:5px;">';
5111d05cddcSAtari911            echo '<summary style="cursor:pointer; color:#999; font-size:11px;">Show cron details</summary>';
5121d05cddcSAtari911            echo '<pre style="background:#f0f0f0; padding:8px; border-radius:3px; font-size:10px; margin:5px 0; overflow-x:auto;">' . hsc($cronStatus['full_line']) . '</pre>';
5131d05cddcSAtari911            echo '</details>';
5141d05cddcSAtari911        }
5151d05cddcSAtari911
5161d05cddcSAtari911        if (!$cronStatus['active']) {
5171d05cddcSAtari911            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>';
518*96df7d3eSAtari911            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>';
5191d05cddcSAtari911        }
5201d05cddcSAtari911
5211d05cddcSAtari911        echo '</div>';
5221d05cddcSAtari911
5231d05cddcSAtari911        // JavaScript for Run Sync Now
5241d05cddcSAtari911        echo '<script>
5251d05cddcSAtari911        let syncAbortController = null;
5261d05cddcSAtari911
5271d05cddcSAtari911        function runSyncNow() {
5281d05cddcSAtari911            const btn = document.getElementById("syncBtn");
5291d05cddcSAtari911            const stopBtn = document.getElementById("stopBtn");
5301d05cddcSAtari911            const status = document.getElementById("syncStatus");
5311d05cddcSAtari911
5321d05cddcSAtari911            btn.disabled = true;
5331d05cddcSAtari911            btn.style.display = "none";
5341d05cddcSAtari911            stopBtn.style.display = "inline-block";
5351d05cddcSAtari911            btn.textContent = "⏳ Running...";
5361d05cddcSAtari911            btn.style.background = "#999";
5371d05cddcSAtari911            status.textContent = "Starting sync...";
5381d05cddcSAtari911            status.style.color = "#00cc07";
5391d05cddcSAtari911
5401d05cddcSAtari911            // Create abort controller for this sync
5411d05cddcSAtari911            syncAbortController = new AbortController();
5421d05cddcSAtari911
5431d05cddcSAtari911            fetch("?do=admin&page=calendar&action=run_sync&call=ajax", {
5441d05cddcSAtari911                method: "POST",
5451d05cddcSAtari911                signal: syncAbortController.signal
5461d05cddcSAtari911            })
5471d05cddcSAtari911                .then(response => response.json())
5481d05cddcSAtari911                .then(data => {
5491d05cddcSAtari911                    if (data.success) {
5501d05cddcSAtari911                        status.textContent = "✅ " + data.message;
5511d05cddcSAtari911                        status.style.color = "#28a745";
5521d05cddcSAtari911                    } else {
5531d05cddcSAtari911                        status.textContent = "❌ " + data.message;
5541d05cddcSAtari911                        status.style.color = "#dc3545";
5551d05cddcSAtari911                    }
5561d05cddcSAtari911                    btn.disabled = false;
5571d05cddcSAtari911                    btn.style.display = "inline-block";
5581d05cddcSAtari911                    stopBtn.style.display = "none";
5591d05cddcSAtari911                    btn.textContent = "▶️ Run Sync Now";
5601d05cddcSAtari911                    btn.style.background = "#00cc07";
5611d05cddcSAtari911                    syncAbortController = null;
5621d05cddcSAtari911
5631d05cddcSAtari911                    // Clear status after 10 seconds
5641d05cddcSAtari911                    setTimeout(() => {
5651d05cddcSAtari911                        status.textContent = "";
5661d05cddcSAtari911                    }, 10000);
5671d05cddcSAtari911                })
5681d05cddcSAtari911                .catch(error => {
5691d05cddcSAtari911                    if (error.name === "AbortError") {
5701d05cddcSAtari911                        status.textContent = "⏹️ Sync stopped by user";
5711d05cddcSAtari911                        status.style.color = "#ff9800";
5721d05cddcSAtari911                    } else {
5731d05cddcSAtari911                        status.textContent = "❌ Error: " + error.message;
5741d05cddcSAtari911                        status.style.color = "#dc3545";
5751d05cddcSAtari911                    }
5761d05cddcSAtari911                    btn.disabled = false;
5771d05cddcSAtari911                    btn.style.display = "inline-block";
5781d05cddcSAtari911                    stopBtn.style.display = "none";
5791d05cddcSAtari911                    btn.textContent = "▶️ Run Sync Now";
5801d05cddcSAtari911                    btn.style.background = "#00cc07";
5811d05cddcSAtari911                    syncAbortController = null;
5821d05cddcSAtari911                });
5831d05cddcSAtari911        }
5841d05cddcSAtari911
5851d05cddcSAtari911        function stopSyncNow() {
5861d05cddcSAtari911            const status = document.getElementById("syncStatus");
5871d05cddcSAtari911
5881d05cddcSAtari911            status.textContent = "⏹️ Sending stop signal...";
5891d05cddcSAtari911            status.style.color = "#ff9800";
5901d05cddcSAtari911
5911d05cddcSAtari911            // First, send stop signal to server
5921d05cddcSAtari911            fetch("?do=admin&page=calendar&action=stop_sync&call=ajax", {
5931d05cddcSAtari911                method: "POST"
5941d05cddcSAtari911            })
5951d05cddcSAtari911            .then(response => response.json())
5961d05cddcSAtari911            .then(data => {
5971d05cddcSAtari911                if (data.success) {
5981d05cddcSAtari911                    status.textContent = "⏹️ Stop signal sent - sync will abort soon";
5991d05cddcSAtari911                    status.style.color = "#ff9800";
6001d05cddcSAtari911                } else {
6011d05cddcSAtari911                    status.textContent = "⚠️ " + data.message;
6021d05cddcSAtari911                    status.style.color = "#ff9800";
6031d05cddcSAtari911                }
6041d05cddcSAtari911            })
6051d05cddcSAtari911            .catch(error => {
6061d05cddcSAtari911                status.textContent = "⚠️ Error sending stop signal: " + error.message;
6071d05cddcSAtari911                status.style.color = "#ff9800";
6081d05cddcSAtari911            });
6091d05cddcSAtari911
6101d05cddcSAtari911            // Also abort the fetch request
6111d05cddcSAtari911            if (syncAbortController) {
6121d05cddcSAtari911                syncAbortController.abort();
6131d05cddcSAtari911                status.textContent = "⏹️ Stopping sync...";
6141d05cddcSAtari911                status.style.color = "#ff9800";
6151d05cddcSAtari911            }
6161d05cddcSAtari911        }
6171d05cddcSAtari911        </script>';
6181d05cddcSAtari911
6191d05cddcSAtari911        // Log Viewer Section - More Compact
6209ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0 10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">';
6211d05cddcSAtari911        echo '<h3 style="margin:0 0 5px 0; color:#00cc07; font-size:16px;">�� Live Sync Log</h3>';
622*96df7d3eSAtari911        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>';
6231d05cddcSAtari911
6241d05cddcSAtari911        // Log viewer container
6251d05cddcSAtari911        echo '<div style="background:#1e1e1e; border-radius:5px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.3);">';
6261d05cddcSAtari911
6271d05cddcSAtari911        // Log header - More compact
6281d05cddcSAtari911        echo '<div style="background:#2d2d2d; padding:6px 10px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #444;">';
6291d05cddcSAtari911        echo '<span style="color:#00cc07; font-family:monospace; font-weight:bold; font-size:12px;">sync.log</span>';
6301d05cddcSAtari911        echo '<div>';
6311d05cddcSAtari911        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>';
6321d05cddcSAtari911        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>';
6331d05cddcSAtari911        echo '<button onclick="downloadLog()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:3px; cursor:pointer; font-size:11px;">�� Download</button>';
6341d05cddcSAtari911        echo '</div>';
6351d05cddcSAtari911        echo '</div>';
6361d05cddcSAtari911
6371d05cddcSAtari911        // Log content - Reduced height to 250px
6381d05cddcSAtari911        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>';
6391d05cddcSAtari911
6401d05cddcSAtari911        echo '</div>';
6411d05cddcSAtari911        echo '</div>';
6421d05cddcSAtari911
6431d05cddcSAtari911        // JavaScript for log viewer
6441d05cddcSAtari911        echo '<script>
6451d05cddcSAtari911        let refreshInterval = null;
6461d05cddcSAtari911        let isPaused = false;
6471d05cddcSAtari911
6481d05cddcSAtari911        function refreshLog() {
6491d05cddcSAtari911            if (isPaused) return;
6501d05cddcSAtari911
6511d05cddcSAtari911            fetch("?do=admin&page=calendar&action=get_log&call=ajax")
6521d05cddcSAtari911                .then(response => response.json())
6531d05cddcSAtari911                .then(data => {
6541d05cddcSAtari911                    const logContent = document.getElementById("logContent");
6551d05cddcSAtari911                    if (logContent) {
6561d05cddcSAtari911                        logContent.textContent = data.log || "No log data available";
6571d05cddcSAtari911                        logContent.scrollTop = logContent.scrollHeight;
6581d05cddcSAtari911                    }
6591d05cddcSAtari911                })
6601d05cddcSAtari911                .catch(error => {
6611d05cddcSAtari911                    console.error("Error fetching log:", error);
6621d05cddcSAtari911                });
6631d05cddcSAtari911        }
6641d05cddcSAtari911
6651d05cddcSAtari911        function togglePause() {
6661d05cddcSAtari911            isPaused = !isPaused;
6671d05cddcSAtari911            const btn = document.getElementById("pauseBtn");
6681d05cddcSAtari911            if (isPaused) {
6691d05cddcSAtari911                btn.textContent = "▶ Resume";
6701d05cddcSAtari911                btn.style.background = "#00cc07";
6711d05cddcSAtari911            } else {
6721d05cddcSAtari911                btn.textContent = "⏸ Pause";
6731d05cddcSAtari911                btn.style.background = "#666";
6741d05cddcSAtari911                refreshLog();
6751d05cddcSAtari911            }
6761d05cddcSAtari911        }
6771d05cddcSAtari911
6781d05cddcSAtari911        function clearLog() {
6791d05cddcSAtari911            if (!confirm("Clear the sync log file?\\n\\nThis will delete all log entries.")) {
6801d05cddcSAtari911                return;
6811d05cddcSAtari911            }
6821d05cddcSAtari911
6831d05cddcSAtari911            fetch("?do=admin&page=calendar&action=clear_log&call=ajax", {
6841d05cddcSAtari911                method: "POST"
6851d05cddcSAtari911            })
6861d05cddcSAtari911                .then(response => response.json())
6871d05cddcSAtari911                .then(data => {
6881d05cddcSAtari911                    if (data.success) {
6891d05cddcSAtari911                        refreshLog();
6901d05cddcSAtari911                        alert("Log cleared successfully");
6911d05cddcSAtari911                    } else {
6921d05cddcSAtari911                        alert("Error clearing log: " + data.message);
6931d05cddcSAtari911                    }
6941d05cddcSAtari911                })
6951d05cddcSAtari911                .catch(error => {
6961d05cddcSAtari911                    alert("Error: " + error.message);
6971d05cddcSAtari911                });
6981d05cddcSAtari911        }
6991d05cddcSAtari911
7001d05cddcSAtari911        function downloadLog() {
7011d05cddcSAtari911            window.location.href = "?do=admin&page=calendar&action=download_log";
7021d05cddcSAtari911        }
7031d05cddcSAtari911
7041d05cddcSAtari911        // Start auto-refresh
7051d05cddcSAtari911        refreshLog();
7061d05cddcSAtari911        refreshInterval = setInterval(refreshLog, 2000);
7071d05cddcSAtari911
7081d05cddcSAtari911        // Cleanup on page unload
7091d05cddcSAtari911        window.addEventListener("beforeunload", function() {
7101d05cddcSAtari911            if (refreshInterval) {
7111d05cddcSAtari911                clearInterval(refreshInterval);
7121d05cddcSAtari911            }
7131d05cddcSAtari911        });
7141d05cddcSAtari911        </script>';
7151d05cddcSAtari911    }
7161d05cddcSAtari911
7179ccd446eSAtari911    private function renderManageTab($colors = null) {
7181d05cddcSAtari911        global $INPUT;
7191d05cddcSAtari911
7209ccd446eSAtari911        // Use defaults if not provided
7219ccd446eSAtari911        if ($colors === null) {
7229ccd446eSAtari911            $colors = $this->getTemplateColors();
7239ccd446eSAtari911        }
7249ccd446eSAtari911
7251d05cddcSAtari911        // Show message if present
7261d05cddcSAtari911        if ($INPUT->has('msg')) {
7271d05cddcSAtari911            $msg = hsc($INPUT->str('msg'));
7281d05cddcSAtari911            $type = $INPUT->str('msgtype', 'success');
7291d05cddcSAtari911            echo "<div style=\"padding:10px; margin:10px 0; border-left:3px solid " . ($type === 'success' ? '#28a745' : '#dc3545') . "; background:" . ($type === 'success' ? '#d4edda' : '#f8d7da') . "; border-radius:3px;\">";
7301d05cddcSAtari911            echo $msg;
7311d05cddcSAtari911            echo "</div>";
7321d05cddcSAtari911        }
7331d05cddcSAtari911
7341d05cddcSAtari911        echo '<h2 style="margin:10px 0; font-size:20px;">Manage Calendar Events</h2>';
7351d05cddcSAtari911
7369ccd446eSAtari911        // Events Manager Section
7379ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
7381d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Events Manager</h3>';
7399ccd446eSAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 10px;">Scan, export, and import all calendar events across all namespaces.</p>';
7401d05cddcSAtari911
7411d05cddcSAtari911        // Get event statistics
7421d05cddcSAtari911        $stats = $this->getEventStatistics();
7431d05cddcSAtari911
7441d05cddcSAtari911        // Statistics display
7459ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:10px; border-radius:3px; margin-bottom:10px; border:1px solid ' . $colors['border'] . ';">';
7461d05cddcSAtari911        echo '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:10px; font-size:12px;">';
7471d05cddcSAtari911
7481d05cddcSAtari911        echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">';
7491d05cddcSAtari911        echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_events'] . '</div>';
7509ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Total Events</div>';
7511d05cddcSAtari911        echo '</div>';
7521d05cddcSAtari911
7531d05cddcSAtari911        echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">';
7541d05cddcSAtari911        echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_namespaces'] . '</div>';
7559ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Namespaces</div>';
7561d05cddcSAtari911        echo '</div>';
7571d05cddcSAtari911
7581d05cddcSAtari911        echo '<div style="background:#e8f5e9; padding:8px; border-radius:3px; text-align:center;">';
7591d05cddcSAtari911        echo '<div style="font-size:24px; font-weight:bold; color:#388e3c;">' . $stats['total_files'] . '</div>';
7609ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">JSON Files</div>';
7611d05cddcSAtari911        echo '</div>';
7621d05cddcSAtari911
7631d05cddcSAtari911        echo '<div style="background:#fff3e0; padding:8px; border-radius:3px; text-align:center;">';
7641d05cddcSAtari911        echo '<div style="font-size:24px; font-weight:bold; color:#f57c00;">' . $stats['total_recurring'] . '</div>';
7659ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Recurring</div>';
7661d05cddcSAtari911        echo '</div>';
7671d05cddcSAtari911
7681d05cddcSAtari911        echo '</div>';
7691d05cddcSAtari911
7701d05cddcSAtari911        // Last scan time
7711d05cddcSAtari911        if (!empty($stats['last_scan'])) {
7729ccd446eSAtari911            echo '<div style="margin-top:8px; color:' . $colors['text'] . '; font-size:10px;">Last scanned: ' . hsc($stats['last_scan']) . '</div>';
7731d05cddcSAtari911        }
7741d05cddcSAtari911
7751d05cddcSAtari911        echo '</div>';
7761d05cddcSAtari911
7771d05cddcSAtari911        // Action buttons
7781d05cddcSAtari911        echo '<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:10px;">';
7791d05cddcSAtari911
7801d05cddcSAtari911        // Rescan button
7811d05cddcSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">';
7821d05cddcSAtari911        echo '<input type="hidden" name="action" value="rescan_events">';
7831d05cddcSAtari911        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;">';
7841d05cddcSAtari911        echo '<span>��</span><span>Re-scan Events</span>';
7851d05cddcSAtari911        echo '</button>';
7861d05cddcSAtari911        echo '</form>';
7871d05cddcSAtari911
7881d05cddcSAtari911        // Export button
7891d05cddcSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">';
7901d05cddcSAtari911        echo '<input type="hidden" name="action" value="export_all_events">';
7911d05cddcSAtari911        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;">';
7921d05cddcSAtari911        echo '<span>��</span><span>Export All Events</span>';
7931d05cddcSAtari911        echo '</button>';
7941d05cddcSAtari911        echo '</form>';
7951d05cddcSAtari911
7961d05cddcSAtari911        // Import button (with file upload)
7971d05cddcSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" enctype="multipart/form-data" style="display:inline;" onsubmit="return confirm(\'Import will merge with existing events. Continue?\')">';
7981d05cddcSAtari911        echo '<input type="hidden" name="action" value="import_all_events">';
7991d05cddcSAtari911        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;">';
8001d05cddcSAtari911        echo '<span>��</span><span>Import Events</span>';
8011d05cddcSAtari911        echo '<input type="file" name="import_file" accept=".json,.zip" required style="display:none;" onchange="this.form.submit()">';
8021d05cddcSAtari911        echo '</label>';
8031d05cddcSAtari911        echo '</form>';
8041d05cddcSAtari911
8051d05cddcSAtari911        echo '</div>';
8061d05cddcSAtari911
8071d05cddcSAtari911        // Breakdown by namespace
8081d05cddcSAtari911        if (!empty($stats['by_namespace'])) {
8091d05cddcSAtari911            echo '<details style="margin-top:12px;">';
8101d05cddcSAtari911            echo '<summary style="cursor:pointer; padding:6px; background:#e9e9e9; border-radius:3px; font-size:11px; font-weight:bold;">View Breakdown by Namespace</summary>';
8119ccd446eSAtari911            echo '<div style="margin-top:8px; max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">';
8121d05cddcSAtari911            echo '<table style="width:100%; border-collapse:collapse; font-size:11px;">';
8131d05cddcSAtari911            echo '<thead style="position:sticky; top:0; background:#f5f5f5;">';
8141d05cddcSAtari911            echo '<tr>';
8151d05cddcSAtari911            echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">Namespace</th>';
8161d05cddcSAtari911            echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">Events</th>';
8171d05cddcSAtari911            echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">Files</th>';
8181d05cddcSAtari911            echo '</tr></thead><tbody>';
8191d05cddcSAtari911
8201d05cddcSAtari911            foreach ($stats['by_namespace'] as $ns => $nsStats) {
8211d05cddcSAtari911                echo '<tr style="border-bottom:1px solid #eee;">';
8221d05cddcSAtari911                echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($ns ?: '(default)') . '</code></td>';
8231d05cddcSAtari911                echo '<td style="padding:4px 6px; text-align:right;"><strong>' . $nsStats['events'] . '</strong></td>';
8241d05cddcSAtari911                echo '<td style="padding:4px 6px; text-align:right;">' . $nsStats['files'] . '</td>';
8251d05cddcSAtari911                echo '</tr>';
8261d05cddcSAtari911            }
8271d05cddcSAtari911
8281d05cddcSAtari911            echo '</tbody></table>';
8291d05cddcSAtari911            echo '</div>';
8301d05cddcSAtari911            echo '</details>';
8311d05cddcSAtari911        }
8321d05cddcSAtari911
8331d05cddcSAtari911        echo '</div>';
8341d05cddcSAtari911
8354590242dSAtari911        // Important Namespaces Section
8364590242dSAtari911        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
8374590242dSAtari911        $importantConfig = [];
8384590242dSAtari911        if (file_exists($configFile)) {
8394590242dSAtari911            $importantConfig = include $configFile;
8404590242dSAtari911        }
8414590242dSAtari911        $importantNsValue = isset($importantConfig['important_namespaces']) ? $importantConfig['important_namespaces'] : 'important';
8424590242dSAtari911
8434590242dSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
844*96df7d3eSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">⭐ Important Namespaces</h3>';
845*96df7d3eSAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">Events from these namespaces will be visually highlighted throughout the calendar:</p>';
846*96df7d3eSAtari911
847*96df7d3eSAtari911        // Effects description
848*96df7d3eSAtari911        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'] . ';">';
849*96df7d3eSAtari911        echo '<strong style="color:#00cc07;">Visual Effects:</strong><br>';
850*96df7d3eSAtari911        echo '• <strong>Calendar Grid:</strong> ⭐ star icon on event bars<br>';
851*96df7d3eSAtari911        echo '• <strong>Event Sidebar:</strong> ⭐ star + highlighted background + accent border<br>';
852*96df7d3eSAtari911        echo '• <strong>Sidebar Widget:</strong> Dedicated "Important Events" section + highlighting<br>';
853*96df7d3eSAtari911        echo '• <strong>Day Popup:</strong> Events shown with full details';
854*96df7d3eSAtari911        echo '</div>';
855*96df7d3eSAtari911
8564590242dSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:flex; gap:8px; align-items:center;">';
8574590242dSAtari911        echo '<input type="hidden" name="action" value="save_important_namespaces">';
8584590242dSAtari911        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">';
8594590242dSAtari911        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;">Save</button>';
8604590242dSAtari911        echo '</form>';
861*96df7d3eSAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:4px 0 0;">Comma-separated list of namespace names (e.g., "important,urgent,bills")</p>';
8624590242dSAtari911        echo '</div>';
8634590242dSAtari911
8649ccd446eSAtari911        // Cleanup Events Section
8659ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
8669ccd446eSAtari911        echo '<h3 style="margin:0 0 6px 0; color:#00cc07; font-size:16px;">�� Cleanup Old Events</h3>';
8679ccd446eSAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 12px;">Delete events based on criteria below. Automatic backup created before deletion.</p>';
8681d05cddcSAtari911
8691d05cddcSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="cleanupForm">';
8701d05cddcSAtari911        echo '<input type="hidden" name="action" value="cleanup_events">';
8711d05cddcSAtari911
8721d05cddcSAtari911        // Compact options layout
8739ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; margin-bottom:10px;">';
8741d05cddcSAtari911
8751d05cddcSAtari911        // Radio buttons in a row
8761d05cddcSAtari911        echo '<div style="display:flex; gap:20px; margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid #f0f0f0;">';
8771d05cddcSAtari911        echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">';
8781d05cddcSAtari911        echo '<input type="radio" name="cleanup_type" value="age" checked onchange="updateCleanupOptions()">';
8791d05cddcSAtari911        echo '<span>By Age</span>';
8801d05cddcSAtari911        echo '</label>';
8811d05cddcSAtari911        echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">';
8821d05cddcSAtari911        echo '<input type="radio" name="cleanup_type" value="status" onchange="updateCleanupOptions()">';
8831d05cddcSAtari911        echo '<span>By Status</span>';
8841d05cddcSAtari911        echo '</label>';
8851d05cddcSAtari911        echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">';
8861d05cddcSAtari911        echo '<input type="radio" name="cleanup_type" value="range" onchange="updateCleanupOptions()">';
8871d05cddcSAtari911        echo '<span>By Date Range</span>';
8881d05cddcSAtari911        echo '</label>';
8891d05cddcSAtari911        echo '</div>';
8901d05cddcSAtari911
8911d05cddcSAtari911        // Age options
8921d05cddcSAtari911        echo '<div id="age-options" style="padding:6px 0;">';
8939ccd446eSAtari911        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">Delete events older than:</span>';
8941d05cddcSAtari911        echo '<select name="age_value" style="width:50px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:4px;">';
8951d05cddcSAtari911        for ($i = 1; $i <= 24; $i++) {
8961d05cddcSAtari911            $sel = $i === 6 ? ' selected' : '';
8971d05cddcSAtari911            echo '<option value="' . $i . '"' . $sel . '>' . $i . '</option>';
8981d05cddcSAtari911        }
8991d05cddcSAtari911        echo '</select>';
9001d05cddcSAtari911        echo '<select name="age_unit" style="width:80px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">';
9011d05cddcSAtari911        echo '<option value="months" selected>months</option>';
9021d05cddcSAtari911        echo '<option value="years">years</option>';
9031d05cddcSAtari911        echo '</select>';
9041d05cddcSAtari911        echo '</div>';
9051d05cddcSAtari911
9061d05cddcSAtari911        // Status options
9071d05cddcSAtari911        echo '<div id="status-options" style="padding:6px 0; opacity:0.4;">';
9089ccd446eSAtari911        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">Delete:</span>';
9091d05cddcSAtari911        echo '<label style="display:inline-block; font-size:11px; margin-right:12px; cursor:pointer;"><input type="checkbox" name="delete_completed" value="1" style="margin-right:3px;"> Completed tasks</label>';
9101d05cddcSAtari911        echo '<label style="display:inline-block; font-size:11px; cursor:pointer;"><input type="checkbox" name="delete_past" value="1" style="margin-right:3px;"> Past events</label>';
9111d05cddcSAtari911        echo '</div>';
9121d05cddcSAtari911
9131d05cddcSAtari911        // Range options
9141d05cddcSAtari911        echo '<div id="range-options" style="padding:6px 0; opacity:0.4;">';
9159ccd446eSAtari911        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">From:</span>';
9161d05cddcSAtari911        echo '<input type="date" name="range_start" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:10px;">';
9179ccd446eSAtari911        echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">To:</span>';
9181d05cddcSAtari911        echo '<input type="date" name="range_end" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">';
9191d05cddcSAtari911        echo '</div>';
9201d05cddcSAtari911
9211d05cddcSAtari911        echo '</div>';
9221d05cddcSAtari911
9231d05cddcSAtari911        // Namespace filter - compact
9249ccd446eSAtari911        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;">';
9251d05cddcSAtari911        echo '<label style="font-size:11px; font-weight:600; white-space:nowrap; color:#555;">Namespace:</label>';
9261d05cddcSAtari911        echo '<input type="text" name="namespace_filter" placeholder="Leave empty for all, or specify: work, personal, etc." style="flex:1; padding:4px 8px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">';
9271d05cddcSAtari911        echo '</div>';
9281d05cddcSAtari911
9291d05cddcSAtari911        // Action buttons - compact row
9301d05cddcSAtari911        echo '<div style="display:flex; gap:8px; align-items:center;">';
9311d05cddcSAtari911        echo '<button type="button" onclick="previewCleanup()" style="background:#7b1fa2; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">��️ Preview</button>';
9321d05cddcSAtari911        echo '<button type="submit" onclick="return confirmCleanup()" style="background:#dc3545; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">��️ Delete</button>';
9331d05cddcSAtari911        echo '<span style="font-size:10px; color:#999;">⚠️ Backup created automatically</span>';
9341d05cddcSAtari911        echo '</div>';
9351d05cddcSAtari911
9361d05cddcSAtari911        echo '</form>';
9371d05cddcSAtari911
9381d05cddcSAtari911        // Preview results area
9391d05cddcSAtari911        echo '<div id="cleanup-preview" style="margin-top:10px; display:none;"></div>';
9401d05cddcSAtari911
9411d05cddcSAtari911        echo '<script>
9421d05cddcSAtari911        function updateCleanupOptions() {
9431d05cddcSAtari911            const type = document.querySelector(\'input[name="cleanup_type"]:checked\').value;
9441d05cddcSAtari911
9451d05cddcSAtari911            // Show selected, gray out others
9461d05cddcSAtari911            document.getElementById(\'age-options\').style.opacity = type === \'age\' ? \'1\' : \'0.4\';
9471d05cddcSAtari911            document.getElementById(\'status-options\').style.opacity = type === \'status\' ? \'1\' : \'0.4\';
9481d05cddcSAtari911            document.getElementById(\'range-options\').style.opacity = type === \'range\' ? \'1\' : \'0.4\';
9491d05cddcSAtari911
9501d05cddcSAtari911            // Enable/disable inputs
9511d05cddcSAtari911            document.querySelectorAll(\'#age-options select\').forEach(el => el.disabled = type !== \'age\');
9521d05cddcSAtari911            document.querySelectorAll(\'#status-options input\').forEach(el => el.disabled = type !== \'status\');
9531d05cddcSAtari911            document.querySelectorAll(\'#range-options input\').forEach(el => el.disabled = type !== \'range\');
9541d05cddcSAtari911        }
9551d05cddcSAtari911
9561d05cddcSAtari911        function previewCleanup() {
9571d05cddcSAtari911            const form = document.getElementById(\'cleanupForm\');
9581d05cddcSAtari911            const formData = new FormData(form);
9591d05cddcSAtari911            formData.set(\'action\', \'preview_cleanup\');
9601d05cddcSAtari911
9611d05cddcSAtari911            const preview = document.getElementById(\'cleanup-preview\');
9629ccd446eSAtari911            preview.innerHTML = \'<div style="text-align:center; padding:20px; color:' . $colors['text'] . ';">Loading preview...</div>\';
9631d05cddcSAtari911            preview.style.display = \'block\';
9641d05cddcSAtari911
9651d05cddcSAtari911            fetch(\'?do=admin&page=calendar&tab=manage\', {
9661d05cddcSAtari911                method: \'POST\',
9671d05cddcSAtari911                body: new URLSearchParams(formData)
9681d05cddcSAtari911            })
9691d05cddcSAtari911            .then(r => r.json())
9701d05cddcSAtari911            .then(data => {
9711d05cddcSAtari911                if (data.count === 0) {
9721d05cddcSAtari911                    let html = \'<div style="background:#d4edda; border:1px solid #c3e6cb; padding:10px; border-radius:3px; font-size:12px; color:#155724;">✅ No events match the criteria. Nothing would be deleted.</div>\';
9731d05cddcSAtari911
9741d05cddcSAtari911                    // Show debug info if available
9751d05cddcSAtari911                    if (data.debug) {
9769ccd446eSAtari911                        html += \'<details style="margin-top:8px; font-size:11px; color:' . $colors['text'] . ';">\';
9771d05cddcSAtari911                        html += \'<summary style="cursor:pointer;">Debug Info</summary>\';
9781d05cddcSAtari911                        html += \'<pre style="background:#f5f5f5; padding:6px; margin-top:4px; border-radius:3px; overflow-x:auto;">\' + JSON.stringify(data.debug, null, 2) + \'</pre>\';
9791d05cddcSAtari911                        html += \'</details>\';
9801d05cddcSAtari911                    }
9811d05cddcSAtari911
9821d05cddcSAtari911                    preview.innerHTML = html;
9831d05cddcSAtari911                } else {
9841d05cddcSAtari911                    let html = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">\';
9851d05cddcSAtari911                    html += \'<strong>⚠️ Warning:</strong> The following \' + data.count + \' event(s) would be deleted:<br><br>\';
9869ccd446eSAtari911                    html += \'<div style="max-height:150px; overflow-y:auto; margin-top:6px; background:' . $colors['bg'] . '; padding:6px; border-radius:3px;">\';
9871d05cddcSAtari911                    data.events.forEach(evt => {
9881d05cddcSAtari911                        html += \'<div style="padding:3px; border-bottom:1px solid #eee; font-size:11px;">\';
9891d05cddcSAtari911                        html += \'\' + evt.title + \' (\' + evt.date + \')\';
9901d05cddcSAtari911                        if (evt.namespace) html += \' <span style="background:#e3f2fd; padding:1px 4px; border-radius:2px; font-size:9px;">\' + evt.namespace + \'</span>\';
9911d05cddcSAtari911                        html += \'</div>\';
9921d05cddcSAtari911                    });
9931d05cddcSAtari911                    html += \'</div></div>\';
9941d05cddcSAtari911                    preview.innerHTML = html;
9951d05cddcSAtari911                }
9961d05cddcSAtari911            })
9971d05cddcSAtari911            .catch(err => {
9981d05cddcSAtari911                preview.innerHTML = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">Error loading preview</div>\';
9991d05cddcSAtari911            });
10001d05cddcSAtari911        }
10011d05cddcSAtari911
10021d05cddcSAtari911        function confirmCleanup() {
10031d05cddcSAtari911            return confirm(\'Are you sure you want to delete these events? A backup will be created first, but this action cannot be easily undone.\');
10041d05cddcSAtari911        }
10051d05cddcSAtari911
10061d05cddcSAtari911        updateCleanupOptions();
10071d05cddcSAtari911        </script>';
10081d05cddcSAtari911
10091d05cddcSAtari911        echo '</div>';
10101d05cddcSAtari911
10111d05cddcSAtari911        // Recurring Events Section
10127e8ea635SAtari911        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;">';
10137e8ea635SAtari911        echo '<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">';
10147e8ea635SAtari911        echo '<h3 style="margin:0; color:#00cc07; font-size:16px;">�� Recurring Events</h3>';
10157e8ea635SAtari911        echo '<div style="display:flex; gap:6px;">';
10167e8ea635SAtari911        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\'">✂️ Trim All Past</button>';
10177e8ea635SAtari911        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\'">�� Rescan</button>';
10187e8ea635SAtari911        echo '</div>';
10197e8ea635SAtari911        echo '</div>';
10201d05cddcSAtari911
10211d05cddcSAtari911        $recurringEvents = $this->findRecurringEvents();
10221d05cddcSAtari911
10237e8ea635SAtari911        echo '<div id="recurring-content">';
10247e8ea635SAtari911        $this->renderRecurringTable($recurringEvents, $colors);
10251d05cddcSAtari911        echo '</div>';
10261d05cddcSAtari911        echo '</div>';
10271d05cddcSAtari911
10281d05cddcSAtari911        // Compact Tree-based Namespace Manager
10299ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
10301d05cddcSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Namespace Explorer</h3>';
10319ccd446eSAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">Select events and move between namespaces. Drag & drop also supported.</p>';
10321d05cddcSAtari911
10331d05cddcSAtari911        // Search bar
10341d05cddcSAtari911        echo '<div style="margin-bottom:8px;">';
10359ccd446eSAtari911        echo '<input type="text" id="searchEvents" onkeyup="filterEvents()" placeholder="�� Search events by title..." style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">';
10361d05cddcSAtari911        echo '</div>';
10371d05cddcSAtari911
10381d05cddcSAtari911        $eventsByNamespace = $this->getEventsByNamespace();
10391d05cddcSAtari911
10401d05cddcSAtari911        // Control bar
10411d05cddcSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="moveForm">';
10421d05cddcSAtari911        echo '<input type="hidden" name="action" value="move_selected_events" id="formAction">';
10431d05cddcSAtari911        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;">';
10441d05cddcSAtari911        echo '<button type="button" onclick="selectAll()" style="background:#00cc07; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☑ All</button>';
10451d05cddcSAtari911        echo '<button type="button" onclick="deselectAll()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☐ None</button>';
10461d05cddcSAtari911        echo '<button type="button" onclick="deleteSelected()" style="background:#e74c3c; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px; margin-left:10px;">��️ Delete</button>';
10471d05cddcSAtari911        echo '<span style="margin-left:10px;">Move to:</span>';
10489ccd446eSAtari911        echo '<input list="namespaceList" name="target_namespace" required style="padding:3px 6px; border:1px solid ' . $colors['border'] . '; border-radius:2px; font-size:11px; min-width:150px;" placeholder="Type or select...">';
10491d05cddcSAtari911        echo '<datalist id="namespaceList">';
10501d05cddcSAtari911        echo '<option value="">(default)</option>';
10511d05cddcSAtari911        foreach (array_keys($eventsByNamespace) as $ns) {
10521d05cddcSAtari911            if ($ns !== '') {
10531d05cddcSAtari911                echo '<option value="' . hsc($ns) . '">' . hsc($ns) . '</option>';
10541d05cddcSAtari911            }
10551d05cddcSAtari911        }
10561d05cddcSAtari911        echo '</datalist>';
10571d05cddcSAtari911        echo '<button type="submit" style="background:#00cc07; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold;">➡️ Move</button>';
10581d05cddcSAtari911        echo '<button type="button" onclick="createNewNamespace()" style="background:#7b1fa2; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold; margin-left:5px;">➕ New Namespace</button>';
10597e8ea635SAtari911        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;">�� Cleanup</button>';
10601d05cddcSAtari911        echo '<span id="selectedCount" style="margin-left:auto; color:#00cc07; font-size:11px;">0 selected</span>';
10611d05cddcSAtari911        echo '</div>';
10621d05cddcSAtari911
10637e8ea635SAtari911        // Cleanup status message - displayed prominently after control bar
10647e8ea635SAtari911        echo '<div id="cleanup-ns-status" style="font-size:12px; margin-bottom:8px; min-height:18px;"></div>';
10657e8ea635SAtari911
10661d05cddcSAtari911        echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">';
10671d05cddcSAtari911
10681d05cddcSAtari911        // Event list with checkboxes
10691d05cddcSAtari911        echo '<div>';
10709ccd446eSAtari911        echo '<div style="max-height:450px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">';
10711d05cddcSAtari911
10721d05cddcSAtari911        foreach ($eventsByNamespace as $namespace => $data) {
10731d05cddcSAtari911            $nsId = 'ns_' . md5($namespace);
1074*96df7d3eSAtari911            $events = isset($data['events']) && is_array($data['events']) ? $data['events'] : [];
1075*96df7d3eSAtari911            $eventCount = count($events);
10761d05cddcSAtari911
10771d05cddcSAtari911            echo '<div style="border-bottom:1px solid #ddd;">';
10781d05cddcSAtari911
10791d05cddcSAtari911            // Namespace header - ultra compact
10801d05cddcSAtari911            echo '<div style="background:#f5f5f5; padding:3px 6px; display:flex; justify-content:space-between; align-items:center; font-size:11px;">';
10811d05cddcSAtari911            echo '<div style="display:flex; align-items:center; gap:4px;">';
10821d05cddcSAtari911            echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; width:12px; display:inline-block; font-size:10px;"><span id="' . $nsId . '_arrow">▶</span></span>';
10831d05cddcSAtari911            echo '<input type="checkbox" onclick="toggleNamespaceSelect(\'' . $nsId . '\')" id="' . $nsId . '_check" style="margin:0; width:12px; height:12px;">';
10841d05cddcSAtari911            echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; font-weight:600; font-size:11px;">�� ' . hsc($namespace ?: '(default)') . '</span>';
10851d05cddcSAtari911            echo '</div>';
10861d05cddcSAtari911            echo '<div style="display:flex; gap:3px; align-items:center;">';
10871d05cddcSAtari911            echo '<span style="background:#00cc07; color:white; padding:0px 4px; border-radius:6px; font-size:9px; line-height:14px;">' . $eventCount . '</span>';
10889ccd446eSAtari911            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>';
10891d05cddcSAtari911            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>';
10901d05cddcSAtari911            echo '</div>';
10911d05cddcSAtari911            echo '</div>';
10921d05cddcSAtari911
10931d05cddcSAtari911            // Events - ultra compact
10941d05cddcSAtari911            echo '<div id="' . $nsId . '" style="display:none; max-height:150px; overflow-y:auto;">';
1095*96df7d3eSAtari911            foreach ($events as $event) {
10961d05cddcSAtari911                $eventId = $event['id'] . '|' . $namespace . '|' . $event['date'] . '|' . $event['month'];
10971d05cddcSAtari911                $checkId = 'evt_' . md5($eventId);
10981d05cddcSAtari911
10991d05cddcSAtari911                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\'">';
11001d05cddcSAtari911                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;">';
11011d05cddcSAtari911                echo '<div style="flex:1; min-width:0;">';
11021d05cddcSAtari911                echo '<div style="font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:10px;">' . hsc($event['title']) . '</div>';
11031d05cddcSAtari911                echo '<div style="color:#999; font-size:9px;">' . hsc($event['date']) . ($event['startTime'] ? ' • ' . hsc($event['startTime']) : '') . '</div>';
11041d05cddcSAtari911                echo '</div>';
11051d05cddcSAtari911                echo '</div>';
11061d05cddcSAtari911            }
11071d05cddcSAtari911            echo '</div>';
11081d05cddcSAtari911            echo '</div>';
11091d05cddcSAtari911        }
11101d05cddcSAtari911
11111d05cddcSAtari911        echo '</div>';
11121d05cddcSAtari911        echo '</div>';
11131d05cddcSAtari911
11141d05cddcSAtari911        // Drop zones - ultra compact
11151d05cddcSAtari911        echo '<div>';
11161d05cddcSAtari911        echo '<div style="background:#00cc07; color:white; padding:3px 6px; border-radius:3px 3px 0 0; font-size:11px; font-weight:bold;">�� Drop Target</div>';
11179ccd446eSAtari911        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'] . ';">';
11181d05cddcSAtari911
11191d05cddcSAtari911        foreach (array_keys($eventsByNamespace) as $namespace) {
11209ccd446eSAtari911            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\'">';
11211d05cddcSAtari911            echo '<div style="font-size:11px; font-weight:600; color:#00cc07;">�� ' . hsc($namespace ?: '(default)') . '</div>';
11221d05cddcSAtari911            echo '<div style="color:#999; font-size:9px; margin-top:1px;">Drop here</div>';
11231d05cddcSAtari911            echo '</div>';
11241d05cddcSAtari911        }
11251d05cddcSAtari911
11261d05cddcSAtari911        echo '</div>';
11271d05cddcSAtari911        echo '</div>';
11281d05cddcSAtari911
11291d05cddcSAtari911        echo '</div>'; // end grid
11301d05cddcSAtari911        echo '</form>';
11311d05cddcSAtari911
11321d05cddcSAtari911        echo '</div>';
11331d05cddcSAtari911
11341d05cddcSAtari911        // JavaScript
11351d05cddcSAtari911        echo '<script>
11367e8ea635SAtari911        var adminColors = {
11377e8ea635SAtari911            text: "' . $colors['text'] . '",
11387e8ea635SAtari911            bg: "' . $colors['bg'] . '",
11397e8ea635SAtari911            border: "' . $colors['border'] . '"
11407e8ea635SAtari911        };
11411d05cddcSAtari911        // Table sorting functionality - defined early so onclick handlers work
11421d05cddcSAtari911        let sortDirection = {}; // Track sort direction for each column
11431d05cddcSAtari911
11447e8ea635SAtari911        function cleanupEmptyNamespaces() {
11457e8ea635SAtari911            var btn = document.getElementById("cleanup-ns-btn");
11467e8ea635SAtari911            var status = document.getElementById("cleanup-ns-status");
11477e8ea635SAtari911            if (btn) { btn.textContent = "⏳ Scanning..."; btn.disabled = true; }
11487e8ea635SAtari911            if (status) { status.innerHTML = ""; }
11497e8ea635SAtari911
11507e8ea635SAtari911            // Dry run first
11517e8ea635SAtari911            fetch(DOKU_BASE + "lib/exe/ajax.php", {
11527e8ea635SAtari911                method: "POST",
11537e8ea635SAtari911                headers: {"Content-Type": "application/x-www-form-urlencoded"},
11547e8ea635SAtari911                body: "call=plugin_calendar&action=cleanup_empty_namespaces&dry_run=1&sectok=" + JSINFO.sectok
11557e8ea635SAtari911            })
11567e8ea635SAtari911            .then(function(r) { return r.json(); })
11577e8ea635SAtari911            .then(function(data) {
11587e8ea635SAtari911                if (btn) { btn.textContent = "�� Cleanup"; btn.disabled = false; }
11597e8ea635SAtari911                if (!data.success) {
11607e8ea635SAtari911                    if (status) { status.innerHTML = "<span style=\\\'color:#e74c3c;\\\'>❌ " + (data.error || "Failed") + "</span>"; }
11617e8ea635SAtari911                    return;
11627e8ea635SAtari911                }
11637e8ea635SAtari911
11647e8ea635SAtari911                var details = data.details || [];
11657e8ea635SAtari911                var totalActions = details.length;
11667e8ea635SAtari911
11677e8ea635SAtari911                if (totalActions === 0) {
11687e8ea635SAtari911                    if (status) { status.innerHTML = "<span style=\\\'color:#00cc07;\\\'>✅ No empty namespaces or orphan calendar folders found.</span>"; }
11697e8ea635SAtari911                    return;
11707e8ea635SAtari911                }
11717e8ea635SAtari911
11727e8ea635SAtari911                // Build detail list for confirm
11737e8ea635SAtari911                var msg = "Found " + totalActions + " item(s) to clean up:\\n\\n";
11747e8ea635SAtari911                for (var i = 0; i < details.length; i++) {
11757e8ea635SAtari911                    msg += "• " + details[i] + "\\n";
11767e8ea635SAtari911                }
11777e8ea635SAtari911                msg += "\\nProceed with cleanup?";
11787e8ea635SAtari911
11797e8ea635SAtari911                if (!confirm(msg)) return;
11807e8ea635SAtari911
11817e8ea635SAtari911                // Execute
11827e8ea635SAtari911                if (btn) { btn.textContent = "⏳ Cleaning..."; btn.disabled = true; }
11837e8ea635SAtari911                fetch(DOKU_BASE + "lib/exe/ajax.php", {
11847e8ea635SAtari911                    method: "POST",
11857e8ea635SAtari911                    headers: {"Content-Type": "application/x-www-form-urlencoded"},
11867e8ea635SAtari911                    body: "call=plugin_calendar&action=cleanup_empty_namespaces&sectok=" + JSINFO.sectok
11877e8ea635SAtari911                })
11887e8ea635SAtari911                .then(function(r) { return r.json(); })
11897e8ea635SAtari911                .then(function(data2) {
11907e8ea635SAtari911                    var msgText = data2.message || "Cleanup complete";
11917e8ea635SAtari911                    if (data2.details && data2.details.length > 0) {
11927e8ea635SAtari911                        msgText += " (" + data2.details.join(", ") + ")";
11937e8ea635SAtari911                    }
11947e8ea635SAtari911                    window.location.href = "?do=admin&page=calendar&tab=manage&msg=" + encodeURIComponent(msgText) + "&msgtype=success";
11957e8ea635SAtari911                });
11967e8ea635SAtari911            })
11977e8ea635SAtari911            .catch(function(err) {
11987e8ea635SAtari911                if (btn) { btn.textContent = "�� Cleanup"; btn.disabled = false; }
11997e8ea635SAtari911                if (status) { status.innerHTML = "<span style=\\\'color:#e74c3c;\\\'>❌ Error: " + err + "</span>"; }
12007e8ea635SAtari911            });
12017e8ea635SAtari911        }
12027e8ea635SAtari911        function trimAllPastRecurring() {
12037e8ea635SAtari911            var btn = document.getElementById("trim-all-past-btn");
12047e8ea635SAtari911            if (btn) { btn.textContent = "⏳ Counting..."; btn.disabled = true; }
12057e8ea635SAtari911
12067e8ea635SAtari911            // Step 1: dry run to get count
12077e8ea635SAtari911            fetch(DOKU_BASE + "lib/exe/ajax.php", {
12087e8ea635SAtari911                method: "POST",
12097e8ea635SAtari911                headers: {"Content-Type": "application/x-www-form-urlencoded"},
12107e8ea635SAtari911                body: "call=plugin_calendar&action=trim_all_past_recurring&dry_run=1&sectok=" + JSINFO.sectok
12117e8ea635SAtari911            })
12127e8ea635SAtari911            .then(function(r) { return r.json(); })
12137e8ea635SAtari911            .then(function(data) {
12147e8ea635SAtari911                if (btn) { btn.textContent = "✂️ Trim All Past"; btn.disabled = false; }
12157e8ea635SAtari911                var count = data.count || 0;
12167e8ea635SAtari911                if (count === 0) {
12177e8ea635SAtari911                    alert("No past recurring events found to remove.");
12187e8ea635SAtari911                    return;
12197e8ea635SAtari911                }
12207e8ea635SAtari911                if (!confirm("Found " + count + " past recurring event" + (count !== 1 ? "s" : "") + " to remove.\n\nThis cannot be undone. Proceed?")) return;
12217e8ea635SAtari911
12227e8ea635SAtari911                // Step 2: actually delete
12237e8ea635SAtari911                if (btn) { btn.textContent = "⏳ Trimming..."; btn.disabled = true; }
12247e8ea635SAtari911                fetch(DOKU_BASE + "lib/exe/ajax.php", {
12257e8ea635SAtari911                    method: "POST",
12267e8ea635SAtari911                    headers: {"Content-Type": "application/x-www-form-urlencoded"},
12277e8ea635SAtari911                    body: "call=plugin_calendar&action=trim_all_past_recurring&sectok=" + JSINFO.sectok
12287e8ea635SAtari911                })
12297e8ea635SAtari911                .then(function(r) { return r.json(); })
12307e8ea635SAtari911                .then(function(data2) {
12317e8ea635SAtari911                    if (btn) {
12327e8ea635SAtari911                        btn.textContent = data2.success ? ("✅ Removed " + (data2.count || 0)) : "❌ Failed";
12337e8ea635SAtari911                        btn.disabled = false;
12347e8ea635SAtari911                    }
12357e8ea635SAtari911                    setTimeout(function() { if (btn) btn.textContent = "✂️ Trim All Past"; }, 3000);
12367e8ea635SAtari911                    rescanRecurringEvents();
12377e8ea635SAtari911                });
12387e8ea635SAtari911            })
12397e8ea635SAtari911            .catch(function(err) {
12407e8ea635SAtari911                if (btn) { btn.textContent = "✂️ Trim All Past"; btn.disabled = false; }
12417e8ea635SAtari911            });
12427e8ea635SAtari911        }
12437e8ea635SAtari911
12447e8ea635SAtari911        function rescanRecurringEvents() {
12457e8ea635SAtari911            var btn = document.getElementById("rescan-recurring-btn");
12467e8ea635SAtari911            var content = document.getElementById("recurring-content");
12477e8ea635SAtari911            if (btn) { btn.textContent = "⏳ Scanning..."; btn.disabled = true; }
12487e8ea635SAtari911
12497e8ea635SAtari911            fetch(DOKU_BASE + "lib/exe/ajax.php", {
12507e8ea635SAtari911                method: "POST",
12517e8ea635SAtari911                headers: {"Content-Type": "application/x-www-form-urlencoded"},
12527e8ea635SAtari911                body: "call=plugin_calendar&action=rescan_recurring&sectok=" + JSINFO.sectok
12537e8ea635SAtari911            })
12547e8ea635SAtari911            .then(function(r) { return r.json(); })
12557e8ea635SAtari911            .then(function(data) {
12567e8ea635SAtari911                if (data.success && content) {
12577e8ea635SAtari911                    content.innerHTML = data.html;
12587e8ea635SAtari911                }
12597e8ea635SAtari911                if (btn) { btn.textContent = "�� Rescan (" + (data.count || 0) + " found)"; btn.disabled = false; }
12607e8ea635SAtari911                setTimeout(function() { if (btn) btn.textContent = "�� Rescan"; }, 3000);
12617e8ea635SAtari911            })
12627e8ea635SAtari911            .catch(function(err) {
12637e8ea635SAtari911                if (btn) { btn.textContent = "�� Rescan"; btn.disabled = false; }
12647e8ea635SAtari911                console.error("Rescan failed:", err);
12657e8ea635SAtari911            });
12667e8ea635SAtari911        }
12677e8ea635SAtari911
12687e8ea635SAtari911        function recurringAction(action, params, statusEl) {
12697e8ea635SAtari911            if (statusEl) statusEl.textContent = "⏳ Working...";
12707e8ea635SAtari911            var body = "call=plugin_calendar&action=" + action + "&sectok=" + JSINFO.sectok;
12717e8ea635SAtari911            for (var key in params) {
12727e8ea635SAtari911                body += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
12737e8ea635SAtari911            }
12747e8ea635SAtari911            return fetch(DOKU_BASE + "lib/exe/ajax.php", {
12757e8ea635SAtari911                method: "POST",
12767e8ea635SAtari911                headers: {"Content-Type": "application/x-www-form-urlencoded"},
12777e8ea635SAtari911                body: body
12787e8ea635SAtari911            })
12797e8ea635SAtari911            .then(function(r) { return r.json(); })
12807e8ea635SAtari911            .then(function(data) {
12817e8ea635SAtari911                if (statusEl) {
12827e8ea635SAtari911                    statusEl.textContent = data.success ? ("✅ " + data.message) : ("❌ " + (data.error || "Failed"));
12837e8ea635SAtari911                    statusEl.style.color = data.success ? "#00cc07" : "#e74c3c";
12847e8ea635SAtari911                }
12857e8ea635SAtari911                return data;
12867e8ea635SAtari911            })
12877e8ea635SAtari911            .catch(function(err) {
12887e8ea635SAtari911                if (statusEl) { statusEl.textContent = "❌ Error: " + err; statusEl.style.color = "#e74c3c"; }
12897e8ea635SAtari911            });
12907e8ea635SAtari911        }
12917e8ea635SAtari911
1292*96df7d3eSAtari911        function manageRecurringSeries(title, namespace, count, firstDate, lastDate, pattern, hasFlag) {
12937e8ea635SAtari911            var isPaused = title.indexOf("⏸") === 0;
12947e8ea635SAtari911            var cleanTitle = title.replace(/^⏸\s*/, "");
12957e8ea635SAtari911            var safeTitle = title.replace(/\x27/g, "\\\x27");
12967e8ea635SAtari911            var todayStr = new Date().toISOString().split("T")[0];
12977e8ea635SAtari911
12987e8ea635SAtari911            var dialog = document.createElement("div");
12997e8ea635SAtari911            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;";
13007e8ea635SAtari911            dialog.addEventListener("click", function(e) { if (e.target === dialog) dialog.remove(); });
13017e8ea635SAtari911
13027e8ea635SAtari911            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;\">";
13037e8ea635SAtari911            h += "<h3 style=\"margin:0 0 5px; color:#00cc07;\">⚙️ Manage Recurring Series</h3>";
1304*96df7d3eSAtari911            h += "<p style=\"margin:0 0 15px; color:' . $colors['text'] . '; font-size:13px;\"><strong>" + cleanTitle + "</strong><br>" + count + " occurrences · " + pattern + "<br>" + firstDate + " → " + lastDate + "</p>";
13057e8ea635SAtari911            h += "<div id=\"manage-status\" style=\"font-size:12px; min-height:18px; margin-bottom:10px;\"></div>";
13067e8ea635SAtari911
13077e8ea635SAtari911            // Extend
13087e8ea635SAtari911            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
13097e8ea635SAtari911            h += "<div style=\"font-weight:700; color:#00cc07; font-size:12px; margin-bottom:6px;\">�� Extend Series</div>";
13107e8ea635SAtari911            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
13117e8ea635SAtari911            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Add occurrences:</label>";
13127e8ea635SAtari911            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>";
13137e8ea635SAtari911            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Days apart:</label>";
13147e8ea635SAtari911            h += "<select id=\"manage-extend-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">";
13157e8ea635SAtari911            h += "<option value=\"1\">Daily</option><option value=\"7\" selected>Weekly</option><option value=\"14\">Bi-weekly</option><option value=\"30\">Monthly</option><option value=\"90\">Quarterly</option><option value=\"365\">Yearly</option></select></div>";
13167e8ea635SAtari911            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;\">Extend</button>";
13177e8ea635SAtari911            h += "</div></div>";
13187e8ea635SAtari911
13197e8ea635SAtari911            // Trim
13207e8ea635SAtari911            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
13217e8ea635SAtari911            h += "<div style=\"font-weight:700; color:#e74c3c; font-size:12px; margin-bottom:6px;\">✂️ Trim Past Events</div>";
13227e8ea635SAtari911            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
13237e8ea635SAtari911            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Remove before:</label>";
13247e8ea635SAtari911            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>";
13257e8ea635SAtari911            h += "<button onclick=\"if(confirm(\x27Remove all occurrences before \x27 + document.getElementById(\x27manage-trim-date\x27).value + \x27?\x27)) 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;\">Trim</button>";
13267e8ea635SAtari911            h += "</div></div>";
13277e8ea635SAtari911
13287e8ea635SAtari911            // Change Pattern
13297e8ea635SAtari911            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
13307e8ea635SAtari911            h += "<div style=\"font-weight:700; color:#ff9800; font-size:12px; margin-bottom:6px;\">�� Change Pattern</div>";
13317e8ea635SAtari911            h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">Respaces future occurrences only. Past events stay in place.</p>";
13327e8ea635SAtari911            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
13337e8ea635SAtari911            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">New interval:</label>";
13347e8ea635SAtari911            h += "<select id=\"manage-pattern-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">";
13357e8ea635SAtari911            h += "<option value=\"1\">Daily</option><option value=\"7\">Weekly</option><option value=\"14\">Bi-weekly</option><option value=\"30\">Monthly</option><option value=\"90\">Quarterly</option><option value=\"365\">Yearly</option></select></div>";
13367e8ea635SAtari911            h += "<button onclick=\"if(confirm(\x27Respace all future occurrences?\x27)) 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;\">Change</button>";
13377e8ea635SAtari911            h += "</div></div>";
13387e8ea635SAtari911
13397e8ea635SAtari911            // Change Start Date
13407e8ea635SAtari911            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
13417e8ea635SAtari911            h += "<div style=\"font-weight:700; color:#2196f3; font-size:12px; margin-bottom:6px;\">�� Change Start Date</div>";
13427e8ea635SAtari911            h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">Shifts ALL occurrences by the difference between old and new start date.</p>";
13437e8ea635SAtari911            h += "<div style=\"display:flex; gap:8px; align-items:end;\">";
13447e8ea635SAtari911            h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Current: " + firstDate + "</label>";
13457e8ea635SAtari911            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>";
13467e8ea635SAtari911            h += "<button onclick=\"if(confirm(\x27Shift all occurrences to new start date?\x27)) 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;\">Shift</button>";
13477e8ea635SAtari911            h += "</div></div>";
13487e8ea635SAtari911
13497e8ea635SAtari911            // Pause/Resume
13507e8ea635SAtari911            h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">";
13517e8ea635SAtari911            h += "<div style=\"font-weight:700; color:#9c27b0; font-size:12px; margin-bottom:6px;\">" + (isPaused ? "▶️ Resume Series" : "⏸ Pause Series") + "</div>";
13527e8ea635SAtari911            h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">" + (isPaused ? "Removes ⏸ prefix and paused flag from all occurrences." : "Adds ⏸ prefix to future occurrences. They remain in the calendar but are visually marked as paused.") + "</p>";
13537e8ea635SAtari911            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 ? "▶️ Resume" : "⏸ Pause") + "</button>";
13547e8ea635SAtari911            h += "</div>";
13557e8ea635SAtari911
13567e8ea635SAtari911            // Close
13577e8ea635SAtari911            h += "<div style=\"text-align:right; margin-top:10px;\">";
13587e8ea635SAtari911            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;\">Close</button>";
13597e8ea635SAtari911            h += "</div></div>";
13607e8ea635SAtari911
13617e8ea635SAtari911            dialog.innerHTML = h;
13627e8ea635SAtari911            document.body.appendChild(dialog);
13637e8ea635SAtari911        }
13647e8ea635SAtari911
13651d05cddcSAtari911        function sortRecurringTable(columnIndex) {
13661d05cddcSAtari911            const table = document.getElementById("recurringTable");
13671d05cddcSAtari911            const tbody = document.getElementById("recurringTableBody");
13681d05cddcSAtari911
13699ccd446eSAtari911            if (!table || !tbody) return;
13701d05cddcSAtari911
13711d05cddcSAtari911            const rows = Array.from(tbody.querySelectorAll("tr"));
13729ccd446eSAtari911            if (rows.length === 0) return;
13731d05cddcSAtari911
13741d05cddcSAtari911            // Toggle sort direction for this column
13751d05cddcSAtari911            if (!sortDirection[columnIndex]) {
13761d05cddcSAtari911                sortDirection[columnIndex] = "asc";
13771d05cddcSAtari911            } else {
13781d05cddcSAtari911                sortDirection[columnIndex] = sortDirection[columnIndex] === "asc" ? "desc" : "asc";
13791d05cddcSAtari911            }
13801d05cddcSAtari911
13811d05cddcSAtari911            const direction = sortDirection[columnIndex];
13821d05cddcSAtari911            const isNumeric = columnIndex === 4; // Count column
13831d05cddcSAtari911
13841d05cddcSAtari911            // Sort rows
13851d05cddcSAtari911            rows.sort((a, b) => {
13861d05cddcSAtari911                let aValue = a.cells[columnIndex].textContent.trim();
13871d05cddcSAtari911                let bValue = b.cells[columnIndex].textContent.trim();
13881d05cddcSAtari911
13891d05cddcSAtari911                // Extract text from code elements for namespace column
13901d05cddcSAtari911                if (columnIndex === 1) {
13911d05cddcSAtari911                    const aCode = a.cells[columnIndex].querySelector("code");
13921d05cddcSAtari911                    const bCode = b.cells[columnIndex].querySelector("code");
13931d05cddcSAtari911                    aValue = aCode ? aCode.textContent.trim() : aValue;
13941d05cddcSAtari911                    bValue = bCode ? bCode.textContent.trim() : bValue;
13951d05cddcSAtari911                }
13961d05cddcSAtari911
13971d05cddcSAtari911                // Extract number from strong elements for count column
13981d05cddcSAtari911                if (isNumeric) {
13991d05cddcSAtari911                    const aStrong = a.cells[columnIndex].querySelector("strong");
14001d05cddcSAtari911                    const bStrong = b.cells[columnIndex].querySelector("strong");
14011d05cddcSAtari911                    aValue = aStrong ? parseInt(aStrong.textContent.trim()) : 0;
14021d05cddcSAtari911                    bValue = bStrong ? parseInt(bStrong.textContent.trim()) : 0;
14031d05cddcSAtari911
14041d05cddcSAtari911                    return direction === "asc" ? aValue - bValue : bValue - aValue;
14051d05cddcSAtari911                }
14061d05cddcSAtari911
14071d05cddcSAtari911                // String comparison
14081d05cddcSAtari911                if (direction === "asc") {
14091d05cddcSAtari911                    return aValue.localeCompare(bValue);
14101d05cddcSAtari911                } else {
14111d05cddcSAtari911                    return bValue.localeCompare(aValue);
14121d05cddcSAtari911                }
14131d05cddcSAtari911            });
14141d05cddcSAtari911
14151d05cddcSAtari911            // Update arrows
14161d05cddcSAtari911            const headers = table.querySelectorAll("th");
14171d05cddcSAtari911            headers.forEach((header, index) => {
14181d05cddcSAtari911                const arrow = header.querySelector(".sort-arrow");
14191d05cddcSAtari911                if (arrow) {
14201d05cddcSAtari911                    if (index === columnIndex) {
14211d05cddcSAtari911                        arrow.textContent = direction === "asc" ? "↑" : "↓";
14221d05cddcSAtari911                        arrow.style.color = "#00cc07";
14231d05cddcSAtari911                    } else {
14241d05cddcSAtari911                        arrow.textContent = "⇅";
14251d05cddcSAtari911                        arrow.style.color = "#999";
14261d05cddcSAtari911                    }
14271d05cddcSAtari911                }
14281d05cddcSAtari911            });
14291d05cddcSAtari911
14301d05cddcSAtari911            // Rebuild tbody
14311d05cddcSAtari911            rows.forEach(row => tbody.appendChild(row));
14321d05cddcSAtari911        }
14331d05cddcSAtari911
14341d05cddcSAtari911        function filterRecurringEvents() {
14351d05cddcSAtari911            const searchInput = document.getElementById("searchRecurring");
14361d05cddcSAtari911            const filter = normalizeText(searchInput.value);
14371d05cddcSAtari911            const tbody = document.getElementById("recurringTableBody");
14381d05cddcSAtari911            const rows = tbody.getElementsByTagName("tr");
14391d05cddcSAtari911
14401d05cddcSAtari911            for (let i = 0; i < rows.length; i++) {
14411d05cddcSAtari911                const row = rows[i];
14421d05cddcSAtari911                const titleCell = row.getElementsByTagName("td")[0];
14431d05cddcSAtari911
14441d05cddcSAtari911                if (titleCell) {
14451d05cddcSAtari911                    const titleText = normalizeText(titleCell.textContent || titleCell.innerText);
14461d05cddcSAtari911
14471d05cddcSAtari911                    if (titleText.indexOf(filter) > -1) {
14481d05cddcSAtari911                        row.classList.remove("recurring-row-hidden");
14491d05cddcSAtari911                    } else {
14501d05cddcSAtari911                        row.classList.add("recurring-row-hidden");
14511d05cddcSAtari911                    }
14521d05cddcSAtari911                }
14531d05cddcSAtari911            }
14541d05cddcSAtari911        }
14551d05cddcSAtari911
14561d05cddcSAtari911        function normalizeText(text) {
14571d05cddcSAtari911            // Convert to lowercase
14581d05cddcSAtari911            text = text.toLowerCase();
14591d05cddcSAtari911
14601d05cddcSAtari911            // Remove apostrophes and quotes
14611d05cddcSAtari911            text = text.replace(/[\'\"]/g, "");
14621d05cddcSAtari911
14631d05cddcSAtari911            // Replace accented characters with regular ones
14641d05cddcSAtari911            text = text.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
14651d05cddcSAtari911
14661d05cddcSAtari911            // Remove special characters except spaces and alphanumeric
14671d05cddcSAtari911            text = text.replace(/[^a-z0-9\s]/g, "");
14681d05cddcSAtari911
14691d05cddcSAtari911            // Collapse multiple spaces
14701d05cddcSAtari911            text = text.replace(/\s+/g, " ");
14711d05cddcSAtari911
14721d05cddcSAtari911            return text.trim();
14731d05cddcSAtari911        }
14741d05cddcSAtari911
14751d05cddcSAtari911        function filterEvents() {
14761d05cddcSAtari911            const searchText = normalizeText(document.getElementById("searchEvents").value);
14771d05cddcSAtari911            const eventRows = document.querySelectorAll(".event-row");
14781d05cddcSAtari911            let visibleCount = 0;
14791d05cddcSAtari911
14801d05cddcSAtari911            eventRows.forEach(row => {
14811d05cddcSAtari911                const titleElement = row.querySelector("div div");
14821d05cddcSAtari911                const originalTitle = titleElement.getAttribute("data-original-title") || titleElement.textContent;
14831d05cddcSAtari911
14841d05cddcSAtari911                // Store original title if not already stored
14851d05cddcSAtari911                if (!titleElement.getAttribute("data-original-title")) {
14861d05cddcSAtari911                    titleElement.setAttribute("data-original-title", originalTitle);
14871d05cddcSAtari911                }
14881d05cddcSAtari911
14891d05cddcSAtari911                const normalizedTitle = normalizeText(originalTitle);
14901d05cddcSAtari911
14911d05cddcSAtari911                if (normalizedTitle.includes(searchText) || searchText === "") {
14921d05cddcSAtari911                    row.style.display = "flex";
14931d05cddcSAtari911                    visibleCount++;
14941d05cddcSAtari911                } else {
14951d05cddcSAtari911                    row.style.display = "none";
14961d05cddcSAtari911                }
14971d05cddcSAtari911            });
14981d05cddcSAtari911
14991d05cddcSAtari911            // Update namespace visibility and counts
15001d05cddcSAtari911            document.querySelectorAll("[id^=ns_]").forEach(nsDiv => {
15011d05cddcSAtari911                if (nsDiv.id.endsWith("_arrow") || nsDiv.id.endsWith("_check")) return;
15021d05cddcSAtari911
15031d05cddcSAtari911                const visibleEvents = nsDiv.querySelectorAll(".event-row[style*=\\"display: flex\\"], .event-row:not([style*=\\"display: none\\"])").length;
15041d05cddcSAtari911                const nsId = nsDiv.id;
15051d05cddcSAtari911                const arrow = document.getElementById(nsId + "_arrow");
15061d05cddcSAtari911
15071d05cddcSAtari911                // Auto-expand namespaces with matches when searching
15081d05cddcSAtari911                if (searchText && visibleEvents > 0) {
15091d05cddcSAtari911                    nsDiv.style.display = "block";
15101d05cddcSAtari911                    if (arrow) arrow.textContent = "▼";
15111d05cddcSAtari911                }
15121d05cddcSAtari911            });
15131d05cddcSAtari911        }
15141d05cddcSAtari911
15151d05cddcSAtari911        function toggleNamespace(id) {
15161d05cddcSAtari911            const elem = document.getElementById(id);
15171d05cddcSAtari911            const arrow = document.getElementById(id + "_arrow");
15181d05cddcSAtari911            if (elem.style.display === "none") {
15191d05cddcSAtari911                elem.style.display = "block";
15201d05cddcSAtari911                arrow.textContent = "▼";
15211d05cddcSAtari911            } else {
15221d05cddcSAtari911                elem.style.display = "none";
15231d05cddcSAtari911                arrow.textContent = "▶";
15241d05cddcSAtari911            }
15251d05cddcSAtari911        }
15261d05cddcSAtari911
15271d05cddcSAtari911        function toggleNamespaceSelect(nsId) {
15281d05cddcSAtari911            const checkbox = document.getElementById(nsId + "_check");
15291d05cddcSAtari911            const events = document.querySelectorAll("." + nsId + "_events");
15301d05cddcSAtari911
15311d05cddcSAtari911            // Only select visible events (not hidden by search)
15321d05cddcSAtari911            events.forEach(cb => {
15331d05cddcSAtari911                const eventRow = cb.closest(".event-row");
15341d05cddcSAtari911                if (eventRow && eventRow.style.display !== "none") {
15351d05cddcSAtari911                    cb.checked = checkbox.checked;
15361d05cddcSAtari911                }
15371d05cddcSAtari911            });
15381d05cddcSAtari911            updateCount();
15391d05cddcSAtari911        }
15401d05cddcSAtari911
15411d05cddcSAtari911        function selectAll() {
15421d05cddcSAtari911            // Only select visible events
15431d05cddcSAtari911            document.querySelectorAll(".event-checkbox").forEach(cb => {
15441d05cddcSAtari911                const eventRow = cb.closest(".event-row");
15451d05cddcSAtari911                if (eventRow && eventRow.style.display !== "none") {
15461d05cddcSAtari911                    cb.checked = true;
15471d05cddcSAtari911                }
15481d05cddcSAtari911            });
15491d05cddcSAtari911            // Update namespace checkboxes to indeterminate if partially selected
15501d05cddcSAtari911            document.querySelectorAll("input[id$=_check]").forEach(nsCheckbox => {
15511d05cddcSAtari911                const nsId = nsCheckbox.id.replace("_check", "");
15521d05cddcSAtari911                const events = document.querySelectorAll("." + nsId + "_events");
15531d05cddcSAtari911                const visibleEvents = Array.from(events).filter(cb => {
15541d05cddcSAtari911                    const row = cb.closest(".event-row");
15551d05cddcSAtari911                    return row && row.style.display !== "none";
15561d05cddcSAtari911                });
15571d05cddcSAtari911                const checkedVisible = visibleEvents.filter(cb => cb.checked);
15581d05cddcSAtari911
15591d05cddcSAtari911                if (checkedVisible.length === visibleEvents.length && visibleEvents.length > 0) {
15601d05cddcSAtari911                    nsCheckbox.checked = true;
15611d05cddcSAtari911                } else if (checkedVisible.length > 0) {
15621d05cddcSAtari911                    nsCheckbox.indeterminate = true;
15631d05cddcSAtari911                } else {
15641d05cddcSAtari911                    nsCheckbox.checked = false;
15651d05cddcSAtari911                }
15661d05cddcSAtari911            });
15671d05cddcSAtari911            updateCount();
15681d05cddcSAtari911        }
15691d05cddcSAtari911
15701d05cddcSAtari911        function deselectAll() {
15711d05cddcSAtari911            document.querySelectorAll(".event-checkbox").forEach(cb => cb.checked = false);
15721d05cddcSAtari911            document.querySelectorAll("input[id$=_check]").forEach(cb => {
15731d05cddcSAtari911                cb.checked = false;
15741d05cddcSAtari911                cb.indeterminate = false;
15751d05cddcSAtari911            });
15761d05cddcSAtari911            updateCount();
15771d05cddcSAtari911        }
15781d05cddcSAtari911
15791d05cddcSAtari911        function deleteSelected() {
15801d05cddcSAtari911            const checkedBoxes = document.querySelectorAll(".event-checkbox:checked");
15811d05cddcSAtari911            if (checkedBoxes.length === 0) {
15821d05cddcSAtari911                alert("No events selected");
15831d05cddcSAtari911                return;
15841d05cddcSAtari911            }
15851d05cddcSAtari911
15861d05cddcSAtari911            const count = checkedBoxes.length;
15871d05cddcSAtari911            if (!confirm(`Delete ${count} selected event(s)?\\n\\nThis cannot be undone!`)) {
15881d05cddcSAtari911                return;
15891d05cddcSAtari911            }
15901d05cddcSAtari911
15911d05cddcSAtari911            const form = document.createElement("form");
15921d05cddcSAtari911            form.method = "POST";
15931d05cddcSAtari911            form.action = "?do=admin&page=calendar&tab=manage";
15941d05cddcSAtari911
15951d05cddcSAtari911            const actionInput = document.createElement("input");
15961d05cddcSAtari911            actionInput.type = "hidden";
15971d05cddcSAtari911            actionInput.name = "action";
15981d05cddcSAtari911            actionInput.value = "delete_selected_events";
15991d05cddcSAtari911            form.appendChild(actionInput);
16001d05cddcSAtari911
16011d05cddcSAtari911            checkedBoxes.forEach(cb => {
16021d05cddcSAtari911                const eventInput = document.createElement("input");
16031d05cddcSAtari911                eventInput.type = "hidden";
16041d05cddcSAtari911                eventInput.name = "events[]";
16051d05cddcSAtari911                eventInput.value = cb.value;
16061d05cddcSAtari911                form.appendChild(eventInput);
16071d05cddcSAtari911            });
16081d05cddcSAtari911
16091d05cddcSAtari911            document.body.appendChild(form);
16101d05cddcSAtari911            form.submit();
16111d05cddcSAtari911        }
16121d05cddcSAtari911
16131d05cddcSAtari911        function createNewNamespace() {
16141d05cddcSAtari911            const namespaceName = prompt("Enter new namespace name:\\n\\nExamples:\\n- work\\n- personal\\n- projects:alpha\\n- aspen:travel:2025");
16151d05cddcSAtari911
16161d05cddcSAtari911            if (!namespaceName) {
16171d05cddcSAtari911                return; // Cancelled
16181d05cddcSAtari911            }
16191d05cddcSAtari911
16201d05cddcSAtari911            // Validate namespace name
16211d05cddcSAtari911            if (!/^[a-zA-Z0-9_:-]+$/.test(namespaceName)) {
16221d05cddcSAtari911                alert("Invalid namespace name.\\n\\nUse only letters, numbers, underscore, hyphen, and colon.\\nExample: work:projects:alpha");
16231d05cddcSAtari911                return;
16241d05cddcSAtari911            }
16251d05cddcSAtari911
16261d05cddcSAtari911            // Submit form to create namespace
16271d05cddcSAtari911            const form = document.createElement("form");
16281d05cddcSAtari911            form.method = "POST";
16291d05cddcSAtari911            form.action = "?do=admin&page=calendar&tab=manage";
16301d05cddcSAtari911
16311d05cddcSAtari911            const actionInput = document.createElement("input");
16321d05cddcSAtari911            actionInput.type = "hidden";
16331d05cddcSAtari911            actionInput.name = "action";
16341d05cddcSAtari911            actionInput.value = "create_namespace";
16351d05cddcSAtari911            form.appendChild(actionInput);
16361d05cddcSAtari911
16371d05cddcSAtari911            const namespaceInput = document.createElement("input");
16381d05cddcSAtari911            namespaceInput.type = "hidden";
16391d05cddcSAtari911            namespaceInput.name = "namespace_name";
16401d05cddcSAtari911            namespaceInput.value = namespaceName;
16411d05cddcSAtari911            form.appendChild(namespaceInput);
16421d05cddcSAtari911
16431d05cddcSAtari911            document.body.appendChild(form);
16441d05cddcSAtari911            form.submit();
16451d05cddcSAtari911        }
16461d05cddcSAtari911
16471d05cddcSAtari911        function updateCount() {
16481d05cddcSAtari911            const count = document.querySelectorAll(".event-checkbox:checked").length;
16491d05cddcSAtari911            document.getElementById("selectedCount").textContent = count + " selected";
16501d05cddcSAtari911        }
16511d05cddcSAtari911
16521d05cddcSAtari911        function deleteNamespace(namespace) {
16531d05cddcSAtari911            const displayName = namespace || "(default)";
16541d05cddcSAtari911            if (!confirm("Delete ENTIRE namespace: " + displayName + "?\\n\\nThis will delete ALL events in this namespace!\\n\\nThis cannot be undone!")) {
16551d05cddcSAtari911                return;
16561d05cddcSAtari911            }
16571d05cddcSAtari911            const form = document.createElement("form");
16581d05cddcSAtari911            form.method = "POST";
16591d05cddcSAtari911            form.action = "?do=admin&page=calendar&tab=manage";
16601d05cddcSAtari911            const actionInput = document.createElement("input");
16611d05cddcSAtari911            actionInput.type = "hidden";
16621d05cddcSAtari911            actionInput.name = "action";
16631d05cddcSAtari911            actionInput.value = "delete_namespace";
16641d05cddcSAtari911            form.appendChild(actionInput);
16651d05cddcSAtari911            const nsInput = document.createElement("input");
16661d05cddcSAtari911            nsInput.type = "hidden";
16671d05cddcSAtari911            nsInput.name = "namespace";
16681d05cddcSAtari911            nsInput.value = namespace;
16691d05cddcSAtari911            form.appendChild(nsInput);
16701d05cddcSAtari911            document.body.appendChild(form);
16711d05cddcSAtari911            form.submit();
16721d05cddcSAtari911        }
16731d05cddcSAtari911
16749ccd446eSAtari911        function renameNamespace(oldNamespace) {
16759ccd446eSAtari911            const displayName = oldNamespace || "(default)";
16769ccd446eSAtari911            const newName = prompt("Rename namespace: " + displayName + "\\n\\nEnter new name:", oldNamespace);
16779ccd446eSAtari911            if (newName === null || newName === oldNamespace) {
16789ccd446eSAtari911                return; // Cancelled or no change
16799ccd446eSAtari911            }
16809ccd446eSAtari911            const form = document.createElement("form");
16819ccd446eSAtari911            form.method = "POST";
16829ccd446eSAtari911            form.action = "?do=admin&page=calendar&tab=manage";
16839ccd446eSAtari911            const actionInput = document.createElement("input");
16849ccd446eSAtari911            actionInput.type = "hidden";
16859ccd446eSAtari911            actionInput.name = "action";
16869ccd446eSAtari911            actionInput.value = "rename_namespace";
16879ccd446eSAtari911            form.appendChild(actionInput);
16889ccd446eSAtari911            const oldInput = document.createElement("input");
16899ccd446eSAtari911            oldInput.type = "hidden";
16909ccd446eSAtari911            oldInput.name = "old_namespace";
16919ccd446eSAtari911            oldInput.value = oldNamespace;
16929ccd446eSAtari911            form.appendChild(oldInput);
16939ccd446eSAtari911            const newInput = document.createElement("input");
16949ccd446eSAtari911            newInput.type = "hidden";
16959ccd446eSAtari911            newInput.name = "new_namespace";
16969ccd446eSAtari911            newInput.value = newName;
16979ccd446eSAtari911            form.appendChild(newInput);
16989ccd446eSAtari911            document.body.appendChild(form);
16999ccd446eSAtari911            form.submit();
17009ccd446eSAtari911        }
17019ccd446eSAtari911
17021d05cddcSAtari911        let draggedEvent = null;
17031d05cddcSAtari911
17041d05cddcSAtari911        function dragStart(event, eventId) {
17051d05cddcSAtari911            const checkbox = event.target.closest(".event-row").querySelector(".event-checkbox");
17061d05cddcSAtari911
17071d05cddcSAtari911            // If this event is checked, drag all checked events
17081d05cddcSAtari911            const checkedBoxes = document.querySelectorAll(".event-checkbox:checked");
17091d05cddcSAtari911            if (checkbox && checkbox.checked && checkedBoxes.length > 1) {
17101d05cddcSAtari911                // Dragging multiple selected events
17111d05cddcSAtari911                draggedEvent = "MULTIPLE";
17121d05cddcSAtari911                event.dataTransfer.setData("text/plain", "MULTIPLE");
17131d05cddcSAtari911            } else {
17141d05cddcSAtari911                // Dragging single event
17151d05cddcSAtari911                draggedEvent = eventId;
17161d05cddcSAtari911                event.dataTransfer.setData("text/plain", eventId);
17171d05cddcSAtari911            }
17181d05cddcSAtari911            event.dataTransfer.effectAllowed = "move";
17191d05cddcSAtari911            event.target.style.opacity = "0.5";
17201d05cddcSAtari911        }
17211d05cddcSAtari911
17221d05cddcSAtari911        function allowDrop(event) {
17231d05cddcSAtari911            event.preventDefault();
17241d05cddcSAtari911            event.dataTransfer.dropEffect = "move";
17251d05cddcSAtari911        }
17261d05cddcSAtari911
17271d05cddcSAtari911        function drop(event, targetNamespace) {
17281d05cddcSAtari911            event.preventDefault();
17291d05cddcSAtari911
17301d05cddcSAtari911            if (draggedEvent === "MULTIPLE") {
17311d05cddcSAtari911                // Move all selected events
17321d05cddcSAtari911                const checkedBoxes = document.querySelectorAll(".event-checkbox:checked");
17331d05cddcSAtari911                if (checkedBoxes.length === 0) return;
17341d05cddcSAtari911
17351d05cddcSAtari911                const form = document.createElement("form");
17361d05cddcSAtari911                form.method = "POST";
17371d05cddcSAtari911                form.action = "?do=admin&page=calendar&tab=manage";
17381d05cddcSAtari911
17391d05cddcSAtari911                const actionInput = document.createElement("input");
17401d05cddcSAtari911                actionInput.type = "hidden";
17411d05cddcSAtari911                actionInput.name = "action";
17421d05cddcSAtari911                actionInput.value = "move_selected_events";
17431d05cddcSAtari911                form.appendChild(actionInput);
17441d05cddcSAtari911
17451d05cddcSAtari911                checkedBoxes.forEach(cb => {
17461d05cddcSAtari911                    const eventInput = document.createElement("input");
17471d05cddcSAtari911                    eventInput.type = "hidden";
17481d05cddcSAtari911                    eventInput.name = "events[]";
17491d05cddcSAtari911                    eventInput.value = cb.value;
17501d05cddcSAtari911                    form.appendChild(eventInput);
17511d05cddcSAtari911                });
17521d05cddcSAtari911
17531d05cddcSAtari911                const targetInput = document.createElement("input");
17541d05cddcSAtari911                targetInput.type = "hidden";
17551d05cddcSAtari911                targetInput.name = "target_namespace";
17561d05cddcSAtari911                targetInput.value = targetNamespace;
17571d05cddcSAtari911                form.appendChild(targetInput);
17581d05cddcSAtari911
17591d05cddcSAtari911                document.body.appendChild(form);
17601d05cddcSAtari911                form.submit();
17611d05cddcSAtari911            } else {
17621d05cddcSAtari911                // Move single event
17631d05cddcSAtari911                if (!draggedEvent) return;
17641d05cddcSAtari911                const parts = draggedEvent.split("|");
17651d05cddcSAtari911                const sourceNamespace = parts[1];
17661d05cddcSAtari911                if (sourceNamespace === targetNamespace) return;
17671d05cddcSAtari911
17681d05cddcSAtari911                const form = document.createElement("form");
17691d05cddcSAtari911                form.method = "POST";
17701d05cddcSAtari911                form.action = "?do=admin&page=calendar&tab=manage";
17711d05cddcSAtari911                const actionInput = document.createElement("input");
17721d05cddcSAtari911                actionInput.type = "hidden";
17731d05cddcSAtari911                actionInput.name = "action";
17741d05cddcSAtari911                actionInput.value = "move_single_event";
17751d05cddcSAtari911                form.appendChild(actionInput);
17761d05cddcSAtari911                const eventInput = document.createElement("input");
17771d05cddcSAtari911                eventInput.type = "hidden";
17781d05cddcSAtari911                eventInput.name = "event";
17791d05cddcSAtari911                eventInput.value = draggedEvent;
17801d05cddcSAtari911                form.appendChild(eventInput);
17811d05cddcSAtari911                const targetInput = document.createElement("input");
17821d05cddcSAtari911                targetInput.type = "hidden";
17831d05cddcSAtari911                targetInput.name = "target_namespace";
17841d05cddcSAtari911                targetInput.value = targetNamespace;
17851d05cddcSAtari911                form.appendChild(targetInput);
17861d05cddcSAtari911                document.body.appendChild(form);
17871d05cddcSAtari911                form.submit();
17881d05cddcSAtari911            }
17891d05cddcSAtari911        }
17901d05cddcSAtari911
1791*96df7d3eSAtari911        function editRecurringSeries(title, namespace, time, color, recurrenceType, recurrenceInterval, weekDays, monthlyType, monthDay, ordinalWeek, ordinalDay) {
17929ccd446eSAtari911            // Get available namespaces from the namespace explorer
17939ccd446eSAtari911            const namespaces = new Set();
17941d05cddcSAtari911
17959ccd446eSAtari911            // Method 1: Try to get from namespace explorer folder names
17969ccd446eSAtari911            document.querySelectorAll("[id^=ns_]").forEach(el => {
17979ccd446eSAtari911                const nsSpan = el.querySelector("span:nth-child(3)");
17989ccd446eSAtari911                if (nsSpan) {
17999ccd446eSAtari911                    let nsText = nsSpan.textContent.replace("�� ", "").trim();
18009ccd446eSAtari911                    if (nsText && nsText !== "(default)") {
18019ccd446eSAtari911                        namespaces.add(nsText);
18029ccd446eSAtari911                    }
18039ccd446eSAtari911                }
18049ccd446eSAtari911            });
18059ccd446eSAtari911
18069ccd446eSAtari911            // Method 2: Get from datalist if it exists
18079ccd446eSAtari911            document.querySelectorAll("#namespaceList option").forEach(opt => {
18089ccd446eSAtari911                if (opt.value && opt.value !== "") {
18099ccd446eSAtari911                    namespaces.add(opt.value);
18109ccd446eSAtari911                }
18119ccd446eSAtari911            });
18129ccd446eSAtari911
18139ccd446eSAtari911            // Convert to sorted array
18149ccd446eSAtari911            const nsArray = Array.from(namespaces).sort();
18159ccd446eSAtari911
1816*96df7d3eSAtari911            // Build namespace options
18179ccd446eSAtari911            let nsOptions = "<option value=\\"\\">(default)</option>";
18189ccd446eSAtari911            if (namespace && namespace !== "") {
18199ccd446eSAtari911                nsOptions += "<option value=\\"" + namespace + "\\" selected>" + namespace + " (current)</option>";
18209ccd446eSAtari911            }
18219ccd446eSAtari911            for (const ns of nsArray) {
18229ccd446eSAtari911                if (ns !== namespace) {
18239ccd446eSAtari911                    nsOptions += "<option value=\\"" + ns + "\\">" + ns + "</option>";
18241d05cddcSAtari911                }
18251d05cddcSAtari911            }
18261d05cddcSAtari911
1827*96df7d3eSAtari911            // Build weekday checkboxes - matching event editor style exactly
1828*96df7d3eSAtari911            const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1829*96df7d3eSAtari911            let weekDayChecks = "";
1830*96df7d3eSAtari911            for (let i = 0; i < 7; i++) {
1831*96df7d3eSAtari911                const checked = weekDays && weekDays.includes(i) ? " checked" : "";
1832*96df7d3eSAtari911                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;">
1833*96df7d3eSAtari911                    <input type="checkbox" name="weekDays" value="${i}"${checked} style="margin-right:3px; width:12px; height:12px;">
1834*96df7d3eSAtari911                    <span>${dayNames[i]}</span>
1835*96df7d3eSAtari911                </label>`;
1836*96df7d3eSAtari911            }
1837*96df7d3eSAtari911
1838*96df7d3eSAtari911            // Build ordinal week options
1839*96df7d3eSAtari911            let ordinalWeekOpts = "";
1840*96df7d3eSAtari911            const ordinalLabels = [[1,"First"], [2,"Second"], [3,"Third"], [4,"Fourth"], [5,"Fifth"], [-1,"Last"]];
1841*96df7d3eSAtari911            for (const [val, label] of ordinalLabels) {
1842*96df7d3eSAtari911                const selected = val === ordinalWeek ? " selected" : "";
1843*96df7d3eSAtari911                ordinalWeekOpts += `<option value="${val}"${selected}>${label}</option>`;
1844*96df7d3eSAtari911            }
1845*96df7d3eSAtari911
1846*96df7d3eSAtari911            // Build ordinal day options - full day names like event editor
1847*96df7d3eSAtari911            const fullDayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1848*96df7d3eSAtari911            let ordinalDayOpts = "";
1849*96df7d3eSAtari911            for (let i = 0; i < 7; i++) {
1850*96df7d3eSAtari911                const selected = i === ordinalDay ? " selected" : "";
1851*96df7d3eSAtari911                ordinalDayOpts += `<option value="${i}"${selected}>${fullDayNames[i]}</option>`;
1852*96df7d3eSAtari911            }
1853*96df7d3eSAtari911
18541d05cddcSAtari911            // Show edit dialog for recurring events
18551d05cddcSAtari911            const dialog = document.createElement("div");
1856*96df7d3eSAtari911            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;";
18571d05cddcSAtari911
18581d05cddcSAtari911            // Close on clicking background
18591d05cddcSAtari911            dialog.addEventListener("click", function(e) {
18601d05cddcSAtari911                if (e.target === dialog) {
18611d05cddcSAtari911                    dialog.remove();
18621d05cddcSAtari911                }
18631d05cddcSAtari911            });
18641d05cddcSAtari911
1865*96df7d3eSAtari911            const monthlyDayChecked = monthlyType !== "ordinalWeekday" ? "checked" : "";
1866*96df7d3eSAtari911            const monthlyOrdinalChecked = monthlyType === "ordinalWeekday" ? "checked" : "";
1867*96df7d3eSAtari911            const weeklyDisplay = recurrenceType === "weekly" ? "block" : "none";
1868*96df7d3eSAtari911            const monthlyDisplay = recurrenceType === "monthly" ? "block" : "none";
1869*96df7d3eSAtari911
1870*96df7d3eSAtari911            // Get recurrence type selection - matching event editor labels
1871*96df7d3eSAtari911            const recTypes = [["daily","Day(s)"], ["weekly","Week(s)"], ["monthly","Month(s)"], ["yearly","Year(s)"]];
1872*96df7d3eSAtari911            let recTypeOptions = "";
1873*96df7d3eSAtari911            for (const [val, label] of recTypes) {
1874*96df7d3eSAtari911                const selected = val === recurrenceType ? " selected" : "";
1875*96df7d3eSAtari911                recTypeOptions += `<option value="${val}"${selected}>${label}</option>`;
1876*96df7d3eSAtari911            }
1877*96df7d3eSAtari911
1878*96df7d3eSAtari911            // Input/select base style matching event editor
1879*96df7d3eSAtari911            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;";
1880*96df7d3eSAtari911            const inputSmallStyle = "padding:4px 6px; border:2px solid #444; border-radius:4px; font-size:11px; background:#2a2a2a; color:#eee;";
1881*96df7d3eSAtari911            const labelStyle = "display:block; font-size:10px; font-weight:500; margin-bottom:4px; color:#888;";
1882*96df7d3eSAtari911
18831d05cddcSAtari911            dialog.innerHTML = `
1884*96df7d3eSAtari911                <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);">
18851d05cddcSAtari911
1886*96df7d3eSAtari911                    <!-- Header - matching event editor -->
1887*96df7d3eSAtari911                    <div style="display:flex; align-items:center; justify-content:space-between; padding:10px 14px; background:#2c3e50; color:white; flex-shrink:0;">
1888*96df7d3eSAtari911                        <h3 style="margin:0; font-size:15px; font-weight:600;">✏️ Edit Recurring Event</h3>
1889*96df7d3eSAtari911                        <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>
18901d05cddcSAtari911                    </div>
18911d05cddcSAtari911
1892*96df7d3eSAtari911                    <!-- Form body - matching event editor -->
1893*96df7d3eSAtari911                    <form id="editRecurringForm" style="padding:10px 12px; overflow-y:auto; flex:1; display:flex; flex-direction:column; gap:8px;">
1894*96df7d3eSAtari911
1895*96df7d3eSAtari911                        <p style="margin:0 0 4px; color:#888; font-size:11px;">Changes apply to ALL occurrences of: <strong style="color:#00cc07;">${title}</strong></p>
1896*96df7d3eSAtari911
1897*96df7d3eSAtari911                        <!-- Title -->
18981d05cddcSAtari911                        <div>
1899*96df7d3eSAtari911                            <label style="${labelStyle}">�� TITLE</label>
1900*96df7d3eSAtari911                            <input type="text" name="new_title" value="${title}" style="${inputStyle}" required>
1901*96df7d3eSAtari911                        </div>
1902*96df7d3eSAtari911
1903*96df7d3eSAtari911                        <!-- Time Row -->
1904*96df7d3eSAtari911                        <div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
1905*96df7d3eSAtari911                            <div>
1906*96df7d3eSAtari911                                <label style="${labelStyle}">�� START TIME</label>
1907*96df7d3eSAtari911                                <input type="time" name="start_time" value="${time || \'\'}" style="${inputStyle}">
19081d05cddcSAtari911                            </div>
19091d05cddcSAtari911                            <div>
1910*96df7d3eSAtari911                                <label style="${labelStyle}">�� END TIME</label>
1911*96df7d3eSAtari911                                <input type="time" name="end_time" style="${inputStyle}">
19121d05cddcSAtari911                            </div>
19131d05cddcSAtari911                        </div>
19141d05cddcSAtari911
1915*96df7d3eSAtari911                        <!-- Recurrence Pattern Box - matching event editor exactly -->
1916*96df7d3eSAtari911                        <div style="border:1px solid #333; border-radius:4px; padding:8px; margin:4px 0; background:rgba(0,0,0,0.2);">
1917*96df7d3eSAtari911
1918*96df7d3eSAtari911                            <!-- Repeat every [N] [period] -->
1919*96df7d3eSAtari911                            <div style="display:flex; gap:8px; align-items:flex-end; margin-bottom:6px;">
1920*96df7d3eSAtari911                                <div style="flex:0 0 auto;">
1921*96df7d3eSAtari911                                    <label style="${labelStyle}">Repeat every</label>
1922*96df7d3eSAtari911                                    <input type="number" name="recurrence_interval" value="${recurrenceInterval || 1}" min="1" max="99" style="width:50px; ${inputSmallStyle}">
1923*96df7d3eSAtari911                                </div>
1924*96df7d3eSAtari911                                <div style="flex:1;">
1925*96df7d3eSAtari911                                    <label style="${labelStyle}">&nbsp;</label>
1926*96df7d3eSAtari911                                    <select name="recurrence_type" id="editRecType" onchange="toggleEditRecOptions()" style="width:100%; ${inputSmallStyle}">
1927*96df7d3eSAtari911                                        ${recTypeOptions}
19281d05cddcSAtari911                                    </select>
19291d05cddcSAtari911                                </div>
1930*96df7d3eSAtari911                            </div>
19311d05cddcSAtari911
1932*96df7d3eSAtari911                            <!-- Weekly options - day checkboxes -->
1933*96df7d3eSAtari911                            <div id="editWeeklyOptions" style="display:${weeklyDisplay}; margin-bottom:6px;">
1934*96df7d3eSAtari911                                <label style="${labelStyle}">On these days:</label>
1935*96df7d3eSAtari911                                <div style="display:flex; flex-wrap:wrap; gap:2px;">
1936*96df7d3eSAtari911                                    ${weekDayChecks}
1937*96df7d3eSAtari911                                </div>
1938*96df7d3eSAtari911                            </div>
1939*96df7d3eSAtari911
1940*96df7d3eSAtari911                            <!-- Monthly options -->
1941*96df7d3eSAtari911                            <div id="editMonthlyOptions" style="display:${monthlyDisplay}; margin-bottom:6px;">
1942*96df7d3eSAtari911                                <label style="${labelStyle}">Repeat on:</label>
1943*96df7d3eSAtari911
1944*96df7d3eSAtari911                                <!-- Radio: Day of month vs Ordinal weekday -->
1945*96df7d3eSAtari911                                <div style="margin-bottom:6px;">
1946*96df7d3eSAtari911                                    <label style="display:inline-flex; align-items:center; margin-right:12px; cursor:pointer; font-size:11px; color:#ccc;">
1947*96df7d3eSAtari911                                        <input type="radio" name="monthly_type" value="dayOfMonth" ${monthlyDayChecked} onchange="toggleEditMonthlyType()" style="margin-right:4px;">
1948*96df7d3eSAtari911                                        Day of month
1949*96df7d3eSAtari911                                    </label>
1950*96df7d3eSAtari911                                    <label style="display:inline-flex; align-items:center; cursor:pointer; font-size:11px; color:#ccc;">
1951*96df7d3eSAtari911                                        <input type="radio" name="monthly_type" value="ordinalWeekday" ${monthlyOrdinalChecked} onchange="toggleEditMonthlyType()" style="margin-right:4px;">
1952*96df7d3eSAtari911                                        Weekday pattern
1953*96df7d3eSAtari911                                    </label>
1954*96df7d3eSAtari911                                </div>
1955*96df7d3eSAtari911
1956*96df7d3eSAtari911                                <!-- Day of month input -->
1957*96df7d3eSAtari911                                <div id="editMonthlyDay" style="display:${monthlyType !== "ordinalWeekday" ? "flex" : "none"}; align-items:center; gap:6px;">
1958*96df7d3eSAtari911                                    <span style="font-size:11px; color:#ccc;">Day</span>
1959*96df7d3eSAtari911                                    <input type="number" name="month_day" value="${monthDay || 1}" min="1" max="31" style="width:50px; ${inputSmallStyle}">
1960*96df7d3eSAtari911                                    <span style="font-size:10px; color:#666;">of each month</span>
1961*96df7d3eSAtari911                                </div>
1962*96df7d3eSAtari911
1963*96df7d3eSAtari911                                <!-- Ordinal weekday -->
1964*96df7d3eSAtari911                                <div id="editMonthlyOrdinal" style="display:${monthlyType === "ordinalWeekday" ? "flex" : "none"}; align-items:center; gap:4px; flex-wrap:wrap;">
1965*96df7d3eSAtari911                                    <select name="ordinal_week" style="width:auto; ${inputSmallStyle}">
1966*96df7d3eSAtari911                                        ${ordinalWeekOpts}
1967*96df7d3eSAtari911                                    </select>
1968*96df7d3eSAtari911                                    <select name="ordinal_day" style="width:auto; ${inputSmallStyle}">
1969*96df7d3eSAtari911                                        ${ordinalDayOpts}
1970*96df7d3eSAtari911                                    </select>
1971*96df7d3eSAtari911                                    <span style="font-size:10px; color:#666;">of each month</span>
1972*96df7d3eSAtari911                                </div>
1973*96df7d3eSAtari911                            </div>
1974*96df7d3eSAtari911
1975*96df7d3eSAtari911                            <!-- Repeat Until -->
19761d05cddcSAtari911                            <div>
1977*96df7d3eSAtari911                                <label style="${labelStyle}">Repeat Until (optional)</label>
1978*96df7d3eSAtari911                                <input type="date" name="recurrence_end" style="width:100%; ${inputSmallStyle}; box-sizing:border-box;">
1979*96df7d3eSAtari911                                <div style="font-size:9px; color:#666; margin-top:2px;">Leave empty to keep existing end date</div>
1980*96df7d3eSAtari911                            </div>
1981*96df7d3eSAtari911                        </div>
1982*96df7d3eSAtari911
1983*96df7d3eSAtari911                        <!-- Namespace -->
1984*96df7d3eSAtari911                        <div>
1985*96df7d3eSAtari911                            <label style="${labelStyle}">�� NAMESPACE</label>
1986*96df7d3eSAtari911                            <select name="new_namespace" style="${inputStyle}">
19871d05cddcSAtari911                                ${nsOptions}
19881d05cddcSAtari911                            </select>
19891d05cddcSAtari911                        </div>
19901d05cddcSAtari911                    </form>
1991*96df7d3eSAtari911
1992*96df7d3eSAtari911                    <!-- Footer buttons - matching event editor -->
1993*96df7d3eSAtari911                    <div style="display:flex; gap:8px; padding:12px 14px; background:#252525; border-top:1px solid #333; flex-shrink:0;">
1994*96df7d3eSAtari911                        <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>
1995*96df7d3eSAtari911                        <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>
1996*96df7d3eSAtari911                    </div>
19971d05cddcSAtari911                </div>
19981d05cddcSAtari911            `;
19991d05cddcSAtari911
20001d05cddcSAtari911            document.body.appendChild(dialog);
20011d05cddcSAtari911
2002*96df7d3eSAtari911            // Toggle functions for recurrence options
2003*96df7d3eSAtari911            window.toggleEditRecOptions = function() {
2004*96df7d3eSAtari911                const type = document.getElementById("editRecType").value;
2005*96df7d3eSAtari911                document.getElementById("editWeeklyOptions").style.display = type === "weekly" ? "block" : "none";
2006*96df7d3eSAtari911                document.getElementById("editMonthlyOptions").style.display = type === "monthly" ? "block" : "none";
2007*96df7d3eSAtari911            };
2008*96df7d3eSAtari911
2009*96df7d3eSAtari911            window.toggleEditMonthlyType = function() {
2010*96df7d3eSAtari911                const radio = document.querySelector("input[name=monthly_type]:checked");
2011*96df7d3eSAtari911                if (radio) {
2012*96df7d3eSAtari911                    document.getElementById("editMonthlyDay").style.display = radio.value === "dayOfMonth" ? "flex" : "none";
2013*96df7d3eSAtari911                    document.getElementById("editMonthlyOrdinal").style.display = radio.value === "ordinalWeekday" ? "flex" : "none";
2014*96df7d3eSAtari911                }
2015*96df7d3eSAtari911            };
2016*96df7d3eSAtari911
20171d05cddcSAtari911            // Add close function to window
20181d05cddcSAtari911            window.closeEditDialog = function() {
20191d05cddcSAtari911                dialog.remove();
20201d05cddcSAtari911            };
20211d05cddcSAtari911
20221d05cddcSAtari911            // Handle form submission
20231d05cddcSAtari911            dialog.querySelector("#editRecurringForm").addEventListener("submit", function(e) {
20241d05cddcSAtari911                e.preventDefault();
20251d05cddcSAtari911                const formData = new FormData(this);
20261d05cddcSAtari911
2027*96df7d3eSAtari911                // Collect weekDays as comma-separated string
2028*96df7d3eSAtari911                const weekDaysArr = [];
2029*96df7d3eSAtari911                document.querySelectorAll("input[name=weekDays]:checked").forEach(cb => {
2030*96df7d3eSAtari911                    weekDaysArr.push(cb.value);
2031*96df7d3eSAtari911                });
2032*96df7d3eSAtari911
20331d05cddcSAtari911                // Submit the edit
20341d05cddcSAtari911                const form = document.createElement("form");
20351d05cddcSAtari911                form.method = "POST";
20361d05cddcSAtari911                form.action = "?do=admin&page=calendar&tab=manage";
20371d05cddcSAtari911
20381d05cddcSAtari911                const actionInput = document.createElement("input");
20391d05cddcSAtari911                actionInput.type = "hidden";
20401d05cddcSAtari911                actionInput.name = "action";
20411d05cddcSAtari911                actionInput.value = "edit_recurring_series";
20421d05cddcSAtari911                form.appendChild(actionInput);
20431d05cddcSAtari911
20441d05cddcSAtari911                const oldTitleInput = document.createElement("input");
20451d05cddcSAtari911                oldTitleInput.type = "hidden";
20461d05cddcSAtari911                oldTitleInput.name = "old_title";
20471d05cddcSAtari911                oldTitleInput.value = title;
20481d05cddcSAtari911                form.appendChild(oldTitleInput);
20491d05cddcSAtari911
20501d05cddcSAtari911                const oldNamespaceInput = document.createElement("input");
20511d05cddcSAtari911                oldNamespaceInput.type = "hidden";
20521d05cddcSAtari911                oldNamespaceInput.name = "old_namespace";
20531d05cddcSAtari911                oldNamespaceInput.value = namespace;
20541d05cddcSAtari911                form.appendChild(oldNamespaceInput);
20551d05cddcSAtari911
2056*96df7d3eSAtari911                // Add weekDays
2057*96df7d3eSAtari911                const weekDaysInput = document.createElement("input");
2058*96df7d3eSAtari911                weekDaysInput.type = "hidden";
2059*96df7d3eSAtari911                weekDaysInput.name = "week_days";
2060*96df7d3eSAtari911                weekDaysInput.value = weekDaysArr.join(",");
2061*96df7d3eSAtari911                form.appendChild(weekDaysInput);
2062*96df7d3eSAtari911
20631d05cddcSAtari911                // Add all form fields
20641d05cddcSAtari911                for (let [key, value] of formData.entries()) {
2065*96df7d3eSAtari911                    if (key === "weekDays") continue; // Skip individual checkboxes
20661d05cddcSAtari911                    const input = document.createElement("input");
20671d05cddcSAtari911                    input.type = "hidden";
20681d05cddcSAtari911                    input.name = key;
20691d05cddcSAtari911                    input.value = value;
20701d05cddcSAtari911                    form.appendChild(input);
20711d05cddcSAtari911                }
20721d05cddcSAtari911
20731d05cddcSAtari911                document.body.appendChild(form);
20741d05cddcSAtari911                form.submit();
20751d05cddcSAtari911            });
20761d05cddcSAtari911        }
20771d05cddcSAtari911
20781d05cddcSAtari911        function deleteRecurringSeries(title, namespace) {
20791d05cddcSAtari911            const displayNs = namespace || "(default)";
20801d05cddcSAtari911            if (!confirm("Delete ALL occurrences of: " + title + " (" + displayNs + ")?\\n\\nThis cannot be undone!")) {
20811d05cddcSAtari911                return;
20821d05cddcSAtari911            }
20831d05cddcSAtari911            const form = document.createElement("form");
20841d05cddcSAtari911            form.method = "POST";
20851d05cddcSAtari911            form.action = "?do=admin&page=calendar&tab=manage";
20861d05cddcSAtari911            const actionInput = document.createElement("input");
20871d05cddcSAtari911            actionInput.type = "hidden";
20881d05cddcSAtari911            actionInput.name = "action";
20891d05cddcSAtari911            actionInput.value = "delete_recurring_series";
20901d05cddcSAtari911            form.appendChild(actionInput);
20911d05cddcSAtari911            const titleInput = document.createElement("input");
20921d05cddcSAtari911            titleInput.type = "hidden";
20931d05cddcSAtari911            titleInput.name = "event_title";
20941d05cddcSAtari911            titleInput.value = title;
20951d05cddcSAtari911            form.appendChild(titleInput);
20961d05cddcSAtari911            const namespaceInput = document.createElement("input");
20971d05cddcSAtari911            namespaceInput.type = "hidden";
20981d05cddcSAtari911            namespaceInput.name = "namespace";
20991d05cddcSAtari911            namespaceInput.value = namespace;
21001d05cddcSAtari911            form.appendChild(namespaceInput);
21011d05cddcSAtari911            document.body.appendChild(form);
21021d05cddcSAtari911            form.submit();
21031d05cddcSAtari911        }
21041d05cddcSAtari911
21051d05cddcSAtari911        document.addEventListener("dragend", function(e) {
21061d05cddcSAtari911            if (e.target.draggable) {
21071d05cddcSAtari911                e.target.style.opacity = "1";
21081d05cddcSAtari911            }
21091d05cddcSAtari911        });
21101d05cddcSAtari911        </script>';
21111d05cddcSAtari911    }
21121d05cddcSAtari911
21139ccd446eSAtari911    private function renderUpdateTab($colors = null) {
21141d05cddcSAtari911        global $INPUT;
21151d05cddcSAtari911
21169ccd446eSAtari911        // Use defaults if not provided
21179ccd446eSAtari911        if ($colors === null) {
21189ccd446eSAtari911            $colors = $this->getTemplateColors();
21199ccd446eSAtari911        }
21201d05cddcSAtari911
21219ccd446eSAtari911        echo '<h2 style="margin:10px 0; font-size:20px;">�� Update Plugin</h2>';
21221d05cddcSAtari911
21231d05cddcSAtari911        // Show message if present
21241d05cddcSAtari911        if ($INPUT->has('msg')) {
21251d05cddcSAtari911            $msg = hsc($INPUT->str('msg'));
21261d05cddcSAtari911            $type = $INPUT->str('msgtype', 'success');
21271d05cddcSAtari911            $class = ($type === 'success') ? 'msg success' : 'msg error';
21289ccd446eSAtari911            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;\">";
21291d05cddcSAtari911            echo $msg;
21301d05cddcSAtari911            echo "</div>";
21311d05cddcSAtari911        }
21321d05cddcSAtari911
21339ccd446eSAtari911        // Show current version FIRST (MOVED TO TOP)
21341d05cddcSAtari911        $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt';
21351d05cddcSAtari911        $info = ['version' => 'Unknown', 'date' => 'Unknown', 'name' => 'Calendar Plugin', 'author' => 'Unknown', 'email' => '', 'desc' => ''];
21361d05cddcSAtari911        if (file_exists($pluginInfo)) {
21371d05cddcSAtari911            $info = array_merge($info, confToHash($pluginInfo));
21381d05cddcSAtari911        }
21391d05cddcSAtari911
21409ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
21419ccd446eSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Current Version</h3>';
21421d05cddcSAtari911        echo '<div style="font-size:12px; line-height:1.6;">';
21431d05cddcSAtari911        echo '<div style="margin:4px 0;"><strong>Version:</strong> ' . hsc($info['version']) . ' (' . hsc($info['date']) . ')</div>';
21441d05cddcSAtari911        echo '<div style="margin:4px 0;"><strong>Author:</strong> ' . hsc($info['author']) . ($info['email'] ? ' &lt;' . hsc($info['email']) . '&gt;' : '') . '</div>';
21451d05cddcSAtari911        if ($info['desc']) {
21461d05cddcSAtari911            echo '<div style="margin:4px 0;"><strong>Description:</strong> ' . hsc($info['desc']) . '</div>';
21471d05cddcSAtari911        }
21481d05cddcSAtari911        echo '<div style="margin:4px 0;"><strong>Location:</strong> <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">' . DOKU_PLUGIN . 'calendar/</code></div>';
21491d05cddcSAtari911        echo '</div>';
21501d05cddcSAtari911
21511d05cddcSAtari911        // Check permissions
21521d05cddcSAtari911        $pluginDir = DOKU_PLUGIN . 'calendar/';
21531d05cddcSAtari911        $pluginWritable = is_writable($pluginDir);
21541d05cddcSAtari911        $parentWritable = is_writable(DOKU_PLUGIN);
21551d05cddcSAtari911
21569ccd446eSAtari911        echo '<div style="margin-top:8px; padding-top:8px; border-top:1px solid ' . $colors['border'] . ';">';
21571d05cddcSAtari911        if ($pluginWritable && $parentWritable) {
21581d05cddcSAtari911            echo '<p style="margin:5px 0; font-size:13px; color:#28a745;"><strong>✅ Permissions:</strong> OK - ready to update</p>';
21591d05cddcSAtari911        } else {
21601d05cddcSAtari911            echo '<p style="margin:5px 0; font-size:13px; color:#dc3545;"><strong>❌ Permissions:</strong> Issues detected</p>';
21611d05cddcSAtari911            if (!$pluginWritable) {
21621d05cddcSAtari911                echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Plugin directory not writable</p>';
21631d05cddcSAtari911            }
21641d05cddcSAtari911            if (!$parentWritable) {
21651d05cddcSAtari911                echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Parent directory not writable</p>';
21661d05cddcSAtari911            }
21679ccd446eSAtari911            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>';
21689ccd446eSAtari911            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>';
21691d05cddcSAtari911        }
21701d05cddcSAtari911        echo '</div>';
21711d05cddcSAtari911
21721d05cddcSAtari911        echo '</div>';
21731d05cddcSAtari911
21749ccd446eSAtari911        // Combined upload and notes section (SIDE BY SIDE)
21759ccd446eSAtari911        echo '<div style="display:flex; gap:15px; max-width:1200px; margin:10px 0;">';
21761d05cddcSAtari911
21779ccd446eSAtari911        // Left side - Upload form (60% width)
21789ccd446eSAtari911        echo '<div style="flex:1; min-width:0; background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">';
21799ccd446eSAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Upload New Version</h3>';
21809ccd446eSAtari911        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>';
21811d05cddcSAtari911
21821d05cddcSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=update" enctype="multipart/form-data" id="uploadForm">';
21831d05cddcSAtari911        echo '<input type="hidden" name="action" value="upload_update">';
21841d05cddcSAtari911        echo '<div style="margin:10px 0;">';
21859ccd446eSAtari911        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%;">';
21861d05cddcSAtari911        echo '</div>';
21871d05cddcSAtari911        echo '<div style="margin:10px 0;">';
21881d05cddcSAtari911        echo '<label style="display:flex; align-items:center; gap:8px; font-size:13px;">';
21891d05cddcSAtari911        echo '<input type="checkbox" name="backup_first" value="1" checked>';
21901d05cddcSAtari911        echo '<span>Create backup before updating (Recommended)</span>';
21911d05cddcSAtari911        echo '</label>';
21921d05cddcSAtari911        echo '</div>';
21939ccd446eSAtari911
21949ccd446eSAtari911        // Buttons side by side
21959ccd446eSAtari911        echo '<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">';
21961d05cddcSAtari911        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>';
21971d05cddcSAtari911        echo '</form>';
21989ccd446eSAtari911
21999ccd446eSAtari911        // Clear Cache button (next to Upload button)
22009ccd446eSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="display:inline; margin:0;">';
22019ccd446eSAtari911        echo '<input type="hidden" name="action" value="clear_cache">';
22029ccd446eSAtari911        echo '<input type="hidden" name="tab" value="update">';
22039ccd446eSAtari911        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>';
22049ccd446eSAtari911        echo '</form>';
22051d05cddcSAtari911        echo '</div>';
22061d05cddcSAtari911
22079ccd446eSAtari911        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>';
22089ccd446eSAtari911        echo '</div>';
22099ccd446eSAtari911
22109ccd446eSAtari911        // Right side - Important Notes (40% width)
22119ccd446eSAtari911        echo '<div style="flex:0 0 350px; min-width:0; background:#fff3e0; border-left:3px solid #ff9800; padding:12px; border-radius:3px;">';
22121d05cddcSAtari911        echo '<h4 style="margin:0 0 5px 0; color:#e65100; font-size:14px;">⚠️ Important Notes</h4>';
22139ccd446eSAtari911        echo '<ul style="margin:5px 0; padding-left:20px; font-size:12px; color:#e65100; line-height:1.6;">';
22141d05cddcSAtari911        echo '<li>This will replace all plugin files</li>';
22151d05cddcSAtari911        echo '<li>Configuration files (sync_config.php) will be preserved</li>';
22161d05cddcSAtari911        echo '<li>Event data will not be affected</li>';
22179ccd446eSAtari911        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>';
22181d05cddcSAtari911        echo '<li>Make sure the ZIP file is a valid calendar plugin</li>';
22191d05cddcSAtari911        echo '</ul>';
22201d05cddcSAtari911        echo '</div>';
22211d05cddcSAtari911
22229ccd446eSAtari911        echo '</div>'; // End flex container
22239ccd446eSAtari911
22249ccd446eSAtari911        // Changelog section - Timeline viewer
22257e8ea635SAtari911        echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">';
22267e8ea635SAtari911        echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">�� Version History</h3>';
22279ccd446eSAtari911
22289ccd446eSAtari911        $changelogFile = DOKU_PLUGIN . 'calendar/CHANGELOG.md';
22299ccd446eSAtari911        if (file_exists($changelogFile)) {
22309ccd446eSAtari911            $changelog = file_get_contents($changelogFile);
22319ccd446eSAtari911
22329ccd446eSAtari911            // Parse ALL versions into structured data
22339ccd446eSAtari911            $lines = explode("\n", $changelog);
22349ccd446eSAtari911            $versions = [];
22359ccd446eSAtari911            $currentVersion = null;
22367e8ea635SAtari911            $currentSubsection = '';
22379ccd446eSAtari911
22389ccd446eSAtari911            foreach ($lines as $line) {
22397e8ea635SAtari911                $trimmed = trim($line);
22409ccd446eSAtari911
22419ccd446eSAtari911                // Version header (## Version X.X.X or ## Version X.X.X (date) - title)
22427e8ea635SAtari911                if (preg_match('/^## Version (.+?)(?:\s*\(([^)]+)\))?\s*(?:-\s*(.+))?$/', $trimmed, $matches)) {
22439ccd446eSAtari911                    if ($currentVersion !== null) {
22449ccd446eSAtari911                        $versions[] = $currentVersion;
22459ccd446eSAtari911                    }
22469ccd446eSAtari911                    $currentVersion = [
22479ccd446eSAtari911                        'number' => trim($matches[1]),
22489ccd446eSAtari911                        'date' => isset($matches[2]) ? trim($matches[2]) : '',
22499ccd446eSAtari911                        'title' => isset($matches[3]) ? trim($matches[3]) : '',
22509ccd446eSAtari911                        'items' => []
22519ccd446eSAtari911                    ];
22527e8ea635SAtari911                    $currentSubsection = '';
22539ccd446eSAtari911                }
22547e8ea635SAtari911                // Subsection header (### Something)
22557e8ea635SAtari911                elseif ($currentVersion !== null && preg_match('/^### (.+)$/', $trimmed, $matches)) {
22567e8ea635SAtari911                    $currentSubsection = trim($matches[1]);
22579ccd446eSAtari911                    $currentVersion['items'][] = [
22587e8ea635SAtari911                        'type' => 'section',
22597e8ea635SAtari911                        'desc' => $currentSubsection
22607e8ea635SAtari911                    ];
22617e8ea635SAtari911                }
22627e8ea635SAtari911                // Formatted item (- **Type:** description)
22637e8ea635SAtari911                elseif ($currentVersion !== null && preg_match('/^- \*\*(.+?):\*\*\s*(.+)$/', $trimmed, $matches)) {
22647e8ea635SAtari911                    $currentVersion['items'][] = [
22657e8ea635SAtari911                        'type' => trim($matches[1]),
22667e8ea635SAtari911                        'desc' => trim($matches[2])
22677e8ea635SAtari911                    ];
22687e8ea635SAtari911                }
22697e8ea635SAtari911                // Plain bullet item (- something)
22707e8ea635SAtari911                elseif ($currentVersion !== null && preg_match('/^- (.+)$/', $trimmed, $matches)) {
22717e8ea635SAtari911                    $currentVersion['items'][] = [
22727e8ea635SAtari911                        'type' => $currentSubsection ?: 'Changed',
22737e8ea635SAtari911                        'desc' => trim($matches[1])
22749ccd446eSAtari911                    ];
22759ccd446eSAtari911                }
22769ccd446eSAtari911            }
22777e8ea635SAtari911            // Don't forget last version
22789ccd446eSAtari911            if ($currentVersion !== null) {
22799ccd446eSAtari911                $versions[] = $currentVersion;
22809ccd446eSAtari911            }
22819ccd446eSAtari911
22829ccd446eSAtari911            $totalVersions = count($versions);
22839ccd446eSAtari911            $uniqueId = 'changelog_' . substr(md5(microtime()), 0, 6);
22849ccd446eSAtari911
22857e8ea635SAtari911            // Find the index of the currently running version
22867e8ea635SAtari911            $runningVersion = trim($info['version']);
22877e8ea635SAtari911            $runningIndex = 0;
22887e8ea635SAtari911            foreach ($versions as $idx => $ver) {
22897e8ea635SAtari911                if (trim($ver['number']) === $runningVersion) {
22907e8ea635SAtari911                    $runningIndex = $idx;
22917e8ea635SAtari911                    break;
22927e8ea635SAtari911                }
22937e8ea635SAtari911            }
22947e8ea635SAtari911
22959ccd446eSAtari911            if ($totalVersions > 0) {
22969ccd446eSAtari911                // Timeline navigation bar
22979ccd446eSAtari911                echo '<div id="' . $uniqueId . '_wrap" style="position:relative;">';
22989ccd446eSAtari911
22999ccd446eSAtari911                // Nav controls
23009ccd446eSAtari911                echo '<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">';
23017e8ea635SAtari911                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>';
23027e8ea635SAtari911                echo '<div style="flex:1; text-align:center; display:flex; align-items:center; justify-content:center; gap:10px;">';
23039ccd446eSAtari911                echo '<span id="' . $uniqueId . '_counter" style="font-size:11px; color:' . $colors['text'] . '; opacity:0.7;">1 of ' . $totalVersions . '</span>';
23047e8ea635SAtari911                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>';
23059ccd446eSAtari911                echo '</div>';
23067e8ea635SAtari911                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>';
23079ccd446eSAtari911                echo '</div>';
23089ccd446eSAtari911
23099ccd446eSAtari911                // Version cards (one per version, only first visible)
23109ccd446eSAtari911                foreach ($versions as $i => $ver) {
23119ccd446eSAtari911                    $display = ($i === 0) ? 'block' : 'none';
23127e8ea635SAtari911                    $isRunning = (trim($ver['number']) === $runningVersion);
23137e8ea635SAtari911                    $cardBorder = $isRunning ? '2px solid #00cc07' : '1px solid ' . $colors['border'];
23147e8ea635SAtari911                    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;">';
23159ccd446eSAtari911
23169ccd446eSAtari911                    // Version header
23179ccd446eSAtari911                    echo '<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px;">';
23187e8ea635SAtari911                    echo '<span style="font-weight:bold; color:#00cc07; font-size:14px;">v' . hsc($ver['number']) . '</span>';
23197e8ea635SAtari911                    if ($isRunning) {
23207e8ea635SAtari911                        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>';
23217e8ea635SAtari911                    }
23229ccd446eSAtari911                    if ($ver['date']) {
23239ccd446eSAtari911                        echo '<span style="font-size:11px; color:' . $colors['text'] . '; opacity:0.6;">' . hsc($ver['date']) . '</span>';
23249ccd446eSAtari911                    }
23259ccd446eSAtari911                    echo '</div>';
23269ccd446eSAtari911                    if ($ver['title']) {
23279ccd446eSAtari911                        echo '<div style="font-size:12px; font-weight:600; color:' . $colors['text'] . '; margin-bottom:8px;">' . hsc($ver['title']) . '</div>';
23289ccd446eSAtari911                    }
23299ccd446eSAtari911
23309ccd446eSAtari911                    // Change items
23319ccd446eSAtari911                    if (!empty($ver['items'])) {
23329ccd446eSAtari911                        echo '<div style="font-size:12px; line-height:1.7;">';
23339ccd446eSAtari911                        foreach ($ver['items'] as $item) {
23347e8ea635SAtari911                            if ($item['type'] === 'section') {
23357e8ea635SAtari911                                echo '<div style="margin:6px 0 2px 0; font-weight:700; color:#00cc07; font-size:11px; letter-spacing:0.3px;">' . hsc($item['desc']) . '</div>';
23367e8ea635SAtari911                                continue;
23377e8ea635SAtari911                            }
23389ccd446eSAtari911                            $color = '#666'; $icon = '•';
23399ccd446eSAtari911                            $t = $item['type'];
23407e8ea635SAtari911                            if ($t === 'Added' || $t === 'New') { $color = '#28a745'; $icon = '✨'; }
23417e8ea635SAtari911                            elseif ($t === 'Fixed' || $t === 'Fix' || $t === 'Bug Fix') { $color = '#dc3545'; $icon = '��'; }
23427e8ea635SAtari911                            elseif ($t === 'Changed' || $t === 'Change') { $color = '#00cc07'; $icon = '��'; }
23437e8ea635SAtari911                            elseif ($t === 'Improved' || $t === 'Enhancement') { $color = '#ff9800'; $icon = '⚡'; }
23449ccd446eSAtari911                            elseif ($t === 'Removed') { $color = '#e91e63'; $icon = '��️'; }
23459ccd446eSAtari911                            elseif ($t === 'Development' || $t === 'Refactored') { $color = '#6c757d'; $icon = '��️'; }
23469ccd446eSAtari911                            elseif ($t === 'Result') { $color = '#2196f3'; $icon = '✅'; }
23477e8ea635SAtari911                            else { $color = $colors['text']; $icon = '•'; }
23489ccd446eSAtari911
23499ccd446eSAtari911                            echo '<div style="margin:2px 0; padding-left:4px;">';
23509ccd446eSAtari911                            echo '<span style="color:' . $color . '; font-weight:600;">' . $icon . ' ' . hsc($item['type']) . ':</span> ';
23519ccd446eSAtari911                            echo '<span style="color:' . $colors['text'] . ';">' . hsc($item['desc']) . '</span>';
23529ccd446eSAtari911                            echo '</div>';
23539ccd446eSAtari911                        }
23549ccd446eSAtari911                        echo '</div>';
23559ccd446eSAtari911                    } else {
23569ccd446eSAtari911                        echo '<div style="font-size:11px; color:' . $colors['text'] . '; opacity:0.5; font-style:italic;">No detailed changes recorded</div>';
23579ccd446eSAtari911                    }
23589ccd446eSAtari911
23599ccd446eSAtari911                    echo '</div>';
23609ccd446eSAtari911                }
23619ccd446eSAtari911
23629ccd446eSAtari911                echo '</div>'; // wrap
23639ccd446eSAtari911
23649ccd446eSAtari911                // JavaScript for navigation
23659ccd446eSAtari911                echo '<script>
23669ccd446eSAtari911                (function() {
23679ccd446eSAtari911                    var id = "' . $uniqueId . '";
23689ccd446eSAtari911                    var total = ' . $totalVersions . ';
23699ccd446eSAtari911                    var current = 0;
23709ccd446eSAtari911
23717e8ea635SAtari911                    function showCard(idx) {
23729ccd446eSAtari911                        // Hide current
23739ccd446eSAtari911                        var curCard = document.getElementById(id + "_card_" + current);
23749ccd446eSAtari911                        if (curCard) curCard.style.display = "none";
23759ccd446eSAtari911
23767e8ea635SAtari911                        // Show target
23777e8ea635SAtari911                        current = idx;
23789ccd446eSAtari911                        var nextCard = document.getElementById(id + "_card_" + current);
23799ccd446eSAtari911                        if (nextCard) nextCard.style.display = "block";
23809ccd446eSAtari911
23819ccd446eSAtari911                        // Update counter
23829ccd446eSAtari911                        var counter = document.getElementById(id + "_counter");
23839ccd446eSAtari911                        if (counter) counter.textContent = (current + 1) + " of " + total;
23849ccd446eSAtari911
23859ccd446eSAtari911                        // Update button states
23869ccd446eSAtari911                        var prevBtn = document.getElementById(id + "_prev");
23879ccd446eSAtari911                        var nextBtn = document.getElementById(id + "_next");
23889ccd446eSAtari911                        if (prevBtn) prevBtn.style.opacity = (current === 0) ? "0.3" : "1";
23899ccd446eSAtari911                        if (nextBtn) nextBtn.style.opacity = (current === total - 1) ? "0.3" : "1";
23907e8ea635SAtari911                    }
23917e8ea635SAtari911
23927e8ea635SAtari911                    window.changelogNav = function(uid, dir) {
23937e8ea635SAtari911                        if (uid !== id) return;
23947e8ea635SAtari911                        var next = current + dir;
23957e8ea635SAtari911                        if (next < 0 || next >= total) return;
23967e8ea635SAtari911                        showCard(next);
23977e8ea635SAtari911                    };
23987e8ea635SAtari911
23997e8ea635SAtari911                    window.changelogJumpTo = function(uid, idx) {
24007e8ea635SAtari911                        if (uid !== id) return;
24017e8ea635SAtari911                        if (idx < 0 || idx >= total) return;
24027e8ea635SAtari911                        showCard(idx);
24039ccd446eSAtari911                    };
24049ccd446eSAtari911
24059ccd446eSAtari911                    // Initialize button states
24069ccd446eSAtari911                    var prevBtn = document.getElementById(id + "_prev");
24079ccd446eSAtari911                    if (prevBtn) prevBtn.style.opacity = "0.3";
24089ccd446eSAtari911                })();
24099ccd446eSAtari911                </script>';
24109ccd446eSAtari911
24119ccd446eSAtari911            } else {
24129ccd446eSAtari911                echo '<p style="color:#999; font-size:13px; font-style:italic;">No versions found in changelog</p>';
24139ccd446eSAtari911            }
24149ccd446eSAtari911        } else {
24159ccd446eSAtari911            echo '<p style="color:#999; font-size:13px; font-style:italic;">Changelog not available</p>';
24169ccd446eSAtari911        }
24179ccd446eSAtari911
24189ccd446eSAtari911        echo '</div>';
24199ccd446eSAtari911
24209ccd446eSAtari911        // Backup list or manual backup section
24211d05cddcSAtari911        $backupDir = DOKU_PLUGIN;
24221d05cddcSAtari911        $backups = glob($backupDir . 'calendar*.zip');
24231d05cddcSAtari911
24241d05cddcSAtari911        // Filter to only show files that look like backups (not the uploaded plugin files)
24251d05cddcSAtari911        $backups = array_filter($backups, function($file) {
24261d05cddcSAtari911            $name = basename($file);
24271d05cddcSAtari911            // Include files that start with "calendar" but exclude files that are just "calendar.zip" (uploaded plugin)
24281d05cddcSAtari911            return $name !== 'calendar.zip';
24291d05cddcSAtari911        });
24301d05cddcSAtari911
24319ccd446eSAtari911        // Always show backup section (even if no backups yet)
24329ccd446eSAtari911        echo '<div id="backupSection" style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">';
24339ccd446eSAtari911        echo '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">';
24349ccd446eSAtari911        echo '<h3 style="margin:0; color:#00cc07; font-size:16px;">�� Backups</h3>';
24359ccd446eSAtari911
24369ccd446eSAtari911        // Manual backup button
24379ccd446eSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="margin:0;">';
24389ccd446eSAtari911        echo '<input type="hidden" name="action" value="create_manual_backup">';
24399ccd446eSAtari911        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>';
24409ccd446eSAtari911        echo '</form>';
24419ccd446eSAtari911        echo '</div>';
24429ccd446eSAtari911
2443*96df7d3eSAtari911        // Restore instructions note
2444*96df7d3eSAtari911        echo '<div style="background:#1a2d1a; border:1px solid #00cc07; border-radius:3px; padding:8px 12px; margin-bottom:10px;">';
2445*96df7d3eSAtari911        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>';
2446*96df7d3eSAtari911        echo '</div>';
2447*96df7d3eSAtari911
24481d05cddcSAtari911        if (!empty($backups)) {
24491d05cddcSAtari911            rsort($backups); // Newest first
2450*96df7d3eSAtari911
2451*96df7d3eSAtari911            // Bulk action bar
2452*96df7d3eSAtari911            echo '<div id="bulkActionBar" style="display:flex; align-items:center; gap:10px; margin-bottom:8px; padding:6px 10px; background:#333; border-radius:3px;">';
2453*96df7d3eSAtari911            echo '<label style="display:flex; align-items:center; gap:5px; color:#ccc; font-size:12px; cursor:pointer;">';
2454*96df7d3eSAtari911            echo '<input type="checkbox" id="selectAllBackups" onchange="toggleAllBackups(this)" style="width:16px; height:16px;">';
2455*96df7d3eSAtari911            echo 'Select All</label>';
2456*96df7d3eSAtari911            echo '<span id="selectedCount" style="color:#888; font-size:11px;">(0 selected)</span>';
2457*96df7d3eSAtari911            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>';
2458*96df7d3eSAtari911            echo '</div>';
2459*96df7d3eSAtari911
24609ccd446eSAtari911            echo '<div style="max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">';
24619ccd446eSAtari911            echo '<table id="backupTable" style="width:100%; border-collapse:collapse; font-size:12px;">';
24621d05cddcSAtari911            echo '<thead style="position:sticky; top:0; background:#e9e9e9;">';
24631d05cddcSAtari911            echo '<tr>';
2464*96df7d3eSAtari911            echo '<th style="padding:6px; text-align:center; border-bottom:2px solid ' . $colors['border'] . '; width:30px;"></th>';
24659ccd446eSAtari911            echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Backup File</th>';
24669ccd446eSAtari911            echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Size</th>';
24679ccd446eSAtari911            echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Actions</th>';
24681d05cddcSAtari911            echo '</tr></thead><tbody>';
24691d05cddcSAtari911
24701d05cddcSAtari911            foreach ($backups as $backup) {
24711d05cddcSAtari911                $filename = basename($backup);
24721d05cddcSAtari911                $size = $this->formatBytes(filesize($backup));
2473*96df7d3eSAtari911                echo '<tr style="border-bottom:1px solid #eee;" data-filename="' . hsc($filename) . '">';
2474*96df7d3eSAtari911                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>';
24751d05cddcSAtari911                echo '<td style="padding:6px;"><code style="font-size:11px;">' . hsc($filename) . '</code></td>';
24761d05cddcSAtari911                echo '<td style="padding:6px;">' . $size . '</td>';
24771d05cddcSAtari911                echo '<td style="padding:6px; white-space:nowrap;">';
24781d05cddcSAtari911                echo '<a href="' . DOKU_BASE . 'lib/plugins/' . hsc($filename) . '" download style="color:#00cc07; text-decoration:none; font-size:11px; margin-right:10px;">�� Download</a>';
24791d05cddcSAtari911                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>';
2480*96df7d3eSAtari911                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>';
24811d05cddcSAtari911                echo '</td>';
24821d05cddcSAtari911                echo '</tr>';
24831d05cddcSAtari911            }
24841d05cddcSAtari911
24851d05cddcSAtari911            echo '</tbody></table>';
24861d05cddcSAtari911            echo '</div>';
24879ccd446eSAtari911        } else {
24889ccd446eSAtari911            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>';
24891d05cddcSAtari911        }
24909ccd446eSAtari911        echo '</div>';
24911d05cddcSAtari911
24921d05cddcSAtari911        echo '<script>
24931d05cddcSAtari911        function confirmUpload() {
24941d05cddcSAtari911            const fileInput = document.querySelector(\'input[name="plugin_zip"]\');
24951d05cddcSAtari911            if (!fileInput.files[0]) {
24961d05cddcSAtari911                alert("Please select a ZIP file");
24971d05cddcSAtari911                return false;
24981d05cddcSAtari911            }
24991d05cddcSAtari911
25001d05cddcSAtari911            const fileName = fileInput.files[0].name;
25011d05cddcSAtari911            if (!fileName.endsWith(".zip")) {
25021d05cddcSAtari911                alert("Please select a ZIP file");
25031d05cddcSAtari911                return false;
25041d05cddcSAtari911            }
25051d05cddcSAtari911
25061d05cddcSAtari911            return confirm("Upload and install: " + fileName + "?\\n\\nThis will replace all plugin files.\\nYour configuration and data will be preserved.\\n\\nContinue?");
25071d05cddcSAtari911        }
25081d05cddcSAtari911
2509*96df7d3eSAtari911        // Toggle all backup checkboxes
2510*96df7d3eSAtari911        function toggleAllBackups(selectAllCheckbox) {
2511*96df7d3eSAtari911            const checkboxes = document.querySelectorAll(\'.backup-checkbox\');
2512*96df7d3eSAtari911            checkboxes.forEach(cb => cb.checked = selectAllCheckbox.checked);
2513*96df7d3eSAtari911            updateSelectedCount();
2514*96df7d3eSAtari911        }
2515*96df7d3eSAtari911
2516*96df7d3eSAtari911        // Update the selected count display
2517*96df7d3eSAtari911        function updateSelectedCount() {
2518*96df7d3eSAtari911            const checkboxes = document.querySelectorAll(\'.backup-checkbox:checked\');
2519*96df7d3eSAtari911            const count = checkboxes.length;
2520*96df7d3eSAtari911            const countSpan = document.getElementById(\'selectedCount\');
2521*96df7d3eSAtari911            const bulkDeleteBtn = document.getElementById(\'bulkDeleteBtn\');
2522*96df7d3eSAtari911            const selectAllCheckbox = document.getElementById(\'selectAllBackups\');
2523*96df7d3eSAtari911            const totalCheckboxes = document.querySelectorAll(\'.backup-checkbox\').length;
2524*96df7d3eSAtari911
2525*96df7d3eSAtari911            if (countSpan) countSpan.textContent = \'(\' + count + \' selected)\';
2526*96df7d3eSAtari911            if (bulkDeleteBtn) bulkDeleteBtn.style.display = count > 0 ? \'block\' : \'none\';
2527*96df7d3eSAtari911            if (selectAllCheckbox) selectAllCheckbox.checked = (count === totalCheckboxes && count > 0);
2528*96df7d3eSAtari911        }
2529*96df7d3eSAtari911
2530*96df7d3eSAtari911        // Delete selected backups
2531*96df7d3eSAtari911        function deleteSelectedBackups() {
2532*96df7d3eSAtari911            const checkboxes = document.querySelectorAll(\'.backup-checkbox:checked\');
2533*96df7d3eSAtari911            const filenames = Array.from(checkboxes).map(cb => cb.value);
2534*96df7d3eSAtari911
2535*96df7d3eSAtari911            if (filenames.length === 0) {
2536*96df7d3eSAtari911                alert(\'No backups selected\');
25371d05cddcSAtari911                return;
25381d05cddcSAtari911            }
25391d05cddcSAtari911
2540*96df7d3eSAtari911            if (!confirm(\'Delete \' + filenames.length + \' selected backup(s)?\\n\\n\' + filenames.join(\'\\n\') + \'\\n\\nThis cannot be undone!\')) {
2541*96df7d3eSAtari911                return;
2542*96df7d3eSAtari911            }
2543*96df7d3eSAtari911
2544*96df7d3eSAtari911            // Delete each backup sequentially
2545*96df7d3eSAtari911            let deleted = 0;
2546*96df7d3eSAtari911            let errors = [];
2547*96df7d3eSAtari911
2548*96df7d3eSAtari911            function deleteNext(index) {
2549*96df7d3eSAtari911                if (index >= filenames.length) {
2550*96df7d3eSAtari911                    // All done
2551*96df7d3eSAtari911                    if (errors.length > 0) {
2552*96df7d3eSAtari911                        alert(\'Deleted \' + deleted + \' backups. Errors: \' + errors.join(\', \'));
2553*96df7d3eSAtari911                    }
2554*96df7d3eSAtari911                    updateSelectedCount();
2555*96df7d3eSAtari911
2556*96df7d3eSAtari911                    // Check if table is now empty
2557*96df7d3eSAtari911                    const tbody = document.querySelector(\'#backupTable tbody\');
2558*96df7d3eSAtari911                    if (tbody && tbody.children.length === 0) {
2559*96df7d3eSAtari911                        location.reload();
2560*96df7d3eSAtari911                    }
2561*96df7d3eSAtari911                    return;
2562*96df7d3eSAtari911                }
2563*96df7d3eSAtari911
2564*96df7d3eSAtari911                const filename = filenames[index];
25659ccd446eSAtari911                const formData = new FormData();
25669ccd446eSAtari911                formData.append(\'action\', \'delete_backup\');
25679ccd446eSAtari911                formData.append(\'backup_file\', filename);
25681d05cddcSAtari911
25699ccd446eSAtari911                fetch(\'?do=admin&page=calendar&tab=update\', {
25709ccd446eSAtari911                    method: \'POST\',
25719ccd446eSAtari911                    body: formData
25729ccd446eSAtari911                })
25739ccd446eSAtari911                .then(response => response.text())
25749ccd446eSAtari911                .then(data => {
25759ccd446eSAtari911                    // Remove the row from the table
2576*96df7d3eSAtari911                    const row = document.querySelector(\'tr[data-filename="\' + filename + \'"]\');
2577*96df7d3eSAtari911                    if (row) {
2578*96df7d3eSAtari911                        row.style.transition = \'opacity 0.2s\';
25799ccd446eSAtari911                        row.style.opacity = \'0\';
2580*96df7d3eSAtari911                        setTimeout(() => row.remove(), 200);
25819ccd446eSAtari911                    }
2582*96df7d3eSAtari911                    deleted++;
2583*96df7d3eSAtari911                    deleteNext(index + 1);
25849ccd446eSAtari911                })
25859ccd446eSAtari911                .catch(error => {
2586*96df7d3eSAtari911                    errors.push(filename);
2587*96df7d3eSAtari911                    deleteNext(index + 1);
25889ccd446eSAtari911                });
25891d05cddcSAtari911            }
25901d05cddcSAtari911
2591*96df7d3eSAtari911            deleteNext(0);
2592*96df7d3eSAtari911        }
2593*96df7d3eSAtari911
25941d05cddcSAtari911        function restoreBackup(filename) {
2595*96df7d3eSAtari911            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?")) {
25961d05cddcSAtari911                return;
25971d05cddcSAtari911            }
25981d05cddcSAtari911
25991d05cddcSAtari911            const form = document.createElement("form");
26001d05cddcSAtari911            form.method = "POST";
26011d05cddcSAtari911            form.action = "?do=admin&page=calendar&tab=update";
26021d05cddcSAtari911
26031d05cddcSAtari911            const actionInput = document.createElement("input");
26041d05cddcSAtari911            actionInput.type = "hidden";
26051d05cddcSAtari911            actionInput.name = "action";
26061d05cddcSAtari911            actionInput.value = "restore_backup";
26071d05cddcSAtari911            form.appendChild(actionInput);
26081d05cddcSAtari911
26091d05cddcSAtari911            const filenameInput = document.createElement("input");
26101d05cddcSAtari911            filenameInput.type = "hidden";
26111d05cddcSAtari911            filenameInput.name = "backup_file";
26121d05cddcSAtari911            filenameInput.value = filename;
26131d05cddcSAtari911            form.appendChild(filenameInput);
26141d05cddcSAtari911
26151d05cddcSAtari911            document.body.appendChild(form);
26161d05cddcSAtari911            form.submit();
26171d05cddcSAtari911        }
26181d05cddcSAtari911
26191d05cddcSAtari911        function renameBackup(filename) {
26201d05cddcSAtari911            const newName = prompt("Enter new backup name (without .zip extension):\\n\\nCurrent: " + filename.replace(/\\.zip$/, ""), filename.replace(/\\.zip$/, ""));
26211d05cddcSAtari911            if (!newName || newName === filename.replace(/\\.zip$/, "")) {
26221d05cddcSAtari911                return;
26231d05cddcSAtari911            }
26241d05cddcSAtari911
26251d05cddcSAtari911            // Add .zip if not present
26261d05cddcSAtari911            const newFilename = newName.endsWith(".zip") ? newName : newName + ".zip";
26271d05cddcSAtari911
26281d05cddcSAtari911            // Basic validation
26291d05cddcSAtari911            if (!/^[a-zA-Z0-9._-]+$/.test(newFilename.replace(/\\.zip$/, ""))) {
26301d05cddcSAtari911                alert("Invalid filename. Use only letters, numbers, dots, dashes, and underscores.");
26311d05cddcSAtari911                return;
26321d05cddcSAtari911            }
26331d05cddcSAtari911
26341d05cddcSAtari911            const form = document.createElement("form");
26351d05cddcSAtari911            form.method = "POST";
26361d05cddcSAtari911            form.action = "?do=admin&page=calendar&tab=update";
26371d05cddcSAtari911
26381d05cddcSAtari911            const actionInput = document.createElement("input");
26391d05cddcSAtari911            actionInput.type = "hidden";
26401d05cddcSAtari911            actionInput.name = "action";
26411d05cddcSAtari911            actionInput.value = "rename_backup";
26421d05cddcSAtari911            form.appendChild(actionInput);
26431d05cddcSAtari911
26441d05cddcSAtari911            const oldNameInput = document.createElement("input");
26451d05cddcSAtari911            oldNameInput.type = "hidden";
26461d05cddcSAtari911            oldNameInput.name = "old_name";
26471d05cddcSAtari911            oldNameInput.value = filename;
26481d05cddcSAtari911            form.appendChild(oldNameInput);
26491d05cddcSAtari911
26501d05cddcSAtari911            const newNameInput = document.createElement("input");
26511d05cddcSAtari911            newNameInput.type = "hidden";
26521d05cddcSAtari911            newNameInput.name = "new_name";
26531d05cddcSAtari911            newNameInput.value = newFilename;
26541d05cddcSAtari911            form.appendChild(newNameInput);
26551d05cddcSAtari911
26561d05cddcSAtari911            document.body.appendChild(form);
26571d05cddcSAtari911            form.submit();
26581d05cddcSAtari911        }
26591d05cddcSAtari911        </script>';
26601d05cddcSAtari911    }
26611d05cddcSAtari911
26621d05cddcSAtari911    private function saveConfig() {
26631d05cddcSAtari911        global $INPUT;
26641d05cddcSAtari911
26651d05cddcSAtari911        // Load existing config to preserve all settings
26661d05cddcSAtari911        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
26671d05cddcSAtari911        $existingConfig = [];
26681d05cddcSAtari911        if (file_exists($configFile)) {
26691d05cddcSAtari911            $existingConfig = include $configFile;
26701d05cddcSAtari911        }
26711d05cddcSAtari911
26721d05cddcSAtari911        // Update only the fields from the form - preserve everything else
26731d05cddcSAtari911        $config = $existingConfig;
26741d05cddcSAtari911
26751d05cddcSAtari911        // Update basic fields
26761d05cddcSAtari911        $config['tenant_id'] = $INPUT->str('tenant_id');
26771d05cddcSAtari911        $config['client_id'] = $INPUT->str('client_id');
26781d05cddcSAtari911        $config['client_secret'] = $INPUT->str('client_secret');
26791d05cddcSAtari911        $config['user_email'] = $INPUT->str('user_email');
26801d05cddcSAtari911        $config['timezone'] = $INPUT->str('timezone', 'America/Los_Angeles');
26811d05cddcSAtari911        $config['default_category'] = $INPUT->str('default_category', 'Blue category');
26821d05cddcSAtari911        $config['reminder_minutes'] = $INPUT->int('reminder_minutes', 15);
26831d05cddcSAtari911        $config['sync_completed_tasks'] = $INPUT->bool('sync_completed_tasks');
26841d05cddcSAtari911        $config['delete_outlook_events'] = $INPUT->bool('delete_outlook_events');
26851d05cddcSAtari911        $config['sync_all_namespaces'] = $INPUT->bool('sync_all_namespaces');
26861d05cddcSAtari911        $config['sync_namespaces'] = $INPUT->arr('sync_namespaces');
26874590242dSAtari911        // important_namespaces is managed from the Manage tab, preserve existing value
26884590242dSAtari911        if (!isset($config['important_namespaces'])) {
26894590242dSAtari911            $config['important_namespaces'] = 'important';
26904590242dSAtari911        }
26911d05cddcSAtari911
26921d05cddcSAtari911        // Parse category mapping
26931d05cddcSAtari911        $config['category_mapping'] = [];
26941d05cddcSAtari911        $mappingText = $INPUT->str('category_mapping');
26951d05cddcSAtari911        if ($mappingText) {
26961d05cddcSAtari911            $lines = explode("\n", $mappingText);
26971d05cddcSAtari911            foreach ($lines as $line) {
26981d05cddcSAtari911                $line = trim($line);
26991d05cddcSAtari911                if (empty($line)) continue;
27001d05cddcSAtari911                $parts = explode('=', $line, 2);
27011d05cddcSAtari911                if (count($parts) === 2) {
27021d05cddcSAtari911                    $config['category_mapping'][trim($parts[0])] = trim($parts[1]);
27031d05cddcSAtari911                }
27041d05cddcSAtari911            }
27051d05cddcSAtari911        }
27061d05cddcSAtari911
27071d05cddcSAtari911        // Parse color mapping from dropdown selections
27081d05cddcSAtari911        $config['color_mapping'] = [];
27091d05cddcSAtari911        $colorMappingCount = $INPUT->int('color_mapping_count', 0);
27101d05cddcSAtari911        for ($i = 0; $i < $colorMappingCount; $i++) {
27111d05cddcSAtari911            $hexColor = $INPUT->str('color_hex_' . $i);
27121d05cddcSAtari911            $category = $INPUT->str('color_map_' . $i);
27131d05cddcSAtari911
27141d05cddcSAtari911            if (!empty($hexColor) && !empty($category)) {
27151d05cddcSAtari911                $config['color_mapping'][$hexColor] = $category;
27161d05cddcSAtari911            }
27171d05cddcSAtari911        }
27181d05cddcSAtari911
27191d05cddcSAtari911        // Build file content using return format
27201d05cddcSAtari911        $content = "<?php\n";
27211d05cddcSAtari911        $content .= "/**\n";
27221d05cddcSAtari911        $content .= " * DokuWiki Calendar → Outlook Sync - Configuration\n";
27231d05cddcSAtari911        $content .= " * \n";
27241d05cddcSAtari911        $content .= " * SECURITY: Add this file to .gitignore!\n";
27251d05cddcSAtari911        $content .= " * Never commit credentials to version control.\n";
27261d05cddcSAtari911        $content .= " */\n\n";
27271d05cddcSAtari911        $content .= "return " . var_export($config, true) . ";\n";
27281d05cddcSAtari911
27291d05cddcSAtari911        // Save file
27301d05cddcSAtari911        if (file_put_contents($configFile, $content)) {
27311d05cddcSAtari911            $this->redirect('Configuration saved successfully!', 'success');
27321d05cddcSAtari911        } else {
27331d05cddcSAtari911            $this->redirect('Error: Could not save configuration file', 'error');
27341d05cddcSAtari911        }
27351d05cddcSAtari911    }
27361d05cddcSAtari911
27371d05cddcSAtari911    private function clearCache() {
27381d05cddcSAtari911        // Clear DokuWiki cache
27391d05cddcSAtari911        $cacheDir = DOKU_INC . 'data/cache';
27401d05cddcSAtari911
27411d05cddcSAtari911        if (is_dir($cacheDir)) {
27421d05cddcSAtari911            $this->recursiveDelete($cacheDir, false);
27431d05cddcSAtari911            $this->redirect('Cache cleared successfully!', 'success', 'update');
27441d05cddcSAtari911        } else {
27451d05cddcSAtari911            $this->redirect('Cache directory not found', 'error', 'update');
27461d05cddcSAtari911        }
27471d05cddcSAtari911    }
27481d05cddcSAtari911
27491d05cddcSAtari911    private function recursiveDelete($dir, $deleteRoot = true) {
27501d05cddcSAtari911        if (!is_dir($dir)) return;
27511d05cddcSAtari911
27521d05cddcSAtari911        $files = array_diff(scandir($dir), array('.', '..'));
27531d05cddcSAtari911        foreach ($files as $file) {
27541d05cddcSAtari911            $path = $dir . '/' . $file;
27551d05cddcSAtari911            if (is_dir($path)) {
27561d05cddcSAtari911                $this->recursiveDelete($path, true);
27571d05cddcSAtari911            } else {
27581d05cddcSAtari911                @unlink($path);
27591d05cddcSAtari911            }
27601d05cddcSAtari911        }
27611d05cddcSAtari911
27621d05cddcSAtari911        if ($deleteRoot) {
27631d05cddcSAtari911            @rmdir($dir);
27641d05cddcSAtari911        }
27651d05cddcSAtari911    }
27661d05cddcSAtari911
27671d05cddcSAtari911    private function findRecurringEvents() {
27681d05cddcSAtari911        $dataDir = DOKU_INC . 'data/meta/';
27691d05cddcSAtari911        $recurring = [];
27701d05cddcSAtari911        $allEvents = []; // Track all events to detect patterns
27717e8ea635SAtari911        $flaggedSeries = []; // Track events with recurring flag by recurringId
27721d05cddcSAtari911
27737e8ea635SAtari911        // Helper to process events from a calendar directory
27747e8ea635SAtari911        $processCalendarDir = function($calDir, $fallbackNamespace) use (&$allEvents, &$flaggedSeries) {
27757e8ea635SAtari911            if (!is_dir($calDir)) return;
27767e8ea635SAtari911
27777e8ea635SAtari911            foreach (glob($calDir . '/*.json') as $file) {
27781d05cddcSAtari911                $data = json_decode(file_get_contents($file), true);
27797e8ea635SAtari911                if (!$data || !is_array($data)) continue;
27801d05cddcSAtari911
27811d05cddcSAtari911                foreach ($data as $dateKey => $events) {
2782*96df7d3eSAtari911                    // Skip non-date keys (like "mapping" or other metadata)
2783*96df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
2784*96df7d3eSAtari911
27857e8ea635SAtari911                    if (!is_array($events)) continue;
27861d05cddcSAtari911                    foreach ($events as $event) {
27877e8ea635SAtari911                        if (!isset($event['title']) || empty(trim($event['title']))) continue;
27881d05cddcSAtari911
27897e8ea635SAtari911                        $ns = isset($event['namespace']) ? $event['namespace'] : $fallbackNamespace;
27907e8ea635SAtari911
27917e8ea635SAtari911                        // If event has recurring flag, group by recurringId
27927e8ea635SAtari911                        if (!empty($event['recurring']) && !empty($event['recurringId'])) {
27937e8ea635SAtari911                            $rid = $event['recurringId'];
27947e8ea635SAtari911                            if (!isset($flaggedSeries[$rid])) {
27957e8ea635SAtari911                                $flaggedSeries[$rid] = [
27961d05cddcSAtari911                                    'title' => $event['title'],
27977e8ea635SAtari911                                    'namespace' => $ns,
27981d05cddcSAtari911                                    'dates' => [],
2799*96df7d3eSAtari911                                    'events' => [],
2800*96df7d3eSAtari911                                    // Capture recurrence metadata from first event
2801*96df7d3eSAtari911                                    'recurrenceType' => $event['recurrenceType'] ?? null,
2802*96df7d3eSAtari911                                    'recurrenceInterval' => $event['recurrenceInterval'] ?? 1,
2803*96df7d3eSAtari911                                    'weekDays' => $event['weekDays'] ?? [],
2804*96df7d3eSAtari911                                    'monthlyType' => $event['monthlyType'] ?? null,
2805*96df7d3eSAtari911                                    'monthDay' => $event['monthDay'] ?? null,
2806*96df7d3eSAtari911                                    'ordinalWeek' => $event['ordinalWeek'] ?? null,
2807*96df7d3eSAtari911                                    'ordinalDay' => $event['ordinalDay'] ?? null,
2808*96df7d3eSAtari911                                    'time' => $event['time'] ?? null,
2809*96df7d3eSAtari911                                    'endTime' => $event['endTime'] ?? null,
2810*96df7d3eSAtari911                                    'color' => $event['color'] ?? null
28111d05cddcSAtari911                                ];
28121d05cddcSAtari911                            }
28137e8ea635SAtari911                            $flaggedSeries[$rid]['dates'][] = $dateKey;
28147e8ea635SAtari911                            $flaggedSeries[$rid]['events'][] = $event;
28151d05cddcSAtari911                        }
28161d05cddcSAtari911
28177e8ea635SAtari911                        // Also group by title+namespace for pattern detection
28187e8ea635SAtari911                        $groupKey = strtolower(trim($event['title'])) . '|' . $ns;
28191d05cddcSAtari911
28201d05cddcSAtari911                        if (!isset($allEvents[$groupKey])) {
28211d05cddcSAtari911                            $allEvents[$groupKey] = [
28221d05cddcSAtari911                                'title' => $event['title'],
28237e8ea635SAtari911                                'namespace' => $ns,
28241d05cddcSAtari911                                'dates' => [],
28257e8ea635SAtari911                                'events' => [],
2826*96df7d3eSAtari911                                'hasFlag' => false,
2827*96df7d3eSAtari911                                'time' => $event['time'] ?? null,
2828*96df7d3eSAtari911                                'color' => $event['color'] ?? null
28291d05cddcSAtari911                            ];
28301d05cddcSAtari911                        }
28311d05cddcSAtari911                        $allEvents[$groupKey]['dates'][] = $dateKey;
28321d05cddcSAtari911                        $allEvents[$groupKey]['events'][] = $event;
28337e8ea635SAtari911                        if (!empty($event['recurring'])) {
28347e8ea635SAtari911                            $allEvents[$groupKey]['hasFlag'] = true;
28351d05cddcSAtari911                        }
28361d05cddcSAtari911                    }
28371d05cddcSAtari911                }
28381d05cddcSAtari911            }
28397e8ea635SAtari911        };
28407e8ea635SAtari911
28417e8ea635SAtari911        // Check root calendar directory (blank/default namespace)
28427e8ea635SAtari911        $processCalendarDir($dataDir . 'calendar', '');
28437e8ea635SAtari911
28447e8ea635SAtari911        // Scan all namespace directories (including nested)
28457e8ea635SAtari911        $this->scanNamespaceDirs($dataDir, $processCalendarDir);
28467e8ea635SAtari911
28477e8ea635SAtari911        // Deduplicate: remove from allEvents groups that are fully covered by flaggedSeries
28487e8ea635SAtari911        $flaggedTitleNs = [];
28497e8ea635SAtari911        foreach ($flaggedSeries as $rid => $series) {
28507e8ea635SAtari911            $key = strtolower(trim($series['title'])) . '|' . $series['namespace'];
28517e8ea635SAtari911            $flaggedTitleNs[$key] = $rid;
28527e8ea635SAtari911        }
28531d05cddcSAtari911
28547e8ea635SAtari911        // Build results from flaggedSeries first (known recurring)
28557e8ea635SAtari911        $seen = [];
28567e8ea635SAtari911        foreach ($flaggedSeries as $rid => $series) {
28577e8ea635SAtari911            sort($series['dates']);
28587e8ea635SAtari911            $dedupDates = array_unique($series['dates']);
28597e8ea635SAtari911
2860*96df7d3eSAtari911            // Use stored recurrence metadata if available, otherwise detect pattern
2861*96df7d3eSAtari911            $pattern = $this->formatRecurrencePattern($series);
2862*96df7d3eSAtari911            if (!$pattern) {
28637e8ea635SAtari911                $pattern = $this->detectRecurrencePattern($dedupDates);
2864*96df7d3eSAtari911            }
28657e8ea635SAtari911
28667e8ea635SAtari911            $recurring[] = [
28677e8ea635SAtari911                'baseId' => $rid,
28687e8ea635SAtari911                'title' => $series['title'],
28697e8ea635SAtari911                'namespace' => $series['namespace'],
28707e8ea635SAtari911                'pattern' => $pattern,
28717e8ea635SAtari911                'count' => count($dedupDates),
28727e8ea635SAtari911                'firstDate' => $dedupDates[0],
2873*96df7d3eSAtari911                'lastDate' => end($dedupDates),
2874*96df7d3eSAtari911                'hasFlag' => true,
2875*96df7d3eSAtari911                'time' => $series['time'],
2876*96df7d3eSAtari911                'endTime' => $series['endTime'],
2877*96df7d3eSAtari911                'color' => $series['color'],
2878*96df7d3eSAtari911                'recurrenceType' => $series['recurrenceType'],
2879*96df7d3eSAtari911                'recurrenceInterval' => $series['recurrenceInterval'],
2880*96df7d3eSAtari911                'weekDays' => $series['weekDays'],
2881*96df7d3eSAtari911                'monthlyType' => $series['monthlyType'],
2882*96df7d3eSAtari911                'monthDay' => $series['monthDay'],
2883*96df7d3eSAtari911                'ordinalWeek' => $series['ordinalWeek'],
2884*96df7d3eSAtari911                'ordinalDay' => $series['ordinalDay']
28857e8ea635SAtari911            ];
28867e8ea635SAtari911            $seen[strtolower(trim($series['title'])) . '|' . $series['namespace']] = true;
28877e8ea635SAtari911        }
28887e8ea635SAtari911
28897e8ea635SAtari911        // Add pattern-detected recurring (3+ occurrences, not already in flaggedSeries)
28901d05cddcSAtari911        foreach ($allEvents as $groupKey => $group) {
28917e8ea635SAtari911            if (isset($seen[$groupKey])) continue;
28921d05cddcSAtari911
28937e8ea635SAtari911            $dedupDates = array_unique($group['dates']);
28947e8ea635SAtari911            sort($dedupDates);
28951d05cddcSAtari911
28967e8ea635SAtari911            if (count($dedupDates) < 3) continue;
28971d05cddcSAtari911
28987e8ea635SAtari911            $pattern = $this->detectRecurrencePattern($dedupDates);
28997e8ea635SAtari911
29001d05cddcSAtari911            $baseId = isset($group['events'][0]['recurringId'])
29011d05cddcSAtari911                ? $group['events'][0]['recurringId']
29021d05cddcSAtari911                : md5($group['title'] . $group['namespace']);
29031d05cddcSAtari911
29041d05cddcSAtari911            $recurring[] = [
29051d05cddcSAtari911                'baseId' => $baseId,
29061d05cddcSAtari911                'title' => $group['title'],
29071d05cddcSAtari911                'namespace' => $group['namespace'],
29081d05cddcSAtari911                'pattern' => $pattern,
29097e8ea635SAtari911                'count' => count($dedupDates),
29107e8ea635SAtari911                'firstDate' => $dedupDates[0],
2911*96df7d3eSAtari911                'lastDate' => end($dedupDates),
2912*96df7d3eSAtari911                'hasFlag' => $group['hasFlag'],
2913*96df7d3eSAtari911                'time' => $group['time'],
2914*96df7d3eSAtari911                'color' => $group['color'],
2915*96df7d3eSAtari911                'recurrenceType' => null,
2916*96df7d3eSAtari911                'recurrenceInterval' => null,
2917*96df7d3eSAtari911                'weekDays' => null,
2918*96df7d3eSAtari911                'monthlyType' => null,
2919*96df7d3eSAtari911                'monthDay' => null,
2920*96df7d3eSAtari911                'ordinalWeek' => null,
2921*96df7d3eSAtari911                'ordinalDay' => null
29221d05cddcSAtari911            ];
29231d05cddcSAtari911        }
29247e8ea635SAtari911
29257e8ea635SAtari911        // Sort by title
29267e8ea635SAtari911        usort($recurring, function($a, $b) {
29277e8ea635SAtari911            return strcasecmp($a['title'], $b['title']);
29287e8ea635SAtari911        });
29297e8ea635SAtari911
29307e8ea635SAtari911        return $recurring;
29317e8ea635SAtari911    }
29327e8ea635SAtari911
29337e8ea635SAtari911    /**
2934*96df7d3eSAtari911     * Format a human-readable recurrence pattern from stored metadata
2935*96df7d3eSAtari911     */
2936*96df7d3eSAtari911    private function formatRecurrencePattern($series) {
2937*96df7d3eSAtari911        $type = $series['recurrenceType'] ?? null;
2938*96df7d3eSAtari911        $interval = $series['recurrenceInterval'] ?? 1;
2939*96df7d3eSAtari911
2940*96df7d3eSAtari911        if (!$type) return null;
2941*96df7d3eSAtari911
2942*96df7d3eSAtari911        $result = '';
2943*96df7d3eSAtari911
2944*96df7d3eSAtari911        switch ($type) {
2945*96df7d3eSAtari911            case 'daily':
2946*96df7d3eSAtari911                if ($interval == 1) {
2947*96df7d3eSAtari911                    $result = 'Daily';
2948*96df7d3eSAtari911                } else {
2949*96df7d3eSAtari911                    $result = "Every $interval days";
2950*96df7d3eSAtari911                }
2951*96df7d3eSAtari911                break;
2952*96df7d3eSAtari911
2953*96df7d3eSAtari911            case 'weekly':
2954*96df7d3eSAtari911                $weekDays = $series['weekDays'] ?? [];
2955*96df7d3eSAtari911                $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
2956*96df7d3eSAtari911
2957*96df7d3eSAtari911                if ($interval == 1) {
2958*96df7d3eSAtari911                    $result = 'Weekly';
2959*96df7d3eSAtari911                } elseif ($interval == 2) {
2960*96df7d3eSAtari911                    $result = 'Bi-weekly';
2961*96df7d3eSAtari911                } else {
2962*96df7d3eSAtari911                    $result = "Every $interval weeks";
2963*96df7d3eSAtari911                }
2964*96df7d3eSAtari911
2965*96df7d3eSAtari911                if (!empty($weekDays) && count($weekDays) < 7) {
2966*96df7d3eSAtari911                    $dayLabels = array_map(function($d) use ($dayNames) {
2967*96df7d3eSAtari911                        return $dayNames[$d] ?? '';
2968*96df7d3eSAtari911                    }, $weekDays);
2969*96df7d3eSAtari911                    $result .= ' (' . implode(', ', $dayLabels) . ')';
2970*96df7d3eSAtari911                }
2971*96df7d3eSAtari911                break;
2972*96df7d3eSAtari911
2973*96df7d3eSAtari911            case 'monthly':
2974*96df7d3eSAtari911                $monthlyType = $series['monthlyType'] ?? 'dayOfMonth';
2975*96df7d3eSAtari911
2976*96df7d3eSAtari911                if ($interval == 1) {
2977*96df7d3eSAtari911                    $prefix = 'Monthly';
2978*96df7d3eSAtari911                } elseif ($interval == 3) {
2979*96df7d3eSAtari911                    $prefix = 'Quarterly';
2980*96df7d3eSAtari911                } elseif ($interval == 6) {
2981*96df7d3eSAtari911                    $prefix = 'Semi-annual';
2982*96df7d3eSAtari911                } else {
2983*96df7d3eSAtari911                    $prefix = "Every $interval months";
2984*96df7d3eSAtari911                }
2985*96df7d3eSAtari911
2986*96df7d3eSAtari911                if ($monthlyType === 'dayOfMonth') {
2987*96df7d3eSAtari911                    $day = $series['monthDay'] ?? '?';
2988*96df7d3eSAtari911                    $result = "$prefix (day $day)";
2989*96df7d3eSAtari911                } else {
2990*96df7d3eSAtari911                    $ordinalNames = [1 => '1st', 2 => '2nd', 3 => '3rd', 4 => '4th', 5 => '5th', -1 => 'Last'];
2991*96df7d3eSAtari911                    $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
2992*96df7d3eSAtari911                    $ordinal = $ordinalNames[$series['ordinalWeek']] ?? '';
2993*96df7d3eSAtari911                    $dayName = $dayNames[$series['ordinalDay']] ?? '';
2994*96df7d3eSAtari911                    $result = "$prefix ($ordinal $dayName)";
2995*96df7d3eSAtari911                }
2996*96df7d3eSAtari911                break;
2997*96df7d3eSAtari911
2998*96df7d3eSAtari911            case 'yearly':
2999*96df7d3eSAtari911                if ($interval == 1) {
3000*96df7d3eSAtari911                    $result = 'Yearly';
3001*96df7d3eSAtari911                } else {
3002*96df7d3eSAtari911                    $result = "Every $interval years";
3003*96df7d3eSAtari911                }
3004*96df7d3eSAtari911                break;
3005*96df7d3eSAtari911
3006*96df7d3eSAtari911            default:
3007*96df7d3eSAtari911                $result = ucfirst($type);
3008*96df7d3eSAtari911        }
3009*96df7d3eSAtari911
3010*96df7d3eSAtari911        return $result;
3011*96df7d3eSAtari911    }
3012*96df7d3eSAtari911
3013*96df7d3eSAtari911    /**
30147e8ea635SAtari911     * Recursively scan namespace directories for calendar data
30157e8ea635SAtari911     */
30167e8ea635SAtari911    private function scanNamespaceDirs($baseDir, $callback) {
30177e8ea635SAtari911        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
30187e8ea635SAtari911            $namespace = basename($nsDir);
30197e8ea635SAtari911
30207e8ea635SAtari911            // Skip the root 'calendar' dir (already processed)
30217e8ea635SAtari911            if ($namespace === 'calendar') continue;
30227e8ea635SAtari911
30237e8ea635SAtari911            $calendarDir = $nsDir . '/calendar';
30247e8ea635SAtari911            if (is_dir($calendarDir)) {
30257e8ea635SAtari911                // Derive namespace from path relative to meta dir
30267e8ea635SAtari911                $metaDir = DOKU_INC . 'data/meta/';
30277e8ea635SAtari911                $relPath = str_replace($metaDir, '', $nsDir);
30287e8ea635SAtari911                $ns = str_replace('/', ':', trim($relPath, '/'));
30297e8ea635SAtari911                $callback($calendarDir, $ns);
30307e8ea635SAtari911            }
30317e8ea635SAtari911
30327e8ea635SAtari911            // Recurse into subdirectories for nested namespaces
30337e8ea635SAtari911            $this->scanNamespaceDirs($nsDir . '/', $callback);
30347e8ea635SAtari911        }
30351d05cddcSAtari911    }
30361d05cddcSAtari911
30377e8ea635SAtari911    /**
30387e8ea635SAtari911     * Detect recurrence pattern from sorted dates using median interval
30397e8ea635SAtari911     */
30407e8ea635SAtari911    private function detectRecurrencePattern($dates) {
30417e8ea635SAtari911        if (count($dates) < 2) return 'Single';
30427e8ea635SAtari911
30437e8ea635SAtari911        // Calculate all intervals between consecutive dates
30447e8ea635SAtari911        $intervals = [];
30457e8ea635SAtari911        for ($i = 1; $i < count($dates); $i++) {
30467e8ea635SAtari911            try {
30477e8ea635SAtari911                $d1 = new DateTime($dates[$i - 1]);
30487e8ea635SAtari911                $d2 = new DateTime($dates[$i]);
30497e8ea635SAtari911                $intervals[] = $d1->diff($d2)->days;
30507e8ea635SAtari911            } catch (Exception $e) {
30517e8ea635SAtari911                continue;
30527e8ea635SAtari911            }
30537e8ea635SAtari911        }
30547e8ea635SAtari911
30557e8ea635SAtari911        if (empty($intervals)) return 'Custom';
30567e8ea635SAtari911
3057*96df7d3eSAtari911        // Check if all intervals are the same (or very close)
3058*96df7d3eSAtari911        $uniqueIntervals = array_unique($intervals);
3059*96df7d3eSAtari911        $isConsistent = (count($uniqueIntervals) === 1) ||
3060*96df7d3eSAtari911                        (max($intervals) - min($intervals) <= 1); // Allow 1 day variance
3061*96df7d3eSAtari911
30627e8ea635SAtari911        // Use median interval (more robust than first pair)
30637e8ea635SAtari911        sort($intervals);
30647e8ea635SAtari911        $mid = floor(count($intervals) / 2);
30657e8ea635SAtari911        $median = (count($intervals) % 2 === 0)
30667e8ea635SAtari911            ? ($intervals[$mid - 1] + $intervals[$mid]) / 2
30677e8ea635SAtari911            : $intervals[$mid];
30687e8ea635SAtari911
3069*96df7d3eSAtari911        // Check for specific day-based patterns first
30707e8ea635SAtari911        if ($median <= 1) return 'Daily';
3071*96df7d3eSAtari911
3072*96df7d3eSAtari911        // Check for every N days (2-6 days)
3073*96df7d3eSAtari911        if ($median >= 2 && $median <= 6 && $isConsistent) {
3074*96df7d3eSAtari911            return 'Every ' . round($median) . ' days';
3075*96df7d3eSAtari911        }
3076*96df7d3eSAtari911
3077*96df7d3eSAtari911        // Weekly patterns
30787e8ea635SAtari911        if ($median >= 6 && $median <= 8) return 'Weekly';
3079*96df7d3eSAtari911
3080*96df7d3eSAtari911        // Check for every N weeks
30817e8ea635SAtari911        if ($median >= 13 && $median <= 16) return 'Bi-weekly';
3082*96df7d3eSAtari911        if ($median >= 20 && $median <= 23) return 'Every 3 weeks';
3083*96df7d3eSAtari911
3084*96df7d3eSAtari911        // Monthly patterns
30857e8ea635SAtari911        if ($median >= 27 && $median <= 32) return 'Monthly';
3086*96df7d3eSAtari911
3087*96df7d3eSAtari911        // Check for every N months by looking at month differences
3088*96df7d3eSAtari911        if ($median >= 55 && $median <= 65) return 'Every 2 months';
30897e8ea635SAtari911        if ($median >= 89 && $median <= 93) return 'Quarterly';
3090*96df7d3eSAtari911        if ($median >= 115 && $median <= 125) return 'Every 4 months';
3091*96df7d3eSAtari911        if ($median >= 175 && $median <= 190) return 'Semi-annual';
3092*96df7d3eSAtari911
3093*96df7d3eSAtari911        // Yearly
30947e8ea635SAtari911        if ($median >= 363 && $median <= 368) return 'Yearly';
30957e8ea635SAtari911
3096*96df7d3eSAtari911        // For other intervals, calculate weeks if appropriate
3097*96df7d3eSAtari911        if ($median >= 7 && $median < 28) {
3098*96df7d3eSAtari911            $weeks = round($median / 7);
3099*96df7d3eSAtari911            if (abs($median - ($weeks * 7)) <= 1) {
3100*96df7d3eSAtari911                return "Every $weeks weeks";
3101*96df7d3eSAtari911            }
3102*96df7d3eSAtari911        }
3103*96df7d3eSAtari911
3104*96df7d3eSAtari911        // For monthly-ish intervals
3105*96df7d3eSAtari911        if ($median >= 28 && $median < 365) {
3106*96df7d3eSAtari911            $months = round($median / 30);
3107*96df7d3eSAtari911            if ($months >= 2 && abs($median - ($months * 30)) <= 3) {
3108*96df7d3eSAtari911                return "Every $months months";
3109*96df7d3eSAtari911            }
3110*96df7d3eSAtari911        }
3111*96df7d3eSAtari911
31127e8ea635SAtari911        return 'Every ~' . round($median) . ' days';
31137e8ea635SAtari911    }
31147e8ea635SAtari911
31157e8ea635SAtari911    /**
31167e8ea635SAtari911     * Render the recurring events table HTML
31177e8ea635SAtari911     */
31187e8ea635SAtari911    private function renderRecurringTable($recurringEvents, $colors) {
31197e8ea635SAtari911        if (empty($recurringEvents)) {
31207e8ea635SAtari911            echo '<p style="color:' . $colors['text'] . '; font-size:13px; margin:5px 0;">No recurring events found.</p>';
31217e8ea635SAtari911            return;
31227e8ea635SAtari911        }
31237e8ea635SAtari911
31247e8ea635SAtari911        // Search bar
31257e8ea635SAtari911        echo '<div style="margin-bottom:8px;">';
31267e8ea635SAtari911        echo '<input type="text" id="searchRecurring" onkeyup="filterRecurringEvents()" placeholder="�� Search recurring events..." style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">';
31277e8ea635SAtari911        echo '</div>';
31287e8ea635SAtari911
31297e8ea635SAtari911        echo '<style>
31307e8ea635SAtari911            .sort-arrow {
31317e8ea635SAtari911                color: #999;
31327e8ea635SAtari911                font-size: 10px;
31337e8ea635SAtari911                margin-left: 3px;
31347e8ea635SAtari911                display: inline-block;
31357e8ea635SAtari911            }
31367e8ea635SAtari911            #recurringTable th:hover {
31377e8ea635SAtari911                background: #ddd;
31387e8ea635SAtari911            }
31397e8ea635SAtari911            #recurringTable th:hover .sort-arrow {
31407e8ea635SAtari911                color: #00cc07;
31417e8ea635SAtari911            }
31427e8ea635SAtari911            .recurring-row-hidden {
31437e8ea635SAtari911                display: none;
31447e8ea635SAtari911            }
3145*96df7d3eSAtari911            .pattern-badge {
3146*96df7d3eSAtari911                display: inline-block;
3147*96df7d3eSAtari911                padding: 1px 4px;
3148*96df7d3eSAtari911                border-radius: 3px;
3149*96df7d3eSAtari911                font-size: 9px;
3150*96df7d3eSAtari911                font-weight: bold;
3151*96df7d3eSAtari911            }
3152*96df7d3eSAtari911            .pattern-daily { background: #e3f2fd; color: #1565c0; }
3153*96df7d3eSAtari911            .pattern-weekly { background: #e8f5e9; color: #2e7d32; }
3154*96df7d3eSAtari911            .pattern-monthly { background: #fff3e0; color: #ef6c00; }
3155*96df7d3eSAtari911            .pattern-yearly { background: #fce4ec; color: #c2185b; }
3156*96df7d3eSAtari911            .pattern-custom { background: #f3e5f5; color: #7b1fa2; }
31577e8ea635SAtari911        </style>';
31587e8ea635SAtari911        echo '<div style="max-height:250px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">';
31597e8ea635SAtari911        echo '<table id="recurringTable" style="width:100%; border-collapse:collapse; font-size:11px;">';
31607e8ea635SAtari911        echo '<thead style="position:sticky; top:0; background:#e9e9e9;">';
31617e8ea635SAtari911        echo '<tr>';
31627e8ea635SAtari911        echo '<th onclick="sortRecurringTable(0)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Title <span class="sort-arrow">⇅</span></th>';
31637e8ea635SAtari911        echo '<th onclick="sortRecurringTable(1)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Namespace <span class="sort-arrow">⇅</span></th>';
31647e8ea635SAtari911        echo '<th onclick="sortRecurringTable(2)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Pattern <span class="sort-arrow">⇅</span></th>';
3165*96df7d3eSAtari911        echo '<th onclick="sortRecurringTable(3)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Range <span class="sort-arrow">⇅</span></th>';
31667e8ea635SAtari911        echo '<th onclick="sortRecurringTable(4)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Count <span class="sort-arrow">⇅</span></th>';
31677e8ea635SAtari911        echo '<th onclick="sortRecurringTable(5)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Source <span class="sort-arrow">⇅</span></th>';
31687e8ea635SAtari911        echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">Actions</th>';
31697e8ea635SAtari911        echo '</tr></thead><tbody id="recurringTableBody">';
31707e8ea635SAtari911
31717e8ea635SAtari911        foreach ($recurringEvents as $series) {
31727e8ea635SAtari911            $sourceLabel = $series['hasFlag'] ? '��️ Flagged' : '�� Detected';
31737e8ea635SAtari911            $sourceColor = $series['hasFlag'] ? '#00cc07' : '#ff9800';
3174*96df7d3eSAtari911
3175*96df7d3eSAtari911            // Determine pattern badge class
3176*96df7d3eSAtari911            $pattern = strtolower($series['pattern']);
3177*96df7d3eSAtari911            if (strpos($pattern, 'daily') !== false || strpos($pattern, 'day') !== false) {
3178*96df7d3eSAtari911                $patternClass = 'pattern-daily';
3179*96df7d3eSAtari911            } elseif (strpos($pattern, 'weekly') !== false || strpos($pattern, 'week') !== false) {
3180*96df7d3eSAtari911                $patternClass = 'pattern-weekly';
3181*96df7d3eSAtari911            } elseif (strpos($pattern, 'monthly') !== false || strpos($pattern, 'month') !== false ||
3182*96df7d3eSAtari911                      strpos($pattern, 'quarterly') !== false || strpos($pattern, 'semi') !== false) {
3183*96df7d3eSAtari911                $patternClass = 'pattern-monthly';
3184*96df7d3eSAtari911            } elseif (strpos($pattern, 'yearly') !== false || strpos($pattern, 'year') !== false) {
3185*96df7d3eSAtari911                $patternClass = 'pattern-yearly';
3186*96df7d3eSAtari911            } else {
3187*96df7d3eSAtari911                $patternClass = 'pattern-custom';
3188*96df7d3eSAtari911            }
3189*96df7d3eSAtari911
3190*96df7d3eSAtari911            // Format date range
3191*96df7d3eSAtari911            $firstDate = date('M j, Y', strtotime($series['firstDate']));
3192*96df7d3eSAtari911            $lastDate = isset($series['lastDate']) ? date('M j, Y', strtotime($series['lastDate'])) : $firstDate;
3193*96df7d3eSAtari911            $dateRange = ($firstDate === $lastDate) ? $firstDate : "$firstDate$lastDate";
3194*96df7d3eSAtari911
31957e8ea635SAtari911            echo '<tr style="border-bottom:1px solid #eee;">';
31967e8ea635SAtari911            echo '<td style="padding:4px 6px;">' . hsc($series['title']) . '</td>';
31977e8ea635SAtari911            echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($series['namespace'] ?: '(default)') . '</code></td>';
3198*96df7d3eSAtari911            echo '<td style="padding:4px 6px;"><span class="pattern-badge ' . $patternClass . '">' . hsc($series['pattern']) . '</span></td>';
3199*96df7d3eSAtari911            echo '<td style="padding:4px 6px; font-size:10px;">' . $dateRange . '</td>';
32007e8ea635SAtari911            echo '<td style="padding:4px 6px;"><strong>' . $series['count'] . '</strong></td>';
32017e8ea635SAtari911            echo '<td style="padding:4px 6px;"><span style="color:' . $sourceColor . '; font-size:10px;">' . $sourceLabel . '</span></td>';
32027e8ea635SAtari911            echo '<td style="padding:4px 6px; white-space:nowrap;">';
3203*96df7d3eSAtari911
3204*96df7d3eSAtari911            // Prepare JS data - include recurrence metadata
32057e8ea635SAtari911            $jsTitle = hsc(addslashes($series['title']));
32067e8ea635SAtari911            $jsNs = hsc($series['namespace']);
32077e8ea635SAtari911            $jsCount = $series['count'];
32087e8ea635SAtari911            $jsFirst = hsc($series['firstDate']);
3209*96df7d3eSAtari911            $jsLast = hsc($series['lastDate'] ?? $series['firstDate']);
32107e8ea635SAtari911            $jsPattern = hsc($series['pattern']);
32117e8ea635SAtari911            $jsHasFlag = $series['hasFlag'] ? 'true' : 'false';
3212*96df7d3eSAtari911            $jsTime = hsc($series['time'] ?? '');
3213*96df7d3eSAtari911            $jsEndTime = hsc($series['endTime'] ?? '');
3214*96df7d3eSAtari911            $jsColor = hsc($series['color'] ?? '');
3215*96df7d3eSAtari911
3216*96df7d3eSAtari911            // Recurrence metadata for edit dialog
3217*96df7d3eSAtari911            $jsRecurrenceType = hsc($series['recurrenceType'] ?? '');
3218*96df7d3eSAtari911            $jsRecurrenceInterval = intval($series['recurrenceInterval'] ?? 1);
3219*96df7d3eSAtari911            $jsWeekDays = json_encode($series['weekDays'] ?? []);
3220*96df7d3eSAtari911            $jsMonthlyType = hsc($series['monthlyType'] ?? '');
3221*96df7d3eSAtari911            $jsMonthDay = intval($series['monthDay'] ?? 0);
3222*96df7d3eSAtari911            $jsOrdinalWeek = intval($series['ordinalWeek'] ?? 1);
3223*96df7d3eSAtari911            $jsOrdinalDay = intval($series['ordinalDay'] ?? 0);
3224*96df7d3eSAtari911
3225*96df7d3eSAtari911            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="Edit title, time, namespace, pattern">Edit</button>';
3226*96df7d3eSAtari911            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="Extend, trim, pause, change dates">Manage</button>';
32277e8ea635SAtari911            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="Delete all occurrences">Del</button>';
32287e8ea635SAtari911            echo '</td>';
32297e8ea635SAtari911            echo '</tr>';
32307e8ea635SAtari911        }
32317e8ea635SAtari911
32327e8ea635SAtari911        echo '</tbody></table>';
32337e8ea635SAtari911        echo '</div>';
32347e8ea635SAtari911        echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:5px 0 0;">Total: ' . count($recurringEvents) . ' series</p>';
32357e8ea635SAtari911    }
32367e8ea635SAtari911
32377e8ea635SAtari911    /**
32387e8ea635SAtari911     * AJAX handler: rescan recurring events and return HTML
32397e8ea635SAtari911     */
32407e8ea635SAtari911    private function handleCleanupEmptyNamespaces() {
32417e8ea635SAtari911        global $INPUT;
32427e8ea635SAtari911        $dryRun = $INPUT->bool('dry_run', false);
32437e8ea635SAtari911
32447e8ea635SAtari911        $metaDir = DOKU_INC . 'data/meta/';
32457e8ea635SAtari911        $details = [];
32467e8ea635SAtari911        $removedDirs = 0;
32477e8ea635SAtari911        $removedCalDirs = 0;
32487e8ea635SAtari911
32497e8ea635SAtari911        // 1. Find all calendar/ subdirectories anywhere under data/meta/
32507e8ea635SAtari911        $allCalDirs = [];
32517e8ea635SAtari911        $this->findAllCalendarDirsRecursive($metaDir, $allCalDirs);
32527e8ea635SAtari911
32537e8ea635SAtari911        // 2. Check each calendar dir for empty JSON files
32547e8ea635SAtari911        foreach ($allCalDirs as $calDir) {
32557e8ea635SAtari911            $jsonFiles = glob($calDir . '/*.json');
32567e8ea635SAtari911            $hasEvents = false;
32577e8ea635SAtari911
32587e8ea635SAtari911            foreach ($jsonFiles as $jsonFile) {
32597e8ea635SAtari911                $data = json_decode(file_get_contents($jsonFile), true);
32607e8ea635SAtari911                if ($data && is_array($data)) {
32617e8ea635SAtari911                    // Check if any date key has actual events
32627e8ea635SAtari911                    foreach ($data as $dateKey => $events) {
32637e8ea635SAtari911                        if (is_array($events) && !empty($events)) {
32647e8ea635SAtari911                            $hasEvents = true;
32657e8ea635SAtari911                            break 2;
32667e8ea635SAtari911                        }
32677e8ea635SAtari911                    }
32687e8ea635SAtari911                    // JSON file has data but all dates are empty — remove it
32697e8ea635SAtari911                    if (!$dryRun) unlink($jsonFile);
32707e8ea635SAtari911                }
32717e8ea635SAtari911            }
32727e8ea635SAtari911
32737e8ea635SAtari911            // Re-check after cleaning empty JSON files
32747e8ea635SAtari911            if (!$dryRun) {
32757e8ea635SAtari911                $jsonFiles = glob($calDir . '/*.json');
32767e8ea635SAtari911            }
32777e8ea635SAtari911
32787e8ea635SAtari911            // Derive display name from path
32797e8ea635SAtari911            $relPath = str_replace($metaDir, '', $calDir);
32807e8ea635SAtari911            $relPath = rtrim(str_replace('/calendar', '', $relPath), '/');
32817e8ea635SAtari911            $displayName = $relPath ?: '(root)';
32827e8ea635SAtari911
32837e8ea635SAtari911            if ($displayName === '(root)') continue; // Never remove root calendar dir
32847e8ea635SAtari911
32857e8ea635SAtari911            if (!$hasEvents || empty($jsonFiles)) {
32867e8ea635SAtari911                $removedCalDirs++;
32877e8ea635SAtari911                $details[] = "Remove empty calendar folder: " . $displayName . "/calendar/ (0 events)";
32887e8ea635SAtari911
32897e8ea635SAtari911                if (!$dryRun) {
32907e8ea635SAtari911                    // Remove all remaining files in calendar dir
32917e8ea635SAtari911                    foreach (glob($calDir . '/*') as $f) {
32927e8ea635SAtari911                        if (is_file($f)) unlink($f);
32937e8ea635SAtari911                    }
32947e8ea635SAtari911                    @rmdir($calDir);
32957e8ea635SAtari911
32967e8ea635SAtari911                    // Check if parent namespace dir is now empty too
32977e8ea635SAtari911                    $parentDir = dirname($calDir);
32987e8ea635SAtari911                    if ($parentDir !== $metaDir && is_dir($parentDir)) {
32997e8ea635SAtari911                        $remaining = array_diff(scandir($parentDir), ['.', '..']);
33007e8ea635SAtari911                        if (empty($remaining)) {
33017e8ea635SAtari911                            @rmdir($parentDir);
33027e8ea635SAtari911                            $removedDirs++;
33037e8ea635SAtari911                            $details[] = "Removed empty namespace directory: " . $displayName . "/";
33047e8ea635SAtari911                        }
33057e8ea635SAtari911                    }
33067e8ea635SAtari911                }
33077e8ea635SAtari911            }
33087e8ea635SAtari911        }
33097e8ea635SAtari911
33107e8ea635SAtari911        // 3. Also scan for namespace dirs that have a calendar/ subdir with 0 json files
33117e8ea635SAtari911        //    (already covered above, but also check for namespace dirs without calendar/ at all
33127e8ea635SAtari911        //    that are tracked in the event system)
33137e8ea635SAtari911
33147e8ea635SAtari911        $total = $removedCalDirs + $removedDirs;
33157e8ea635SAtari911        $message = $dryRun
33167e8ea635SAtari911            ? "Found $total item(s) to clean up"
33177e8ea635SAtari911            : "Cleaned up $removedCalDirs empty calendar folder(s)" . ($removedDirs > 0 ? " and $removedDirs empty namespace directory(ies)" : "");
33187e8ea635SAtari911
33197e8ea635SAtari911        if (!$dryRun) $this->clearStatsCache();
33207e8ea635SAtari911
33217e8ea635SAtari911        echo json_encode([
33227e8ea635SAtari911            'success' => true,
33237e8ea635SAtari911            'count' => $total,
33247e8ea635SAtari911            'message' => $message,
33257e8ea635SAtari911            'details' => $details
33267e8ea635SAtari911        ]);
33277e8ea635SAtari911    }
33287e8ea635SAtari911
33297e8ea635SAtari911    /**
33307e8ea635SAtari911     * Recursively find all 'calendar' directories under a base path
33317e8ea635SAtari911     */
33327e8ea635SAtari911    private function findAllCalendarDirsRecursive($baseDir, &$results) {
33337e8ea635SAtari911        $entries = glob($baseDir . '*', GLOB_ONLYDIR);
33347e8ea635SAtari911        if (!$entries) return;
33357e8ea635SAtari911
33367e8ea635SAtari911        foreach ($entries as $dir) {
33377e8ea635SAtari911            $name = basename($dir);
33387e8ea635SAtari911            if ($name === 'calendar') {
33397e8ea635SAtari911                $results[] = $dir;
33407e8ea635SAtari911            } else {
33417e8ea635SAtari911                // Check for calendar subdir
33427e8ea635SAtari911                if (is_dir($dir . '/calendar')) {
33437e8ea635SAtari911                    $results[] = $dir . '/calendar';
33447e8ea635SAtari911                }
33457e8ea635SAtari911                // Recurse into subdirectories for nested namespaces
33467e8ea635SAtari911                $this->findAllCalendarDirsRecursive($dir . '/', $results);
33477e8ea635SAtari911            }
33487e8ea635SAtari911        }
33497e8ea635SAtari911    }
33507e8ea635SAtari911
33517e8ea635SAtari911    private function handleTrimAllPastRecurring() {
33527e8ea635SAtari911        global $INPUT;
33537e8ea635SAtari911        $dryRun = $INPUT->bool('dry_run', false);
33547e8ea635SAtari911        $today = date('Y-m-d');
33557e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
33567e8ea635SAtari911        $calendarDirs = [];
33577e8ea635SAtari911
33587e8ea635SAtari911        if (is_dir($dataDir . 'calendar')) {
33597e8ea635SAtari911            $calendarDirs[] = $dataDir . 'calendar';
33607e8ea635SAtari911        }
33617e8ea635SAtari911        $this->findCalendarDirs($dataDir, $calendarDirs);
33627e8ea635SAtari911
33637e8ea635SAtari911        $removed = 0;
33647e8ea635SAtari911
33657e8ea635SAtari911        foreach ($calendarDirs as $calDir) {
33667e8ea635SAtari911            foreach (glob($calDir . '/*.json') as $file) {
33677e8ea635SAtari911                $data = json_decode(file_get_contents($file), true);
33687e8ea635SAtari911                if (!$data || !is_array($data)) continue;
33697e8ea635SAtari911
33707e8ea635SAtari911                $modified = false;
33717e8ea635SAtari911                foreach ($data as $dateKey => &$dayEvents) {
3372*96df7d3eSAtari911                    // Skip non-date keys (like "mapping" or other metadata)
3373*96df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3374*96df7d3eSAtari911
33757e8ea635SAtari911                    if ($dateKey >= $today) continue;
33767e8ea635SAtari911                    if (!is_array($dayEvents)) continue;
33777e8ea635SAtari911
33787e8ea635SAtari911                    $filtered = [];
33797e8ea635SAtari911                    foreach ($dayEvents as $event) {
33807e8ea635SAtari911                        if (!empty($event['recurring']) || !empty($event['recurringId'])) {
33817e8ea635SAtari911                            $removed++;
33827e8ea635SAtari911                            if (!$dryRun) $modified = true;
33837e8ea635SAtari911                        } else {
33847e8ea635SAtari911                            $filtered[] = $event;
33857e8ea635SAtari911                        }
33867e8ea635SAtari911                    }
33877e8ea635SAtari911                    if (!$dryRun) $dayEvents = $filtered;
33887e8ea635SAtari911                }
33897e8ea635SAtari911                unset($dayEvents);
33907e8ea635SAtari911
33917e8ea635SAtari911                if (!$dryRun && $modified) {
33927e8ea635SAtari911                    foreach ($data as $dk => $evts) {
33937e8ea635SAtari911                        if (empty($evts)) unset($data[$dk]);
33947e8ea635SAtari911                    }
33957e8ea635SAtari911                    if (empty($data)) {
33967e8ea635SAtari911                        unlink($file);
33977e8ea635SAtari911                    } else {
33987e8ea635SAtari911                        file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
33997e8ea635SAtari911                    }
34007e8ea635SAtari911                }
34017e8ea635SAtari911            }
34027e8ea635SAtari911        }
34037e8ea635SAtari911
34047e8ea635SAtari911        if (!$dryRun) $this->clearStatsCache();
34057e8ea635SAtari911        echo json_encode(['success' => true, 'count' => $removed, 'message' => "Removed $removed past recurring occurrences"]);
34067e8ea635SAtari911    }
34077e8ea635SAtari911
34087e8ea635SAtari911    private function handleRescanRecurring() {
34097e8ea635SAtari911        $colors = $this->getTemplateColors();
34107e8ea635SAtari911        $recurringEvents = $this->findRecurringEvents();
34117e8ea635SAtari911
34127e8ea635SAtari911        ob_start();
34137e8ea635SAtari911        $this->renderRecurringTable($recurringEvents, $colors);
34147e8ea635SAtari911        $html = ob_get_clean();
34157e8ea635SAtari911
34167e8ea635SAtari911        echo json_encode([
34177e8ea635SAtari911            'success' => true,
34187e8ea635SAtari911            'html' => $html,
34197e8ea635SAtari911            'count' => count($recurringEvents)
34207e8ea635SAtari911        ]);
34217e8ea635SAtari911    }
34227e8ea635SAtari911
34237e8ea635SAtari911    /**
34247e8ea635SAtari911     * Helper: find all events matching a title in a namespace's calendar dir
34257e8ea635SAtari911     */
34267e8ea635SAtari911    private function getRecurringSeriesEvents($title, $namespace) {
34277e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
34287e8ea635SAtari911        if ($namespace !== '') {
34297e8ea635SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
34307e8ea635SAtari911        }
34317e8ea635SAtari911        $dataDir .= 'calendar/';
34327e8ea635SAtari911
34337e8ea635SAtari911        $events = []; // ['date' => dateKey, 'file' => filepath, 'event' => eventData, 'index' => idx]
34347e8ea635SAtari911
34357e8ea635SAtari911        if (!is_dir($dataDir)) return $events;
34367e8ea635SAtari911
34377e8ea635SAtari911        foreach (glob($dataDir . '*.json') as $file) {
34387e8ea635SAtari911            $data = json_decode(file_get_contents($file), true);
34397e8ea635SAtari911            if (!$data || !is_array($data)) continue;
34407e8ea635SAtari911
34417e8ea635SAtari911            foreach ($data as $dateKey => $dayEvents) {
3442*96df7d3eSAtari911                // Skip non-date keys (like "mapping" or other metadata)
3443*96df7d3eSAtari911                if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3444*96df7d3eSAtari911
34457e8ea635SAtari911                if (!is_array($dayEvents)) continue;
34467e8ea635SAtari911                foreach ($dayEvents as $idx => $event) {
3447*96df7d3eSAtari911                    if (!isset($event['title'])) continue;
34487e8ea635SAtari911                    if (strtolower(trim($event['title'])) === strtolower(trim($title))) {
34497e8ea635SAtari911                        $events[] = [
34507e8ea635SAtari911                            'date' => $dateKey,
34517e8ea635SAtari911                            'file' => $file,
34527e8ea635SAtari911                            'event' => $event,
34537e8ea635SAtari911                            'index' => $idx
34547e8ea635SAtari911                        ];
34557e8ea635SAtari911                    }
34567e8ea635SAtari911                }
34577e8ea635SAtari911            }
34587e8ea635SAtari911        }
34597e8ea635SAtari911
34607e8ea635SAtari911        // Sort by date
34617e8ea635SAtari911        usort($events, function($a, $b) {
34627e8ea635SAtari911            return strcmp($a['date'], $b['date']);
34637e8ea635SAtari911        });
34647e8ea635SAtari911
34657e8ea635SAtari911        return $events;
34667e8ea635SAtari911    }
34677e8ea635SAtari911
34687e8ea635SAtari911    /**
34697e8ea635SAtari911     * Extend series: add more future occurrences
34707e8ea635SAtari911     */
34717e8ea635SAtari911    private function handleExtendRecurring() {
34727e8ea635SAtari911        global $INPUT;
34737e8ea635SAtari911        $title = $INPUT->str('title');
34747e8ea635SAtari911        $namespace = $INPUT->str('namespace');
34757e8ea635SAtari911        $count = $INPUT->int('count', 4);
34767e8ea635SAtari911        $intervalDays = $INPUT->int('interval_days', 7);
34777e8ea635SAtari911
34787e8ea635SAtari911        $events = $this->getRecurringSeriesEvents($title, $namespace);
34797e8ea635SAtari911        if (empty($events)) {
34807e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Series not found']);
34817e8ea635SAtari911            return;
34827e8ea635SAtari911        }
34837e8ea635SAtari911
34847e8ea635SAtari911        // Use last event as template
34857e8ea635SAtari911        $lastEvent = end($events);
34867e8ea635SAtari911        $lastDate = new DateTime($lastEvent['date']);
34877e8ea635SAtari911        $template = $lastEvent['event'];
34887e8ea635SAtari911
34897e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
34907e8ea635SAtari911        if ($namespace !== '') {
34917e8ea635SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
34927e8ea635SAtari911        }
34937e8ea635SAtari911        $dataDir .= 'calendar/';
34947e8ea635SAtari911
34957e8ea635SAtari911        if (!is_dir($dataDir)) mkdir($dataDir, 0755, true);
34967e8ea635SAtari911
34977e8ea635SAtari911        $added = 0;
34987e8ea635SAtari911        $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace);
34997e8ea635SAtari911        $maxExistingIdx = 0;
35007e8ea635SAtari911        foreach ($events as $e) {
35017e8ea635SAtari911            if (isset($e['event']['id']) && preg_match('/-(\d+)$/', $e['event']['id'], $m)) {
35027e8ea635SAtari911                $maxExistingIdx = max($maxExistingIdx, (int)$m[1]);
35037e8ea635SAtari911            }
35047e8ea635SAtari911        }
35057e8ea635SAtari911
35067e8ea635SAtari911        for ($i = 1; $i <= $count; $i++) {
35077e8ea635SAtari911            $newDate = clone $lastDate;
35087e8ea635SAtari911            $newDate->modify('+' . ($i * $intervalDays) . ' days');
35097e8ea635SAtari911            $dateKey = $newDate->format('Y-m-d');
35107e8ea635SAtari911            list($year, $month) = explode('-', $dateKey);
35117e8ea635SAtari911
35127e8ea635SAtari911            $file = $dataDir . sprintf('%04d-%02d.json', $year, $month);
35137e8ea635SAtari911            $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
35147e8ea635SAtari911            if (!is_array($fileData)) $fileData = [];
35157e8ea635SAtari911
35167e8ea635SAtari911            if (!isset($fileData[$dateKey])) $fileData[$dateKey] = [];
35177e8ea635SAtari911
35187e8ea635SAtari911            $newEvent = $template;
35197e8ea635SAtari911            $newEvent['id'] = $baseId . '-' . ($maxExistingIdx + $i);
35207e8ea635SAtari911            $newEvent['recurring'] = true;
35217e8ea635SAtari911            $newEvent['recurringId'] = $baseId;
35227e8ea635SAtari911            $newEvent['created'] = date('Y-m-d H:i:s');
35237e8ea635SAtari911            unset($newEvent['completed']);
35247e8ea635SAtari911            $newEvent['completed'] = false;
35257e8ea635SAtari911
35267e8ea635SAtari911            $fileData[$dateKey][] = $newEvent;
35277e8ea635SAtari911            file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT));
35287e8ea635SAtari911            $added++;
35297e8ea635SAtari911        }
35307e8ea635SAtari911
35317e8ea635SAtari911        $this->clearStatsCache();
35327e8ea635SAtari911        echo json_encode(['success' => true, 'message' => "Added $added new occurrences"]);
35337e8ea635SAtari911    }
35347e8ea635SAtari911
35357e8ea635SAtari911    /**
35367e8ea635SAtari911     * Trim series: remove past occurrences before a cutoff date
35377e8ea635SAtari911     */
35387e8ea635SAtari911    private function handleTrimRecurring() {
35397e8ea635SAtari911        global $INPUT;
35407e8ea635SAtari911        $title = $INPUT->str('title');
35417e8ea635SAtari911        $namespace = $INPUT->str('namespace');
35427e8ea635SAtari911        $cutoffDate = $INPUT->str('cutoff_date', date('Y-m-d'));
35437e8ea635SAtari911
35447e8ea635SAtari911        $events = $this->getRecurringSeriesEvents($title, $namespace);
35457e8ea635SAtari911        $removed = 0;
35467e8ea635SAtari911
35477e8ea635SAtari911        foreach ($events as $entry) {
35487e8ea635SAtari911            if ($entry['date'] < $cutoffDate) {
35497e8ea635SAtari911                // Remove this event from its file
35507e8ea635SAtari911                $data = json_decode(file_get_contents($entry['file']), true);
35517e8ea635SAtari911                if (!$data || !isset($data[$entry['date']])) continue;
35527e8ea635SAtari911
35537e8ea635SAtari911                // Find and remove by matching title
35547e8ea635SAtari911                foreach ($data[$entry['date']] as $k => $evt) {
35557e8ea635SAtari911                    if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
35567e8ea635SAtari911                        unset($data[$entry['date']][$k]);
35577e8ea635SAtari911                        $data[$entry['date']] = array_values($data[$entry['date']]);
35587e8ea635SAtari911                        $removed++;
35597e8ea635SAtari911                        break;
35607e8ea635SAtari911                    }
35617e8ea635SAtari911                }
35627e8ea635SAtari911
35637e8ea635SAtari911                // Clean up empty dates
35647e8ea635SAtari911                if (empty($data[$entry['date']])) unset($data[$entry['date']]);
35657e8ea635SAtari911
35667e8ea635SAtari911                if (empty($data)) {
35677e8ea635SAtari911                    unlink($entry['file']);
35687e8ea635SAtari911                } else {
35697e8ea635SAtari911                    file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
35707e8ea635SAtari911                }
35717e8ea635SAtari911            }
35727e8ea635SAtari911        }
35737e8ea635SAtari911
35747e8ea635SAtari911        $this->clearStatsCache();
35757e8ea635SAtari911        echo json_encode(['success' => true, 'message' => "Removed $removed past occurrences before $cutoffDate"]);
35767e8ea635SAtari911    }
35777e8ea635SAtari911
35787e8ea635SAtari911    /**
35797e8ea635SAtari911     * Pause series: mark all future occurrences as paused
35807e8ea635SAtari911     */
35817e8ea635SAtari911    private function handlePauseRecurring() {
35827e8ea635SAtari911        global $INPUT;
35837e8ea635SAtari911        $title = $INPUT->str('title');
35847e8ea635SAtari911        $namespace = $INPUT->str('namespace');
35857e8ea635SAtari911        $today = date('Y-m-d');
35867e8ea635SAtari911
35877e8ea635SAtari911        $events = $this->getRecurringSeriesEvents($title, $namespace);
35887e8ea635SAtari911        $paused = 0;
35897e8ea635SAtari911
35907e8ea635SAtari911        foreach ($events as $entry) {
35917e8ea635SAtari911            if ($entry['date'] >= $today) {
35927e8ea635SAtari911                $data = json_decode(file_get_contents($entry['file']), true);
35937e8ea635SAtari911                if (!$data || !isset($data[$entry['date']])) continue;
35947e8ea635SAtari911
35957e8ea635SAtari911                foreach ($data[$entry['date']] as $k => &$evt) {
35967e8ea635SAtari911                    if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
35977e8ea635SAtari911                        $evt['paused'] = true;
35987e8ea635SAtari911                        $evt['title'] = '⏸ ' . preg_replace('/^⏸\s*/', '', $evt['title']);
35997e8ea635SAtari911                        $paused++;
36007e8ea635SAtari911                        break;
36017e8ea635SAtari911                    }
36027e8ea635SAtari911                }
36037e8ea635SAtari911                unset($evt);
36047e8ea635SAtari911
36057e8ea635SAtari911                file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
36067e8ea635SAtari911            }
36077e8ea635SAtari911        }
36087e8ea635SAtari911
36097e8ea635SAtari911        $this->clearStatsCache();
36107e8ea635SAtari911        echo json_encode(['success' => true, 'message' => "Paused $paused future occurrences"]);
36117e8ea635SAtari911    }
36127e8ea635SAtari911
36137e8ea635SAtari911    /**
36147e8ea635SAtari911     * Resume series: unmark paused occurrences
36157e8ea635SAtari911     */
36167e8ea635SAtari911    private function handleResumeRecurring() {
36177e8ea635SAtari911        global $INPUT;
36187e8ea635SAtari911        $title = $INPUT->str('title');
36197e8ea635SAtari911        $namespace = $INPUT->str('namespace');
36207e8ea635SAtari911
36217e8ea635SAtari911        // Search for both paused and non-paused versions
36227e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
36237e8ea635SAtari911        if ($namespace !== '') {
36247e8ea635SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
36257e8ea635SAtari911        }
36267e8ea635SAtari911        $dataDir .= 'calendar/';
36277e8ea635SAtari911
36287e8ea635SAtari911        $resumed = 0;
36297e8ea635SAtari911        $cleanTitle = preg_replace('/^⏸\s*/', '', $title);
36307e8ea635SAtari911
36317e8ea635SAtari911        if (!is_dir($dataDir)) {
36327e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Directory not found']);
36337e8ea635SAtari911            return;
36347e8ea635SAtari911        }
36357e8ea635SAtari911
36367e8ea635SAtari911        foreach (glob($dataDir . '*.json') as $file) {
36377e8ea635SAtari911            $data = json_decode(file_get_contents($file), true);
36387e8ea635SAtari911            if (!$data) continue;
36397e8ea635SAtari911
36407e8ea635SAtari911            $modified = false;
36417e8ea635SAtari911            foreach ($data as $dateKey => &$dayEvents) {
3642*96df7d3eSAtari911                // Skip non-date keys (like "mapping" or other metadata)
3643*96df7d3eSAtari911                if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3644*96df7d3eSAtari911                if (!is_array($dayEvents)) continue;
3645*96df7d3eSAtari911
36467e8ea635SAtari911                foreach ($dayEvents as $k => &$evt) {
3647*96df7d3eSAtari911                    if (!isset($evt['title'])) continue;
36487e8ea635SAtari911                    $evtCleanTitle = preg_replace('/^⏸\s*/', '', $evt['title']);
36497e8ea635SAtari911                    if (strtolower(trim($evtCleanTitle)) === strtolower(trim($cleanTitle)) &&
36507e8ea635SAtari911                        (!empty($evt['paused']) || strpos($evt['title'], '⏸') === 0)) {
36517e8ea635SAtari911                        $evt['paused'] = false;
36527e8ea635SAtari911                        $evt['title'] = $cleanTitle;
36537e8ea635SAtari911                        $resumed++;
36547e8ea635SAtari911                        $modified = true;
36557e8ea635SAtari911                    }
36567e8ea635SAtari911                }
36577e8ea635SAtari911                unset($evt);
36587e8ea635SAtari911            }
36597e8ea635SAtari911            unset($dayEvents);
36607e8ea635SAtari911
36617e8ea635SAtari911            if ($modified) {
36627e8ea635SAtari911                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
36637e8ea635SAtari911            }
36647e8ea635SAtari911        }
36657e8ea635SAtari911
36667e8ea635SAtari911        $this->clearStatsCache();
36677e8ea635SAtari911        echo json_encode(['success' => true, 'message' => "Resumed $resumed occurrences"]);
36687e8ea635SAtari911    }
36697e8ea635SAtari911
36707e8ea635SAtari911    /**
36717e8ea635SAtari911     * Change start date: shift all occurrences by an offset
36727e8ea635SAtari911     */
36737e8ea635SAtari911    private function handleChangeStartRecurring() {
36747e8ea635SAtari911        global $INPUT;
36757e8ea635SAtari911        $title = $INPUT->str('title');
36767e8ea635SAtari911        $namespace = $INPUT->str('namespace');
36777e8ea635SAtari911        $newStartDate = $INPUT->str('new_start_date');
36787e8ea635SAtari911
36797e8ea635SAtari911        if (empty($newStartDate)) {
36807e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'No start date provided']);
36817e8ea635SAtari911            return;
36827e8ea635SAtari911        }
36837e8ea635SAtari911
36847e8ea635SAtari911        $events = $this->getRecurringSeriesEvents($title, $namespace);
36857e8ea635SAtari911        if (empty($events)) {
36867e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'Series not found']);
36877e8ea635SAtari911            return;
36887e8ea635SAtari911        }
36897e8ea635SAtari911
36907e8ea635SAtari911        // Calculate offset from old first date to new first date
36917e8ea635SAtari911        $oldFirst = new DateTime($events[0]['date']);
36927e8ea635SAtari911        $newFirst = new DateTime($newStartDate);
36937e8ea635SAtari911        $offsetDays = (int)$oldFirst->diff($newFirst)->format('%r%a');
36947e8ea635SAtari911
36957e8ea635SAtari911        if ($offsetDays === 0) {
36967e8ea635SAtari911            echo json_encode(['success' => true, 'message' => 'Start date unchanged']);
36977e8ea635SAtari911            return;
36987e8ea635SAtari911        }
36997e8ea635SAtari911
37007e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
37017e8ea635SAtari911        if ($namespace !== '') {
37027e8ea635SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
37037e8ea635SAtari911        }
37047e8ea635SAtari911        $dataDir .= 'calendar/';
37057e8ea635SAtari911
37067e8ea635SAtari911        // Collect all events to move
37077e8ea635SAtari911        $toMove = [];
37087e8ea635SAtari911        foreach ($events as $entry) {
37097e8ea635SAtari911            $oldDate = new DateTime($entry['date']);
37107e8ea635SAtari911            $newDate = clone $oldDate;
37117e8ea635SAtari911            $newDate->modify(($offsetDays > 0 ? '+' : '') . $offsetDays . ' days');
37127e8ea635SAtari911
37137e8ea635SAtari911            $toMove[] = [
37147e8ea635SAtari911                'oldDate' => $entry['date'],
37157e8ea635SAtari911                'newDate' => $newDate->format('Y-m-d'),
37167e8ea635SAtari911                'event' => $entry['event'],
37177e8ea635SAtari911                'file' => $entry['file']
37187e8ea635SAtari911            ];
37197e8ea635SAtari911        }
37207e8ea635SAtari911
37217e8ea635SAtari911        // Remove all from old positions
37227e8ea635SAtari911        foreach ($toMove as $move) {
37237e8ea635SAtari911            $data = json_decode(file_get_contents($move['file']), true);
37247e8ea635SAtari911            if (!$data || !isset($data[$move['oldDate']])) continue;
37257e8ea635SAtari911
37267e8ea635SAtari911            foreach ($data[$move['oldDate']] as $k => $evt) {
37277e8ea635SAtari911                if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
37287e8ea635SAtari911                    unset($data[$move['oldDate']][$k]);
37297e8ea635SAtari911                    $data[$move['oldDate']] = array_values($data[$move['oldDate']]);
37307e8ea635SAtari911                    break;
37317e8ea635SAtari911                }
37327e8ea635SAtari911            }
37337e8ea635SAtari911            if (empty($data[$move['oldDate']])) unset($data[$move['oldDate']]);
37347e8ea635SAtari911            if (empty($data)) {
37357e8ea635SAtari911                unlink($move['file']);
37367e8ea635SAtari911            } else {
37377e8ea635SAtari911                file_put_contents($move['file'], json_encode($data, JSON_PRETTY_PRINT));
37387e8ea635SAtari911            }
37397e8ea635SAtari911        }
37407e8ea635SAtari911
37417e8ea635SAtari911        // Add to new positions
37427e8ea635SAtari911        $moved = 0;
37437e8ea635SAtari911        foreach ($toMove as $move) {
37447e8ea635SAtari911            list($year, $month) = explode('-', $move['newDate']);
37457e8ea635SAtari911            $file = $dataDir . sprintf('%04d-%02d.json', $year, $month);
37467e8ea635SAtari911            $data = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
37477e8ea635SAtari911            if (!is_array($data)) $data = [];
37487e8ea635SAtari911
37497e8ea635SAtari911            if (!isset($data[$move['newDate']])) $data[$move['newDate']] = [];
37507e8ea635SAtari911            $data[$move['newDate']][] = $move['event'];
37517e8ea635SAtari911            file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
37527e8ea635SAtari911            $moved++;
37537e8ea635SAtari911        }
37547e8ea635SAtari911
37557e8ea635SAtari911        $dir = $offsetDays > 0 ? 'forward' : 'back';
37567e8ea635SAtari911        $this->clearStatsCache();
37577e8ea635SAtari911        echo json_encode(['success' => true, 'message' => "Shifted $moved occurrences $dir by " . abs($offsetDays) . " days"]);
37587e8ea635SAtari911    }
37597e8ea635SAtari911
37607e8ea635SAtari911    /**
37617e8ea635SAtari911     * Change pattern: re-space all future events with a new interval
37627e8ea635SAtari911     */
37637e8ea635SAtari911    private function handleChangePatternRecurring() {
37647e8ea635SAtari911        global $INPUT;
37657e8ea635SAtari911        $title = $INPUT->str('title');
37667e8ea635SAtari911        $namespace = $INPUT->str('namespace');
37677e8ea635SAtari911        $newIntervalDays = $INPUT->int('interval_days', 7);
37687e8ea635SAtari911
37697e8ea635SAtari911        $events = $this->getRecurringSeriesEvents($title, $namespace);
37707e8ea635SAtari911        $today = date('Y-m-d');
37717e8ea635SAtari911
37727e8ea635SAtari911        // Split into past and future
37737e8ea635SAtari911        $pastEvents = [];
37747e8ea635SAtari911        $futureEvents = [];
37757e8ea635SAtari911        foreach ($events as $e) {
37767e8ea635SAtari911            if ($e['date'] < $today) {
37777e8ea635SAtari911                $pastEvents[] = $e;
37787e8ea635SAtari911            } else {
37797e8ea635SAtari911                $futureEvents[] = $e;
37807e8ea635SAtari911            }
37817e8ea635SAtari911        }
37827e8ea635SAtari911
37837e8ea635SAtari911        if (empty($futureEvents)) {
37847e8ea635SAtari911            echo json_encode(['success' => false, 'error' => 'No future occurrences to respace']);
37857e8ea635SAtari911            return;
37867e8ea635SAtari911        }
37877e8ea635SAtari911
37887e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
37897e8ea635SAtari911        if ($namespace !== '') {
37907e8ea635SAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
37917e8ea635SAtari911        }
37927e8ea635SAtari911        $dataDir .= 'calendar/';
37937e8ea635SAtari911
37947e8ea635SAtari911        // Use first future event as anchor
37957e8ea635SAtari911        $anchorDate = new DateTime($futureEvents[0]['date']);
37967e8ea635SAtari911
37977e8ea635SAtari911        // Remove all future events from files
37987e8ea635SAtari911        foreach ($futureEvents as $entry) {
37997e8ea635SAtari911            $data = json_decode(file_get_contents($entry['file']), true);
38007e8ea635SAtari911            if (!$data || !isset($data[$entry['date']])) continue;
38017e8ea635SAtari911
38027e8ea635SAtari911            foreach ($data[$entry['date']] as $k => $evt) {
38037e8ea635SAtari911                if (strtolower(trim($evt['title'])) === strtolower(trim($title))) {
38047e8ea635SAtari911                    unset($data[$entry['date']][$k]);
38057e8ea635SAtari911                    $data[$entry['date']] = array_values($data[$entry['date']]);
38067e8ea635SAtari911                    break;
38077e8ea635SAtari911                }
38087e8ea635SAtari911            }
38097e8ea635SAtari911            if (empty($data[$entry['date']])) unset($data[$entry['date']]);
38107e8ea635SAtari911            if (empty($data)) {
38117e8ea635SAtari911                unlink($entry['file']);
38127e8ea635SAtari911            } else {
38137e8ea635SAtari911                file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
38147e8ea635SAtari911            }
38157e8ea635SAtari911        }
38167e8ea635SAtari911
38177e8ea635SAtari911        // Re-create with new spacing
38187e8ea635SAtari911        $template = $futureEvents[0]['event'];
38197e8ea635SAtari911        $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace);
38207e8ea635SAtari911        $count = count($futureEvents);
38217e8ea635SAtari911        $created = 0;
38227e8ea635SAtari911
38237e8ea635SAtari911        for ($i = 0; $i < $count; $i++) {
38247e8ea635SAtari911            $newDate = clone $anchorDate;
38257e8ea635SAtari911            $newDate->modify('+' . ($i * $newIntervalDays) . ' days');
38267e8ea635SAtari911            $dateKey = $newDate->format('Y-m-d');
38277e8ea635SAtari911            list($year, $month) = explode('-', $dateKey);
38287e8ea635SAtari911
38297e8ea635SAtari911            $file = $dataDir . sprintf('%04d-%02d.json', $year, $month);
38307e8ea635SAtari911            $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
38317e8ea635SAtari911            if (!is_array($fileData)) $fileData = [];
38327e8ea635SAtari911
38337e8ea635SAtari911            if (!isset($fileData[$dateKey])) $fileData[$dateKey] = [];
38347e8ea635SAtari911
38357e8ea635SAtari911            $newEvent = $template;
38367e8ea635SAtari911            $newEvent['id'] = $baseId . '-respace-' . $i;
38377e8ea635SAtari911            $newEvent['recurring'] = true;
38387e8ea635SAtari911            $newEvent['recurringId'] = $baseId;
38397e8ea635SAtari911
38407e8ea635SAtari911            $fileData[$dateKey][] = $newEvent;
38417e8ea635SAtari911            file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT));
38427e8ea635SAtari911            $created++;
38437e8ea635SAtari911        }
38447e8ea635SAtari911
38457e8ea635SAtari911        $this->clearStatsCache();
38467e8ea635SAtari911        $patternName = $this->intervalToPattern($newIntervalDays);
38477e8ea635SAtari911        echo json_encode(['success' => true, 'message' => "Respaced $created future occurrences to $patternName ($newIntervalDays days)"]);
38487e8ea635SAtari911    }
38497e8ea635SAtari911
38507e8ea635SAtari911    private function intervalToPattern($days) {
38517e8ea635SAtari911        if ($days == 1) return 'Daily';
38527e8ea635SAtari911        if ($days == 7) return 'Weekly';
38537e8ea635SAtari911        if ($days == 14) return 'Bi-weekly';
38547e8ea635SAtari911        if ($days >= 28 && $days <= 31) return 'Monthly';
38557e8ea635SAtari911        if ($days >= 89 && $days <= 93) return 'Quarterly';
38567e8ea635SAtari911        if ($days >= 363 && $days <= 368) return 'Yearly';
38577e8ea635SAtari911        return "Every $days days";
38581d05cddcSAtari911    }
38591d05cddcSAtari911
38601d05cddcSAtari911    private function getEventsByNamespace() {
38611d05cddcSAtari911        $dataDir = DOKU_INC . 'data/meta/';
38621d05cddcSAtari911        $result = [];
38631d05cddcSAtari911
38641d05cddcSAtari911        // Check root calendar directory first (blank/default namespace)
38651d05cddcSAtari911        $rootCalendarDir = $dataDir . 'calendar';
38661d05cddcSAtari911        if (is_dir($rootCalendarDir)) {
38671d05cddcSAtari911            $hasFiles = false;
38681d05cddcSAtari911            $events = [];
38691d05cddcSAtari911
38701d05cddcSAtari911            foreach (glob($rootCalendarDir . '/*.json') as $file) {
38711d05cddcSAtari911                $hasFiles = true;
38721d05cddcSAtari911                $month = basename($file, '.json');
38731d05cddcSAtari911                $data = json_decode(file_get_contents($file), true);
38741d05cddcSAtari911                if (!$data) continue;
38751d05cddcSAtari911
38761d05cddcSAtari911                foreach ($data as $dateKey => $eventList) {
3877*96df7d3eSAtari911                    // Skip non-date keys (like "mapping" or other metadata)
3878*96df7d3eSAtari911                    // Date keys should be in YYYY-MM-DD format
3879*96df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3880*96df7d3eSAtari911
3881*96df7d3eSAtari911                    // Skip if eventList is not an array (corrupted data)
3882*96df7d3eSAtari911                    if (!is_array($eventList)) continue;
3883*96df7d3eSAtari911
38841d05cddcSAtari911                    foreach ($eventList as $event) {
3885*96df7d3eSAtari911                        // Skip if event is not an array
3886*96df7d3eSAtari911                        if (!is_array($event)) continue;
3887*96df7d3eSAtari911
3888*96df7d3eSAtari911                        // Skip if event doesn't have required fields
3889*96df7d3eSAtari911                        if (empty($event['id']) || empty($event['title'])) continue;
3890*96df7d3eSAtari911
38911d05cddcSAtari911                        $events[] = [
38921d05cddcSAtari911                            'id' => $event['id'],
38931d05cddcSAtari911                            'title' => $event['title'],
38941d05cddcSAtari911                            'date' => $dateKey,
38951d05cddcSAtari911                            'startTime' => $event['startTime'] ?? null,
38961d05cddcSAtari911                            'month' => $month
38971d05cddcSAtari911                        ];
38981d05cddcSAtari911                    }
38991d05cddcSAtari911                }
39001d05cddcSAtari911            }
39011d05cddcSAtari911
39021d05cddcSAtari911            // Add if it has JSON files (even if empty)
39031d05cddcSAtari911            if ($hasFiles) {
39041d05cddcSAtari911                $result[''] = ['events' => $events];
39051d05cddcSAtari911            }
39061d05cddcSAtari911        }
39071d05cddcSAtari911
39081d05cddcSAtari911        // Recursively scan all namespace directories including sub-namespaces
39091d05cddcSAtari911        $this->scanNamespaceRecursive($dataDir, '', $result);
39101d05cddcSAtari911
39111d05cddcSAtari911        // Sort namespaces, but keep '' (default) first
39121d05cddcSAtari911        uksort($result, function($a, $b) {
39131d05cddcSAtari911            if ($a === '') return -1;
39141d05cddcSAtari911            if ($b === '') return 1;
39151d05cddcSAtari911            return strcmp($a, $b);
39161d05cddcSAtari911        });
39171d05cddcSAtari911
39181d05cddcSAtari911        return $result;
39191d05cddcSAtari911    }
39201d05cddcSAtari911
39211d05cddcSAtari911    private function scanNamespaceRecursive($baseDir, $parentNamespace, &$result) {
39221d05cddcSAtari911        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
39231d05cddcSAtari911            $dirName = basename($nsDir);
39241d05cddcSAtari911
39251d05cddcSAtari911            // Skip the root 'calendar' dir
39261d05cddcSAtari911            if ($dirName === 'calendar' && empty($parentNamespace)) continue;
39271d05cddcSAtari911
39281d05cddcSAtari911            // Build namespace path
39291d05cddcSAtari911            $namespace = empty($parentNamespace) ? $dirName : $parentNamespace . ':' . $dirName;
39301d05cddcSAtari911
39311d05cddcSAtari911            // Check for calendar directory
39321d05cddcSAtari911            $calendarDir = $nsDir . '/calendar';
39331d05cddcSAtari911            if (is_dir($calendarDir)) {
39341d05cddcSAtari911                $hasFiles = false;
39351d05cddcSAtari911                $events = [];
39361d05cddcSAtari911
39371d05cddcSAtari911                // Scan all calendar files
39381d05cddcSAtari911                foreach (glob($calendarDir . '/*.json') as $file) {
39391d05cddcSAtari911                    $hasFiles = true;
39401d05cddcSAtari911                    $month = basename($file, '.json');
39411d05cddcSAtari911                    $data = json_decode(file_get_contents($file), true);
39421d05cddcSAtari911                    if (!$data) continue;
39431d05cddcSAtari911
39441d05cddcSAtari911                    foreach ($data as $dateKey => $eventList) {
3945*96df7d3eSAtari911                        // Skip non-date keys (like "mapping" or other metadata)
3946*96df7d3eSAtari911                        // Date keys should be in YYYY-MM-DD format
3947*96df7d3eSAtari911                        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
3948*96df7d3eSAtari911
3949*96df7d3eSAtari911                        // Skip if eventList is not an array (corrupted data)
3950*96df7d3eSAtari911                        if (!is_array($eventList)) continue;
3951*96df7d3eSAtari911
39521d05cddcSAtari911                        foreach ($eventList as $event) {
3953*96df7d3eSAtari911                            // Skip if event is not an array
3954*96df7d3eSAtari911                            if (!is_array($event)) continue;
3955*96df7d3eSAtari911
3956*96df7d3eSAtari911                            // Skip if event doesn't have required fields
3957*96df7d3eSAtari911                            if (empty($event['id']) || empty($event['title'])) continue;
3958*96df7d3eSAtari911
39591d05cddcSAtari911                            $events[] = [
39601d05cddcSAtari911                                'id' => $event['id'],
39611d05cddcSAtari911                                'title' => $event['title'],
39621d05cddcSAtari911                                'date' => $dateKey,
39631d05cddcSAtari911                                'startTime' => $event['startTime'] ?? null,
39641d05cddcSAtari911                                'month' => $month
39651d05cddcSAtari911                            ];
39661d05cddcSAtari911                        }
39671d05cddcSAtari911                    }
39681d05cddcSAtari911                }
39691d05cddcSAtari911
39701d05cddcSAtari911                // Add namespace if it has JSON files (even if empty)
39711d05cddcSAtari911                if ($hasFiles) {
39721d05cddcSAtari911                    $result[$namespace] = ['events' => $events];
39731d05cddcSAtari911                }
39741d05cddcSAtari911            }
39751d05cddcSAtari911
39761d05cddcSAtari911            // Recursively scan sub-directories
39771d05cddcSAtari911            $this->scanNamespaceRecursive($nsDir . '/', $namespace, $result);
39781d05cddcSAtari911        }
39791d05cddcSAtari911    }
39801d05cddcSAtari911
39811d05cddcSAtari911    private function getAllNamespaces() {
39821d05cddcSAtari911        $dataDir = DOKU_INC . 'data/meta/';
39831d05cddcSAtari911        $namespaces = [];
39841d05cddcSAtari911
39851d05cddcSAtari911        // Check root calendar directory first
39861d05cddcSAtari911        $rootCalendarDir = $dataDir . 'calendar';
39871d05cddcSAtari911        if (is_dir($rootCalendarDir)) {
39881d05cddcSAtari911            $namespaces[] = '';  // Blank/default namespace
39891d05cddcSAtari911        }
39901d05cddcSAtari911
39911d05cddcSAtari911        // Check all other namespace directories
39921d05cddcSAtari911        foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) {
39931d05cddcSAtari911            $namespace = basename($nsDir);
39941d05cddcSAtari911
39951d05cddcSAtari911            // Skip the root 'calendar' dir (already added as '')
39961d05cddcSAtari911            if ($namespace === 'calendar') continue;
39971d05cddcSAtari911
39981d05cddcSAtari911            $calendarDir = $nsDir . '/calendar';
39991d05cddcSAtari911            if (is_dir($calendarDir)) {
40001d05cddcSAtari911                $namespaces[] = $namespace;
40011d05cddcSAtari911            }
40021d05cddcSAtari911        }
40031d05cddcSAtari911
40041d05cddcSAtari911        return $namespaces;
40051d05cddcSAtari911    }
40061d05cddcSAtari911
40071d05cddcSAtari911    private function searchEvents($search, $filterNamespace) {
40081d05cddcSAtari911        $dataDir = DOKU_INC . 'data/meta/';
40091d05cddcSAtari911        $results = [];
40101d05cddcSAtari911
40111d05cddcSAtari911        $search = strtolower(trim($search));
40121d05cddcSAtari911
40131d05cddcSAtari911        foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) {
40141d05cddcSAtari911            $namespace = basename($nsDir);
40151d05cddcSAtari911            $calendarDir = $nsDir . '/calendar';
40161d05cddcSAtari911
40171d05cddcSAtari911            if (!is_dir($calendarDir)) continue;
40181d05cddcSAtari911            if ($filterNamespace !== '' && $namespace !== $filterNamespace) continue;
40191d05cddcSAtari911
40201d05cddcSAtari911            foreach (glob($calendarDir . '/*.json') as $file) {
40211d05cddcSAtari911                $month = basename($file, '.json');
40221d05cddcSAtari911                $data = json_decode(file_get_contents($file), true);
40231d05cddcSAtari911                if (!$data) continue;
40241d05cddcSAtari911
40251d05cddcSAtari911                foreach ($data as $dateKey => $events) {
4026*96df7d3eSAtari911                    // Skip non-date keys (like "mapping" or other metadata)
4027*96df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
4028*96df7d3eSAtari911                    if (!is_array($events)) continue;
4029*96df7d3eSAtari911
40301d05cddcSAtari911                    foreach ($events as $event) {
4031*96df7d3eSAtari911                        if (!isset($event['title']) || !isset($event['id'])) continue;
40321d05cddcSAtari911                        if ($search === '' || strpos(strtolower($event['title']), $search) !== false) {
40331d05cddcSAtari911                            $results[] = [
40341d05cddcSAtari911                                'id' => $event['id'],
40351d05cddcSAtari911                                'title' => $event['title'],
40361d05cddcSAtari911                                'date' => $dateKey,
40371d05cddcSAtari911                                'startTime' => $event['startTime'] ?? null,
40381d05cddcSAtari911                                'namespace' => $event['namespace'] ?? '',
40391d05cddcSAtari911                                'month' => $month
40401d05cddcSAtari911                            ];
40411d05cddcSAtari911                        }
40421d05cddcSAtari911                    }
40431d05cddcSAtari911                }
40441d05cddcSAtari911            }
40451d05cddcSAtari911        }
40461d05cddcSAtari911
40471d05cddcSAtari911        return $results;
40481d05cddcSAtari911    }
40491d05cddcSAtari911
40501d05cddcSAtari911    private function deleteRecurringSeries() {
40511d05cddcSAtari911        global $INPUT;
40521d05cddcSAtari911
40531d05cddcSAtari911        $eventTitle = $INPUT->str('event_title');
40541d05cddcSAtari911        $namespace = $INPUT->str('namespace');
40551d05cddcSAtari911
40567e8ea635SAtari911        // Collect ALL calendar directories
40577e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
40587e8ea635SAtari911        $calendarDirs = [];
40597e8ea635SAtari911        if (is_dir($dataDir . 'calendar')) {
40607e8ea635SAtari911            $calendarDirs[] = $dataDir . 'calendar';
40611d05cddcSAtari911        }
40627e8ea635SAtari911        $this->findCalendarDirs($dataDir, $calendarDirs);
40631d05cddcSAtari911
40641d05cddcSAtari911        $count = 0;
40651d05cddcSAtari911
40667e8ea635SAtari911        foreach ($calendarDirs as $calDir) {
40677e8ea635SAtari911            foreach (glob($calDir . '/*.json') as $file) {
40681d05cddcSAtari911                $data = json_decode(file_get_contents($file), true);
40697e8ea635SAtari911                if (!$data || !is_array($data)) continue;
40701d05cddcSAtari911
40711d05cddcSAtari911                $modified = false;
40721d05cddcSAtari911                foreach ($data as $dateKey => $events) {
4073*96df7d3eSAtari911                    // Skip non-date keys (like "mapping" or other metadata)
4074*96df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
4075*96df7d3eSAtari911                    if (!is_array($events)) continue;
4076*96df7d3eSAtari911
40771d05cddcSAtari911                    $filtered = [];
40781d05cddcSAtari911                    foreach ($events as $event) {
4079*96df7d3eSAtari911                        if (!isset($event['title'])) {
4080*96df7d3eSAtari911                            $filtered[] = $event;
4081*96df7d3eSAtari911                            continue;
4082*96df7d3eSAtari911                        }
40837e8ea635SAtari911                        $eventNs = isset($event['namespace']) ? $event['namespace'] : '';
40847e8ea635SAtari911                        // Match by title AND namespace field
40857e8ea635SAtari911                        if (strtolower(trim($event['title'])) === strtolower(trim($eventTitle)) &&
40867e8ea635SAtari911                            strtolower(trim($eventNs)) === strtolower(trim($namespace))) {
40871d05cddcSAtari911                            $count++;
40881d05cddcSAtari911                            $modified = true;
40891d05cddcSAtari911                        } else {
40901d05cddcSAtari911                            $filtered[] = $event;
40911d05cddcSAtari911                        }
40921d05cddcSAtari911                    }
40931d05cddcSAtari911                    $data[$dateKey] = $filtered;
40941d05cddcSAtari911                }
40951d05cddcSAtari911
40961d05cddcSAtari911                if ($modified) {
40979ccd446eSAtari911                    foreach ($data as $dk => $evts) {
40989ccd446eSAtari911                        if (empty($evts)) unset($data[$dk]);
40999ccd446eSAtari911                    }
41009ccd446eSAtari911
41019ccd446eSAtari911                    if (empty($data)) {
41029ccd446eSAtari911                        unlink($file);
41039ccd446eSAtari911                    } else {
41041d05cddcSAtari911                        file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
41051d05cddcSAtari911                    }
41061d05cddcSAtari911                }
41071d05cddcSAtari911            }
41089ccd446eSAtari911        }
41091d05cddcSAtari911
41109ccd446eSAtari911        $this->clearStatsCache();
41111d05cddcSAtari911        $this->redirect("Deleted $count occurrences of recurring event: " . $eventTitle, 'success', 'manage');
41121d05cddcSAtari911    }
41131d05cddcSAtari911
41141d05cddcSAtari911    private function editRecurringSeries() {
41151d05cddcSAtari911        global $INPUT;
41161d05cddcSAtari911
41171d05cddcSAtari911        $oldTitle = $INPUT->str('old_title');
41181d05cddcSAtari911        $oldNamespace = $INPUT->str('old_namespace');
41191d05cddcSAtari911        $newTitle = $INPUT->str('new_title');
41201d05cddcSAtari911        $startTime = $INPUT->str('start_time');
41211d05cddcSAtari911        $endTime = $INPUT->str('end_time');
41221d05cddcSAtari911        $newNamespace = $INPUT->str('new_namespace');
41231d05cddcSAtari911
4124*96df7d3eSAtari911        // New recurrence parameters
4125*96df7d3eSAtari911        $recurrenceType = $INPUT->str('recurrence_type', '');
4126*96df7d3eSAtari911        $recurrenceInterval = $INPUT->int('recurrence_interval', 0);
4127*96df7d3eSAtari911        $weekDaysStr = $INPUT->str('week_days', '');
4128*96df7d3eSAtari911        $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : [];
4129*96df7d3eSAtari911        $monthlyType = $INPUT->str('monthly_type', '');
4130*96df7d3eSAtari911        $monthDay = $INPUT->int('month_day', 0);
4131*96df7d3eSAtari911        $ordinalWeek = $INPUT->int('ordinal_week', 0);
4132*96df7d3eSAtari911        $ordinalDay = $INPUT->int('ordinal_day', 0);
4133*96df7d3eSAtari911
41341d05cddcSAtari911        // Use old namespace if new namespace is empty (keep current)
41351d05cddcSAtari911        if (empty($newNamespace) && !isset($_POST['new_namespace'])) {
41361d05cddcSAtari911            $newNamespace = $oldNamespace;
41371d05cddcSAtari911        }
41381d05cddcSAtari911
41397e8ea635SAtari911        // Collect ALL calendar directories to search
41407e8ea635SAtari911        $dataDir = DOKU_INC . 'data/meta/';
41417e8ea635SAtari911        $calendarDirs = [];
41427e8ea635SAtari911
41437e8ea635SAtari911        // Root calendar dir
41447e8ea635SAtari911        if (is_dir($dataDir . 'calendar')) {
41457e8ea635SAtari911            $calendarDirs[] = $dataDir . 'calendar';
41461d05cddcSAtari911        }
41471d05cddcSAtari911
41487e8ea635SAtari911        // All namespace dirs
41497e8ea635SAtari911        $this->findCalendarDirs($dataDir, $calendarDirs);
41501d05cddcSAtari911
41517e8ea635SAtari911        $count = 0;
41527e8ea635SAtari911
4153*96df7d3eSAtari911        // Pass 1: Rename title, update time, update namespace field and recurrence metadata in ALL matching events
41547e8ea635SAtari911        foreach ($calendarDirs as $calDir) {
41557e8ea635SAtari911            if (is_string($calDir)) {
41567e8ea635SAtari911                $dir = $calDir;
41577e8ea635SAtari911            } else {
41587e8ea635SAtari911                $dir = $calDir['dir'];
41597e8ea635SAtari911            }
41607e8ea635SAtari911
41617e8ea635SAtari911            foreach (glob($dir . '/*.json') as $file) {
41621d05cddcSAtari911                $data = json_decode(file_get_contents($file), true);
41637e8ea635SAtari911                if (!$data || !is_array($data)) continue;
41641d05cddcSAtari911
41651d05cddcSAtari911                $modified = false;
41667e8ea635SAtari911                foreach ($data as $dateKey => &$dayEvents) {
4167*96df7d3eSAtari911                    // Skip non-date keys (like "mapping" or other metadata)
4168*96df7d3eSAtari911                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
41697e8ea635SAtari911                    if (!is_array($dayEvents)) continue;
4170*96df7d3eSAtari911
41717e8ea635SAtari911                    foreach ($dayEvents as $key => &$event) {
4172*96df7d3eSAtari911                        if (!isset($event['title'])) continue;
41737e8ea635SAtari911                        // Match by old title (case-insensitive) AND namespace field
41747e8ea635SAtari911                        $eventNs = isset($event['namespace']) ? $event['namespace'] : '';
41757e8ea635SAtari911                        if (strtolower(trim($event['title'])) !== strtolower(trim($oldTitle))) continue;
41767e8ea635SAtari911                        if (strtolower(trim($eventNs)) !== strtolower(trim($oldNamespace))) continue;
41777e8ea635SAtari911
41787e8ea635SAtari911                        // Update title
41797e8ea635SAtari911                        $event['title'] = $newTitle;
41801d05cddcSAtari911
41811d05cddcSAtari911                        // Update start time if provided
41821d05cddcSAtari911                        if (!empty($startTime)) {
41837e8ea635SAtari911                            $event['time'] = $startTime;
41841d05cddcSAtari911                        }
41851d05cddcSAtari911
41861d05cddcSAtari911                        // Update end time if provided
41871d05cddcSAtari911                        if (!empty($endTime)) {
41887e8ea635SAtari911                            $event['endTime'] = $endTime;
41891d05cddcSAtari911                        }
41901d05cddcSAtari911
41917e8ea635SAtari911                        // Update namespace field
41927e8ea635SAtari911                        $event['namespace'] = $newNamespace;
41931d05cddcSAtari911
4194*96df7d3eSAtari911                        // Update recurrence metadata if provided
4195*96df7d3eSAtari911                        if (!empty($recurrenceType)) {
4196*96df7d3eSAtari911                            $event['recurrenceType'] = $recurrenceType;
4197*96df7d3eSAtari911                        }
4198*96df7d3eSAtari911                        if ($recurrenceInterval > 0) {
4199*96df7d3eSAtari911                            $event['recurrenceInterval'] = $recurrenceInterval;
4200*96df7d3eSAtari911                        }
4201*96df7d3eSAtari911                        if (!empty($weekDays)) {
4202*96df7d3eSAtari911                            $event['weekDays'] = $weekDays;
4203*96df7d3eSAtari911                        }
4204*96df7d3eSAtari911                        if (!empty($monthlyType)) {
4205*96df7d3eSAtari911                            $event['monthlyType'] = $monthlyType;
4206*96df7d3eSAtari911                            if ($monthlyType === 'dayOfMonth' && $monthDay > 0) {
4207*96df7d3eSAtari911                                $event['monthDay'] = $monthDay;
4208*96df7d3eSAtari911                                unset($event['ordinalWeek']);
4209*96df7d3eSAtari911                                unset($event['ordinalDay']);
4210*96df7d3eSAtari911                            } elseif ($monthlyType === 'ordinalWeekday') {
4211*96df7d3eSAtari911                                $event['ordinalWeek'] = $ordinalWeek;
4212*96df7d3eSAtari911                                $event['ordinalDay'] = $ordinalDay;
4213*96df7d3eSAtari911                                unset($event['monthDay']);
4214*96df7d3eSAtari911                            }
4215*96df7d3eSAtari911                        }
4216*96df7d3eSAtari911
42171d05cddcSAtari911                        $count++;
42181d05cddcSAtari911                        $modified = true;
42191d05cddcSAtari911                    }
42207e8ea635SAtari911                    unset($event);
42211d05cddcSAtari911                }
42227e8ea635SAtari911                unset($dayEvents);
42231d05cddcSAtari911
42241d05cddcSAtari911                if ($modified) {
42251d05cddcSAtari911                    file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
42261d05cddcSAtari911                }
42271d05cddcSAtari911            }
42287e8ea635SAtari911        }
42291d05cddcSAtari911
4230*96df7d3eSAtari911        // Pass 2: Handle recurrence pattern changes - reschedule future events
4231*96df7d3eSAtari911        $needsReschedule = !empty($recurrenceType) && $recurrenceInterval > 0;
4232*96df7d3eSAtari911
4233*96df7d3eSAtari911        if ($needsReschedule && $count > 0) {
4234*96df7d3eSAtari911            // Get all events with the NEW title
42357e8ea635SAtari911            $allEvents = $this->getRecurringSeriesEvents($newTitle, $newNamespace);
42361d05cddcSAtari911
42377e8ea635SAtari911            if (count($allEvents) > 1) {
4238*96df7d3eSAtari911                // Sort by date
4239*96df7d3eSAtari911                usort($allEvents, function($a, $b) {
4240*96df7d3eSAtari911                    return strcmp($a['date'], $b['date']);
4241*96df7d3eSAtari911                });
42421d05cddcSAtari911
4243*96df7d3eSAtari911                $firstDate = new DateTime($allEvents[0]['date']);
4244*96df7d3eSAtari911                $today = new DateTime();
4245*96df7d3eSAtari911                $today->setTime(0, 0, 0);
4246*96df7d3eSAtari911
4247*96df7d3eSAtari911                // Find the anchor date - either first date or first future date
4248*96df7d3eSAtari911                $anchorDate = $firstDate;
4249*96df7d3eSAtari911                $anchorIndex = 0;
4250*96df7d3eSAtari911                for ($i = 0; $i < count($allEvents); $i++) {
4251*96df7d3eSAtari911                    $eventDate = new DateTime($allEvents[$i]['date']);
4252*96df7d3eSAtari911                    if ($eventDate >= $today) {
4253*96df7d3eSAtari911                        $anchorDate = $eventDate;
4254*96df7d3eSAtari911                        $anchorIndex = $i;
4255*96df7d3eSAtari911                        break;
4256*96df7d3eSAtari911                    }
4257*96df7d3eSAtari911                }
4258*96df7d3eSAtari911
4259*96df7d3eSAtari911                // Get template from anchor event
4260*96df7d3eSAtari911                $template = $allEvents[$anchorIndex]['event'];
4261*96df7d3eSAtari911
4262*96df7d3eSAtari911                // Remove all future events (we'll recreate them)
4263*96df7d3eSAtari911                for ($i = $anchorIndex + 1; $i < count($allEvents); $i++) {
42647e8ea635SAtari911                    $entry = $allEvents[$i];
42657e8ea635SAtari911                    $data = json_decode(file_get_contents($entry['file']), true);
42667e8ea635SAtari911                    if (!$data || !isset($data[$entry['date']])) continue;
42677e8ea635SAtari911
42687e8ea635SAtari911                    foreach ($data[$entry['date']] as $k => $evt) {
42697e8ea635SAtari911                        if (strtolower(trim($evt['title'])) === strtolower(trim($newTitle))) {
42707e8ea635SAtari911                            unset($data[$entry['date']][$k]);
42717e8ea635SAtari911                            $data[$entry['date']] = array_values($data[$entry['date']]);
42727e8ea635SAtari911                            break;
42731d05cddcSAtari911                        }
42741d05cddcSAtari911                    }
42757e8ea635SAtari911                    if (empty($data[$entry['date']])) unset($data[$entry['date']]);
42767e8ea635SAtari911                    if (empty($data)) {
42777e8ea635SAtari911                        unlink($entry['file']);
42787e8ea635SAtari911                    } else {
42797e8ea635SAtari911                        file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT));
42801d05cddcSAtari911                    }
42811d05cddcSAtari911                }
42821d05cddcSAtari911
4283*96df7d3eSAtari911                // Recreate with new pattern
42847e8ea635SAtari911                $targetDir = ($newNamespace === '')
42857e8ea635SAtari911                    ? DOKU_INC . 'data/meta/calendar'
42867e8ea635SAtari911                    : DOKU_INC . 'data/meta/' . str_replace(':', '/', $newNamespace) . '/calendar';
42877e8ea635SAtari911                if (!is_dir($targetDir)) mkdir($targetDir, 0755, true);
42881d05cddcSAtari911
42897e8ea635SAtari911                $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($newTitle . $newNamespace);
42901d05cddcSAtari911
4291*96df7d3eSAtari911                // Calculate how many future events we need (use same count as before)
4292*96df7d3eSAtari911                $futureCount = count($allEvents) - $anchorIndex - 1;
4293*96df7d3eSAtari911                if ($futureCount < 1) $futureCount = 12; // Default to 12 future occurrences
4294*96df7d3eSAtari911
4295*96df7d3eSAtari911                // Generate new dates based on recurrence pattern
4296*96df7d3eSAtari911                $newDates = $this->generateRecurrenceDates(
4297*96df7d3eSAtari911                    $anchorDate->format('Y-m-d'),
4298*96df7d3eSAtari911                    $recurrenceType,
4299*96df7d3eSAtari911                    $recurrenceInterval,
4300*96df7d3eSAtari911                    $weekDays,
4301*96df7d3eSAtari911                    $monthlyType,
4302*96df7d3eSAtari911                    $monthDay,
4303*96df7d3eSAtari911                    $ordinalWeek,
4304*96df7d3eSAtari911                    $ordinalDay,
4305*96df7d3eSAtari911                    $futureCount
4306*96df7d3eSAtari911                );
4307*96df7d3eSAtari911
4308*96df7d3eSAtari911                // Create events for new dates (skip first since it's the anchor)
4309*96df7d3eSAtari911                for ($i = 1; $i < count($newDates); $i++) {
4310*96df7d3eSAtari911                    $dateKey = $newDates[$i];
43117e8ea635SAtari911                    list($year, $month) = explode('-', $dateKey);
43121d05cddcSAtari911
43137e8ea635SAtari911                    $file = $targetDir . '/' . sprintf('%04d-%02d.json', $year, $month);
43147e8ea635SAtari911                    $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
43157e8ea635SAtari911                    if (!is_array($fileData)) $fileData = [];
43167e8ea635SAtari911                    if (!isset($fileData[$dateKey])) $fileData[$dateKey] = [];
43171d05cddcSAtari911
43187e8ea635SAtari911                    $newEvent = $template;
43197e8ea635SAtari911                    $newEvent['id'] = $baseId . '-respace-' . $i;
4320*96df7d3eSAtari911                    $newEvent['recurrenceType'] = $recurrenceType;
4321*96df7d3eSAtari911                    $newEvent['recurrenceInterval'] = $recurrenceInterval;
4322*96df7d3eSAtari911                    if (!empty($weekDays)) $newEvent['weekDays'] = $weekDays;
4323*96df7d3eSAtari911                    if (!empty($monthlyType)) $newEvent['monthlyType'] = $monthlyType;
4324*96df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth' && $monthDay > 0) $newEvent['monthDay'] = $monthDay;
4325*96df7d3eSAtari911                    if ($monthlyType === 'ordinalWeekday') {
4326*96df7d3eSAtari911                        $newEvent['ordinalWeek'] = $ordinalWeek;
4327*96df7d3eSAtari911                        $newEvent['ordinalDay'] = $ordinalDay;
4328*96df7d3eSAtari911                    }
4329*96df7d3eSAtari911
43307e8ea635SAtari911                    $fileData[$dateKey][] = $newEvent;
43317e8ea635SAtari911                    file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT));
43321d05cddcSAtari911                }
43331d05cddcSAtari911            }
43341d05cddcSAtari911        }
43351d05cddcSAtari911
43361d05cddcSAtari911        $changes = [];
43371d05cddcSAtari911        if ($oldTitle !== $newTitle) $changes[] = "title";
43381d05cddcSAtari911        if (!empty($startTime) || !empty($endTime)) $changes[] = "time";
4339*96df7d3eSAtari911        if (!empty($recurrenceType)) $changes[] = "pattern";
43401d05cddcSAtari911        if ($newNamespace !== $oldNamespace) $changes[] = "namespace";
43411d05cddcSAtari911
43421d05cddcSAtari911        $changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : "";
43439ccd446eSAtari911        $this->clearStatsCache();
43441d05cddcSAtari911        $this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage');
43451d05cddcSAtari911    }
43461d05cddcSAtari911
43477e8ea635SAtari911    /**
4348*96df7d3eSAtari911     * Generate dates for a recurrence pattern
4349*96df7d3eSAtari911     */
4350*96df7d3eSAtari911    private function generateRecurrenceDates($startDate, $type, $interval, $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $count) {
4351*96df7d3eSAtari911        $dates = [$startDate];
4352*96df7d3eSAtari911        $currentDate = new DateTime($startDate);
4353*96df7d3eSAtari911        $maxIterations = $count * 100; // Safety limit
4354*96df7d3eSAtari911        $iterations = 0;
4355*96df7d3eSAtari911
4356*96df7d3eSAtari911        while (count($dates) < $count + 1 && $iterations < $maxIterations) {
4357*96df7d3eSAtari911            $iterations++;
4358*96df7d3eSAtari911            $currentDate->modify('+1 day');
4359*96df7d3eSAtari911            $shouldInclude = false;
4360*96df7d3eSAtari911
4361*96df7d3eSAtari911            switch ($type) {
4362*96df7d3eSAtari911                case 'daily':
4363*96df7d3eSAtari911                    $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days;
4364*96df7d3eSAtari911                    $shouldInclude = ($daysSinceStart % $interval === 0);
4365*96df7d3eSAtari911                    break;
4366*96df7d3eSAtari911
4367*96df7d3eSAtari911                case 'weekly':
4368*96df7d3eSAtari911                    $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days;
4369*96df7d3eSAtari911                    $weeksSinceStart = floor($daysSinceStart / 7);
4370*96df7d3eSAtari911                    $isCorrectWeek = ($weeksSinceStart % $interval === 0);
4371*96df7d3eSAtari911                    $currentDayOfWeek = (int)$currentDate->format('w');
4372*96df7d3eSAtari911                    $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays);
4373*96df7d3eSAtari911                    $shouldInclude = $isCorrectWeek && $isDaySelected;
4374*96df7d3eSAtari911                    break;
4375*96df7d3eSAtari911
4376*96df7d3eSAtari911                case 'monthly':
4377*96df7d3eSAtari911                    $startDT = new DateTime($startDate);
4378*96df7d3eSAtari911                    $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) +
4379*96df7d3eSAtari911                                        ($currentDate->format('n') - $startDT->format('n'));
4380*96df7d3eSAtari911                    $isCorrectMonth = ($monthsSinceStart > 0 && $monthsSinceStart % $interval === 0);
4381*96df7d3eSAtari911
4382*96df7d3eSAtari911                    if (!$isCorrectMonth) break;
4383*96df7d3eSAtari911
4384*96df7d3eSAtari911                    if ($monthlyType === 'dayOfMonth' || empty($monthlyType)) {
4385*96df7d3eSAtari911                        $targetDay = $monthDay ?: (int)$startDT->format('j');
4386*96df7d3eSAtari911                        $currentDay = (int)$currentDate->format('j');
4387*96df7d3eSAtari911                        $daysInMonth = (int)$currentDate->format('t');
4388*96df7d3eSAtari911                        $effectiveTargetDay = min($targetDay, $daysInMonth);
4389*96df7d3eSAtari911                        $shouldInclude = ($currentDay === $effectiveTargetDay);
4390*96df7d3eSAtari911                    } else {
4391*96df7d3eSAtari911                        $shouldInclude = $this->isOrdinalWeekdayAdmin($currentDate, $ordinalWeek, $ordinalDay);
4392*96df7d3eSAtari911                    }
4393*96df7d3eSAtari911                    break;
4394*96df7d3eSAtari911
4395*96df7d3eSAtari911                case 'yearly':
4396*96df7d3eSAtari911                    $startDT = new DateTime($startDate);
4397*96df7d3eSAtari911                    $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y');
4398*96df7d3eSAtari911                    $isCorrectYear = ($yearsSinceStart > 0 && $yearsSinceStart % $interval === 0);
4399*96df7d3eSAtari911                    $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d'));
4400*96df7d3eSAtari911                    $shouldInclude = $isCorrectYear && $sameMonthDay;
4401*96df7d3eSAtari911                    break;
4402*96df7d3eSAtari911            }
4403*96df7d3eSAtari911
4404*96df7d3eSAtari911            if ($shouldInclude) {
4405*96df7d3eSAtari911                $dates[] = $currentDate->format('Y-m-d');
4406*96df7d3eSAtari911            }
4407*96df7d3eSAtari911        }
4408*96df7d3eSAtari911
4409*96df7d3eSAtari911        return $dates;
4410*96df7d3eSAtari911    }
4411*96df7d3eSAtari911
4412*96df7d3eSAtari911    /**
4413*96df7d3eSAtari911     * Check if a date is the Nth occurrence of a weekday in its month (admin version)
4414*96df7d3eSAtari911     */
4415*96df7d3eSAtari911    private function isOrdinalWeekdayAdmin($date, $ordinalWeek, $targetDayOfWeek) {
4416*96df7d3eSAtari911        $currentDayOfWeek = (int)$date->format('w');
4417*96df7d3eSAtari911        if ($currentDayOfWeek !== $targetDayOfWeek) return false;
4418*96df7d3eSAtari911
4419*96df7d3eSAtari911        $dayOfMonth = (int)$date->format('j');
4420*96df7d3eSAtari911        $daysInMonth = (int)$date->format('t');
4421*96df7d3eSAtari911
4422*96df7d3eSAtari911        if ($ordinalWeek === -1) {
4423*96df7d3eSAtari911            $daysRemaining = $daysInMonth - $dayOfMonth;
4424*96df7d3eSAtari911            return $daysRemaining < 7;
4425*96df7d3eSAtari911        } else {
4426*96df7d3eSAtari911            $weekNumber = ceil($dayOfMonth / 7);
4427*96df7d3eSAtari911            return $weekNumber === $ordinalWeek;
4428*96df7d3eSAtari911        }
4429*96df7d3eSAtari911    }
4430*96df7d3eSAtari911
4431*96df7d3eSAtari911    /**
44327e8ea635SAtari911     * Find all calendar directories recursively
44337e8ea635SAtari911     */
44347e8ea635SAtari911    private function findCalendarDirs($baseDir, &$dirs) {
44357e8ea635SAtari911        foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
44367e8ea635SAtari911            $name = basename($nsDir);
44377e8ea635SAtari911            if ($name === 'calendar') continue; // Skip root calendar (added separately)
44387e8ea635SAtari911
44397e8ea635SAtari911            $calDir = $nsDir . '/calendar';
44407e8ea635SAtari911            if (is_dir($calDir)) {
44417e8ea635SAtari911                $dirs[] = $calDir;
44427e8ea635SAtari911            }
44437e8ea635SAtari911
44447e8ea635SAtari911            // Recurse
44457e8ea635SAtari911            $this->findCalendarDirs($nsDir . '/', $dirs);
44467e8ea635SAtari911        }
44477e8ea635SAtari911    }
44487e8ea635SAtari911
44491d05cddcSAtari911    private function moveEvents() {
44501d05cddcSAtari911        global $INPUT;
44511d05cddcSAtari911
44521d05cddcSAtari911        $events = $INPUT->arr('events');
44531d05cddcSAtari911        $targetNamespace = $INPUT->str('target_namespace');
44541d05cddcSAtari911
44551d05cddcSAtari911        if (empty($events)) {
44561d05cddcSAtari911            $this->redirect('No events selected', 'error', 'manage');
44571d05cddcSAtari911        }
44581d05cddcSAtari911
44591d05cddcSAtari911        $moved = 0;
44601d05cddcSAtari911
44611d05cddcSAtari911        foreach ($events as $eventData) {
44621d05cddcSAtari911            list($id, $namespace, $date, $month) = explode('|', $eventData);
44631d05cddcSAtari911
44641d05cddcSAtari911            // Determine old file path
44651d05cddcSAtari911            if ($namespace === '') {
44661d05cddcSAtari911                $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
44671d05cddcSAtari911            } else {
44681d05cddcSAtari911                $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
44691d05cddcSAtari911            }
44701d05cddcSAtari911
44711d05cddcSAtari911            if (!file_exists($oldFile)) continue;
44721d05cddcSAtari911
44731d05cddcSAtari911            $oldData = json_decode(file_get_contents($oldFile), true);
44741d05cddcSAtari911            if (!$oldData) continue;
44751d05cddcSAtari911
44761d05cddcSAtari911            // Find and remove event from old file
44771d05cddcSAtari911            $event = null;
44789ccd446eSAtari911            if (isset($oldData[$date])) {
44791d05cddcSAtari911                foreach ($oldData[$date] as $key => $evt) {
44801d05cddcSAtari911                    if ($evt['id'] === $id) {
44811d05cddcSAtari911                        $event = $evt;
44821d05cddcSAtari911                        unset($oldData[$date][$key]);
44831d05cddcSAtari911                        $oldData[$date] = array_values($oldData[$date]);
44841d05cddcSAtari911                        break;
44851d05cddcSAtari911                    }
44861d05cddcSAtari911                }
44871d05cddcSAtari911
44889ccd446eSAtari911                // Remove empty date arrays
44899ccd446eSAtari911                if (empty($oldData[$date])) {
44909ccd446eSAtari911                    unset($oldData[$date]);
44919ccd446eSAtari911                }
44929ccd446eSAtari911            }
44939ccd446eSAtari911
44941d05cddcSAtari911            if (!$event) continue;
44951d05cddcSAtari911
44961d05cddcSAtari911            // Save old file
44971d05cddcSAtari911            file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT));
44981d05cddcSAtari911
44991d05cddcSAtari911            // Update event namespace
45001d05cddcSAtari911            $event['namespace'] = $targetNamespace;
45011d05cddcSAtari911
45021d05cddcSAtari911            // Determine new file path
45031d05cddcSAtari911            if ($targetNamespace === '') {
45041d05cddcSAtari911                $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
45051d05cddcSAtari911                $newDir = dirname($newFile);
45061d05cddcSAtari911            } else {
45071d05cddcSAtari911                $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json';
45081d05cddcSAtari911                $newDir = dirname($newFile);
45091d05cddcSAtari911            }
45101d05cddcSAtari911
45111d05cddcSAtari911            if (!is_dir($newDir)) {
45121d05cddcSAtari911                mkdir($newDir, 0755, true);
45131d05cddcSAtari911            }
45141d05cddcSAtari911
45151d05cddcSAtari911            $newData = [];
45161d05cddcSAtari911            if (file_exists($newFile)) {
45171d05cddcSAtari911                $newData = json_decode(file_get_contents($newFile), true) ?: [];
45181d05cddcSAtari911            }
45191d05cddcSAtari911
45201d05cddcSAtari911            if (!isset($newData[$date])) {
45211d05cddcSAtari911                $newData[$date] = [];
45221d05cddcSAtari911            }
45231d05cddcSAtari911            $newData[$date][] = $event;
45241d05cddcSAtari911
45251d05cddcSAtari911            file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT));
45261d05cddcSAtari911            $moved++;
45271d05cddcSAtari911        }
45281d05cddcSAtari911
45291d05cddcSAtari911        $displayTarget = $targetNamespace ?: '(default)';
45309ccd446eSAtari911        $this->clearStatsCache();
45311d05cddcSAtari911        $this->redirect("Moved $moved event(s) to namespace: " . $displayTarget, 'success', 'manage');
45321d05cddcSAtari911    }
45331d05cddcSAtari911
45341d05cddcSAtari911    private function moveSingleEvent() {
45351d05cddcSAtari911        global $INPUT;
45361d05cddcSAtari911
45371d05cddcSAtari911        $eventData = $INPUT->str('event');
45381d05cddcSAtari911        $targetNamespace = $INPUT->str('target_namespace');
45391d05cddcSAtari911
45401d05cddcSAtari911        list($id, $namespace, $date, $month) = explode('|', $eventData);
45411d05cddcSAtari911
45421d05cddcSAtari911        // Determine old file path
45431d05cddcSAtari911        if ($namespace === '') {
45441d05cddcSAtari911            $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
45451d05cddcSAtari911        } else {
45461d05cddcSAtari911            $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
45471d05cddcSAtari911        }
45481d05cddcSAtari911
45491d05cddcSAtari911        if (!file_exists($oldFile)) {
45501d05cddcSAtari911            $this->redirect('Event file not found', 'error', 'manage');
45511d05cddcSAtari911        }
45521d05cddcSAtari911
45531d05cddcSAtari911        $oldData = json_decode(file_get_contents($oldFile), true);
45541d05cddcSAtari911        if (!$oldData) {
45551d05cddcSAtari911            $this->redirect('Could not read event file', 'error', 'manage');
45561d05cddcSAtari911        }
45571d05cddcSAtari911
45581d05cddcSAtari911        // Find and remove event from old file
45591d05cddcSAtari911        $event = null;
45609ccd446eSAtari911        if (isset($oldData[$date])) {
45611d05cddcSAtari911            foreach ($oldData[$date] as $key => $evt) {
45621d05cddcSAtari911                if ($evt['id'] === $id) {
45631d05cddcSAtari911                    $event = $evt;
45641d05cddcSAtari911                    unset($oldData[$date][$key]);
45651d05cddcSAtari911                    $oldData[$date] = array_values($oldData[$date]);
45661d05cddcSAtari911                    break;
45671d05cddcSAtari911                }
45681d05cddcSAtari911            }
45691d05cddcSAtari911
45709ccd446eSAtari911            // Remove empty date arrays
45719ccd446eSAtari911            if (empty($oldData[$date])) {
45729ccd446eSAtari911                unset($oldData[$date]);
45739ccd446eSAtari911            }
45749ccd446eSAtari911        }
45759ccd446eSAtari911
45761d05cddcSAtari911        if (!$event) {
45771d05cddcSAtari911            $this->redirect('Event not found', 'error', 'manage');
45781d05cddcSAtari911        }
45791d05cddcSAtari911
45809ccd446eSAtari911        // Save old file (or delete if empty)
45819ccd446eSAtari911        if (empty($oldData)) {
45829ccd446eSAtari911            unlink($oldFile);
45839ccd446eSAtari911        } else {
45841d05cddcSAtari911            file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT));
45859ccd446eSAtari911        }
45861d05cddcSAtari911
45871d05cddcSAtari911        // Update event namespace
45881d05cddcSAtari911        $event['namespace'] = $targetNamespace;
45891d05cddcSAtari911
45901d05cddcSAtari911        // Determine new file path
45911d05cddcSAtari911        if ($targetNamespace === '') {
45921d05cddcSAtari911            $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
45931d05cddcSAtari911            $newDir = dirname($newFile);
45941d05cddcSAtari911        } else {
45951d05cddcSAtari911            $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json';
45961d05cddcSAtari911            $newDir = dirname($newFile);
45971d05cddcSAtari911        }
45981d05cddcSAtari911
45991d05cddcSAtari911        if (!is_dir($newDir)) {
46001d05cddcSAtari911            mkdir($newDir, 0755, true);
46011d05cddcSAtari911        }
46021d05cddcSAtari911
46031d05cddcSAtari911        $newData = [];
46041d05cddcSAtari911        if (file_exists($newFile)) {
46051d05cddcSAtari911            $newData = json_decode(file_get_contents($newFile), true) ?: [];
46061d05cddcSAtari911        }
46071d05cddcSAtari911
46081d05cddcSAtari911        if (!isset($newData[$date])) {
46091d05cddcSAtari911            $newData[$date] = [];
46101d05cddcSAtari911        }
46111d05cddcSAtari911        $newData[$date][] = $event;
46121d05cddcSAtari911
46131d05cddcSAtari911        file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT));
46141d05cddcSAtari911
46151d05cddcSAtari911        $displayTarget = $targetNamespace ?: '(default)';
46169ccd446eSAtari911        $this->clearStatsCache();
46171d05cddcSAtari911        $this->redirect('Moved "' . $event['title'] . '" to ' . $displayTarget, 'success', 'manage');
46181d05cddcSAtari911    }
46191d05cddcSAtari911
46201d05cddcSAtari911    private function createNamespace() {
46211d05cddcSAtari911        global $INPUT;
46221d05cddcSAtari911
46231d05cddcSAtari911        $namespaceName = $INPUT->str('namespace_name');
46241d05cddcSAtari911
46251d05cddcSAtari911        // Validate namespace name
46261d05cddcSAtari911        if (empty($namespaceName)) {
46271d05cddcSAtari911            $this->redirect('Namespace name cannot be empty', 'error', 'manage');
46281d05cddcSAtari911        }
46291d05cddcSAtari911
46301d05cddcSAtari911        if (!preg_match('/^[a-zA-Z0-9_:-]+$/', $namespaceName)) {
46311d05cddcSAtari911            $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
46321d05cddcSAtari911        }
46331d05cddcSAtari911
46341d05cddcSAtari911        // Convert namespace to directory path
46351d05cddcSAtari911        $namespacePath = str_replace(':', '/', $namespaceName);
46361d05cddcSAtari911        $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar';
46371d05cddcSAtari911
46381d05cddcSAtari911        // Check if already exists
46391d05cddcSAtari911        if (is_dir($calendarDir)) {
46401d05cddcSAtari911            // Check if it has any JSON files
46411d05cddcSAtari911            $hasFiles = !empty(glob($calendarDir . '/*.json'));
46421d05cddcSAtari911            if ($hasFiles) {
46431d05cddcSAtari911                $this->redirect("Namespace '$namespaceName' already exists with events", 'info', 'manage');
46441d05cddcSAtari911            }
46451d05cddcSAtari911            // If directory exists but empty, continue to create placeholder
46461d05cddcSAtari911        }
46471d05cddcSAtari911
46481d05cddcSAtari911        // Create the directory
46491d05cddcSAtari911        if (!is_dir($calendarDir)) {
46501d05cddcSAtari911            if (!mkdir($calendarDir, 0755, true)) {
46511d05cddcSAtari911                $this->redirect("Failed to create namespace directory", 'error', 'manage');
46521d05cddcSAtari911            }
46531d05cddcSAtari911        }
46541d05cddcSAtari911
46551d05cddcSAtari911        // Create a placeholder JSON file with an empty structure for current month
46561d05cddcSAtari911        // This ensures the namespace appears in the list immediately
46571d05cddcSAtari911        $currentMonth = date('Y-m');
46581d05cddcSAtari911        $placeholderFile = $calendarDir . '/' . $currentMonth . '.json';
46591d05cddcSAtari911
46601d05cddcSAtari911        if (!file_exists($placeholderFile)) {
46611d05cddcSAtari911            file_put_contents($placeholderFile, json_encode([], JSON_PRETTY_PRINT));
46621d05cddcSAtari911        }
46631d05cddcSAtari911
46641d05cddcSAtari911        $this->redirect("Created namespace: $namespaceName", 'success', 'manage');
46651d05cddcSAtari911    }
46661d05cddcSAtari911
46671d05cddcSAtari911    private function deleteNamespace() {
46681d05cddcSAtari911        global $INPUT;
46691d05cddcSAtari911
46701d05cddcSAtari911        $namespace = $INPUT->str('namespace');
46711d05cddcSAtari911
46727e8ea635SAtari911        // Validate namespace name to prevent path traversal
46737e8ea635SAtari911        if ($namespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $namespace)) {
46747e8ea635SAtari911            $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
46757e8ea635SAtari911            return;
46767e8ea635SAtari911        }
46777e8ea635SAtari911
46787e8ea635SAtari911        // Additional safety: ensure no path traversal sequences
46797e8ea635SAtari911        if (strpos($namespace, '..') !== false || strpos($namespace, '/') !== false || strpos($namespace, '\\') !== false) {
46807e8ea635SAtari911            $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage');
46817e8ea635SAtari911            return;
46827e8ea635SAtari911        }
46837e8ea635SAtari911
46841d05cddcSAtari911        // Convert namespace to directory path (e.g., "work:projects" → "work/projects")
46851d05cddcSAtari911        $namespacePath = str_replace(':', '/', $namespace);
46861d05cddcSAtari911
46871d05cddcSAtari911        // Determine calendar directory
46881d05cddcSAtari911        if ($namespace === '') {
46891d05cddcSAtari911            $calendarDir = DOKU_INC . 'data/meta/calendar';
46901d05cddcSAtari911            $namespaceDir = null; // Don't delete root
46911d05cddcSAtari911        } else {
46921d05cddcSAtari911            $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar';
46931d05cddcSAtari911            $namespaceDir = DOKU_INC . 'data/meta/' . $namespacePath;
46941d05cddcSAtari911        }
46951d05cddcSAtari911
46961d05cddcSAtari911        // Check if directory exists
46971d05cddcSAtari911        if (!is_dir($calendarDir)) {
46981d05cddcSAtari911            // Maybe it was never created or already deleted
46991d05cddcSAtari911            $this->redirect("Namespace directory not found: $calendarDir", 'error', 'manage');
47001d05cddcSAtari911            return;
47011d05cddcSAtari911        }
47021d05cddcSAtari911
47031d05cddcSAtari911        $filesDeleted = 0;
47041d05cddcSAtari911        $eventsDeleted = 0;
47051d05cddcSAtari911
47061d05cddcSAtari911        // Delete all calendar JSON files (including empty ones)
47071d05cddcSAtari911        foreach (glob($calendarDir . '/*.json') as $file) {
47081d05cddcSAtari911            $data = json_decode(file_get_contents($file), true);
47091d05cddcSAtari911            if ($data) {
47101d05cddcSAtari911                foreach ($data as $events) {
4711*96df7d3eSAtari911                    if (is_array($events)) {
47121d05cddcSAtari911                        $eventsDeleted += count($events);
47131d05cddcSAtari911                    }
47141d05cddcSAtari911                }
4715*96df7d3eSAtari911            }
47161d05cddcSAtari911            unlink($file);
47171d05cddcSAtari911            $filesDeleted++;
47181d05cddcSAtari911        }
47191d05cddcSAtari911
47201d05cddcSAtari911        // Delete any other files in calendar directory
47211d05cddcSAtari911        foreach (glob($calendarDir . '/*') as $file) {
47221d05cddcSAtari911            if (is_file($file)) {
47231d05cddcSAtari911                unlink($file);
47241d05cddcSAtari911            }
47251d05cddcSAtari911        }
47261d05cddcSAtari911
47271d05cddcSAtari911        // Remove the calendar directory
47281d05cddcSAtari911        if ($namespace !== '') {
47291d05cddcSAtari911            @rmdir($calendarDir);
47301d05cddcSAtari911
47311d05cddcSAtari911            // Try to remove parent directories if they're empty
47321d05cddcSAtari911            // This handles nested namespaces like work:projects:alpha
47331d05cddcSAtari911            $currentDir = dirname($calendarDir);
47341d05cddcSAtari911            $metaDir = DOKU_INC . 'data/meta';
47351d05cddcSAtari911
47361d05cddcSAtari911            while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) {
47371d05cddcSAtari911                if (is_dir($currentDir)) {
47381d05cddcSAtari911                    // Check if directory is empty
47391d05cddcSAtari911                    $contents = scandir($currentDir);
47401d05cddcSAtari911                    $isEmpty = count($contents) === 2; // Only . and ..
47411d05cddcSAtari911
47421d05cddcSAtari911                    if ($isEmpty) {
47431d05cddcSAtari911                        @rmdir($currentDir);
47441d05cddcSAtari911                        $currentDir = dirname($currentDir);
47451d05cddcSAtari911                    } else {
47461d05cddcSAtari911                        break; // Directory not empty, stop
47471d05cddcSAtari911                    }
47481d05cddcSAtari911                } else {
47491d05cddcSAtari911                    break;
47501d05cddcSAtari911                }
47511d05cddcSAtari911            }
47521d05cddcSAtari911        }
47531d05cddcSAtari911
47541d05cddcSAtari911        $displayName = $namespace ?: '(default)';
47559ccd446eSAtari911        $this->clearStatsCache();
47561d05cddcSAtari911        $this->redirect("Deleted namespace '$displayName': $eventsDeleted events in $filesDeleted files", 'success', 'manage');
47571d05cddcSAtari911    }
47581d05cddcSAtari911
47599ccd446eSAtari911    private function renameNamespace() {
47609ccd446eSAtari911        global $INPUT;
47619ccd446eSAtari911
47629ccd446eSAtari911        $oldNamespace = $INPUT->str('old_namespace');
47639ccd446eSAtari911        $newNamespace = $INPUT->str('new_namespace');
47649ccd446eSAtari911
47657e8ea635SAtari911        // Validate namespace names to prevent path traversal
47667e8ea635SAtari911        if ($oldNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $oldNamespace)) {
47677e8ea635SAtari911            $this->redirect('Invalid old namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
47687e8ea635SAtari911            return;
47697e8ea635SAtari911        }
47707e8ea635SAtari911
47717e8ea635SAtari911        if ($newNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $newNamespace)) {
47727e8ea635SAtari911            $this->redirect('Invalid new namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
47737e8ea635SAtari911            return;
47747e8ea635SAtari911        }
47757e8ea635SAtari911
47767e8ea635SAtari911        // Additional safety: ensure no path traversal sequences
47777e8ea635SAtari911        if (strpos($oldNamespace, '..') !== false || strpos($oldNamespace, '/') !== false || strpos($oldNamespace, '\\') !== false ||
47787e8ea635SAtari911            strpos($newNamespace, '..') !== false || strpos($newNamespace, '/') !== false || strpos($newNamespace, '\\') !== false) {
47797e8ea635SAtari911            $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage');
47807e8ea635SAtari911            return;
47817e8ea635SAtari911        }
47827e8ea635SAtari911
47839ccd446eSAtari911        // Validate new namespace name
47849ccd446eSAtari911        if ($newNamespace === '') {
47859ccd446eSAtari911            $this->redirect("Cannot rename to empty namespace", 'error', 'manage');
47869ccd446eSAtari911            return;
47879ccd446eSAtari911        }
47889ccd446eSAtari911
47899ccd446eSAtari911        // Convert namespaces to directory paths
47909ccd446eSAtari911        $oldPath = str_replace(':', '/', $oldNamespace);
47919ccd446eSAtari911        $newPath = str_replace(':', '/', $newNamespace);
47929ccd446eSAtari911
47939ccd446eSAtari911        // Determine source and destination directories
47949ccd446eSAtari911        if ($oldNamespace === '') {
47959ccd446eSAtari911            $sourceDir = DOKU_INC . 'data/meta/calendar';
47969ccd446eSAtari911        } else {
47979ccd446eSAtari911            $sourceDir = DOKU_INC . 'data/meta/' . $oldPath . '/calendar';
47989ccd446eSAtari911        }
47999ccd446eSAtari911
48009ccd446eSAtari911        if ($newNamespace === '') {
48019ccd446eSAtari911            $targetDir = DOKU_INC . 'data/meta/calendar';
48029ccd446eSAtari911        } else {
48039ccd446eSAtari911            $targetDir = DOKU_INC . 'data/meta/' . $newPath . '/calendar';
48049ccd446eSAtari911        }
48059ccd446eSAtari911
48069ccd446eSAtari911        // Check if source exists
48079ccd446eSAtari911        if (!is_dir($sourceDir)) {
48089ccd446eSAtari911            $this->redirect("Source namespace not found: $oldNamespace", 'error', 'manage');
48099ccd446eSAtari911            return;
48109ccd446eSAtari911        }
48119ccd446eSAtari911
48129ccd446eSAtari911        // Check if target already exists
48139ccd446eSAtari911        if (is_dir($targetDir)) {
48149ccd446eSAtari911            $this->redirect("Target namespace already exists: $newNamespace", 'error', 'manage');
48159ccd446eSAtari911            return;
48169ccd446eSAtari911        }
48179ccd446eSAtari911
48189ccd446eSAtari911        // Create target directory
48199ccd446eSAtari911        if (!file_exists(dirname($targetDir))) {
48209ccd446eSAtari911            mkdir(dirname($targetDir), 0755, true);
48219ccd446eSAtari911        }
48229ccd446eSAtari911
48239ccd446eSAtari911        // Rename directory
48249ccd446eSAtari911        if (!rename($sourceDir, $targetDir)) {
48259ccd446eSAtari911            $this->redirect("Failed to rename namespace", 'error', 'manage');
48269ccd446eSAtari911            return;
48279ccd446eSAtari911        }
48289ccd446eSAtari911
48299ccd446eSAtari911        // Update event namespace field in all JSON files
48309ccd446eSAtari911        $eventsUpdated = 0;
48319ccd446eSAtari911        foreach (glob($targetDir . '/*.json') as $file) {
48329ccd446eSAtari911            $data = json_decode(file_get_contents($file), true);
48339ccd446eSAtari911            if ($data) {
48349ccd446eSAtari911                foreach ($data as $date => &$events) {
48359ccd446eSAtari911                    foreach ($events as &$event) {
48369ccd446eSAtari911                        if (isset($event['namespace']) && $event['namespace'] === $oldNamespace) {
48379ccd446eSAtari911                            $event['namespace'] = $newNamespace;
48389ccd446eSAtari911                            $eventsUpdated++;
48399ccd446eSAtari911                        }
48409ccd446eSAtari911                    }
48419ccd446eSAtari911                }
48429ccd446eSAtari911                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
48439ccd446eSAtari911            }
48449ccd446eSAtari911        }
48459ccd446eSAtari911
48469ccd446eSAtari911        // Clean up old directory structure if empty
48479ccd446eSAtari911        if ($oldNamespace !== '') {
48489ccd446eSAtari911            $currentDir = dirname($sourceDir);
48499ccd446eSAtari911            $metaDir = DOKU_INC . 'data/meta';
48509ccd446eSAtari911
48519ccd446eSAtari911            while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) {
48529ccd446eSAtari911                if (is_dir($currentDir)) {
48539ccd446eSAtari911                    $contents = scandir($currentDir);
48549ccd446eSAtari911                    $isEmpty = count($contents) === 2; // Only . and ..
48559ccd446eSAtari911
48569ccd446eSAtari911                    if ($isEmpty) {
48579ccd446eSAtari911                        @rmdir($currentDir);
48589ccd446eSAtari911                        $currentDir = dirname($currentDir);
48599ccd446eSAtari911                    } else {
48609ccd446eSAtari911                        break;
48619ccd446eSAtari911                    }
48629ccd446eSAtari911                } else {
48639ccd446eSAtari911                    break;
48649ccd446eSAtari911                }
48659ccd446eSAtari911            }
48669ccd446eSAtari911        }
48679ccd446eSAtari911
48689ccd446eSAtari911        $this->clearStatsCache();
48699ccd446eSAtari911        $this->redirect("Renamed namespace from '$oldNamespace' to '$newNamespace' ($eventsUpdated events updated)", 'success', 'manage');
48709ccd446eSAtari911    }
48719ccd446eSAtari911
48721d05cddcSAtari911    private function deleteSelectedEvents() {
48731d05cddcSAtari911        global $INPUT;
48741d05cddcSAtari911
48751d05cddcSAtari911        $events = $INPUT->arr('events');
48761d05cddcSAtari911
48771d05cddcSAtari911        if (empty($events)) {
48781d05cddcSAtari911            $this->redirect('No events selected', 'error', 'manage');
48791d05cddcSAtari911        }
48801d05cddcSAtari911
48811d05cddcSAtari911        $deletedCount = 0;
48821d05cddcSAtari911
48831d05cddcSAtari911        foreach ($events as $eventData) {
48841d05cddcSAtari911            list($id, $namespace, $date, $month) = explode('|', $eventData);
48851d05cddcSAtari911
48861d05cddcSAtari911            // Determine file path
48871d05cddcSAtari911            if ($namespace === '') {
48881d05cddcSAtari911                $file = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
48891d05cddcSAtari911            } else {
48901d05cddcSAtari911                $file = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
48911d05cddcSAtari911            }
48921d05cddcSAtari911
48931d05cddcSAtari911            if (!file_exists($file)) continue;
48941d05cddcSAtari911
48951d05cddcSAtari911            $data = json_decode(file_get_contents($file), true);
48961d05cddcSAtari911            if (!$data) continue;
48971d05cddcSAtari911
48981d05cddcSAtari911            // Find and remove event
48991d05cddcSAtari911            if (isset($data[$date])) {
49001d05cddcSAtari911                foreach ($data[$date] as $key => $evt) {
49011d05cddcSAtari911                    if ($evt['id'] === $id) {
49021d05cddcSAtari911                        unset($data[$date][$key]);
49031d05cddcSAtari911                        $data[$date] = array_values($data[$date]);
49041d05cddcSAtari911                        $deletedCount++;
49051d05cddcSAtari911                        break;
49061d05cddcSAtari911                    }
49071d05cddcSAtari911                }
49081d05cddcSAtari911
49091d05cddcSAtari911                // Remove empty date arrays
49101d05cddcSAtari911                if (empty($data[$date])) {
49111d05cddcSAtari911                    unset($data[$date]);
49121d05cddcSAtari911                }
49131d05cddcSAtari911
49141d05cddcSAtari911                // Save file
49151d05cddcSAtari911                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
49161d05cddcSAtari911            }
49171d05cddcSAtari911        }
49181d05cddcSAtari911
49199ccd446eSAtari911        $this->clearStatsCache();
49201d05cddcSAtari911        $this->redirect("Deleted $deletedCount event(s)", 'success', 'manage');
49211d05cddcSAtari911    }
49221d05cddcSAtari911
49239ccd446eSAtari911    /**
49249ccd446eSAtari911     * Clear the event statistics cache so counts refresh after mutations
49259ccd446eSAtari911     */
49264590242dSAtari911    private function saveImportantNamespaces() {
49274590242dSAtari911        global $INPUT;
49284590242dSAtari911
49294590242dSAtari911        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
49304590242dSAtari911        $config = [];
49314590242dSAtari911        if (file_exists($configFile)) {
49324590242dSAtari911            $config = include $configFile;
49334590242dSAtari911        }
49344590242dSAtari911
49354590242dSAtari911        $config['important_namespaces'] = $INPUT->str('important_namespaces', 'important');
49364590242dSAtari911
49374590242dSAtari911        $content = "<?php\nreturn " . var_export($config, true) . ";\n";
49384590242dSAtari911        if (file_put_contents($configFile, $content)) {
49394590242dSAtari911            $this->redirect('Important namespaces saved', 'success', 'manage');
49404590242dSAtari911        } else {
49414590242dSAtari911            $this->redirect('Error: Could not save configuration', 'error', 'manage');
49424590242dSAtari911        }
49434590242dSAtari911    }
49444590242dSAtari911
49459ccd446eSAtari911    private function clearStatsCache() {
49469ccd446eSAtari911        $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
49479ccd446eSAtari911        if (file_exists($cacheFile)) {
49489ccd446eSAtari911            unlink($cacheFile);
49499ccd446eSAtari911        }
49509ccd446eSAtari911    }
49519ccd446eSAtari911
49521d05cddcSAtari911    private function getCronStatus() {
49531d05cddcSAtari911        // Try to read root's crontab first, then current user
49541d05cddcSAtari911        $output = [];
49551d05cddcSAtari911        exec('sudo crontab -l 2>/dev/null', $output);
49561d05cddcSAtari911
49571d05cddcSAtari911        // If sudo doesn't work, try current user
49581d05cddcSAtari911        if (empty($output)) {
49591d05cddcSAtari911            exec('crontab -l 2>/dev/null', $output);
49601d05cddcSAtari911        }
49611d05cddcSAtari911
49621d05cddcSAtari911        // Also check system crontab files
49631d05cddcSAtari911        if (empty($output)) {
49641d05cddcSAtari911            $cronFiles = [
49651d05cddcSAtari911                '/etc/crontab',
49661d05cddcSAtari911                '/etc/cron.d/calendar',
49671d05cddcSAtari911                '/var/spool/cron/root',
49681d05cddcSAtari911                '/var/spool/cron/crontabs/root'
49691d05cddcSAtari911            ];
49701d05cddcSAtari911
49711d05cddcSAtari911            foreach ($cronFiles as $file) {
49721d05cddcSAtari911                if (file_exists($file) && is_readable($file)) {
49731d05cddcSAtari911                    $content = file_get_contents($file);
49741d05cddcSAtari911                    $output = explode("\n", $content);
49751d05cddcSAtari911                    break;
49761d05cddcSAtari911                }
49771d05cddcSAtari911            }
49781d05cddcSAtari911        }
49791d05cddcSAtari911
49801d05cddcSAtari911        // Look for sync_outlook.php in the cron entries
49811d05cddcSAtari911        foreach ($output as $line) {
49821d05cddcSAtari911            $line = trim($line);
49831d05cddcSAtari911
49841d05cddcSAtari911            // Skip empty lines and comments
49851d05cddcSAtari911            if (empty($line) || $line[0] === '#') continue;
49861d05cddcSAtari911
49871d05cddcSAtari911            // Check if line contains sync_outlook.php
49881d05cddcSAtari911            if (strpos($line, 'sync_outlook.php') !== false) {
49891d05cddcSAtari911                // Parse cron expression
49901d05cddcSAtari911                // Format: minute hour day month weekday [user] command
49911d05cddcSAtari911                $parts = preg_split('/\s+/', $line, 7);
49921d05cddcSAtari911
49931d05cddcSAtari911                if (count($parts) >= 5) {
49941d05cddcSAtari911                    // Determine if this has a user field (system crontab format)
49951d05cddcSAtari911                    $hasUser = (count($parts) >= 6 && !preg_match('/^[\/\*]/', $parts[5]));
49961d05cddcSAtari911                    $offset = $hasUser ? 1 : 0;
49971d05cddcSAtari911
49981d05cddcSAtari911                    $frequency = $this->parseCronExpression($parts[0], $parts[1], $parts[2], $parts[3], $parts[4]);
49991d05cddcSAtari911                    return [
50001d05cddcSAtari911                        'active' => true,
50011d05cddcSAtari911                        'frequency' => $frequency,
50021d05cddcSAtari911                        'expression' => implode(' ', array_slice($parts, 0, 5)),
50031d05cddcSAtari911                        'full_line' => $line
50041d05cddcSAtari911                    ];
50051d05cddcSAtari911                }
50061d05cddcSAtari911            }
50071d05cddcSAtari911        }
50081d05cddcSAtari911
50091d05cddcSAtari911        return ['active' => false, 'frequency' => '', 'expression' => '', 'full_line' => ''];
50101d05cddcSAtari911    }
50111d05cddcSAtari911
50121d05cddcSAtari911    private function parseCronExpression($minute, $hour, $day, $month, $weekday) {
50131d05cddcSAtari911        // Parse minute field
50141d05cddcSAtari911        if ($minute === '*') {
50151d05cddcSAtari911            return 'Runs every minute';
50161d05cddcSAtari911        } elseif (strpos($minute, '*/') === 0) {
50171d05cddcSAtari911            $interval = substr($minute, 2);
50181d05cddcSAtari911            if ($interval == 1) {
50191d05cddcSAtari911                return 'Runs every minute';
50201d05cddcSAtari911            } elseif ($interval == 5) {
50211d05cddcSAtari911                return 'Runs every 5 minutes';
50221d05cddcSAtari911            } elseif ($interval == 8) {
50231d05cddcSAtari911                return 'Runs every 8 minutes';
50241d05cddcSAtari911            } elseif ($interval == 10) {
50251d05cddcSAtari911                return 'Runs every 10 minutes';
50261d05cddcSAtari911            } elseif ($interval == 15) {
50271d05cddcSAtari911                return 'Runs every 15 minutes';
50281d05cddcSAtari911            } elseif ($interval == 30) {
50291d05cddcSAtari911                return 'Runs every 30 minutes';
50301d05cddcSAtari911            } else {
50311d05cddcSAtari911                return "Runs every $interval minutes";
50321d05cddcSAtari911            }
50331d05cddcSAtari911        }
50341d05cddcSAtari911
50351d05cddcSAtari911        // Parse hour field
50361d05cddcSAtari911        if ($hour === '*' && $minute !== '*') {
50371d05cddcSAtari911            return 'Runs hourly';
50381d05cddcSAtari911        } elseif (strpos($hour, '*/') === 0 && $minute !== '*') {
50391d05cddcSAtari911            $interval = substr($hour, 2);
50401d05cddcSAtari911            if ($interval == 1) {
50411d05cddcSAtari911                return 'Runs every hour';
50421d05cddcSAtari911            } else {
50431d05cddcSAtari911                return "Runs every $interval hours";
50441d05cddcSAtari911            }
50451d05cddcSAtari911        }
50461d05cddcSAtari911
50471d05cddcSAtari911        // Parse day field
50481d05cddcSAtari911        if ($day === '*' && $hour !== '*' && $minute !== '*') {
50491d05cddcSAtari911            return 'Runs daily';
50501d05cddcSAtari911        }
50511d05cddcSAtari911
50521d05cddcSAtari911        // Default
50531d05cddcSAtari911        return 'Custom schedule';
50541d05cddcSAtari911    }
50551d05cddcSAtari911
50561d05cddcSAtari911    private function runSync() {
50571d05cddcSAtari911        global $INPUT;
50581d05cddcSAtari911
50591d05cddcSAtari911        if ($INPUT->str('call') === 'ajax') {
50601d05cddcSAtari911            header('Content-Type: application/json');
50611d05cddcSAtari911
50621d05cddcSAtari911            $syncScript = DOKU_PLUGIN . 'calendar/sync_outlook.php';
50631d05cddcSAtari911            $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort';
50641d05cddcSAtari911
50651d05cddcSAtari911            // Remove any existing abort flag
50661d05cddcSAtari911            if (file_exists($abortFile)) {
50671d05cddcSAtari911                @unlink($abortFile);
50681d05cddcSAtari911            }
50691d05cddcSAtari911
50701d05cddcSAtari911            if (!file_exists($syncScript)) {
50711d05cddcSAtari911                echo json_encode(['success' => false, 'message' => 'Sync script not found at: ' . $syncScript]);
50721d05cddcSAtari911                exit;
50731d05cddcSAtari911            }
50741d05cddcSAtari911
5075*96df7d3eSAtari911            // Get log file from data directory (writable)
5076*96df7d3eSAtari911            $logFile = $this->getSyncLogPath();
5077*96df7d3eSAtari911            $logDir = dirname($logFile);
5078*96df7d3eSAtari911
5079*96df7d3eSAtari911            // Ensure log directory exists
5080*96df7d3eSAtari911            if (!is_dir($logDir)) {
5081*96df7d3eSAtari911                if (!@mkdir($logDir, 0755, true)) {
5082*96df7d3eSAtari911                    echo json_encode(['success' => false, 'message' => 'Cannot create log directory: ' . $logDir]);
5083*96df7d3eSAtari911                    exit;
5084*96df7d3eSAtari911                }
5085*96df7d3eSAtari911            }
50861d05cddcSAtari911
50871d05cddcSAtari911            // Ensure log file exists and is writable
50881d05cddcSAtari911            if (!file_exists($logFile)) {
5089*96df7d3eSAtari911                if (!@touch($logFile)) {
5090*96df7d3eSAtari911                    echo json_encode(['success' => false, 'message' => 'Cannot create log file: ' . $logFile]);
5091*96df7d3eSAtari911                    exit;
5092*96df7d3eSAtari911                }
50931d05cddcSAtari911                @chmod($logFile, 0666);
50941d05cddcSAtari911            }
50951d05cddcSAtari911
5096*96df7d3eSAtari911            // Check if we can write to the log
5097*96df7d3eSAtari911            if (!is_writable($logFile)) {
5098*96df7d3eSAtari911                echo json_encode(['success' => false, 'message' => 'Log file not writable: ' . $logFile . ' - Run: chmod 666 ' . $logFile]);
5099*96df7d3eSAtari911                exit;
5100*96df7d3eSAtari911            }
5101*96df7d3eSAtari911
5102*96df7d3eSAtari911            // Find PHP binary
5103*96df7d3eSAtari911            $phpPath = $this->findPhpBinary();
5104*96df7d3eSAtari911            if (!$phpPath) {
5105*96df7d3eSAtari911                echo json_encode(['success' => false, 'message' => 'Cannot find PHP binary']);
5106*96df7d3eSAtari911                exit;
5107*96df7d3eSAtari911            }
5108*96df7d3eSAtari911
5109*96df7d3eSAtari911            // Get plugin directory for cd command
5110*96df7d3eSAtari911            $pluginDir = DOKU_PLUGIN . 'calendar';
5111*96df7d3eSAtari911
5112*96df7d3eSAtari911            // Build command - NO --verbose flag because the script logs internally
5113*96df7d3eSAtari911            // The script writes directly to the log file, so we don't need to capture stdout
5114*96df7d3eSAtari911            $command = sprintf(
5115*96df7d3eSAtari911                'cd %s && %s sync_outlook.php 2>&1',
5116*96df7d3eSAtari911                escapeshellarg($pluginDir),
5117*96df7d3eSAtari911                $phpPath
5118*96df7d3eSAtari911            );
5119*96df7d3eSAtari911
5120*96df7d3eSAtari911            // Log that we're starting
51211d05cddcSAtari911            $tz = new DateTimeZone('America/Los_Angeles');
51221d05cddcSAtari911            $now = new DateTime('now', $tz);
51231d05cddcSAtari911            $timestamp = $now->format('Y-m-d H:i:s');
51241d05cddcSAtari911            @file_put_contents($logFile, "[$timestamp] [ADMIN] Manual sync triggered via admin panel\n", FILE_APPEND);
5125*96df7d3eSAtari911            @file_put_contents($logFile, "[$timestamp] [ADMIN] Command: $command\n", FILE_APPEND);
51261d05cddcSAtari911
5127*96df7d3eSAtari911            // Execute sync
51281d05cddcSAtari911            $output = [];
51291d05cddcSAtari911            $returnCode = 0;
51301d05cddcSAtari911            exec($command, $output, $returnCode);
51311d05cddcSAtari911
5132*96df7d3eSAtari911            // Only log output if there was an error (the script logs its own progress)
5133*96df7d3eSAtari911            if ($returnCode !== 0 && !empty($output)) {
5134*96df7d3eSAtari911                @file_put_contents($logFile, "[$timestamp] [ADMIN] Error output:\n" . implode("\n", $output) . "\n", FILE_APPEND);
51351d05cddcSAtari911            }
51361d05cddcSAtari911
5137*96df7d3eSAtari911            // Check results
5138*96df7d3eSAtari911            if ($returnCode === 0) {
51391d05cddcSAtari911                echo json_encode([
51401d05cddcSAtari911                    'success' => true,
5141*96df7d3eSAtari911                    'message' => 'Sync completed! Check log for details.'
51421d05cddcSAtari911                ]);
51431d05cddcSAtari911            } else {
5144*96df7d3eSAtari911                $errorMsg = 'Sync failed (exit code: ' . $returnCode . ')';
51451d05cddcSAtari911                if (!empty($output)) {
5146*96df7d3eSAtari911                    $lastLines = array_slice($output, -3);
5147*96df7d3eSAtari911                    $errorMsg .= ' - ' . implode(' | ', $lastLines);
51481d05cddcSAtari911                }
51491d05cddcSAtari911                echo json_encode([
51501d05cddcSAtari911                    'success' => false,
51511d05cddcSAtari911                    'message' => $errorMsg
51521d05cddcSAtari911                ]);
51531d05cddcSAtari911            }
51541d05cddcSAtari911            exit;
51551d05cddcSAtari911        }
51561d05cddcSAtari911    }
51571d05cddcSAtari911
51581d05cddcSAtari911    private function stopSync() {
51591d05cddcSAtari911        global $INPUT;
51601d05cddcSAtari911
51611d05cddcSAtari911        if ($INPUT->str('call') === 'ajax') {
51621d05cddcSAtari911            header('Content-Type: application/json');
51631d05cddcSAtari911
51641d05cddcSAtari911            $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort';
51651d05cddcSAtari911
51661d05cddcSAtari911            // Create abort flag file
51671d05cddcSAtari911            if (file_put_contents($abortFile, date('Y-m-d H:i:s')) !== false) {
51681d05cddcSAtari911                echo json_encode([
51691d05cddcSAtari911                    'success' => true,
51701d05cddcSAtari911                    'message' => 'Stop signal sent to sync process'
51711d05cddcSAtari911                ]);
51721d05cddcSAtari911            } else {
51731d05cddcSAtari911                echo json_encode([
51741d05cddcSAtari911                    'success' => false,
51751d05cddcSAtari911                    'message' => 'Failed to create abort flag'
51761d05cddcSAtari911                ]);
51771d05cddcSAtari911            }
51781d05cddcSAtari911            exit;
51791d05cddcSAtari911        }
51801d05cddcSAtari911    }
51811d05cddcSAtari911
51821d05cddcSAtari911    private function uploadUpdate() {
51831d05cddcSAtari911        if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) {
51841d05cddcSAtari911            $this->redirect('Upload failed: ' . ($_FILES['plugin_zip']['error'] ?? 'No file uploaded'), 'error', 'update');
51851d05cddcSAtari911            return;
51861d05cddcSAtari911        }
51871d05cddcSAtari911
51881d05cddcSAtari911        $uploadedFile = $_FILES['plugin_zip']['tmp_name'];
51891d05cddcSAtari911        $pluginDir = DOKU_PLUGIN . 'calendar/';
51901d05cddcSAtari911        $backupFirst = isset($_POST['backup_first']);
51911d05cddcSAtari911
51921d05cddcSAtari911        // Check if plugin directory is writable
51931d05cddcSAtari911        if (!is_writable($pluginDir)) {
51941d05cddcSAtari911            $this->redirect('Plugin directory is not writable. Please check permissions: ' . $pluginDir, 'error', 'update');
51951d05cddcSAtari911            return;
51961d05cddcSAtari911        }
51971d05cddcSAtari911
51981d05cddcSAtari911        // Check if parent directory is writable (for backup and temp files)
51991d05cddcSAtari911        if (!is_writable(DOKU_PLUGIN)) {
52001d05cddcSAtari911            $this->redirect('Plugin parent directory is not writable. Please check permissions: ' . DOKU_PLUGIN, 'error', 'update');
52011d05cddcSAtari911            return;
52021d05cddcSAtari911        }
52031d05cddcSAtari911
52041d05cddcSAtari911        // Verify it's a ZIP file
52051d05cddcSAtari911        $finfo = finfo_open(FILEINFO_MIME_TYPE);
52061d05cddcSAtari911        $mimeType = finfo_file($finfo, $uploadedFile);
52071d05cddcSAtari911        finfo_close($finfo);
52081d05cddcSAtari911
52091d05cddcSAtari911        if ($mimeType !== 'application/zip' && $mimeType !== 'application/x-zip-compressed') {
52101d05cddcSAtari911            $this->redirect('Invalid file type. Please upload a ZIP file.', 'error', 'update');
52111d05cddcSAtari911            return;
52121d05cddcSAtari911        }
52131d05cddcSAtari911
52141d05cddcSAtari911        // Create backup if requested
52151d05cddcSAtari911        if ($backupFirst) {
52161d05cddcSAtari911            // Get current version
52171d05cddcSAtari911            $pluginInfo = $pluginDir . 'plugin.info.txt';
52181d05cddcSAtari911            $version = 'unknown';
52191d05cddcSAtari911            if (file_exists($pluginInfo)) {
52201d05cddcSAtari911                $info = confToHash($pluginInfo);
52211d05cddcSAtari911                $version = $info['version'] ?? ($info['date'] ?? 'unknown');
52221d05cddcSAtari911            }
52231d05cddcSAtari911
52241d05cddcSAtari911            $backupName = 'calendar.backup.v' . $version . '.' . date('Y-m-d_H-i-s') . '.zip';
52251d05cddcSAtari911            $backupPath = DOKU_PLUGIN . $backupName;
52261d05cddcSAtari911
52271d05cddcSAtari911            try {
52281d05cddcSAtari911                $zip = new ZipArchive();
52291d05cddcSAtari911                if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) {
52309ccd446eSAtari911                    $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/');
52311d05cddcSAtari911                    $zip->close();
52329ccd446eSAtari911
52339ccd446eSAtari911                    // Verify backup was created and has content
52349ccd446eSAtari911                    if (!file_exists($backupPath)) {
52359ccd446eSAtari911                        $this->redirect('Backup file was not created', 'error', 'update');
52369ccd446eSAtari911                        return;
52379ccd446eSAtari911                    }
52389ccd446eSAtari911
52399ccd446eSAtari911                    $backupSize = filesize($backupPath);
52409ccd446eSAtari911                    if ($backupSize < 1000) { // Backup should be at least 1KB
52419ccd446eSAtari911                        @unlink($backupPath);
52429ccd446eSAtari911                        $this->redirect('Backup file is too small (' . $backupSize . ' bytes). Only ' . $fileCount . ' files were added. Backup aborted.', 'error', 'update');
52439ccd446eSAtari911                        return;
52449ccd446eSAtari911                    }
52459ccd446eSAtari911
52469ccd446eSAtari911                    if ($fileCount < 10) { // Should have at least 10 files
52479ccd446eSAtari911                        @unlink($backupPath);
52489ccd446eSAtari911                        $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup aborted.', 'error', 'update');
52499ccd446eSAtari911                        return;
52509ccd446eSAtari911                    }
52511d05cddcSAtari911                } else {
52521d05cddcSAtari911                    $this->redirect('Failed to create backup ZIP file', 'error', 'update');
52531d05cddcSAtari911                    return;
52541d05cddcSAtari911                }
52551d05cddcSAtari911            } catch (Exception $e) {
52569ccd446eSAtari911                if (file_exists($backupPath)) {
52579ccd446eSAtari911                    @unlink($backupPath);
52589ccd446eSAtari911                }
52591d05cddcSAtari911                $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update');
52601d05cddcSAtari911                return;
52611d05cddcSAtari911            }
52621d05cddcSAtari911        }
52631d05cddcSAtari911
52641d05cddcSAtari911        // Extract uploaded ZIP
52651d05cddcSAtari911        $zip = new ZipArchive();
52661d05cddcSAtari911        if ($zip->open($uploadedFile) !== TRUE) {
52671d05cddcSAtari911            $this->redirect('Failed to open ZIP file', 'error', 'update');
52681d05cddcSAtari911            return;
52691d05cddcSAtari911        }
52701d05cddcSAtari911
52711d05cddcSAtari911        // Check if ZIP contains calendar folder
52721d05cddcSAtari911        $hasCalendarFolder = false;
52731d05cddcSAtari911        for ($i = 0; $i < $zip->numFiles; $i++) {
52741d05cddcSAtari911            $filename = $zip->getNameIndex($i);
52751d05cddcSAtari911            if (strpos($filename, 'calendar/') === 0) {
52761d05cddcSAtari911                $hasCalendarFolder = true;
52771d05cddcSAtari911                break;
52781d05cddcSAtari911            }
52791d05cddcSAtari911        }
52801d05cddcSAtari911
52811d05cddcSAtari911        // Extract to temp directory first
52821d05cddcSAtari911        $tempDir = DOKU_PLUGIN . 'calendar_update_temp/';
52831d05cddcSAtari911        if (is_dir($tempDir)) {
52841d05cddcSAtari911            $this->deleteDirectory($tempDir);
52851d05cddcSAtari911        }
52861d05cddcSAtari911        mkdir($tempDir);
52871d05cddcSAtari911
52881d05cddcSAtari911        $zip->extractTo($tempDir);
52891d05cddcSAtari911        $zip->close();
52901d05cddcSAtari911
52911d05cddcSAtari911        // Determine source directory
52921d05cddcSAtari911        if ($hasCalendarFolder) {
52931d05cddcSAtari911            $sourceDir = $tempDir . 'calendar/';
52941d05cddcSAtari911        } else {
52951d05cddcSAtari911            $sourceDir = $tempDir;
52961d05cddcSAtari911        }
52971d05cddcSAtari911
5298*96df7d3eSAtari911        // Preserve configuration files (sync_state.json and sync.log are now in data/meta/calendar/)
5299*96df7d3eSAtari911        $preserveFiles = ['sync_config.php'];
53001d05cddcSAtari911        $preserved = [];
53011d05cddcSAtari911        foreach ($preserveFiles as $file) {
53021d05cddcSAtari911            $oldFile = $pluginDir . $file;
53031d05cddcSAtari911            if (file_exists($oldFile)) {
53041d05cddcSAtari911                $preserved[$file] = file_get_contents($oldFile);
53051d05cddcSAtari911            }
53061d05cddcSAtari911        }
53071d05cddcSAtari911
53081d05cddcSAtari911        // Delete old plugin files (except data files)
53091d05cddcSAtari911        $this->deleteDirectoryContents($pluginDir, $preserveFiles);
53101d05cddcSAtari911
53111d05cddcSAtari911        // Copy new files
53121d05cddcSAtari911        $this->recursiveCopy($sourceDir, $pluginDir);
53131d05cddcSAtari911
53141d05cddcSAtari911        // Restore preserved files
53151d05cddcSAtari911        foreach ($preserved as $file => $content) {
53161d05cddcSAtari911            file_put_contents($pluginDir . $file, $content);
53171d05cddcSAtari911        }
53181d05cddcSAtari911
53191d05cddcSAtari911        // Update version and date in plugin.info.txt
53201d05cddcSAtari911        $pluginInfo = $pluginDir . 'plugin.info.txt';
53211d05cddcSAtari911        if (file_exists($pluginInfo)) {
53221d05cddcSAtari911            $info = confToHash($pluginInfo);
53231d05cddcSAtari911
53241d05cddcSAtari911            // Get new version from uploaded plugin
53251d05cddcSAtari911            $newVersion = $info['version'] ?? 'unknown';
53261d05cddcSAtari911
53271d05cddcSAtari911            // Update date to current
53281d05cddcSAtari911            $info['date'] = date('Y-m-d');
53291d05cddcSAtari911
53301d05cddcSAtari911            // Write updated info back
53311d05cddcSAtari911            $lines = [];
53321d05cddcSAtari911            foreach ($info as $key => $value) {
53331d05cddcSAtari911                $lines[] = str_pad($key, 8) . ' ' . $value;
53341d05cddcSAtari911            }
53351d05cddcSAtari911            file_put_contents($pluginInfo, implode("\n", $lines) . "\n");
53361d05cddcSAtari911        }
53371d05cddcSAtari911
53381d05cddcSAtari911        // Cleanup temp directory
53391d05cddcSAtari911        $this->deleteDirectory($tempDir);
53401d05cddcSAtari911
53411d05cddcSAtari911        $message = 'Plugin updated successfully!';
53421d05cddcSAtari911        if ($backupFirst) {
53431d05cddcSAtari911            $message .= ' Backup saved as: ' . $backupName;
53441d05cddcSAtari911        }
53451d05cddcSAtari911        $this->redirect($message, 'success', 'update');
53461d05cddcSAtari911    }
53471d05cddcSAtari911
53481d05cddcSAtari911    private function deleteBackup() {
53491d05cddcSAtari911        global $INPUT;
53501d05cddcSAtari911
53511d05cddcSAtari911        $filename = $INPUT->str('backup_file');
53521d05cddcSAtari911
53531d05cddcSAtari911        if (empty($filename)) {
53541d05cddcSAtari911            $this->redirect('No backup file specified', 'error', 'update');
53551d05cddcSAtari911            return;
53561d05cddcSAtari911        }
53571d05cddcSAtari911
53581d05cddcSAtari911        // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal
53591d05cddcSAtari911        if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) {
53601d05cddcSAtari911            $this->redirect('Invalid backup filename', 'error', 'update');
53611d05cddcSAtari911            return;
53621d05cddcSAtari911        }
53631d05cddcSAtari911
53641d05cddcSAtari911        $backupPath = DOKU_PLUGIN . $filename;
53651d05cddcSAtari911
53661d05cddcSAtari911        if (!file_exists($backupPath)) {
53671d05cddcSAtari911            $this->redirect('Backup file not found', 'error', 'update');
53681d05cddcSAtari911            return;
53691d05cddcSAtari911        }
53701d05cddcSAtari911
53711d05cddcSAtari911        if (@unlink($backupPath)) {
53721d05cddcSAtari911            $this->redirect('Backup deleted: ' . $filename, 'success', 'update');
53731d05cddcSAtari911        } else {
53741d05cddcSAtari911            $this->redirect('Failed to delete backup. Check file permissions.', 'error', 'update');
53751d05cddcSAtari911        }
53761d05cddcSAtari911    }
53771d05cddcSAtari911
53781d05cddcSAtari911    private function renameBackup() {
53791d05cddcSAtari911        global $INPUT;
53801d05cddcSAtari911
53811d05cddcSAtari911        $oldName = $INPUT->str('old_name');
53821d05cddcSAtari911        $newName = $INPUT->str('new_name');
53831d05cddcSAtari911
53841d05cddcSAtari911        if (empty($oldName) || empty($newName)) {
53851d05cddcSAtari911            $this->redirect('Missing filename(s)', 'error', 'update');
53861d05cddcSAtari911            return;
53871d05cddcSAtari911        }
53881d05cddcSAtari911
53891d05cddcSAtari911        // Security: validate filenames
53901d05cddcSAtari911        if (!preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $oldName) || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $newName)) {
53911d05cddcSAtari911            $this->redirect('Invalid filename format', 'error', 'update');
53921d05cddcSAtari911            return;
53931d05cddcSAtari911        }
53941d05cddcSAtari911
53951d05cddcSAtari911        $oldPath = DOKU_PLUGIN . $oldName;
53961d05cddcSAtari911        $newPath = DOKU_PLUGIN . $newName;
53971d05cddcSAtari911
53981d05cddcSAtari911        if (!file_exists($oldPath)) {
53991d05cddcSAtari911            $this->redirect('Backup file not found', 'error', 'update');
54001d05cddcSAtari911            return;
54011d05cddcSAtari911        }
54021d05cddcSAtari911
54031d05cddcSAtari911        if (file_exists($newPath)) {
54041d05cddcSAtari911            $this->redirect('A file with the new name already exists', 'error', 'update');
54051d05cddcSAtari911            return;
54061d05cddcSAtari911        }
54071d05cddcSAtari911
54081d05cddcSAtari911        if (@rename($oldPath, $newPath)) {
54091d05cddcSAtari911            $this->redirect('Backup renamed: ' . $oldName . ' → ' . $newName, 'success', 'update');
54101d05cddcSAtari911        } else {
54111d05cddcSAtari911            $this->redirect('Failed to rename backup. Check file permissions.', 'error', 'update');
54121d05cddcSAtari911        }
54131d05cddcSAtari911    }
54141d05cddcSAtari911
5415*96df7d3eSAtari911    /**
5416*96df7d3eSAtari911     * Restore a backup using DokuWiki's extension manager
5417*96df7d3eSAtari911     * This ensures proper permissions and follows DokuWiki's standard installation process
5418*96df7d3eSAtari911     */
54191d05cddcSAtari911    private function restoreBackup() {
54201d05cddcSAtari911        global $INPUT;
54211d05cddcSAtari911
54221d05cddcSAtari911        $filename = $INPUT->str('backup_file');
54231d05cddcSAtari911
54241d05cddcSAtari911        if (empty($filename)) {
54251d05cddcSAtari911            $this->redirect('No backup file specified', 'error', 'update');
54261d05cddcSAtari911            return;
54271d05cddcSAtari911        }
54281d05cddcSAtari911
54291d05cddcSAtari911        // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal
54301d05cddcSAtari911        if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) {
54311d05cddcSAtari911            $this->redirect('Invalid backup filename', 'error', 'update');
54321d05cddcSAtari911            return;
54331d05cddcSAtari911        }
54341d05cddcSAtari911
54351d05cddcSAtari911        $backupPath = DOKU_PLUGIN . $filename;
54361d05cddcSAtari911
54371d05cddcSAtari911        if (!file_exists($backupPath)) {
54381d05cddcSAtari911            $this->redirect('Backup file not found', 'error', 'update');
54391d05cddcSAtari911            return;
54401d05cddcSAtari911        }
54411d05cddcSAtari911
5442*96df7d3eSAtari911        // Try to use DokuWiki's extension manager helper
5443*96df7d3eSAtari911        $extensionHelper = plugin_load('helper', 'extension_extension');
5444*96df7d3eSAtari911
5445*96df7d3eSAtari911        if (!$extensionHelper) {
5446*96df7d3eSAtari911            // Extension manager not available - provide manual instructions
5447*96df7d3eSAtari911            $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');
54481d05cddcSAtari911            return;
54491d05cddcSAtari911        }
54501d05cddcSAtari911
5451*96df7d3eSAtari911        try {
5452*96df7d3eSAtari911            // Set the extension we're working with
5453*96df7d3eSAtari911            $extensionHelper->setExtension('calendar');
5454*96df7d3eSAtari911
5455*96df7d3eSAtari911            // Use DokuWiki's extension manager to install from the local file
5456*96df7d3eSAtari911            // This handles all permissions and file operations properly
5457*96df7d3eSAtari911            $installed = $extensionHelper->installFromLocal($backupPath, true); // true = overwrite
5458*96df7d3eSAtari911
5459*96df7d3eSAtari911            if ($installed) {
5460*96df7d3eSAtari911                $this->redirect('Plugin restored from backup: ' . $filename . ' (via Extension Manager)', 'success', 'update');
5461*96df7d3eSAtari911            } else {
5462*96df7d3eSAtari911                // Get any error message from the extension helper
5463*96df7d3eSAtari911                $errors = $extensionHelper->getErrors();
5464*96df7d3eSAtari911                $errorMsg = !empty($errors) ? implode(', ', $errors) : 'Unknown error';
5465*96df7d3eSAtari911                $this->redirect('Restore failed: ' . $errorMsg, 'error', 'update');
54661d05cddcSAtari911            }
5467*96df7d3eSAtari911        } catch (Exception $e) {
5468*96df7d3eSAtari911            $this->redirect('Restore failed: ' . $e->getMessage(), 'error', 'update');
54691d05cddcSAtari911        }
54701d05cddcSAtari911    }
54711d05cddcSAtari911
54729ccd446eSAtari911    private function createManualBackup() {
54739ccd446eSAtari911        $pluginDir = DOKU_PLUGIN . 'calendar/';
54749ccd446eSAtari911
54759ccd446eSAtari911        // Check if plugin directory is readable
54769ccd446eSAtari911        if (!is_readable($pluginDir)) {
54779ccd446eSAtari911            $this->redirect('Plugin directory is not readable. Please check permissions.', 'error', 'update');
54789ccd446eSAtari911            return;
54799ccd446eSAtari911        }
54809ccd446eSAtari911
54819ccd446eSAtari911        // Check if parent directory is writable (for saving backup)
54829ccd446eSAtari911        if (!is_writable(DOKU_PLUGIN)) {
54839ccd446eSAtari911            $this->redirect('Plugin parent directory is not writable. Cannot save backup.', 'error', 'update');
54849ccd446eSAtari911            return;
54859ccd446eSAtari911        }
54869ccd446eSAtari911
54879ccd446eSAtari911        // Get current version
54889ccd446eSAtari911        $pluginInfo = $pluginDir . 'plugin.info.txt';
54899ccd446eSAtari911        $version = 'unknown';
54909ccd446eSAtari911        if (file_exists($pluginInfo)) {
54919ccd446eSAtari911            $info = confToHash($pluginInfo);
54929ccd446eSAtari911            $version = $info['version'] ?? ($info['date'] ?? 'unknown');
54939ccd446eSAtari911        }
54949ccd446eSAtari911
54959ccd446eSAtari911        $backupName = 'calendar.backup.v' . $version . '.manual.' . date('Y-m-d_H-i-s') . '.zip';
54969ccd446eSAtari911        $backupPath = DOKU_PLUGIN . $backupName;
54979ccd446eSAtari911
54989ccd446eSAtari911        try {
54999ccd446eSAtari911            $zip = new ZipArchive();
55009ccd446eSAtari911            if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) {
55019ccd446eSAtari911                $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/');
55029ccd446eSAtari911                $zip->close();
55039ccd446eSAtari911
55049ccd446eSAtari911                // Verify backup was created and has content
55059ccd446eSAtari911                if (!file_exists($backupPath)) {
55069ccd446eSAtari911                    $this->redirect('Backup file was not created', 'error', 'update');
55079ccd446eSAtari911                    return;
55089ccd446eSAtari911                }
55099ccd446eSAtari911
55109ccd446eSAtari911                $backupSize = filesize($backupPath);
55119ccd446eSAtari911                if ($backupSize < 1000) { // Backup should be at least 1KB
55129ccd446eSAtari911                    @unlink($backupPath);
55139ccd446eSAtari911                    $this->redirect('Backup file is too small (' . $this->formatBytes($backupSize) . '). Only ' . $fileCount . ' files were added. Backup failed.', 'error', 'update');
55149ccd446eSAtari911                    return;
55159ccd446eSAtari911                }
55169ccd446eSAtari911
55179ccd446eSAtari911                if ($fileCount < 10) { // Should have at least 10 files
55189ccd446eSAtari911                    @unlink($backupPath);
55199ccd446eSAtari911                    $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup failed.', 'error', 'update');
55209ccd446eSAtari911                    return;
55219ccd446eSAtari911                }
55229ccd446eSAtari911
55239ccd446eSAtari911                // Success!
55249ccd446eSAtari911                $this->redirect('✓ Manual backup created successfully: ' . $backupName . ' (' . $this->formatBytes($backupSize) . ', ' . $fileCount . ' files)', 'success', 'update');
55259ccd446eSAtari911
55269ccd446eSAtari911            } else {
55279ccd446eSAtari911                $this->redirect('Failed to create backup ZIP file', 'error', 'update');
55289ccd446eSAtari911                return;
55299ccd446eSAtari911            }
55309ccd446eSAtari911        } catch (Exception $e) {
55319ccd446eSAtari911            if (file_exists($backupPath)) {
55329ccd446eSAtari911                @unlink($backupPath);
55339ccd446eSAtari911            }
55349ccd446eSAtari911            $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update');
55359ccd446eSAtari911            return;
55369ccd446eSAtari911        }
55379ccd446eSAtari911    }
55389ccd446eSAtari911
55391d05cddcSAtari911    private function addDirectoryToZip($zip, $dir, $zipPath = '') {
55409ccd446eSAtari911        $fileCount = 0;
55419ccd446eSAtari911        $errors = [];
55429ccd446eSAtari911
55437e8ea635SAtari911        // Ensure dir has trailing slash
55447e8ea635SAtari911        $dir = rtrim($dir, '/') . '/';
55457e8ea635SAtari911
55469ccd446eSAtari911        if (!is_dir($dir)) {
55479ccd446eSAtari911            throw new Exception("Directory does not exist: $dir");
55489ccd446eSAtari911        }
55499ccd446eSAtari911
55509ccd446eSAtari911        if (!is_readable($dir)) {
55519ccd446eSAtari911            throw new Exception("Directory is not readable: $dir");
55529ccd446eSAtari911        }
55539ccd446eSAtari911
55541d05cddcSAtari911        try {
55557e8ea635SAtari911            // First, add all directories to preserve structure (including empty ones)
55567e8ea635SAtari911            $dirs = new RecursiveIteratorIterator(
55571d05cddcSAtari911                new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
55587e8ea635SAtari911                RecursiveIteratorIterator::SELF_FIRST  // Process directories before their contents
55591d05cddcSAtari911            );
55601d05cddcSAtari911
55617e8ea635SAtari911            foreach ($dirs as $item) {
55627e8ea635SAtari911                $itemPath = $item->getRealPath();
55637e8ea635SAtari911                if (!$itemPath) continue;
55649ccd446eSAtari911
55657e8ea635SAtari911                // Calculate relative path from the source directory
55667e8ea635SAtari911                $relativePath = $zipPath . substr($itemPath, strlen($dir));
55677e8ea635SAtari911
55687e8ea635SAtari911                if ($item->isDir()) {
55697e8ea635SAtari911                    // Add directory to ZIP (preserves empty directories and structure)
55707e8ea635SAtari911                    $dirInZip = rtrim($relativePath, '/') . '/';
55717e8ea635SAtari911                    $zip->addEmptyDir($dirInZip);
55727e8ea635SAtari911                } else {
55737e8ea635SAtari911                    // Add file to ZIP
55747e8ea635SAtari911                    if (is_readable($itemPath)) {
55757e8ea635SAtari911                        if ($zip->addFile($itemPath, $relativePath)) {
55769ccd446eSAtari911                            $fileCount++;
55779ccd446eSAtari911                        } else {
55787e8ea635SAtari911                            $errors[] = "Failed to add: " . basename($itemPath);
55799ccd446eSAtari911                        }
55809ccd446eSAtari911                    } else {
55817e8ea635SAtari911                        $errors[] = "Cannot read: " . basename($itemPath);
55821d05cddcSAtari911                    }
55831d05cddcSAtari911                }
55841d05cddcSAtari911            }
55859ccd446eSAtari911
55869ccd446eSAtari911            // Log any errors but don't fail if we got most files
55879ccd446eSAtari911            if (!empty($errors) && count($errors) < 5) {
55889ccd446eSAtari911                foreach ($errors as $error) {
55899ccd446eSAtari911                    error_log('Calendar plugin backup warning: ' . $error);
55909ccd446eSAtari911                }
55919ccd446eSAtari911            }
55929ccd446eSAtari911
55939ccd446eSAtari911            // If too many errors, fail
55949ccd446eSAtari911            if (count($errors) > 5) {
55959ccd446eSAtari911                throw new Exception("Too many errors adding files to backup: " . implode(', ', array_slice($errors, 0, 5)));
55969ccd446eSAtari911            }
55979ccd446eSAtari911
55981d05cddcSAtari911        } catch (Exception $e) {
55999ccd446eSAtari911            error_log('Calendar plugin backup error: ' . $e->getMessage());
56009ccd446eSAtari911            throw $e;
56011d05cddcSAtari911        }
56029ccd446eSAtari911
56039ccd446eSAtari911        return $fileCount;
56041d05cddcSAtari911    }
56051d05cddcSAtari911
56061d05cddcSAtari911    private function deleteDirectory($dir) {
56071d05cddcSAtari911        if (!is_dir($dir)) return;
56081d05cddcSAtari911
56091d05cddcSAtari911        try {
56101d05cddcSAtari911            $files = new RecursiveIteratorIterator(
56111d05cddcSAtari911                new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
56121d05cddcSAtari911                RecursiveIteratorIterator::CHILD_FIRST
56131d05cddcSAtari911            );
56141d05cddcSAtari911
56151d05cddcSAtari911            foreach ($files as $file) {
56161d05cddcSAtari911                if ($file->isDir()) {
56171d05cddcSAtari911                    @rmdir($file->getRealPath());
56181d05cddcSAtari911                } else {
56191d05cddcSAtari911                    @unlink($file->getRealPath());
56201d05cddcSAtari911                }
56211d05cddcSAtari911            }
56221d05cddcSAtari911
56231d05cddcSAtari911            @rmdir($dir);
56241d05cddcSAtari911        } catch (Exception $e) {
56251d05cddcSAtari911            error_log('Calendar plugin delete directory error: ' . $e->getMessage());
56261d05cddcSAtari911        }
56271d05cddcSAtari911    }
56281d05cddcSAtari911
56291d05cddcSAtari911    private function deleteDirectoryContents($dir, $preserve = []) {
56301d05cddcSAtari911        if (!is_dir($dir)) return;
56311d05cddcSAtari911
56321d05cddcSAtari911        $items = scandir($dir);
56331d05cddcSAtari911        foreach ($items as $item) {
56341d05cddcSAtari911            if ($item === '.' || $item === '..') continue;
56351d05cddcSAtari911            if (in_array($item, $preserve)) continue;
56361d05cddcSAtari911
56371d05cddcSAtari911            $path = $dir . $item;
56381d05cddcSAtari911            if (is_dir($path)) {
56391d05cddcSAtari911                $this->deleteDirectory($path);
56401d05cddcSAtari911            } else {
56411d05cddcSAtari911                unlink($path);
56421d05cddcSAtari911            }
56431d05cddcSAtari911        }
56441d05cddcSAtari911    }
56451d05cddcSAtari911
56461d05cddcSAtari911    private function recursiveCopy($src, $dst) {
56477e8ea635SAtari911        if (!is_dir($src)) {
56487e8ea635SAtari911            return false;
56497e8ea635SAtari911        }
56507e8ea635SAtari911
56511d05cddcSAtari911        $dir = opendir($src);
56527e8ea635SAtari911        if (!$dir) {
56537e8ea635SAtari911            return false;
56547e8ea635SAtari911        }
56557e8ea635SAtari911
56567e8ea635SAtari911        // Create destination directory with proper permissions (0755)
56577e8ea635SAtari911        if (!is_dir($dst)) {
56587e8ea635SAtari911            mkdir($dst, 0755, true);
56597e8ea635SAtari911        }
56601d05cddcSAtari911
56611d05cddcSAtari911        while (($file = readdir($dir)) !== false) {
56621d05cddcSAtari911            if ($file !== '.' && $file !== '..') {
56637e8ea635SAtari911                $srcPath = $src . '/' . $file;
56647e8ea635SAtari911                $dstPath = $dst . '/' . $file;
56657e8ea635SAtari911
56667e8ea635SAtari911                if (is_dir($srcPath)) {
56677e8ea635SAtari911                    // Recursively copy subdirectory
56687e8ea635SAtari911                    $this->recursiveCopy($srcPath, $dstPath);
56691d05cddcSAtari911                } else {
56707e8ea635SAtari911                    // Copy file and preserve permissions
56717e8ea635SAtari911                    if (copy($srcPath, $dstPath)) {
56727e8ea635SAtari911                        // Try to preserve file permissions from source, fallback to 0644
56737e8ea635SAtari911                        $perms = @fileperms($srcPath);
56747e8ea635SAtari911                        if ($perms !== false) {
56757e8ea635SAtari911                            @chmod($dstPath, $perms);
56767e8ea635SAtari911                        } else {
56777e8ea635SAtari911                            @chmod($dstPath, 0644);
56787e8ea635SAtari911                        }
56797e8ea635SAtari911                    }
56801d05cddcSAtari911                }
56811d05cddcSAtari911            }
56821d05cddcSAtari911        }
56831d05cddcSAtari911
56841d05cddcSAtari911        closedir($dir);
56857e8ea635SAtari911        return true;
56861d05cddcSAtari911    }
56871d05cddcSAtari911
56881d05cddcSAtari911    private function formatBytes($bytes) {
56891d05cddcSAtari911        if ($bytes >= 1073741824) {
56901d05cddcSAtari911            return number_format($bytes / 1073741824, 2) . ' GB';
56911d05cddcSAtari911        } elseif ($bytes >= 1048576) {
56921d05cddcSAtari911            return number_format($bytes / 1048576, 2) . ' MB';
56931d05cddcSAtari911        } elseif ($bytes >= 1024) {
56941d05cddcSAtari911            return number_format($bytes / 1024, 2) . ' KB';
56951d05cddcSAtari911        } else {
56961d05cddcSAtari911            return $bytes . ' bytes';
56971d05cddcSAtari911        }
56981d05cddcSAtari911    }
56991d05cddcSAtari911
57001d05cddcSAtari911    private function findPhpBinary() {
57011d05cddcSAtari911        // Try PHP_BINARY constant first (most reliable if available)
57021d05cddcSAtari911        if (defined('PHP_BINARY') && !empty(PHP_BINARY) && is_executable(PHP_BINARY)) {
5703*96df7d3eSAtari911            return PHP_BINARY;
57041d05cddcSAtari911        }
57051d05cddcSAtari911
57061d05cddcSAtari911        // Try common PHP binary locations
57071d05cddcSAtari911        $possiblePaths = [
57081d05cddcSAtari911            '/usr/bin/php',
57091d05cddcSAtari911            '/usr/bin/php8.1',
57101d05cddcSAtari911            '/usr/bin/php8.2',
57111d05cddcSAtari911            '/usr/bin/php8.3',
57121d05cddcSAtari911            '/usr/bin/php7.4',
57131d05cddcSAtari911            '/usr/local/bin/php',
57141d05cddcSAtari911        ];
57151d05cddcSAtari911
57161d05cddcSAtari911        foreach ($possiblePaths as $path) {
5717*96df7d3eSAtari911            if (is_executable($path)) {
5718*96df7d3eSAtari911                return $path;
57191d05cddcSAtari911            }
57201d05cddcSAtari911        }
57211d05cddcSAtari911
5722*96df7d3eSAtari911        // Try using 'which' to find php
5723*96df7d3eSAtari911        $which = trim(shell_exec('which php 2>/dev/null') ?? '');
5724*96df7d3eSAtari911        if (!empty($which) && is_executable($which)) {
5725*96df7d3eSAtari911            return $which;
5726*96df7d3eSAtari911        }
5727*96df7d3eSAtari911
57281d05cddcSAtari911        // Fallback to 'php' and hope it's in PATH
57291d05cddcSAtari911        return 'php';
57301d05cddcSAtari911    }
57311d05cddcSAtari911
57321d05cddcSAtari911    private function redirect($message, $type = 'success', $tab = null) {
57331d05cddcSAtari911        $url = '?do=admin&page=calendar';
57341d05cddcSAtari911        if ($tab) {
57351d05cddcSAtari911            $url .= '&tab=' . $tab;
57361d05cddcSAtari911        }
57371d05cddcSAtari911        $url .= '&msg=' . urlencode($message) . '&msgtype=' . $type;
57381d05cddcSAtari911        header('Location: ' . $url);
57391d05cddcSAtari911        exit;
57401d05cddcSAtari911    }
57411d05cddcSAtari911
57421d05cddcSAtari911    private function getLog() {
57431d05cddcSAtari911        global $INPUT;
57441d05cddcSAtari911
57451d05cddcSAtari911        if ($INPUT->str('call') === 'ajax') {
57461d05cddcSAtari911            header('Content-Type: application/json');
57471d05cddcSAtari911
5748*96df7d3eSAtari911            $logFile = $this->getSyncLogPath();
57491d05cddcSAtari911            $log = '';
57501d05cddcSAtari911
57511d05cddcSAtari911            if (file_exists($logFile)) {
57521d05cddcSAtari911                // Get last 500 lines
57531d05cddcSAtari911                $lines = file($logFile);
57541d05cddcSAtari911                if ($lines !== false) {
57551d05cddcSAtari911                    $lines = array_slice($lines, -500);
57561d05cddcSAtari911                    $log = implode('', $lines);
57571d05cddcSAtari911                }
57581d05cddcSAtari911            } else {
57591d05cddcSAtari911                $log = "No log file found. Sync hasn't run yet.";
57601d05cddcSAtari911            }
57611d05cddcSAtari911
57621d05cddcSAtari911            echo json_encode(['log' => $log]);
57631d05cddcSAtari911            exit;
57641d05cddcSAtari911        }
57651d05cddcSAtari911    }
57661d05cddcSAtari911
57671d05cddcSAtari911    private function exportConfig() {
57681d05cddcSAtari911        global $INPUT;
57691d05cddcSAtari911
57701d05cddcSAtari911        if ($INPUT->str('call') === 'ajax') {
57711d05cddcSAtari911            header('Content-Type: application/json');
57721d05cddcSAtari911
57731d05cddcSAtari911            try {
57741d05cddcSAtari911                $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
57751d05cddcSAtari911
57761d05cddcSAtari911                if (!file_exists($configFile)) {
57771d05cddcSAtari911                    echo json_encode([
57781d05cddcSAtari911                        'success' => false,
57791d05cddcSAtari911                        'message' => 'Config file not found'
57801d05cddcSAtari911                    ]);
57811d05cddcSAtari911                    exit;
57821d05cddcSAtari911                }
57831d05cddcSAtari911
57841d05cddcSAtari911                // Read config file
57851d05cddcSAtari911                $configContent = file_get_contents($configFile);
57861d05cddcSAtari911
57871d05cddcSAtari911                // Generate encryption key from DokuWiki secret
57881d05cddcSAtari911                $key = $this->getEncryptionKey();
57891d05cddcSAtari911
57901d05cddcSAtari911                // Encrypt config
57911d05cddcSAtari911                $encrypted = $this->encryptData($configContent, $key);
57921d05cddcSAtari911
57931d05cddcSAtari911                echo json_encode([
57941d05cddcSAtari911                    'success' => true,
57951d05cddcSAtari911                    'encrypted' => $encrypted,
57961d05cddcSAtari911                    'message' => 'Config exported successfully'
57971d05cddcSAtari911                ]);
57981d05cddcSAtari911                exit;
57991d05cddcSAtari911
58001d05cddcSAtari911            } catch (Exception $e) {
58011d05cddcSAtari911                echo json_encode([
58021d05cddcSAtari911                    'success' => false,
58031d05cddcSAtari911                    'message' => $e->getMessage()
58041d05cddcSAtari911                ]);
58051d05cddcSAtari911                exit;
58061d05cddcSAtari911            }
58071d05cddcSAtari911        }
58081d05cddcSAtari911    }
58091d05cddcSAtari911
58101d05cddcSAtari911    private function importConfig() {
58111d05cddcSAtari911        global $INPUT;
58121d05cddcSAtari911
58131d05cddcSAtari911        if ($INPUT->str('call') === 'ajax') {
58141d05cddcSAtari911            header('Content-Type: application/json');
58151d05cddcSAtari911
58161d05cddcSAtari911            try {
58171d05cddcSAtari911                $encrypted = $_POST['encrypted_config'] ?? '';
58181d05cddcSAtari911
58191d05cddcSAtari911                if (empty($encrypted)) {
58201d05cddcSAtari911                    echo json_encode([
58211d05cddcSAtari911                        'success' => false,
58221d05cddcSAtari911                        'message' => 'No config data provided'
58231d05cddcSAtari911                    ]);
58241d05cddcSAtari911                    exit;
58251d05cddcSAtari911                }
58261d05cddcSAtari911
58271d05cddcSAtari911                // Generate encryption key from DokuWiki secret
58281d05cddcSAtari911                $key = $this->getEncryptionKey();
58291d05cddcSAtari911
58301d05cddcSAtari911                // Decrypt config
58311d05cddcSAtari911                $configContent = $this->decryptData($encrypted, $key);
58321d05cddcSAtari911
58331d05cddcSAtari911                if ($configContent === false) {
58341d05cddcSAtari911                    echo json_encode([
58351d05cddcSAtari911                        'success' => false,
58361d05cddcSAtari911                        'message' => 'Decryption failed. Invalid key or corrupted file.'
58371d05cddcSAtari911                    ]);
58381d05cddcSAtari911                    exit;
58391d05cddcSAtari911                }
58401d05cddcSAtari911
58417e8ea635SAtari911                // Validate PHP config file structure (without using eval)
58427e8ea635SAtari911                // Check that it starts with <?php and contains a return statement with array
58437e8ea635SAtari911                if (strpos($configContent, '<?php') === false) {
58441d05cddcSAtari911                    echo json_encode([
58451d05cddcSAtari911                        'success' => false,
58467e8ea635SAtari911                        'message' => 'Invalid config file: missing PHP opening tag'
58477e8ea635SAtari911                    ]);
58487e8ea635SAtari911                    exit;
58497e8ea635SAtari911                }
58507e8ea635SAtari911
58517e8ea635SAtari911                // Check for dangerous patterns that shouldn't be in a config file
58527e8ea635SAtari911                $dangerousPatterns = [
58537e8ea635SAtari911                    '/\b(exec|shell_exec|system|passthru|popen|proc_open)\s*\(/i',
58547e8ea635SAtari911                    '/\b(eval|assert|create_function)\s*\(/i',
58557e8ea635SAtari911                    '/\b(file_get_contents|file_put_contents|fopen|fwrite|unlink|rmdir)\s*\(/i',
58567e8ea635SAtari911                    '/\$_(GET|POST|REQUEST|SERVER|FILES|COOKIE|SESSION)\s*\[/i',
58577e8ea635SAtari911                    '/`[^`]+`/',  // Backtick execution
58587e8ea635SAtari911                ];
58597e8ea635SAtari911
58607e8ea635SAtari911                foreach ($dangerousPatterns as $pattern) {
58617e8ea635SAtari911                    if (preg_match($pattern, $configContent)) {
58627e8ea635SAtari911                        echo json_encode([
58637e8ea635SAtari911                            'success' => false,
58647e8ea635SAtari911                            'message' => 'Invalid config file: contains prohibited code patterns'
58657e8ea635SAtari911                        ]);
58667e8ea635SAtari911                        exit;
58677e8ea635SAtari911                    }
58687e8ea635SAtari911                }
58697e8ea635SAtari911
58707e8ea635SAtari911                // Verify it looks like a valid config (has return array structure)
58717e8ea635SAtari911                if (!preg_match('/return\s*\[/', $configContent)) {
58727e8ea635SAtari911                    echo json_encode([
58737e8ea635SAtari911                        'success' => false,
58747e8ea635SAtari911                        'message' => 'Invalid config file: must contain a return array statement'
58751d05cddcSAtari911                    ]);
58761d05cddcSAtari911                    exit;
58771d05cddcSAtari911                }
58781d05cddcSAtari911
58791d05cddcSAtari911                // Write to config file
58801d05cddcSAtari911                $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
58811d05cddcSAtari911
58821d05cddcSAtari911                // Backup existing config
58831d05cddcSAtari911                if (file_exists($configFile)) {
58841d05cddcSAtari911                    $backupFile = $configFile . '.backup.' . date('Y-m-d_H-i-s');
58851d05cddcSAtari911                    copy($configFile, $backupFile);
58861d05cddcSAtari911                }
58871d05cddcSAtari911
58881d05cddcSAtari911                // Write new config
58891d05cddcSAtari911                if (file_put_contents($configFile, $configContent) === false) {
58901d05cddcSAtari911                    echo json_encode([
58911d05cddcSAtari911                        'success' => false,
58921d05cddcSAtari911                        'message' => 'Failed to write config file'
58931d05cddcSAtari911                    ]);
58941d05cddcSAtari911                    exit;
58951d05cddcSAtari911                }
58961d05cddcSAtari911
58971d05cddcSAtari911                echo json_encode([
58981d05cddcSAtari911                    'success' => true,
58991d05cddcSAtari911                    'message' => 'Config imported successfully'
59001d05cddcSAtari911                ]);
59011d05cddcSAtari911                exit;
59021d05cddcSAtari911
59031d05cddcSAtari911            } catch (Exception $e) {
59041d05cddcSAtari911                echo json_encode([
59051d05cddcSAtari911                    'success' => false,
59061d05cddcSAtari911                    'message' => $e->getMessage()
59071d05cddcSAtari911                ]);
59081d05cddcSAtari911                exit;
59091d05cddcSAtari911            }
59101d05cddcSAtari911        }
59111d05cddcSAtari911    }
59121d05cddcSAtari911
59131d05cddcSAtari911    private function getEncryptionKey() {
59141d05cddcSAtari911        global $conf;
59151d05cddcSAtari911        // Use DokuWiki's secret as the base for encryption
59161d05cddcSAtari911        // This ensures the key is unique per installation
59171d05cddcSAtari911        return hash('sha256', $conf['secret'] . 'calendar_config_encryption', true);
59181d05cddcSAtari911    }
59191d05cddcSAtari911
59201d05cddcSAtari911    private function encryptData($data, $key) {
59211d05cddcSAtari911        // Use AES-256-CBC encryption
59221d05cddcSAtari911        $ivLength = openssl_cipher_iv_length('aes-256-cbc');
59231d05cddcSAtari911        $iv = openssl_random_pseudo_bytes($ivLength);
59241d05cddcSAtari911
59251d05cddcSAtari911        $encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv);
59261d05cddcSAtari911
59271d05cddcSAtari911        // Combine IV and encrypted data, then base64 encode
59281d05cddcSAtari911        return base64_encode($iv . $encrypted);
59291d05cddcSAtari911    }
59301d05cddcSAtari911
59311d05cddcSAtari911    private function decryptData($encryptedData, $key) {
59321d05cddcSAtari911        // Decode base64
59331d05cddcSAtari911        $data = base64_decode($encryptedData);
59341d05cddcSAtari911
59351d05cddcSAtari911        if ($data === false) {
59361d05cddcSAtari911            return false;
59371d05cddcSAtari911        }
59381d05cddcSAtari911
59391d05cddcSAtari911        // Extract IV and encrypted content
59401d05cddcSAtari911        $ivLength = openssl_cipher_iv_length('aes-256-cbc');
59411d05cddcSAtari911        $iv = substr($data, 0, $ivLength);
59421d05cddcSAtari911        $encrypted = substr($data, $ivLength);
59431d05cddcSAtari911
59441d05cddcSAtari911        // Decrypt
59451d05cddcSAtari911        $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv);
59461d05cddcSAtari911
59471d05cddcSAtari911        return $decrypted;
59481d05cddcSAtari911    }
59491d05cddcSAtari911
59501d05cddcSAtari911    private function clearLogFile() {
59511d05cddcSAtari911        global $INPUT;
59521d05cddcSAtari911
59531d05cddcSAtari911        if ($INPUT->str('call') === 'ajax') {
59541d05cddcSAtari911            header('Content-Type: application/json');
59551d05cddcSAtari911
5956*96df7d3eSAtari911            $logFile = $this->getSyncLogPath();
59571d05cddcSAtari911
5958*96df7d3eSAtari911            // Check if file exists
5959*96df7d3eSAtari911            if (!file_exists($logFile)) {
5960*96df7d3eSAtari911                // Try to create empty file
5961*96df7d3eSAtari911                if (@touch($logFile)) {
5962*96df7d3eSAtari911                    echo json_encode(['success' => true, 'message' => 'Log file created']);
5963*96df7d3eSAtari911                } else {
5964*96df7d3eSAtari911                    echo json_encode(['success' => false, 'message' => 'Log file does not exist and cannot be created: ' . $logFile]);
5965*96df7d3eSAtari911                }
5966*96df7d3eSAtari911                exit;
5967*96df7d3eSAtari911            }
5968*96df7d3eSAtari911
5969*96df7d3eSAtari911            // Check if writable
5970*96df7d3eSAtari911            if (!is_writable($logFile)) {
5971*96df7d3eSAtari911                echo json_encode(['success' => false, 'message' => 'Log file not writable. Run: sudo chmod 666 ' . $logFile]);
5972*96df7d3eSAtari911                exit;
5973*96df7d3eSAtari911            }
5974*96df7d3eSAtari911
5975*96df7d3eSAtari911            // Try to clear it
5976*96df7d3eSAtari911            $result = file_put_contents($logFile, '');
5977*96df7d3eSAtari911            if ($result !== false) {
59781d05cddcSAtari911                echo json_encode(['success' => true]);
59791d05cddcSAtari911            } else {
5980*96df7d3eSAtari911                echo json_encode(['success' => false, 'message' => 'file_put_contents failed on: ' . $logFile]);
59811d05cddcSAtari911            }
59821d05cddcSAtari911            exit;
59831d05cddcSAtari911        }
59841d05cddcSAtari911    }
59851d05cddcSAtari911
59861d05cddcSAtari911    private function downloadLog() {
5987*96df7d3eSAtari911        $logFile = $this->getSyncLogPath();
59881d05cddcSAtari911
59891d05cddcSAtari911        if (file_exists($logFile)) {
59901d05cddcSAtari911            header('Content-Type: text/plain');
59911d05cddcSAtari911            header('Content-Disposition: attachment; filename="calendar-sync-' . date('Y-m-d-His') . '.log"');
59921d05cddcSAtari911            readfile($logFile);
59931d05cddcSAtari911            exit;
59941d05cddcSAtari911        } else {
59951d05cddcSAtari911            echo 'No log file found';
59961d05cddcSAtari911            exit;
59971d05cddcSAtari911        }
59981d05cddcSAtari911    }
59991d05cddcSAtari911
60001d05cddcSAtari911    private function getEventStatistics() {
60011d05cddcSAtari911        $stats = [
60021d05cddcSAtari911            'total_events' => 0,
60031d05cddcSAtari911            'total_namespaces' => 0,
60041d05cddcSAtari911            'total_files' => 0,
60051d05cddcSAtari911            'total_recurring' => 0,
60061d05cddcSAtari911            'by_namespace' => [],
60071d05cddcSAtari911            'last_scan' => ''
60081d05cddcSAtari911        ];
60091d05cddcSAtari911
60101d05cddcSAtari911        $metaDir = DOKU_INC . 'data/meta/';
60111d05cddcSAtari911        $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
60121d05cddcSAtari911
60131d05cddcSAtari911        // Check if we have cached stats (less than 5 minutes old)
60141d05cddcSAtari911        if (file_exists($cacheFile)) {
60151d05cddcSAtari911            $cacheData = json_decode(file_get_contents($cacheFile), true);
60161d05cddcSAtari911            if ($cacheData && (time() - $cacheData['timestamp']) < 300) {
60171d05cddcSAtari911                return $cacheData['stats'];
60181d05cddcSAtari911            }
60191d05cddcSAtari911        }
60201d05cddcSAtari911
60211d05cddcSAtari911        // Scan for events
60221d05cddcSAtari911        $this->scanDirectoryForStats($metaDir, '', $stats);
60231d05cddcSAtari911
60241d05cddcSAtari911        // Count recurring events
60251d05cddcSAtari911        $recurringEvents = $this->findRecurringEvents();
60261d05cddcSAtari911        $stats['total_recurring'] = count($recurringEvents);
60271d05cddcSAtari911
60281d05cddcSAtari911        $stats['total_namespaces'] = count($stats['by_namespace']);
60291d05cddcSAtari911        $stats['last_scan'] = date('Y-m-d H:i:s');
60301d05cddcSAtari911
60311d05cddcSAtari911        // Cache the results
60321d05cddcSAtari911        file_put_contents($cacheFile, json_encode([
60331d05cddcSAtari911            'timestamp' => time(),
60341d05cddcSAtari911            'stats' => $stats
60351d05cddcSAtari911        ]));
60361d05cddcSAtari911
60371d05cddcSAtari911        return $stats;
60381d05cddcSAtari911    }
60391d05cddcSAtari911
60401d05cddcSAtari911    private function scanDirectoryForStats($dir, $namespace, &$stats) {
60411d05cddcSAtari911        if (!is_dir($dir)) return;
60421d05cddcSAtari911
60431d05cddcSAtari911        $items = scandir($dir);
60441d05cddcSAtari911        foreach ($items as $item) {
60451d05cddcSAtari911            if ($item === '.' || $item === '..') continue;
60461d05cddcSAtari911
60471d05cddcSAtari911            $path = $dir . $item;
60481d05cddcSAtari911
60491d05cddcSAtari911            // Check if this is a calendar directory
60501d05cddcSAtari911            if ($item === 'calendar' && is_dir($path)) {
60511d05cddcSAtari911                $jsonFiles = glob($path . '/*.json');
60521d05cddcSAtari911                $eventCount = 0;
60531d05cddcSAtari911
60541d05cddcSAtari911                foreach ($jsonFiles as $file) {
60551d05cddcSAtari911                    $stats['total_files']++;
60561d05cddcSAtari911                    $data = json_decode(file_get_contents($file), true);
60571d05cddcSAtari911                    if ($data) {
6058*96df7d3eSAtari911                        foreach ($data as $dateKey => $dateEvents) {
6059*96df7d3eSAtari911                            // Skip non-date keys (like "mapping" or other metadata)
6060*96df7d3eSAtari911                            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue;
6061*96df7d3eSAtari911
6062*96df7d3eSAtari911                            if (is_array($dateEvents)) {
6063*96df7d3eSAtari911                                // Only count events that have id and title
6064*96df7d3eSAtari911                                foreach ($dateEvents as $event) {
6065*96df7d3eSAtari911                                    if (is_array($event) && !empty($event['id']) && !empty($event['title'])) {
6066*96df7d3eSAtari911                                        $eventCount++;
6067*96df7d3eSAtari911                                    }
6068*96df7d3eSAtari911                                }
6069*96df7d3eSAtari911                            }
60701d05cddcSAtari911                        }
60711d05cddcSAtari911                    }
60721d05cddcSAtari911                }
60731d05cddcSAtari911
60741d05cddcSAtari911                $stats['total_events'] += $eventCount;
60751d05cddcSAtari911
60761d05cddcSAtari911                if ($eventCount > 0) {
60771d05cddcSAtari911                    $stats['by_namespace'][$namespace] = [
60781d05cddcSAtari911                        'events' => $eventCount,
60791d05cddcSAtari911                        'files' => count($jsonFiles)
60801d05cddcSAtari911                    ];
60811d05cddcSAtari911                }
60821d05cddcSAtari911            } elseif (is_dir($path)) {
60831d05cddcSAtari911                // Recurse into subdirectories
60841d05cddcSAtari911                $newNamespace = $namespace ? $namespace . ':' . $item : $item;
60851d05cddcSAtari911                $this->scanDirectoryForStats($path . '/', $newNamespace, $stats);
60861d05cddcSAtari911            }
60871d05cddcSAtari911        }
60881d05cddcSAtari911    }
60891d05cddcSAtari911
60901d05cddcSAtari911    private function rescanEvents() {
60911d05cddcSAtari911        // Clear the cache to force a rescan
60929ccd446eSAtari911        $this->clearStatsCache();
60931d05cddcSAtari911
60941d05cddcSAtari911        // Get fresh statistics
60951d05cddcSAtari911        $stats = $this->getEventStatistics();
60961d05cddcSAtari911
60971d05cddcSAtari911        // Build absolute redirect URL
60981d05cddcSAtari911        $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';
60991d05cddcSAtari911
61001d05cddcSAtari911        // Redirect with success message using absolute URL
61011d05cddcSAtari911        header('Location: ' . $redirectUrl, true, 303);
61021d05cddcSAtari911        exit;
61031d05cddcSAtari911    }
61041d05cddcSAtari911
61051d05cddcSAtari911    private function exportAllEvents() {
61061d05cddcSAtari911        $metaDir = DOKU_INC . 'data/meta/';
61071d05cddcSAtari911        $allEvents = [];
61081d05cddcSAtari911
61091d05cddcSAtari911        // Collect all events
61101d05cddcSAtari911        $this->collectAllEvents($metaDir, '', $allEvents);
61111d05cddcSAtari911
61121d05cddcSAtari911        // Create export package
61139ccd446eSAtari911        // Get current version
61149ccd446eSAtari911        $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt';
61159ccd446eSAtari911        $info = file_exists($pluginInfo) ? confToHash($pluginInfo) : [];
61169ccd446eSAtari911        $currentVersion = isset($info['version']) ? trim($info['version']) : 'unknown';
61179ccd446eSAtari911
61181d05cddcSAtari911        $exportData = [
61191d05cddcSAtari911            'export_date' => date('Y-m-d H:i:s'),
61209ccd446eSAtari911            'version' => $currentVersion,
61211d05cddcSAtari911            'total_events' => 0,
61221d05cddcSAtari911            'namespaces' => []
61231d05cddcSAtari911        ];
61241d05cddcSAtari911
61251d05cddcSAtari911        foreach ($allEvents as $namespace => $files) {
61261d05cddcSAtari911            $exportData['namespaces'][$namespace] = [];
61271d05cddcSAtari911            foreach ($files as $filename => $events) {
61281d05cddcSAtari911                $exportData['namespaces'][$namespace][$filename] = $events;
61291d05cddcSAtari911                foreach ($events as $dateEvents) {
6130*96df7d3eSAtari911                    if (is_array($dateEvents)) {
61311d05cddcSAtari911                        $exportData['total_events'] += count($dateEvents);
61321d05cddcSAtari911                    }
61331d05cddcSAtari911                }
61341d05cddcSAtari911            }
6135*96df7d3eSAtari911        }
61361d05cddcSAtari911
61371d05cddcSAtari911        // Send as download
61381d05cddcSAtari911        header('Content-Type: application/json');
61391d05cddcSAtari911        header('Content-Disposition: attachment; filename="calendar-events-export-' . date('Y-m-d-His') . '.json"');
61401d05cddcSAtari911        echo json_encode($exportData, JSON_PRETTY_PRINT);
61411d05cddcSAtari911        exit;
61421d05cddcSAtari911    }
61431d05cddcSAtari911
61441d05cddcSAtari911    private function collectAllEvents($dir, $namespace, &$allEvents) {
61451d05cddcSAtari911        if (!is_dir($dir)) return;
61461d05cddcSAtari911
61471d05cddcSAtari911        $items = scandir($dir);
61481d05cddcSAtari911        foreach ($items as $item) {
61491d05cddcSAtari911            if ($item === '.' || $item === '..') continue;
61501d05cddcSAtari911
61511d05cddcSAtari911            $path = $dir . $item;
61521d05cddcSAtari911
61531d05cddcSAtari911            // Check if this is a calendar directory
61541d05cddcSAtari911            if ($item === 'calendar' && is_dir($path)) {
61551d05cddcSAtari911                $jsonFiles = glob($path . '/*.json');
61561d05cddcSAtari911
61571d05cddcSAtari911                if (!isset($allEvents[$namespace])) {
61581d05cddcSAtari911                    $allEvents[$namespace] = [];
61591d05cddcSAtari911                }
61601d05cddcSAtari911
61611d05cddcSAtari911                foreach ($jsonFiles as $file) {
61621d05cddcSAtari911                    $filename = basename($file);
61631d05cddcSAtari911                    $data = json_decode(file_get_contents($file), true);
61641d05cddcSAtari911                    if ($data) {
61651d05cddcSAtari911                        $allEvents[$namespace][$filename] = $data;
61661d05cddcSAtari911                    }
61671d05cddcSAtari911                }
61681d05cddcSAtari911            } elseif (is_dir($path)) {
61691d05cddcSAtari911                // Recurse into subdirectories
61701d05cddcSAtari911                $newNamespace = $namespace ? $namespace . ':' . $item : $item;
61711d05cddcSAtari911                $this->collectAllEvents($path . '/', $newNamespace, $allEvents);
61721d05cddcSAtari911            }
61731d05cddcSAtari911        }
61741d05cddcSAtari911    }
61751d05cddcSAtari911
61761d05cddcSAtari911    private function importAllEvents() {
61771d05cddcSAtari911        global $INPUT;
61781d05cddcSAtari911
61791d05cddcSAtari911        if (!isset($_FILES['import_file'])) {
61801d05cddcSAtari911            $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('No file uploaded') . '&msgtype=error';
61811d05cddcSAtari911            header('Location: ' . $redirectUrl, true, 303);
61821d05cddcSAtari911            exit;
61831d05cddcSAtari911        }
61841d05cddcSAtari911
61851d05cddcSAtari911        $file = $_FILES['import_file'];
61861d05cddcSAtari911
61871d05cddcSAtari911        if ($file['error'] !== UPLOAD_ERR_OK) {
61881d05cddcSAtari911            $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Upload error: ' . $file['error']) . '&msgtype=error';
61891d05cddcSAtari911            header('Location: ' . $redirectUrl, true, 303);
61901d05cddcSAtari911            exit;
61911d05cddcSAtari911        }
61921d05cddcSAtari911
61931d05cddcSAtari911        // Read and decode the import file
61941d05cddcSAtari911        $importData = json_decode(file_get_contents($file['tmp_name']), true);
61951d05cddcSAtari911
61961d05cddcSAtari911        if (!$importData || !isset($importData['namespaces'])) {
61971d05cddcSAtari911            $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Invalid import file format') . '&msgtype=error';
61981d05cddcSAtari911            header('Location: ' . $redirectUrl, true, 303);
61991d05cddcSAtari911            exit;
62001d05cddcSAtari911        }
62011d05cddcSAtari911
62021d05cddcSAtari911        $importedCount = 0;
62031d05cddcSAtari911        $mergedCount = 0;
62041d05cddcSAtari911
62051d05cddcSAtari911        // Import events
62061d05cddcSAtari911        foreach ($importData['namespaces'] as $namespace => $files) {
62071d05cddcSAtari911            $metaDir = DOKU_INC . 'data/meta/';
62081d05cddcSAtari911            if ($namespace) {
62091d05cddcSAtari911                $metaDir .= str_replace(':', '/', $namespace) . '/';
62101d05cddcSAtari911            }
62111d05cddcSAtari911            $calendarDir = $metaDir . 'calendar/';
62121d05cddcSAtari911
62131d05cddcSAtari911            // Create directory if needed
62141d05cddcSAtari911            if (!is_dir($calendarDir)) {
62151d05cddcSAtari911                mkdir($calendarDir, 0755, true);
62161d05cddcSAtari911            }
62171d05cddcSAtari911
62181d05cddcSAtari911            foreach ($files as $filename => $events) {
62191d05cddcSAtari911                $targetFile = $calendarDir . $filename;
62201d05cddcSAtari911
62211d05cddcSAtari911                // If file exists, merge events
62221d05cddcSAtari911                if (file_exists($targetFile)) {
62231d05cddcSAtari911                    $existing = json_decode(file_get_contents($targetFile), true);
62241d05cddcSAtari911                    if ($existing) {
62251d05cddcSAtari911                        foreach ($events as $date => $dateEvents) {
62261d05cddcSAtari911                            if (!isset($existing[$date])) {
62271d05cddcSAtari911                                $existing[$date] = [];
62281d05cddcSAtari911                            }
62291d05cddcSAtari911                            foreach ($dateEvents as $event) {
62301d05cddcSAtari911                                // Check if event with same ID exists
62311d05cddcSAtari911                                $found = false;
62321d05cddcSAtari911                                foreach ($existing[$date] as $existingEvent) {
62331d05cddcSAtari911                                    if ($existingEvent['id'] === $event['id']) {
62341d05cddcSAtari911                                        $found = true;
62351d05cddcSAtari911                                        break;
62361d05cddcSAtari911                                    }
62371d05cddcSAtari911                                }
62381d05cddcSAtari911                                if (!$found) {
62391d05cddcSAtari911                                    $existing[$date][] = $event;
62401d05cddcSAtari911                                    $importedCount++;
62411d05cddcSAtari911                                } else {
62421d05cddcSAtari911                                    $mergedCount++;
62431d05cddcSAtari911                                }
62441d05cddcSAtari911                            }
62451d05cddcSAtari911                        }
62461d05cddcSAtari911                        file_put_contents($targetFile, json_encode($existing, JSON_PRETTY_PRINT));
62471d05cddcSAtari911                    }
62481d05cddcSAtari911                } else {
62491d05cddcSAtari911                    // New file
62501d05cddcSAtari911                    file_put_contents($targetFile, json_encode($events, JSON_PRETTY_PRINT));
62511d05cddcSAtari911                    foreach ($events as $dateEvents) {
6252*96df7d3eSAtari911                        if (is_array($dateEvents)) {
62531d05cddcSAtari911                            $importedCount += count($dateEvents);
62541d05cddcSAtari911                        }
62551d05cddcSAtari911                    }
62561d05cddcSAtari911                }
62571d05cddcSAtari911            }
6258*96df7d3eSAtari911        }
62591d05cddcSAtari911
62601d05cddcSAtari911        // Clear cache
62619ccd446eSAtari911        $this->clearStatsCache();
62621d05cddcSAtari911
62631d05cddcSAtari911        $message = "Import complete! Imported $importedCount new events";
62641d05cddcSAtari911        if ($mergedCount > 0) {
62651d05cddcSAtari911            $message .= ", skipped $mergedCount duplicates";
62661d05cddcSAtari911        }
62671d05cddcSAtari911
62681d05cddcSAtari911        $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success';
62691d05cddcSAtari911        header('Location: ' . $redirectUrl, true, 303);
62701d05cddcSAtari911        exit;
62711d05cddcSAtari911    }
62721d05cddcSAtari911
62731d05cddcSAtari911    private function previewCleanup() {
62741d05cddcSAtari911        global $INPUT;
62751d05cddcSAtari911
62761d05cddcSAtari911        $cleanupType = $INPUT->str('cleanup_type', 'age');
62771d05cddcSAtari911        $namespaceFilter = $INPUT->str('namespace_filter', '');
62781d05cddcSAtari911
62791d05cddcSAtari911        // Debug info
62801d05cddcSAtari911        $debug = [];
62811d05cddcSAtari911        $debug['cleanup_type'] = $cleanupType;
62821d05cddcSAtari911        $debug['namespace_filter'] = $namespaceFilter;
62831d05cddcSAtari911        $debug['age_value'] = $INPUT->int('age_value', 6);
62841d05cddcSAtari911        $debug['age_unit'] = $INPUT->str('age_unit', 'months');
62851d05cddcSAtari911        $debug['range_start'] = $INPUT->str('range_start', '');
62861d05cddcSAtari911        $debug['range_end'] = $INPUT->str('range_end', '');
62871d05cddcSAtari911        $debug['delete_completed'] = $INPUT->bool('delete_completed', false);
62881d05cddcSAtari911        $debug['delete_past'] = $INPUT->bool('delete_past', false);
62891d05cddcSAtari911
62901d05cddcSAtari911        $dataDir = DOKU_INC . 'data/meta/';
62911d05cddcSAtari911        $debug['data_dir'] = $dataDir;
62921d05cddcSAtari911        $debug['data_dir_exists'] = is_dir($dataDir);
62931d05cddcSAtari911
62941d05cddcSAtari911        $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter);
62951d05cddcSAtari911
62961d05cddcSAtari911        // Merge with scan debug info
62971d05cddcSAtari911        if (isset($this->_cleanupDebug)) {
62981d05cddcSAtari911            $debug = array_merge($debug, $this->_cleanupDebug);
62991d05cddcSAtari911        }
63001d05cddcSAtari911
63011d05cddcSAtari911        // Return JSON for preview with debug info
63021d05cddcSAtari911        header('Content-Type: application/json');
63031d05cddcSAtari911        echo json_encode([
63041d05cddcSAtari911            'count' => count($eventsToDelete),
63051d05cddcSAtari911            'events' => array_slice($eventsToDelete, 0, 50), // Limit to 50 for preview
63061d05cddcSAtari911            'debug' => $debug
63071d05cddcSAtari911        ]);
63081d05cddcSAtari911        exit;
63091d05cddcSAtari911    }
63101d05cddcSAtari911
63111d05cddcSAtari911    private function cleanupEvents() {
63121d05cddcSAtari911        global $INPUT;
63131d05cddcSAtari911
63141d05cddcSAtari911        $cleanupType = $INPUT->str('cleanup_type', 'age');
63151d05cddcSAtari911        $namespaceFilter = $INPUT->str('namespace_filter', '');
63161d05cddcSAtari911
63171d05cddcSAtari911        // Create backup first
63181d05cddcSAtari911        $backupDir = DOKU_PLUGIN . 'calendar/backups/';
63191d05cddcSAtari911        if (!is_dir($backupDir)) {
63201d05cddcSAtari911            mkdir($backupDir, 0755, true);
63211d05cddcSAtari911        }
63221d05cddcSAtari911
63231d05cddcSAtari911        $backupFile = $backupDir . 'before-cleanup-' . date('Y-m-d-His') . '.zip';
63241d05cddcSAtari911        $this->createBackup($backupFile);
63251d05cddcSAtari911
63261d05cddcSAtari911        // Find events to delete
63271d05cddcSAtari911        $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter);
63281d05cddcSAtari911        $deletedCount = 0;
63291d05cddcSAtari911
63301d05cddcSAtari911        // Group by file
63311d05cddcSAtari911        $fileGroups = [];
63321d05cddcSAtari911        foreach ($eventsToDelete as $evt) {
63331d05cddcSAtari911            $fileGroups[$evt['file']][] = $evt;
63341d05cddcSAtari911        }
63351d05cddcSAtari911
63361d05cddcSAtari911        // Delete from each file
63371d05cddcSAtari911        foreach ($fileGroups as $file => $events) {
63381d05cddcSAtari911            if (!file_exists($file)) continue;
63391d05cddcSAtari911
63401d05cddcSAtari911            $json = file_get_contents($file);
63411d05cddcSAtari911            $data = json_decode($json, true);
63421d05cddcSAtari911
63431d05cddcSAtari911            if (!$data) continue;
63441d05cddcSAtari911
63451d05cddcSAtari911            // Remove events
63461d05cddcSAtari911            foreach ($events as $evt) {
63471d05cddcSAtari911                if (isset($data[$evt['date']])) {
63481d05cddcSAtari911                    $data[$evt['date']] = array_filter($data[$evt['date']], function($e) use ($evt) {
63491d05cddcSAtari911                        return $e['id'] !== $evt['id'];
63501d05cddcSAtari911                    });
63511d05cddcSAtari911
63521d05cddcSAtari911                    // Remove date key if empty
63531d05cddcSAtari911                    if (empty($data[$evt['date']])) {
63541d05cddcSAtari911                        unset($data[$evt['date']]);
63551d05cddcSAtari911                    }
63561d05cddcSAtari911
63571d05cddcSAtari911                    $deletedCount++;
63581d05cddcSAtari911                }
63591d05cddcSAtari911            }
63601d05cddcSAtari911
63611d05cddcSAtari911            // Save file or delete if empty
63621d05cddcSAtari911            if (empty($data)) {
63631d05cddcSAtari911                unlink($file);
63641d05cddcSAtari911            } else {
63651d05cddcSAtari911                file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
63661d05cddcSAtari911            }
63671d05cddcSAtari911        }
63681d05cddcSAtari911
63691d05cddcSAtari911        // Clear cache
63709ccd446eSAtari911        $this->clearStatsCache();
63711d05cddcSAtari911
63721d05cddcSAtari911        $message = "Cleanup complete! Deleted $deletedCount event(s). Backup created: " . basename($backupFile);
63731d05cddcSAtari911        $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success';
63741d05cddcSAtari911        header('Location: ' . $redirectUrl, true, 303);
63751d05cddcSAtari911        exit;
63761d05cddcSAtari911    }
63771d05cddcSAtari911
63781d05cddcSAtari911    private function findEventsToCleanup($cleanupType, $namespaceFilter) {
63791d05cddcSAtari911        global $INPUT;
63801d05cddcSAtari911
63811d05cddcSAtari911        $eventsToDelete = [];
63821d05cddcSAtari911        $dataDir = DOKU_INC . 'data/meta/';
63831d05cddcSAtari911
63841d05cddcSAtari911        $debug = [];
63851d05cddcSAtari911        $debug['scanned_dirs'] = [];
63861d05cddcSAtari911        $debug['found_files'] = [];
63871d05cddcSAtari911
63881d05cddcSAtari911        // Calculate cutoff date for age-based cleanup
63891d05cddcSAtari911        $cutoffDate = null;
63901d05cddcSAtari911        if ($cleanupType === 'age') {
63911d05cddcSAtari911            $ageValue = $INPUT->int('age_value', 6);
63921d05cddcSAtari911            $ageUnit = $INPUT->str('age_unit', 'months');
63931d05cddcSAtari911
63941d05cddcSAtari911            if ($ageUnit === 'years') {
63951d05cddcSAtari911                $ageValue *= 12; // Convert to months
63961d05cddcSAtari911            }
63971d05cddcSAtari911
63981d05cddcSAtari911            $cutoffDate = date('Y-m-d', strtotime("-$ageValue months"));
63991d05cddcSAtari911            $debug['cutoff_date'] = $cutoffDate;
64001d05cddcSAtari911        }
64011d05cddcSAtari911
64021d05cddcSAtari911        // Get date range for range-based cleanup
64031d05cddcSAtari911        $rangeStart = $cleanupType === 'range' ? $INPUT->str('range_start', '') : null;
64041d05cddcSAtari911        $rangeEnd = $cleanupType === 'range' ? $INPUT->str('range_end', '') : null;
64051d05cddcSAtari911
64061d05cddcSAtari911        // Get status filters
64071d05cddcSAtari911        $deleteCompleted = $cleanupType === 'status' && $INPUT->bool('delete_completed', false);
64081d05cddcSAtari911        $deletePast = $cleanupType === 'status' && $INPUT->bool('delete_past', false);
64091d05cddcSAtari911
64101d05cddcSAtari911        // Check root calendar directory first (blank/default namespace)
64111d05cddcSAtari911        $rootCalendarDir = $dataDir . 'calendar';
64121d05cddcSAtari911        $debug['root_calendar_dir'] = $rootCalendarDir;
64131d05cddcSAtari911        $debug['root_exists'] = is_dir($rootCalendarDir);
64141d05cddcSAtari911
64151d05cddcSAtari911        if (is_dir($rootCalendarDir)) {
64161d05cddcSAtari911            if (!$namespaceFilter || $namespaceFilter === '' || $namespaceFilter === 'default') {
64171d05cddcSAtari911                $debug['scanned_dirs'][] = $rootCalendarDir;
64181d05cddcSAtari911                $files = glob($rootCalendarDir . '/*.json');
64191d05cddcSAtari911                $debug['found_files'] = array_merge($debug['found_files'], $files);
64201d05cddcSAtari911                $this->processCalendarFiles($rootCalendarDir, '', $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast);
64211d05cddcSAtari911            }
64221d05cddcSAtari911        }
64231d05cddcSAtari911
64241d05cddcSAtari911        // Scan all namespace directories
64251d05cddcSAtari911        $namespaceDirs = glob($dataDir . '*', GLOB_ONLYDIR);
64261d05cddcSAtari911        $debug['namespace_dirs_found'] = $namespaceDirs;
64271d05cddcSAtari911
64281d05cddcSAtari911        foreach ($namespaceDirs as $nsDir) {
64291d05cddcSAtari911            $namespace = basename($nsDir);
64301d05cddcSAtari911
64311d05cddcSAtari911            // Skip the root 'calendar' dir (already processed above)
64321d05cddcSAtari911            if ($namespace === 'calendar') continue;
64331d05cddcSAtari911
64341d05cddcSAtari911            // Check namespace filter
64351d05cddcSAtari911            if ($namespaceFilter && strpos($namespace, $namespaceFilter) === false) {
64361d05cddcSAtari911                continue;
64371d05cddcSAtari911            }
64381d05cddcSAtari911
64391d05cddcSAtari911            $calendarDir = $nsDir . '/calendar';
64401d05cddcSAtari911            $debug['checked_calendar_dirs'][] = $calendarDir;
64411d05cddcSAtari911
64421d05cddcSAtari911            if (!is_dir($calendarDir)) {
64431d05cddcSAtari911                $debug['missing_calendar_dirs'][] = $calendarDir;
64441d05cddcSAtari911                continue;
64451d05cddcSAtari911            }
64461d05cddcSAtari911
64471d05cddcSAtari911            $debug['scanned_dirs'][] = $calendarDir;
64481d05cddcSAtari911            $files = glob($calendarDir . '/*.json');
64491d05cddcSAtari911            $debug['found_files'] = array_merge($debug['found_files'], $files);
64501d05cddcSAtari911            $this->processCalendarFiles($calendarDir, $namespace, $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast);
64511d05cddcSAtari911        }
64521d05cddcSAtari911
64531d05cddcSAtari911        // Store debug info globally for preview
64541d05cddcSAtari911        $this->_cleanupDebug = $debug;
64551d05cddcSAtari911
64561d05cddcSAtari911        return $eventsToDelete;
64571d05cddcSAtari911    }
64581d05cddcSAtari911
64591d05cddcSAtari911    private function processCalendarFiles($calendarDir, $namespace, &$eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast) {
64601d05cddcSAtari911        foreach (glob($calendarDir . '/*.json') as $file) {
64611d05cddcSAtari911            $json = file_get_contents($file);
64621d05cddcSAtari911            $data = json_decode($json, true);
64631d05cddcSAtari911
64641d05cddcSAtari911            if (!$data) continue;
64651d05cddcSAtari911
64661d05cddcSAtari911            foreach ($data as $date => $dateEvents) {
64671d05cddcSAtari911                foreach ($dateEvents as $event) {
64681d05cddcSAtari911                    $shouldDelete = false;
64691d05cddcSAtari911
64701d05cddcSAtari911                    // Age-based
64711d05cddcSAtari911                    if ($cleanupType === 'age' && $cutoffDate && $date < $cutoffDate) {
64721d05cddcSAtari911                        $shouldDelete = true;
64731d05cddcSAtari911                    }
64741d05cddcSAtari911
64751d05cddcSAtari911                    // Range-based
64761d05cddcSAtari911                    if ($cleanupType === 'range' && $rangeStart && $rangeEnd) {
64771d05cddcSAtari911                        if ($date >= $rangeStart && $date <= $rangeEnd) {
64781d05cddcSAtari911                            $shouldDelete = true;
64791d05cddcSAtari911                        }
64801d05cddcSAtari911                    }
64811d05cddcSAtari911
64821d05cddcSAtari911                    // Status-based
64831d05cddcSAtari911                    if ($cleanupType === 'status') {
64841d05cddcSAtari911                        $isTask = isset($event['isTask']) && $event['isTask'];
64851d05cddcSAtari911                        $isCompleted = isset($event['completed']) && $event['completed'];
64861d05cddcSAtari911                        $isPast = $date < date('Y-m-d');
64871d05cddcSAtari911
64881d05cddcSAtari911                        if ($deleteCompleted && $isTask && $isCompleted) {
64891d05cddcSAtari911                            $shouldDelete = true;
64901d05cddcSAtari911                        }
64911d05cddcSAtari911                        if ($deletePast && !$isTask && $isPast) {
64921d05cddcSAtari911                            $shouldDelete = true;
64931d05cddcSAtari911                        }
64941d05cddcSAtari911                    }
64951d05cddcSAtari911
64961d05cddcSAtari911                    if ($shouldDelete) {
64971d05cddcSAtari911                        $eventsToDelete[] = [
64981d05cddcSAtari911                            'id' => $event['id'],
64991d05cddcSAtari911                            'title' => $event['title'],
65001d05cddcSAtari911                            'date' => $date,
65011d05cddcSAtari911                            'namespace' => $namespace ?: 'default',
65021d05cddcSAtari911                            'file' => $file
65031d05cddcSAtari911                        ];
65041d05cddcSAtari911                    }
65051d05cddcSAtari911                }
65061d05cddcSAtari911            }
65071d05cddcSAtari911        }
65081d05cddcSAtari911    }
65099ccd446eSAtari911
65109ccd446eSAtari911    /**
65119ccd446eSAtari911     * Render Themes tab for sidebar widget theme selection
65129ccd446eSAtari911     */
65139ccd446eSAtari911    private function renderThemesTab($colors = null) {
65149ccd446eSAtari911        global $INPUT;
65159ccd446eSAtari911
65169ccd446eSAtari911        // Use defaults if not provided
65179ccd446eSAtari911        if ($colors === null) {
65189ccd446eSAtari911            $colors = $this->getTemplateColors();
65199ccd446eSAtari911        }
65209ccd446eSAtari911
65219ccd446eSAtari911        // Handle theme save
65229ccd446eSAtari911        if ($INPUT->str('action') === 'save_theme') {
65239ccd446eSAtari911            $theme = $INPUT->str('theme', 'matrix');
65249ccd446eSAtari911            $weekStart = $INPUT->str('week_start', 'monday');
6525*96df7d3eSAtari911            $itineraryCollapsed = $INPUT->str('itinerary_collapsed', 'no');
65269ccd446eSAtari911            $this->saveSidebarTheme($theme);
65279ccd446eSAtari911            $this->saveWeekStartDay($weekStart);
6528*96df7d3eSAtari911            $this->saveItineraryCollapsed($itineraryCollapsed === 'yes');
65299ccd446eSAtari911            echo '<div style="background:#d4edda; border:1px solid #c3e6cb; color:#155724; padding:12px; border-radius:4px; margin-bottom:20px;">';
65309ccd446eSAtari911            echo '✓ Theme and settings saved successfully! Refresh any page with the sidebar to see changes.';
65319ccd446eSAtari911            echo '</div>';
65329ccd446eSAtari911        }
65339ccd446eSAtari911
65349ccd446eSAtari911        $currentTheme = $this->getSidebarTheme();
65359ccd446eSAtari911        $currentWeekStart = $this->getWeekStartDay();
6536*96df7d3eSAtari911        $currentItineraryCollapsed = $this->getItineraryCollapsed();
65379ccd446eSAtari911
65389ccd446eSAtari911        echo '<h2 style="margin:0 0 20px 0; color:' . $colors['text'] . ';">�� Sidebar Widget Settings</h2>';
65399ccd446eSAtari911        echo '<p style="color:' . $colors['text'] . '; margin-bottom:20px;">Customize the appearance and behavior of the sidebar calendar widget.</p>';
65409ccd446eSAtari911
65419ccd446eSAtari911        echo '<form method="post" action="?do=admin&page=calendar&tab=themes">';
65429ccd446eSAtari911        echo '<input type="hidden" name="action" value="save_theme">';
65439ccd446eSAtari911
65449ccd446eSAtari911        // Week Start Day Section
65459ccd446eSAtari911        echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">';
65469ccd446eSAtari911        echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;">�� Week Start Day</h3>';
65479ccd446eSAtari911        echo '<p style="color:' . $colors['text'] . '; margin-bottom:15px; font-size:13px;">Choose which day the week calendar grid starts with:</p>';
65489ccd446eSAtari911
65499ccd446eSAtari911        echo '<div style="display:flex; gap:15px;">';
65509ccd446eSAtari911        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;">';
65519ccd446eSAtari911        echo '<input type="radio" name="week_start" value="monday" ' . ($currentWeekStart === 'monday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
65529ccd446eSAtari911        echo '<div>';
65539ccd446eSAtari911        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Monday</div>';
65549ccd446eSAtari911        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Monday (ISO standard)</div>';
65559ccd446eSAtari911        echo '</div>';
65569ccd446eSAtari911        echo '</label>';
65579ccd446eSAtari911
65589ccd446eSAtari911        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;">';
65599ccd446eSAtari911        echo '<input type="radio" name="week_start" value="sunday" ' . ($currentWeekStart === 'sunday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
65609ccd446eSAtari911        echo '<div>';
65619ccd446eSAtari911        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Sunday</div>';
65629ccd446eSAtari911        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Sunday (US/Canada standard)</div>';
65639ccd446eSAtari911        echo '</div>';
65649ccd446eSAtari911        echo '</label>';
65659ccd446eSAtari911        echo '</div>';
65669ccd446eSAtari911        echo '</div>';
65679ccd446eSAtari911
6568*96df7d3eSAtari911        // Itinerary Default State Section
6569*96df7d3eSAtari911        echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">';
6570*96df7d3eSAtari911        echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;">�� Itinerary Section</h3>';
6571*96df7d3eSAtari911        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>';
6572*96df7d3eSAtari911
6573*96df7d3eSAtari911        echo '<div style="display:flex; gap:15px;">';
6574*96df7d3eSAtari911        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;">';
6575*96df7d3eSAtari911        echo '<input type="radio" name="itinerary_collapsed" value="no" ' . (!$currentItineraryCollapsed ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
6576*96df7d3eSAtari911        echo '<div>';
6577*96df7d3eSAtari911        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Expanded</div>';
6578*96df7d3eSAtari911        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Show itinerary sections by default</div>';
6579*96df7d3eSAtari911        echo '</div>';
6580*96df7d3eSAtari911        echo '</label>';
6581*96df7d3eSAtari911
6582*96df7d3eSAtari911        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;">';
6583*96df7d3eSAtari911        echo '<input type="radio" name="itinerary_collapsed" value="yes" ' . ($currentItineraryCollapsed ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">';
6584*96df7d3eSAtari911        echo '<div>';
6585*96df7d3eSAtari911        echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Collapsed</div>';
6586*96df7d3eSAtari911        echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Hide itinerary sections by default (click bar to expand)</div>';
6587*96df7d3eSAtari911        echo '</div>';
6588*96df7d3eSAtari911        echo '</label>';
6589*96df7d3eSAtari911        echo '</div>';
6590*96df7d3eSAtari911        echo '</div>';
6591*96df7d3eSAtari911
65929ccd446eSAtari911        // Visual Theme Section
65939ccd446eSAtari911        echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;">�� Visual Theme</h3>';
65949ccd446eSAtari911
65959ccd446eSAtari911        // Matrix Theme
65969ccd446eSAtari911        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']) . ';">';
65979ccd446eSAtari911        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
65989ccd446eSAtari911        echo '<input type="radio" name="theme" value="matrix" ' . ($currentTheme === 'matrix' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
65999ccd446eSAtari911        echo '<div style="flex:1;">';
66009ccd446eSAtari911        echo '<div style="font-size:18px; font-weight:bold; color:#00cc07; margin-bottom:8px;">�� Matrix Edition</div>';
66019ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Dark green theme with Matrix-style glow effects and neon accents</div>';
66029ccd446eSAtari911        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>';
66039ccd446eSAtari911        echo '</div>';
66049ccd446eSAtari911        echo '</label>';
66059ccd446eSAtari911        echo '</div>';
66069ccd446eSAtari911
66079ccd446eSAtari911        // Purple Theme
66089ccd446eSAtari911        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']) . ';">';
66099ccd446eSAtari911        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
66109ccd446eSAtari911        echo '<input type="radio" name="theme" value="purple" ' . ($currentTheme === 'purple' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
66119ccd446eSAtari911        echo '<div style="flex:1;">';
66129ccd446eSAtari911        echo '<div style="font-size:18px; font-weight:bold; color:#9b59b6; margin-bottom:8px;">�� Purple Dream</div>';
66139ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Rich purple theme with elegant violet accents and soft glow</div>';
66149ccd446eSAtari911        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>';
66159ccd446eSAtari911        echo '</div>';
66169ccd446eSAtari911        echo '</label>';
66179ccd446eSAtari911        echo '</div>';
66189ccd446eSAtari911
66199ccd446eSAtari911        // Professional Blue Theme
66209ccd446eSAtari911        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']) . ';">';
66219ccd446eSAtari911        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
66229ccd446eSAtari911        echo '<input type="radio" name="theme" value="professional" ' . ($currentTheme === 'professional' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
66239ccd446eSAtari911        echo '<div style="flex:1;">';
66249ccd446eSAtari911        echo '<div style="font-size:18px; font-weight:bold; color:#4a90e2; margin-bottom:8px;">�� Professional Blue</div>';
66259ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Clean blue and grey theme with modern professional styling, no glow effects</div>';
66269ccd446eSAtari911        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>';
66279ccd446eSAtari911        echo '</div>';
66289ccd446eSAtari911        echo '</label>';
66299ccd446eSAtari911        echo '</div>';
66309ccd446eSAtari911
66319ccd446eSAtari911        // Pink Bling Theme
66329ccd446eSAtari911        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']) . ';">';
66339ccd446eSAtari911        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
66349ccd446eSAtari911        echo '<input type="radio" name="theme" value="pink" ' . ($currentTheme === 'pink' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
66359ccd446eSAtari911        echo '<div style="flex:1;">';
66369ccd446eSAtari911        echo '<div style="font-size:18px; font-weight:bold; color:#ff1493; margin-bottom:8px;">�� Pink Bling</div>';
66379ccd446eSAtari911        echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Glamorous hot pink theme with maximum sparkle, hearts, and diamonds ✨</div>';
66389ccd446eSAtari911        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>';
66399ccd446eSAtari911        echo '</div>';
66409ccd446eSAtari911        echo '</label>';
66419ccd446eSAtari911        echo '</div>';
66429ccd446eSAtari911
66439ccd446eSAtari911        // Wiki Default Theme
66449ccd446eSAtari911        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']) . ';">';
66459ccd446eSAtari911        echo '<label style="display:flex; align-items:center; cursor:pointer;">';
66469ccd446eSAtari911        echo '<input type="radio" name="theme" value="wiki" ' . ($currentTheme === 'wiki' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">';
66479ccd446eSAtari911        echo '<div style="flex:1;">';
66489ccd446eSAtari911        echo '<div style="font-size:18px; font-weight:bold; color:#2b73b7; margin-bottom:8px;">�� Wiki Default</div>';
66499ccd446eSAtari911        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>';
66509ccd446eSAtari911        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>';
66519ccd446eSAtari911        echo '</div>';
66529ccd446eSAtari911        echo '</label>';
66539ccd446eSAtari911        echo '</div>';
66549ccd446eSAtari911
66559ccd446eSAtari911        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>';
66569ccd446eSAtari911        echo '</form>';
66579ccd446eSAtari911    }
66589ccd446eSAtari911
66599ccd446eSAtari911    /**
66609ccd446eSAtari911     * Get current sidebar theme
66619ccd446eSAtari911     */
66629ccd446eSAtari911    private function getSidebarTheme() {
66639ccd446eSAtari911        $configFile = DOKU_INC . 'data/meta/calendar_theme.txt';
66649ccd446eSAtari911        if (file_exists($configFile)) {
66659ccd446eSAtari911            return trim(file_get_contents($configFile));
66669ccd446eSAtari911        }
66679ccd446eSAtari911        return 'matrix'; // Default
66689ccd446eSAtari911    }
66699ccd446eSAtari911
66709ccd446eSAtari911    /**
66719ccd446eSAtari911     * Save sidebar theme
66729ccd446eSAtari911     */
66739ccd446eSAtari911    private function saveSidebarTheme($theme) {
66749ccd446eSAtari911        $configFile = DOKU_INC . 'data/meta/calendar_theme.txt';
66759ccd446eSAtari911        $validThemes = ['matrix', 'purple', 'professional', 'pink', 'wiki'];
66769ccd446eSAtari911
66779ccd446eSAtari911        if (in_array($theme, $validThemes)) {
66789ccd446eSAtari911            file_put_contents($configFile, $theme);
66799ccd446eSAtari911            return true;
66809ccd446eSAtari911        }
66819ccd446eSAtari911        return false;
66829ccd446eSAtari911    }
66839ccd446eSAtari911
66849ccd446eSAtari911    /**
66859ccd446eSAtari911     * Get week start day
66869ccd446eSAtari911     */
66879ccd446eSAtari911    private function getWeekStartDay() {
66889ccd446eSAtari911        $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt';
66899ccd446eSAtari911        if (file_exists($configFile)) {
66909ccd446eSAtari911            $start = trim(file_get_contents($configFile));
66919ccd446eSAtari911            if (in_array($start, ['monday', 'sunday'])) {
66929ccd446eSAtari911                return $start;
66939ccd446eSAtari911            }
66949ccd446eSAtari911        }
66959ccd446eSAtari911        return 'sunday'; // Default to Sunday (US/Canada standard)
66969ccd446eSAtari911    }
66979ccd446eSAtari911
66989ccd446eSAtari911    /**
66999ccd446eSAtari911     * Save week start day
67009ccd446eSAtari911     */
67019ccd446eSAtari911    private function saveWeekStartDay($weekStart) {
67029ccd446eSAtari911        $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt';
67039ccd446eSAtari911        $validStarts = ['monday', 'sunday'];
67049ccd446eSAtari911
67059ccd446eSAtari911        if (in_array($weekStart, $validStarts)) {
67069ccd446eSAtari911            file_put_contents($configFile, $weekStart);
67079ccd446eSAtari911            return true;
67089ccd446eSAtari911        }
67099ccd446eSAtari911        return false;
67109ccd446eSAtari911    }
67119ccd446eSAtari911
67129ccd446eSAtari911    /**
6713*96df7d3eSAtari911     * Get itinerary collapsed default state
6714*96df7d3eSAtari911     */
6715*96df7d3eSAtari911    private function getItineraryCollapsed() {
6716*96df7d3eSAtari911        $configFile = DOKU_INC . 'data/meta/calendar_itinerary_collapsed.txt';
6717*96df7d3eSAtari911        if (file_exists($configFile)) {
6718*96df7d3eSAtari911            return trim(file_get_contents($configFile)) === 'yes';
6719*96df7d3eSAtari911        }
6720*96df7d3eSAtari911        return false; // Default to expanded
6721*96df7d3eSAtari911    }
6722*96df7d3eSAtari911
6723*96df7d3eSAtari911    /**
6724*96df7d3eSAtari911     * Save itinerary collapsed default state
6725*96df7d3eSAtari911     */
6726*96df7d3eSAtari911    private function saveItineraryCollapsed($collapsed) {
6727*96df7d3eSAtari911        $configFile = DOKU_INC . 'data/meta/calendar_itinerary_collapsed.txt';
6728*96df7d3eSAtari911        file_put_contents($configFile, $collapsed ? 'yes' : 'no');
6729*96df7d3eSAtari911        return true;
6730*96df7d3eSAtari911    }
6731*96df7d3eSAtari911
6732*96df7d3eSAtari911    /**
67339ccd446eSAtari911     * Get colors from DokuWiki template's style.ini file
67349ccd446eSAtari911     */
67359ccd446eSAtari911    private function getTemplateColors() {
67369ccd446eSAtari911        global $conf;
67379ccd446eSAtari911
67389ccd446eSAtari911        // Get current template name
67399ccd446eSAtari911        $template = $conf['template'];
67409ccd446eSAtari911
67419ccd446eSAtari911        // Try multiple possible locations for style.ini
67429ccd446eSAtari911        $possiblePaths = [
67439ccd446eSAtari911            DOKU_INC . 'conf/tpl/' . $template . '/style.ini',
67449ccd446eSAtari911            DOKU_INC . 'lib/tpl/' . $template . '/style.ini',
67459ccd446eSAtari911        ];
67469ccd446eSAtari911
67479ccd446eSAtari911        $styleIni = null;
67489ccd446eSAtari911        foreach ($possiblePaths as $path) {
67499ccd446eSAtari911            if (file_exists($path)) {
67509ccd446eSAtari911                $styleIni = parse_ini_file($path, true);
67519ccd446eSAtari911                break;
67529ccd446eSAtari911            }
67539ccd446eSAtari911        }
67549ccd446eSAtari911
67559ccd446eSAtari911        if (!$styleIni || !isset($styleIni['replacements'])) {
67569ccd446eSAtari911            // Return defaults
67579ccd446eSAtari911            return [
67589ccd446eSAtari911                'bg' => '#fff',
67599ccd446eSAtari911                'bg_alt' => '#e8e8e8',
67609ccd446eSAtari911                'text' => '#333',
67619ccd446eSAtari911                'border' => '#ccc',
67629ccd446eSAtari911                'link' => '#2b73b7',
67639ccd446eSAtari911            ];
67649ccd446eSAtari911        }
67659ccd446eSAtari911
67669ccd446eSAtari911        $r = $styleIni['replacements'];
67679ccd446eSAtari911
67689ccd446eSAtari911        return [
67699ccd446eSAtari911            'bg' => isset($r['__background__']) ? $r['__background__'] : '#fff',
67709ccd446eSAtari911            'bg_alt' => isset($r['__background_alt__']) ? $r['__background_alt__'] : '#e8e8e8',
67719ccd446eSAtari911            'text' => isset($r['__text__']) ? $r['__text__'] : '#333',
67729ccd446eSAtari911            'border' => isset($r['__border__']) ? $r['__border__'] : '#ccc',
67739ccd446eSAtari911            'link' => isset($r['__link__']) ? $r['__link__'] : '#2b73b7',
67749ccd446eSAtari911        ];
67759ccd446eSAtari911    }
67761d05cddcSAtari911}
6777