11d05cddcSAtari911<?php 21d05cddcSAtari911/** 31d05cddcSAtari911 * Calendar Plugin - Admin Interface 4815440faSAtari911 * 5815440faSAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 6815440faSAtari911 * @author DokuWiki Community 7*2866e827SAtari911 * @version 7.2.6 81d05cddcSAtari911 */ 91d05cddcSAtari911 101d05cddcSAtari911if(!defined('DOKU_INC')) die(); 111d05cddcSAtari911 12815440faSAtari911// Load class dependencies 13815440faSAtari911require_once __DIR__ . '/classes/FileHandler.php'; 14815440faSAtari911require_once __DIR__ . '/classes/EventCache.php'; 15815440faSAtari911require_once __DIR__ . '/classes/RateLimiter.php'; 16815440faSAtari911require_once __DIR__ . '/classes/EventManager.php'; 17815440faSAtari911 181d05cddcSAtari911class admin_plugin_calendar extends DokuWiki_Admin_Plugin { 191d05cddcSAtari911 2096df7d3eSAtari911 /** 21*2866e827SAtari911 * Get the meta directory path (farm-safe) 22*2866e827SAtari911 * Uses $conf['metadir'] instead of hardcoded DOKU_INC . 'data/meta/' 23*2866e827SAtari911 */ 24*2866e827SAtari911 private function metaDir() { 25*2866e827SAtari911 global $conf; 26*2866e827SAtari911 return rtrim($conf['metadir'], '/') . '/'; 27*2866e827SAtari911 } 28*2866e827SAtari911 29*2866e827SAtari911 /** 30*2866e827SAtari911 * Get sync config file path (farm-safe) 31*2866e827SAtari911 * Checks per-animal metadir first, falls back to shared plugin dir 32*2866e827SAtari911 */ 33*2866e827SAtari911 private function syncConfigPath() { 34*2866e827SAtari911 $perAnimal = $this->metaDir() . 'calendar/sync_config.php'; 35*2866e827SAtari911 if (file_exists($perAnimal)) return $perAnimal; 36*2866e827SAtari911 return DOKU_PLUGIN . 'calendar/sync_config.php'; 37*2866e827SAtari911 } 38*2866e827SAtari911 39*2866e827SAtari911 /** 4096df7d3eSAtari911 * Get the path to the sync log file (in data directory, not plugin directory) 4196df7d3eSAtari911 */ 4296df7d3eSAtari911 private function getSyncLogPath() { 43*2866e827SAtari911 $dataDir = $this->metaDir() . 'calendar/'; 4496df7d3eSAtari911 if (!is_dir($dataDir)) { 4596df7d3eSAtari911 @mkdir($dataDir, 0755, true); 4696df7d3eSAtari911 } 4796df7d3eSAtari911 return $dataDir . 'sync.log'; 4896df7d3eSAtari911 } 4996df7d3eSAtari911 5096df7d3eSAtari911 /** 5196df7d3eSAtari911 * Get the path to the sync state file (in data directory, not plugin directory) 5296df7d3eSAtari911 */ 5396df7d3eSAtari911 private function getSyncStatePath() { 54*2866e827SAtari911 $dataDir = $this->metaDir() . 'calendar/'; 5596df7d3eSAtari911 if (!is_dir($dataDir)) { 5696df7d3eSAtari911 mkdir($dataDir, 0755, true); 5796df7d3eSAtari911 } 5896df7d3eSAtari911 return $dataDir . 'sync_state.json'; 5996df7d3eSAtari911 } 6096df7d3eSAtari911 611d05cddcSAtari911 public function getMenuText($language) { 6222228b0eSAtari911 return $this->getLang('menu'); 631d05cddcSAtari911 } 641d05cddcSAtari911 651d05cddcSAtari911 public function getMenuSort() { 661d05cddcSAtari911 return 100; 671d05cddcSAtari911 } 681d05cddcSAtari911 69da206178SAtari911 /** 70da206178SAtari911 * Return the path to the icon for the admin menu 71da206178SAtari911 * @return string path to SVG icon 72da206178SAtari911 */ 73da206178SAtari911 public function getMenuIcon() { 74da206178SAtari911 return DOKU_PLUGIN . 'calendar/images/icon.svg'; 75da206178SAtari911 } 76da206178SAtari911 771d05cddcSAtari911 public function forAdminOnly() { 781d05cddcSAtari911 return true; 791d05cddcSAtari911 } 801d05cddcSAtari911 817e8ea635SAtari911 /** 827e8ea635SAtari911 * Public entry point for AJAX actions routed from action.php 837e8ea635SAtari911 */ 847e8ea635SAtari911 public function handleAjaxAction($action) { 857e8ea635SAtari911 // Verify admin privileges for all admin AJAX actions 867e8ea635SAtari911 if (!auth_isadmin()) { 87da206178SAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 887e8ea635SAtari911 return; 897e8ea635SAtari911 } 907e8ea635SAtari911 917e8ea635SAtari911 switch ($action) { 927e8ea635SAtari911 case 'cleanup_empty_namespaces': $this->handleCleanupEmptyNamespaces(); break; 937e8ea635SAtari911 case 'trim_all_past_recurring': $this->handleTrimAllPastRecurring(); break; 947e8ea635SAtari911 case 'rescan_recurring': $this->handleRescanRecurring(); break; 957e8ea635SAtari911 case 'extend_recurring': $this->handleExtendRecurring(); break; 967e8ea635SAtari911 case 'trim_recurring': $this->handleTrimRecurring(); break; 977e8ea635SAtari911 case 'pause_recurring': $this->handlePauseRecurring(); break; 987e8ea635SAtari911 case 'resume_recurring': $this->handleResumeRecurring(); break; 997e8ea635SAtari911 case 'change_start_recurring': $this->handleChangeStartRecurring(); break; 1007e8ea635SAtari911 case 'change_pattern_recurring': $this->handleChangePatternRecurring(); break; 1017e8ea635SAtari911 default: 102da206178SAtari911 echo json_encode(['success' => false, 'error' => 'Unknown admin action']); 1037e8ea635SAtari911 } 1047e8ea635SAtari911 } 1057e8ea635SAtari911 1061d05cddcSAtari911 public function handle() { 1071d05cddcSAtari911 global $INPUT; 1081d05cddcSAtari911 1091d05cddcSAtari911 $action = $INPUT->str('action'); 1101d05cddcSAtari911 111*2866e827SAtari911 // CSRF protection: all POST actions require a valid security token 112*2866e827SAtari911 if ($action && !checkSecurityToken()) { 113*2866e827SAtari911 msg('Security token expired. Please try again.', -1); 114*2866e827SAtari911 return; 115*2866e827SAtari911 } 116*2866e827SAtari911 1171d05cddcSAtari911 if ($action === 'clear_cache') { 1181d05cddcSAtari911 $this->clearCache(); 1191d05cddcSAtari911 } elseif ($action === 'save_config') { 1201d05cddcSAtari911 $this->saveConfig(); 1211d05cddcSAtari911 } elseif ($action === 'delete_recurring_series') { 1221d05cddcSAtari911 $this->deleteRecurringSeries(); 1231d05cddcSAtari911 } elseif ($action === 'edit_recurring_series') { 1241d05cddcSAtari911 $this->editRecurringSeries(); 1251d05cddcSAtari911 } elseif ($action === 'move_selected_events') { 1261d05cddcSAtari911 $this->moveEvents(); 1271d05cddcSAtari911 } elseif ($action === 'move_single_event') { 1281d05cddcSAtari911 $this->moveSingleEvent(); 1291d05cddcSAtari911 } elseif ($action === 'delete_selected_events') { 1301d05cddcSAtari911 $this->deleteSelectedEvents(); 1311d05cddcSAtari911 } elseif ($action === 'create_namespace') { 1321d05cddcSAtari911 $this->createNamespace(); 1331d05cddcSAtari911 } elseif ($action === 'delete_namespace') { 1341d05cddcSAtari911 $this->deleteNamespace(); 1359ccd446eSAtari911 } elseif ($action === 'rename_namespace') { 1369ccd446eSAtari911 $this->renameNamespace(); 1371d05cddcSAtari911 } elseif ($action === 'run_sync') { 1381d05cddcSAtari911 $this->runSync(); 1391d05cddcSAtari911 } elseif ($action === 'stop_sync') { 1401d05cddcSAtari911 $this->stopSync(); 1411d05cddcSAtari911 } elseif ($action === 'upload_update') { 1421d05cddcSAtari911 $this->uploadUpdate(); 1431d05cddcSAtari911 } elseif ($action === 'delete_backup') { 1441d05cddcSAtari911 $this->deleteBackup(); 1451d05cddcSAtari911 } elseif ($action === 'rename_backup') { 1461d05cddcSAtari911 $this->renameBackup(); 1471d05cddcSAtari911 } elseif ($action === 'restore_backup') { 1481d05cddcSAtari911 $this->restoreBackup(); 1499ccd446eSAtari911 } elseif ($action === 'create_manual_backup') { 1509ccd446eSAtari911 $this->createManualBackup(); 1511d05cddcSAtari911 } elseif ($action === 'export_config') { 1521d05cddcSAtari911 $this->exportConfig(); 1531d05cddcSAtari911 } elseif ($action === 'import_config') { 1541d05cddcSAtari911 $this->importConfig(); 1551d05cddcSAtari911 } elseif ($action === 'get_log') { 1561d05cddcSAtari911 $this->getLog(); 1577e8ea635SAtari911 } elseif ($action === 'cleanup_empty_namespaces') { 1587e8ea635SAtari911 $this->handleCleanupEmptyNamespaces(); 1597e8ea635SAtari911 } elseif ($action === 'trim_all_past_recurring') { 1607e8ea635SAtari911 $this->handleTrimAllPastRecurring(); 1617e8ea635SAtari911 } elseif ($action === 'rescan_recurring') { 1627e8ea635SAtari911 $this->handleRescanRecurring(); 1637e8ea635SAtari911 } elseif ($action === 'extend_recurring') { 1647e8ea635SAtari911 $this->handleExtendRecurring(); 1657e8ea635SAtari911 } elseif ($action === 'trim_recurring') { 1667e8ea635SAtari911 $this->handleTrimRecurring(); 1677e8ea635SAtari911 } elseif ($action === 'pause_recurring') { 1687e8ea635SAtari911 $this->handlePauseRecurring(); 1697e8ea635SAtari911 } elseif ($action === 'resume_recurring') { 1707e8ea635SAtari911 $this->handleResumeRecurring(); 1717e8ea635SAtari911 } elseif ($action === 'change_start_recurring') { 1727e8ea635SAtari911 $this->handleChangeStartRecurring(); 1737e8ea635SAtari911 } elseif ($action === 'change_pattern_recurring') { 1747e8ea635SAtari911 $this->handleChangePatternRecurring(); 1751d05cddcSAtari911 } elseif ($action === 'clear_log') { 1761d05cddcSAtari911 $this->clearLogFile(); 1771d05cddcSAtari911 } elseif ($action === 'download_log') { 1781d05cddcSAtari911 $this->downloadLog(); 1791d05cddcSAtari911 } elseif ($action === 'rescan_events') { 1801d05cddcSAtari911 $this->rescanEvents(); 1811d05cddcSAtari911 } elseif ($action === 'export_all_events') { 1821d05cddcSAtari911 $this->exportAllEvents(); 1831d05cddcSAtari911 } elseif ($action === 'import_all_events') { 1841d05cddcSAtari911 $this->importAllEvents(); 1851d05cddcSAtari911 } elseif ($action === 'preview_cleanup') { 1861d05cddcSAtari911 $this->previewCleanup(); 1871d05cddcSAtari911 } elseif ($action === 'cleanup_events') { 1881d05cddcSAtari911 $this->cleanupEvents(); 1894590242dSAtari911 } elseif ($action === 'save_important_namespaces') { 1904590242dSAtari911 $this->saveImportantNamespaces(); 1911d05cddcSAtari911 } 1921d05cddcSAtari911 } 1931d05cddcSAtari911 1941d05cddcSAtari911 public function html() { 1951d05cddcSAtari911 global $INPUT; 1961d05cddcSAtari911 1979ccd446eSAtari911 // Get current tab - default to 'manage' (Manage Events tab) 1989ccd446eSAtari911 $tab = $INPUT->str('tab', 'manage'); 1991d05cddcSAtari911 2009ccd446eSAtari911 // Get template colors 2019ccd446eSAtari911 $colors = $this->getTemplateColors(); 2029ccd446eSAtari911 $accentColor = '#00cc07'; // Keep calendar plugin accent color 2039ccd446eSAtari911 204815440faSAtari911 // Tab navigation (Manage Events, Update Plugin, Outlook Sync, Google Sync, Themes) 2059ccd446eSAtari911 echo '<div style="border-bottom:2px solid ' . $colors['border'] . '; margin:10px 0 15px 0;">'; 20622228b0eSAtari911 echo '<a href="?do=admin&page=calendar&tab=manage" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'manage' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'manage' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'manage' ? 'bold' : 'normal') . ';"> ' . $this->getLang('tab_manage') . '</a>'; 20722228b0eSAtari911 echo '<a href="?do=admin&page=calendar&tab=update" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'update' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'update' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'update' ? 'bold' : 'normal') . ';"> ' . $this->getLang('tab_update') . '</a>'; 208815440faSAtari911 echo '<a href="?do=admin&page=calendar&tab=config" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'config' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'config' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'config' ? 'bold' : 'normal') . ';"> Outlook</a>'; 209815440faSAtari911 echo '<a href="?do=admin&page=calendar&tab=google" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'google' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'google' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'google' ? 'bold' : 'normal') . ';"> Google</a>'; 21022228b0eSAtari911 echo '<a href="?do=admin&page=calendar&tab=themes" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'themes' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'themes' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'themes' ? 'bold' : 'normal') . ';"> ' . $this->getLang('tab_themes') . '</a>'; 2111d05cddcSAtari911 echo '</div>'; 2121d05cddcSAtari911 2131d05cddcSAtari911 // Render appropriate tab 2141d05cddcSAtari911 if ($tab === 'config') { 2159ccd446eSAtari911 $this->renderConfigTab($colors); 2161d05cddcSAtari911 } elseif ($tab === 'manage') { 2179ccd446eSAtari911 $this->renderManageTab($colors); 2189ccd446eSAtari911 } elseif ($tab === 'themes') { 2199ccd446eSAtari911 $this->renderThemesTab($colors); 220815440faSAtari911 } elseif ($tab === 'google') { 221815440faSAtari911 $this->renderGoogleSyncTab($colors); 2221d05cddcSAtari911 } else { 2239ccd446eSAtari911 $this->renderUpdateTab($colors); 2241d05cddcSAtari911 } 2251d05cddcSAtari911 } 2261d05cddcSAtari911 2279ccd446eSAtari911 private function renderConfigTab($colors = null) { 2281d05cddcSAtari911 global $INPUT; 2291d05cddcSAtari911 2309ccd446eSAtari911 // Use defaults if not provided 2319ccd446eSAtari911 if ($colors === null) { 2329ccd446eSAtari911 $colors = $this->getTemplateColors(); 2339ccd446eSAtari911 } 2349ccd446eSAtari911 2351d05cddcSAtari911 // Load current config 236*2866e827SAtari911 $configFile = $this->syncConfigPath(); 2371d05cddcSAtari911 $config = []; 2381d05cddcSAtari911 if (file_exists($configFile)) { 2391d05cddcSAtari911 $config = include $configFile; 2401d05cddcSAtari911 } 2411d05cddcSAtari911 2421d05cddcSAtari911 // Show message if present 2431d05cddcSAtari911 if ($INPUT->has('msg')) { 2441d05cddcSAtari911 $msg = hsc($INPUT->str('msg')); 2451d05cddcSAtari911 $type = $INPUT->str('msgtype', 'success'); 2461d05cddcSAtari911 $class = ($type === 'success') ? 'msg success' : 'msg error'; 2471d05cddcSAtari911 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;\">"; 2481d05cddcSAtari911 echo $msg; 2491d05cddcSAtari911 echo "</div>"; 2501d05cddcSAtari911 } 2511d05cddcSAtari911 252da206178SAtari911 echo '<h2 style="margin:10px 0; font-size:20px;">Outlook Sync Configuration</h2>'; 2531d05cddcSAtari911 2541d05cddcSAtari911 // Import/Export buttons 2551d05cddcSAtari911 echo '<div style="display:flex; gap:10px; margin-bottom:15px;">'; 256da206178SAtari911 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>'; 257da206178SAtari911 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>'; 2581d05cddcSAtari911 echo '<input type="file" id="importFileInput" accept=".enc" style="display:none;" onchange="importConfig(this)">'; 2591d05cddcSAtari911 echo '<span id="importStatus" style="margin-left:10px; font-size:12px;"></span>'; 2601d05cddcSAtari911 echo '</div>'; 2611d05cddcSAtari911 2621d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar" style="max-width:900px;">'; 263*2866e827SAtari911 echo formSecurityToken(false); 2641d05cddcSAtari911 echo '<input type="hidden" name="action" value="save_config">'; 2651d05cddcSAtari911 2661d05cddcSAtari911 // Azure Credentials 2679ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">'; 268da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Microsoft Azure App Credentials</h3>'; 269da206178SAtari911 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>'; 2701d05cddcSAtari911 271da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Tenant ID</label>'; 2729ccd446eSAtari911 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;">'; 2731d05cddcSAtari911 274da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client ID (Application ID)</label>'; 27596df7d3eSAtari911 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;">'; 2761d05cddcSAtari911 277da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client Secret</label>'; 278da206178SAtari911 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;">'; 279da206178SAtari911 echo '<p style="color:#999; font-size:0.8em; margin:3px 0 0;">⚠️ Keep this secret safe!</p>'; 2801d05cddcSAtari911 echo '</div>'; 2811d05cddcSAtari911 2821d05cddcSAtari911 // Outlook Settings 2839ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">'; 284da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Outlook Settings</h3>'; 2851d05cddcSAtari911 2861d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">'; 2871d05cddcSAtari911 2881d05cddcSAtari911 echo '<div>'; 289da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">User Email</label>'; 29096df7d3eSAtari911 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;">'; 2911d05cddcSAtari911 echo '</div>'; 2921d05cddcSAtari911 2931d05cddcSAtari911 echo '<div>'; 294da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Timezone</label>'; 2959ccd446eSAtari911 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;">'; 2961d05cddcSAtari911 echo '</div>'; 2971d05cddcSAtari911 2981d05cddcSAtari911 echo '<div>'; 299da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Default Category</label>'; 3009ccd446eSAtari911 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;">'; 3011d05cddcSAtari911 echo '</div>'; 3021d05cddcSAtari911 3031d05cddcSAtari911 echo '<div>'; 304da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Reminder (minutes)</label>'; 3059ccd446eSAtari911 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;">'; 3061d05cddcSAtari911 echo '</div>'; 3071d05cddcSAtari911 3081d05cddcSAtari911 echo '</div>'; // end grid 3091d05cddcSAtari911 echo '</div>'; 3101d05cddcSAtari911 3111d05cddcSAtari911 // Sync Options 3129ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">'; 313da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Sync Options</h3>'; 3141d05cddcSAtari911 3151d05cddcSAtari911 $syncCompleted = isset($config['sync_completed_tasks']) ? $config['sync_completed_tasks'] : false; 316da206178SAtari911 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>'; 3171d05cddcSAtari911 3181d05cddcSAtari911 $deleteOutlook = isset($config['delete_outlook_events']) ? $config['delete_outlook_events'] : true; 319da206178SAtari911 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>'; 3201d05cddcSAtari911 3211d05cddcSAtari911 $syncAll = isset($config['sync_all_namespaces']) ? $config['sync_all_namespaces'] : true; 322da206178SAtari911 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>'; 3231d05cddcSAtari911 3241d05cddcSAtari911 // Namespace selection (shown when sync_all is unchecked) 3251d05cddcSAtari911 echo '<div id="namespace_selection" style="margin-top:10px; ' . ($syncAll ? 'display:none;' : '') . '">'; 326da206178SAtari911 echo '<label style="display:block; font-weight:bold; margin-bottom:5px; font-size:13px;">Select namespaces to sync:</label>'; 3271d05cddcSAtari911 3281d05cddcSAtari911 // Get available namespaces 3291d05cddcSAtari911 $availableNamespaces = $this->getAllNamespaces(); 3301d05cddcSAtari911 $selectedNamespaces = isset($config['sync_namespaces']) ? $config['sync_namespaces'] : []; 3311d05cddcSAtari911 3329ccd446eSAtari911 echo '<div style="max-height:150px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; padding:8px; background:' . $colors['bg'] . ';">'; 3331d05cddcSAtari911 echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value=""> (default)</label>'; 3341d05cddcSAtari911 foreach ($availableNamespaces as $ns) { 3351d05cddcSAtari911 if ($ns !== '') { 3361d05cddcSAtari911 $checked = in_array($ns, $selectedNamespaces) ? 'checked' : ''; 3371d05cddcSAtari911 echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value="' . hsc($ns) . '" ' . $checked . '> ' . hsc($ns) . '</label>'; 3381d05cddcSAtari911 } 3391d05cddcSAtari911 } 3401d05cddcSAtari911 echo '</div>'; 3411d05cddcSAtari911 echo '</div>'; 3421d05cddcSAtari911 3431d05cddcSAtari911 echo '<script> 3441d05cddcSAtari911 function toggleNamespaceSelection(checkbox) { 3451d05cddcSAtari911 document.getElementById("namespace_selection").style.display = checkbox.checked ? "none" : "block"; 3461d05cddcSAtari911 } 3471d05cddcSAtari911 </script>'; 3481d05cddcSAtari911 3491d05cddcSAtari911 echo '</div>'; 3501d05cddcSAtari911 3511d05cddcSAtari911 // Namespace and Color Mapping - Side by Side 3521d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:10px 0;">'; 3531d05cddcSAtari911 3541d05cddcSAtari911 // Namespace Mapping 3559ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">'; 356da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Namespace → Category</h3>'; 357da206178SAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 5px;">One per line: namespace=Category</p>'; 3589ccd446eSAtari911 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 personal=Green category">'; 3591d05cddcSAtari911 if (isset($config['category_mapping']) && is_array($config['category_mapping'])) { 3601d05cddcSAtari911 foreach ($config['category_mapping'] as $ns => $cat) { 3611d05cddcSAtari911 echo hsc($ns) . '=' . hsc($cat) . "\n"; 3621d05cddcSAtari911 } 3631d05cddcSAtari911 } 3641d05cddcSAtari911 echo '</textarea>'; 3651d05cddcSAtari911 echo '</div>'; 3661d05cddcSAtari911 3671d05cddcSAtari911 // Color Mapping with Color Picker 3689ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">'; 369da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Event Color → Category</h3>'; 370da206178SAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 8px;">Map calendar colors to Outlook categories</p>'; 3711d05cddcSAtari911 3721d05cddcSAtari911 // Define calendar colors and Outlook categories (only the main 6 colors) 3731d05cddcSAtari911 $calendarColors = [ 374da206178SAtari911 '#3498db' => 'Blue', 375da206178SAtari911 '#2ecc71' => 'Green', 376da206178SAtari911 '#e74c3c' => 'Red', 377da206178SAtari911 '#f39c12' => 'Orange', 378da206178SAtari911 '#9b59b6' => 'Purple', 379da206178SAtari911 '#1abc9c' => 'Teal' 3801d05cddcSAtari911 ]; 3811d05cddcSAtari911 3821d05cddcSAtari911 $outlookCategories = [ 3831d05cddcSAtari911 'Blue category', 3841d05cddcSAtari911 'Green category', 3851d05cddcSAtari911 'Orange category', 3861d05cddcSAtari911 'Red category', 3871d05cddcSAtari911 'Yellow category', 3881d05cddcSAtari911 'Purple category' 3891d05cddcSAtari911 ]; 3901d05cddcSAtari911 3911d05cddcSAtari911 // Load existing color mappings 3921d05cddcSAtari911 $existingMappings = isset($config['color_mapping']) && is_array($config['color_mapping']) 3931d05cddcSAtari911 ? $config['color_mapping'] 3941d05cddcSAtari911 : []; 3951d05cddcSAtari911 3961d05cddcSAtari911 // Display color mapping rows 3971d05cddcSAtari911 echo '<div id="colorMappings" style="max-height:200px; overflow-y:auto;">'; 3981d05cddcSAtari911 3991d05cddcSAtari911 $rowIndex = 0; 4001d05cddcSAtari911 foreach ($calendarColors as $hexColor => $colorName) { 4011d05cddcSAtari911 $selectedCategory = isset($existingMappings[$hexColor]) ? $existingMappings[$hexColor] : ''; 4021d05cddcSAtari911 4031d05cddcSAtari911 echo '<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px;">'; 4041d05cddcSAtari911 4051d05cddcSAtari911 // Color preview box 4061d05cddcSAtari911 echo '<div style="width:24px; height:24px; background:' . $hexColor . '; border:2px solid #ddd; border-radius:3px; flex-shrink:0;"></div>'; 4071d05cddcSAtari911 4081d05cddcSAtari911 // Color name 4099ccd446eSAtari911 echo '<span style="font-size:12px; min-width:90px; color:' . $colors['text'] . ';">' . $colorName . '</span>'; 4101d05cddcSAtari911 4111d05cddcSAtari911 // Arrow 4121d05cddcSAtari911 echo '<span style="color:#999; font-size:12px;">→</span>'; 4131d05cddcSAtari911 4141d05cddcSAtari911 // Outlook category dropdown 4159ccd446eSAtari911 echo '<select name="color_map_' . $rowIndex . '" style="flex:1; padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">'; 416da206178SAtari911 echo '<option value="">-- None --</option>'; 4171d05cddcSAtari911 foreach ($outlookCategories as $category) { 4181d05cddcSAtari911 $selected = ($selectedCategory === $category) ? 'selected' : ''; 4191d05cddcSAtari911 echo '<option value="' . hsc($category) . '" ' . $selected . '>' . hsc($category) . '</option>'; 4201d05cddcSAtari911 } 4211d05cddcSAtari911 echo '</select>'; 4221d05cddcSAtari911 4231d05cddcSAtari911 // Hidden input for the hex color 4241d05cddcSAtari911 echo '<input type="hidden" name="color_hex_' . $rowIndex . '" value="' . $hexColor . '">'; 4251d05cddcSAtari911 4261d05cddcSAtari911 echo '</div>'; 4271d05cddcSAtari911 $rowIndex++; 4281d05cddcSAtari911 } 4291d05cddcSAtari911 4301d05cddcSAtari911 echo '</div>'; 4311d05cddcSAtari911 4321d05cddcSAtari911 // Hidden input to track number of color mappings 4331d05cddcSAtari911 echo '<input type="hidden" name="color_mapping_count" value="' . $rowIndex . '">'; 4341d05cddcSAtari911 4351d05cddcSAtari911 echo '</div>'; 4361d05cddcSAtari911 4371d05cddcSAtari911 echo '</div>'; // end grid 4381d05cddcSAtari911 4391d05cddcSAtari911 // Submit button 440da206178SAtari911 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>'; 4411d05cddcSAtari911 echo '</form>'; 4421d05cddcSAtari911 443da206178SAtari911 // JavaScript for Import/Export 4441d05cddcSAtari911 echo '<script> 4451d05cddcSAtari911 async function exportConfig() { 4461d05cddcSAtari911 try { 447*2866e827SAtari911 const response = await fetch("?do=admin&page=calendar&action=export_config&call=ajax§ok=" + JSINFO.sectok, { 4481d05cddcSAtari911 method: "POST" 4491d05cddcSAtari911 }); 4501d05cddcSAtari911 const data = await response.json(); 4511d05cddcSAtari911 4521d05cddcSAtari911 if (data.success) { 4531d05cddcSAtari911 // Create download link 4541d05cddcSAtari911 const blob = new Blob([data.encrypted], {type: "application/octet-stream"}); 4551d05cddcSAtari911 const url = URL.createObjectURL(blob); 4561d05cddcSAtari911 const a = document.createElement("a"); 4571d05cddcSAtari911 a.href = url; 4581d05cddcSAtari911 a.download = "sync_config_" + new Date().toISOString().split("T")[0] + ".enc"; 4591d05cddcSAtari911 document.body.appendChild(a); 4601d05cddcSAtari911 a.click(); 4611d05cddcSAtari911 document.body.removeChild(a); 4621d05cddcSAtari911 URL.revokeObjectURL(url); 4631d05cddcSAtari911 464da206178SAtari911 alert("✅ Config exported successfully!\\n\\n⚠️ This file contains encrypted credentials.\\nKeep it secure!"); 4651d05cddcSAtari911 } else { 466da206178SAtari911 alert("❌ Export failed: " + data.message); 4671d05cddcSAtari911 } 4681d05cddcSAtari911 } catch (error) { 469da206178SAtari911 alert("❌ Error: " + error.message); 4701d05cddcSAtari911 } 4711d05cddcSAtari911 } 4721d05cddcSAtari911 4731d05cddcSAtari911 async function importConfig(input) { 4741d05cddcSAtari911 const file = input.files[0]; 4751d05cddcSAtari911 if (!file) return; 4761d05cddcSAtari911 4771d05cddcSAtari911 const status = document.getElementById("importStatus"); 478da206178SAtari911 status.textContent = "⏳ Importing..."; 4791d05cddcSAtari911 status.style.color = "#00cc07"; 4801d05cddcSAtari911 4811d05cddcSAtari911 try { 4821d05cddcSAtari911 const encrypted = await file.text(); 4831d05cddcSAtari911 4841d05cddcSAtari911 const formData = new FormData(); 4851d05cddcSAtari911 formData.append("encrypted_config", encrypted); 4861d05cddcSAtari911 487*2866e827SAtari911 const response = await fetch("?do=admin&page=calendar&action=import_config&call=ajax§ok=" + JSINFO.sectok, { 4881d05cddcSAtari911 method: "POST", 4891d05cddcSAtari911 body: formData 4901d05cddcSAtari911 }); 4911d05cddcSAtari911 const data = await response.json(); 4921d05cddcSAtari911 4931d05cddcSAtari911 if (data.success) { 494da206178SAtari911 status.textContent = "✅ Import successful! Reloading..."; 4951d05cddcSAtari911 status.style.color = "#28a745"; 4961d05cddcSAtari911 setTimeout(() => { 4971d05cddcSAtari911 window.location.reload(); 4981d05cddcSAtari911 }, 1500); 4991d05cddcSAtari911 } else { 500da206178SAtari911 status.textContent = "❌ Import failed: " + data.message; 5011d05cddcSAtari911 status.style.color = "#dc3545"; 5021d05cddcSAtari911 } 5031d05cddcSAtari911 } catch (error) { 5041d05cddcSAtari911 status.textContent = "❌ Error: " + error.message; 5051d05cddcSAtari911 status.style.color = "#dc3545"; 5061d05cddcSAtari911 } 5071d05cddcSAtari911 5081d05cddcSAtari911 // Reset file input 5091d05cddcSAtari911 input.value = ""; 5101d05cddcSAtari911 } 5111d05cddcSAtari911 </script>'; 5121d05cddcSAtari911 5131d05cddcSAtari911 // Sync Controls Section 5149ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">'; 515da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Sync Controls</h3>'; 5161d05cddcSAtari911 5171d05cddcSAtari911 // Check cron job status 5181d05cddcSAtari911 $cronStatus = $this->getCronStatus(); 5191d05cddcSAtari911 5201d05cddcSAtari911 // Check log file permissions 52196df7d3eSAtari911 $logFile = $this->getSyncLogPath(); 5221d05cddcSAtari911 $logWritable = is_writable($logFile) || is_writable(dirname($logFile)); 5231d05cddcSAtari911 5241d05cddcSAtari911 echo '<div style="display:flex; gap:10px; align-items:center; margin-bottom:10px;">'; 525da206178SAtari911 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>'; 526da206178SAtari911 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>'; 5271d05cddcSAtari911 5281d05cddcSAtari911 if ($cronStatus['active']) { 5299ccd446eSAtari911 echo '<span style="color:' . $colors['text'] . '; font-size:12px;">⏰ ' . hsc($cronStatus['frequency']) . '</span>'; 5301d05cddcSAtari911 } else { 531da206178SAtari911 echo '<span style="color:#999; font-size:12px;">⚠️ No cron job detected</span>'; 5321d05cddcSAtari911 } 5331d05cddcSAtari911 5349ccd446eSAtari911 echo '<span id="syncStatus" style="color:' . $colors['text'] . '; font-size:12px; margin-left:auto;"></span>'; 5351d05cddcSAtari911 echo '</div>'; 5361d05cddcSAtari911 5371d05cddcSAtari911 // Show permission warning if log not writable 5381d05cddcSAtari911 if (!$logWritable) { 5391d05cddcSAtari911 echo '<div style="background:#fff3e0; border-left:3px solid #ff9800; padding:8px; margin:8px 0; border-radius:3px;">'; 540da206178SAtari911 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>'; 5411d05cddcSAtari911 echo '</div>'; 5421d05cddcSAtari911 } 5431d05cddcSAtari911 5441d05cddcSAtari911 // Show debug info if cron detected 5451d05cddcSAtari911 if ($cronStatus['active'] && !empty($cronStatus['full_line'])) { 54696df7d3eSAtari911 // Check if crontab has >> redirect which will cause duplicate log entries 54796df7d3eSAtari911 $hasRedirect = (strpos($cronStatus['full_line'], '>>') !== false || strpos($cronStatus['full_line'], '> ') !== false); 54896df7d3eSAtari911 54996df7d3eSAtari911 if ($hasRedirect) { 55096df7d3eSAtari911 echo '<div style="background:#fff3e0; border-left:3px solid #ff9800; padding:8px; margin:8px 0; border-radius:3px;">'; 551da206178SAtari911 echo '<span style="color:#e65100; font-size:11px;">⚠️ <strong>Duplicate log entries:</strong> Your crontab has a <code>>></code> redirect. The sync script logs internally, so this causes duplicate entries. Remove the redirect from your crontab.</span>'; 55296df7d3eSAtari911 echo '</div>'; 55396df7d3eSAtari911 } 55496df7d3eSAtari911 5551d05cddcSAtari911 echo '<details style="margin-top:5px;">'; 556da206178SAtari911 echo '<summary style="cursor:pointer; color:#999; font-size:11px;">Show cron details</summary>'; 5571d05cddcSAtari911 echo '<pre style="background:#f0f0f0; padding:8px; border-radius:3px; font-size:10px; margin:5px 0; overflow-x:auto;">' . hsc($cronStatus['full_line']) . '</pre>'; 5581d05cddcSAtari911 echo '</details>'; 5591d05cddcSAtari911 } 5601d05cddcSAtari911 5611d05cddcSAtari911 if (!$cronStatus['active']) { 562da206178SAtari911 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>'; 563da206178SAtari911 echo '<p style="color:#888; font-size:10px; margin:3px 0;"><em>Note: The script logs to ' . $logFile . ' automatically. Do not use >> redirect.</em></p>'; 5641d05cddcSAtari911 } 5651d05cddcSAtari911 5661d05cddcSAtari911 echo '</div>'; 5671d05cddcSAtari911 568da206178SAtari911 // JavaScript for Run Sync Now 5691d05cddcSAtari911 echo '<script> 5701d05cddcSAtari911 let syncAbortController = null; 5711d05cddcSAtari911 5721d05cddcSAtari911 function runSyncNow() { 5731d05cddcSAtari911 const btn = document.getElementById("syncBtn"); 5741d05cddcSAtari911 const stopBtn = document.getElementById("stopBtn"); 5751d05cddcSAtari911 const status = document.getElementById("syncStatus"); 5761d05cddcSAtari911 5771d05cddcSAtari911 btn.disabled = true; 5781d05cddcSAtari911 btn.style.display = "none"; 5791d05cddcSAtari911 stopBtn.style.display = "inline-block"; 580da206178SAtari911 btn.textContent = "⏳ Running..."; 5811d05cddcSAtari911 btn.style.background = "#999"; 582da206178SAtari911 status.textContent = "Starting sync..."; 5831d05cddcSAtari911 status.style.color = "#00cc07"; 5841d05cddcSAtari911 5851d05cddcSAtari911 // Create abort controller for this sync 5861d05cddcSAtari911 syncAbortController = new AbortController(); 5871d05cddcSAtari911 588*2866e827SAtari911 fetch("?do=admin&page=calendar&action=run_sync&call=ajax§ok=" + JSINFO.sectok, { 5891d05cddcSAtari911 method: "POST", 5901d05cddcSAtari911 signal: syncAbortController.signal 5911d05cddcSAtari911 }) 5921d05cddcSAtari911 .then(response => response.json()) 5931d05cddcSAtari911 .then(data => { 5941d05cddcSAtari911 if (data.success) { 5951d05cddcSAtari911 status.textContent = "✅ " + data.message; 5961d05cddcSAtari911 status.style.color = "#28a745"; 5971d05cddcSAtari911 } else { 5981d05cddcSAtari911 status.textContent = "❌ " + data.message; 5991d05cddcSAtari911 status.style.color = "#dc3545"; 6001d05cddcSAtari911 } 6011d05cddcSAtari911 btn.disabled = false; 6021d05cddcSAtari911 btn.style.display = "inline-block"; 6031d05cddcSAtari911 stopBtn.style.display = "none"; 604da206178SAtari911 btn.textContent = "▶️ Run Sync Now"; 6051d05cddcSAtari911 btn.style.background = "#00cc07"; 6061d05cddcSAtari911 syncAbortController = null; 6071d05cddcSAtari911 6081d05cddcSAtari911 // Clear status after 10 seconds 6091d05cddcSAtari911 setTimeout(() => { 6101d05cddcSAtari911 status.textContent = ""; 6111d05cddcSAtari911 }, 10000); 6121d05cddcSAtari911 }) 6131d05cddcSAtari911 .catch(error => { 6141d05cddcSAtari911 if (error.name === "AbortError") { 615da206178SAtari911 status.textContent = "⏹️ Sync stopped by user"; 6161d05cddcSAtari911 status.style.color = "#ff9800"; 6171d05cddcSAtari911 } else { 618da206178SAtari911 status.textContent = "❌ Error: " + error.message; 6191d05cddcSAtari911 status.style.color = "#dc3545"; 6201d05cddcSAtari911 } 6211d05cddcSAtari911 btn.disabled = false; 6221d05cddcSAtari911 btn.style.display = "inline-block"; 6231d05cddcSAtari911 stopBtn.style.display = "none"; 624da206178SAtari911 btn.textContent = "▶️ Run Sync Now"; 6251d05cddcSAtari911 btn.style.background = "#00cc07"; 6261d05cddcSAtari911 syncAbortController = null; 6271d05cddcSAtari911 }); 6281d05cddcSAtari911 } 6291d05cddcSAtari911 6301d05cddcSAtari911 function stopSyncNow() { 6311d05cddcSAtari911 const status = document.getElementById("syncStatus"); 6321d05cddcSAtari911 633da206178SAtari911 status.textContent = "⏹️ Sending stop signal..."; 6341d05cddcSAtari911 status.style.color = "#ff9800"; 6351d05cddcSAtari911 6361d05cddcSAtari911 // First, send stop signal to server 637*2866e827SAtari911 fetch("?do=admin&page=calendar&action=stop_sync&call=ajax§ok=" + JSINFO.sectok, { 6381d05cddcSAtari911 method: "POST" 6391d05cddcSAtari911 }) 6401d05cddcSAtari911 .then(response => response.json()) 6411d05cddcSAtari911 .then(data => { 6421d05cddcSAtari911 if (data.success) { 643da206178SAtari911 status.textContent = "⏹️ Stop signal sent - sync will abort soon"; 6441d05cddcSAtari911 status.style.color = "#ff9800"; 6451d05cddcSAtari911 } else { 6461d05cddcSAtari911 status.textContent = "⚠️ " + data.message; 6471d05cddcSAtari911 status.style.color = "#ff9800"; 6481d05cddcSAtari911 } 6491d05cddcSAtari911 }) 6501d05cddcSAtari911 .catch(error => { 651da206178SAtari911 status.textContent = "⚠️ Error sending stop signal: " + error.message; 6521d05cddcSAtari911 status.style.color = "#ff9800"; 6531d05cddcSAtari911 }); 6541d05cddcSAtari911 6551d05cddcSAtari911 // Also abort the fetch request 6561d05cddcSAtari911 if (syncAbortController) { 6571d05cddcSAtari911 syncAbortController.abort(); 658da206178SAtari911 status.textContent = "⏹️ Stopping sync..."; 6591d05cddcSAtari911 status.style.color = "#ff9800"; 6601d05cddcSAtari911 } 6611d05cddcSAtari911 } 6621d05cddcSAtari911 </script>'; 6631d05cddcSAtari911 6641d05cddcSAtari911 // Log Viewer Section - More Compact 6659ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0 10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">'; 666da206178SAtari911 echo '<h3 style="margin:0 0 5px 0; color:#00cc07; font-size:16px;"> Live Sync Log</h3>'; 667da206178SAtari911 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>'; 6681d05cddcSAtari911 6691d05cddcSAtari911 // Log viewer container 6701d05cddcSAtari911 echo '<div style="background:#1e1e1e; border-radius:5px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.3);">'; 6711d05cddcSAtari911 6721d05cddcSAtari911 // Log header - More compact 6731d05cddcSAtari911 echo '<div style="background:#2d2d2d; padding:6px 10px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #444;">'; 6741d05cddcSAtari911 echo '<span style="color:#00cc07; font-family:monospace; font-weight:bold; font-size:12px;">sync.log</span>'; 6751d05cddcSAtari911 echo '<div>'; 676da206178SAtari911 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>'; 677da206178SAtari911 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>'; 678da206178SAtari911 echo '<button onclick="downloadLog()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:3px; cursor:pointer; font-size:11px;"> Download</button>'; 6791d05cddcSAtari911 echo '</div>'; 6801d05cddcSAtari911 echo '</div>'; 6811d05cddcSAtari911 6821d05cddcSAtari911 // Log content - Reduced height to 250px 683da206178SAtari911 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>'; 6841d05cddcSAtari911 6851d05cddcSAtari911 echo '</div>'; 6861d05cddcSAtari911 echo '</div>'; 6871d05cddcSAtari911 688da206178SAtari911 // JavaScript for log viewer 6891d05cddcSAtari911 echo '<script> 6901d05cddcSAtari911 let refreshInterval = null; 6911d05cddcSAtari911 let isPaused = false; 6921d05cddcSAtari911 6931d05cddcSAtari911 function refreshLog() { 6941d05cddcSAtari911 if (isPaused) return; 6951d05cddcSAtari911 696*2866e827SAtari911 fetch("?do=admin&page=calendar&action=get_log&call=ajax§ok=" + JSINFO.sectok) 6971d05cddcSAtari911 .then(response => response.json()) 6981d05cddcSAtari911 .then(data => { 6991d05cddcSAtari911 const logContent = document.getElementById("logContent"); 7001d05cddcSAtari911 if (logContent) { 701da206178SAtari911 logContent.textContent = data.log || "No log data available"; 7021d05cddcSAtari911 logContent.scrollTop = logContent.scrollHeight; 7031d05cddcSAtari911 } 7041d05cddcSAtari911 }) 7051d05cddcSAtari911 .catch(error => { 7061d05cddcSAtari911 console.error("Error fetching log:", error); 7071d05cddcSAtari911 }); 7081d05cddcSAtari911 } 7091d05cddcSAtari911 7101d05cddcSAtari911 function togglePause() { 7111d05cddcSAtari911 isPaused = !isPaused; 7121d05cddcSAtari911 const btn = document.getElementById("pauseBtn"); 7131d05cddcSAtari911 if (isPaused) { 714da206178SAtari911 btn.textContent = "▶ Resume"; 7151d05cddcSAtari911 btn.style.background = "#00cc07"; 7161d05cddcSAtari911 } else { 717da206178SAtari911 btn.textContent = "⏸ Pause"; 7181d05cddcSAtari911 btn.style.background = "#666"; 7191d05cddcSAtari911 refreshLog(); 7201d05cddcSAtari911 } 7211d05cddcSAtari911 } 7221d05cddcSAtari911 7231d05cddcSAtari911 function clearLog() { 724da206178SAtari911 if (!confirm("Clear the sync log file?\\n\\nThis will delete all log entries.")) { 7251d05cddcSAtari911 return; 7261d05cddcSAtari911 } 7271d05cddcSAtari911 728*2866e827SAtari911 fetch("?do=admin&page=calendar&action=clear_log&call=ajax§ok=" + JSINFO.sectok, { 7291d05cddcSAtari911 method: "POST" 7301d05cddcSAtari911 }) 7311d05cddcSAtari911 .then(response => response.json()) 7321d05cddcSAtari911 .then(data => { 7331d05cddcSAtari911 if (data.success) { 7341d05cddcSAtari911 refreshLog(); 735da206178SAtari911 alert("Log cleared successfully"); 7361d05cddcSAtari911 } else { 737da206178SAtari911 alert("Error clearing log: " + data.message); 7381d05cddcSAtari911 } 7391d05cddcSAtari911 }) 7401d05cddcSAtari911 .catch(error => { 741da206178SAtari911 alert("Error: " + error.message); 7421d05cddcSAtari911 }); 7431d05cddcSAtari911 } 7441d05cddcSAtari911 7451d05cddcSAtari911 function downloadLog() { 7461d05cddcSAtari911 window.location.href = "?do=admin&page=calendar&action=download_log"; 7471d05cddcSAtari911 } 7481d05cddcSAtari911 7491d05cddcSAtari911 // Start auto-refresh 7501d05cddcSAtari911 refreshLog(); 7511d05cddcSAtari911 refreshInterval = setInterval(refreshLog, 2000); 7521d05cddcSAtari911 7531d05cddcSAtari911 // Cleanup on page unload 7541d05cddcSAtari911 window.addEventListener("beforeunload", function() { 7551d05cddcSAtari911 if (refreshInterval) { 7561d05cddcSAtari911 clearInterval(refreshInterval); 7571d05cddcSAtari911 } 7581d05cddcSAtari911 }); 7591d05cddcSAtari911 </script>'; 7601d05cddcSAtari911 } 7611d05cddcSAtari911 7629ccd446eSAtari911 private function renderManageTab($colors = null) { 7631d05cddcSAtari911 global $INPUT; 7641d05cddcSAtari911 7659ccd446eSAtari911 // Use defaults if not provided 7669ccd446eSAtari911 if ($colors === null) { 7679ccd446eSAtari911 $colors = $this->getTemplateColors(); 7689ccd446eSAtari911 } 7699ccd446eSAtari911 7701d05cddcSAtari911 // Show message if present 7711d05cddcSAtari911 if ($INPUT->has('msg')) { 7721d05cddcSAtari911 $msg = hsc($INPUT->str('msg')); 7731d05cddcSAtari911 $type = $INPUT->str('msgtype', 'success'); 7741d05cddcSAtari911 echo "<div style=\"padding:10px; margin:10px 0; border-left:3px solid " . ($type === 'success' ? '#28a745' : '#dc3545') . "; background:" . ($type === 'success' ? '#d4edda' : '#f8d7da') . "; border-radius:3px;\">"; 7751d05cddcSAtari911 echo $msg; 7761d05cddcSAtari911 echo "</div>"; 7771d05cddcSAtari911 } 7781d05cddcSAtari911 77922228b0eSAtari911 echo '<h2 style="margin:10px 0; font-size:20px;">' . $this->getLang('manage_calendar_events') . '</h2>'; 7801d05cddcSAtari911 7819ccd446eSAtari911 // Events Manager Section 7829ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 78322228b0eSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> ' . $this->getLang('events_manager') . '</h3>'; 78422228b0eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 10px;">' . $this->getLang('events_manager_desc') . '</p>'; 7851d05cddcSAtari911 7861d05cddcSAtari911 // Get event statistics 7871d05cddcSAtari911 $stats = $this->getEventStatistics(); 7881d05cddcSAtari911 7891d05cddcSAtari911 // Statistics display 7909ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:10px; border-radius:3px; margin-bottom:10px; border:1px solid ' . $colors['border'] . ';">'; 7911d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:10px; font-size:12px;">'; 7921d05cddcSAtari911 7931d05cddcSAtari911 echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">'; 7941d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_events'] . '</div>'; 79522228b0eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">' . $this->getLang('total_events') . '</div>'; 7961d05cddcSAtari911 echo '</div>'; 7971d05cddcSAtari911 7981d05cddcSAtari911 echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">'; 7991d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_namespaces'] . '</div>'; 80022228b0eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">' . $this->getLang('namespaces') . '</div>'; 8011d05cddcSAtari911 echo '</div>'; 8021d05cddcSAtari911 8031d05cddcSAtari911 echo '<div style="background:#e8f5e9; padding:8px; border-radius:3px; text-align:center;">'; 8041d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#388e3c;">' . $stats['total_files'] . '</div>'; 80522228b0eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">' . $this->getLang('json_files') . '</div>'; 8061d05cddcSAtari911 echo '</div>'; 8071d05cddcSAtari911 8081d05cddcSAtari911 echo '<div style="background:#fff3e0; padding:8px; border-radius:3px; text-align:center;">'; 8091d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#f57c00;">' . $stats['total_recurring'] . '</div>'; 81022228b0eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">' . $this->getLang('recurring') . '</div>'; 8111d05cddcSAtari911 echo '</div>'; 8121d05cddcSAtari911 8131d05cddcSAtari911 echo '</div>'; 8141d05cddcSAtari911 8151d05cddcSAtari911 // Last scan time 8161d05cddcSAtari911 if (!empty($stats['last_scan'])) { 81722228b0eSAtari911 echo '<div style="margin-top:8px; color:' . $colors['text'] . '; font-size:10px;">' . $this->getLang('last_scanned') . ': ' . hsc($stats['last_scan']) . '</div>'; 8181d05cddcSAtari911 } 8191d05cddcSAtari911 8201d05cddcSAtari911 echo '</div>'; 8211d05cddcSAtari911 8221d05cddcSAtari911 // Action buttons 8231d05cddcSAtari911 echo '<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:10px;">'; 8241d05cddcSAtari911 8251d05cddcSAtari911 // Rescan button 8261d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">'; 827*2866e827SAtari911 echo formSecurityToken(false); 8281d05cddcSAtari911 echo '<input type="hidden" name="action" value="rescan_events">'; 8291d05cddcSAtari911 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;">'; 83022228b0eSAtari911 echo '<span></span><span>' . $this->getLang('rescan_events') . '</span>'; 8311d05cddcSAtari911 echo '</button>'; 8321d05cddcSAtari911 echo '</form>'; 8331d05cddcSAtari911 8341d05cddcSAtari911 // Export button 8351d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">'; 836*2866e827SAtari911 echo formSecurityToken(false); 8371d05cddcSAtari911 echo '<input type="hidden" name="action" value="export_all_events">'; 8381d05cddcSAtari911 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;">'; 83922228b0eSAtari911 echo '<span></span><span>' . $this->getLang('export_all_events') . '</span>'; 8401d05cddcSAtari911 echo '</button>'; 8411d05cddcSAtari911 echo '</form>'; 8421d05cddcSAtari911 8431d05cddcSAtari911 // Import button (with file upload) 84422228b0eSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" enctype="multipart/form-data" style="display:inline;" onsubmit="return confirm(\'' . $this->getLang('import_confirm') . '\')">'; 845*2866e827SAtari911 echo formSecurityToken(false); 8461d05cddcSAtari911 echo '<input type="hidden" name="action" value="import_all_events">'; 8471d05cddcSAtari911 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;">'; 84822228b0eSAtari911 echo '<span></span><span>' . $this->getLang('import_events') . '</span>'; 8491d05cddcSAtari911 echo '<input type="file" name="import_file" accept=".json,.zip" required style="display:none;" onchange="this.form.submit()">'; 8501d05cddcSAtari911 echo '</label>'; 8511d05cddcSAtari911 echo '</form>'; 8521d05cddcSAtari911 8531d05cddcSAtari911 echo '</div>'; 8541d05cddcSAtari911 8551d05cddcSAtari911 // Breakdown by namespace 8561d05cddcSAtari911 if (!empty($stats['by_namespace'])) { 8571d05cddcSAtari911 echo '<details style="margin-top:12px;">'; 85822228b0eSAtari911 echo '<summary style="cursor:pointer; padding:6px; background:#e9e9e9; border-radius:3px; font-size:11px; font-weight:bold;">' . $this->getLang('view_breakdown') . '</summary>'; 8599ccd446eSAtari911 echo '<div style="margin-top:8px; max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">'; 8601d05cddcSAtari911 echo '<table style="width:100%; border-collapse:collapse; font-size:11px;">'; 8611d05cddcSAtari911 echo '<thead style="position:sticky; top:0; background:#f5f5f5;">'; 8621d05cddcSAtari911 echo '<tr>'; 86322228b0eSAtari911 echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">' . $this->getLang('namespace') . '</th>'; 86422228b0eSAtari911 echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">' . $this->getLang('events_column') . '</th>'; 86522228b0eSAtari911 echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">' . $this->getLang('files_column') . '</th>'; 8661d05cddcSAtari911 echo '</tr></thead><tbody>'; 8671d05cddcSAtari911 8681d05cddcSAtari911 foreach ($stats['by_namespace'] as $ns => $nsStats) { 8691d05cddcSAtari911 echo '<tr style="border-bottom:1px solid #eee;">'; 87022228b0eSAtari911 echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($ns ?: $this->getLang('default_ns')) . '</code></td>'; 8711d05cddcSAtari911 echo '<td style="padding:4px 6px; text-align:right;"><strong>' . $nsStats['events'] . '</strong></td>'; 8721d05cddcSAtari911 echo '<td style="padding:4px 6px; text-align:right;">' . $nsStats['files'] . '</td>'; 8731d05cddcSAtari911 echo '</tr>'; 8741d05cddcSAtari911 } 8751d05cddcSAtari911 8761d05cddcSAtari911 echo '</tbody></table>'; 8771d05cddcSAtari911 echo '</div>'; 8781d05cddcSAtari911 echo '</details>'; 8791d05cddcSAtari911 } 8801d05cddcSAtari911 8811d05cddcSAtari911 echo '</div>'; 8821d05cddcSAtari911 8834590242dSAtari911 // Important Namespaces Section 884*2866e827SAtari911 $configFile = $this->syncConfigPath(); 8854590242dSAtari911 $importantConfig = []; 8864590242dSAtari911 if (file_exists($configFile)) { 8874590242dSAtari911 $importantConfig = include $configFile; 8884590242dSAtari911 } 8894590242dSAtari911 $importantNsValue = isset($importantConfig['important_namespaces']) ? $importantConfig['important_namespaces'] : 'important'; 8904590242dSAtari911 8914590242dSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 89222228b0eSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">⭐ ' . $this->getLang('important_namespaces') . '</h3>'; 89322228b0eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">' . $this->getLang('important_ns_desc') . '</p>'; 89496df7d3eSAtari911 89596df7d3eSAtari911 // Effects description 89696df7d3eSAtari911 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'] . ';">'; 89722228b0eSAtari911 echo '<strong style="color:#00cc07;">' . $this->getLang('visual_effects') . ':</strong><br>'; 89822228b0eSAtari911 echo '• ' . $this->getLang('effect_grid') . '<br>'; 89922228b0eSAtari911 echo '• ' . $this->getLang('effect_sidebar') . '<br>'; 90022228b0eSAtari911 echo '• ' . $this->getLang('effect_widget') . '<br>'; 90122228b0eSAtari911 echo '• ' . $this->getLang('effect_popup'); 90296df7d3eSAtari911 echo '</div>'; 90396df7d3eSAtari911 9044590242dSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:flex; gap:8px; align-items:center;">'; 905*2866e827SAtari911 echo formSecurityToken(false); 9064590242dSAtari911 echo '<input type="hidden" name="action" value="save_important_namespaces">'; 9074590242dSAtari911 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">'; 90822228b0eSAtari911 echo '<button type="submit" style="background:#00cc07; color:white; padding:6px 16px; border:none; border-radius:3px; cursor:pointer; font-size:12px; font-weight:bold; white-space:nowrap;">' . $this->getLang('save') . '</button>'; 9094590242dSAtari911 echo '</form>'; 91022228b0eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:4px 0 0;">' . $this->getLang('important_ns_hint') . '</p>'; 9114590242dSAtari911 echo '</div>'; 9124590242dSAtari911 9139ccd446eSAtari911 // Cleanup Events Section 9149ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 91522228b0eSAtari911 echo '<h3 style="margin:0 0 6px 0; color:#00cc07; font-size:16px;"> ' . $this->getLang('cleanup_old_events') . '</h3>'; 91622228b0eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 12px;">' . $this->getLang('cleanup_desc') . '</p>'; 9171d05cddcSAtari911 9181d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="cleanupForm">'; 919*2866e827SAtari911 echo formSecurityToken(false); 9201d05cddcSAtari911 echo '<input type="hidden" name="action" value="cleanup_events">'; 9211d05cddcSAtari911 9221d05cddcSAtari911 // Compact options layout 9239ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; margin-bottom:10px;">'; 9241d05cddcSAtari911 9251d05cddcSAtari911 // Radio buttons in a row 9261d05cddcSAtari911 echo '<div style="display:flex; gap:20px; margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid #f0f0f0;">'; 9271d05cddcSAtari911 echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">'; 9281d05cddcSAtari911 echo '<input type="radio" name="cleanup_type" value="age" checked onchange="updateCleanupOptions()">'; 92922228b0eSAtari911 echo '<span>' . $this->getLang('by_age') . '</span>'; 9301d05cddcSAtari911 echo '</label>'; 9311d05cddcSAtari911 echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">'; 9321d05cddcSAtari911 echo '<input type="radio" name="cleanup_type" value="status" onchange="updateCleanupOptions()">'; 93322228b0eSAtari911 echo '<span>' . $this->getLang('by_status') . '</span>'; 9341d05cddcSAtari911 echo '</label>'; 9351d05cddcSAtari911 echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">'; 9361d05cddcSAtari911 echo '<input type="radio" name="cleanup_type" value="range" onchange="updateCleanupOptions()">'; 93722228b0eSAtari911 echo '<span>' . $this->getLang('by_date_range') . '</span>'; 9381d05cddcSAtari911 echo '</label>'; 9391d05cddcSAtari911 echo '</div>'; 9401d05cddcSAtari911 9411d05cddcSAtari911 // Age options 9421d05cddcSAtari911 echo '<div id="age-options" style="padding:6px 0;">'; 94322228b0eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">' . $this->getLang('delete_older_than') . ':</span>'; 9441d05cddcSAtari911 echo '<select name="age_value" style="width:50px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:4px;">'; 9451d05cddcSAtari911 for ($i = 1; $i <= 24; $i++) { 9461d05cddcSAtari911 $sel = $i === 6 ? ' selected' : ''; 9471d05cddcSAtari911 echo '<option value="' . $i . '"' . $sel . '>' . $i . '</option>'; 9481d05cddcSAtari911 } 9491d05cddcSAtari911 echo '</select>'; 9501d05cddcSAtari911 echo '<select name="age_unit" style="width:80px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">'; 95122228b0eSAtari911 echo '<option value="months" selected>' . $this->getLang('months') . '</option>'; 95222228b0eSAtari911 echo '<option value="years">' . $this->getLang('years') . '</option>'; 9531d05cddcSAtari911 echo '</select>'; 9541d05cddcSAtari911 echo '</div>'; 9551d05cddcSAtari911 9561d05cddcSAtari911 // Status options 9571d05cddcSAtari911 echo '<div id="status-options" style="padding:6px 0; opacity:0.4;">'; 95822228b0eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">' . $this->getLang('delete') . ':</span>'; 95922228b0eSAtari911 echo '<label style="display:inline-block; font-size:11px; margin-right:12px; cursor:pointer;"><input type="checkbox" name="delete_completed" value="1" style="margin-right:3px;"> ' . $this->getLang('completed_tasks') . '</label>'; 96022228b0eSAtari911 echo '<label style="display:inline-block; font-size:11px; cursor:pointer;"><input type="checkbox" name="delete_past" value="1" style="margin-right:3px;"> ' . $this->getLang('past_events') . '</label>'; 9611d05cddcSAtari911 echo '</div>'; 9621d05cddcSAtari911 9631d05cddcSAtari911 // Range options 9641d05cddcSAtari911 echo '<div id="range-options" style="padding:6px 0; opacity:0.4;">'; 96522228b0eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">' . $this->getLang('from_date') . ':</span>'; 9661d05cddcSAtari911 echo '<input type="date" name="range_start" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:10px;">'; 96722228b0eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">' . $this->getLang('to_date') . ':</span>'; 9681d05cddcSAtari911 echo '<input type="date" name="range_end" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">'; 9691d05cddcSAtari911 echo '</div>'; 9701d05cddcSAtari911 9711d05cddcSAtari911 echo '</div>'; 9721d05cddcSAtari911 9731d05cddcSAtari911 // Namespace filter - compact 9749ccd446eSAtari911 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;">'; 97522228b0eSAtari911 echo '<label style="font-size:11px; font-weight:600; white-space:nowrap; color:#555;">' . $this->getLang('namespace_filter') . ':</label>'; 97622228b0eSAtari911 echo '<input type="text" name="namespace_filter" placeholder="' . $this->getLang('namespace_filter_hint') . '" style="flex:1; padding:4px 8px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">'; 9771d05cddcSAtari911 echo '</div>'; 9781d05cddcSAtari911 9791d05cddcSAtari911 // Action buttons - compact row 9801d05cddcSAtari911 echo '<div style="display:flex; gap:8px; align-items:center;">'; 98122228b0eSAtari911 echo '<button type="button" onclick="previewCleanup()" style="background:#7b1fa2; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">️ ' . $this->getLang('preview') . '</button>'; 98222228b0eSAtari911 echo '<button type="submit" onclick="return confirmCleanup()" style="background:#dc3545; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">️ ' . $this->getLang('delete') . '</button>'; 98322228b0eSAtari911 echo '<span style="font-size:10px; color:#999;">⚠️ ' . $this->getLang('backup_auto') . '</span>'; 9841d05cddcSAtari911 echo '</div>'; 9851d05cddcSAtari911 9861d05cddcSAtari911 echo '</form>'; 9871d05cddcSAtari911 9881d05cddcSAtari911 // Preview results area 9891d05cddcSAtari911 echo '<div id="cleanup-preview" style="margin-top:10px; display:none;"></div>'; 9901d05cddcSAtari911 99122228b0eSAtari911 // Store language strings for JavaScript 99222228b0eSAtari911 $jsLang = [ 99322228b0eSAtari911 'loading_preview' => $this->getLang('loading_preview'), 99422228b0eSAtari911 'no_events_match' => $this->getLang('no_events_match'), 99522228b0eSAtari911 'debug_info' => $this->getLang('debug_info'), 99622228b0eSAtari911 'error_loading' => $this->getLang('error_loading'), 99722228b0eSAtari911 'cleanup_confirm' => $this->getLang('cleanup_confirm'), 99822228b0eSAtari911 ]; 99922228b0eSAtari911 10001d05cddcSAtari911 echo '<script> 100122228b0eSAtari911 var calendarLang = ' . json_encode($jsLang) . '; 100222228b0eSAtari911 10031d05cddcSAtari911 function updateCleanupOptions() { 10041d05cddcSAtari911 const type = document.querySelector(\'input[name="cleanup_type"]:checked\').value; 10051d05cddcSAtari911 10061d05cddcSAtari911 // Show selected, gray out others 10071d05cddcSAtari911 document.getElementById(\'age-options\').style.opacity = type === \'age\' ? \'1\' : \'0.4\'; 10081d05cddcSAtari911 document.getElementById(\'status-options\').style.opacity = type === \'status\' ? \'1\' : \'0.4\'; 10091d05cddcSAtari911 document.getElementById(\'range-options\').style.opacity = type === \'range\' ? \'1\' : \'0.4\'; 10101d05cddcSAtari911 10111d05cddcSAtari911 // Enable/disable inputs 10121d05cddcSAtari911 document.querySelectorAll(\'#age-options select\').forEach(el => el.disabled = type !== \'age\'); 10131d05cddcSAtari911 document.querySelectorAll(\'#status-options input\').forEach(el => el.disabled = type !== \'status\'); 10141d05cddcSAtari911 document.querySelectorAll(\'#range-options input\').forEach(el => el.disabled = type !== \'range\'); 10151d05cddcSAtari911 } 10161d05cddcSAtari911 10171d05cddcSAtari911 function previewCleanup() { 10181d05cddcSAtari911 const form = document.getElementById(\'cleanupForm\'); 10191d05cddcSAtari911 const formData = new FormData(form); 10201d05cddcSAtari911 formData.set(\'action\', \'preview_cleanup\'); 10211d05cddcSAtari911 10221d05cddcSAtari911 const preview = document.getElementById(\'cleanup-preview\'); 102322228b0eSAtari911 preview.innerHTML = \'<div style="text-align:center; padding:20px; color:' . $colors['text'] . ';">\' + calendarLang.loading_preview + \'</div>\'; 10241d05cddcSAtari911 preview.style.display = \'block\'; 10251d05cddcSAtari911 10261d05cddcSAtari911 fetch(\'?do=admin&page=calendar&tab=manage\', { 10271d05cddcSAtari911 method: \'POST\', 10281d05cddcSAtari911 body: new URLSearchParams(formData) 10291d05cddcSAtari911 }) 10301d05cddcSAtari911 .then(r => r.json()) 10311d05cddcSAtari911 .then(data => { 10321d05cddcSAtari911 if (data.count === 0) { 103322228b0eSAtari911 let html = \'<div style="background:#d4edda; border:1px solid #c3e6cb; padding:10px; border-radius:3px; font-size:12px; color:#155724;">✅ \' + calendarLang.no_events_match + \'</div>\'; 10341d05cddcSAtari911 10351d05cddcSAtari911 // Show debug info if available 10361d05cddcSAtari911 if (data.debug) { 10379ccd446eSAtari911 html += \'<details style="margin-top:8px; font-size:11px; color:' . $colors['text'] . ';">\'; 103822228b0eSAtari911 html += \'<summary style="cursor:pointer;">\' + calendarLang.debug_info + \'</summary>\'; 10391d05cddcSAtari911 html += \'<pre style="background:#f5f5f5; padding:6px; margin-top:4px; border-radius:3px; overflow-x:auto;">\' + JSON.stringify(data.debug, null, 2) + \'</pre>\'; 10401d05cddcSAtari911 html += \'</details>\'; 10411d05cddcSAtari911 } 10421d05cddcSAtari911 10431d05cddcSAtari911 preview.innerHTML = html; 10441d05cddcSAtari911 } else { 10451d05cddcSAtari911 let html = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">\'; 104622228b0eSAtari911 html += \'<strong>⚠️</strong> \' + data.count + \' event(s):<br><br>\'; 10479ccd446eSAtari911 html += \'<div style="max-height:150px; overflow-y:auto; margin-top:6px; background:' . $colors['bg'] . '; padding:6px; border-radius:3px;">\'; 10481d05cddcSAtari911 data.events.forEach(evt => { 10491d05cddcSAtari911 html += \'<div style="padding:3px; border-bottom:1px solid #eee; font-size:11px;">\'; 10501d05cddcSAtari911 html += \'• \' + evt.title + \' (\' + evt.date + \')\'; 10511d05cddcSAtari911 if (evt.namespace) html += \' <span style="background:#e3f2fd; padding:1px 4px; border-radius:2px; font-size:9px;">\' + evt.namespace + \'</span>\'; 10521d05cddcSAtari911 html += \'</div>\'; 10531d05cddcSAtari911 }); 10541d05cddcSAtari911 html += \'</div></div>\'; 10551d05cddcSAtari911 preview.innerHTML = html; 10561d05cddcSAtari911 } 10571d05cddcSAtari911 }) 10581d05cddcSAtari911 .catch(err => { 105922228b0eSAtari911 preview.innerHTML = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">\' + calendarLang.error_loading + \'</div>\'; 10601d05cddcSAtari911 }); 10611d05cddcSAtari911 } 10621d05cddcSAtari911 10631d05cddcSAtari911 function confirmCleanup() { 106422228b0eSAtari911 return confirm(calendarLang.cleanup_confirm); 10651d05cddcSAtari911 } 10661d05cddcSAtari911 10671d05cddcSAtari911 updateCleanupOptions(); 10681d05cddcSAtari911 </script>'; 10691d05cddcSAtari911 10701d05cddcSAtari911 echo '</div>'; 10711d05cddcSAtari911 10721d05cddcSAtari911 // Recurring Events Section 10737e8ea635SAtari911 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;">'; 10747e8ea635SAtari911 echo '<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">'; 107522228b0eSAtari911 echo '<h3 style="margin:0; color:#00cc07; font-size:16px;"> ' . $this->getLang('recurring_events') . '</h3>'; 10767e8ea635SAtari911 echo '<div style="display:flex; gap:6px;">'; 107722228b0eSAtari911 echo '<button onclick="trimAllPastRecurring()" id="trim-all-past-btn" style="background:#e74c3c; color:#fff; border:none; padding:4px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600; transition:all 0.15s;" onmouseover="this.style.filter=\'brightness(1.2)\'" onmouseout="this.style.filter=\'none\'">✂️ ' . $this->getLang('trim_all_past') . '</button>'; 107822228b0eSAtari911 echo '<button onclick="rescanRecurringEvents()" id="rescan-recurring-btn" style="background:#00cc07; color:#fff; border:none; padding:4px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600; transition:all 0.15s;" onmouseover="this.style.filter=\'brightness(1.2)\'" onmouseout="this.style.filter=\'none\'"> ' . $this->getLang('rescan') . '</button>'; 10797e8ea635SAtari911 echo '</div>'; 10807e8ea635SAtari911 echo '</div>'; 10811d05cddcSAtari911 10821d05cddcSAtari911 $recurringEvents = $this->findRecurringEvents(); 10831d05cddcSAtari911 10847e8ea635SAtari911 echo '<div id="recurring-content">'; 10857e8ea635SAtari911 $this->renderRecurringTable($recurringEvents, $colors); 10861d05cddcSAtari911 echo '</div>'; 10871d05cddcSAtari911 echo '</div>'; 10881d05cddcSAtari911 10891d05cddcSAtari911 // Compact Tree-based Namespace Manager 10909ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 109122228b0eSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> ' . $this->getLang('namespace_explorer') . '</h3>'; 109222228b0eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">' . $this->getLang('namespace_explorer_desc') . '</p>'; 10931d05cddcSAtari911 10941d05cddcSAtari911 // Search bar 10951d05cddcSAtari911 echo '<div style="margin-bottom:8px;">'; 109622228b0eSAtari911 echo '<input type="text" id="searchEvents" onkeyup="filterEvents()" placeholder=" ' . $this->getLang('search_events') . '" style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">'; 10971d05cddcSAtari911 echo '</div>'; 10981d05cddcSAtari911 10991d05cddcSAtari911 $eventsByNamespace = $this->getEventsByNamespace(); 11001d05cddcSAtari911 11011d05cddcSAtari911 // Control bar 11021d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="moveForm">'; 1103*2866e827SAtari911 echo formSecurityToken(false); 11041d05cddcSAtari911 echo '<input type="hidden" name="action" value="move_selected_events" id="formAction">'; 11051d05cddcSAtari911 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;">'; 110622228b0eSAtari911 echo '<button type="button" onclick="selectAll()" style="background:#00cc07; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☑ ' . $this->getLang('select_all') . '</button>'; 110722228b0eSAtari911 echo '<button type="button" onclick="deselectAll()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☐ ' . $this->getLang('select_none') . '</button>'; 110822228b0eSAtari911 echo '<button type="button" onclick="deleteSelected()" style="background:#e74c3c; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px; margin-left:10px;">️ ' . $this->getLang('delete') . '</button>'; 110922228b0eSAtari911 echo '<span style="margin-left:10px;">' . $this->getLang('move_to') . ':</span>'; 111022228b0eSAtari911 echo '<input list="namespaceList" name="target_namespace" required style="padding:3px 6px; border:1px solid ' . $colors['border'] . '; border-radius:2px; font-size:11px; min-width:150px;" placeholder="' . $this->getLang('type_or_select') . '">'; 11111d05cddcSAtari911 echo '<datalist id="namespaceList">'; 111222228b0eSAtari911 echo '<option value="">' . $this->getLang('default_ns') . '</option>'; 11131d05cddcSAtari911 foreach (array_keys($eventsByNamespace) as $ns) { 11141d05cddcSAtari911 if ($ns !== '') { 11151d05cddcSAtari911 echo '<option value="' . hsc($ns) . '">' . hsc($ns) . '</option>'; 11161d05cddcSAtari911 } 11171d05cddcSAtari911 } 11181d05cddcSAtari911 echo '</datalist>'; 111922228b0eSAtari911 echo '<button type="submit" style="background:#00cc07; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold;">➡️ ' . $this->getLang('btn_move') . '</button>'; 112022228b0eSAtari911 echo '<button type="button" onclick="createNewNamespace()" style="background:#7b1fa2; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold; margin-left:5px;">➕ ' . $this->getLang('new_namespace') . '</button>'; 112122228b0eSAtari911 echo '<button type="button" onclick="cleanupEmptyNamespaces()" id="cleanup-ns-btn" style="background:#e74c3c; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold; margin-left:5px;"> ' . $this->getLang('cleanup_empty') . '</button>'; 112222228b0eSAtari911 echo '<span id="selectedCount" style="margin-left:auto; color:#00cc07; font-size:11px;">' . $this->getLang('zero_selected') . '</span>'; 11231d05cddcSAtari911 echo '</div>'; 11241d05cddcSAtari911 11257e8ea635SAtari911 // Cleanup status message - displayed prominently after control bar 11267e8ea635SAtari911 echo '<div id="cleanup-ns-status" style="font-size:12px; margin-bottom:8px; min-height:18px;"></div>'; 11277e8ea635SAtari911 11281d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">'; 11291d05cddcSAtari911 11301d05cddcSAtari911 // Event list with checkboxes 11311d05cddcSAtari911 echo '<div>'; 11329ccd446eSAtari911 echo '<div style="max-height:450px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">'; 11331d05cddcSAtari911 11341d05cddcSAtari911 foreach ($eventsByNamespace as $namespace => $data) { 11351d05cddcSAtari911 $nsId = 'ns_' . md5($namespace); 113696df7d3eSAtari911 $events = isset($data['events']) && is_array($data['events']) ? $data['events'] : []; 113796df7d3eSAtari911 $eventCount = count($events); 11381d05cddcSAtari911 11391d05cddcSAtari911 echo '<div style="border-bottom:1px solid #ddd;">'; 11401d05cddcSAtari911 11411d05cddcSAtari911 // Namespace header - ultra compact 11421d05cddcSAtari911 echo '<div style="background:#f5f5f5; padding:3px 6px; display:flex; justify-content:space-between; align-items:center; font-size:11px;">'; 11431d05cddcSAtari911 echo '<div style="display:flex; align-items:center; gap:4px;">'; 11441d05cddcSAtari911 echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; width:12px; display:inline-block; font-size:10px;"><span id="' . $nsId . '_arrow">▶</span></span>'; 11451d05cddcSAtari911 echo '<input type="checkbox" onclick="toggleNamespaceSelect(\'' . $nsId . '\')" id="' . $nsId . '_check" style="margin:0; width:12px; height:12px;">'; 11461d05cddcSAtari911 echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; font-weight:600; font-size:11px;"> ' . hsc($namespace ?: '(default)') . '</span>'; 11471d05cddcSAtari911 echo '</div>'; 11481d05cddcSAtari911 echo '<div style="display:flex; gap:3px; align-items:center;">'; 11491d05cddcSAtari911 echo '<span style="background:#00cc07; color:white; padding:0px 4px; border-radius:6px; font-size:9px; line-height:14px;">' . $eventCount . '</span>'; 11509ccd446eSAtari911 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>'; 11511d05cddcSAtari911 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>'; 11521d05cddcSAtari911 echo '</div>'; 11531d05cddcSAtari911 echo '</div>'; 11541d05cddcSAtari911 11551d05cddcSAtari911 // Events - ultra compact 11561d05cddcSAtari911 echo '<div id="' . $nsId . '" style="display:none; max-height:150px; overflow-y:auto;">'; 115796df7d3eSAtari911 foreach ($events as $event) { 11581d05cddcSAtari911 $eventId = $event['id'] . '|' . $namespace . '|' . $event['date'] . '|' . $event['month']; 11591d05cddcSAtari911 $checkId = 'evt_' . md5($eventId); 11601d05cddcSAtari911 11611d05cddcSAtari911 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\'">'; 11621d05cddcSAtari911 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;">'; 11631d05cddcSAtari911 echo '<div style="flex:1; min-width:0;">'; 11641d05cddcSAtari911 echo '<div style="font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:10px;">' . hsc($event['title']) . '</div>'; 11651d05cddcSAtari911 echo '<div style="color:#999; font-size:9px;">' . hsc($event['date']) . ($event['startTime'] ? ' • ' . hsc($event['startTime']) : '') . '</div>'; 11661d05cddcSAtari911 echo '</div>'; 11671d05cddcSAtari911 echo '</div>'; 11681d05cddcSAtari911 } 11691d05cddcSAtari911 echo '</div>'; 11701d05cddcSAtari911 echo '</div>'; 11711d05cddcSAtari911 } 11721d05cddcSAtari911 11731d05cddcSAtari911 echo '</div>'; 11741d05cddcSAtari911 echo '</div>'; 11751d05cddcSAtari911 11761d05cddcSAtari911 // Drop zones - ultra compact 11771d05cddcSAtari911 echo '<div>'; 117822228b0eSAtari911 echo '<div style="background:#00cc07; color:white; padding:3px 6px; border-radius:3px 3px 0 0; font-size:11px; font-weight:bold;"> ' . $this->getLang('drop_target') . '</div>'; 11799ccd446eSAtari911 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'] . ';">'; 11801d05cddcSAtari911 11811d05cddcSAtari911 foreach (array_keys($eventsByNamespace) as $namespace) { 11829ccd446eSAtari911 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\'">'; 118322228b0eSAtari911 echo '<div style="font-size:11px; font-weight:600; color:#00cc07;"> ' . hsc($namespace ?: $this->getLang('default_ns')) . '</div>'; 118422228b0eSAtari911 echo '<div style="color:#999; font-size:9px; margin-top:1px;">' . $this->getLang('drop_here') . '</div>'; 11851d05cddcSAtari911 echo '</div>'; 11861d05cddcSAtari911 } 11871d05cddcSAtari911 11881d05cddcSAtari911 echo '</div>'; 11891d05cddcSAtari911 echo '</div>'; 11901d05cddcSAtari911 11911d05cddcSAtari911 echo '</div>'; // end grid 11921d05cddcSAtari911 echo '</form>'; 11931d05cddcSAtari911 11941d05cddcSAtari911 echo '</div>'; 11951d05cddcSAtari911 119622228b0eSAtari911 // JavaScript language strings 119722228b0eSAtari911 $jsAdminLang = [ 119822228b0eSAtari911 // Namespace explorer 119922228b0eSAtari911 'x_selected' => $this->getLang('x_selected'), 120022228b0eSAtari911 'zero_selected' => $this->getLang('zero_selected'), 120122228b0eSAtari911 'cleanup_empty' => $this->getLang('cleanup_empty'), 120222228b0eSAtari911 'default_ns' => $this->getLang('default_ns'), 120322228b0eSAtari911 'no_events_selected' => $this->getLang('no_events_selected'), 120422228b0eSAtari911 'delete_confirm' => $this->getLang('delete_confirm'), 120522228b0eSAtari911 'delete_ns_confirm' => $this->getLang('delete_ns_confirm'), 120622228b0eSAtari911 'scanning' => $this->getLang('scanning'), 120722228b0eSAtari911 'cleaning' => $this->getLang('cleaning'), 120822228b0eSAtari911 'no_empty_ns' => $this->getLang('no_empty_ns'), 120922228b0eSAtari911 'found_items' => $this->getLang('found_items'), 121022228b0eSAtari911 'proceed_cleanup' => $this->getLang('proceed_cleanup'), 121122228b0eSAtari911 'enter_namespace' => $this->getLang('enter_namespace'), 121222228b0eSAtari911 'invalid_namespace' => $this->getLang('invalid_namespace'), 121322228b0eSAtari911 'rename_namespace' => $this->getLang('rename_namespace'), 121422228b0eSAtari911 'delete_recurring_confirm' => $this->getLang('delete_recurring_confirm'), 121522228b0eSAtari911 'no_past_recurring' => $this->getLang('no_past_recurring'), 121622228b0eSAtari911 'found_past_recurring' => $this->getLang('found_past_recurring'), 121722228b0eSAtari911 'counting' => $this->getLang('counting'), 121822228b0eSAtari911 'trimming' => $this->getLang('trimming'), 121922228b0eSAtari911 'trim_confirm' => $this->getLang('trim_confirm'), 122022228b0eSAtari911 'respace_confirm' => $this->getLang('respace_confirm'), 122122228b0eSAtari911 'shift_confirm' => $this->getLang('shift_confirm'), 122222228b0eSAtari911 'trim_all_past' => $this->getLang('trim_all_past'), 122322228b0eSAtari911 // Manage recurring dialog 122422228b0eSAtari911 'manage_recurring_title' => $this->getLang('manage_recurring_title'), 122522228b0eSAtari911 'occurrences' => $this->getLang('occurrences'), 122622228b0eSAtari911 'extend_series' => $this->getLang('extend_series'), 122722228b0eSAtari911 'add_occurrences' => $this->getLang('add_occurrences'), 122822228b0eSAtari911 'days_apart' => $this->getLang('days_apart'), 122922228b0eSAtari911 'btn_extend' => $this->getLang('btn_extend'), 123022228b0eSAtari911 'trim_past_events' => $this->getLang('trim_past_events'), 123122228b0eSAtari911 'remove_before' => $this->getLang('remove_before'), 123222228b0eSAtari911 'change_pattern' => $this->getLang('change_pattern'), 123322228b0eSAtari911 'respace_note' => $this->getLang('respace_note'), 123422228b0eSAtari911 'new_interval' => $this->getLang('new_interval'), 123522228b0eSAtari911 'change_start_date' => $this->getLang('change_start_date'), 123622228b0eSAtari911 'shift_note' => $this->getLang('shift_note'), 123722228b0eSAtari911 'current_label' => $this->getLang('current_label'), 123822228b0eSAtari911 'pause_series' => $this->getLang('pause_series'), 123922228b0eSAtari911 'resume_series' => $this->getLang('resume_series'), 124022228b0eSAtari911 'pause_note' => $this->getLang('pause_note'), 124122228b0eSAtari911 'resume_note' => $this->getLang('resume_note'), 124222228b0eSAtari911 'btn_pause' => $this->getLang('btn_pause'), 124322228b0eSAtari911 'btn_resume' => $this->getLang('btn_resume'), 124422228b0eSAtari911 'btn_close' => $this->getLang('btn_close'), 124522228b0eSAtari911 'btn_trim' => $this->getLang('btn_trim'), 124622228b0eSAtari911 'btn_change' => $this->getLang('btn_change'), 124722228b0eSAtari911 'btn_shift' => $this->getLang('btn_shift'), 124822228b0eSAtari911 // Interval options 124922228b0eSAtari911 'interval_daily' => $this->getLang('interval_daily'), 125022228b0eSAtari911 'interval_weekly' => $this->getLang('interval_weekly'), 125122228b0eSAtari911 'interval_biweekly' => $this->getLang('interval_biweekly'), 125222228b0eSAtari911 'interval_monthly' => $this->getLang('interval_monthly'), 125322228b0eSAtari911 'interval_quarterly' => $this->getLang('interval_quarterly'), 125422228b0eSAtari911 'interval_yearly' => $this->getLang('interval_yearly'), 125522228b0eSAtari911 // Edit recurring dialog 125622228b0eSAtari911 'edit_recurring_title' => $this->getLang('edit_recurring_title'), 125722228b0eSAtari911 'changes_apply_to' => $this->getLang('changes_apply_to'), 125822228b0eSAtari911 'field_title' => $this->getLang('field_title'), 125922228b0eSAtari911 'field_start_time' => $this->getLang('field_start_time'), 126022228b0eSAtari911 'field_end_time' => $this->getLang('field_end_time'), 126122228b0eSAtari911 'field_namespace' => $this->getLang('field_namespace'), 126222228b0eSAtari911 'field_color' => $this->getLang('field_color'), 126322228b0eSAtari911 'recurrence_pattern' => $this->getLang('recurrence_pattern'), 126422228b0eSAtari911 'every' => $this->getLang('every'), 126522228b0eSAtari911 'on_days' => $this->getLang('on_days'), 126622228b0eSAtari911 'monthly_options' => $this->getLang('monthly_options'), 126722228b0eSAtari911 'day_of_month' => $this->getLang('day_of_month'), 126822228b0eSAtari911 'ordinal_weekday' => $this->getLang('ordinal_weekday'), 126922228b0eSAtari911 'btn_save_changes' => $this->getLang('btn_save_changes'), 127022228b0eSAtari911 'btn_cancel' => $this->getLang('btn_cancel'), 127122228b0eSAtari911 // Day names 127222228b0eSAtari911 'day_names' => [$this->getLang('day_sun'), $this->getLang('day_mon'), $this->getLang('day_tue'), $this->getLang('day_wed'), $this->getLang('day_thu'), $this->getLang('day_fri'), $this->getLang('day_sat')], 127322228b0eSAtari911 'day_names_full' => [$this->getLang('day_sunday'), $this->getLang('day_monday'), $this->getLang('day_tuesday'), $this->getLang('day_wednesday'), $this->getLang('day_thursday'), $this->getLang('day_friday'), $this->getLang('day_saturday')], 127422228b0eSAtari911 // Ordinal labels 127522228b0eSAtari911 'ordinal_first' => $this->getLang('ordinal_first'), 127622228b0eSAtari911 'ordinal_second' => $this->getLang('ordinal_second'), 127722228b0eSAtari911 'ordinal_third' => $this->getLang('ordinal_third'), 127822228b0eSAtari911 'ordinal_fourth' => $this->getLang('ordinal_fourth'), 127922228b0eSAtari911 'ordinal_fifth' => $this->getLang('ordinal_fifth'), 128022228b0eSAtari911 'ordinal_last' => $this->getLang('ordinal_last'), 128122228b0eSAtari911 // Recurrence types 128222228b0eSAtari911 'rec_days' => $this->getLang('rec_days'), 128322228b0eSAtari911 'rec_weeks' => $this->getLang('rec_weeks'), 128422228b0eSAtari911 'rec_months' => $this->getLang('rec_months'), 128522228b0eSAtari911 'rec_years' => $this->getLang('rec_years'), 128622228b0eSAtari911 ]; 128722228b0eSAtari911 12881d05cddcSAtari911 // JavaScript 12891d05cddcSAtari911 echo '<script> 12907e8ea635SAtari911 var adminColors = { 12917e8ea635SAtari911 text: "' . $colors['text'] . '", 12927e8ea635SAtari911 bg: "' . $colors['bg'] . '", 12937e8ea635SAtari911 border: "' . $colors['border'] . '" 12947e8ea635SAtari911 }; 129522228b0eSAtari911 var adminLang = ' . json_encode($jsAdminLang) . '; 12961d05cddcSAtari911 // Table sorting functionality - defined early so onclick handlers work 12971d05cddcSAtari911 let sortDirection = {}; // Track sort direction for each column 12981d05cddcSAtari911 12997e8ea635SAtari911 function cleanupEmptyNamespaces() { 13007e8ea635SAtari911 var btn = document.getElementById("cleanup-ns-btn"); 13017e8ea635SAtari911 var status = document.getElementById("cleanup-ns-status"); 130222228b0eSAtari911 if (btn) { btn.textContent = "⏳ " + adminLang.scanning; btn.disabled = true; } 13037e8ea635SAtari911 if (status) { status.innerHTML = ""; } 13047e8ea635SAtari911 13057e8ea635SAtari911 // Dry run first 13067e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 13077e8ea635SAtari911 method: "POST", 13087e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 13097e8ea635SAtari911 body: "call=plugin_calendar&action=cleanup_empty_namespaces&dry_run=1§ok=" + JSINFO.sectok 13107e8ea635SAtari911 }) 13117e8ea635SAtari911 .then(function(r) { return r.json(); }) 13127e8ea635SAtari911 .then(function(data) { 131322228b0eSAtari911 if (btn) { btn.textContent = " " + adminLang.cleanup_empty; btn.disabled = false; } 13147e8ea635SAtari911 if (!data.success) { 1315da206178SAtari911 if (status) { status.innerHTML = "<span style=\"color:#e74c3c;\">❌ " + (data.error || "Failed") + "</span>"; } 13167e8ea635SAtari911 return; 13177e8ea635SAtari911 } 13187e8ea635SAtari911 13197e8ea635SAtari911 var details = data.details || []; 13207e8ea635SAtari911 var totalActions = details.length; 13217e8ea635SAtari911 13227e8ea635SAtari911 if (totalActions === 0) { 132322228b0eSAtari911 if (status) { status.innerHTML = "<span style=\"color:#00cc07;\">✅ " + adminLang.no_empty_ns + "</span>"; } 13247e8ea635SAtari911 return; 13257e8ea635SAtari911 } 13267e8ea635SAtari911 13277e8ea635SAtari911 // Build detail list for confirm 132822228b0eSAtari911 var msg = adminLang.found_items.replace(/%d/, totalActions) + ":\\n\\n"; 13297e8ea635SAtari911 for (var i = 0; i < details.length; i++) { 13307e8ea635SAtari911 msg += "• " + details[i] + "\\n"; 13317e8ea635SAtari911 } 133222228b0eSAtari911 msg += "\\n" + adminLang.proceed_cleanup; 13337e8ea635SAtari911 13347e8ea635SAtari911 if (!confirm(msg)) return; 13357e8ea635SAtari911 13367e8ea635SAtari911 // Execute 133722228b0eSAtari911 if (btn) { btn.textContent = "⏳ " + adminLang.cleaning; btn.disabled = true; } 13387e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 13397e8ea635SAtari911 method: "POST", 13407e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 13417e8ea635SAtari911 body: "call=plugin_calendar&action=cleanup_empty_namespaces§ok=" + JSINFO.sectok 13427e8ea635SAtari911 }) 13437e8ea635SAtari911 .then(function(r) { return r.json(); }) 13447e8ea635SAtari911 .then(function(data2) { 1345da206178SAtari911 var msgText = data2.message || "Cleanup complete"; 13467e8ea635SAtari911 if (data2.details && data2.details.length > 0) { 13477e8ea635SAtari911 msgText += " (" + data2.details.join(", ") + ")"; 13487e8ea635SAtari911 } 13497e8ea635SAtari911 window.location.href = "?do=admin&page=calendar&tab=manage&msg=" + encodeURIComponent(msgText) + "&msgtype=success"; 13507e8ea635SAtari911 }); 13517e8ea635SAtari911 }) 13527e8ea635SAtari911 .catch(function(err) { 135322228b0eSAtari911 if (btn) { btn.textContent = " " + adminLang.cleanup_empty; btn.disabled = false; } 135422228b0eSAtari911 if (status) { status.innerHTML = "<span style=\"color:#e74c3c;\">❌ Error: " + err + "</span>"; } 13557e8ea635SAtari911 }); 13567e8ea635SAtari911 } 13577e8ea635SAtari911 function trimAllPastRecurring() { 13587e8ea635SAtari911 var btn = document.getElementById("trim-all-past-btn"); 135922228b0eSAtari911 if (btn) { btn.textContent = "⏳ " + adminLang.counting; btn.disabled = true; } 13607e8ea635SAtari911 13617e8ea635SAtari911 // Step 1: dry run to get count 13627e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 13637e8ea635SAtari911 method: "POST", 13647e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 13657e8ea635SAtari911 body: "call=plugin_calendar&action=trim_all_past_recurring&dry_run=1§ok=" + JSINFO.sectok 13667e8ea635SAtari911 }) 13677e8ea635SAtari911 .then(function(r) { return r.json(); }) 13687e8ea635SAtari911 .then(function(data) { 136922228b0eSAtari911 if (btn) { btn.textContent = "✂️ " + adminLang.trim_all_past; btn.disabled = false; } 13707e8ea635SAtari911 var count = data.count || 0; 13717e8ea635SAtari911 if (count === 0) { 137222228b0eSAtari911 alert(adminLang.no_past_recurring); 13737e8ea635SAtari911 return; 13747e8ea635SAtari911 } 137522228b0eSAtari911 if (!confirm(adminLang.found_past_recurring.replace(/%d/, count))) return; 13767e8ea635SAtari911 13777e8ea635SAtari911 // Step 2: actually delete 137822228b0eSAtari911 if (btn) { btn.textContent = "⏳ " + adminLang.trimming; btn.disabled = true; } 13797e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 13807e8ea635SAtari911 method: "POST", 13817e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 13827e8ea635SAtari911 body: "call=plugin_calendar&action=trim_all_past_recurring§ok=" + JSINFO.sectok 13837e8ea635SAtari911 }) 13847e8ea635SAtari911 .then(function(r) { return r.json(); }) 13857e8ea635SAtari911 .then(function(data2) { 13867e8ea635SAtari911 if (btn) { 138722228b0eSAtari911 btn.textContent = data2.success ? ("✅ " + (data2.count || 0)) : "❌"; 13887e8ea635SAtari911 btn.disabled = false; 13897e8ea635SAtari911 } 139022228b0eSAtari911 setTimeout(function() { if (btn) btn.textContent = "✂️ " + adminLang.trim_all_past; }, 3000); 13917e8ea635SAtari911 rescanRecurringEvents(); 13927e8ea635SAtari911 }); 13937e8ea635SAtari911 }) 13947e8ea635SAtari911 .catch(function(err) { 139522228b0eSAtari911 if (btn) { btn.textContent = "✂️ " + adminLang.trim_all_past; btn.disabled = false; } 13967e8ea635SAtari911 }); 13977e8ea635SAtari911 } 13987e8ea635SAtari911 13997e8ea635SAtari911 function rescanRecurringEvents() { 14007e8ea635SAtari911 var btn = document.getElementById("rescan-recurring-btn"); 14017e8ea635SAtari911 var content = document.getElementById("recurring-content"); 14027e8ea635SAtari911 if (btn) { btn.textContent = "⏳ Scanning..."; btn.disabled = true; } 14037e8ea635SAtari911 14047e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 14057e8ea635SAtari911 method: "POST", 14067e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 14077e8ea635SAtari911 body: "call=plugin_calendar&action=rescan_recurring§ok=" + JSINFO.sectok 14087e8ea635SAtari911 }) 14097e8ea635SAtari911 .then(function(r) { return r.json(); }) 14107e8ea635SAtari911 .then(function(data) { 14117e8ea635SAtari911 if (data.success && content) { 14127e8ea635SAtari911 content.innerHTML = data.html; 14137e8ea635SAtari911 } 14147e8ea635SAtari911 if (btn) { btn.textContent = " Rescan (" + (data.count || 0) + " found)"; btn.disabled = false; } 14157e8ea635SAtari911 setTimeout(function() { if (btn) btn.textContent = " Rescan"; }, 3000); 14167e8ea635SAtari911 }) 14177e8ea635SAtari911 .catch(function(err) { 14187e8ea635SAtari911 if (btn) { btn.textContent = " Rescan"; btn.disabled = false; } 14197e8ea635SAtari911 console.error("Rescan failed:", err); 14207e8ea635SAtari911 }); 14217e8ea635SAtari911 } 14227e8ea635SAtari911 14237e8ea635SAtari911 function recurringAction(action, params, statusEl) { 14247e8ea635SAtari911 if (statusEl) statusEl.textContent = "⏳ Working..."; 14257e8ea635SAtari911 var body = "call=plugin_calendar&action=" + action + "§ok=" + JSINFO.sectok; 14267e8ea635SAtari911 for (var key in params) { 14277e8ea635SAtari911 body += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]); 14287e8ea635SAtari911 } 14297e8ea635SAtari911 return fetch(DOKU_BASE + "lib/exe/ajax.php", { 14307e8ea635SAtari911 method: "POST", 14317e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 14327e8ea635SAtari911 body: body 14337e8ea635SAtari911 }) 14347e8ea635SAtari911 .then(function(r) { return r.json(); }) 14357e8ea635SAtari911 .then(function(data) { 14367e8ea635SAtari911 if (statusEl) { 14377e8ea635SAtari911 statusEl.textContent = data.success ? ("✅ " + data.message) : ("❌ " + (data.error || "Failed")); 14387e8ea635SAtari911 statusEl.style.color = data.success ? "#00cc07" : "#e74c3c"; 14397e8ea635SAtari911 } 14407e8ea635SAtari911 return data; 14417e8ea635SAtari911 }) 14427e8ea635SAtari911 .catch(function(err) { 14437e8ea635SAtari911 if (statusEl) { statusEl.textContent = "❌ Error: " + err; statusEl.style.color = "#e74c3c"; } 14447e8ea635SAtari911 }); 14457e8ea635SAtari911 } 14467e8ea635SAtari911 144796df7d3eSAtari911 function manageRecurringSeries(title, namespace, count, firstDate, lastDate, pattern, hasFlag) { 14487e8ea635SAtari911 var isPaused = title.indexOf("⏸") === 0; 14497e8ea635SAtari911 var cleanTitle = title.replace(/^⏸\s*/, ""); 14507e8ea635SAtari911 var safeTitle = title.replace(/\x27/g, "\\\x27"); 14517e8ea635SAtari911 var todayStr = new Date().toISOString().split("T")[0]; 14527e8ea635SAtari911 14537e8ea635SAtari911 var dialog = document.createElement("div"); 14547e8ea635SAtari911 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;"; 14557e8ea635SAtari911 dialog.addEventListener("click", function(e) { if (e.target === dialog) dialog.remove(); }); 14567e8ea635SAtari911 14577e8ea635SAtari911 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;\">"; 145822228b0eSAtari911 h += "<h3 style=\"margin:0 0 5px; color:#00cc07;\">⚙️ " + adminLang.manage_recurring_title + "</h3>"; 145922228b0eSAtari911 h += "<p style=\"margin:0 0 15px; color:' . $colors['text'] . '; font-size:13px;\"><strong>" + cleanTitle + "</strong><br>" + count + " " + adminLang.occurrences + " · " + pattern + "<br>" + firstDate + " → " + lastDate + "</p>"; 14607e8ea635SAtari911 h += "<div id=\"manage-status\" style=\"font-size:12px; min-height:18px; margin-bottom:10px;\"></div>"; 14617e8ea635SAtari911 14627e8ea635SAtari911 // Extend 14637e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 146422228b0eSAtari911 h += "<div style=\"font-weight:700; color:#00cc07; font-size:12px; margin-bottom:6px;\"> " + adminLang.extend_series + "</div>"; 14657e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 146622228b0eSAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">" + adminLang.add_occurrences + "</label>"; 14677e8ea635SAtari911 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>"; 146822228b0eSAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">" + adminLang.days_apart + "</label>"; 14697e8ea635SAtari911 h += "<select id=\"manage-extend-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">"; 147022228b0eSAtari911 h += "<option value=\"1\">" + adminLang.interval_daily + "</option><option value=\"7\" selected>" + adminLang.interval_weekly + "</option><option value=\"14\">" + adminLang.interval_biweekly + "</option><option value=\"30\">" + adminLang.interval_monthly + "</option><option value=\"90\">" + adminLang.interval_quarterly + "</option><option value=\"365\">" + adminLang.interval_yearly + "</option></select></div>"; 147122228b0eSAtari911 h += "<button onclick=\"recurringAction(\x27extend_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, count:document.getElementById(\x27manage-extend-count\x27).value, interval_days:document.getElementById(\x27manage-extend-interval\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#00cc07; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">" + adminLang.btn_extend + "</button>"; 14727e8ea635SAtari911 h += "</div></div>"; 14737e8ea635SAtari911 14747e8ea635SAtari911 // Trim 14757e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 147622228b0eSAtari911 h += "<div style=\"font-weight:700; color:#e74c3c; font-size:12px; margin-bottom:6px;\">✂️ " + adminLang.trim_past_events + "</div>"; 14777e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 147822228b0eSAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">" + adminLang.remove_before + "</label>"; 14797e8ea635SAtari911 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>"; 148022228b0eSAtari911 h += "<button onclick=\"if(confirm(adminLang.trim_confirm.replace(/%s/, document.getElementById(\x27manage-trim-date\x27).value))) recurringAction(\x27trim_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, cutoff_date:document.getElementById(\x27manage-trim-date\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#e74c3c; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">" + adminLang.btn_trim + "</button>"; 14817e8ea635SAtari911 h += "</div></div>"; 14827e8ea635SAtari911 14837e8ea635SAtari911 // Change Pattern 14847e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 148522228b0eSAtari911 h += "<div style=\"font-weight:700; color:#ff9800; font-size:12px; margin-bottom:6px;\"> " + adminLang.change_pattern + "</div>"; 148622228b0eSAtari911 h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">" + adminLang.respace_note + "</p>"; 14877e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 148822228b0eSAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">" + adminLang.new_interval + "</label>"; 14897e8ea635SAtari911 h += "<select id=\"manage-pattern-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">"; 149022228b0eSAtari911 h += "<option value=\"1\">" + adminLang.interval_daily + "</option><option value=\"7\">" + adminLang.interval_weekly + "</option><option value=\"14\">" + adminLang.interval_biweekly + "</option><option value=\"30\">" + adminLang.interval_monthly + "</option><option value=\"90\">" + adminLang.interval_quarterly + "</option><option value=\"365\">" + adminLang.interval_yearly + "</option></select></div>"; 149122228b0eSAtari911 h += "<button onclick=\"if(confirm(adminLang.respace_confirm)) recurringAction(\x27change_pattern_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, interval_days:document.getElementById(\x27manage-pattern-interval\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#ff9800; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">" + adminLang.btn_change + "</button>"; 14927e8ea635SAtari911 h += "</div></div>"; 14937e8ea635SAtari911 14947e8ea635SAtari911 // Change Start Date 14957e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 149622228b0eSAtari911 h += "<div style=\"font-weight:700; color:#2196f3; font-size:12px; margin-bottom:6px;\"> " + adminLang.change_start_date + "</div>"; 149722228b0eSAtari911 h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">" + adminLang.shift_note + "</p>"; 14987e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 149922228b0eSAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">" + adminLang.current_label + " " + firstDate + "</label>"; 15007e8ea635SAtari911 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>"; 150122228b0eSAtari911 h += "<button onclick=\"if(confirm(adminLang.shift_confirm)) recurringAction(\x27change_start_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, new_start_date:document.getElementById(\x27manage-start-date\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#2196f3; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">" + adminLang.btn_shift + "</button>"; 15027e8ea635SAtari911 h += "</div></div>"; 15037e8ea635SAtari911 15047e8ea635SAtari911 // Pause/Resume 15057e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 150622228b0eSAtari911 h += "<div style=\"font-weight:700; color:#9c27b0; font-size:12px; margin-bottom:6px;\">" + (isPaused ? "▶️ " + adminLang.resume_series : "⏸ " + adminLang.pause_series) + "</div>"; 150722228b0eSAtari911 h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">" + (isPaused ? adminLang.resume_note : adminLang.pause_note) + "</p>"; 150822228b0eSAtari911 h += "<button onclick=\"recurringAction(\x27" + (isPaused ? "resume_recurring" : "pause_recurring") + "\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27}, document.getElementById(\x27manage-status\x27))\" style=\"background:#9c27b0; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">" + (isPaused ? "▶️ " + adminLang.btn_resume : "⏸ " + adminLang.btn_pause) + "</button>"; 15097e8ea635SAtari911 h += "</div>"; 15107e8ea635SAtari911 15117e8ea635SAtari911 // Close 15127e8ea635SAtari911 h += "<div style=\"text-align:right; margin-top:10px;\">"; 151322228b0eSAtari911 h += "<button onclick=\"this.closest(\x27[style*=fixed]\x27).remove(); rescanRecurringEvents();\" style=\"background:#666; color:#fff; border:none; padding:8px 20px; border-radius:3px; cursor:pointer; font-weight:600;\">" + adminLang.btn_close + "</button>"; 15147e8ea635SAtari911 h += "</div></div>"; 15157e8ea635SAtari911 15167e8ea635SAtari911 dialog.innerHTML = h; 15177e8ea635SAtari911 document.body.appendChild(dialog); 15187e8ea635SAtari911 } 15197e8ea635SAtari911 15201d05cddcSAtari911 function sortRecurringTable(columnIndex) { 15211d05cddcSAtari911 const table = document.getElementById("recurringTable"); 15221d05cddcSAtari911 const tbody = document.getElementById("recurringTableBody"); 15231d05cddcSAtari911 15249ccd446eSAtari911 if (!table || !tbody) return; 15251d05cddcSAtari911 15261d05cddcSAtari911 const rows = Array.from(tbody.querySelectorAll("tr")); 15279ccd446eSAtari911 if (rows.length === 0) return; 15281d05cddcSAtari911 15291d05cddcSAtari911 // Toggle sort direction for this column 15301d05cddcSAtari911 if (!sortDirection[columnIndex]) { 15311d05cddcSAtari911 sortDirection[columnIndex] = "asc"; 15321d05cddcSAtari911 } else { 15331d05cddcSAtari911 sortDirection[columnIndex] = sortDirection[columnIndex] === "asc" ? "desc" : "asc"; 15341d05cddcSAtari911 } 15351d05cddcSAtari911 15361d05cddcSAtari911 const direction = sortDirection[columnIndex]; 15371d05cddcSAtari911 const isNumeric = columnIndex === 4; // Count column 15381d05cddcSAtari911 15391d05cddcSAtari911 // Sort rows 15401d05cddcSAtari911 rows.sort((a, b) => { 15411d05cddcSAtari911 let aValue = a.cells[columnIndex].textContent.trim(); 15421d05cddcSAtari911 let bValue = b.cells[columnIndex].textContent.trim(); 15431d05cddcSAtari911 15441d05cddcSAtari911 // Extract text from code elements for namespace column 15451d05cddcSAtari911 if (columnIndex === 1) { 15461d05cddcSAtari911 const aCode = a.cells[columnIndex].querySelector("code"); 15471d05cddcSAtari911 const bCode = b.cells[columnIndex].querySelector("code"); 15481d05cddcSAtari911 aValue = aCode ? aCode.textContent.trim() : aValue; 15491d05cddcSAtari911 bValue = bCode ? bCode.textContent.trim() : bValue; 15501d05cddcSAtari911 } 15511d05cddcSAtari911 15521d05cddcSAtari911 // Extract number from strong elements for count column 15531d05cddcSAtari911 if (isNumeric) { 15541d05cddcSAtari911 const aStrong = a.cells[columnIndex].querySelector("strong"); 15551d05cddcSAtari911 const bStrong = b.cells[columnIndex].querySelector("strong"); 15561d05cddcSAtari911 aValue = aStrong ? parseInt(aStrong.textContent.trim()) : 0; 15571d05cddcSAtari911 bValue = bStrong ? parseInt(bStrong.textContent.trim()) : 0; 15581d05cddcSAtari911 15591d05cddcSAtari911 return direction === "asc" ? aValue - bValue : bValue - aValue; 15601d05cddcSAtari911 } 15611d05cddcSAtari911 15621d05cddcSAtari911 // String comparison 15631d05cddcSAtari911 if (direction === "asc") { 15641d05cddcSAtari911 return aValue.localeCompare(bValue); 15651d05cddcSAtari911 } else { 15661d05cddcSAtari911 return bValue.localeCompare(aValue); 15671d05cddcSAtari911 } 15681d05cddcSAtari911 }); 15691d05cddcSAtari911 15701d05cddcSAtari911 // Update arrows 15711d05cddcSAtari911 const headers = table.querySelectorAll("th"); 15721d05cddcSAtari911 headers.forEach((header, index) => { 15731d05cddcSAtari911 const arrow = header.querySelector(".sort-arrow"); 15741d05cddcSAtari911 if (arrow) { 15751d05cddcSAtari911 if (index === columnIndex) { 15761d05cddcSAtari911 arrow.textContent = direction === "asc" ? "↑" : "↓"; 15771d05cddcSAtari911 arrow.style.color = "#00cc07"; 15781d05cddcSAtari911 } else { 15791d05cddcSAtari911 arrow.textContent = "⇅"; 15801d05cddcSAtari911 arrow.style.color = "#999"; 15811d05cddcSAtari911 } 15821d05cddcSAtari911 } 15831d05cddcSAtari911 }); 15841d05cddcSAtari911 15851d05cddcSAtari911 // Rebuild tbody 15861d05cddcSAtari911 rows.forEach(row => tbody.appendChild(row)); 15871d05cddcSAtari911 } 15881d05cddcSAtari911 15891d05cddcSAtari911 function filterRecurringEvents() { 15901d05cddcSAtari911 const searchInput = document.getElementById("searchRecurring"); 15911d05cddcSAtari911 const filter = normalizeText(searchInput.value); 15921d05cddcSAtari911 const tbody = document.getElementById("recurringTableBody"); 15931d05cddcSAtari911 const rows = tbody.getElementsByTagName("tr"); 15941d05cddcSAtari911 15951d05cddcSAtari911 for (let i = 0; i < rows.length; i++) { 15961d05cddcSAtari911 const row = rows[i]; 15971d05cddcSAtari911 const titleCell = row.getElementsByTagName("td")[0]; 15981d05cddcSAtari911 15991d05cddcSAtari911 if (titleCell) { 16001d05cddcSAtari911 const titleText = normalizeText(titleCell.textContent || titleCell.innerText); 16011d05cddcSAtari911 16021d05cddcSAtari911 if (titleText.indexOf(filter) > -1) { 16031d05cddcSAtari911 row.classList.remove("recurring-row-hidden"); 16041d05cddcSAtari911 } else { 16051d05cddcSAtari911 row.classList.add("recurring-row-hidden"); 16061d05cddcSAtari911 } 16071d05cddcSAtari911 } 16081d05cddcSAtari911 } 16091d05cddcSAtari911 } 16101d05cddcSAtari911 16111d05cddcSAtari911 function normalizeText(text) { 16121d05cddcSAtari911 // Convert to lowercase 16131d05cddcSAtari911 text = text.toLowerCase(); 16141d05cddcSAtari911 16151d05cddcSAtari911 // Remove apostrophes and quotes 16161d05cddcSAtari911 text = text.replace(/[\'\"]/g, ""); 16171d05cddcSAtari911 16181d05cddcSAtari911 // Replace accented characters with regular ones 16191d05cddcSAtari911 text = text.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 16201d05cddcSAtari911 16211d05cddcSAtari911 // Remove special characters except spaces and alphanumeric 16221d05cddcSAtari911 text = text.replace(/[^a-z0-9\s]/g, ""); 16231d05cddcSAtari911 16241d05cddcSAtari911 // Collapse multiple spaces 16251d05cddcSAtari911 text = text.replace(/\s+/g, " "); 16261d05cddcSAtari911 16271d05cddcSAtari911 return text.trim(); 16281d05cddcSAtari911 } 16291d05cddcSAtari911 16301d05cddcSAtari911 function filterEvents() { 16311d05cddcSAtari911 const searchText = normalizeText(document.getElementById("searchEvents").value); 16321d05cddcSAtari911 const eventRows = document.querySelectorAll(".event-row"); 16331d05cddcSAtari911 let visibleCount = 0; 16341d05cddcSAtari911 16351d05cddcSAtari911 eventRows.forEach(row => { 16361d05cddcSAtari911 const titleElement = row.querySelector("div div"); 16371d05cddcSAtari911 const originalTitle = titleElement.getAttribute("data-original-title") || titleElement.textContent; 16381d05cddcSAtari911 16391d05cddcSAtari911 // Store original title if not already stored 16401d05cddcSAtari911 if (!titleElement.getAttribute("data-original-title")) { 16411d05cddcSAtari911 titleElement.setAttribute("data-original-title", originalTitle); 16421d05cddcSAtari911 } 16431d05cddcSAtari911 16441d05cddcSAtari911 const normalizedTitle = normalizeText(originalTitle); 16451d05cddcSAtari911 16461d05cddcSAtari911 if (normalizedTitle.includes(searchText) || searchText === "") { 16471d05cddcSAtari911 row.style.display = "flex"; 16481d05cddcSAtari911 visibleCount++; 16491d05cddcSAtari911 } else { 16501d05cddcSAtari911 row.style.display = "none"; 16511d05cddcSAtari911 } 16521d05cddcSAtari911 }); 16531d05cddcSAtari911 16541d05cddcSAtari911 // Update namespace visibility and counts 16551d05cddcSAtari911 document.querySelectorAll("[id^=ns_]").forEach(nsDiv => { 16561d05cddcSAtari911 if (nsDiv.id.endsWith("_arrow") || nsDiv.id.endsWith("_check")) return; 16571d05cddcSAtari911 16581d05cddcSAtari911 const visibleEvents = nsDiv.querySelectorAll(".event-row[style*=\\"display: flex\\"], .event-row:not([style*=\\"display: none\\"])").length; 16591d05cddcSAtari911 const nsId = nsDiv.id; 16601d05cddcSAtari911 const arrow = document.getElementById(nsId + "_arrow"); 16611d05cddcSAtari911 16621d05cddcSAtari911 // Auto-expand namespaces with matches when searching 16631d05cddcSAtari911 if (searchText && visibleEvents > 0) { 16641d05cddcSAtari911 nsDiv.style.display = "block"; 16651d05cddcSAtari911 if (arrow) arrow.textContent = "▼"; 16661d05cddcSAtari911 } 16671d05cddcSAtari911 }); 16681d05cddcSAtari911 } 16691d05cddcSAtari911 16701d05cddcSAtari911 function toggleNamespace(id) { 16711d05cddcSAtari911 const elem = document.getElementById(id); 16721d05cddcSAtari911 const arrow = document.getElementById(id + "_arrow"); 16731d05cddcSAtari911 if (elem.style.display === "none") { 16741d05cddcSAtari911 elem.style.display = "block"; 16751d05cddcSAtari911 arrow.textContent = "▼"; 16761d05cddcSAtari911 } else { 16771d05cddcSAtari911 elem.style.display = "none"; 16781d05cddcSAtari911 arrow.textContent = "▶"; 16791d05cddcSAtari911 } 16801d05cddcSAtari911 } 16811d05cddcSAtari911 16821d05cddcSAtari911 function toggleNamespaceSelect(nsId) { 16831d05cddcSAtari911 const checkbox = document.getElementById(nsId + "_check"); 16841d05cddcSAtari911 const events = document.querySelectorAll("." + nsId + "_events"); 16851d05cddcSAtari911 16861d05cddcSAtari911 // Only select visible events (not hidden by search) 16871d05cddcSAtari911 events.forEach(cb => { 16881d05cddcSAtari911 const eventRow = cb.closest(".event-row"); 16891d05cddcSAtari911 if (eventRow && eventRow.style.display !== "none") { 16901d05cddcSAtari911 cb.checked = checkbox.checked; 16911d05cddcSAtari911 } 16921d05cddcSAtari911 }); 16931d05cddcSAtari911 updateCount(); 16941d05cddcSAtari911 } 16951d05cddcSAtari911 16961d05cddcSAtari911 function selectAll() { 16971d05cddcSAtari911 // Only select visible events 16981d05cddcSAtari911 document.querySelectorAll(".event-checkbox").forEach(cb => { 16991d05cddcSAtari911 const eventRow = cb.closest(".event-row"); 17001d05cddcSAtari911 if (eventRow && eventRow.style.display !== "none") { 17011d05cddcSAtari911 cb.checked = true; 17021d05cddcSAtari911 } 17031d05cddcSAtari911 }); 17041d05cddcSAtari911 // Update namespace checkboxes to indeterminate if partially selected 17051d05cddcSAtari911 document.querySelectorAll("input[id$=_check]").forEach(nsCheckbox => { 17061d05cddcSAtari911 const nsId = nsCheckbox.id.replace("_check", ""); 17071d05cddcSAtari911 const events = document.querySelectorAll("." + nsId + "_events"); 17081d05cddcSAtari911 const visibleEvents = Array.from(events).filter(cb => { 17091d05cddcSAtari911 const row = cb.closest(".event-row"); 17101d05cddcSAtari911 return row && row.style.display !== "none"; 17111d05cddcSAtari911 }); 17121d05cddcSAtari911 const checkedVisible = visibleEvents.filter(cb => cb.checked); 17131d05cddcSAtari911 17141d05cddcSAtari911 if (checkedVisible.length === visibleEvents.length && visibleEvents.length > 0) { 17151d05cddcSAtari911 nsCheckbox.checked = true; 17161d05cddcSAtari911 } else if (checkedVisible.length > 0) { 17171d05cddcSAtari911 nsCheckbox.indeterminate = true; 17181d05cddcSAtari911 } else { 17191d05cddcSAtari911 nsCheckbox.checked = false; 17201d05cddcSAtari911 } 17211d05cddcSAtari911 }); 17221d05cddcSAtari911 updateCount(); 17231d05cddcSAtari911 } 17241d05cddcSAtari911 17251d05cddcSAtari911 function deselectAll() { 17261d05cddcSAtari911 document.querySelectorAll(".event-checkbox").forEach(cb => cb.checked = false); 17271d05cddcSAtari911 document.querySelectorAll("input[id$=_check]").forEach(cb => { 17281d05cddcSAtari911 cb.checked = false; 17291d05cddcSAtari911 cb.indeterminate = false; 17301d05cddcSAtari911 }); 17311d05cddcSAtari911 updateCount(); 17321d05cddcSAtari911 } 17331d05cddcSAtari911 17341d05cddcSAtari911 function deleteSelected() { 17351d05cddcSAtari911 const checkedBoxes = document.querySelectorAll(".event-checkbox:checked"); 17361d05cddcSAtari911 if (checkedBoxes.length === 0) { 173722228b0eSAtari911 alert(adminLang.no_events_selected); 17381d05cddcSAtari911 return; 17391d05cddcSAtari911 } 17401d05cddcSAtari911 17411d05cddcSAtari911 const count = checkedBoxes.length; 174222228b0eSAtari911 if (!confirm(adminLang.delete_confirm.replace(/%d/, count))) { 17431d05cddcSAtari911 return; 17441d05cddcSAtari911 } 17451d05cddcSAtari911 17461d05cddcSAtari911 const form = document.createElement("form"); 17471d05cddcSAtari911 form.method = "POST"; 17481d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 17491d05cddcSAtari911 1750*2866e827SAtari911 var sectokInput = document.createElement("input"); 1751*2866e827SAtari911 sectokInput.type = "hidden"; 1752*2866e827SAtari911 sectokInput.name = "sectok"; 1753*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 1754*2866e827SAtari911 form.appendChild(sectokInput); 1755*2866e827SAtari911 17561d05cddcSAtari911 const actionInput = document.createElement("input"); 17571d05cddcSAtari911 actionInput.type = "hidden"; 17581d05cddcSAtari911 actionInput.name = "action"; 17591d05cddcSAtari911 actionInput.value = "delete_selected_events"; 17601d05cddcSAtari911 form.appendChild(actionInput); 17611d05cddcSAtari911 17621d05cddcSAtari911 checkedBoxes.forEach(cb => { 17631d05cddcSAtari911 const eventInput = document.createElement("input"); 17641d05cddcSAtari911 eventInput.type = "hidden"; 17651d05cddcSAtari911 eventInput.name = "events[]"; 17661d05cddcSAtari911 eventInput.value = cb.value; 17671d05cddcSAtari911 form.appendChild(eventInput); 17681d05cddcSAtari911 }); 17691d05cddcSAtari911 17701d05cddcSAtari911 document.body.appendChild(form); 17711d05cddcSAtari911 form.submit(); 17721d05cddcSAtari911 } 17731d05cddcSAtari911 17741d05cddcSAtari911 function createNewNamespace() { 177522228b0eSAtari911 const namespaceName = prompt(adminLang.enter_namespace); 17761d05cddcSAtari911 17771d05cddcSAtari911 if (!namespaceName) { 17781d05cddcSAtari911 return; // Cancelled 17791d05cddcSAtari911 } 17801d05cddcSAtari911 17811d05cddcSAtari911 // Validate namespace name 17821d05cddcSAtari911 if (!/^[a-zA-Z0-9_:-]+$/.test(namespaceName)) { 178322228b0eSAtari911 alert(adminLang.invalid_namespace); 17841d05cddcSAtari911 return; 17851d05cddcSAtari911 } 17861d05cddcSAtari911 17871d05cddcSAtari911 // Submit form to create namespace 17881d05cddcSAtari911 const form = document.createElement("form"); 17891d05cddcSAtari911 form.method = "POST"; 17901d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 17911d05cddcSAtari911 1792*2866e827SAtari911 var sectokInput = document.createElement("input"); 1793*2866e827SAtari911 sectokInput.type = "hidden"; 1794*2866e827SAtari911 sectokInput.name = "sectok"; 1795*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 1796*2866e827SAtari911 form.appendChild(sectokInput); 1797*2866e827SAtari911 17981d05cddcSAtari911 const actionInput = document.createElement("input"); 17991d05cddcSAtari911 actionInput.type = "hidden"; 18001d05cddcSAtari911 actionInput.name = "action"; 18011d05cddcSAtari911 actionInput.value = "create_namespace"; 18021d05cddcSAtari911 form.appendChild(actionInput); 18031d05cddcSAtari911 18041d05cddcSAtari911 const namespaceInput = document.createElement("input"); 18051d05cddcSAtari911 namespaceInput.type = "hidden"; 18061d05cddcSAtari911 namespaceInput.name = "namespace_name"; 18071d05cddcSAtari911 namespaceInput.value = namespaceName; 18081d05cddcSAtari911 form.appendChild(namespaceInput); 18091d05cddcSAtari911 18101d05cddcSAtari911 document.body.appendChild(form); 18111d05cddcSAtari911 form.submit(); 18121d05cddcSAtari911 } 18131d05cddcSAtari911 18141d05cddcSAtari911 function updateCount() { 18151d05cddcSAtari911 const count = document.querySelectorAll(".event-checkbox:checked").length; 181622228b0eSAtari911 document.getElementById("selectedCount").textContent = adminLang.x_selected.replace(/%d/, count); 18171d05cddcSAtari911 } 18181d05cddcSAtari911 18191d05cddcSAtari911 function deleteNamespace(namespace) { 182022228b0eSAtari911 const displayName = namespace || adminLang.default_ns; 182122228b0eSAtari911 if (!confirm(adminLang.delete_ns_confirm.replace(/%s/, displayName))) { 18221d05cddcSAtari911 return; 18231d05cddcSAtari911 } 18241d05cddcSAtari911 const form = document.createElement("form"); 18251d05cddcSAtari911 form.method = "POST"; 18261d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 1827*2866e827SAtari911 1828*2866e827SAtari911 var sectokInput = document.createElement("input"); 1829*2866e827SAtari911 sectokInput.type = "hidden"; 1830*2866e827SAtari911 sectokInput.name = "sectok"; 1831*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 1832*2866e827SAtari911 form.appendChild(sectokInput); 18331d05cddcSAtari911 const actionInput = document.createElement("input"); 18341d05cddcSAtari911 actionInput.type = "hidden"; 18351d05cddcSAtari911 actionInput.name = "action"; 18361d05cddcSAtari911 actionInput.value = "delete_namespace"; 18371d05cddcSAtari911 form.appendChild(actionInput); 18381d05cddcSAtari911 const nsInput = document.createElement("input"); 18391d05cddcSAtari911 nsInput.type = "hidden"; 18401d05cddcSAtari911 nsInput.name = "namespace"; 18411d05cddcSAtari911 nsInput.value = namespace; 18421d05cddcSAtari911 form.appendChild(nsInput); 18431d05cddcSAtari911 document.body.appendChild(form); 18441d05cddcSAtari911 form.submit(); 18451d05cddcSAtari911 } 18461d05cddcSAtari911 18479ccd446eSAtari911 function renameNamespace(oldNamespace) { 184822228b0eSAtari911 const displayName = oldNamespace || adminLang.default_ns; 184922228b0eSAtari911 const newName = prompt(adminLang.rename_namespace.replace(/%s/, displayName), oldNamespace); 18509ccd446eSAtari911 if (newName === null || newName === oldNamespace) { 18519ccd446eSAtari911 return; // Cancelled or no change 18529ccd446eSAtari911 } 18539ccd446eSAtari911 const form = document.createElement("form"); 18549ccd446eSAtari911 form.method = "POST"; 18559ccd446eSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 1856*2866e827SAtari911 1857*2866e827SAtari911 var sectokInput = document.createElement("input"); 1858*2866e827SAtari911 sectokInput.type = "hidden"; 1859*2866e827SAtari911 sectokInput.name = "sectok"; 1860*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 1861*2866e827SAtari911 form.appendChild(sectokInput); 18629ccd446eSAtari911 const actionInput = document.createElement("input"); 18639ccd446eSAtari911 actionInput.type = "hidden"; 18649ccd446eSAtari911 actionInput.name = "action"; 18659ccd446eSAtari911 actionInput.value = "rename_namespace"; 18669ccd446eSAtari911 form.appendChild(actionInput); 18679ccd446eSAtari911 const oldInput = document.createElement("input"); 18689ccd446eSAtari911 oldInput.type = "hidden"; 18699ccd446eSAtari911 oldInput.name = "old_namespace"; 18709ccd446eSAtari911 oldInput.value = oldNamespace; 18719ccd446eSAtari911 form.appendChild(oldInput); 18729ccd446eSAtari911 const newInput = document.createElement("input"); 18739ccd446eSAtari911 newInput.type = "hidden"; 18749ccd446eSAtari911 newInput.name = "new_namespace"; 18759ccd446eSAtari911 newInput.value = newName; 18769ccd446eSAtari911 form.appendChild(newInput); 18779ccd446eSAtari911 document.body.appendChild(form); 18789ccd446eSAtari911 form.submit(); 18799ccd446eSAtari911 } 18809ccd446eSAtari911 18811d05cddcSAtari911 let draggedEvent = null; 18821d05cddcSAtari911 18831d05cddcSAtari911 function dragStart(event, eventId) { 18841d05cddcSAtari911 const checkbox = event.target.closest(".event-row").querySelector(".event-checkbox"); 18851d05cddcSAtari911 18861d05cddcSAtari911 // If this event is checked, drag all checked events 18871d05cddcSAtari911 const checkedBoxes = document.querySelectorAll(".event-checkbox:checked"); 18881d05cddcSAtari911 if (checkbox && checkbox.checked && checkedBoxes.length > 1) { 18891d05cddcSAtari911 // Dragging multiple selected events 18901d05cddcSAtari911 draggedEvent = "MULTIPLE"; 18911d05cddcSAtari911 event.dataTransfer.setData("text/plain", "MULTIPLE"); 18921d05cddcSAtari911 } else { 18931d05cddcSAtari911 // Dragging single event 18941d05cddcSAtari911 draggedEvent = eventId; 18951d05cddcSAtari911 event.dataTransfer.setData("text/plain", eventId); 18961d05cddcSAtari911 } 18971d05cddcSAtari911 event.dataTransfer.effectAllowed = "move"; 18981d05cddcSAtari911 event.target.style.opacity = "0.5"; 18991d05cddcSAtari911 } 19001d05cddcSAtari911 19011d05cddcSAtari911 function allowDrop(event) { 19021d05cddcSAtari911 event.preventDefault(); 19031d05cddcSAtari911 event.dataTransfer.dropEffect = "move"; 19041d05cddcSAtari911 } 19051d05cddcSAtari911 19061d05cddcSAtari911 function drop(event, targetNamespace) { 19071d05cddcSAtari911 event.preventDefault(); 19081d05cddcSAtari911 19091d05cddcSAtari911 if (draggedEvent === "MULTIPLE") { 19101d05cddcSAtari911 // Move all selected events 19111d05cddcSAtari911 const checkedBoxes = document.querySelectorAll(".event-checkbox:checked"); 19121d05cddcSAtari911 if (checkedBoxes.length === 0) return; 19131d05cddcSAtari911 19141d05cddcSAtari911 const form = document.createElement("form"); 19151d05cddcSAtari911 form.method = "POST"; 19161d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 19171d05cddcSAtari911 1918*2866e827SAtari911 var sectokInput = document.createElement("input"); 1919*2866e827SAtari911 sectokInput.type = "hidden"; 1920*2866e827SAtari911 sectokInput.name = "sectok"; 1921*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 1922*2866e827SAtari911 form.appendChild(sectokInput); 1923*2866e827SAtari911 19241d05cddcSAtari911 const actionInput = document.createElement("input"); 19251d05cddcSAtari911 actionInput.type = "hidden"; 19261d05cddcSAtari911 actionInput.name = "action"; 19271d05cddcSAtari911 actionInput.value = "move_selected_events"; 19281d05cddcSAtari911 form.appendChild(actionInput); 19291d05cddcSAtari911 19301d05cddcSAtari911 checkedBoxes.forEach(cb => { 19311d05cddcSAtari911 const eventInput = document.createElement("input"); 19321d05cddcSAtari911 eventInput.type = "hidden"; 19331d05cddcSAtari911 eventInput.name = "events[]"; 19341d05cddcSAtari911 eventInput.value = cb.value; 19351d05cddcSAtari911 form.appendChild(eventInput); 19361d05cddcSAtari911 }); 19371d05cddcSAtari911 19381d05cddcSAtari911 const targetInput = document.createElement("input"); 19391d05cddcSAtari911 targetInput.type = "hidden"; 19401d05cddcSAtari911 targetInput.name = "target_namespace"; 19411d05cddcSAtari911 targetInput.value = targetNamespace; 19421d05cddcSAtari911 form.appendChild(targetInput); 19431d05cddcSAtari911 19441d05cddcSAtari911 document.body.appendChild(form); 19451d05cddcSAtari911 form.submit(); 19461d05cddcSAtari911 } else { 19471d05cddcSAtari911 // Move single event 19481d05cddcSAtari911 if (!draggedEvent) return; 19491d05cddcSAtari911 const parts = draggedEvent.split("|"); 19501d05cddcSAtari911 const sourceNamespace = parts[1]; 19511d05cddcSAtari911 if (sourceNamespace === targetNamespace) return; 19521d05cddcSAtari911 19531d05cddcSAtari911 const form = document.createElement("form"); 19541d05cddcSAtari911 form.method = "POST"; 19551d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 1956*2866e827SAtari911 1957*2866e827SAtari911 var sectokInput = document.createElement("input"); 1958*2866e827SAtari911 sectokInput.type = "hidden"; 1959*2866e827SAtari911 sectokInput.name = "sectok"; 1960*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 1961*2866e827SAtari911 form.appendChild(sectokInput); 19621d05cddcSAtari911 const actionInput = document.createElement("input"); 19631d05cddcSAtari911 actionInput.type = "hidden"; 19641d05cddcSAtari911 actionInput.name = "action"; 19651d05cddcSAtari911 actionInput.value = "move_single_event"; 19661d05cddcSAtari911 form.appendChild(actionInput); 19671d05cddcSAtari911 const eventInput = document.createElement("input"); 19681d05cddcSAtari911 eventInput.type = "hidden"; 19691d05cddcSAtari911 eventInput.name = "event"; 19701d05cddcSAtari911 eventInput.value = draggedEvent; 19711d05cddcSAtari911 form.appendChild(eventInput); 19721d05cddcSAtari911 const targetInput = document.createElement("input"); 19731d05cddcSAtari911 targetInput.type = "hidden"; 19741d05cddcSAtari911 targetInput.name = "target_namespace"; 19751d05cddcSAtari911 targetInput.value = targetNamespace; 19761d05cddcSAtari911 form.appendChild(targetInput); 19771d05cddcSAtari911 document.body.appendChild(form); 19781d05cddcSAtari911 form.submit(); 19791d05cddcSAtari911 } 19801d05cddcSAtari911 } 19811d05cddcSAtari911 198296df7d3eSAtari911 function editRecurringSeries(title, namespace, time, color, recurrenceType, recurrenceInterval, weekDays, monthlyType, monthDay, ordinalWeek, ordinalDay) { 19839ccd446eSAtari911 // Get available namespaces from the namespace explorer 19849ccd446eSAtari911 const namespaces = new Set(); 19851d05cddcSAtari911 19869ccd446eSAtari911 // Method 1: Try to get from namespace explorer folder names 19879ccd446eSAtari911 document.querySelectorAll("[id^=ns_]").forEach(el => { 19889ccd446eSAtari911 const nsSpan = el.querySelector("span:nth-child(3)"); 19899ccd446eSAtari911 if (nsSpan) { 19909ccd446eSAtari911 let nsText = nsSpan.textContent.replace(" ", "").trim(); 19919ccd446eSAtari911 if (nsText && nsText !== "(default)") { 19929ccd446eSAtari911 namespaces.add(nsText); 19939ccd446eSAtari911 } 19949ccd446eSAtari911 } 19959ccd446eSAtari911 }); 19969ccd446eSAtari911 19979ccd446eSAtari911 // Method 2: Get from datalist if it exists 19989ccd446eSAtari911 document.querySelectorAll("#namespaceList option").forEach(opt => { 19999ccd446eSAtari911 if (opt.value && opt.value !== "") { 20009ccd446eSAtari911 namespaces.add(opt.value); 20019ccd446eSAtari911 } 20029ccd446eSAtari911 }); 20039ccd446eSAtari911 20049ccd446eSAtari911 // Convert to sorted array 20059ccd446eSAtari911 const nsArray = Array.from(namespaces).sort(); 20069ccd446eSAtari911 200796df7d3eSAtari911 // Build namespace options 2008da206178SAtari911 let nsOptions = "<option value=\\"\\">(default)</option>"; 20099ccd446eSAtari911 if (namespace && namespace !== "") { 2010da206178SAtari911 nsOptions += "<option value=\\"" + namespace + "\\" selected>" + namespace + " (current)</option>"; 20119ccd446eSAtari911 } 20129ccd446eSAtari911 for (const ns of nsArray) { 20139ccd446eSAtari911 if (ns !== namespace) { 20149ccd446eSAtari911 nsOptions += "<option value=\\"" + ns + "\\">" + ns + "</option>"; 20151d05cddcSAtari911 } 20161d05cddcSAtari911 } 20171d05cddcSAtari911 201896df7d3eSAtari911 // Build weekday checkboxes - matching event editor style exactly 2019da206178SAtari911 const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 202096df7d3eSAtari911 let weekDayChecks = ""; 202196df7d3eSAtari911 for (let i = 0; i < 7; i++) { 202296df7d3eSAtari911 const checked = weekDays && weekDays.includes(i) ? " checked" : ""; 202396df7d3eSAtari911 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;"> 202496df7d3eSAtari911 <input type="checkbox" name="weekDays" value="${i}"${checked} style="margin-right:3px; width:12px; height:12px;"> 202596df7d3eSAtari911 <span>${dayNames[i]}</span> 202696df7d3eSAtari911 </label>`; 202796df7d3eSAtari911 } 202896df7d3eSAtari911 202996df7d3eSAtari911 // Build ordinal week options 203096df7d3eSAtari911 let ordinalWeekOpts = ""; 2031da206178SAtari911 const ordinalLabels = [[1,"First"], [2,"Second"], [3,"Third"], [4,"Fourth"], [5,"Fifth"], [-1,"Last"]]; 203296df7d3eSAtari911 for (const [val, label] of ordinalLabels) { 203396df7d3eSAtari911 const selected = val === ordinalWeek ? " selected" : ""; 203496df7d3eSAtari911 ordinalWeekOpts += `<option value="${val}"${selected}>${label}</option>`; 203596df7d3eSAtari911 } 203696df7d3eSAtari911 203796df7d3eSAtari911 // Build ordinal day options - full day names like event editor 2038da206178SAtari911 const fullDayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; 203996df7d3eSAtari911 let ordinalDayOpts = ""; 204096df7d3eSAtari911 for (let i = 0; i < 7; i++) { 204196df7d3eSAtari911 const selected = i === ordinalDay ? " selected" : ""; 204296df7d3eSAtari911 ordinalDayOpts += `<option value="${i}"${selected}>${fullDayNames[i]}</option>`; 204396df7d3eSAtari911 } 204496df7d3eSAtari911 20451d05cddcSAtari911 // Show edit dialog for recurring events 20461d05cddcSAtari911 const dialog = document.createElement("div"); 204796df7d3eSAtari911 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;"; 20481d05cddcSAtari911 20491d05cddcSAtari911 // Close on clicking background 20501d05cddcSAtari911 dialog.addEventListener("click", function(e) { 20511d05cddcSAtari911 if (e.target === dialog) { 20521d05cddcSAtari911 dialog.remove(); 20531d05cddcSAtari911 } 20541d05cddcSAtari911 }); 20551d05cddcSAtari911 205696df7d3eSAtari911 const monthlyDayChecked = monthlyType !== "ordinalWeekday" ? "checked" : ""; 205796df7d3eSAtari911 const monthlyOrdinalChecked = monthlyType === "ordinalWeekday" ? "checked" : ""; 205896df7d3eSAtari911 const weeklyDisplay = recurrenceType === "weekly" ? "block" : "none"; 205996df7d3eSAtari911 const monthlyDisplay = recurrenceType === "monthly" ? "block" : "none"; 206096df7d3eSAtari911 206196df7d3eSAtari911 // Get recurrence type selection - matching event editor labels 2062da206178SAtari911 const recTypes = [["daily","Day(s)"], ["weekly","Week(s)"], ["monthly","Month(s)"], ["yearly","Year(s)"]]; 206396df7d3eSAtari911 let recTypeOptions = ""; 206496df7d3eSAtari911 for (const [val, label] of recTypes) { 206596df7d3eSAtari911 const selected = val === recurrenceType ? " selected" : ""; 206696df7d3eSAtari911 recTypeOptions += `<option value="${val}"${selected}>${label}</option>`; 206796df7d3eSAtari911 } 206896df7d3eSAtari911 206996df7d3eSAtari911 // Input/select base style matching event editor 207096df7d3eSAtari911 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;"; 207196df7d3eSAtari911 const inputSmallStyle = "padding:4px 6px; border:2px solid #444; border-radius:4px; font-size:11px; background:#2a2a2a; color:#eee;"; 207296df7d3eSAtari911 const labelStyle = "display:block; font-size:10px; font-weight:500; margin-bottom:4px; color:#888;"; 207396df7d3eSAtari911 20741d05cddcSAtari911 dialog.innerHTML = ` 207596df7d3eSAtari911 <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);"> 20761d05cddcSAtari911 207796df7d3eSAtari911 <!-- Header - matching event editor --> 207896df7d3eSAtari911 <div style="display:flex; align-items:center; justify-content:space-between; padding:10px 14px; background:#2c3e50; color:white; flex-shrink:0;"> 2079da206178SAtari911 <h3 style="margin:0; font-size:15px; font-weight:600;">✏️ Edit Recurring Event</h3> 208096df7d3eSAtari911 <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> 20811d05cddcSAtari911 </div> 20821d05cddcSAtari911 208396df7d3eSAtari911 <!-- Form body - matching event editor --> 208496df7d3eSAtari911 <form id="editRecurringForm" style="padding:10px 12px; overflow-y:auto; flex:1; display:flex; flex-direction:column; gap:8px;"> 208596df7d3eSAtari911 2086da206178SAtari911 <p style="margin:0 0 4px; color:#888; font-size:11px;">Changes apply to ALL occurrences of: <strong style="color:#00cc07;">${title}</strong></p> 208796df7d3eSAtari911 208896df7d3eSAtari911 <!-- Title --> 20891d05cddcSAtari911 <div> 2090da206178SAtari911 <label style="${labelStyle}"> TITLE</label> 209196df7d3eSAtari911 <input type="text" name="new_title" value="${title}" style="${inputStyle}" required> 209296df7d3eSAtari911 </div> 209396df7d3eSAtari911 209496df7d3eSAtari911 <!-- Time Row --> 209596df7d3eSAtari911 <div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;"> 209696df7d3eSAtari911 <div> 2097da206178SAtari911 <label style="${labelStyle}"> START TIME</label> 209896df7d3eSAtari911 <input type="time" name="start_time" value="${time || \'\'}" style="${inputStyle}"> 20991d05cddcSAtari911 </div> 21001d05cddcSAtari911 <div> 2101da206178SAtari911 <label style="${labelStyle}"> END TIME</label> 210296df7d3eSAtari911 <input type="time" name="end_time" style="${inputStyle}"> 21031d05cddcSAtari911 </div> 21041d05cddcSAtari911 </div> 21051d05cddcSAtari911 210696df7d3eSAtari911 <!-- Recurrence Pattern Box - matching event editor exactly --> 210796df7d3eSAtari911 <div style="border:1px solid #333; border-radius:4px; padding:8px; margin:4px 0; background:rgba(0,0,0,0.2);"> 210896df7d3eSAtari911 210996df7d3eSAtari911 <!-- Repeat every [N] [period] --> 211096df7d3eSAtari911 <div style="display:flex; gap:8px; align-items:flex-end; margin-bottom:6px;"> 211196df7d3eSAtari911 <div style="flex:0 0 auto;"> 2112da206178SAtari911 <label style="${labelStyle}">Repeat every</label> 211396df7d3eSAtari911 <input type="number" name="recurrence_interval" value="${recurrenceInterval || 1}" min="1" max="99" style="width:50px; ${inputSmallStyle}"> 211496df7d3eSAtari911 </div> 211596df7d3eSAtari911 <div style="flex:1;"> 211696df7d3eSAtari911 <label style="${labelStyle}"> </label> 211796df7d3eSAtari911 <select name="recurrence_type" id="editRecType" onchange="toggleEditRecOptions()" style="width:100%; ${inputSmallStyle}"> 211896df7d3eSAtari911 ${recTypeOptions} 21191d05cddcSAtari911 </select> 21201d05cddcSAtari911 </div> 212196df7d3eSAtari911 </div> 21221d05cddcSAtari911 212396df7d3eSAtari911 <!-- Weekly options - day checkboxes --> 212496df7d3eSAtari911 <div id="editWeeklyOptions" style="display:${weeklyDisplay}; margin-bottom:6px;"> 2125da206178SAtari911 <label style="${labelStyle}">On these days:</label> 212696df7d3eSAtari911 <div style="display:flex; flex-wrap:wrap; gap:2px;"> 212796df7d3eSAtari911 ${weekDayChecks} 212896df7d3eSAtari911 </div> 212996df7d3eSAtari911 </div> 213096df7d3eSAtari911 213196df7d3eSAtari911 <!-- Monthly options --> 213296df7d3eSAtari911 <div id="editMonthlyOptions" style="display:${monthlyDisplay}; margin-bottom:6px;"> 2133da206178SAtari911 <label style="${labelStyle}">Repeat on:</label> 213496df7d3eSAtari911 213596df7d3eSAtari911 <!-- Radio: Day of month vs Ordinal weekday --> 213696df7d3eSAtari911 <div style="margin-bottom:6px;"> 213796df7d3eSAtari911 <label style="display:inline-flex; align-items:center; margin-right:12px; cursor:pointer; font-size:11px; color:#ccc;"> 213896df7d3eSAtari911 <input type="radio" name="monthly_type" value="dayOfMonth" ${monthlyDayChecked} onchange="toggleEditMonthlyType()" style="margin-right:4px;"> 2139da206178SAtari911 Day of month 214096df7d3eSAtari911 </label> 214196df7d3eSAtari911 <label style="display:inline-flex; align-items:center; cursor:pointer; font-size:11px; color:#ccc;"> 214296df7d3eSAtari911 <input type="radio" name="monthly_type" value="ordinalWeekday" ${monthlyOrdinalChecked} onchange="toggleEditMonthlyType()" style="margin-right:4px;"> 2143da206178SAtari911 Weekday pattern 214496df7d3eSAtari911 </label> 214596df7d3eSAtari911 </div> 214696df7d3eSAtari911 214796df7d3eSAtari911 <!-- Day of month input --> 214896df7d3eSAtari911 <div id="editMonthlyDay" style="display:${monthlyType !== "ordinalWeekday" ? "flex" : "none"}; align-items:center; gap:6px;"> 2149da206178SAtari911 <span style="font-size:11px; color:#ccc;">Day</span> 215096df7d3eSAtari911 <input type="number" name="month_day" value="${monthDay || 1}" min="1" max="31" style="width:50px; ${inputSmallStyle}"> 2151da206178SAtari911 <span style="font-size:10px; color:#666;">of each month</span> 215296df7d3eSAtari911 </div> 215396df7d3eSAtari911 215496df7d3eSAtari911 <!-- Ordinal weekday --> 215596df7d3eSAtari911 <div id="editMonthlyOrdinal" style="display:${monthlyType === "ordinalWeekday" ? "flex" : "none"}; align-items:center; gap:4px; flex-wrap:wrap;"> 215696df7d3eSAtari911 <select name="ordinal_week" style="width:auto; ${inputSmallStyle}"> 215796df7d3eSAtari911 ${ordinalWeekOpts} 215896df7d3eSAtari911 </select> 215996df7d3eSAtari911 <select name="ordinal_day" style="width:auto; ${inputSmallStyle}"> 216096df7d3eSAtari911 ${ordinalDayOpts} 216196df7d3eSAtari911 </select> 2162da206178SAtari911 <span style="font-size:10px; color:#666;">of each month</span> 216396df7d3eSAtari911 </div> 216496df7d3eSAtari911 </div> 216596df7d3eSAtari911 216696df7d3eSAtari911 <!-- Repeat Until --> 21671d05cddcSAtari911 <div> 2168da206178SAtari911 <label style="${labelStyle}">Repeat Until (optional)</label> 216996df7d3eSAtari911 <input type="date" name="recurrence_end" style="width:100%; ${inputSmallStyle}; box-sizing:border-box;"> 2170da206178SAtari911 <div style="font-size:9px; color:#666; margin-top:2px;">Leave empty to keep existing end date</div> 217196df7d3eSAtari911 </div> 217296df7d3eSAtari911 </div> 217396df7d3eSAtari911 217496df7d3eSAtari911 <!-- Namespace --> 217596df7d3eSAtari911 <div> 2176da206178SAtari911 <label style="${labelStyle}"> NAMESPACE</label> 217796df7d3eSAtari911 <select name="new_namespace" style="${inputStyle}"> 21781d05cddcSAtari911 ${nsOptions} 21791d05cddcSAtari911 </select> 21801d05cddcSAtari911 </div> 21811d05cddcSAtari911 </form> 218296df7d3eSAtari911 218396df7d3eSAtari911 <!-- Footer buttons - matching event editor --> 218496df7d3eSAtari911 <div style="display:flex; gap:8px; padding:12px 14px; background:#252525; border-top:1px solid #333; flex-shrink:0;"> 2185da206178SAtari911 <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> 2186da206178SAtari911 <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> 218796df7d3eSAtari911 </div> 21881d05cddcSAtari911 </div> 21891d05cddcSAtari911 `; 21901d05cddcSAtari911 21911d05cddcSAtari911 document.body.appendChild(dialog); 21921d05cddcSAtari911 219396df7d3eSAtari911 // Toggle functions for recurrence options 219496df7d3eSAtari911 window.toggleEditRecOptions = function() { 219596df7d3eSAtari911 const type = document.getElementById("editRecType").value; 219696df7d3eSAtari911 document.getElementById("editWeeklyOptions").style.display = type === "weekly" ? "block" : "none"; 219796df7d3eSAtari911 document.getElementById("editMonthlyOptions").style.display = type === "monthly" ? "block" : "none"; 219896df7d3eSAtari911 }; 219996df7d3eSAtari911 220096df7d3eSAtari911 window.toggleEditMonthlyType = function() { 220196df7d3eSAtari911 const radio = document.querySelector("input[name=monthly_type]:checked"); 220296df7d3eSAtari911 if (radio) { 220396df7d3eSAtari911 document.getElementById("editMonthlyDay").style.display = radio.value === "dayOfMonth" ? "flex" : "none"; 220496df7d3eSAtari911 document.getElementById("editMonthlyOrdinal").style.display = radio.value === "ordinalWeekday" ? "flex" : "none"; 220596df7d3eSAtari911 } 220696df7d3eSAtari911 }; 220796df7d3eSAtari911 22081d05cddcSAtari911 // Add close function to window 22091d05cddcSAtari911 window.closeEditDialog = function() { 22101d05cddcSAtari911 dialog.remove(); 22111d05cddcSAtari911 }; 22121d05cddcSAtari911 22131d05cddcSAtari911 // Handle form submission 22141d05cddcSAtari911 dialog.querySelector("#editRecurringForm").addEventListener("submit", function(e) { 22151d05cddcSAtari911 e.preventDefault(); 22161d05cddcSAtari911 const formData = new FormData(this); 22171d05cddcSAtari911 221896df7d3eSAtari911 // Collect weekDays as comma-separated string 221996df7d3eSAtari911 const weekDaysArr = []; 222096df7d3eSAtari911 document.querySelectorAll("input[name=weekDays]:checked").forEach(cb => { 222196df7d3eSAtari911 weekDaysArr.push(cb.value); 222296df7d3eSAtari911 }); 222396df7d3eSAtari911 22241d05cddcSAtari911 // Submit the edit 22251d05cddcSAtari911 const form = document.createElement("form"); 22261d05cddcSAtari911 form.method = "POST"; 22271d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 22281d05cddcSAtari911 2229*2866e827SAtari911 var sectokInput = document.createElement("input"); 2230*2866e827SAtari911 sectokInput.type = "hidden"; 2231*2866e827SAtari911 sectokInput.name = "sectok"; 2232*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 2233*2866e827SAtari911 form.appendChild(sectokInput); 2234*2866e827SAtari911 22351d05cddcSAtari911 const actionInput = document.createElement("input"); 22361d05cddcSAtari911 actionInput.type = "hidden"; 22371d05cddcSAtari911 actionInput.name = "action"; 22381d05cddcSAtari911 actionInput.value = "edit_recurring_series"; 22391d05cddcSAtari911 form.appendChild(actionInput); 22401d05cddcSAtari911 22411d05cddcSAtari911 const oldTitleInput = document.createElement("input"); 22421d05cddcSAtari911 oldTitleInput.type = "hidden"; 22431d05cddcSAtari911 oldTitleInput.name = "old_title"; 22441d05cddcSAtari911 oldTitleInput.value = title; 22451d05cddcSAtari911 form.appendChild(oldTitleInput); 22461d05cddcSAtari911 22471d05cddcSAtari911 const oldNamespaceInput = document.createElement("input"); 22481d05cddcSAtari911 oldNamespaceInput.type = "hidden"; 22491d05cddcSAtari911 oldNamespaceInput.name = "old_namespace"; 22501d05cddcSAtari911 oldNamespaceInput.value = namespace; 22511d05cddcSAtari911 form.appendChild(oldNamespaceInput); 22521d05cddcSAtari911 225396df7d3eSAtari911 // Add weekDays 225496df7d3eSAtari911 const weekDaysInput = document.createElement("input"); 225596df7d3eSAtari911 weekDaysInput.type = "hidden"; 225696df7d3eSAtari911 weekDaysInput.name = "week_days"; 225796df7d3eSAtari911 weekDaysInput.value = weekDaysArr.join(","); 225896df7d3eSAtari911 form.appendChild(weekDaysInput); 225996df7d3eSAtari911 22601d05cddcSAtari911 // Add all form fields 22611d05cddcSAtari911 for (let [key, value] of formData.entries()) { 226296df7d3eSAtari911 if (key === "weekDays") continue; // Skip individual checkboxes 22631d05cddcSAtari911 const input = document.createElement("input"); 22641d05cddcSAtari911 input.type = "hidden"; 22651d05cddcSAtari911 input.name = key; 22661d05cddcSAtari911 input.value = value; 22671d05cddcSAtari911 form.appendChild(input); 22681d05cddcSAtari911 } 22691d05cddcSAtari911 22701d05cddcSAtari911 document.body.appendChild(form); 22711d05cddcSAtari911 form.submit(); 22721d05cddcSAtari911 }); 22731d05cddcSAtari911 } 22741d05cddcSAtari911 22751d05cddcSAtari911 function deleteRecurringSeries(title, namespace) { 227622228b0eSAtari911 const displayNs = namespace || adminLang.default_ns; 227722228b0eSAtari911 if (!confirm(adminLang.delete_recurring_confirm.replace(/%s/, title).replace(/%s/, displayNs))) { 22781d05cddcSAtari911 return; 22791d05cddcSAtari911 } 22801d05cddcSAtari911 const form = document.createElement("form"); 22811d05cddcSAtari911 form.method = "POST"; 22821d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 2283*2866e827SAtari911 2284*2866e827SAtari911 var sectokInput = document.createElement("input"); 2285*2866e827SAtari911 sectokInput.type = "hidden"; 2286*2866e827SAtari911 sectokInput.name = "sectok"; 2287*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 2288*2866e827SAtari911 form.appendChild(sectokInput); 22891d05cddcSAtari911 const actionInput = document.createElement("input"); 22901d05cddcSAtari911 actionInput.type = "hidden"; 22911d05cddcSAtari911 actionInput.name = "action"; 22921d05cddcSAtari911 actionInput.value = "delete_recurring_series"; 22931d05cddcSAtari911 form.appendChild(actionInput); 22941d05cddcSAtari911 const titleInput = document.createElement("input"); 22951d05cddcSAtari911 titleInput.type = "hidden"; 22961d05cddcSAtari911 titleInput.name = "event_title"; 22971d05cddcSAtari911 titleInput.value = title; 22981d05cddcSAtari911 form.appendChild(titleInput); 22991d05cddcSAtari911 const namespaceInput = document.createElement("input"); 23001d05cddcSAtari911 namespaceInput.type = "hidden"; 23011d05cddcSAtari911 namespaceInput.name = "namespace"; 23021d05cddcSAtari911 namespaceInput.value = namespace; 23031d05cddcSAtari911 form.appendChild(namespaceInput); 23041d05cddcSAtari911 document.body.appendChild(form); 23051d05cddcSAtari911 form.submit(); 23061d05cddcSAtari911 } 23071d05cddcSAtari911 23081d05cddcSAtari911 document.addEventListener("dragend", function(e) { 23091d05cddcSAtari911 if (e.target.draggable) { 23101d05cddcSAtari911 e.target.style.opacity = "1"; 23111d05cddcSAtari911 } 23121d05cddcSAtari911 }); 23131d05cddcSAtari911 </script>'; 23141d05cddcSAtari911 } 23151d05cddcSAtari911 23169ccd446eSAtari911 private function renderUpdateTab($colors = null) { 23171d05cddcSAtari911 global $INPUT; 23181d05cddcSAtari911 23199ccd446eSAtari911 // Use defaults if not provided 23209ccd446eSAtari911 if ($colors === null) { 23219ccd446eSAtari911 $colors = $this->getTemplateColors(); 23229ccd446eSAtari911 } 23231d05cddcSAtari911 2324da206178SAtari911 echo '<h2 style="margin:10px 0; font-size:20px;"> Update Plugin</h2>'; 23251d05cddcSAtari911 23261d05cddcSAtari911 // Show message if present 23271d05cddcSAtari911 if ($INPUT->has('msg')) { 23281d05cddcSAtari911 $msg = hsc($INPUT->str('msg')); 23291d05cddcSAtari911 $type = $INPUT->str('msgtype', 'success'); 23301d05cddcSAtari911 $class = ($type === 'success') ? 'msg success' : 'msg error'; 23319ccd446eSAtari911 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;\">"; 23321d05cddcSAtari911 echo $msg; 23331d05cddcSAtari911 echo "</div>"; 23341d05cddcSAtari911 } 23351d05cddcSAtari911 23369ccd446eSAtari911 // Show current version FIRST (MOVED TO TOP) 23371d05cddcSAtari911 $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt'; 23381d05cddcSAtari911 $info = ['version' => 'Unknown', 'date' => 'Unknown', 'name' => 'Calendar Plugin', 'author' => 'Unknown', 'email' => '', 'desc' => '']; 23391d05cddcSAtari911 if (file_exists($pluginInfo)) { 23401d05cddcSAtari911 $info = array_merge($info, confToHash($pluginInfo)); 23411d05cddcSAtari911 } 23421d05cddcSAtari911 23439ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 2344da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Current Version</h3>'; 23451d05cddcSAtari911 echo '<div style="font-size:12px; line-height:1.6;">'; 2346da206178SAtari911 echo '<div style="margin:4px 0;"><strong>Version:</strong> ' . hsc($info['version']) . ' (' . hsc($info['date']) . ')</div>'; 2347da206178SAtari911 echo '<div style="margin:4px 0;"><strong>Author:</strong> ' . hsc($info['author']) . ($info['email'] ? ' <' . hsc($info['email']) . '>' : '') . '</div>'; 23481d05cddcSAtari911 if ($info['desc']) { 2349da206178SAtari911 echo '<div style="margin:4px 0;"><strong>Description:</strong> ' . hsc($info['desc']) . '</div>'; 23501d05cddcSAtari911 } 2351da206178SAtari911 echo '<div style="margin:4px 0;"><strong>Location:</strong> <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">' . DOKU_PLUGIN . 'calendar/</code></div>'; 23521d05cddcSAtari911 echo '</div>'; 23531d05cddcSAtari911 23541d05cddcSAtari911 // Check permissions 23551d05cddcSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar/'; 23561d05cddcSAtari911 $pluginWritable = is_writable($pluginDir); 23571d05cddcSAtari911 $parentWritable = is_writable(DOKU_PLUGIN); 23581d05cddcSAtari911 23599ccd446eSAtari911 echo '<div style="margin-top:8px; padding-top:8px; border-top:1px solid ' . $colors['border'] . ';">'; 23601d05cddcSAtari911 if ($pluginWritable && $parentWritable) { 2361da206178SAtari911 echo '<p style="margin:5px 0; font-size:13px; color:#28a745;"><strong>✅ Permissions:</strong> OK - ready to update</p>'; 23621d05cddcSAtari911 } else { 2363da206178SAtari911 echo '<p style="margin:5px 0; font-size:13px; color:#dc3545;"><strong>❌ Permissions:</strong> Issues detected</p>'; 23641d05cddcSAtari911 if (!$pluginWritable) { 2365da206178SAtari911 echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Plugin directory not writable</p>'; 23661d05cddcSAtari911 } 23671d05cddcSAtari911 if (!$parentWritable) { 2368da206178SAtari911 echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Parent directory not writable</p>'; 23691d05cddcSAtari911 } 2370da206178SAtari911 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>'; 2371da206178SAtari911 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>'; 23721d05cddcSAtari911 } 23731d05cddcSAtari911 echo '</div>'; 23741d05cddcSAtari911 23751d05cddcSAtari911 echo '</div>'; 23761d05cddcSAtari911 23779ccd446eSAtari911 // Combined upload and notes section (SIDE BY SIDE) 23789ccd446eSAtari911 echo '<div style="display:flex; gap:15px; max-width:1200px; margin:10px 0;">'; 23791d05cddcSAtari911 23809ccd446eSAtari911 // Left side - Upload form (60% width) 23819ccd446eSAtari911 echo '<div style="flex:1; min-width:0; background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">'; 2382da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Upload New Version</h3>'; 2383da206178SAtari911 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>'; 23841d05cddcSAtari911 23851d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=update" enctype="multipart/form-data" id="uploadForm">'; 2386*2866e827SAtari911 echo formSecurityToken(false); 23871d05cddcSAtari911 echo '<input type="hidden" name="action" value="upload_update">'; 23881d05cddcSAtari911 echo '<div style="margin:10px 0;">'; 23899ccd446eSAtari911 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%;">'; 23901d05cddcSAtari911 echo '</div>'; 23911d05cddcSAtari911 echo '<div style="margin:10px 0;">'; 23921d05cddcSAtari911 echo '<label style="display:flex; align-items:center; gap:8px; font-size:13px;">'; 23931d05cddcSAtari911 echo '<input type="checkbox" name="backup_first" value="1" checked>'; 2394da206178SAtari911 echo '<span>Create backup before updating (Recommended)</span>'; 23951d05cddcSAtari911 echo '</label>'; 23961d05cddcSAtari911 echo '</div>'; 23979ccd446eSAtari911 23989ccd446eSAtari911 // Buttons side by side 23999ccd446eSAtari911 echo '<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">'; 2400da206178SAtari911 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>'; 24011d05cddcSAtari911 echo '</form>'; 24029ccd446eSAtari911 24039ccd446eSAtari911 // Clear Cache button (next to Upload button) 24049ccd446eSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="display:inline; margin:0;">'; 2405*2866e827SAtari911 echo formSecurityToken(false); 24069ccd446eSAtari911 echo '<input type="hidden" name="action" value="clear_cache">'; 24079ccd446eSAtari911 echo '<input type="hidden" name="tab" value="update">'; 2408da206178SAtari911 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>'; 24099ccd446eSAtari911 echo '</form>'; 24101d05cddcSAtari911 echo '</div>'; 24111d05cddcSAtari911 2412da206178SAtari911 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>'; 24139ccd446eSAtari911 echo '</div>'; 24149ccd446eSAtari911 24159ccd446eSAtari911 // Right side - Important Notes (40% width) 24169ccd446eSAtari911 echo '<div style="flex:0 0 350px; min-width:0; background:#fff3e0; border-left:3px solid #ff9800; padding:12px; border-radius:3px;">'; 2417da206178SAtari911 echo '<h4 style="margin:0 0 5px 0; color:#e65100; font-size:14px;">⚠️ Important Notes</h4>'; 24189ccd446eSAtari911 echo '<ul style="margin:5px 0; padding-left:20px; font-size:12px; color:#e65100; line-height:1.6;">'; 2419da206178SAtari911 echo '<li>This will replace all plugin files</li>'; 2420da206178SAtari911 echo '<li>Configuration files (sync_config.php) will be preserved</li>'; 2421da206178SAtari911 echo '<li>Event data will not be affected</li>'; 2422da206178SAtari911 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>'; 2423da206178SAtari911 echo '<li>Make sure the ZIP file is a valid calendar plugin</li>'; 24241d05cddcSAtari911 echo '</ul>'; 24251d05cddcSAtari911 echo '</div>'; 24261d05cddcSAtari911 24279ccd446eSAtari911 echo '</div>'; // End flex container 24289ccd446eSAtari911 24299ccd446eSAtari911 // Changelog section - Timeline viewer 24307e8ea635SAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 2431da206178SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Version History</h3>'; 24329ccd446eSAtari911 24339ccd446eSAtari911 $changelogFile = DOKU_PLUGIN . 'calendar/CHANGELOG.md'; 24349ccd446eSAtari911 if (file_exists($changelogFile)) { 24359ccd446eSAtari911 $changelog = file_get_contents($changelogFile); 24369ccd446eSAtari911 24379ccd446eSAtari911 // Parse ALL versions into structured data 24389ccd446eSAtari911 $lines = explode("\n", $changelog); 24399ccd446eSAtari911 $versions = []; 24409ccd446eSAtari911 $currentVersion = null; 24417e8ea635SAtari911 $currentSubsection = ''; 24429ccd446eSAtari911 24439ccd446eSAtari911 foreach ($lines as $line) { 24447e8ea635SAtari911 $trimmed = trim($line); 24459ccd446eSAtari911 24469ccd446eSAtari911 // Version header (## Version X.X.X or ## Version X.X.X (date) - title) 24477e8ea635SAtari911 if (preg_match('/^## Version (.+?)(?:\s*\(([^)]+)\))?\s*(?:-\s*(.+))?$/', $trimmed, $matches)) { 24489ccd446eSAtari911 if ($currentVersion !== null) { 24499ccd446eSAtari911 $versions[] = $currentVersion; 24509ccd446eSAtari911 } 24519ccd446eSAtari911 $currentVersion = [ 24529ccd446eSAtari911 'number' => trim($matches[1]), 24539ccd446eSAtari911 'date' => isset($matches[2]) ? trim($matches[2]) : '', 24549ccd446eSAtari911 'title' => isset($matches[3]) ? trim($matches[3]) : '', 24559ccd446eSAtari911 'items' => [] 24569ccd446eSAtari911 ]; 24577e8ea635SAtari911 $currentSubsection = ''; 24589ccd446eSAtari911 } 24597e8ea635SAtari911 // Subsection header (### Something) 24607e8ea635SAtari911 elseif ($currentVersion !== null && preg_match('/^### (.+)$/', $trimmed, $matches)) { 24617e8ea635SAtari911 $currentSubsection = trim($matches[1]); 24629ccd446eSAtari911 $currentVersion['items'][] = [ 24637e8ea635SAtari911 'type' => 'section', 24647e8ea635SAtari911 'desc' => $currentSubsection 24657e8ea635SAtari911 ]; 24667e8ea635SAtari911 } 24677e8ea635SAtari911 // Formatted item (- **Type:** description) 24687e8ea635SAtari911 elseif ($currentVersion !== null && preg_match('/^- \*\*(.+?):\*\*\s*(.+)$/', $trimmed, $matches)) { 24697e8ea635SAtari911 $currentVersion['items'][] = [ 24707e8ea635SAtari911 'type' => trim($matches[1]), 24717e8ea635SAtari911 'desc' => trim($matches[2]) 24727e8ea635SAtari911 ]; 24737e8ea635SAtari911 } 24747e8ea635SAtari911 // Plain bullet item (- something) 24757e8ea635SAtari911 elseif ($currentVersion !== null && preg_match('/^- (.+)$/', $trimmed, $matches)) { 24767e8ea635SAtari911 $currentVersion['items'][] = [ 24777e8ea635SAtari911 'type' => $currentSubsection ?: 'Changed', 24787e8ea635SAtari911 'desc' => trim($matches[1]) 24799ccd446eSAtari911 ]; 24809ccd446eSAtari911 } 24819ccd446eSAtari911 } 24827e8ea635SAtari911 // Don't forget last version 24839ccd446eSAtari911 if ($currentVersion !== null) { 24849ccd446eSAtari911 $versions[] = $currentVersion; 24859ccd446eSAtari911 } 24869ccd446eSAtari911 24879ccd446eSAtari911 $totalVersions = count($versions); 24889ccd446eSAtari911 $uniqueId = 'changelog_' . substr(md5(microtime()), 0, 6); 24899ccd446eSAtari911 24907e8ea635SAtari911 // Find the index of the currently running version 24917e8ea635SAtari911 $runningVersion = trim($info['version']); 24927e8ea635SAtari911 $runningIndex = 0; 24937e8ea635SAtari911 foreach ($versions as $idx => $ver) { 24947e8ea635SAtari911 if (trim($ver['number']) === $runningVersion) { 24957e8ea635SAtari911 $runningIndex = $idx; 24967e8ea635SAtari911 break; 24977e8ea635SAtari911 } 24987e8ea635SAtari911 } 24997e8ea635SAtari911 25009ccd446eSAtari911 if ($totalVersions > 0) { 25019ccd446eSAtari911 // Timeline navigation bar 25029ccd446eSAtari911 echo '<div id="' . $uniqueId . '_wrap" style="position:relative;">'; 25039ccd446eSAtari911 25049ccd446eSAtari911 // Nav controls 25059ccd446eSAtari911 echo '<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">'; 25067e8ea635SAtari911 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>'; 25077e8ea635SAtari911 echo '<div style="flex:1; text-align:center; display:flex; align-items:center; justify-content:center; gap:10px;">'; 25089ccd446eSAtari911 echo '<span id="' . $uniqueId . '_counter" style="font-size:11px; color:' . $colors['text'] . '; opacity:0.7;">1 of ' . $totalVersions . '</span>'; 2509da206178SAtari911 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>'; 25109ccd446eSAtari911 echo '</div>'; 25117e8ea635SAtari911 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>'; 25129ccd446eSAtari911 echo '</div>'; 25139ccd446eSAtari911 25149ccd446eSAtari911 // Version cards (one per version, only first visible) 25159ccd446eSAtari911 foreach ($versions as $i => $ver) { 25169ccd446eSAtari911 $display = ($i === 0) ? 'block' : 'none'; 25177e8ea635SAtari911 $isRunning = (trim($ver['number']) === $runningVersion); 25187e8ea635SAtari911 $cardBorder = $isRunning ? '2px solid #00cc07' : '1px solid ' . $colors['border']; 25197e8ea635SAtari911 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;">'; 25209ccd446eSAtari911 25219ccd446eSAtari911 // Version header 25229ccd446eSAtari911 echo '<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px;">'; 25237e8ea635SAtari911 echo '<span style="font-weight:bold; color:#00cc07; font-size:14px;">v' . hsc($ver['number']) . '</span>'; 25247e8ea635SAtari911 if ($isRunning) { 2525da206178SAtari911 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>'; 25267e8ea635SAtari911 } 25279ccd446eSAtari911 if ($ver['date']) { 25289ccd446eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; opacity:0.6;">' . hsc($ver['date']) . '</span>'; 25299ccd446eSAtari911 } 25309ccd446eSAtari911 echo '</div>'; 25319ccd446eSAtari911 if ($ver['title']) { 25329ccd446eSAtari911 echo '<div style="font-size:12px; font-weight:600; color:' . $colors['text'] . '; margin-bottom:8px;">' . hsc($ver['title']) . '</div>'; 25339ccd446eSAtari911 } 25349ccd446eSAtari911 25359ccd446eSAtari911 // Change items 25369ccd446eSAtari911 if (!empty($ver['items'])) { 25379ccd446eSAtari911 echo '<div style="font-size:12px; line-height:1.7;">'; 25389ccd446eSAtari911 foreach ($ver['items'] as $item) { 25397e8ea635SAtari911 if ($item['type'] === 'section') { 25407e8ea635SAtari911 echo '<div style="margin:6px 0 2px 0; font-weight:700; color:#00cc07; font-size:11px; letter-spacing:0.3px;">' . hsc($item['desc']) . '</div>'; 25417e8ea635SAtari911 continue; 25427e8ea635SAtari911 } 25439ccd446eSAtari911 $color = '#666'; $icon = '•'; 25449ccd446eSAtari911 $t = $item['type']; 25457e8ea635SAtari911 if ($t === 'Added' || $t === 'New') { $color = '#28a745'; $icon = '✨'; } 25467e8ea635SAtari911 elseif ($t === 'Fixed' || $t === 'Fix' || $t === 'Bug Fix') { $color = '#dc3545'; $icon = ''; } 25477e8ea635SAtari911 elseif ($t === 'Changed' || $t === 'Change') { $color = '#00cc07'; $icon = ''; } 25487e8ea635SAtari911 elseif ($t === 'Improved' || $t === 'Enhancement') { $color = '#ff9800'; $icon = '⚡'; } 25499ccd446eSAtari911 elseif ($t === 'Removed') { $color = '#e91e63'; $icon = '️'; } 25509ccd446eSAtari911 elseif ($t === 'Development' || $t === 'Refactored') { $color = '#6c757d'; $icon = '️'; } 25519ccd446eSAtari911 elseif ($t === 'Result') { $color = '#2196f3'; $icon = '✅'; } 25527e8ea635SAtari911 else { $color = $colors['text']; $icon = '•'; } 25539ccd446eSAtari911 25549ccd446eSAtari911 echo '<div style="margin:2px 0; padding-left:4px;">'; 25559ccd446eSAtari911 echo '<span style="color:' . $color . '; font-weight:600;">' . $icon . ' ' . hsc($item['type']) . ':</span> '; 25569ccd446eSAtari911 echo '<span style="color:' . $colors['text'] . ';">' . hsc($item['desc']) . '</span>'; 25579ccd446eSAtari911 echo '</div>'; 25589ccd446eSAtari911 } 25599ccd446eSAtari911 echo '</div>'; 25609ccd446eSAtari911 } else { 2561da206178SAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . '; opacity:0.5; font-style:italic;">No detailed changes recorded</div>'; 25629ccd446eSAtari911 } 25639ccd446eSAtari911 25649ccd446eSAtari911 echo '</div>'; 25659ccd446eSAtari911 } 25669ccd446eSAtari911 25679ccd446eSAtari911 echo '</div>'; // wrap 25689ccd446eSAtari911 25699ccd446eSAtari911 // JavaScript for navigation 25709ccd446eSAtari911 echo '<script> 25719ccd446eSAtari911 (function() { 25729ccd446eSAtari911 var id = "' . $uniqueId . '"; 25739ccd446eSAtari911 var total = ' . $totalVersions . '; 25749ccd446eSAtari911 var current = 0; 25759ccd446eSAtari911 25767e8ea635SAtari911 function showCard(idx) { 25779ccd446eSAtari911 // Hide current 25789ccd446eSAtari911 var curCard = document.getElementById(id + "_card_" + current); 25799ccd446eSAtari911 if (curCard) curCard.style.display = "none"; 25809ccd446eSAtari911 25817e8ea635SAtari911 // Show target 25827e8ea635SAtari911 current = idx; 25839ccd446eSAtari911 var nextCard = document.getElementById(id + "_card_" + current); 25849ccd446eSAtari911 if (nextCard) nextCard.style.display = "block"; 25859ccd446eSAtari911 25869ccd446eSAtari911 // Update counter 25879ccd446eSAtari911 var counter = document.getElementById(id + "_counter"); 25889ccd446eSAtari911 if (counter) counter.textContent = (current + 1) + " of " + total; 25899ccd446eSAtari911 25909ccd446eSAtari911 // Update button states 25919ccd446eSAtari911 var prevBtn = document.getElementById(id + "_prev"); 25929ccd446eSAtari911 var nextBtn = document.getElementById(id + "_next"); 25939ccd446eSAtari911 if (prevBtn) prevBtn.style.opacity = (current === 0) ? "0.3" : "1"; 25949ccd446eSAtari911 if (nextBtn) nextBtn.style.opacity = (current === total - 1) ? "0.3" : "1"; 25957e8ea635SAtari911 } 25967e8ea635SAtari911 25977e8ea635SAtari911 window.changelogNav = function(uid, dir) { 25987e8ea635SAtari911 if (uid !== id) return; 25997e8ea635SAtari911 var next = current + dir; 26007e8ea635SAtari911 if (next < 0 || next >= total) return; 26017e8ea635SAtari911 showCard(next); 26027e8ea635SAtari911 }; 26037e8ea635SAtari911 26047e8ea635SAtari911 window.changelogJumpTo = function(uid, idx) { 26057e8ea635SAtari911 if (uid !== id) return; 26067e8ea635SAtari911 if (idx < 0 || idx >= total) return; 26077e8ea635SAtari911 showCard(idx); 26089ccd446eSAtari911 }; 26099ccd446eSAtari911 26109ccd446eSAtari911 // Initialize button states 26119ccd446eSAtari911 var prevBtn = document.getElementById(id + "_prev"); 26129ccd446eSAtari911 if (prevBtn) prevBtn.style.opacity = "0.3"; 26139ccd446eSAtari911 })(); 26149ccd446eSAtari911 </script>'; 26159ccd446eSAtari911 26169ccd446eSAtari911 } else { 2617da206178SAtari911 echo '<p style="color:#999; font-size:13px; font-style:italic;">No versions found in changelog</p>'; 26189ccd446eSAtari911 } 26199ccd446eSAtari911 } else { 2620da206178SAtari911 echo '<p style="color:#999; font-size:13px; font-style:italic;">Changelog not available</p>'; 26219ccd446eSAtari911 } 26229ccd446eSAtari911 26239ccd446eSAtari911 echo '</div>'; 26249ccd446eSAtari911 26259ccd446eSAtari911 // Backup list or manual backup section 26261d05cddcSAtari911 $backupDir = DOKU_PLUGIN; 26271d05cddcSAtari911 $backups = glob($backupDir . 'calendar*.zip'); 26281d05cddcSAtari911 26291d05cddcSAtari911 // Filter to only show files that look like backups (not the uploaded plugin files) 26301d05cddcSAtari911 $backups = array_filter($backups, function($file) { 26311d05cddcSAtari911 $name = basename($file); 26321d05cddcSAtari911 // Include files that start with "calendar" but exclude files that are just "calendar.zip" (uploaded plugin) 26331d05cddcSAtari911 return $name !== 'calendar.zip'; 26341d05cddcSAtari911 }); 26351d05cddcSAtari911 26369ccd446eSAtari911 // Always show backup section (even if no backups yet) 26379ccd446eSAtari911 echo '<div id="backupSection" style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">'; 26389ccd446eSAtari911 echo '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">'; 2639da206178SAtari911 echo '<h3 style="margin:0; color:#00cc07; font-size:16px;"> Backups</h3>'; 26409ccd446eSAtari911 26419ccd446eSAtari911 // Manual backup button 26429ccd446eSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="margin:0;">'; 2643*2866e827SAtari911 echo formSecurityToken(false); 26449ccd446eSAtari911 echo '<input type="hidden" name="action" value="create_manual_backup">'; 2645da206178SAtari911 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>'; 26469ccd446eSAtari911 echo '</form>'; 26479ccd446eSAtari911 echo '</div>'; 26489ccd446eSAtari911 264996df7d3eSAtari911 // Restore instructions note 265096df7d3eSAtari911 echo '<div style="background:#1a2d1a; border:1px solid #00cc07; border-radius:3px; padding:8px 12px; margin-bottom:10px;">'; 2651da206178SAtari911 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>'; 265296df7d3eSAtari911 echo '</div>'; 265396df7d3eSAtari911 26541d05cddcSAtari911 if (!empty($backups)) { 26551d05cddcSAtari911 rsort($backups); // Newest first 265696df7d3eSAtari911 265796df7d3eSAtari911 // Bulk action bar 265896df7d3eSAtari911 echo '<div id="bulkActionBar" style="display:flex; align-items:center; gap:10px; margin-bottom:8px; padding:6px 10px; background:#333; border-radius:3px;">'; 265996df7d3eSAtari911 echo '<label style="display:flex; align-items:center; gap:5px; color:#ccc; font-size:12px; cursor:pointer;">'; 266096df7d3eSAtari911 echo '<input type="checkbox" id="selectAllBackups" onchange="toggleAllBackups(this)" style="width:16px; height:16px;">'; 2661da206178SAtari911 echo 'Select All</label>'; 2662da206178SAtari911 echo '<span id="selectedCount" style="color:#888; font-size:11px;">(0 selected)</span>'; 2663da206178SAtari911 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>'; 266496df7d3eSAtari911 echo '</div>'; 266596df7d3eSAtari911 26669ccd446eSAtari911 echo '<div style="max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">'; 26679ccd446eSAtari911 echo '<table id="backupTable" style="width:100%; border-collapse:collapse; font-size:12px;">'; 26681d05cddcSAtari911 echo '<thead style="position:sticky; top:0; background:#e9e9e9;">'; 26691d05cddcSAtari911 echo '<tr>'; 267096df7d3eSAtari911 echo '<th style="padding:6px; text-align:center; border-bottom:2px solid ' . $colors['border'] . '; width:30px;"></th>'; 2671da206178SAtari911 echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Backup File</th>'; 2672da206178SAtari911 echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Size</th>'; 2673da206178SAtari911 echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Actions</th>'; 26741d05cddcSAtari911 echo '</tr></thead><tbody>'; 26751d05cddcSAtari911 26761d05cddcSAtari911 foreach ($backups as $backup) { 26771d05cddcSAtari911 $filename = basename($backup); 26781d05cddcSAtari911 $size = $this->formatBytes(filesize($backup)); 267996df7d3eSAtari911 echo '<tr style="border-bottom:1px solid #eee;" data-filename="' . hsc($filename) . '">'; 268096df7d3eSAtari911 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>'; 26811d05cddcSAtari911 echo '<td style="padding:6px;"><code style="font-size:11px;">' . hsc($filename) . '</code></td>'; 26821d05cddcSAtari911 echo '<td style="padding:6px;">' . $size . '</td>'; 26831d05cddcSAtari911 echo '<td style="padding:6px; white-space:nowrap;">'; 2684da206178SAtari911 echo '<a href="' . DOKU_BASE . 'lib/plugins/' . hsc($filename) . '" download style="color:#00cc07; text-decoration:none; font-size:11px; margin-right:10px;"> Download</a>'; 2685da206178SAtari911 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>'; 2686da206178SAtari911 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>'; 26871d05cddcSAtari911 echo '</td>'; 26881d05cddcSAtari911 echo '</tr>'; 26891d05cddcSAtari911 } 26901d05cddcSAtari911 26911d05cddcSAtari911 echo '</tbody></table>'; 26921d05cddcSAtari911 echo '</div>'; 26939ccd446eSAtari911 } else { 2694da206178SAtari911 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>'; 26951d05cddcSAtari911 } 26969ccd446eSAtari911 echo '</div>'; 26971d05cddcSAtari911 26981d05cddcSAtari911 echo '<script> 26991d05cddcSAtari911 function confirmUpload() { 27001d05cddcSAtari911 const fileInput = document.querySelector(\'input[name="plugin_zip"]\'); 27011d05cddcSAtari911 if (!fileInput.files[0]) { 2702da206178SAtari911 alert("Please select a ZIP file"); 27031d05cddcSAtari911 return false; 27041d05cddcSAtari911 } 27051d05cddcSAtari911 27061d05cddcSAtari911 const fileName = fileInput.files[0].name; 27071d05cddcSAtari911 if (!fileName.endsWith(".zip")) { 2708da206178SAtari911 alert("Please select a ZIP file"); 27091d05cddcSAtari911 return false; 27101d05cddcSAtari911 } 27111d05cddcSAtari911 2712da206178SAtari911 return confirm("Upload and install: " + fileName + "?\\n\\nThis will replace all plugin files.\\nYour configuration and data will be preserved.\\n\\nContinue?"); 27131d05cddcSAtari911 } 27141d05cddcSAtari911 271596df7d3eSAtari911 // Toggle all backup checkboxes 271696df7d3eSAtari911 function toggleAllBackups(selectAllCheckbox) { 271796df7d3eSAtari911 const checkboxes = document.querySelectorAll(\'.backup-checkbox\'); 271896df7d3eSAtari911 checkboxes.forEach(cb => cb.checked = selectAllCheckbox.checked); 271996df7d3eSAtari911 updateSelectedCount(); 272096df7d3eSAtari911 } 272196df7d3eSAtari911 272296df7d3eSAtari911 // Update the selected count display 272396df7d3eSAtari911 function updateSelectedCount() { 272496df7d3eSAtari911 const checkboxes = document.querySelectorAll(\'.backup-checkbox:checked\'); 272596df7d3eSAtari911 const count = checkboxes.length; 272696df7d3eSAtari911 const countSpan = document.getElementById(\'selectedCount\'); 272796df7d3eSAtari911 const bulkDeleteBtn = document.getElementById(\'bulkDeleteBtn\'); 272896df7d3eSAtari911 const selectAllCheckbox = document.getElementById(\'selectAllBackups\'); 272996df7d3eSAtari911 const totalCheckboxes = document.querySelectorAll(\'.backup-checkbox\').length; 273096df7d3eSAtari911 2731da206178SAtari911 if (countSpan) countSpan.textContent = \'(\' + count + \' selected)\'; 273296df7d3eSAtari911 if (bulkDeleteBtn) bulkDeleteBtn.style.display = count > 0 ? \'block\' : \'none\'; 273396df7d3eSAtari911 if (selectAllCheckbox) selectAllCheckbox.checked = (count === totalCheckboxes && count > 0); 273496df7d3eSAtari911 } 273596df7d3eSAtari911 273696df7d3eSAtari911 // Delete selected backups 273796df7d3eSAtari911 function deleteSelectedBackups() { 273896df7d3eSAtari911 const checkboxes = document.querySelectorAll(\'.backup-checkbox:checked\'); 273996df7d3eSAtari911 const filenames = Array.from(checkboxes).map(cb => cb.value); 274096df7d3eSAtari911 274196df7d3eSAtari911 if (filenames.length === 0) { 2742da206178SAtari911 alert(\'No backups selected\'); 27431d05cddcSAtari911 return; 27441d05cddcSAtari911 } 27451d05cddcSAtari911 2746da206178SAtari911 if (!confirm(\'Delete \' + filenames.length + \' selected backup(s)?\\n\\n\' + filenames.join(\'\\n\') + \'\\n\\nThis cannot be undone!\')) { 274796df7d3eSAtari911 return; 274896df7d3eSAtari911 } 274996df7d3eSAtari911 275096df7d3eSAtari911 // Delete each backup sequentially 275196df7d3eSAtari911 let deleted = 0; 275296df7d3eSAtari911 let errors = []; 275396df7d3eSAtari911 275496df7d3eSAtari911 function deleteNext(index) { 275596df7d3eSAtari911 if (index >= filenames.length) { 275696df7d3eSAtari911 // All done 275796df7d3eSAtari911 if (errors.length > 0) { 2758da206178SAtari911 alert(\'Deleted \' + deleted + \' backups. Errors: \' + errors.join(\', \')); 275996df7d3eSAtari911 } 276096df7d3eSAtari911 updateSelectedCount(); 276196df7d3eSAtari911 276296df7d3eSAtari911 // Check if table is now empty 276396df7d3eSAtari911 const tbody = document.querySelector(\'#backupTable tbody\'); 276496df7d3eSAtari911 if (tbody && tbody.children.length === 0) { 276596df7d3eSAtari911 location.reload(); 276696df7d3eSAtari911 } 276796df7d3eSAtari911 return; 276896df7d3eSAtari911 } 276996df7d3eSAtari911 277096df7d3eSAtari911 const filename = filenames[index]; 27719ccd446eSAtari911 const formData = new FormData(); 27729ccd446eSAtari911 formData.append(\'action\', \'delete_backup\'); 27739ccd446eSAtari911 formData.append(\'backup_file\', filename); 27741d05cddcSAtari911 27759ccd446eSAtari911 fetch(\'?do=admin&page=calendar&tab=update\', { 27769ccd446eSAtari911 method: \'POST\', 27779ccd446eSAtari911 body: formData 27789ccd446eSAtari911 }) 27799ccd446eSAtari911 .then(response => response.text()) 27809ccd446eSAtari911 .then(data => { 27819ccd446eSAtari911 // Remove the row from the table 278296df7d3eSAtari911 const row = document.querySelector(\'tr[data-filename="\' + filename + \'"]\'); 278396df7d3eSAtari911 if (row) { 278496df7d3eSAtari911 row.style.transition = \'opacity 0.2s\'; 27859ccd446eSAtari911 row.style.opacity = \'0\'; 278696df7d3eSAtari911 setTimeout(() => row.remove(), 200); 27879ccd446eSAtari911 } 278896df7d3eSAtari911 deleted++; 278996df7d3eSAtari911 deleteNext(index + 1); 27909ccd446eSAtari911 }) 27919ccd446eSAtari911 .catch(error => { 279296df7d3eSAtari911 errors.push(filename); 279396df7d3eSAtari911 deleteNext(index + 1); 27949ccd446eSAtari911 }); 27951d05cddcSAtari911 } 27961d05cddcSAtari911 279796df7d3eSAtari911 deleteNext(0); 279896df7d3eSAtari911 } 279996df7d3eSAtari911 28001d05cddcSAtari911 function restoreBackup(filename) { 2801da206178SAtari911 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?")) { 28021d05cddcSAtari911 return; 28031d05cddcSAtari911 } 28041d05cddcSAtari911 28051d05cddcSAtari911 const form = document.createElement("form"); 28061d05cddcSAtari911 form.method = "POST"; 28071d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=update"; 28081d05cddcSAtari911 2809*2866e827SAtari911 var sectokInput = document.createElement("input"); 2810*2866e827SAtari911 sectokInput.type = "hidden"; 2811*2866e827SAtari911 sectokInput.name = "sectok"; 2812*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 2813*2866e827SAtari911 form.appendChild(sectokInput); 2814*2866e827SAtari911 28151d05cddcSAtari911 const actionInput = document.createElement("input"); 28161d05cddcSAtari911 actionInput.type = "hidden"; 28171d05cddcSAtari911 actionInput.name = "action"; 28181d05cddcSAtari911 actionInput.value = "restore_backup"; 28191d05cddcSAtari911 form.appendChild(actionInput); 28201d05cddcSAtari911 28211d05cddcSAtari911 const filenameInput = document.createElement("input"); 28221d05cddcSAtari911 filenameInput.type = "hidden"; 28231d05cddcSAtari911 filenameInput.name = "backup_file"; 28241d05cddcSAtari911 filenameInput.value = filename; 28251d05cddcSAtari911 form.appendChild(filenameInput); 28261d05cddcSAtari911 28271d05cddcSAtari911 document.body.appendChild(form); 28281d05cddcSAtari911 form.submit(); 28291d05cddcSAtari911 } 28301d05cddcSAtari911 28311d05cddcSAtari911 function renameBackup(filename) { 2832da206178SAtari911 const newName = prompt("Enter new backup name (without .zip extension):\\n\\nCurrent: " + filename.replace(/\\.zip$/, ""), filename.replace(/\\.zip$/, "")); 2833da206178SAtari911 if (!newName || newName === filename.replace(/\\.zip$/, "")) { 28341d05cddcSAtari911 return; 28351d05cddcSAtari911 } 28361d05cddcSAtari911 28371d05cddcSAtari911 // Add .zip if not present 28381d05cddcSAtari911 const newFilename = newName.endsWith(".zip") ? newName : newName + ".zip"; 28391d05cddcSAtari911 28401d05cddcSAtari911 // Basic validation 28411d05cddcSAtari911 if (!/^[a-zA-Z0-9._-]+$/.test(newFilename.replace(/\\.zip$/, ""))) { 2842da206178SAtari911 alert("Invalid filename. Use only letters, numbers, dots, dashes, and underscores."); 28431d05cddcSAtari911 return; 28441d05cddcSAtari911 } 28451d05cddcSAtari911 28461d05cddcSAtari911 const form = document.createElement("form"); 28471d05cddcSAtari911 form.method = "POST"; 28481d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=update"; 28491d05cddcSAtari911 2850*2866e827SAtari911 var sectokInput = document.createElement("input"); 2851*2866e827SAtari911 sectokInput.type = "hidden"; 2852*2866e827SAtari911 sectokInput.name = "sectok"; 2853*2866e827SAtari911 sectokInput.value = JSINFO.sectok; 2854*2866e827SAtari911 form.appendChild(sectokInput); 2855*2866e827SAtari911 28561d05cddcSAtari911 const actionInput = document.createElement("input"); 28571d05cddcSAtari911 actionInput.type = "hidden"; 28581d05cddcSAtari911 actionInput.name = "action"; 28591d05cddcSAtari911 actionInput.value = "rename_backup"; 28601d05cddcSAtari911 form.appendChild(actionInput); 28611d05cddcSAtari911 28621d05cddcSAtari911 const oldNameInput = document.createElement("input"); 28631d05cddcSAtari911 oldNameInput.type = "hidden"; 28641d05cddcSAtari911 oldNameInput.name = "old_name"; 28651d05cddcSAtari911 oldNameInput.value = filename; 28661d05cddcSAtari911 form.appendChild(oldNameInput); 28671d05cddcSAtari911 28681d05cddcSAtari911 const newNameInput = document.createElement("input"); 28691d05cddcSAtari911 newNameInput.type = "hidden"; 28701d05cddcSAtari911 newNameInput.name = "new_name"; 28711d05cddcSAtari911 newNameInput.value = newFilename; 28721d05cddcSAtari911 form.appendChild(newNameInput); 28731d05cddcSAtari911 28741d05cddcSAtari911 document.body.appendChild(form); 28751d05cddcSAtari911 form.submit(); 28761d05cddcSAtari911 } 28771d05cddcSAtari911 </script>'; 28781d05cddcSAtari911 } 28791d05cddcSAtari911 28801d05cddcSAtari911 private function saveConfig() { 28811d05cddcSAtari911 global $INPUT; 28821d05cddcSAtari911 28831d05cddcSAtari911 // Load existing config to preserve all settings 2884*2866e827SAtari911 $configFile = $this->syncConfigPath(); 28851d05cddcSAtari911 $existingConfig = []; 28861d05cddcSAtari911 if (file_exists($configFile)) { 28871d05cddcSAtari911 $existingConfig = include $configFile; 28881d05cddcSAtari911 } 28891d05cddcSAtari911 28901d05cddcSAtari911 // Update only the fields from the form - preserve everything else 28911d05cddcSAtari911 $config = $existingConfig; 28921d05cddcSAtari911 28931d05cddcSAtari911 // Update basic fields 28941d05cddcSAtari911 $config['tenant_id'] = $INPUT->str('tenant_id'); 28951d05cddcSAtari911 $config['client_id'] = $INPUT->str('client_id'); 28961d05cddcSAtari911 $config['client_secret'] = $INPUT->str('client_secret'); 28971d05cddcSAtari911 $config['user_email'] = $INPUT->str('user_email'); 28981d05cddcSAtari911 $config['timezone'] = $INPUT->str('timezone', 'America/Los_Angeles'); 28991d05cddcSAtari911 $config['default_category'] = $INPUT->str('default_category', 'Blue category'); 29001d05cddcSAtari911 $config['reminder_minutes'] = $INPUT->int('reminder_minutes', 15); 29011d05cddcSAtari911 $config['sync_completed_tasks'] = $INPUT->bool('sync_completed_tasks'); 29021d05cddcSAtari911 $config['delete_outlook_events'] = $INPUT->bool('delete_outlook_events'); 29031d05cddcSAtari911 $config['sync_all_namespaces'] = $INPUT->bool('sync_all_namespaces'); 29041d05cddcSAtari911 $config['sync_namespaces'] = $INPUT->arr('sync_namespaces'); 29054590242dSAtari911 // important_namespaces is managed from the Manage tab, preserve existing value 29064590242dSAtari911 if (!isset($config['important_namespaces'])) { 29074590242dSAtari911 $config['important_namespaces'] = 'important'; 29084590242dSAtari911 } 29091d05cddcSAtari911 29101d05cddcSAtari911 // Parse category mapping 29111d05cddcSAtari911 $config['category_mapping'] = []; 29121d05cddcSAtari911 $mappingText = $INPUT->str('category_mapping'); 29131d05cddcSAtari911 if ($mappingText) { 29141d05cddcSAtari911 $lines = explode("\n", $mappingText); 29151d05cddcSAtari911 foreach ($lines as $line) { 29161d05cddcSAtari911 $line = trim($line); 29171d05cddcSAtari911 if (empty($line)) continue; 29181d05cddcSAtari911 $parts = explode('=', $line, 2); 29191d05cddcSAtari911 if (count($parts) === 2) { 29201d05cddcSAtari911 $config['category_mapping'][trim($parts[0])] = trim($parts[1]); 29211d05cddcSAtari911 } 29221d05cddcSAtari911 } 29231d05cddcSAtari911 } 29241d05cddcSAtari911 29251d05cddcSAtari911 // Parse color mapping from dropdown selections 29261d05cddcSAtari911 $config['color_mapping'] = []; 29271d05cddcSAtari911 $colorMappingCount = $INPUT->int('color_mapping_count', 0); 29281d05cddcSAtari911 for ($i = 0; $i < $colorMappingCount; $i++) { 29291d05cddcSAtari911 $hexColor = $INPUT->str('color_hex_' . $i); 29301d05cddcSAtari911 $category = $INPUT->str('color_map_' . $i); 29311d05cddcSAtari911 29321d05cddcSAtari911 if (!empty($hexColor) && !empty($category)) { 29331d05cddcSAtari911 $config['color_mapping'][$hexColor] = $category; 29341d05cddcSAtari911 } 29351d05cddcSAtari911 } 29361d05cddcSAtari911 29371d05cddcSAtari911 // Build file content using return format 29381d05cddcSAtari911 $content = "<?php\n"; 29391d05cddcSAtari911 $content .= "/**\n"; 29401d05cddcSAtari911 $content .= " * DokuWiki Calendar → Outlook Sync - Configuration\n"; 29411d05cddcSAtari911 $content .= " * \n"; 29421d05cddcSAtari911 $content .= " * SECURITY: Add this file to .gitignore!\n"; 29431d05cddcSAtari911 $content .= " * Never commit credentials to version control.\n"; 29441d05cddcSAtari911 $content .= " */\n\n"; 29451d05cddcSAtari911 $content .= "return " . var_export($config, true) . ";\n"; 29461d05cddcSAtari911 29471d05cddcSAtari911 // Save file 29481d05cddcSAtari911 if (file_put_contents($configFile, $content)) { 2949da206178SAtari911 $this->redirect('Configuration saved successfully!', 'success'); 29501d05cddcSAtari911 } else { 2951da206178SAtari911 $this->redirect('Error: Could not save configuration file', 'error'); 29521d05cddcSAtari911 } 29531d05cddcSAtari911 } 29541d05cddcSAtari911 29551d05cddcSAtari911 private function clearCache() { 2956*2866e827SAtari911 // Clear DokuWiki cache (farm-safe) 2957*2866e827SAtari911 global $conf; 2958*2866e827SAtari911 $cacheDir = $conf['cachedir']; 29591d05cddcSAtari911 29601d05cddcSAtari911 if (is_dir($cacheDir)) { 29611d05cddcSAtari911 $this->recursiveDelete($cacheDir, false); 2962da206178SAtari911 $this->redirect('Cache cleared successfully!', 'success', 'update'); 29631d05cddcSAtari911 } else { 2964da206178SAtari911 $this->redirect('Cache directory not found', 'error', 'update'); 29651d05cddcSAtari911 } 29661d05cddcSAtari911 } 29671d05cddcSAtari911 29681d05cddcSAtari911 private function recursiveDelete($dir, $deleteRoot = true) { 29691d05cddcSAtari911 if (!is_dir($dir)) return; 29701d05cddcSAtari911 29711d05cddcSAtari911 $files = array_diff(scandir($dir), array('.', '..')); 29721d05cddcSAtari911 foreach ($files as $file) { 29731d05cddcSAtari911 $path = $dir . '/' . $file; 29741d05cddcSAtari911 if (is_dir($path)) { 29751d05cddcSAtari911 $this->recursiveDelete($path, true); 29761d05cddcSAtari911 } else { 29771d05cddcSAtari911 @unlink($path); 29781d05cddcSAtari911 } 29791d05cddcSAtari911 } 29801d05cddcSAtari911 29811d05cddcSAtari911 if ($deleteRoot) { 29821d05cddcSAtari911 @rmdir($dir); 29831d05cddcSAtari911 } 29841d05cddcSAtari911 } 29851d05cddcSAtari911 29861d05cddcSAtari911 private function findRecurringEvents() { 2987*2866e827SAtari911 $dataDir = $this->metaDir(); 29881d05cddcSAtari911 $recurring = []; 29891d05cddcSAtari911 $allEvents = []; // Track all events to detect patterns 29907e8ea635SAtari911 $flaggedSeries = []; // Track events with recurring flag by recurringId 29911d05cddcSAtari911 29927e8ea635SAtari911 // Helper to process events from a calendar directory 29937e8ea635SAtari911 $processCalendarDir = function($calDir, $fallbackNamespace) use (&$allEvents, &$flaggedSeries) { 29947e8ea635SAtari911 if (!is_dir($calDir)) return; 29957e8ea635SAtari911 29967e8ea635SAtari911 foreach (glob($calDir . '/*.json') as $file) { 2997815440faSAtari911 $data = CalendarFileHandler::readJson($file); 29987e8ea635SAtari911 if (!$data || !is_array($data)) continue; 29991d05cddcSAtari911 30001d05cddcSAtari911 foreach ($data as $dateKey => $events) { 300196df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 300296df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 300396df7d3eSAtari911 30047e8ea635SAtari911 if (!is_array($events)) continue; 30051d05cddcSAtari911 foreach ($events as $event) { 30067e8ea635SAtari911 if (!isset($event['title']) || empty(trim($event['title']))) continue; 30071d05cddcSAtari911 30087e8ea635SAtari911 $ns = isset($event['namespace']) ? $event['namespace'] : $fallbackNamespace; 30097e8ea635SAtari911 30107e8ea635SAtari911 // If event has recurring flag, group by recurringId 30117e8ea635SAtari911 if (!empty($event['recurring']) && !empty($event['recurringId'])) { 30127e8ea635SAtari911 $rid = $event['recurringId']; 30137e8ea635SAtari911 if (!isset($flaggedSeries[$rid])) { 30147e8ea635SAtari911 $flaggedSeries[$rid] = [ 30151d05cddcSAtari911 'title' => $event['title'], 30167e8ea635SAtari911 'namespace' => $ns, 30171d05cddcSAtari911 'dates' => [], 301896df7d3eSAtari911 'events' => [], 301996df7d3eSAtari911 // Capture recurrence metadata from first event 302096df7d3eSAtari911 'recurrenceType' => $event['recurrenceType'] ?? null, 302196df7d3eSAtari911 'recurrenceInterval' => $event['recurrenceInterval'] ?? 1, 302296df7d3eSAtari911 'weekDays' => $event['weekDays'] ?? [], 302396df7d3eSAtari911 'monthlyType' => $event['monthlyType'] ?? null, 302496df7d3eSAtari911 'monthDay' => $event['monthDay'] ?? null, 302596df7d3eSAtari911 'ordinalWeek' => $event['ordinalWeek'] ?? null, 302696df7d3eSAtari911 'ordinalDay' => $event['ordinalDay'] ?? null, 302796df7d3eSAtari911 'time' => $event['time'] ?? null, 302896df7d3eSAtari911 'endTime' => $event['endTime'] ?? null, 302996df7d3eSAtari911 'color' => $event['color'] ?? null 30301d05cddcSAtari911 ]; 30311d05cddcSAtari911 } 30327e8ea635SAtari911 $flaggedSeries[$rid]['dates'][] = $dateKey; 30337e8ea635SAtari911 $flaggedSeries[$rid]['events'][] = $event; 30341d05cddcSAtari911 } 30351d05cddcSAtari911 30367e8ea635SAtari911 // Also group by title+namespace for pattern detection 30377e8ea635SAtari911 $groupKey = strtolower(trim($event['title'])) . '|' . $ns; 30381d05cddcSAtari911 30391d05cddcSAtari911 if (!isset($allEvents[$groupKey])) { 30401d05cddcSAtari911 $allEvents[$groupKey] = [ 30411d05cddcSAtari911 'title' => $event['title'], 30427e8ea635SAtari911 'namespace' => $ns, 30431d05cddcSAtari911 'dates' => [], 30447e8ea635SAtari911 'events' => [], 304596df7d3eSAtari911 'hasFlag' => false, 304696df7d3eSAtari911 'time' => $event['time'] ?? null, 304796df7d3eSAtari911 'color' => $event['color'] ?? null 30481d05cddcSAtari911 ]; 30491d05cddcSAtari911 } 30501d05cddcSAtari911 $allEvents[$groupKey]['dates'][] = $dateKey; 30511d05cddcSAtari911 $allEvents[$groupKey]['events'][] = $event; 30527e8ea635SAtari911 if (!empty($event['recurring'])) { 30537e8ea635SAtari911 $allEvents[$groupKey]['hasFlag'] = true; 30541d05cddcSAtari911 } 30551d05cddcSAtari911 } 30561d05cddcSAtari911 } 30571d05cddcSAtari911 } 30587e8ea635SAtari911 }; 30597e8ea635SAtari911 30607e8ea635SAtari911 // Check root calendar directory (blank/default namespace) 30617e8ea635SAtari911 $processCalendarDir($dataDir . 'calendar', ''); 30627e8ea635SAtari911 30637e8ea635SAtari911 // Scan all namespace directories (including nested) 30647e8ea635SAtari911 $this->scanNamespaceDirs($dataDir, $processCalendarDir); 30657e8ea635SAtari911 30667e8ea635SAtari911 // Deduplicate: remove from allEvents groups that are fully covered by flaggedSeries 30677e8ea635SAtari911 $flaggedTitleNs = []; 30687e8ea635SAtari911 foreach ($flaggedSeries as $rid => $series) { 30697e8ea635SAtari911 $key = strtolower(trim($series['title'])) . '|' . $series['namespace']; 30707e8ea635SAtari911 $flaggedTitleNs[$key] = $rid; 30717e8ea635SAtari911 } 30721d05cddcSAtari911 30737e8ea635SAtari911 // Build results from flaggedSeries first (known recurring) 30747e8ea635SAtari911 $seen = []; 30757e8ea635SAtari911 foreach ($flaggedSeries as $rid => $series) { 30767e8ea635SAtari911 sort($series['dates']); 30777e8ea635SAtari911 $dedupDates = array_unique($series['dates']); 30787e8ea635SAtari911 307996df7d3eSAtari911 // Use stored recurrence metadata if available, otherwise detect pattern 308096df7d3eSAtari911 $pattern = $this->formatRecurrencePattern($series); 308196df7d3eSAtari911 if (!$pattern) { 30827e8ea635SAtari911 $pattern = $this->detectRecurrencePattern($dedupDates); 308396df7d3eSAtari911 } 30847e8ea635SAtari911 30857e8ea635SAtari911 $recurring[] = [ 30867e8ea635SAtari911 'baseId' => $rid, 30877e8ea635SAtari911 'title' => $series['title'], 30887e8ea635SAtari911 'namespace' => $series['namespace'], 30897e8ea635SAtari911 'pattern' => $pattern, 30907e8ea635SAtari911 'count' => count($dedupDates), 30917e8ea635SAtari911 'firstDate' => $dedupDates[0], 309296df7d3eSAtari911 'lastDate' => end($dedupDates), 309396df7d3eSAtari911 'hasFlag' => true, 309496df7d3eSAtari911 'time' => $series['time'], 309596df7d3eSAtari911 'endTime' => $series['endTime'], 309696df7d3eSAtari911 'color' => $series['color'], 309796df7d3eSAtari911 'recurrenceType' => $series['recurrenceType'], 309896df7d3eSAtari911 'recurrenceInterval' => $series['recurrenceInterval'], 309996df7d3eSAtari911 'weekDays' => $series['weekDays'], 310096df7d3eSAtari911 'monthlyType' => $series['monthlyType'], 310196df7d3eSAtari911 'monthDay' => $series['monthDay'], 310296df7d3eSAtari911 'ordinalWeek' => $series['ordinalWeek'], 310396df7d3eSAtari911 'ordinalDay' => $series['ordinalDay'] 31047e8ea635SAtari911 ]; 31057e8ea635SAtari911 $seen[strtolower(trim($series['title'])) . '|' . $series['namespace']] = true; 31067e8ea635SAtari911 } 31077e8ea635SAtari911 31087e8ea635SAtari911 // Add pattern-detected recurring (3+ occurrences, not already in flaggedSeries) 31091d05cddcSAtari911 foreach ($allEvents as $groupKey => $group) { 31107e8ea635SAtari911 if (isset($seen[$groupKey])) continue; 31111d05cddcSAtari911 31127e8ea635SAtari911 $dedupDates = array_unique($group['dates']); 31137e8ea635SAtari911 sort($dedupDates); 31141d05cddcSAtari911 31157e8ea635SAtari911 if (count($dedupDates) < 3) continue; 31161d05cddcSAtari911 31177e8ea635SAtari911 $pattern = $this->detectRecurrencePattern($dedupDates); 31187e8ea635SAtari911 31191d05cddcSAtari911 $baseId = isset($group['events'][0]['recurringId']) 31201d05cddcSAtari911 ? $group['events'][0]['recurringId'] 31211d05cddcSAtari911 : md5($group['title'] . $group['namespace']); 31221d05cddcSAtari911 31231d05cddcSAtari911 $recurring[] = [ 31241d05cddcSAtari911 'baseId' => $baseId, 31251d05cddcSAtari911 'title' => $group['title'], 31261d05cddcSAtari911 'namespace' => $group['namespace'], 31271d05cddcSAtari911 'pattern' => $pattern, 31287e8ea635SAtari911 'count' => count($dedupDates), 31297e8ea635SAtari911 'firstDate' => $dedupDates[0], 313096df7d3eSAtari911 'lastDate' => end($dedupDates), 313196df7d3eSAtari911 'hasFlag' => $group['hasFlag'], 313296df7d3eSAtari911 'time' => $group['time'], 313396df7d3eSAtari911 'color' => $group['color'], 313496df7d3eSAtari911 'recurrenceType' => null, 313596df7d3eSAtari911 'recurrenceInterval' => null, 313696df7d3eSAtari911 'weekDays' => null, 313796df7d3eSAtari911 'monthlyType' => null, 313896df7d3eSAtari911 'monthDay' => null, 313996df7d3eSAtari911 'ordinalWeek' => null, 314096df7d3eSAtari911 'ordinalDay' => null 31411d05cddcSAtari911 ]; 31421d05cddcSAtari911 } 31437e8ea635SAtari911 31447e8ea635SAtari911 // Sort by title 31457e8ea635SAtari911 usort($recurring, function($a, $b) { 31467e8ea635SAtari911 return strcasecmp($a['title'], $b['title']); 31477e8ea635SAtari911 }); 31487e8ea635SAtari911 31497e8ea635SAtari911 return $recurring; 31507e8ea635SAtari911 } 31517e8ea635SAtari911 31527e8ea635SAtari911 /** 315396df7d3eSAtari911 * Format a human-readable recurrence pattern from stored metadata 315496df7d3eSAtari911 */ 315596df7d3eSAtari911 private function formatRecurrencePattern($series) { 315696df7d3eSAtari911 $type = $series['recurrenceType'] ?? null; 315796df7d3eSAtari911 $interval = $series['recurrenceInterval'] ?? 1; 315896df7d3eSAtari911 315996df7d3eSAtari911 if (!$type) return null; 316096df7d3eSAtari911 316196df7d3eSAtari911 $result = ''; 316296df7d3eSAtari911 316396df7d3eSAtari911 switch ($type) { 316496df7d3eSAtari911 case 'daily': 316596df7d3eSAtari911 if ($interval == 1) { 3166da206178SAtari911 $result = 'Daily'; 316796df7d3eSAtari911 } else { 3168da206178SAtari911 $result = "Every $interval days"; 316996df7d3eSAtari911 } 317096df7d3eSAtari911 break; 317196df7d3eSAtari911 317296df7d3eSAtari911 case 'weekly': 317396df7d3eSAtari911 $weekDays = $series['weekDays'] ?? []; 3174da206178SAtari911 $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 317596df7d3eSAtari911 317696df7d3eSAtari911 if ($interval == 1) { 3177da206178SAtari911 $result = 'Weekly'; 317896df7d3eSAtari911 } elseif ($interval == 2) { 3179da206178SAtari911 $result = 'Bi-weekly'; 318096df7d3eSAtari911 } else { 3181da206178SAtari911 $result = "Every $interval weeks"; 318296df7d3eSAtari911 } 318396df7d3eSAtari911 318496df7d3eSAtari911 if (!empty($weekDays) && count($weekDays) < 7) { 318596df7d3eSAtari911 $dayLabels = array_map(function($d) use ($dayNames) { 318696df7d3eSAtari911 return $dayNames[$d] ?? ''; 318796df7d3eSAtari911 }, $weekDays); 318896df7d3eSAtari911 $result .= ' (' . implode(', ', $dayLabels) . ')'; 318996df7d3eSAtari911 } 319096df7d3eSAtari911 break; 319196df7d3eSAtari911 319296df7d3eSAtari911 case 'monthly': 319396df7d3eSAtari911 $monthlyType = $series['monthlyType'] ?? 'dayOfMonth'; 319496df7d3eSAtari911 319596df7d3eSAtari911 if ($interval == 1) { 3196da206178SAtari911 $prefix = 'Monthly'; 319796df7d3eSAtari911 } elseif ($interval == 3) { 3198da206178SAtari911 $prefix = 'Quarterly'; 319996df7d3eSAtari911 } elseif ($interval == 6) { 3200da206178SAtari911 $prefix = 'Semi-annual'; 320196df7d3eSAtari911 } else { 3202da206178SAtari911 $prefix = "Every $interval months"; 320396df7d3eSAtari911 } 320496df7d3eSAtari911 320596df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 320696df7d3eSAtari911 $day = $series['monthDay'] ?? '?'; 3207da206178SAtari911 $result = "$prefix (day $day)"; 320896df7d3eSAtari911 } else { 3209da206178SAtari911 $ordinalNames = [1 => '1st', 2 => '2nd', 3 => '3rd', 4 => '4th', 5 => '5th', -1 => 'Last']; 3210da206178SAtari911 $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 321196df7d3eSAtari911 $ordinal = $ordinalNames[$series['ordinalWeek']] ?? ''; 321296df7d3eSAtari911 $dayName = $dayNames[$series['ordinalDay']] ?? ''; 3213da206178SAtari911 $result = "$prefix ($ordinal $dayName)"; 321496df7d3eSAtari911 } 321596df7d3eSAtari911 break; 321696df7d3eSAtari911 321796df7d3eSAtari911 case 'yearly': 321896df7d3eSAtari911 if ($interval == 1) { 3219da206178SAtari911 $result = 'Yearly'; 322096df7d3eSAtari911 } else { 3221da206178SAtari911 $result = "Every $interval years"; 322296df7d3eSAtari911 } 322396df7d3eSAtari911 break; 322496df7d3eSAtari911 322596df7d3eSAtari911 default: 322696df7d3eSAtari911 $result = ucfirst($type); 322796df7d3eSAtari911 } 322896df7d3eSAtari911 322996df7d3eSAtari911 return $result; 323096df7d3eSAtari911 } 323196df7d3eSAtari911 323296df7d3eSAtari911 /** 32337e8ea635SAtari911 * Recursively scan namespace directories for calendar data 32347e8ea635SAtari911 */ 32357e8ea635SAtari911 private function scanNamespaceDirs($baseDir, $callback) { 32367e8ea635SAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 32377e8ea635SAtari911 $namespace = basename($nsDir); 32387e8ea635SAtari911 32397e8ea635SAtari911 // Skip the root 'calendar' dir (already processed) 32407e8ea635SAtari911 if ($namespace === 'calendar') continue; 32417e8ea635SAtari911 32427e8ea635SAtari911 $calendarDir = $nsDir . '/calendar'; 32437e8ea635SAtari911 if (is_dir($calendarDir)) { 32447e8ea635SAtari911 // Derive namespace from path relative to meta dir 3245*2866e827SAtari911 $metaDir = $this->metaDir(); 32467e8ea635SAtari911 $relPath = str_replace($metaDir, '', $nsDir); 32477e8ea635SAtari911 $ns = str_replace('/', ':', trim($relPath, '/')); 32487e8ea635SAtari911 $callback($calendarDir, $ns); 32497e8ea635SAtari911 } 32507e8ea635SAtari911 32517e8ea635SAtari911 // Recurse into subdirectories for nested namespaces 32527e8ea635SAtari911 $this->scanNamespaceDirs($nsDir . '/', $callback); 32537e8ea635SAtari911 } 32541d05cddcSAtari911 } 32551d05cddcSAtari911 32567e8ea635SAtari911 /** 32577e8ea635SAtari911 * Detect recurrence pattern from sorted dates using median interval 32587e8ea635SAtari911 */ 32597e8ea635SAtari911 private function detectRecurrencePattern($dates) { 32607e8ea635SAtari911 if (count($dates) < 2) return 'Single'; 32617e8ea635SAtari911 32627e8ea635SAtari911 // Calculate all intervals between consecutive dates 32637e8ea635SAtari911 $intervals = []; 32647e8ea635SAtari911 for ($i = 1; $i < count($dates); $i++) { 32657e8ea635SAtari911 try { 32667e8ea635SAtari911 $d1 = new DateTime($dates[$i - 1]); 32677e8ea635SAtari911 $d2 = new DateTime($dates[$i]); 32687e8ea635SAtari911 $intervals[] = $d1->diff($d2)->days; 32697e8ea635SAtari911 } catch (Exception $e) { 32707e8ea635SAtari911 continue; 32717e8ea635SAtari911 } 32727e8ea635SAtari911 } 32737e8ea635SAtari911 32747e8ea635SAtari911 if (empty($intervals)) return 'Custom'; 32757e8ea635SAtari911 327696df7d3eSAtari911 // Check if all intervals are the same (or very close) 327796df7d3eSAtari911 $uniqueIntervals = array_unique($intervals); 327896df7d3eSAtari911 $isConsistent = (count($uniqueIntervals) === 1) || 327996df7d3eSAtari911 (max($intervals) - min($intervals) <= 1); // Allow 1 day variance 328096df7d3eSAtari911 32817e8ea635SAtari911 // Use median interval (more robust than first pair) 32827e8ea635SAtari911 sort($intervals); 32837e8ea635SAtari911 $mid = floor(count($intervals) / 2); 32847e8ea635SAtari911 $median = (count($intervals) % 2 === 0) 32857e8ea635SAtari911 ? ($intervals[$mid - 1] + $intervals[$mid]) / 2 32867e8ea635SAtari911 : $intervals[$mid]; 32877e8ea635SAtari911 328896df7d3eSAtari911 // Check for specific day-based patterns first 32897e8ea635SAtari911 if ($median <= 1) return 'Daily'; 329096df7d3eSAtari911 329196df7d3eSAtari911 // Check for every N days (2-6 days) 329296df7d3eSAtari911 if ($median >= 2 && $median <= 6 && $isConsistent) { 329396df7d3eSAtari911 return 'Every ' . round($median) . ' days'; 329496df7d3eSAtari911 } 329596df7d3eSAtari911 329696df7d3eSAtari911 // Weekly patterns 32977e8ea635SAtari911 if ($median >= 6 && $median <= 8) return 'Weekly'; 329896df7d3eSAtari911 329996df7d3eSAtari911 // Check for every N weeks 33007e8ea635SAtari911 if ($median >= 13 && $median <= 16) return 'Bi-weekly'; 330196df7d3eSAtari911 if ($median >= 20 && $median <= 23) return 'Every 3 weeks'; 330296df7d3eSAtari911 330396df7d3eSAtari911 // Monthly patterns 33047e8ea635SAtari911 if ($median >= 27 && $median <= 32) return 'Monthly'; 330596df7d3eSAtari911 330696df7d3eSAtari911 // Check for every N months by looking at month differences 330796df7d3eSAtari911 if ($median >= 55 && $median <= 65) return 'Every 2 months'; 33087e8ea635SAtari911 if ($median >= 89 && $median <= 93) return 'Quarterly'; 330996df7d3eSAtari911 if ($median >= 115 && $median <= 125) return 'Every 4 months'; 331096df7d3eSAtari911 if ($median >= 175 && $median <= 190) return 'Semi-annual'; 331196df7d3eSAtari911 331296df7d3eSAtari911 // Yearly 33137e8ea635SAtari911 if ($median >= 363 && $median <= 368) return 'Yearly'; 33147e8ea635SAtari911 331596df7d3eSAtari911 // For other intervals, calculate weeks if appropriate 331696df7d3eSAtari911 if ($median >= 7 && $median < 28) { 331796df7d3eSAtari911 $weeks = round($median / 7); 331896df7d3eSAtari911 if (abs($median - ($weeks * 7)) <= 1) { 331996df7d3eSAtari911 return "Every $weeks weeks"; 332096df7d3eSAtari911 } 332196df7d3eSAtari911 } 332296df7d3eSAtari911 332396df7d3eSAtari911 // For monthly-ish intervals 332496df7d3eSAtari911 if ($median >= 28 && $median < 365) { 332596df7d3eSAtari911 $months = round($median / 30); 332696df7d3eSAtari911 if ($months >= 2 && abs($median - ($months * 30)) <= 3) { 332796df7d3eSAtari911 return "Every $months months"; 332896df7d3eSAtari911 } 332996df7d3eSAtari911 } 333096df7d3eSAtari911 33317e8ea635SAtari911 return 'Every ~' . round($median) . ' days'; 33327e8ea635SAtari911 } 33337e8ea635SAtari911 33347e8ea635SAtari911 /** 33357e8ea635SAtari911 * Render the recurring events table HTML 33367e8ea635SAtari911 */ 33377e8ea635SAtari911 private function renderRecurringTable($recurringEvents, $colors) { 33387e8ea635SAtari911 if (empty($recurringEvents)) { 333922228b0eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:13px; margin:5px 0;">' . $this->getLang('no_recurring_found') . '</p>'; 33407e8ea635SAtari911 return; 33417e8ea635SAtari911 } 33427e8ea635SAtari911 33437e8ea635SAtari911 // Search bar 33447e8ea635SAtari911 echo '<div style="margin-bottom:8px;">'; 334522228b0eSAtari911 echo '<input type="text" id="searchRecurring" onkeyup="filterRecurringEvents()" placeholder=" ' . $this->getLang('search_recurring') . '" style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">'; 33467e8ea635SAtari911 echo '</div>'; 33477e8ea635SAtari911 33487e8ea635SAtari911 echo '<style> 33497e8ea635SAtari911 .sort-arrow { 33507e8ea635SAtari911 color: #999; 33517e8ea635SAtari911 font-size: 10px; 33527e8ea635SAtari911 margin-left: 3px; 33537e8ea635SAtari911 display: inline-block; 33547e8ea635SAtari911 } 33557e8ea635SAtari911 #recurringTable th:hover { 33567e8ea635SAtari911 background: #ddd; 33577e8ea635SAtari911 } 33587e8ea635SAtari911 #recurringTable th:hover .sort-arrow { 33597e8ea635SAtari911 color: #00cc07; 33607e8ea635SAtari911 } 33617e8ea635SAtari911 .recurring-row-hidden { 33627e8ea635SAtari911 display: none; 33637e8ea635SAtari911 } 336496df7d3eSAtari911 .pattern-badge { 336596df7d3eSAtari911 display: inline-block; 336696df7d3eSAtari911 padding: 1px 4px; 336796df7d3eSAtari911 border-radius: 3px; 336896df7d3eSAtari911 font-size: 9px; 336996df7d3eSAtari911 font-weight: bold; 337096df7d3eSAtari911 } 337196df7d3eSAtari911 .pattern-daily { background: #e3f2fd; color: #1565c0; } 337296df7d3eSAtari911 .pattern-weekly { background: #e8f5e9; color: #2e7d32; } 337396df7d3eSAtari911 .pattern-monthly { background: #fff3e0; color: #ef6c00; } 337496df7d3eSAtari911 .pattern-yearly { background: #fce4ec; color: #c2185b; } 337596df7d3eSAtari911 .pattern-custom { background: #f3e5f5; color: #7b1fa2; } 33767e8ea635SAtari911 </style>'; 33777e8ea635SAtari911 echo '<div style="max-height:250px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">'; 33787e8ea635SAtari911 echo '<table id="recurringTable" style="width:100%; border-collapse:collapse; font-size:11px;">'; 33797e8ea635SAtari911 echo '<thead style="position:sticky; top:0; background:#e9e9e9;">'; 33807e8ea635SAtari911 echo '<tr>'; 338122228b0eSAtari911 echo '<th onclick="sortRecurringTable(0)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">' . $this->getLang('col_title') . ' <span class="sort-arrow">⇅</span></th>'; 338222228b0eSAtari911 echo '<th onclick="sortRecurringTable(1)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">' . $this->getLang('col_namespace') . ' <span class="sort-arrow">⇅</span></th>'; 338322228b0eSAtari911 echo '<th onclick="sortRecurringTable(2)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">' . $this->getLang('col_pattern') . ' <span class="sort-arrow">⇅</span></th>'; 338422228b0eSAtari911 echo '<th onclick="sortRecurringTable(3)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">' . $this->getLang('col_range') . ' <span class="sort-arrow">⇅</span></th>'; 338522228b0eSAtari911 echo '<th onclick="sortRecurringTable(4)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">' . $this->getLang('col_count') . ' <span class="sort-arrow">⇅</span></th>'; 338622228b0eSAtari911 echo '<th onclick="sortRecurringTable(5)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">' . $this->getLang('col_source') . ' <span class="sort-arrow">⇅</span></th>'; 338722228b0eSAtari911 echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">' . $this->getLang('col_actions') . '</th>'; 33887e8ea635SAtari911 echo '</tr></thead><tbody id="recurringTableBody">'; 33897e8ea635SAtari911 339022228b0eSAtari911 // Pattern translations 339122228b0eSAtari911 $patternTranslations = [ 339222228b0eSAtari911 'daily' => $this->getLang('pattern_daily'), 339322228b0eSAtari911 'weekly' => $this->getLang('pattern_weekly'), 339422228b0eSAtari911 'monthly' => $this->getLang('pattern_monthly'), 339522228b0eSAtari911 'yearly' => $this->getLang('pattern_yearly'), 339622228b0eSAtari911 ]; 339722228b0eSAtari911 33987e8ea635SAtari911 foreach ($recurringEvents as $series) { 339922228b0eSAtari911 $sourceLabel = $series['hasFlag'] ? '️ ' . $this->getLang('source_flagged') : ' ' . $this->getLang('source_detected'); 34007e8ea635SAtari911 $sourceColor = $series['hasFlag'] ? '#00cc07' : '#ff9800'; 340196df7d3eSAtari911 340222228b0eSAtari911 // Determine pattern badge class and translate pattern 340396df7d3eSAtari911 $pattern = strtolower($series['pattern']); 340422228b0eSAtari911 $displayPattern = $series['pattern']; 340522228b0eSAtari911 340696df7d3eSAtari911 if (strpos($pattern, 'daily') !== false || strpos($pattern, 'day') !== false) { 340796df7d3eSAtari911 $patternClass = 'pattern-daily'; 340822228b0eSAtari911 $displayPattern = $this->getLang('pattern_daily'); 340996df7d3eSAtari911 } elseif (strpos($pattern, 'weekly') !== false || strpos($pattern, 'week') !== false) { 341096df7d3eSAtari911 $patternClass = 'pattern-weekly'; 341122228b0eSAtari911 $displayPattern = $this->getLang('pattern_weekly'); 341296df7d3eSAtari911 } elseif (strpos($pattern, 'monthly') !== false || strpos($pattern, 'month') !== false || 341396df7d3eSAtari911 strpos($pattern, 'quarterly') !== false || strpos($pattern, 'semi') !== false) { 341496df7d3eSAtari911 $patternClass = 'pattern-monthly'; 341522228b0eSAtari911 $displayPattern = $this->getLang('pattern_monthly'); 341696df7d3eSAtari911 } elseif (strpos($pattern, 'yearly') !== false || strpos($pattern, 'year') !== false) { 341796df7d3eSAtari911 $patternClass = 'pattern-yearly'; 341822228b0eSAtari911 $displayPattern = $this->getLang('pattern_yearly'); 341996df7d3eSAtari911 } else { 342096df7d3eSAtari911 $patternClass = 'pattern-custom'; 342122228b0eSAtari911 $displayPattern = $this->getLang('pattern_custom'); 342296df7d3eSAtari911 } 342396df7d3eSAtari911 342496df7d3eSAtari911 // Format date range 342596df7d3eSAtari911 $firstDate = date('M j, Y', strtotime($series['firstDate'])); 342696df7d3eSAtari911 $lastDate = isset($series['lastDate']) ? date('M j, Y', strtotime($series['lastDate'])) : $firstDate; 342796df7d3eSAtari911 $dateRange = ($firstDate === $lastDate) ? $firstDate : "$firstDate → $lastDate"; 342896df7d3eSAtari911 34297e8ea635SAtari911 echo '<tr style="border-bottom:1px solid #eee;">'; 34307e8ea635SAtari911 echo '<td style="padding:4px 6px;">' . hsc($series['title']) . '</td>'; 343122228b0eSAtari911 echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($series['namespace'] ?: $this->getLang('default_ns')) . '</code></td>'; 343222228b0eSAtari911 echo '<td style="padding:4px 6px;"><span class="pattern-badge ' . $patternClass . '">' . hsc($displayPattern) . '</span></td>'; 343396df7d3eSAtari911 echo '<td style="padding:4px 6px; font-size:10px;">' . $dateRange . '</td>'; 34347e8ea635SAtari911 echo '<td style="padding:4px 6px;"><strong>' . $series['count'] . '</strong></td>'; 34357e8ea635SAtari911 echo '<td style="padding:4px 6px;"><span style="color:' . $sourceColor . '; font-size:10px;">' . $sourceLabel . '</span></td>'; 34367e8ea635SAtari911 echo '<td style="padding:4px 6px; white-space:nowrap;">'; 343796df7d3eSAtari911 343896df7d3eSAtari911 // Prepare JS data - include recurrence metadata 34397e8ea635SAtari911 $jsTitle = hsc(addslashes($series['title'])); 34407e8ea635SAtari911 $jsNs = hsc($series['namespace']); 34417e8ea635SAtari911 $jsCount = $series['count']; 34427e8ea635SAtari911 $jsFirst = hsc($series['firstDate']); 344396df7d3eSAtari911 $jsLast = hsc($series['lastDate'] ?? $series['firstDate']); 34447e8ea635SAtari911 $jsPattern = hsc($series['pattern']); 34457e8ea635SAtari911 $jsHasFlag = $series['hasFlag'] ? 'true' : 'false'; 344696df7d3eSAtari911 $jsTime = hsc($series['time'] ?? ''); 344796df7d3eSAtari911 $jsEndTime = hsc($series['endTime'] ?? ''); 344896df7d3eSAtari911 $jsColor = hsc($series['color'] ?? ''); 344996df7d3eSAtari911 345096df7d3eSAtari911 // Recurrence metadata for edit dialog 345196df7d3eSAtari911 $jsRecurrenceType = hsc($series['recurrenceType'] ?? ''); 345296df7d3eSAtari911 $jsRecurrenceInterval = intval($series['recurrenceInterval'] ?? 1); 345396df7d3eSAtari911 $jsWeekDays = json_encode($series['weekDays'] ?? []); 345496df7d3eSAtari911 $jsMonthlyType = hsc($series['monthlyType'] ?? ''); 345596df7d3eSAtari911 $jsMonthDay = intval($series['monthDay'] ?? 0); 345696df7d3eSAtari911 $jsOrdinalWeek = intval($series['ordinalWeek'] ?? 1); 345796df7d3eSAtari911 $jsOrdinalDay = intval($series['ordinalDay'] ?? 0); 345896df7d3eSAtari911 345922228b0eSAtari911 echo '<button onclick="editRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\', \'' . $jsTime . '\', \'' . $jsColor . '\', \'' . $jsRecurrenceType . '\', ' . $jsRecurrenceInterval . ', ' . $jsWeekDays . ', \'' . $jsMonthlyType . '\', ' . $jsMonthDay . ', ' . $jsOrdinalWeek . ', ' . $jsOrdinalDay . ')" style="background:#00cc07; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px; margin-right:2px;" title="' . $this->getLang('tooltip_edit') . '">' . $this->getLang('btn_edit') . '</button>'; 346022228b0eSAtari911 echo '<button onclick="manageRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\', ' . $jsCount . ', \'' . $jsFirst . '\', \'' . $jsLast . '\', \'' . $jsPattern . '\', ' . $jsHasFlag . ')" style="background:#ff9800; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px; margin-right:2px;" title="' . $this->getLang('tooltip_manage') . '">' . $this->getLang('btn_manage') . '</button>'; 346122228b0eSAtari911 echo '<button onclick="deleteRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\')" style="background:#e74c3c; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px;" title="' . $this->getLang('tooltip_delete_all') . '">' . $this->getLang('btn_del') . '</button>'; 34627e8ea635SAtari911 echo '</td>'; 34637e8ea635SAtari911 echo '</tr>'; 34647e8ea635SAtari911 } 34657e8ea635SAtari911 34667e8ea635SAtari911 echo '</tbody></table>'; 34677e8ea635SAtari911 echo '</div>'; 346822228b0eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:5px 0 0;">' . sprintf($this->getLang('total_series'), count($recurringEvents)) . '</p>'; 34697e8ea635SAtari911 } 34707e8ea635SAtari911 34717e8ea635SAtari911 /** 34727e8ea635SAtari911 * AJAX handler: rescan recurring events and return HTML 34737e8ea635SAtari911 */ 34747e8ea635SAtari911 private function handleCleanupEmptyNamespaces() { 34757e8ea635SAtari911 global $INPUT; 34767e8ea635SAtari911 $dryRun = $INPUT->bool('dry_run', false); 34777e8ea635SAtari911 3478*2866e827SAtari911 $metaDir = $this->metaDir(); 34797e8ea635SAtari911 $details = []; 34807e8ea635SAtari911 $removedDirs = 0; 34817e8ea635SAtari911 $removedCalDirs = 0; 34827e8ea635SAtari911 34837e8ea635SAtari911 // 1. Find all calendar/ subdirectories anywhere under data/meta/ 34847e8ea635SAtari911 $allCalDirs = []; 34857e8ea635SAtari911 $this->findAllCalendarDirsRecursive($metaDir, $allCalDirs); 34867e8ea635SAtari911 34877e8ea635SAtari911 // 2. Check each calendar dir for empty JSON files 34887e8ea635SAtari911 foreach ($allCalDirs as $calDir) { 34897e8ea635SAtari911 $jsonFiles = glob($calDir . '/*.json'); 34907e8ea635SAtari911 $hasEvents = false; 34917e8ea635SAtari911 34927e8ea635SAtari911 foreach ($jsonFiles as $jsonFile) { 3493815440faSAtari911 $data = CalendarFileHandler::readJson($jsonFile); 34947e8ea635SAtari911 if ($data && is_array($data)) { 34957e8ea635SAtari911 // Check if any date key has actual events 34967e8ea635SAtari911 foreach ($data as $dateKey => $events) { 34977e8ea635SAtari911 if (is_array($events) && !empty($events)) { 34987e8ea635SAtari911 $hasEvents = true; 34997e8ea635SAtari911 break 2; 35007e8ea635SAtari911 } 35017e8ea635SAtari911 } 35027e8ea635SAtari911 // JSON file has data but all dates are empty — remove it 35037e8ea635SAtari911 if (!$dryRun) unlink($jsonFile); 35047e8ea635SAtari911 } 35057e8ea635SAtari911 } 35067e8ea635SAtari911 35077e8ea635SAtari911 // Re-check after cleaning empty JSON files 35087e8ea635SAtari911 if (!$dryRun) { 35097e8ea635SAtari911 $jsonFiles = glob($calDir . '/*.json'); 35107e8ea635SAtari911 } 35117e8ea635SAtari911 35127e8ea635SAtari911 // Derive display name from path 35137e8ea635SAtari911 $relPath = str_replace($metaDir, '', $calDir); 35147e8ea635SAtari911 $relPath = rtrim(str_replace('/calendar', '', $relPath), '/'); 35157e8ea635SAtari911 $displayName = $relPath ?: '(root)'; 35167e8ea635SAtari911 35177e8ea635SAtari911 if ($displayName === '(root)') continue; // Never remove root calendar dir 35187e8ea635SAtari911 35197e8ea635SAtari911 if (!$hasEvents || empty($jsonFiles)) { 35207e8ea635SAtari911 $removedCalDirs++; 35217e8ea635SAtari911 $details[] = "Remove empty calendar folder: " . $displayName . "/calendar/ (0 events)"; 35227e8ea635SAtari911 35237e8ea635SAtari911 if (!$dryRun) { 35247e8ea635SAtari911 // Remove all remaining files in calendar dir 35257e8ea635SAtari911 foreach (glob($calDir . '/*') as $f) { 35267e8ea635SAtari911 if (is_file($f)) unlink($f); 35277e8ea635SAtari911 } 35287e8ea635SAtari911 @rmdir($calDir); 35297e8ea635SAtari911 35307e8ea635SAtari911 // Check if parent namespace dir is now empty too 35317e8ea635SAtari911 $parentDir = dirname($calDir); 35327e8ea635SAtari911 if ($parentDir !== $metaDir && is_dir($parentDir)) { 35337e8ea635SAtari911 $remaining = array_diff(scandir($parentDir), ['.', '..']); 35347e8ea635SAtari911 if (empty($remaining)) { 35357e8ea635SAtari911 @rmdir($parentDir); 35367e8ea635SAtari911 $removedDirs++; 35377e8ea635SAtari911 $details[] = "Removed empty namespace directory: " . $displayName . "/"; 35387e8ea635SAtari911 } 35397e8ea635SAtari911 } 35407e8ea635SAtari911 } 35417e8ea635SAtari911 } 35427e8ea635SAtari911 } 35437e8ea635SAtari911 35447e8ea635SAtari911 // 3. Also scan for namespace dirs that have a calendar/ subdir with 0 json files 35457e8ea635SAtari911 // (already covered above, but also check for namespace dirs without calendar/ at all 35467e8ea635SAtari911 // that are tracked in the event system) 35477e8ea635SAtari911 35487e8ea635SAtari911 $total = $removedCalDirs + $removedDirs; 35497e8ea635SAtari911 $message = $dryRun 35507e8ea635SAtari911 ? "Found $total item(s) to clean up" 35517e8ea635SAtari911 : "Cleaned up $removedCalDirs empty calendar folder(s)" . ($removedDirs > 0 ? " and $removedDirs empty namespace directory(ies)" : ""); 35527e8ea635SAtari911 35537e8ea635SAtari911 if (!$dryRun) $this->clearStatsCache(); 35547e8ea635SAtari911 35557e8ea635SAtari911 echo json_encode([ 35567e8ea635SAtari911 'success' => true, 35577e8ea635SAtari911 'count' => $total, 35587e8ea635SAtari911 'message' => $message, 35597e8ea635SAtari911 'details' => $details 35607e8ea635SAtari911 ]); 35617e8ea635SAtari911 } 35627e8ea635SAtari911 35637e8ea635SAtari911 /** 35647e8ea635SAtari911 * Recursively find all 'calendar' directories under a base path 35657e8ea635SAtari911 */ 35667e8ea635SAtari911 private function findAllCalendarDirsRecursive($baseDir, &$results) { 35677e8ea635SAtari911 $entries = glob($baseDir . '*', GLOB_ONLYDIR); 35687e8ea635SAtari911 if (!$entries) return; 35697e8ea635SAtari911 35707e8ea635SAtari911 foreach ($entries as $dir) { 35717e8ea635SAtari911 $name = basename($dir); 35727e8ea635SAtari911 if ($name === 'calendar') { 35737e8ea635SAtari911 $results[] = $dir; 35747e8ea635SAtari911 } else { 35757e8ea635SAtari911 // Check for calendar subdir 35767e8ea635SAtari911 if (is_dir($dir . '/calendar')) { 35777e8ea635SAtari911 $results[] = $dir . '/calendar'; 35787e8ea635SAtari911 } 35797e8ea635SAtari911 // Recurse into subdirectories for nested namespaces 35807e8ea635SAtari911 $this->findAllCalendarDirsRecursive($dir . '/', $results); 35817e8ea635SAtari911 } 35827e8ea635SAtari911 } 35837e8ea635SAtari911 } 35847e8ea635SAtari911 35857e8ea635SAtari911 private function handleTrimAllPastRecurring() { 35867e8ea635SAtari911 global $INPUT; 35877e8ea635SAtari911 $dryRun = $INPUT->bool('dry_run', false); 35887e8ea635SAtari911 $today = date('Y-m-d'); 3589*2866e827SAtari911 $dataDir = $this->metaDir(); 35907e8ea635SAtari911 $calendarDirs = []; 35917e8ea635SAtari911 35927e8ea635SAtari911 if (is_dir($dataDir . 'calendar')) { 35937e8ea635SAtari911 $calendarDirs[] = $dataDir . 'calendar'; 35947e8ea635SAtari911 } 35957e8ea635SAtari911 $this->findCalendarDirs($dataDir, $calendarDirs); 35967e8ea635SAtari911 35977e8ea635SAtari911 $removed = 0; 35987e8ea635SAtari911 35997e8ea635SAtari911 foreach ($calendarDirs as $calDir) { 36007e8ea635SAtari911 foreach (glob($calDir . '/*.json') as $file) { 3601815440faSAtari911 $data = CalendarFileHandler::readJson($file); 36027e8ea635SAtari911 if (!$data || !is_array($data)) continue; 36037e8ea635SAtari911 36047e8ea635SAtari911 $modified = false; 36057e8ea635SAtari911 foreach ($data as $dateKey => &$dayEvents) { 360696df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 360796df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 360896df7d3eSAtari911 36097e8ea635SAtari911 if ($dateKey >= $today) continue; 36107e8ea635SAtari911 if (!is_array($dayEvents)) continue; 36117e8ea635SAtari911 36127e8ea635SAtari911 $filtered = []; 36137e8ea635SAtari911 foreach ($dayEvents as $event) { 36147e8ea635SAtari911 if (!empty($event['recurring']) || !empty($event['recurringId'])) { 36157e8ea635SAtari911 $removed++; 36167e8ea635SAtari911 if (!$dryRun) $modified = true; 36177e8ea635SAtari911 } else { 36187e8ea635SAtari911 $filtered[] = $event; 36197e8ea635SAtari911 } 36207e8ea635SAtari911 } 36217e8ea635SAtari911 if (!$dryRun) $dayEvents = $filtered; 36227e8ea635SAtari911 } 36237e8ea635SAtari911 unset($dayEvents); 36247e8ea635SAtari911 36257e8ea635SAtari911 if (!$dryRun && $modified) { 36267e8ea635SAtari911 foreach ($data as $dk => $evts) { 36277e8ea635SAtari911 if (empty($evts)) unset($data[$dk]); 36287e8ea635SAtari911 } 36297e8ea635SAtari911 if (empty($data)) { 36307e8ea635SAtari911 unlink($file); 36317e8ea635SAtari911 } else { 3632815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 36337e8ea635SAtari911 } 36347e8ea635SAtari911 } 36357e8ea635SAtari911 } 36367e8ea635SAtari911 } 36377e8ea635SAtari911 36387e8ea635SAtari911 if (!$dryRun) $this->clearStatsCache(); 3639da206178SAtari911 echo json_encode(['success' => true, 'count' => $removed, 'message' => "Removed $removed past recurring occurrences"]); 36407e8ea635SAtari911 } 36417e8ea635SAtari911 36427e8ea635SAtari911 private function handleRescanRecurring() { 36437e8ea635SAtari911 $colors = $this->getTemplateColors(); 36447e8ea635SAtari911 $recurringEvents = $this->findRecurringEvents(); 36457e8ea635SAtari911 36467e8ea635SAtari911 ob_start(); 36477e8ea635SAtari911 $this->renderRecurringTable($recurringEvents, $colors); 36487e8ea635SAtari911 $html = ob_get_clean(); 36497e8ea635SAtari911 36507e8ea635SAtari911 echo json_encode([ 36517e8ea635SAtari911 'success' => true, 36527e8ea635SAtari911 'html' => $html, 36537e8ea635SAtari911 'count' => count($recurringEvents) 36547e8ea635SAtari911 ]); 36557e8ea635SAtari911 } 36567e8ea635SAtari911 36577e8ea635SAtari911 /** 36587e8ea635SAtari911 * Helper: find all events matching a title in a namespace's calendar dir 36597e8ea635SAtari911 */ 36607e8ea635SAtari911 private function getRecurringSeriesEvents($title, $namespace) { 3661*2866e827SAtari911 $dataDir = $this->metaDir(); 36627e8ea635SAtari911 if ($namespace !== '') { 36637e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 36647e8ea635SAtari911 } 36657e8ea635SAtari911 $dataDir .= 'calendar/'; 36667e8ea635SAtari911 36677e8ea635SAtari911 $events = []; // ['date' => dateKey, 'file' => filepath, 'event' => eventData, 'index' => idx] 36687e8ea635SAtari911 36697e8ea635SAtari911 if (!is_dir($dataDir)) return $events; 36707e8ea635SAtari911 36717e8ea635SAtari911 foreach (glob($dataDir . '*.json') as $file) { 3672815440faSAtari911 $data = CalendarFileHandler::readJson($file); 36737e8ea635SAtari911 if (!$data || !is_array($data)) continue; 36747e8ea635SAtari911 36757e8ea635SAtari911 foreach ($data as $dateKey => $dayEvents) { 367696df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 367796df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 367896df7d3eSAtari911 36797e8ea635SAtari911 if (!is_array($dayEvents)) continue; 36807e8ea635SAtari911 foreach ($dayEvents as $idx => $event) { 368196df7d3eSAtari911 if (!isset($event['title'])) continue; 36827e8ea635SAtari911 if (strtolower(trim($event['title'])) === strtolower(trim($title))) { 36837e8ea635SAtari911 $events[] = [ 36847e8ea635SAtari911 'date' => $dateKey, 36857e8ea635SAtari911 'file' => $file, 36867e8ea635SAtari911 'event' => $event, 36877e8ea635SAtari911 'index' => $idx 36887e8ea635SAtari911 ]; 36897e8ea635SAtari911 } 36907e8ea635SAtari911 } 36917e8ea635SAtari911 } 36927e8ea635SAtari911 } 36937e8ea635SAtari911 36947e8ea635SAtari911 // Sort by date 36957e8ea635SAtari911 usort($events, function($a, $b) { 36967e8ea635SAtari911 return strcmp($a['date'], $b['date']); 36977e8ea635SAtari911 }); 36987e8ea635SAtari911 36997e8ea635SAtari911 return $events; 37007e8ea635SAtari911 } 37017e8ea635SAtari911 37027e8ea635SAtari911 /** 37037e8ea635SAtari911 * Extend series: add more future occurrences 37047e8ea635SAtari911 */ 37057e8ea635SAtari911 private function handleExtendRecurring() { 37067e8ea635SAtari911 global $INPUT; 37077e8ea635SAtari911 $title = $INPUT->str('title'); 37087e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 37097e8ea635SAtari911 $count = $INPUT->int('count', 4); 37107e8ea635SAtari911 $intervalDays = $INPUT->int('interval_days', 7); 37117e8ea635SAtari911 37127e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 37137e8ea635SAtari911 if (empty($events)) { 37147e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Series not found']); 37157e8ea635SAtari911 return; 37167e8ea635SAtari911 } 37177e8ea635SAtari911 37187e8ea635SAtari911 // Use last event as template 37197e8ea635SAtari911 $lastEvent = end($events); 37207e8ea635SAtari911 $lastDate = new DateTime($lastEvent['date']); 37217e8ea635SAtari911 $template = $lastEvent['event']; 37227e8ea635SAtari911 3723*2866e827SAtari911 $dataDir = $this->metaDir(); 37247e8ea635SAtari911 if ($namespace !== '') { 37257e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 37267e8ea635SAtari911 } 37277e8ea635SAtari911 $dataDir .= 'calendar/'; 37287e8ea635SAtari911 37297e8ea635SAtari911 if (!is_dir($dataDir)) mkdir($dataDir, 0755, true); 37307e8ea635SAtari911 37317e8ea635SAtari911 $added = 0; 37327e8ea635SAtari911 $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace); 37337e8ea635SAtari911 $maxExistingIdx = 0; 37347e8ea635SAtari911 foreach ($events as $e) { 37357e8ea635SAtari911 if (isset($e['event']['id']) && preg_match('/-(\d+)$/', $e['event']['id'], $m)) { 37367e8ea635SAtari911 $maxExistingIdx = max($maxExistingIdx, (int)$m[1]); 37377e8ea635SAtari911 } 37387e8ea635SAtari911 } 37397e8ea635SAtari911 37407e8ea635SAtari911 for ($i = 1; $i <= $count; $i++) { 37417e8ea635SAtari911 $newDate = clone $lastDate; 37427e8ea635SAtari911 $newDate->modify('+' . ($i * $intervalDays) . ' days'); 37437e8ea635SAtari911 $dateKey = $newDate->format('Y-m-d'); 37447e8ea635SAtari911 list($year, $month) = explode('-', $dateKey); 37457e8ea635SAtari911 37467e8ea635SAtari911 $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); 3747815440faSAtari911 $fileData = file_exists($file) ? CalendarFileHandler::readJson($file) : []; 37487e8ea635SAtari911 if (!is_array($fileData)) $fileData = []; 37497e8ea635SAtari911 37507e8ea635SAtari911 if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; 37517e8ea635SAtari911 37527e8ea635SAtari911 $newEvent = $template; 37537e8ea635SAtari911 $newEvent['id'] = $baseId . '-' . ($maxExistingIdx + $i); 37547e8ea635SAtari911 $newEvent['recurring'] = true; 37557e8ea635SAtari911 $newEvent['recurringId'] = $baseId; 37567e8ea635SAtari911 $newEvent['created'] = date('Y-m-d H:i:s'); 37577e8ea635SAtari911 unset($newEvent['completed']); 37587e8ea635SAtari911 $newEvent['completed'] = false; 37597e8ea635SAtari911 37607e8ea635SAtari911 $fileData[$dateKey][] = $newEvent; 3761815440faSAtari911 CalendarFileHandler::writeJson($file, $fileData); 37627e8ea635SAtari911 $added++; 37637e8ea635SAtari911 } 37647e8ea635SAtari911 37657e8ea635SAtari911 $this->clearStatsCache(); 3766da206178SAtari911 echo json_encode(['success' => true, 'message' => "Added $added new occurrences"]); 37677e8ea635SAtari911 } 37687e8ea635SAtari911 37697e8ea635SAtari911 /** 37707e8ea635SAtari911 * Trim series: remove past occurrences before a cutoff date 37717e8ea635SAtari911 */ 37727e8ea635SAtari911 private function handleTrimRecurring() { 37737e8ea635SAtari911 global $INPUT; 37747e8ea635SAtari911 $title = $INPUT->str('title'); 37757e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 37767e8ea635SAtari911 $cutoffDate = $INPUT->str('cutoff_date', date('Y-m-d')); 37777e8ea635SAtari911 37787e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 37797e8ea635SAtari911 $removed = 0; 37807e8ea635SAtari911 37817e8ea635SAtari911 foreach ($events as $entry) { 37827e8ea635SAtari911 if ($entry['date'] < $cutoffDate) { 37837e8ea635SAtari911 // Remove this event from its file 3784815440faSAtari911 $data = CalendarFileHandler::readJson($entry['file']); 37857e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 37867e8ea635SAtari911 37877e8ea635SAtari911 // Find and remove by matching title 37887e8ea635SAtari911 foreach ($data[$entry['date']] as $k => $evt) { 37897e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 37907e8ea635SAtari911 unset($data[$entry['date']][$k]); 37917e8ea635SAtari911 $data[$entry['date']] = array_values($data[$entry['date']]); 37927e8ea635SAtari911 $removed++; 37937e8ea635SAtari911 break; 37947e8ea635SAtari911 } 37957e8ea635SAtari911 } 37967e8ea635SAtari911 37977e8ea635SAtari911 // Clean up empty dates 37987e8ea635SAtari911 if (empty($data[$entry['date']])) unset($data[$entry['date']]); 37997e8ea635SAtari911 38007e8ea635SAtari911 if (empty($data)) { 38017e8ea635SAtari911 unlink($entry['file']); 38027e8ea635SAtari911 } else { 3803815440faSAtari911 CalendarFileHandler::writeJson($entry['file'], $data); 38047e8ea635SAtari911 } 38057e8ea635SAtari911 } 38067e8ea635SAtari911 } 38077e8ea635SAtari911 38087e8ea635SAtari911 $this->clearStatsCache(); 3809da206178SAtari911 echo json_encode(['success' => true, 'message' => "Removed $removed past occurrences before $cutoffDate"]); 38107e8ea635SAtari911 } 38117e8ea635SAtari911 38127e8ea635SAtari911 /** 38137e8ea635SAtari911 * Pause series: mark all future occurrences as paused 38147e8ea635SAtari911 */ 38157e8ea635SAtari911 private function handlePauseRecurring() { 38167e8ea635SAtari911 global $INPUT; 38177e8ea635SAtari911 $title = $INPUT->str('title'); 38187e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 38197e8ea635SAtari911 $today = date('Y-m-d'); 38207e8ea635SAtari911 38217e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 38227e8ea635SAtari911 $paused = 0; 38237e8ea635SAtari911 38247e8ea635SAtari911 foreach ($events as $entry) { 38257e8ea635SAtari911 if ($entry['date'] >= $today) { 3826815440faSAtari911 $data = CalendarFileHandler::readJson($entry['file']); 38277e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 38287e8ea635SAtari911 38297e8ea635SAtari911 foreach ($data[$entry['date']] as $k => &$evt) { 38307e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 38317e8ea635SAtari911 $evt['paused'] = true; 38327e8ea635SAtari911 $evt['title'] = '⏸ ' . preg_replace('/^⏸\s*/', '', $evt['title']); 38337e8ea635SAtari911 $paused++; 38347e8ea635SAtari911 break; 38357e8ea635SAtari911 } 38367e8ea635SAtari911 } 38377e8ea635SAtari911 unset($evt); 38387e8ea635SAtari911 3839815440faSAtari911 CalendarFileHandler::writeJson($entry['file'], $data); 38407e8ea635SAtari911 } 38417e8ea635SAtari911 } 38427e8ea635SAtari911 38437e8ea635SAtari911 $this->clearStatsCache(); 3844da206178SAtari911 echo json_encode(['success' => true, 'message' => "Paused $paused future occurrences"]); 38457e8ea635SAtari911 } 38467e8ea635SAtari911 38477e8ea635SAtari911 /** 38487e8ea635SAtari911 * Resume series: unmark paused occurrences 38497e8ea635SAtari911 */ 38507e8ea635SAtari911 private function handleResumeRecurring() { 38517e8ea635SAtari911 global $INPUT; 38527e8ea635SAtari911 $title = $INPUT->str('title'); 38537e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 38547e8ea635SAtari911 38557e8ea635SAtari911 // Search for both paused and non-paused versions 3856*2866e827SAtari911 $dataDir = $this->metaDir(); 38577e8ea635SAtari911 if ($namespace !== '') { 38587e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 38597e8ea635SAtari911 } 38607e8ea635SAtari911 $dataDir .= 'calendar/'; 38617e8ea635SAtari911 38627e8ea635SAtari911 $resumed = 0; 38637e8ea635SAtari911 $cleanTitle = preg_replace('/^⏸\s*/', '', $title); 38647e8ea635SAtari911 38657e8ea635SAtari911 if (!is_dir($dataDir)) { 38667e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Directory not found']); 38677e8ea635SAtari911 return; 38687e8ea635SAtari911 } 38697e8ea635SAtari911 38707e8ea635SAtari911 foreach (glob($dataDir . '*.json') as $file) { 3871815440faSAtari911 $data = CalendarFileHandler::readJson($file); 38727e8ea635SAtari911 if (!$data) continue; 38737e8ea635SAtari911 38747e8ea635SAtari911 $modified = false; 38757e8ea635SAtari911 foreach ($data as $dateKey => &$dayEvents) { 387696df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 387796df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 387896df7d3eSAtari911 if (!is_array($dayEvents)) continue; 387996df7d3eSAtari911 38807e8ea635SAtari911 foreach ($dayEvents as $k => &$evt) { 388196df7d3eSAtari911 if (!isset($evt['title'])) continue; 38827e8ea635SAtari911 $evtCleanTitle = preg_replace('/^⏸\s*/', '', $evt['title']); 38837e8ea635SAtari911 if (strtolower(trim($evtCleanTitle)) === strtolower(trim($cleanTitle)) && 38847e8ea635SAtari911 (!empty($evt['paused']) || strpos($evt['title'], '⏸') === 0)) { 38857e8ea635SAtari911 $evt['paused'] = false; 38867e8ea635SAtari911 $evt['title'] = $cleanTitle; 38877e8ea635SAtari911 $resumed++; 38887e8ea635SAtari911 $modified = true; 38897e8ea635SAtari911 } 38907e8ea635SAtari911 } 38917e8ea635SAtari911 unset($evt); 38927e8ea635SAtari911 } 38937e8ea635SAtari911 unset($dayEvents); 38947e8ea635SAtari911 38957e8ea635SAtari911 if ($modified) { 3896815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 38977e8ea635SAtari911 } 38987e8ea635SAtari911 } 38997e8ea635SAtari911 39007e8ea635SAtari911 $this->clearStatsCache(); 3901da206178SAtari911 echo json_encode(['success' => true, 'message' => "Resumed $resumed occurrences"]); 39027e8ea635SAtari911 } 39037e8ea635SAtari911 39047e8ea635SAtari911 /** 39057e8ea635SAtari911 * Change start date: shift all occurrences by an offset 39067e8ea635SAtari911 */ 39077e8ea635SAtari911 private function handleChangeStartRecurring() { 39087e8ea635SAtari911 global $INPUT; 39097e8ea635SAtari911 $title = $INPUT->str('title'); 39107e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 39117e8ea635SAtari911 $newStartDate = $INPUT->str('new_start_date'); 39127e8ea635SAtari911 39137e8ea635SAtari911 if (empty($newStartDate)) { 39147e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'No start date provided']); 39157e8ea635SAtari911 return; 39167e8ea635SAtari911 } 39177e8ea635SAtari911 39187e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 39197e8ea635SAtari911 if (empty($events)) { 39207e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Series not found']); 39217e8ea635SAtari911 return; 39227e8ea635SAtari911 } 39237e8ea635SAtari911 39247e8ea635SAtari911 // Calculate offset from old first date to new first date 39257e8ea635SAtari911 $oldFirst = new DateTime($events[0]['date']); 39267e8ea635SAtari911 $newFirst = new DateTime($newStartDate); 39277e8ea635SAtari911 $offsetDays = (int)$oldFirst->diff($newFirst)->format('%r%a'); 39287e8ea635SAtari911 39297e8ea635SAtari911 if ($offsetDays === 0) { 3930da206178SAtari911 echo json_encode(['success' => true, 'message' => 'Start date unchanged']); 39317e8ea635SAtari911 return; 39327e8ea635SAtari911 } 39337e8ea635SAtari911 3934*2866e827SAtari911 $dataDir = $this->metaDir(); 39357e8ea635SAtari911 if ($namespace !== '') { 39367e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 39377e8ea635SAtari911 } 39387e8ea635SAtari911 $dataDir .= 'calendar/'; 39397e8ea635SAtari911 39407e8ea635SAtari911 // Collect all events to move 39417e8ea635SAtari911 $toMove = []; 39427e8ea635SAtari911 foreach ($events as $entry) { 39437e8ea635SAtari911 $oldDate = new DateTime($entry['date']); 39447e8ea635SAtari911 $newDate = clone $oldDate; 39457e8ea635SAtari911 $newDate->modify(($offsetDays > 0 ? '+' : '') . $offsetDays . ' days'); 39467e8ea635SAtari911 39477e8ea635SAtari911 $toMove[] = [ 39487e8ea635SAtari911 'oldDate' => $entry['date'], 39497e8ea635SAtari911 'newDate' => $newDate->format('Y-m-d'), 39507e8ea635SAtari911 'event' => $entry['event'], 39517e8ea635SAtari911 'file' => $entry['file'] 39527e8ea635SAtari911 ]; 39537e8ea635SAtari911 } 39547e8ea635SAtari911 39557e8ea635SAtari911 // Remove all from old positions 39567e8ea635SAtari911 foreach ($toMove as $move) { 3957815440faSAtari911 $data = CalendarFileHandler::readJson($move['file']); 39587e8ea635SAtari911 if (!$data || !isset($data[$move['oldDate']])) continue; 39597e8ea635SAtari911 39607e8ea635SAtari911 foreach ($data[$move['oldDate']] as $k => $evt) { 39617e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 39627e8ea635SAtari911 unset($data[$move['oldDate']][$k]); 39637e8ea635SAtari911 $data[$move['oldDate']] = array_values($data[$move['oldDate']]); 39647e8ea635SAtari911 break; 39657e8ea635SAtari911 } 39667e8ea635SAtari911 } 39677e8ea635SAtari911 if (empty($data[$move['oldDate']])) unset($data[$move['oldDate']]); 39687e8ea635SAtari911 if (empty($data)) { 39697e8ea635SAtari911 unlink($move['file']); 39707e8ea635SAtari911 } else { 3971815440faSAtari911 CalendarFileHandler::writeJson($move['file'], $data); 39727e8ea635SAtari911 } 39737e8ea635SAtari911 } 39747e8ea635SAtari911 39757e8ea635SAtari911 // Add to new positions 39767e8ea635SAtari911 $moved = 0; 39777e8ea635SAtari911 foreach ($toMove as $move) { 39787e8ea635SAtari911 list($year, $month) = explode('-', $move['newDate']); 39797e8ea635SAtari911 $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); 3980815440faSAtari911 $data = file_exists($file) ? CalendarFileHandler::readJson($file) : []; 39817e8ea635SAtari911 if (!is_array($data)) $data = []; 39827e8ea635SAtari911 39837e8ea635SAtari911 if (!isset($data[$move['newDate']])) $data[$move['newDate']] = []; 39847e8ea635SAtari911 $data[$move['newDate']][] = $move['event']; 3985815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 39867e8ea635SAtari911 $moved++; 39877e8ea635SAtari911 } 39887e8ea635SAtari911 3989da206178SAtari911 $dir = $offsetDays > 0 ? 'forward' : 'back'; 39907e8ea635SAtari911 $this->clearStatsCache(); 3991da206178SAtari911 echo json_encode(['success' => true, 'message' => "Shifted $moved occurrences $dir by " . abs($offsetDays) . " days"]); 39927e8ea635SAtari911 } 39937e8ea635SAtari911 39947e8ea635SAtari911 /** 39957e8ea635SAtari911 * Change pattern: re-space all future events with a new interval 39967e8ea635SAtari911 */ 39977e8ea635SAtari911 private function handleChangePatternRecurring() { 39987e8ea635SAtari911 global $INPUT; 39997e8ea635SAtari911 $title = $INPUT->str('title'); 40007e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 40017e8ea635SAtari911 $newIntervalDays = $INPUT->int('interval_days', 7); 40027e8ea635SAtari911 40037e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 40047e8ea635SAtari911 $today = date('Y-m-d'); 40057e8ea635SAtari911 40067e8ea635SAtari911 // Split into past and future 40077e8ea635SAtari911 $pastEvents = []; 40087e8ea635SAtari911 $futureEvents = []; 40097e8ea635SAtari911 foreach ($events as $e) { 40107e8ea635SAtari911 if ($e['date'] < $today) { 40117e8ea635SAtari911 $pastEvents[] = $e; 40127e8ea635SAtari911 } else { 40137e8ea635SAtari911 $futureEvents[] = $e; 40147e8ea635SAtari911 } 40157e8ea635SAtari911 } 40167e8ea635SAtari911 40177e8ea635SAtari911 if (empty($futureEvents)) { 4018da206178SAtari911 echo json_encode(['success' => false, 'error' => 'No future occurrences to respace']); 40197e8ea635SAtari911 return; 40207e8ea635SAtari911 } 40217e8ea635SAtari911 4022*2866e827SAtari911 $dataDir = $this->metaDir(); 40237e8ea635SAtari911 if ($namespace !== '') { 40247e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 40257e8ea635SAtari911 } 40267e8ea635SAtari911 $dataDir .= 'calendar/'; 40277e8ea635SAtari911 40287e8ea635SAtari911 // Use first future event as anchor 40297e8ea635SAtari911 $anchorDate = new DateTime($futureEvents[0]['date']); 40307e8ea635SAtari911 40317e8ea635SAtari911 // Remove all future events from files 40327e8ea635SAtari911 foreach ($futureEvents as $entry) { 4033815440faSAtari911 $data = CalendarFileHandler::readJson($entry['file']); 40347e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 40357e8ea635SAtari911 40367e8ea635SAtari911 foreach ($data[$entry['date']] as $k => $evt) { 40377e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 40387e8ea635SAtari911 unset($data[$entry['date']][$k]); 40397e8ea635SAtari911 $data[$entry['date']] = array_values($data[$entry['date']]); 40407e8ea635SAtari911 break; 40417e8ea635SAtari911 } 40427e8ea635SAtari911 } 40437e8ea635SAtari911 if (empty($data[$entry['date']])) unset($data[$entry['date']]); 40447e8ea635SAtari911 if (empty($data)) { 40457e8ea635SAtari911 unlink($entry['file']); 40467e8ea635SAtari911 } else { 4047815440faSAtari911 CalendarFileHandler::writeJson($entry['file'], $data); 40487e8ea635SAtari911 } 40497e8ea635SAtari911 } 40507e8ea635SAtari911 40517e8ea635SAtari911 // Re-create with new spacing 40527e8ea635SAtari911 $template = $futureEvents[0]['event']; 40537e8ea635SAtari911 $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace); 40547e8ea635SAtari911 $count = count($futureEvents); 40557e8ea635SAtari911 $created = 0; 40567e8ea635SAtari911 40577e8ea635SAtari911 for ($i = 0; $i < $count; $i++) { 40587e8ea635SAtari911 $newDate = clone $anchorDate; 40597e8ea635SAtari911 $newDate->modify('+' . ($i * $newIntervalDays) . ' days'); 40607e8ea635SAtari911 $dateKey = $newDate->format('Y-m-d'); 40617e8ea635SAtari911 list($year, $month) = explode('-', $dateKey); 40627e8ea635SAtari911 40637e8ea635SAtari911 $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); 4064815440faSAtari911 $fileData = file_exists($file) ? CalendarFileHandler::readJson($file) : []; 40657e8ea635SAtari911 if (!is_array($fileData)) $fileData = []; 40667e8ea635SAtari911 40677e8ea635SAtari911 if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; 40687e8ea635SAtari911 40697e8ea635SAtari911 $newEvent = $template; 40707e8ea635SAtari911 $newEvent['id'] = $baseId . '-respace-' . $i; 40717e8ea635SAtari911 $newEvent['recurring'] = true; 40727e8ea635SAtari911 $newEvent['recurringId'] = $baseId; 40737e8ea635SAtari911 40747e8ea635SAtari911 $fileData[$dateKey][] = $newEvent; 4075815440faSAtari911 CalendarFileHandler::writeJson($file, $fileData); 40767e8ea635SAtari911 $created++; 40777e8ea635SAtari911 } 40787e8ea635SAtari911 40797e8ea635SAtari911 $this->clearStatsCache(); 40807e8ea635SAtari911 $patternName = $this->intervalToPattern($newIntervalDays); 4081da206178SAtari911 echo json_encode(['success' => true, 'message' => "Respaced $created future occurrences to $patternName ($newIntervalDays days)"]); 40827e8ea635SAtari911 } 40837e8ea635SAtari911 40847e8ea635SAtari911 private function intervalToPattern($days) { 4085da206178SAtari911 if ($days == 1) return 'Daily'; 4086da206178SAtari911 if ($days == 7) return 'Weekly'; 4087da206178SAtari911 if ($days == 14) return 'Bi-weekly'; 4088da206178SAtari911 if ($days >= 28 && $days <= 31) return 'Monthly'; 4089da206178SAtari911 if ($days >= 89 && $days <= 93) return 'Quarterly'; 4090da206178SAtari911 if ($days >= 363 && $days <= 368) return 'Yearly'; 4091da206178SAtari911 return "Every $days days"; 40921d05cddcSAtari911 } 40931d05cddcSAtari911 40941d05cddcSAtari911 private function getEventsByNamespace() { 4095*2866e827SAtari911 $dataDir = $this->metaDir(); 40961d05cddcSAtari911 $result = []; 40971d05cddcSAtari911 40981d05cddcSAtari911 // Check root calendar directory first (blank/default namespace) 40991d05cddcSAtari911 $rootCalendarDir = $dataDir . 'calendar'; 41001d05cddcSAtari911 if (is_dir($rootCalendarDir)) { 41011d05cddcSAtari911 $hasFiles = false; 41021d05cddcSAtari911 $events = []; 41031d05cddcSAtari911 41041d05cddcSAtari911 foreach (glob($rootCalendarDir . '/*.json') as $file) { 41051d05cddcSAtari911 $hasFiles = true; 41061d05cddcSAtari911 $month = basename($file, '.json'); 4107815440faSAtari911 $data = CalendarFileHandler::readJson($file); 41081d05cddcSAtari911 if (!$data) continue; 41091d05cddcSAtari911 41101d05cddcSAtari911 foreach ($data as $dateKey => $eventList) { 411196df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 411296df7d3eSAtari911 // Date keys should be in YYYY-MM-DD format 411396df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 411496df7d3eSAtari911 411596df7d3eSAtari911 // Skip if eventList is not an array (corrupted data) 411696df7d3eSAtari911 if (!is_array($eventList)) continue; 411796df7d3eSAtari911 41181d05cddcSAtari911 foreach ($eventList as $event) { 411996df7d3eSAtari911 // Skip if event is not an array 412096df7d3eSAtari911 if (!is_array($event)) continue; 412196df7d3eSAtari911 412296df7d3eSAtari911 // Skip if event doesn't have required fields 412396df7d3eSAtari911 if (empty($event['id']) || empty($event['title'])) continue; 412496df7d3eSAtari911 41251d05cddcSAtari911 $events[] = [ 41261d05cddcSAtari911 'id' => $event['id'], 41271d05cddcSAtari911 'title' => $event['title'], 41281d05cddcSAtari911 'date' => $dateKey, 41291d05cddcSAtari911 'startTime' => $event['startTime'] ?? null, 41301d05cddcSAtari911 'month' => $month 41311d05cddcSAtari911 ]; 41321d05cddcSAtari911 } 41331d05cddcSAtari911 } 41341d05cddcSAtari911 } 41351d05cddcSAtari911 41361d05cddcSAtari911 // Add if it has JSON files (even if empty) 41371d05cddcSAtari911 if ($hasFiles) { 41381d05cddcSAtari911 $result[''] = ['events' => $events]; 41391d05cddcSAtari911 } 41401d05cddcSAtari911 } 41411d05cddcSAtari911 41421d05cddcSAtari911 // Recursively scan all namespace directories including sub-namespaces 41431d05cddcSAtari911 $this->scanNamespaceRecursive($dataDir, '', $result); 41441d05cddcSAtari911 41451d05cddcSAtari911 // Sort namespaces, but keep '' (default) first 41461d05cddcSAtari911 uksort($result, function($a, $b) { 41471d05cddcSAtari911 if ($a === '') return -1; 41481d05cddcSAtari911 if ($b === '') return 1; 41491d05cddcSAtari911 return strcmp($a, $b); 41501d05cddcSAtari911 }); 41511d05cddcSAtari911 41521d05cddcSAtari911 return $result; 41531d05cddcSAtari911 } 41541d05cddcSAtari911 41551d05cddcSAtari911 private function scanNamespaceRecursive($baseDir, $parentNamespace, &$result) { 41561d05cddcSAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 41571d05cddcSAtari911 $dirName = basename($nsDir); 41581d05cddcSAtari911 41591d05cddcSAtari911 // Skip the root 'calendar' dir 41601d05cddcSAtari911 if ($dirName === 'calendar' && empty($parentNamespace)) continue; 41611d05cddcSAtari911 41621d05cddcSAtari911 // Build namespace path 41631d05cddcSAtari911 $namespace = empty($parentNamespace) ? $dirName : $parentNamespace . ':' . $dirName; 41641d05cddcSAtari911 41651d05cddcSAtari911 // Check for calendar directory 41661d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 41671d05cddcSAtari911 if (is_dir($calendarDir)) { 41681d05cddcSAtari911 $hasFiles = false; 41691d05cddcSAtari911 $events = []; 41701d05cddcSAtari911 41711d05cddcSAtari911 // Scan all calendar files 41721d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 41731d05cddcSAtari911 $hasFiles = true; 41741d05cddcSAtari911 $month = basename($file, '.json'); 4175815440faSAtari911 $data = CalendarFileHandler::readJson($file); 41761d05cddcSAtari911 if (!$data) continue; 41771d05cddcSAtari911 41781d05cddcSAtari911 foreach ($data as $dateKey => $eventList) { 417996df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 418096df7d3eSAtari911 // Date keys should be in YYYY-MM-DD format 418196df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 418296df7d3eSAtari911 418396df7d3eSAtari911 // Skip if eventList is not an array (corrupted data) 418496df7d3eSAtari911 if (!is_array($eventList)) continue; 418596df7d3eSAtari911 41861d05cddcSAtari911 foreach ($eventList as $event) { 418796df7d3eSAtari911 // Skip if event is not an array 418896df7d3eSAtari911 if (!is_array($event)) continue; 418996df7d3eSAtari911 419096df7d3eSAtari911 // Skip if event doesn't have required fields 419196df7d3eSAtari911 if (empty($event['id']) || empty($event['title'])) continue; 419296df7d3eSAtari911 41931d05cddcSAtari911 $events[] = [ 41941d05cddcSAtari911 'id' => $event['id'], 41951d05cddcSAtari911 'title' => $event['title'], 41961d05cddcSAtari911 'date' => $dateKey, 41971d05cddcSAtari911 'startTime' => $event['startTime'] ?? null, 41981d05cddcSAtari911 'month' => $month 41991d05cddcSAtari911 ]; 42001d05cddcSAtari911 } 42011d05cddcSAtari911 } 42021d05cddcSAtari911 } 42031d05cddcSAtari911 42041d05cddcSAtari911 // Add namespace if it has JSON files (even if empty) 42051d05cddcSAtari911 if ($hasFiles) { 42061d05cddcSAtari911 $result[$namespace] = ['events' => $events]; 42071d05cddcSAtari911 } 42081d05cddcSAtari911 } 42091d05cddcSAtari911 42101d05cddcSAtari911 // Recursively scan sub-directories 42111d05cddcSAtari911 $this->scanNamespaceRecursive($nsDir . '/', $namespace, $result); 42121d05cddcSAtari911 } 42131d05cddcSAtari911 } 42141d05cddcSAtari911 42151d05cddcSAtari911 private function getAllNamespaces() { 4216*2866e827SAtari911 $dataDir = $this->metaDir(); 42171d05cddcSAtari911 $namespaces = []; 42181d05cddcSAtari911 42191d05cddcSAtari911 // Check root calendar directory first 42201d05cddcSAtari911 $rootCalendarDir = $dataDir . 'calendar'; 42211d05cddcSAtari911 if (is_dir($rootCalendarDir)) { 42221d05cddcSAtari911 $namespaces[] = ''; // Blank/default namespace 42231d05cddcSAtari911 } 42241d05cddcSAtari911 42251d05cddcSAtari911 // Check all other namespace directories 42261d05cddcSAtari911 foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { 42271d05cddcSAtari911 $namespace = basename($nsDir); 42281d05cddcSAtari911 42291d05cddcSAtari911 // Skip the root 'calendar' dir (already added as '') 42301d05cddcSAtari911 if ($namespace === 'calendar') continue; 42311d05cddcSAtari911 42321d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 42331d05cddcSAtari911 if (is_dir($calendarDir)) { 42341d05cddcSAtari911 $namespaces[] = $namespace; 42351d05cddcSAtari911 } 42361d05cddcSAtari911 } 42371d05cddcSAtari911 42381d05cddcSAtari911 return $namespaces; 42391d05cddcSAtari911 } 42401d05cddcSAtari911 42411d05cddcSAtari911 private function searchEvents($search, $filterNamespace) { 4242*2866e827SAtari911 $dataDir = $this->metaDir(); 42431d05cddcSAtari911 $results = []; 42441d05cddcSAtari911 42451d05cddcSAtari911 $search = strtolower(trim($search)); 42461d05cddcSAtari911 42471d05cddcSAtari911 foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { 42481d05cddcSAtari911 $namespace = basename($nsDir); 42491d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 42501d05cddcSAtari911 42511d05cddcSAtari911 if (!is_dir($calendarDir)) continue; 42521d05cddcSAtari911 if ($filterNamespace !== '' && $namespace !== $filterNamespace) continue; 42531d05cddcSAtari911 42541d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 42551d05cddcSAtari911 $month = basename($file, '.json'); 4256815440faSAtari911 $data = CalendarFileHandler::readJson($file); 42571d05cddcSAtari911 if (!$data) continue; 42581d05cddcSAtari911 42591d05cddcSAtari911 foreach ($data as $dateKey => $events) { 426096df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 426196df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 426296df7d3eSAtari911 if (!is_array($events)) continue; 426396df7d3eSAtari911 42641d05cddcSAtari911 foreach ($events as $event) { 426596df7d3eSAtari911 if (!isset($event['title']) || !isset($event['id'])) continue; 42661d05cddcSAtari911 if ($search === '' || strpos(strtolower($event['title']), $search) !== false) { 42671d05cddcSAtari911 $results[] = [ 42681d05cddcSAtari911 'id' => $event['id'], 42691d05cddcSAtari911 'title' => $event['title'], 42701d05cddcSAtari911 'date' => $dateKey, 42711d05cddcSAtari911 'startTime' => $event['startTime'] ?? null, 42721d05cddcSAtari911 'namespace' => $event['namespace'] ?? '', 42731d05cddcSAtari911 'month' => $month 42741d05cddcSAtari911 ]; 42751d05cddcSAtari911 } 42761d05cddcSAtari911 } 42771d05cddcSAtari911 } 42781d05cddcSAtari911 } 42791d05cddcSAtari911 } 42801d05cddcSAtari911 42811d05cddcSAtari911 return $results; 42821d05cddcSAtari911 } 42831d05cddcSAtari911 42841d05cddcSAtari911 private function deleteRecurringSeries() { 42851d05cddcSAtari911 global $INPUT; 42861d05cddcSAtari911 42871d05cddcSAtari911 $eventTitle = $INPUT->str('event_title'); 42881d05cddcSAtari911 $namespace = $INPUT->str('namespace'); 42891d05cddcSAtari911 42907e8ea635SAtari911 // Collect ALL calendar directories 4291*2866e827SAtari911 $dataDir = $this->metaDir(); 42927e8ea635SAtari911 $calendarDirs = []; 42937e8ea635SAtari911 if (is_dir($dataDir . 'calendar')) { 42947e8ea635SAtari911 $calendarDirs[] = $dataDir . 'calendar'; 42951d05cddcSAtari911 } 42967e8ea635SAtari911 $this->findCalendarDirs($dataDir, $calendarDirs); 42971d05cddcSAtari911 42981d05cddcSAtari911 $count = 0; 42991d05cddcSAtari911 43007e8ea635SAtari911 foreach ($calendarDirs as $calDir) { 43017e8ea635SAtari911 foreach (glob($calDir . '/*.json') as $file) { 4302815440faSAtari911 $data = CalendarFileHandler::readJson($file); 43037e8ea635SAtari911 if (!$data || !is_array($data)) continue; 43041d05cddcSAtari911 43051d05cddcSAtari911 $modified = false; 43061d05cddcSAtari911 foreach ($data as $dateKey => $events) { 430796df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 430896df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 430996df7d3eSAtari911 if (!is_array($events)) continue; 431096df7d3eSAtari911 43111d05cddcSAtari911 $filtered = []; 43121d05cddcSAtari911 foreach ($events as $event) { 431396df7d3eSAtari911 if (!isset($event['title'])) { 431496df7d3eSAtari911 $filtered[] = $event; 431596df7d3eSAtari911 continue; 431696df7d3eSAtari911 } 43177e8ea635SAtari911 $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; 43187e8ea635SAtari911 // Match by title AND namespace field 43197e8ea635SAtari911 if (strtolower(trim($event['title'])) === strtolower(trim($eventTitle)) && 43207e8ea635SAtari911 strtolower(trim($eventNs)) === strtolower(trim($namespace))) { 43211d05cddcSAtari911 $count++; 43221d05cddcSAtari911 $modified = true; 43231d05cddcSAtari911 } else { 43241d05cddcSAtari911 $filtered[] = $event; 43251d05cddcSAtari911 } 43261d05cddcSAtari911 } 43271d05cddcSAtari911 $data[$dateKey] = $filtered; 43281d05cddcSAtari911 } 43291d05cddcSAtari911 43301d05cddcSAtari911 if ($modified) { 43319ccd446eSAtari911 foreach ($data as $dk => $evts) { 43329ccd446eSAtari911 if (empty($evts)) unset($data[$dk]); 43339ccd446eSAtari911 } 43349ccd446eSAtari911 43359ccd446eSAtari911 if (empty($data)) { 43369ccd446eSAtari911 unlink($file); 43379ccd446eSAtari911 } else { 4338815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 43391d05cddcSAtari911 } 43401d05cddcSAtari911 } 43411d05cddcSAtari911 } 43429ccd446eSAtari911 } 43431d05cddcSAtari911 43449ccd446eSAtari911 $this->clearStatsCache(); 4345da206178SAtari911 $this->redirect("Deleted $count occurrences of recurring event: " . $eventTitle, 'success', 'manage'); 43461d05cddcSAtari911 } 43471d05cddcSAtari911 43481d05cddcSAtari911 private function editRecurringSeries() { 43491d05cddcSAtari911 global $INPUT; 43501d05cddcSAtari911 43511d05cddcSAtari911 $oldTitle = $INPUT->str('old_title'); 43521d05cddcSAtari911 $oldNamespace = $INPUT->str('old_namespace'); 43531d05cddcSAtari911 $newTitle = $INPUT->str('new_title'); 43541d05cddcSAtari911 $startTime = $INPUT->str('start_time'); 43551d05cddcSAtari911 $endTime = $INPUT->str('end_time'); 43561d05cddcSAtari911 $newNamespace = $INPUT->str('new_namespace'); 43571d05cddcSAtari911 435896df7d3eSAtari911 // New recurrence parameters 435996df7d3eSAtari911 $recurrenceType = $INPUT->str('recurrence_type', ''); 436096df7d3eSAtari911 $recurrenceInterval = $INPUT->int('recurrence_interval', 0); 436196df7d3eSAtari911 $weekDaysStr = $INPUT->str('week_days', ''); 436296df7d3eSAtari911 $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : []; 436396df7d3eSAtari911 $monthlyType = $INPUT->str('monthly_type', ''); 436496df7d3eSAtari911 $monthDay = $INPUT->int('month_day', 0); 436596df7d3eSAtari911 $ordinalWeek = $INPUT->int('ordinal_week', 0); 436696df7d3eSAtari911 $ordinalDay = $INPUT->int('ordinal_day', 0); 436796df7d3eSAtari911 43681d05cddcSAtari911 // Use old namespace if new namespace is empty (keep current) 43691d05cddcSAtari911 if (empty($newNamespace) && !isset($_POST['new_namespace'])) { 43701d05cddcSAtari911 $newNamespace = $oldNamespace; 43711d05cddcSAtari911 } 43721d05cddcSAtari911 43737e8ea635SAtari911 // Collect ALL calendar directories to search 4374*2866e827SAtari911 $dataDir = $this->metaDir(); 43757e8ea635SAtari911 $calendarDirs = []; 43767e8ea635SAtari911 43777e8ea635SAtari911 // Root calendar dir 43787e8ea635SAtari911 if (is_dir($dataDir . 'calendar')) { 43797e8ea635SAtari911 $calendarDirs[] = $dataDir . 'calendar'; 43801d05cddcSAtari911 } 43811d05cddcSAtari911 43827e8ea635SAtari911 // All namespace dirs 43837e8ea635SAtari911 $this->findCalendarDirs($dataDir, $calendarDirs); 43841d05cddcSAtari911 43857e8ea635SAtari911 $count = 0; 43867e8ea635SAtari911 438796df7d3eSAtari911 // Pass 1: Rename title, update time, update namespace field and recurrence metadata in ALL matching events 43887e8ea635SAtari911 foreach ($calendarDirs as $calDir) { 43897e8ea635SAtari911 if (is_string($calDir)) { 43907e8ea635SAtari911 $dir = $calDir; 43917e8ea635SAtari911 } else { 43927e8ea635SAtari911 $dir = $calDir['dir']; 43937e8ea635SAtari911 } 43947e8ea635SAtari911 43957e8ea635SAtari911 foreach (glob($dir . '/*.json') as $file) { 4396815440faSAtari911 $data = CalendarFileHandler::readJson($file); 43977e8ea635SAtari911 if (!$data || !is_array($data)) continue; 43981d05cddcSAtari911 43991d05cddcSAtari911 $modified = false; 44007e8ea635SAtari911 foreach ($data as $dateKey => &$dayEvents) { 440196df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 440296df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 44037e8ea635SAtari911 if (!is_array($dayEvents)) continue; 440496df7d3eSAtari911 44057e8ea635SAtari911 foreach ($dayEvents as $key => &$event) { 440696df7d3eSAtari911 if (!isset($event['title'])) continue; 44077e8ea635SAtari911 // Match by old title (case-insensitive) AND namespace field 44087e8ea635SAtari911 $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; 44097e8ea635SAtari911 if (strtolower(trim($event['title'])) !== strtolower(trim($oldTitle))) continue; 44107e8ea635SAtari911 if (strtolower(trim($eventNs)) !== strtolower(trim($oldNamespace))) continue; 44117e8ea635SAtari911 44127e8ea635SAtari911 // Update title 44137e8ea635SAtari911 $event['title'] = $newTitle; 44141d05cddcSAtari911 44151d05cddcSAtari911 // Update start time if provided 44161d05cddcSAtari911 if (!empty($startTime)) { 44177e8ea635SAtari911 $event['time'] = $startTime; 44181d05cddcSAtari911 } 44191d05cddcSAtari911 44201d05cddcSAtari911 // Update end time if provided 44211d05cddcSAtari911 if (!empty($endTime)) { 44227e8ea635SAtari911 $event['endTime'] = $endTime; 44231d05cddcSAtari911 } 44241d05cddcSAtari911 44257e8ea635SAtari911 // Update namespace field 44267e8ea635SAtari911 $event['namespace'] = $newNamespace; 44271d05cddcSAtari911 442896df7d3eSAtari911 // Update recurrence metadata if provided 442996df7d3eSAtari911 if (!empty($recurrenceType)) { 443096df7d3eSAtari911 $event['recurrenceType'] = $recurrenceType; 443196df7d3eSAtari911 } 443296df7d3eSAtari911 if ($recurrenceInterval > 0) { 443396df7d3eSAtari911 $event['recurrenceInterval'] = $recurrenceInterval; 443496df7d3eSAtari911 } 443596df7d3eSAtari911 if (!empty($weekDays)) { 443696df7d3eSAtari911 $event['weekDays'] = $weekDays; 443796df7d3eSAtari911 } 443896df7d3eSAtari911 if (!empty($monthlyType)) { 443996df7d3eSAtari911 $event['monthlyType'] = $monthlyType; 444096df7d3eSAtari911 if ($monthlyType === 'dayOfMonth' && $monthDay > 0) { 444196df7d3eSAtari911 $event['monthDay'] = $monthDay; 444296df7d3eSAtari911 unset($event['ordinalWeek']); 444396df7d3eSAtari911 unset($event['ordinalDay']); 444496df7d3eSAtari911 } elseif ($monthlyType === 'ordinalWeekday') { 444596df7d3eSAtari911 $event['ordinalWeek'] = $ordinalWeek; 444696df7d3eSAtari911 $event['ordinalDay'] = $ordinalDay; 444796df7d3eSAtari911 unset($event['monthDay']); 444896df7d3eSAtari911 } 444996df7d3eSAtari911 } 445096df7d3eSAtari911 44511d05cddcSAtari911 $count++; 44521d05cddcSAtari911 $modified = true; 44531d05cddcSAtari911 } 44547e8ea635SAtari911 unset($event); 44551d05cddcSAtari911 } 44567e8ea635SAtari911 unset($dayEvents); 44571d05cddcSAtari911 44581d05cddcSAtari911 if ($modified) { 4459815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 44601d05cddcSAtari911 } 44611d05cddcSAtari911 } 44627e8ea635SAtari911 } 44631d05cddcSAtari911 446496df7d3eSAtari911 // Pass 2: Handle recurrence pattern changes - reschedule future events 446596df7d3eSAtari911 $needsReschedule = !empty($recurrenceType) && $recurrenceInterval > 0; 446696df7d3eSAtari911 446796df7d3eSAtari911 if ($needsReschedule && $count > 0) { 446896df7d3eSAtari911 // Get all events with the NEW title 44697e8ea635SAtari911 $allEvents = $this->getRecurringSeriesEvents($newTitle, $newNamespace); 44701d05cddcSAtari911 44717e8ea635SAtari911 if (count($allEvents) > 1) { 447296df7d3eSAtari911 // Sort by date 447396df7d3eSAtari911 usort($allEvents, function($a, $b) { 447496df7d3eSAtari911 return strcmp($a['date'], $b['date']); 447596df7d3eSAtari911 }); 44761d05cddcSAtari911 447796df7d3eSAtari911 $firstDate = new DateTime($allEvents[0]['date']); 447896df7d3eSAtari911 $today = new DateTime(); 447996df7d3eSAtari911 $today->setTime(0, 0, 0); 448096df7d3eSAtari911 448196df7d3eSAtari911 // Find the anchor date - either first date or first future date 448296df7d3eSAtari911 $anchorDate = $firstDate; 448396df7d3eSAtari911 $anchorIndex = 0; 448496df7d3eSAtari911 for ($i = 0; $i < count($allEvents); $i++) { 448596df7d3eSAtari911 $eventDate = new DateTime($allEvents[$i]['date']); 448696df7d3eSAtari911 if ($eventDate >= $today) { 448796df7d3eSAtari911 $anchorDate = $eventDate; 448896df7d3eSAtari911 $anchorIndex = $i; 448996df7d3eSAtari911 break; 449096df7d3eSAtari911 } 449196df7d3eSAtari911 } 449296df7d3eSAtari911 449396df7d3eSAtari911 // Get template from anchor event 449496df7d3eSAtari911 $template = $allEvents[$anchorIndex]['event']; 449596df7d3eSAtari911 449696df7d3eSAtari911 // Remove all future events (we'll recreate them) 449796df7d3eSAtari911 for ($i = $anchorIndex + 1; $i < count($allEvents); $i++) { 44987e8ea635SAtari911 $entry = $allEvents[$i]; 4499815440faSAtari911 $data = CalendarFileHandler::readJson($entry['file']); 45007e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 45017e8ea635SAtari911 45027e8ea635SAtari911 foreach ($data[$entry['date']] as $k => $evt) { 45037e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($newTitle))) { 45047e8ea635SAtari911 unset($data[$entry['date']][$k]); 45057e8ea635SAtari911 $data[$entry['date']] = array_values($data[$entry['date']]); 45067e8ea635SAtari911 break; 45071d05cddcSAtari911 } 45081d05cddcSAtari911 } 45097e8ea635SAtari911 if (empty($data[$entry['date']])) unset($data[$entry['date']]); 45107e8ea635SAtari911 if (empty($data)) { 45117e8ea635SAtari911 unlink($entry['file']); 45127e8ea635SAtari911 } else { 4513815440faSAtari911 CalendarFileHandler::writeJson($entry['file'], $data); 45141d05cddcSAtari911 } 45151d05cddcSAtari911 } 45161d05cddcSAtari911 451796df7d3eSAtari911 // Recreate with new pattern 45187e8ea635SAtari911 $targetDir = ($newNamespace === '') 4519*2866e827SAtari911 ? $this->metaDir() . 'calendar' 4520*2866e827SAtari911 : $this->metaDir() . str_replace(':', '/', $newNamespace) . '/calendar'; 45217e8ea635SAtari911 if (!is_dir($targetDir)) mkdir($targetDir, 0755, true); 45221d05cddcSAtari911 45237e8ea635SAtari911 $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($newTitle . $newNamespace); 45241d05cddcSAtari911 452596df7d3eSAtari911 // Calculate how many future events we need (use same count as before) 452696df7d3eSAtari911 $futureCount = count($allEvents) - $anchorIndex - 1; 452796df7d3eSAtari911 if ($futureCount < 1) $futureCount = 12; // Default to 12 future occurrences 452896df7d3eSAtari911 452996df7d3eSAtari911 // Generate new dates based on recurrence pattern 453096df7d3eSAtari911 $newDates = $this->generateRecurrenceDates( 453196df7d3eSAtari911 $anchorDate->format('Y-m-d'), 453296df7d3eSAtari911 $recurrenceType, 453396df7d3eSAtari911 $recurrenceInterval, 453496df7d3eSAtari911 $weekDays, 453596df7d3eSAtari911 $monthlyType, 453696df7d3eSAtari911 $monthDay, 453796df7d3eSAtari911 $ordinalWeek, 453896df7d3eSAtari911 $ordinalDay, 453996df7d3eSAtari911 $futureCount 454096df7d3eSAtari911 ); 454196df7d3eSAtari911 454296df7d3eSAtari911 // Create events for new dates (skip first since it's the anchor) 454396df7d3eSAtari911 for ($i = 1; $i < count($newDates); $i++) { 454496df7d3eSAtari911 $dateKey = $newDates[$i]; 45457e8ea635SAtari911 list($year, $month) = explode('-', $dateKey); 45461d05cddcSAtari911 45477e8ea635SAtari911 $file = $targetDir . '/' . sprintf('%04d-%02d.json', $year, $month); 4548815440faSAtari911 $fileData = file_exists($file) ? CalendarFileHandler::readJson($file) : []; 45497e8ea635SAtari911 if (!is_array($fileData)) $fileData = []; 45507e8ea635SAtari911 if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; 45511d05cddcSAtari911 45527e8ea635SAtari911 $newEvent = $template; 45537e8ea635SAtari911 $newEvent['id'] = $baseId . '-respace-' . $i; 455496df7d3eSAtari911 $newEvent['recurrenceType'] = $recurrenceType; 455596df7d3eSAtari911 $newEvent['recurrenceInterval'] = $recurrenceInterval; 455696df7d3eSAtari911 if (!empty($weekDays)) $newEvent['weekDays'] = $weekDays; 455796df7d3eSAtari911 if (!empty($monthlyType)) $newEvent['monthlyType'] = $monthlyType; 455896df7d3eSAtari911 if ($monthlyType === 'dayOfMonth' && $monthDay > 0) $newEvent['monthDay'] = $monthDay; 455996df7d3eSAtari911 if ($monthlyType === 'ordinalWeekday') { 456096df7d3eSAtari911 $newEvent['ordinalWeek'] = $ordinalWeek; 456196df7d3eSAtari911 $newEvent['ordinalDay'] = $ordinalDay; 456296df7d3eSAtari911 } 456396df7d3eSAtari911 45647e8ea635SAtari911 $fileData[$dateKey][] = $newEvent; 4565815440faSAtari911 CalendarFileHandler::writeJson($file, $fileData); 45661d05cddcSAtari911 } 45671d05cddcSAtari911 } 45681d05cddcSAtari911 } 45691d05cddcSAtari911 45701d05cddcSAtari911 $changes = []; 45711d05cddcSAtari911 if ($oldTitle !== $newTitle) $changes[] = "title"; 45721d05cddcSAtari911 if (!empty($startTime) || !empty($endTime)) $changes[] = "time"; 457396df7d3eSAtari911 if (!empty($recurrenceType)) $changes[] = "pattern"; 45741d05cddcSAtari911 if ($newNamespace !== $oldNamespace) $changes[] = "namespace"; 45751d05cddcSAtari911 45761d05cddcSAtari911 $changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : ""; 45779ccd446eSAtari911 $this->clearStatsCache(); 4578da206178SAtari911 $this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage'); 45791d05cddcSAtari911 } 45801d05cddcSAtari911 45817e8ea635SAtari911 /** 458296df7d3eSAtari911 * Generate dates for a recurrence pattern 458396df7d3eSAtari911 */ 458496df7d3eSAtari911 private function generateRecurrenceDates($startDate, $type, $interval, $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $count) { 458596df7d3eSAtari911 $dates = [$startDate]; 458696df7d3eSAtari911 $currentDate = new DateTime($startDate); 458796df7d3eSAtari911 $maxIterations = $count * 100; // Safety limit 458896df7d3eSAtari911 $iterations = 0; 458996df7d3eSAtari911 459096df7d3eSAtari911 while (count($dates) < $count + 1 && $iterations < $maxIterations) { 459196df7d3eSAtari911 $iterations++; 459296df7d3eSAtari911 $currentDate->modify('+1 day'); 459396df7d3eSAtari911 $shouldInclude = false; 459496df7d3eSAtari911 459596df7d3eSAtari911 switch ($type) { 459696df7d3eSAtari911 case 'daily': 459796df7d3eSAtari911 $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days; 459896df7d3eSAtari911 $shouldInclude = ($daysSinceStart % $interval === 0); 459996df7d3eSAtari911 break; 460096df7d3eSAtari911 460196df7d3eSAtari911 case 'weekly': 460296df7d3eSAtari911 $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days; 460396df7d3eSAtari911 $weeksSinceStart = floor($daysSinceStart / 7); 460496df7d3eSAtari911 $isCorrectWeek = ($weeksSinceStart % $interval === 0); 460596df7d3eSAtari911 $currentDayOfWeek = (int)$currentDate->format('w'); 460696df7d3eSAtari911 $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays); 460796df7d3eSAtari911 $shouldInclude = $isCorrectWeek && $isDaySelected; 460896df7d3eSAtari911 break; 460996df7d3eSAtari911 461096df7d3eSAtari911 case 'monthly': 461196df7d3eSAtari911 $startDT = new DateTime($startDate); 461296df7d3eSAtari911 $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) + 461396df7d3eSAtari911 ($currentDate->format('n') - $startDT->format('n')); 461496df7d3eSAtari911 $isCorrectMonth = ($monthsSinceStart > 0 && $monthsSinceStart % $interval === 0); 461596df7d3eSAtari911 461696df7d3eSAtari911 if (!$isCorrectMonth) break; 461796df7d3eSAtari911 461896df7d3eSAtari911 if ($monthlyType === 'dayOfMonth' || empty($monthlyType)) { 461996df7d3eSAtari911 $targetDay = $monthDay ?: (int)$startDT->format('j'); 462096df7d3eSAtari911 $currentDay = (int)$currentDate->format('j'); 462196df7d3eSAtari911 $daysInMonth = (int)$currentDate->format('t'); 462296df7d3eSAtari911 $effectiveTargetDay = min($targetDay, $daysInMonth); 462396df7d3eSAtari911 $shouldInclude = ($currentDay === $effectiveTargetDay); 462496df7d3eSAtari911 } else { 462596df7d3eSAtari911 $shouldInclude = $this->isOrdinalWeekdayAdmin($currentDate, $ordinalWeek, $ordinalDay); 462696df7d3eSAtari911 } 462796df7d3eSAtari911 break; 462896df7d3eSAtari911 462996df7d3eSAtari911 case 'yearly': 463096df7d3eSAtari911 $startDT = new DateTime($startDate); 463196df7d3eSAtari911 $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y'); 463296df7d3eSAtari911 $isCorrectYear = ($yearsSinceStart > 0 && $yearsSinceStart % $interval === 0); 463396df7d3eSAtari911 $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d')); 463496df7d3eSAtari911 $shouldInclude = $isCorrectYear && $sameMonthDay; 463596df7d3eSAtari911 break; 463696df7d3eSAtari911 } 463796df7d3eSAtari911 463896df7d3eSAtari911 if ($shouldInclude) { 463996df7d3eSAtari911 $dates[] = $currentDate->format('Y-m-d'); 464096df7d3eSAtari911 } 464196df7d3eSAtari911 } 464296df7d3eSAtari911 464396df7d3eSAtari911 return $dates; 464496df7d3eSAtari911 } 464596df7d3eSAtari911 464696df7d3eSAtari911 /** 464796df7d3eSAtari911 * Check if a date is the Nth occurrence of a weekday in its month (admin version) 464896df7d3eSAtari911 */ 464996df7d3eSAtari911 private function isOrdinalWeekdayAdmin($date, $ordinalWeek, $targetDayOfWeek) { 465096df7d3eSAtari911 $currentDayOfWeek = (int)$date->format('w'); 465196df7d3eSAtari911 if ($currentDayOfWeek !== $targetDayOfWeek) return false; 465296df7d3eSAtari911 465396df7d3eSAtari911 $dayOfMonth = (int)$date->format('j'); 465496df7d3eSAtari911 $daysInMonth = (int)$date->format('t'); 465596df7d3eSAtari911 465696df7d3eSAtari911 if ($ordinalWeek === -1) { 465796df7d3eSAtari911 $daysRemaining = $daysInMonth - $dayOfMonth; 465896df7d3eSAtari911 return $daysRemaining < 7; 465996df7d3eSAtari911 } else { 466096df7d3eSAtari911 $weekNumber = ceil($dayOfMonth / 7); 466196df7d3eSAtari911 return $weekNumber === $ordinalWeek; 466296df7d3eSAtari911 } 466396df7d3eSAtari911 } 466496df7d3eSAtari911 466596df7d3eSAtari911 /** 46667e8ea635SAtari911 * Find all calendar directories recursively 46677e8ea635SAtari911 */ 46687e8ea635SAtari911 private function findCalendarDirs($baseDir, &$dirs) { 46697e8ea635SAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 46707e8ea635SAtari911 $name = basename($nsDir); 46717e8ea635SAtari911 if ($name === 'calendar') continue; // Skip root calendar (added separately) 46727e8ea635SAtari911 46737e8ea635SAtari911 $calDir = $nsDir . '/calendar'; 46747e8ea635SAtari911 if (is_dir($calDir)) { 46757e8ea635SAtari911 $dirs[] = $calDir; 46767e8ea635SAtari911 } 46777e8ea635SAtari911 46787e8ea635SAtari911 // Recurse 46797e8ea635SAtari911 $this->findCalendarDirs($nsDir . '/', $dirs); 46807e8ea635SAtari911 } 46817e8ea635SAtari911 } 46827e8ea635SAtari911 46831d05cddcSAtari911 private function moveEvents() { 46841d05cddcSAtari911 global $INPUT; 46851d05cddcSAtari911 46861d05cddcSAtari911 $events = $INPUT->arr('events'); 46871d05cddcSAtari911 $targetNamespace = $INPUT->str('target_namespace'); 46881d05cddcSAtari911 46891d05cddcSAtari911 if (empty($events)) { 4690da206178SAtari911 $this->redirect('No events selected', 'error', 'manage'); 46911d05cddcSAtari911 } 46921d05cddcSAtari911 46931d05cddcSAtari911 $moved = 0; 46941d05cddcSAtari911 46951d05cddcSAtari911 foreach ($events as $eventData) { 46961d05cddcSAtari911 list($id, $namespace, $date, $month) = explode('|', $eventData); 46971d05cddcSAtari911 46981d05cddcSAtari911 // Determine old file path 46991d05cddcSAtari911 if ($namespace === '') { 4700*2866e827SAtari911 $oldFile = $this->metaDir() . 'calendar/' . $month . '.json'; 47011d05cddcSAtari911 } else { 4702*2866e827SAtari911 $oldFile = $this->metaDir() . $namespace . '/calendar/' . $month . '.json'; 47031d05cddcSAtari911 } 47041d05cddcSAtari911 47051d05cddcSAtari911 if (!file_exists($oldFile)) continue; 47061d05cddcSAtari911 4707815440faSAtari911 $oldData = CalendarFileHandler::readJson($oldFile); 47081d05cddcSAtari911 if (!$oldData) continue; 47091d05cddcSAtari911 47101d05cddcSAtari911 // Find and remove event from old file 47111d05cddcSAtari911 $event = null; 47129ccd446eSAtari911 if (isset($oldData[$date])) { 47131d05cddcSAtari911 foreach ($oldData[$date] as $key => $evt) { 47141d05cddcSAtari911 if ($evt['id'] === $id) { 47151d05cddcSAtari911 $event = $evt; 47161d05cddcSAtari911 unset($oldData[$date][$key]); 47171d05cddcSAtari911 $oldData[$date] = array_values($oldData[$date]); 47181d05cddcSAtari911 break; 47191d05cddcSAtari911 } 47201d05cddcSAtari911 } 47211d05cddcSAtari911 47229ccd446eSAtari911 // Remove empty date arrays 47239ccd446eSAtari911 if (empty($oldData[$date])) { 47249ccd446eSAtari911 unset($oldData[$date]); 47259ccd446eSAtari911 } 47269ccd446eSAtari911 } 47279ccd446eSAtari911 47281d05cddcSAtari911 if (!$event) continue; 47291d05cddcSAtari911 47301d05cddcSAtari911 // Save old file 4731815440faSAtari911 CalendarFileHandler::writeJson($oldFile, $oldData); 47321d05cddcSAtari911 47331d05cddcSAtari911 // Update event namespace 47341d05cddcSAtari911 $event['namespace'] = $targetNamespace; 47351d05cddcSAtari911 47361d05cddcSAtari911 // Determine new file path 47371d05cddcSAtari911 if ($targetNamespace === '') { 4738*2866e827SAtari911 $newFile = $this->metaDir() . 'calendar/' . $month . '.json'; 47391d05cddcSAtari911 $newDir = dirname($newFile); 47401d05cddcSAtari911 } else { 4741*2866e827SAtari911 $newFile = $this->metaDir() . $targetNamespace . '/calendar/' . $month . '.json'; 47421d05cddcSAtari911 $newDir = dirname($newFile); 47431d05cddcSAtari911 } 47441d05cddcSAtari911 47451d05cddcSAtari911 if (!is_dir($newDir)) { 47461d05cddcSAtari911 mkdir($newDir, 0755, true); 47471d05cddcSAtari911 } 47481d05cddcSAtari911 47491d05cddcSAtari911 $newData = []; 47501d05cddcSAtari911 if (file_exists($newFile)) { 4751815440faSAtari911 $newData = CalendarFileHandler::readJson($newFile) ?: []; 47521d05cddcSAtari911 } 47531d05cddcSAtari911 47541d05cddcSAtari911 if (!isset($newData[$date])) { 47551d05cddcSAtari911 $newData[$date] = []; 47561d05cddcSAtari911 } 47571d05cddcSAtari911 $newData[$date][] = $event; 47581d05cddcSAtari911 4759815440faSAtari911 CalendarFileHandler::writeJson($newFile, $newData); 47601d05cddcSAtari911 $moved++; 47611d05cddcSAtari911 } 47621d05cddcSAtari911 4763da206178SAtari911 $displayTarget = $targetNamespace ?: '(default)'; 47649ccd446eSAtari911 $this->clearStatsCache(); 4765da206178SAtari911 $this->redirect("Moved $moved event(s) to namespace: " . $displayTarget, 'success', 'manage'); 47661d05cddcSAtari911 } 47671d05cddcSAtari911 47681d05cddcSAtari911 private function moveSingleEvent() { 47691d05cddcSAtari911 global $INPUT; 47701d05cddcSAtari911 47711d05cddcSAtari911 $eventData = $INPUT->str('event'); 47721d05cddcSAtari911 $targetNamespace = $INPUT->str('target_namespace'); 47731d05cddcSAtari911 47741d05cddcSAtari911 list($id, $namespace, $date, $month) = explode('|', $eventData); 47751d05cddcSAtari911 47761d05cddcSAtari911 // Determine old file path 47771d05cddcSAtari911 if ($namespace === '') { 4778*2866e827SAtari911 $oldFile = $this->metaDir() . 'calendar/' . $month . '.json'; 47791d05cddcSAtari911 } else { 4780*2866e827SAtari911 $oldFile = $this->metaDir() . $namespace . '/calendar/' . $month . '.json'; 47811d05cddcSAtari911 } 47821d05cddcSAtari911 47831d05cddcSAtari911 if (!file_exists($oldFile)) { 4784da206178SAtari911 $this->redirect('Event file not found', 'error', 'manage'); 47851d05cddcSAtari911 } 47861d05cddcSAtari911 4787815440faSAtari911 $oldData = CalendarFileHandler::readJson($oldFile); 47881d05cddcSAtari911 if (!$oldData) { 4789da206178SAtari911 $this->redirect('Could not read event file', 'error', 'manage'); 47901d05cddcSAtari911 } 47911d05cddcSAtari911 47921d05cddcSAtari911 // Find and remove event from old file 47931d05cddcSAtari911 $event = null; 47949ccd446eSAtari911 if (isset($oldData[$date])) { 47951d05cddcSAtari911 foreach ($oldData[$date] as $key => $evt) { 47961d05cddcSAtari911 if ($evt['id'] === $id) { 47971d05cddcSAtari911 $event = $evt; 47981d05cddcSAtari911 unset($oldData[$date][$key]); 47991d05cddcSAtari911 $oldData[$date] = array_values($oldData[$date]); 48001d05cddcSAtari911 break; 48011d05cddcSAtari911 } 48021d05cddcSAtari911 } 48031d05cddcSAtari911 48049ccd446eSAtari911 // Remove empty date arrays 48059ccd446eSAtari911 if (empty($oldData[$date])) { 48069ccd446eSAtari911 unset($oldData[$date]); 48079ccd446eSAtari911 } 48089ccd446eSAtari911 } 48099ccd446eSAtari911 48101d05cddcSAtari911 if (!$event) { 4811da206178SAtari911 $this->redirect('Event not found', 'error', 'manage'); 48121d05cddcSAtari911 } 48131d05cddcSAtari911 48149ccd446eSAtari911 // Save old file (or delete if empty) 48159ccd446eSAtari911 if (empty($oldData)) { 48169ccd446eSAtari911 unlink($oldFile); 48179ccd446eSAtari911 } else { 4818815440faSAtari911 CalendarFileHandler::writeJson($oldFile, $oldData); 48199ccd446eSAtari911 } 48201d05cddcSAtari911 48211d05cddcSAtari911 // Update event namespace 48221d05cddcSAtari911 $event['namespace'] = $targetNamespace; 48231d05cddcSAtari911 48241d05cddcSAtari911 // Determine new file path 48251d05cddcSAtari911 if ($targetNamespace === '') { 4826*2866e827SAtari911 $newFile = $this->metaDir() . 'calendar/' . $month . '.json'; 48271d05cddcSAtari911 $newDir = dirname($newFile); 48281d05cddcSAtari911 } else { 4829*2866e827SAtari911 $newFile = $this->metaDir() . $targetNamespace . '/calendar/' . $month . '.json'; 48301d05cddcSAtari911 $newDir = dirname($newFile); 48311d05cddcSAtari911 } 48321d05cddcSAtari911 48331d05cddcSAtari911 if (!is_dir($newDir)) { 48341d05cddcSAtari911 mkdir($newDir, 0755, true); 48351d05cddcSAtari911 } 48361d05cddcSAtari911 48371d05cddcSAtari911 $newData = []; 48381d05cddcSAtari911 if (file_exists($newFile)) { 4839815440faSAtari911 $newData = CalendarFileHandler::readJson($newFile) ?: []; 48401d05cddcSAtari911 } 48411d05cddcSAtari911 48421d05cddcSAtari911 if (!isset($newData[$date])) { 48431d05cddcSAtari911 $newData[$date] = []; 48441d05cddcSAtari911 } 48451d05cddcSAtari911 $newData[$date][] = $event; 48461d05cddcSAtari911 4847815440faSAtari911 CalendarFileHandler::writeJson($newFile, $newData); 48481d05cddcSAtari911 4849da206178SAtari911 $displayTarget = $targetNamespace ?: '(default)'; 48509ccd446eSAtari911 $this->clearStatsCache(); 4851da206178SAtari911 $this->redirect('Moved "' . $event['title'] . '" to ' . $displayTarget, 'success', 'manage'); 48521d05cddcSAtari911 } 48531d05cddcSAtari911 48541d05cddcSAtari911 private function createNamespace() { 48551d05cddcSAtari911 global $INPUT; 48561d05cddcSAtari911 48571d05cddcSAtari911 $namespaceName = $INPUT->str('namespace_name'); 48581d05cddcSAtari911 48591d05cddcSAtari911 // Validate namespace name 48601d05cddcSAtari911 if (empty($namespaceName)) { 4861da206178SAtari911 $this->redirect('Namespace name cannot be empty', 'error', 'manage'); 48621d05cddcSAtari911 } 48631d05cddcSAtari911 48641d05cddcSAtari911 if (!preg_match('/^[a-zA-Z0-9_:-]+$/', $namespaceName)) { 4865da206178SAtari911 $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 48661d05cddcSAtari911 } 48671d05cddcSAtari911 48681d05cddcSAtari911 // Convert namespace to directory path 48691d05cddcSAtari911 $namespacePath = str_replace(':', '/', $namespaceName); 4870*2866e827SAtari911 $calendarDir = $this->metaDir() . $namespacePath . '/calendar'; 48711d05cddcSAtari911 48721d05cddcSAtari911 // Check if already exists 48731d05cddcSAtari911 if (is_dir($calendarDir)) { 48741d05cddcSAtari911 // Check if it has any JSON files 48751d05cddcSAtari911 $hasFiles = !empty(glob($calendarDir . '/*.json')); 48761d05cddcSAtari911 if ($hasFiles) { 4877da206178SAtari911 $this->redirect("Namespace '$namespaceName' already exists with events", 'info', 'manage'); 48781d05cddcSAtari911 } 48791d05cddcSAtari911 // If directory exists but empty, continue to create placeholder 48801d05cddcSAtari911 } 48811d05cddcSAtari911 48821d05cddcSAtari911 // Create the directory 48831d05cddcSAtari911 if (!is_dir($calendarDir)) { 48841d05cddcSAtari911 if (!mkdir($calendarDir, 0755, true)) { 4885da206178SAtari911 $this->redirect("Failed to create namespace directory", 'error', 'manage'); 48861d05cddcSAtari911 } 48871d05cddcSAtari911 } 48881d05cddcSAtari911 48891d05cddcSAtari911 // Create a placeholder JSON file with an empty structure for current month 48901d05cddcSAtari911 // This ensures the namespace appears in the list immediately 48911d05cddcSAtari911 $currentMonth = date('Y-m'); 48921d05cddcSAtari911 $placeholderFile = $calendarDir . '/' . $currentMonth . '.json'; 48931d05cddcSAtari911 48941d05cddcSAtari911 if (!file_exists($placeholderFile)) { 4895815440faSAtari911 CalendarFileHandler::writeJson($placeholderFile, []); 48961d05cddcSAtari911 } 48971d05cddcSAtari911 4898da206178SAtari911 $this->redirect("Created namespace: $namespaceName", 'success', 'manage'); 48991d05cddcSAtari911 } 49001d05cddcSAtari911 49011d05cddcSAtari911 private function deleteNamespace() { 49021d05cddcSAtari911 global $INPUT; 49031d05cddcSAtari911 49041d05cddcSAtari911 $namespace = $INPUT->str('namespace'); 49051d05cddcSAtari911 49067e8ea635SAtari911 // Validate namespace name to prevent path traversal 49077e8ea635SAtari911 if ($namespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $namespace)) { 4908da206178SAtari911 $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 49097e8ea635SAtari911 return; 49107e8ea635SAtari911 } 49117e8ea635SAtari911 49127e8ea635SAtari911 // Additional safety: ensure no path traversal sequences 49137e8ea635SAtari911 if (strpos($namespace, '..') !== false || strpos($namespace, '/') !== false || strpos($namespace, '\\') !== false) { 4914da206178SAtari911 $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage'); 49157e8ea635SAtari911 return; 49167e8ea635SAtari911 } 49177e8ea635SAtari911 49181d05cddcSAtari911 // Convert namespace to directory path (e.g., "work:projects" → "work/projects") 49191d05cddcSAtari911 $namespacePath = str_replace(':', '/', $namespace); 49201d05cddcSAtari911 49211d05cddcSAtari911 // Determine calendar directory 49221d05cddcSAtari911 if ($namespace === '') { 4923*2866e827SAtari911 $calendarDir = $this->metaDir() . 'calendar'; 49241d05cddcSAtari911 $namespaceDir = null; // Don't delete root 49251d05cddcSAtari911 } else { 4926*2866e827SAtari911 $calendarDir = $this->metaDir() . $namespacePath . '/calendar'; 4927*2866e827SAtari911 $namespaceDir = $this->metaDir() . $namespacePath; 49281d05cddcSAtari911 } 49291d05cddcSAtari911 49301d05cddcSAtari911 // Check if directory exists 49311d05cddcSAtari911 if (!is_dir($calendarDir)) { 49321d05cddcSAtari911 // Maybe it was never created or already deleted 4933da206178SAtari911 $this->redirect("Namespace directory not found: $calendarDir", 'error', 'manage'); 49341d05cddcSAtari911 return; 49351d05cddcSAtari911 } 49361d05cddcSAtari911 49371d05cddcSAtari911 $filesDeleted = 0; 49381d05cddcSAtari911 $eventsDeleted = 0; 49391d05cddcSAtari911 49401d05cddcSAtari911 // Delete all calendar JSON files (including empty ones) 49411d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 4942815440faSAtari911 $data = CalendarFileHandler::readJson($file); 49431d05cddcSAtari911 if ($data) { 49441d05cddcSAtari911 foreach ($data as $events) { 494596df7d3eSAtari911 if (is_array($events)) { 49461d05cddcSAtari911 $eventsDeleted += count($events); 49471d05cddcSAtari911 } 49481d05cddcSAtari911 } 494996df7d3eSAtari911 } 49501d05cddcSAtari911 unlink($file); 49511d05cddcSAtari911 $filesDeleted++; 49521d05cddcSAtari911 } 49531d05cddcSAtari911 49541d05cddcSAtari911 // Delete any other files in calendar directory 49551d05cddcSAtari911 foreach (glob($calendarDir . '/*') as $file) { 49561d05cddcSAtari911 if (is_file($file)) { 49571d05cddcSAtari911 unlink($file); 49581d05cddcSAtari911 } 49591d05cddcSAtari911 } 49601d05cddcSAtari911 49611d05cddcSAtari911 // Remove the calendar directory 49621d05cddcSAtari911 if ($namespace !== '') { 49631d05cddcSAtari911 @rmdir($calendarDir); 49641d05cddcSAtari911 49651d05cddcSAtari911 // Try to remove parent directories if they're empty 49661d05cddcSAtari911 // This handles nested namespaces like work:projects:alpha 49671d05cddcSAtari911 $currentDir = dirname($calendarDir); 4968*2866e827SAtari911 $metaDir = rtrim($this->metaDir(), '/'); 49691d05cddcSAtari911 49701d05cddcSAtari911 while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) { 49711d05cddcSAtari911 if (is_dir($currentDir)) { 49721d05cddcSAtari911 // Check if directory is empty 49731d05cddcSAtari911 $contents = scandir($currentDir); 49741d05cddcSAtari911 $isEmpty = count($contents) === 2; // Only . and .. 49751d05cddcSAtari911 49761d05cddcSAtari911 if ($isEmpty) { 49771d05cddcSAtari911 @rmdir($currentDir); 49781d05cddcSAtari911 $currentDir = dirname($currentDir); 49791d05cddcSAtari911 } else { 49801d05cddcSAtari911 break; // Directory not empty, stop 49811d05cddcSAtari911 } 49821d05cddcSAtari911 } else { 49831d05cddcSAtari911 break; 49841d05cddcSAtari911 } 49851d05cddcSAtari911 } 49861d05cddcSAtari911 } 49871d05cddcSAtari911 4988da206178SAtari911 $displayName = $namespace ?: '(default)'; 49899ccd446eSAtari911 $this->clearStatsCache(); 4990da206178SAtari911 $this->redirect("Deleted namespace '$displayName': $eventsDeleted events in $filesDeleted files", 'success', 'manage'); 49911d05cddcSAtari911 } 49921d05cddcSAtari911 49939ccd446eSAtari911 private function renameNamespace() { 49949ccd446eSAtari911 global $INPUT; 49959ccd446eSAtari911 49969ccd446eSAtari911 $oldNamespace = $INPUT->str('old_namespace'); 49979ccd446eSAtari911 $newNamespace = $INPUT->str('new_namespace'); 49989ccd446eSAtari911 49997e8ea635SAtari911 // Validate namespace names to prevent path traversal 50007e8ea635SAtari911 if ($oldNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $oldNamespace)) { 5001da206178SAtari911 $this->redirect('Invalid old namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 50027e8ea635SAtari911 return; 50037e8ea635SAtari911 } 50047e8ea635SAtari911 50057e8ea635SAtari911 if ($newNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $newNamespace)) { 5006da206178SAtari911 $this->redirect('Invalid new namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 50077e8ea635SAtari911 return; 50087e8ea635SAtari911 } 50097e8ea635SAtari911 50107e8ea635SAtari911 // Additional safety: ensure no path traversal sequences 50117e8ea635SAtari911 if (strpos($oldNamespace, '..') !== false || strpos($oldNamespace, '/') !== false || strpos($oldNamespace, '\\') !== false || 50127e8ea635SAtari911 strpos($newNamespace, '..') !== false || strpos($newNamespace, '/') !== false || strpos($newNamespace, '\\') !== false) { 5013da206178SAtari911 $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage'); 50147e8ea635SAtari911 return; 50157e8ea635SAtari911 } 50167e8ea635SAtari911 50179ccd446eSAtari911 // Validate new namespace name 50189ccd446eSAtari911 if ($newNamespace === '') { 5019da206178SAtari911 $this->redirect("Cannot rename to empty namespace", 'error', 'manage'); 50209ccd446eSAtari911 return; 50219ccd446eSAtari911 } 50229ccd446eSAtari911 50239ccd446eSAtari911 // Convert namespaces to directory paths 50249ccd446eSAtari911 $oldPath = str_replace(':', '/', $oldNamespace); 50259ccd446eSAtari911 $newPath = str_replace(':', '/', $newNamespace); 50269ccd446eSAtari911 50279ccd446eSAtari911 // Determine source and destination directories 50289ccd446eSAtari911 if ($oldNamespace === '') { 5029*2866e827SAtari911 $sourceDir = $this->metaDir() . 'calendar'; 50309ccd446eSAtari911 } else { 5031*2866e827SAtari911 $sourceDir = $this->metaDir() . $oldPath . '/calendar'; 50329ccd446eSAtari911 } 50339ccd446eSAtari911 50349ccd446eSAtari911 if ($newNamespace === '') { 5035*2866e827SAtari911 $targetDir = $this->metaDir() . 'calendar'; 50369ccd446eSAtari911 } else { 5037*2866e827SAtari911 $targetDir = $this->metaDir() . $newPath . '/calendar'; 50389ccd446eSAtari911 } 50399ccd446eSAtari911 50409ccd446eSAtari911 // Check if source exists 50419ccd446eSAtari911 if (!is_dir($sourceDir)) { 5042da206178SAtari911 $this->redirect("Source namespace not found: $oldNamespace", 'error', 'manage'); 50439ccd446eSAtari911 return; 50449ccd446eSAtari911 } 50459ccd446eSAtari911 50469ccd446eSAtari911 // Check if target already exists 50479ccd446eSAtari911 if (is_dir($targetDir)) { 5048da206178SAtari911 $this->redirect("Target namespace already exists: $newNamespace", 'error', 'manage'); 50499ccd446eSAtari911 return; 50509ccd446eSAtari911 } 50519ccd446eSAtari911 50529ccd446eSAtari911 // Create target directory 50539ccd446eSAtari911 if (!file_exists(dirname($targetDir))) { 50549ccd446eSAtari911 mkdir(dirname($targetDir), 0755, true); 50559ccd446eSAtari911 } 50569ccd446eSAtari911 50579ccd446eSAtari911 // Rename directory 50589ccd446eSAtari911 if (!rename($sourceDir, $targetDir)) { 5059da206178SAtari911 $this->redirect("Failed to rename namespace", 'error', 'manage'); 50609ccd446eSAtari911 return; 50619ccd446eSAtari911 } 50629ccd446eSAtari911 50639ccd446eSAtari911 // Update event namespace field in all JSON files 50649ccd446eSAtari911 $eventsUpdated = 0; 50659ccd446eSAtari911 foreach (glob($targetDir . '/*.json') as $file) { 5066815440faSAtari911 $data = CalendarFileHandler::readJson($file); 50679ccd446eSAtari911 if ($data) { 50689ccd446eSAtari911 foreach ($data as $date => &$events) { 50699ccd446eSAtari911 foreach ($events as &$event) { 50709ccd446eSAtari911 if (isset($event['namespace']) && $event['namespace'] === $oldNamespace) { 50719ccd446eSAtari911 $event['namespace'] = $newNamespace; 50729ccd446eSAtari911 $eventsUpdated++; 50739ccd446eSAtari911 } 50749ccd446eSAtari911 } 50759ccd446eSAtari911 } 5076815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 50779ccd446eSAtari911 } 50789ccd446eSAtari911 } 50799ccd446eSAtari911 50809ccd446eSAtari911 // Clean up old directory structure if empty 50819ccd446eSAtari911 if ($oldNamespace !== '') { 50829ccd446eSAtari911 $currentDir = dirname($sourceDir); 5083*2866e827SAtari911 $metaDir = rtrim($this->metaDir(), '/'); 50849ccd446eSAtari911 50859ccd446eSAtari911 while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) { 50869ccd446eSAtari911 if (is_dir($currentDir)) { 50879ccd446eSAtari911 $contents = scandir($currentDir); 50889ccd446eSAtari911 $isEmpty = count($contents) === 2; // Only . and .. 50899ccd446eSAtari911 50909ccd446eSAtari911 if ($isEmpty) { 50919ccd446eSAtari911 @rmdir($currentDir); 50929ccd446eSAtari911 $currentDir = dirname($currentDir); 50939ccd446eSAtari911 } else { 50949ccd446eSAtari911 break; 50959ccd446eSAtari911 } 50969ccd446eSAtari911 } else { 50979ccd446eSAtari911 break; 50989ccd446eSAtari911 } 50999ccd446eSAtari911 } 51009ccd446eSAtari911 } 51019ccd446eSAtari911 51029ccd446eSAtari911 $this->clearStatsCache(); 5103da206178SAtari911 $this->redirect("Renamed namespace from '$oldNamespace' to '$newNamespace' ($eventsUpdated events updated)", 'success', 'manage'); 51049ccd446eSAtari911 } 51059ccd446eSAtari911 51061d05cddcSAtari911 private function deleteSelectedEvents() { 51071d05cddcSAtari911 global $INPUT; 51081d05cddcSAtari911 51091d05cddcSAtari911 $events = $INPUT->arr('events'); 51101d05cddcSAtari911 51111d05cddcSAtari911 if (empty($events)) { 5112da206178SAtari911 $this->redirect('No events selected', 'error', 'manage'); 51131d05cddcSAtari911 } 51141d05cddcSAtari911 51151d05cddcSAtari911 $deletedCount = 0; 51161d05cddcSAtari911 51171d05cddcSAtari911 foreach ($events as $eventData) { 51181d05cddcSAtari911 list($id, $namespace, $date, $month) = explode('|', $eventData); 51191d05cddcSAtari911 51201d05cddcSAtari911 // Determine file path 51211d05cddcSAtari911 if ($namespace === '') { 5122*2866e827SAtari911 $file = $this->metaDir() . 'calendar/' . $month . '.json'; 51231d05cddcSAtari911 } else { 5124*2866e827SAtari911 $file = $this->metaDir() . $namespace . '/calendar/' . $month . '.json'; 51251d05cddcSAtari911 } 51261d05cddcSAtari911 51271d05cddcSAtari911 if (!file_exists($file)) continue; 51281d05cddcSAtari911 5129815440faSAtari911 $data = CalendarFileHandler::readJson($file); 51301d05cddcSAtari911 if (!$data) continue; 51311d05cddcSAtari911 51321d05cddcSAtari911 // Find and remove event 51331d05cddcSAtari911 if (isset($data[$date])) { 51341d05cddcSAtari911 foreach ($data[$date] as $key => $evt) { 51351d05cddcSAtari911 if ($evt['id'] === $id) { 51361d05cddcSAtari911 unset($data[$date][$key]); 51371d05cddcSAtari911 $data[$date] = array_values($data[$date]); 51381d05cddcSAtari911 $deletedCount++; 51391d05cddcSAtari911 break; 51401d05cddcSAtari911 } 51411d05cddcSAtari911 } 51421d05cddcSAtari911 51431d05cddcSAtari911 // Remove empty date arrays 51441d05cddcSAtari911 if (empty($data[$date])) { 51451d05cddcSAtari911 unset($data[$date]); 51461d05cddcSAtari911 } 51471d05cddcSAtari911 51481d05cddcSAtari911 // Save file 5149815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 51501d05cddcSAtari911 } 51511d05cddcSAtari911 } 51521d05cddcSAtari911 51539ccd446eSAtari911 $this->clearStatsCache(); 5154da206178SAtari911 $this->redirect("Deleted $deletedCount event(s)", 'success', 'manage'); 51551d05cddcSAtari911 } 51561d05cddcSAtari911 51579ccd446eSAtari911 /** 51589ccd446eSAtari911 * Clear the event statistics cache so counts refresh after mutations 51599ccd446eSAtari911 */ 51604590242dSAtari911 private function saveImportantNamespaces() { 51614590242dSAtari911 global $INPUT; 51624590242dSAtari911 5163*2866e827SAtari911 $configFile = $this->syncConfigPath(); 51644590242dSAtari911 $config = []; 51654590242dSAtari911 if (file_exists($configFile)) { 51664590242dSAtari911 $config = include $configFile; 51674590242dSAtari911 } 51684590242dSAtari911 51694590242dSAtari911 $config['important_namespaces'] = $INPUT->str('important_namespaces', 'important'); 51704590242dSAtari911 51714590242dSAtari911 $content = "<?php\nreturn " . var_export($config, true) . ";\n"; 51724590242dSAtari911 if (file_put_contents($configFile, $content)) { 5173da206178SAtari911 $this->redirect('Important namespaces saved', 'success', 'manage'); 51744590242dSAtari911 } else { 5175da206178SAtari911 $this->redirect('Error: Could not save configuration', 'error', 'manage'); 51764590242dSAtari911 } 51774590242dSAtari911 } 51784590242dSAtari911 51799ccd446eSAtari911 private function clearStatsCache() { 5180*2866e827SAtari911 $cacheFile = $this->metaDir() . 'calendar/.event_stats_cache'; 51819ccd446eSAtari911 if (file_exists($cacheFile)) { 51829ccd446eSAtari911 unlink($cacheFile); 51839ccd446eSAtari911 } 51849ccd446eSAtari911 } 51859ccd446eSAtari911 51861d05cddcSAtari911 private function getCronStatus() { 51871d05cddcSAtari911 // Try to read root's crontab first, then current user 51881d05cddcSAtari911 $output = []; 51891d05cddcSAtari911 exec('sudo crontab -l 2>/dev/null', $output); 51901d05cddcSAtari911 51911d05cddcSAtari911 // If sudo doesn't work, try current user 51921d05cddcSAtari911 if (empty($output)) { 51931d05cddcSAtari911 exec('crontab -l 2>/dev/null', $output); 51941d05cddcSAtari911 } 51951d05cddcSAtari911 51961d05cddcSAtari911 // Also check system crontab files 51971d05cddcSAtari911 if (empty($output)) { 51981d05cddcSAtari911 $cronFiles = [ 51991d05cddcSAtari911 '/etc/crontab', 52001d05cddcSAtari911 '/etc/cron.d/calendar', 52011d05cddcSAtari911 '/var/spool/cron/root', 52021d05cddcSAtari911 '/var/spool/cron/crontabs/root' 52031d05cddcSAtari911 ]; 52041d05cddcSAtari911 52051d05cddcSAtari911 foreach ($cronFiles as $file) { 52061d05cddcSAtari911 if (file_exists($file) && is_readable($file)) { 52071d05cddcSAtari911 $content = file_get_contents($file); 52081d05cddcSAtari911 $output = explode("\n", $content); 52091d05cddcSAtari911 break; 52101d05cddcSAtari911 } 52111d05cddcSAtari911 } 52121d05cddcSAtari911 } 52131d05cddcSAtari911 52141d05cddcSAtari911 // Look for sync_outlook.php in the cron entries 52151d05cddcSAtari911 foreach ($output as $line) { 52161d05cddcSAtari911 $line = trim($line); 52171d05cddcSAtari911 52181d05cddcSAtari911 // Skip empty lines and comments 52191d05cddcSAtari911 if (empty($line) || $line[0] === '#') continue; 52201d05cddcSAtari911 52211d05cddcSAtari911 // Check if line contains sync_outlook.php 52221d05cddcSAtari911 if (strpos($line, 'sync_outlook.php') !== false) { 52231d05cddcSAtari911 // Parse cron expression 52241d05cddcSAtari911 // Format: minute hour day month weekday [user] command 52251d05cddcSAtari911 $parts = preg_split('/\s+/', $line, 7); 52261d05cddcSAtari911 52271d05cddcSAtari911 if (count($parts) >= 5) { 52281d05cddcSAtari911 // Determine if this has a user field (system crontab format) 52291d05cddcSAtari911 $hasUser = (count($parts) >= 6 && !preg_match('/^[\/\*]/', $parts[5])); 52301d05cddcSAtari911 $offset = $hasUser ? 1 : 0; 52311d05cddcSAtari911 52321d05cddcSAtari911 $frequency = $this->parseCronExpression($parts[0], $parts[1], $parts[2], $parts[3], $parts[4]); 52331d05cddcSAtari911 return [ 52341d05cddcSAtari911 'active' => true, 52351d05cddcSAtari911 'frequency' => $frequency, 52361d05cddcSAtari911 'expression' => implode(' ', array_slice($parts, 0, 5)), 52371d05cddcSAtari911 'full_line' => $line 52381d05cddcSAtari911 ]; 52391d05cddcSAtari911 } 52401d05cddcSAtari911 } 52411d05cddcSAtari911 } 52421d05cddcSAtari911 52431d05cddcSAtari911 return ['active' => false, 'frequency' => '', 'expression' => '', 'full_line' => '']; 52441d05cddcSAtari911 } 52451d05cddcSAtari911 52461d05cddcSAtari911 private function parseCronExpression($minute, $hour, $day, $month, $weekday) { 52471d05cddcSAtari911 // Parse minute field 52481d05cddcSAtari911 if ($minute === '*') { 5249da206178SAtari911 return 'Runs every minute'; 52501d05cddcSAtari911 } elseif (strpos($minute, '*/') === 0) { 5251da206178SAtari911 $interval = substr($minute, 2); 52521d05cddcSAtari911 if ($interval == 1) { 5253da206178SAtari911 return 'Runs every minute'; 5254da206178SAtari911 } elseif ($interval == 5) { 5255da206178SAtari911 return 'Runs every 5 minutes'; 5256da206178SAtari911 } elseif ($interval == 8) { 5257da206178SAtari911 return 'Runs every 8 minutes'; 5258da206178SAtari911 } elseif ($interval == 10) { 5259da206178SAtari911 return 'Runs every 10 minutes'; 5260da206178SAtari911 } elseif ($interval == 15) { 5261da206178SAtari911 return 'Runs every 15 minutes'; 5262da206178SAtari911 } elseif ($interval == 30) { 5263da206178SAtari911 return 'Runs every 30 minutes'; 52641d05cddcSAtari911 } else { 5265da206178SAtari911 return "Runs every $interval minutes"; 52661d05cddcSAtari911 } 52671d05cddcSAtari911 } 52681d05cddcSAtari911 52691d05cddcSAtari911 // Parse hour field 52701d05cddcSAtari911 if ($hour === '*' && $minute !== '*') { 5271da206178SAtari911 return 'Runs hourly'; 52721d05cddcSAtari911 } elseif (strpos($hour, '*/') === 0 && $minute !== '*') { 5273da206178SAtari911 $interval = substr($hour, 2); 52741d05cddcSAtari911 if ($interval == 1) { 5275da206178SAtari911 return 'Runs every hour'; 52761d05cddcSAtari911 } else { 5277da206178SAtari911 return "Runs every $interval hours"; 52781d05cddcSAtari911 } 52791d05cddcSAtari911 } 52801d05cddcSAtari911 52811d05cddcSAtari911 // Parse day field 52821d05cddcSAtari911 if ($day === '*' && $hour !== '*' && $minute !== '*') { 5283da206178SAtari911 return 'Runs daily'; 52841d05cddcSAtari911 } 52851d05cddcSAtari911 52861d05cddcSAtari911 // Default 5287da206178SAtari911 return 'Custom schedule'; 52881d05cddcSAtari911 } 52891d05cddcSAtari911 52901d05cddcSAtari911 private function runSync() { 52911d05cddcSAtari911 global $INPUT; 52921d05cddcSAtari911 52931d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 52941d05cddcSAtari911 header('Content-Type: application/json'); 52951d05cddcSAtari911 52961d05cddcSAtari911 $syncScript = DOKU_PLUGIN . 'calendar/sync_outlook.php'; 5297*2866e827SAtari911 $abortFile = $this->metaDir() . 'calendar/.sync_abort'; 52981d05cddcSAtari911 52991d05cddcSAtari911 // Remove any existing abort flag 53001d05cddcSAtari911 if (file_exists($abortFile)) { 53011d05cddcSAtari911 @unlink($abortFile); 53021d05cddcSAtari911 } 53031d05cddcSAtari911 53041d05cddcSAtari911 if (!file_exists($syncScript)) { 5305da206178SAtari911 echo json_encode(['success' => false, 'message' => 'Sync script not found at: ' . $syncScript]); 53061d05cddcSAtari911 exit; 53071d05cddcSAtari911 } 53081d05cddcSAtari911 530996df7d3eSAtari911 // Get log file from data directory (writable) 531096df7d3eSAtari911 $logFile = $this->getSyncLogPath(); 531196df7d3eSAtari911 $logDir = dirname($logFile); 531296df7d3eSAtari911 531396df7d3eSAtari911 // Ensure log directory exists 531496df7d3eSAtari911 if (!is_dir($logDir)) { 531596df7d3eSAtari911 if (!@mkdir($logDir, 0755, true)) { 5316da206178SAtari911 echo json_encode(['success' => false, 'message' => 'Cannot create log directory: ' . $logDir]); 531796df7d3eSAtari911 exit; 531896df7d3eSAtari911 } 531996df7d3eSAtari911 } 53201d05cddcSAtari911 53211d05cddcSAtari911 // Ensure log file exists and is writable 53221d05cddcSAtari911 if (!file_exists($logFile)) { 532396df7d3eSAtari911 if (!@touch($logFile)) { 5324da206178SAtari911 echo json_encode(['success' => false, 'message' => 'Cannot create log file: ' . $logFile]); 532596df7d3eSAtari911 exit; 532696df7d3eSAtari911 } 53271d05cddcSAtari911 @chmod($logFile, 0666); 53281d05cddcSAtari911 } 53291d05cddcSAtari911 533096df7d3eSAtari911 // Check if we can write to the log 533196df7d3eSAtari911 if (!is_writable($logFile)) { 5332da206178SAtari911 echo json_encode(['success' => false, 'message' => 'Log file not writable: ' . $logFile . ' - Run: chmod 666 ' . $logFile]); 533396df7d3eSAtari911 exit; 533496df7d3eSAtari911 } 533596df7d3eSAtari911 533696df7d3eSAtari911 // Find PHP binary 533796df7d3eSAtari911 $phpPath = $this->findPhpBinary(); 533896df7d3eSAtari911 if (!$phpPath) { 5339da206178SAtari911 echo json_encode(['success' => false, 'message' => 'Cannot find PHP binary']); 534096df7d3eSAtari911 exit; 534196df7d3eSAtari911 } 534296df7d3eSAtari911 534396df7d3eSAtari911 // Get plugin directory for cd command 534496df7d3eSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar'; 534596df7d3eSAtari911 534696df7d3eSAtari911 // Build command - NO --verbose flag because the script logs internally 534796df7d3eSAtari911 // The script writes directly to the log file, so we don't need to capture stdout 534896df7d3eSAtari911 $command = sprintf( 534996df7d3eSAtari911 'cd %s && %s sync_outlook.php 2>&1', 535096df7d3eSAtari911 escapeshellarg($pluginDir), 535196df7d3eSAtari911 $phpPath 535296df7d3eSAtari911 ); 535396df7d3eSAtari911 535496df7d3eSAtari911 // Log that we're starting 53551d05cddcSAtari911 $tz = new DateTimeZone('America/Los_Angeles'); 53561d05cddcSAtari911 $now = new DateTime('now', $tz); 53571d05cddcSAtari911 $timestamp = $now->format('Y-m-d H:i:s'); 53581d05cddcSAtari911 @file_put_contents($logFile, "[$timestamp] [ADMIN] Manual sync triggered via admin panel\n", FILE_APPEND); 535996df7d3eSAtari911 @file_put_contents($logFile, "[$timestamp] [ADMIN] Command: $command\n", FILE_APPEND); 53601d05cddcSAtari911 536196df7d3eSAtari911 // Execute sync 53621d05cddcSAtari911 $output = []; 53631d05cddcSAtari911 $returnCode = 0; 53641d05cddcSAtari911 exec($command, $output, $returnCode); 53651d05cddcSAtari911 536696df7d3eSAtari911 // Only log output if there was an error (the script logs its own progress) 536796df7d3eSAtari911 if ($returnCode !== 0 && !empty($output)) { 536896df7d3eSAtari911 @file_put_contents($logFile, "[$timestamp] [ADMIN] Error output:\n" . implode("\n", $output) . "\n", FILE_APPEND); 53691d05cddcSAtari911 } 53701d05cddcSAtari911 537196df7d3eSAtari911 // Check results 537296df7d3eSAtari911 if ($returnCode === 0) { 53731d05cddcSAtari911 echo json_encode([ 53741d05cddcSAtari911 'success' => true, 5375da206178SAtari911 'message' => 'Sync completed! Check log for details.' 53761d05cddcSAtari911 ]); 53771d05cddcSAtari911 } else { 5378da206178SAtari911 $errorMsg = 'Sync failed (exit code: ' . $returnCode . ')'; 53791d05cddcSAtari911 if (!empty($output)) { 538096df7d3eSAtari911 $lastLines = array_slice($output, -3); 538196df7d3eSAtari911 $errorMsg .= ' - ' . implode(' | ', $lastLines); 53821d05cddcSAtari911 } 53831d05cddcSAtari911 echo json_encode([ 53841d05cddcSAtari911 'success' => false, 53851d05cddcSAtari911 'message' => $errorMsg 53861d05cddcSAtari911 ]); 53871d05cddcSAtari911 } 53881d05cddcSAtari911 exit; 53891d05cddcSAtari911 } 53901d05cddcSAtari911 } 53911d05cddcSAtari911 53921d05cddcSAtari911 private function stopSync() { 53931d05cddcSAtari911 global $INPUT; 53941d05cddcSAtari911 53951d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 53961d05cddcSAtari911 header('Content-Type: application/json'); 53971d05cddcSAtari911 5398*2866e827SAtari911 $abortFile = $this->metaDir() . 'calendar/.sync_abort'; 53991d05cddcSAtari911 54001d05cddcSAtari911 // Create abort flag file 54011d05cddcSAtari911 if (file_put_contents($abortFile, date('Y-m-d H:i:s')) !== false) { 54021d05cddcSAtari911 echo json_encode([ 54031d05cddcSAtari911 'success' => true, 5404da206178SAtari911 'message' => 'Stop signal sent to sync process' 54051d05cddcSAtari911 ]); 54061d05cddcSAtari911 } else { 54071d05cddcSAtari911 echo json_encode([ 54081d05cddcSAtari911 'success' => false, 5409da206178SAtari911 'message' => 'Failed to create abort flag' 54101d05cddcSAtari911 ]); 54111d05cddcSAtari911 } 54121d05cddcSAtari911 exit; 54131d05cddcSAtari911 } 54141d05cddcSAtari911 } 54151d05cddcSAtari911 54161d05cddcSAtari911 private function uploadUpdate() { 54171d05cddcSAtari911 if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) { 5418da206178SAtari911 $this->redirect('Upload failed: ' . ($_FILES['plugin_zip']['error'] ?? 'No file uploaded'), 'error', 'update'); 54191d05cddcSAtari911 return; 54201d05cddcSAtari911 } 54211d05cddcSAtari911 54221d05cddcSAtari911 $uploadedFile = $_FILES['plugin_zip']['tmp_name']; 54231d05cddcSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar/'; 54241d05cddcSAtari911 $backupFirst = isset($_POST['backup_first']); 54251d05cddcSAtari911 54261d05cddcSAtari911 // Check if plugin directory is writable 54271d05cddcSAtari911 if (!is_writable($pluginDir)) { 5428da206178SAtari911 $this->redirect('Plugin directory is not writable. Please check permissions: ' . $pluginDir, 'error', 'update'); 54291d05cddcSAtari911 return; 54301d05cddcSAtari911 } 54311d05cddcSAtari911 54321d05cddcSAtari911 // Check if parent directory is writable (for backup and temp files) 54331d05cddcSAtari911 if (!is_writable(DOKU_PLUGIN)) { 5434da206178SAtari911 $this->redirect('Plugin parent directory is not writable. Please check permissions: ' . DOKU_PLUGIN, 'error', 'update'); 54351d05cddcSAtari911 return; 54361d05cddcSAtari911 } 54371d05cddcSAtari911 54381d05cddcSAtari911 // Verify it's a ZIP file 54391d05cddcSAtari911 $finfo = finfo_open(FILEINFO_MIME_TYPE); 54401d05cddcSAtari911 $mimeType = finfo_file($finfo, $uploadedFile); 54411d05cddcSAtari911 finfo_close($finfo); 54421d05cddcSAtari911 54431d05cddcSAtari911 if ($mimeType !== 'application/zip' && $mimeType !== 'application/x-zip-compressed') { 5444da206178SAtari911 $this->redirect('Invalid file type. Please upload a ZIP file.', 'error', 'update'); 54451d05cddcSAtari911 return; 54461d05cddcSAtari911 } 54471d05cddcSAtari911 54481d05cddcSAtari911 // Create backup if requested 54491d05cddcSAtari911 if ($backupFirst) { 54501d05cddcSAtari911 // Get current version 54511d05cddcSAtari911 $pluginInfo = $pluginDir . 'plugin.info.txt'; 54521d05cddcSAtari911 $version = 'unknown'; 54531d05cddcSAtari911 if (file_exists($pluginInfo)) { 54541d05cddcSAtari911 $info = confToHash($pluginInfo); 54551d05cddcSAtari911 $version = $info['version'] ?? ($info['date'] ?? 'unknown'); 54561d05cddcSAtari911 } 54571d05cddcSAtari911 54581d05cddcSAtari911 $backupName = 'calendar.backup.v' . $version . '.' . date('Y-m-d_H-i-s') . '.zip'; 54591d05cddcSAtari911 $backupPath = DOKU_PLUGIN . $backupName; 54601d05cddcSAtari911 54611d05cddcSAtari911 try { 54621d05cddcSAtari911 $zip = new ZipArchive(); 54631d05cddcSAtari911 if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) { 54649ccd446eSAtari911 $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/'); 54651d05cddcSAtari911 $zip->close(); 54669ccd446eSAtari911 54679ccd446eSAtari911 // Verify backup was created and has content 54689ccd446eSAtari911 if (!file_exists($backupPath)) { 5469da206178SAtari911 $this->redirect('Backup file was not created', 'error', 'update'); 54709ccd446eSAtari911 return; 54719ccd446eSAtari911 } 54729ccd446eSAtari911 54739ccd446eSAtari911 $backupSize = filesize($backupPath); 54749ccd446eSAtari911 if ($backupSize < 1000) { // Backup should be at least 1KB 54759ccd446eSAtari911 @unlink($backupPath); 5476da206178SAtari911 $this->redirect('Backup file is too small (' . $backupSize . ' bytes). Only ' . $fileCount . ' files were added. Backup aborted.', 'error', 'update'); 54779ccd446eSAtari911 return; 54789ccd446eSAtari911 } 54799ccd446eSAtari911 54809ccd446eSAtari911 if ($fileCount < 10) { // Should have at least 10 files 54819ccd446eSAtari911 @unlink($backupPath); 5482da206178SAtari911 $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup aborted.', 'error', 'update'); 54839ccd446eSAtari911 return; 54849ccd446eSAtari911 } 54851d05cddcSAtari911 } else { 5486da206178SAtari911 $this->redirect('Failed to create backup ZIP file', 'error', 'update'); 54871d05cddcSAtari911 return; 54881d05cddcSAtari911 } 54891d05cddcSAtari911 } catch (Exception $e) { 54909ccd446eSAtari911 if (file_exists($backupPath)) { 54919ccd446eSAtari911 @unlink($backupPath); 54929ccd446eSAtari911 } 5493da206178SAtari911 $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update'); 54941d05cddcSAtari911 return; 54951d05cddcSAtari911 } 54961d05cddcSAtari911 } 54971d05cddcSAtari911 54981d05cddcSAtari911 // Extract uploaded ZIP 54991d05cddcSAtari911 $zip = new ZipArchive(); 55001d05cddcSAtari911 if ($zip->open($uploadedFile) !== TRUE) { 5501da206178SAtari911 $this->redirect('Failed to open ZIP file', 'error', 'update'); 55021d05cddcSAtari911 return; 55031d05cddcSAtari911 } 55041d05cddcSAtari911 55051d05cddcSAtari911 // Check if ZIP contains calendar folder 55061d05cddcSAtari911 $hasCalendarFolder = false; 55071d05cddcSAtari911 for ($i = 0; $i < $zip->numFiles; $i++) { 55081d05cddcSAtari911 $filename = $zip->getNameIndex($i); 55091d05cddcSAtari911 if (strpos($filename, 'calendar/') === 0) { 55101d05cddcSAtari911 $hasCalendarFolder = true; 55111d05cddcSAtari911 break; 55121d05cddcSAtari911 } 55131d05cddcSAtari911 } 55141d05cddcSAtari911 55151d05cddcSAtari911 // Extract to temp directory first 55161d05cddcSAtari911 $tempDir = DOKU_PLUGIN . 'calendar_update_temp/'; 55171d05cddcSAtari911 if (is_dir($tempDir)) { 55181d05cddcSAtari911 $this->deleteDirectory($tempDir); 55191d05cddcSAtari911 } 55201d05cddcSAtari911 mkdir($tempDir); 55211d05cddcSAtari911 55221d05cddcSAtari911 $zip->extractTo($tempDir); 55231d05cddcSAtari911 $zip->close(); 55241d05cddcSAtari911 55251d05cddcSAtari911 // Determine source directory 55261d05cddcSAtari911 if ($hasCalendarFolder) { 55271d05cddcSAtari911 $sourceDir = $tempDir . 'calendar/'; 55281d05cddcSAtari911 } else { 55291d05cddcSAtari911 $sourceDir = $tempDir; 55301d05cddcSAtari911 } 55311d05cddcSAtari911 553296df7d3eSAtari911 // Preserve configuration files (sync_state.json and sync.log are now in data/meta/calendar/) 553396df7d3eSAtari911 $preserveFiles = ['sync_config.php']; 55341d05cddcSAtari911 $preserved = []; 55351d05cddcSAtari911 foreach ($preserveFiles as $file) { 55361d05cddcSAtari911 $oldFile = $pluginDir . $file; 55371d05cddcSAtari911 if (file_exists($oldFile)) { 55381d05cddcSAtari911 $preserved[$file] = file_get_contents($oldFile); 55391d05cddcSAtari911 } 55401d05cddcSAtari911 } 55411d05cddcSAtari911 55421d05cddcSAtari911 // Delete old plugin files (except data files) 55431d05cddcSAtari911 $this->deleteDirectoryContents($pluginDir, $preserveFiles); 55441d05cddcSAtari911 55451d05cddcSAtari911 // Copy new files 55461d05cddcSAtari911 $this->recursiveCopy($sourceDir, $pluginDir); 55471d05cddcSAtari911 55481d05cddcSAtari911 // Restore preserved files 55491d05cddcSAtari911 foreach ($preserved as $file => $content) { 55501d05cddcSAtari911 file_put_contents($pluginDir . $file, $content); 55511d05cddcSAtari911 } 55521d05cddcSAtari911 55531d05cddcSAtari911 // Update version and date in plugin.info.txt 55541d05cddcSAtari911 $pluginInfo = $pluginDir . 'plugin.info.txt'; 55551d05cddcSAtari911 if (file_exists($pluginInfo)) { 55561d05cddcSAtari911 $info = confToHash($pluginInfo); 55571d05cddcSAtari911 55581d05cddcSAtari911 // Get new version from uploaded plugin 55591d05cddcSAtari911 $newVersion = $info['version'] ?? 'unknown'; 55601d05cddcSAtari911 55611d05cddcSAtari911 // Update date to current 55621d05cddcSAtari911 $info['date'] = date('Y-m-d'); 55631d05cddcSAtari911 55641d05cddcSAtari911 // Write updated info back 55651d05cddcSAtari911 $lines = []; 55661d05cddcSAtari911 foreach ($info as $key => $value) { 55671d05cddcSAtari911 $lines[] = str_pad($key, 8) . ' ' . $value; 55681d05cddcSAtari911 } 55691d05cddcSAtari911 file_put_contents($pluginInfo, implode("\n", $lines) . "\n"); 55701d05cddcSAtari911 } 55711d05cddcSAtari911 55721d05cddcSAtari911 // Cleanup temp directory 55731d05cddcSAtari911 $this->deleteDirectory($tempDir); 55741d05cddcSAtari911 5575da206178SAtari911 $message = 'Plugin updated successfully!'; 55761d05cddcSAtari911 if ($backupFirst) { 5577da206178SAtari911 $message .= ' Backup saved as: ' . $backupName; 55781d05cddcSAtari911 } 55791d05cddcSAtari911 $this->redirect($message, 'success', 'update'); 55801d05cddcSAtari911 } 55811d05cddcSAtari911 55821d05cddcSAtari911 private function deleteBackup() { 55831d05cddcSAtari911 global $INPUT; 55841d05cddcSAtari911 55851d05cddcSAtari911 $filename = $INPUT->str('backup_file'); 55861d05cddcSAtari911 55871d05cddcSAtari911 if (empty($filename)) { 5588da206178SAtari911 $this->redirect('No backup file specified', 'error', 'update'); 55891d05cddcSAtari911 return; 55901d05cddcSAtari911 } 55911d05cddcSAtari911 55921d05cddcSAtari911 // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal 55931d05cddcSAtari911 if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { 5594da206178SAtari911 $this->redirect('Invalid backup filename', 'error', 'update'); 55951d05cddcSAtari911 return; 55961d05cddcSAtari911 } 55971d05cddcSAtari911 55981d05cddcSAtari911 $backupPath = DOKU_PLUGIN . $filename; 55991d05cddcSAtari911 56001d05cddcSAtari911 if (!file_exists($backupPath)) { 5601da206178SAtari911 $this->redirect('Backup file not found', 'error', 'update'); 56021d05cddcSAtari911 return; 56031d05cddcSAtari911 } 56041d05cddcSAtari911 56051d05cddcSAtari911 if (@unlink($backupPath)) { 5606da206178SAtari911 $this->redirect('Backup deleted: ' . $filename, 'success', 'update'); 56071d05cddcSAtari911 } else { 5608da206178SAtari911 $this->redirect('Failed to delete backup. Check file permissions.', 'error', 'update'); 56091d05cddcSAtari911 } 56101d05cddcSAtari911 } 56111d05cddcSAtari911 56121d05cddcSAtari911 private function renameBackup() { 56131d05cddcSAtari911 global $INPUT; 56141d05cddcSAtari911 56151d05cddcSAtari911 $oldName = $INPUT->str('old_name'); 56161d05cddcSAtari911 $newName = $INPUT->str('new_name'); 56171d05cddcSAtari911 56181d05cddcSAtari911 if (empty($oldName) || empty($newName)) { 5619da206178SAtari911 $this->redirect('Missing filename(s)', 'error', 'update'); 56201d05cddcSAtari911 return; 56211d05cddcSAtari911 } 56221d05cddcSAtari911 56231d05cddcSAtari911 // Security: validate filenames 56241d05cddcSAtari911 if (!preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $oldName) || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $newName)) { 5625da206178SAtari911 $this->redirect('Invalid filename format', 'error', 'update'); 56261d05cddcSAtari911 return; 56271d05cddcSAtari911 } 56281d05cddcSAtari911 56291d05cddcSAtari911 $oldPath = DOKU_PLUGIN . $oldName; 56301d05cddcSAtari911 $newPath = DOKU_PLUGIN . $newName; 56311d05cddcSAtari911 56321d05cddcSAtari911 if (!file_exists($oldPath)) { 5633da206178SAtari911 $this->redirect('Backup file not found', 'error', 'update'); 56341d05cddcSAtari911 return; 56351d05cddcSAtari911 } 56361d05cddcSAtari911 56371d05cddcSAtari911 if (file_exists($newPath)) { 5638da206178SAtari911 $this->redirect('A file with the new name already exists', 'error', 'update'); 56391d05cddcSAtari911 return; 56401d05cddcSAtari911 } 56411d05cddcSAtari911 56421d05cddcSAtari911 if (@rename($oldPath, $newPath)) { 5643da206178SAtari911 $this->redirect('Backup renamed: ' . $oldName . ' → ' . $newName, 'success', 'update'); 56441d05cddcSAtari911 } else { 5645da206178SAtari911 $this->redirect('Failed to rename backup. Check file permissions.', 'error', 'update'); 56461d05cddcSAtari911 } 56471d05cddcSAtari911 } 56481d05cddcSAtari911 564996df7d3eSAtari911 /** 565096df7d3eSAtari911 * Restore a backup using DokuWiki's extension manager 565196df7d3eSAtari911 * This ensures proper permissions and follows DokuWiki's standard installation process 565296df7d3eSAtari911 */ 56531d05cddcSAtari911 private function restoreBackup() { 56541d05cddcSAtari911 global $INPUT; 56551d05cddcSAtari911 56561d05cddcSAtari911 $filename = $INPUT->str('backup_file'); 56571d05cddcSAtari911 56581d05cddcSAtari911 if (empty($filename)) { 5659da206178SAtari911 $this->redirect('No backup file specified', 'error', 'update'); 56601d05cddcSAtari911 return; 56611d05cddcSAtari911 } 56621d05cddcSAtari911 56631d05cddcSAtari911 // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal 56641d05cddcSAtari911 if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { 5665da206178SAtari911 $this->redirect('Invalid backup filename', 'error', 'update'); 56661d05cddcSAtari911 return; 56671d05cddcSAtari911 } 56681d05cddcSAtari911 56691d05cddcSAtari911 $backupPath = DOKU_PLUGIN . $filename; 56701d05cddcSAtari911 56711d05cddcSAtari911 if (!file_exists($backupPath)) { 5672da206178SAtari911 $this->redirect('Backup file not found', 'error', 'update'); 56731d05cddcSAtari911 return; 56741d05cddcSAtari911 } 56751d05cddcSAtari911 567696df7d3eSAtari911 // Try to use DokuWiki's extension manager helper 567796df7d3eSAtari911 $extensionHelper = plugin_load('helper', 'extension_extension'); 567896df7d3eSAtari911 567996df7d3eSAtari911 if (!$extensionHelper) { 568096df7d3eSAtari911 // Extension manager not available - provide manual instructions 5681da206178SAtari911 $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'); 56821d05cddcSAtari911 return; 56831d05cddcSAtari911 } 56841d05cddcSAtari911 568596df7d3eSAtari911 try { 568696df7d3eSAtari911 // Set the extension we're working with 568796df7d3eSAtari911 $extensionHelper->setExtension('calendar'); 568896df7d3eSAtari911 568996df7d3eSAtari911 // Use DokuWiki's extension manager to install from the local file 569096df7d3eSAtari911 // This handles all permissions and file operations properly 569196df7d3eSAtari911 $installed = $extensionHelper->installFromLocal($backupPath, true); // true = overwrite 569296df7d3eSAtari911 569396df7d3eSAtari911 if ($installed) { 5694da206178SAtari911 $this->redirect('Plugin restored from backup: ' . $filename . ' (via Extension Manager)', 'success', 'update'); 569596df7d3eSAtari911 } else { 569696df7d3eSAtari911 // Get any error message from the extension helper 569796df7d3eSAtari911 $errors = $extensionHelper->getErrors(); 569896df7d3eSAtari911 $errorMsg = !empty($errors) ? implode(', ', $errors) : 'Unknown error'; 5699da206178SAtari911 $this->redirect('Restore failed: ' . $errorMsg, 'error', 'update'); 57001d05cddcSAtari911 } 570196df7d3eSAtari911 } catch (Exception $e) { 5702da206178SAtari911 $this->redirect('Restore failed: ' . $e->getMessage(), 'error', 'update'); 57031d05cddcSAtari911 } 57041d05cddcSAtari911 } 57051d05cddcSAtari911 57069ccd446eSAtari911 private function createManualBackup() { 57079ccd446eSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar/'; 57089ccd446eSAtari911 57099ccd446eSAtari911 // Check if plugin directory is readable 57109ccd446eSAtari911 if (!is_readable($pluginDir)) { 5711da206178SAtari911 $this->redirect('Plugin directory is not readable. Please check permissions.', 'error', 'update'); 57129ccd446eSAtari911 return; 57139ccd446eSAtari911 } 57149ccd446eSAtari911 57159ccd446eSAtari911 // Check if parent directory is writable (for saving backup) 57169ccd446eSAtari911 if (!is_writable(DOKU_PLUGIN)) { 5717da206178SAtari911 $this->redirect('Plugin parent directory is not writable. Cannot save backup.', 'error', 'update'); 57189ccd446eSAtari911 return; 57199ccd446eSAtari911 } 57209ccd446eSAtari911 57219ccd446eSAtari911 // Get current version 57229ccd446eSAtari911 $pluginInfo = $pluginDir . 'plugin.info.txt'; 57239ccd446eSAtari911 $version = 'unknown'; 57249ccd446eSAtari911 if (file_exists($pluginInfo)) { 57259ccd446eSAtari911 $info = confToHash($pluginInfo); 57269ccd446eSAtari911 $version = $info['version'] ?? ($info['date'] ?? 'unknown'); 57279ccd446eSAtari911 } 57289ccd446eSAtari911 57299ccd446eSAtari911 $backupName = 'calendar.backup.v' . $version . '.manual.' . date('Y-m-d_H-i-s') . '.zip'; 57309ccd446eSAtari911 $backupPath = DOKU_PLUGIN . $backupName; 57319ccd446eSAtari911 57329ccd446eSAtari911 try { 57339ccd446eSAtari911 $zip = new ZipArchive(); 57349ccd446eSAtari911 if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) { 57359ccd446eSAtari911 $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/'); 57369ccd446eSAtari911 $zip->close(); 57379ccd446eSAtari911 57389ccd446eSAtari911 // Verify backup was created and has content 57399ccd446eSAtari911 if (!file_exists($backupPath)) { 5740da206178SAtari911 $this->redirect('Backup file was not created', 'error', 'update'); 57419ccd446eSAtari911 return; 57429ccd446eSAtari911 } 57439ccd446eSAtari911 57449ccd446eSAtari911 $backupSize = filesize($backupPath); 57459ccd446eSAtari911 if ($backupSize < 1000) { // Backup should be at least 1KB 57469ccd446eSAtari911 @unlink($backupPath); 5747da206178SAtari911 $this->redirect('Backup file is too small (' . $this->formatBytes($backupSize) . '). Only ' . $fileCount . ' files were added. Backup failed.', 'error', 'update'); 57489ccd446eSAtari911 return; 57499ccd446eSAtari911 } 57509ccd446eSAtari911 57519ccd446eSAtari911 if ($fileCount < 10) { // Should have at least 10 files 57529ccd446eSAtari911 @unlink($backupPath); 5753da206178SAtari911 $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup failed.', 'error', 'update'); 57549ccd446eSAtari911 return; 57559ccd446eSAtari911 } 57569ccd446eSAtari911 57579ccd446eSAtari911 // Success! 5758da206178SAtari911 $this->redirect('✓ Manual backup created successfully: ' . $backupName . ' (' . $this->formatBytes($backupSize) . ', ' . $fileCount . ' files)', 'success', 'update'); 57599ccd446eSAtari911 57609ccd446eSAtari911 } else { 5761da206178SAtari911 $this->redirect('Failed to create backup ZIP file', 'error', 'update'); 57629ccd446eSAtari911 return; 57639ccd446eSAtari911 } 57649ccd446eSAtari911 } catch (Exception $e) { 57659ccd446eSAtari911 if (file_exists($backupPath)) { 57669ccd446eSAtari911 @unlink($backupPath); 57679ccd446eSAtari911 } 5768da206178SAtari911 $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update'); 57699ccd446eSAtari911 return; 57709ccd446eSAtari911 } 57719ccd446eSAtari911 } 57729ccd446eSAtari911 57731d05cddcSAtari911 private function addDirectoryToZip($zip, $dir, $zipPath = '') { 57749ccd446eSAtari911 $fileCount = 0; 57759ccd446eSAtari911 $errors = []; 57769ccd446eSAtari911 57777e8ea635SAtari911 // Ensure dir has trailing slash 57787e8ea635SAtari911 $dir = rtrim($dir, '/') . '/'; 57797e8ea635SAtari911 57809ccd446eSAtari911 if (!is_dir($dir)) { 5781da206178SAtari911 throw new Exception("Directory does not exist: $dir"); 57829ccd446eSAtari911 } 57839ccd446eSAtari911 57849ccd446eSAtari911 if (!is_readable($dir)) { 5785da206178SAtari911 throw new Exception("Directory is not readable: $dir"); 57869ccd446eSAtari911 } 57879ccd446eSAtari911 57881d05cddcSAtari911 try { 57897e8ea635SAtari911 // First, add all directories to preserve structure (including empty ones) 57907e8ea635SAtari911 $dirs = new RecursiveIteratorIterator( 57911d05cddcSAtari911 new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), 57927e8ea635SAtari911 RecursiveIteratorIterator::SELF_FIRST // Process directories before their contents 57931d05cddcSAtari911 ); 57941d05cddcSAtari911 57957e8ea635SAtari911 foreach ($dirs as $item) { 57967e8ea635SAtari911 $itemPath = $item->getRealPath(); 57977e8ea635SAtari911 if (!$itemPath) continue; 57989ccd446eSAtari911 57997e8ea635SAtari911 // Calculate relative path from the source directory 58007e8ea635SAtari911 $relativePath = $zipPath . substr($itemPath, strlen($dir)); 58017e8ea635SAtari911 58027e8ea635SAtari911 if ($item->isDir()) { 58037e8ea635SAtari911 // Add directory to ZIP (preserves empty directories and structure) 58047e8ea635SAtari911 $dirInZip = rtrim($relativePath, '/') . '/'; 58057e8ea635SAtari911 $zip->addEmptyDir($dirInZip); 58067e8ea635SAtari911 } else { 58077e8ea635SAtari911 // Add file to ZIP 58087e8ea635SAtari911 if (is_readable($itemPath)) { 58097e8ea635SAtari911 if ($zip->addFile($itemPath, $relativePath)) { 58109ccd446eSAtari911 $fileCount++; 58119ccd446eSAtari911 } else { 5812da206178SAtari911 $errors[] = "Failed to add: " . basename($itemPath); 58139ccd446eSAtari911 } 58149ccd446eSAtari911 } else { 5815da206178SAtari911 $errors[] = "Cannot read: " . basename($itemPath); 58161d05cddcSAtari911 } 58171d05cddcSAtari911 } 58181d05cddcSAtari911 } 58199ccd446eSAtari911 58209ccd446eSAtari911 // Log any errors but don't fail if we got most files 58219ccd446eSAtari911 if (!empty($errors) && count($errors) < 5) { 58229ccd446eSAtari911 foreach ($errors as $error) { 58239ccd446eSAtari911 error_log('Calendar plugin backup warning: ' . $error); 58249ccd446eSAtari911 } 58259ccd446eSAtari911 } 58269ccd446eSAtari911 58279ccd446eSAtari911 // If too many errors, fail 58289ccd446eSAtari911 if (count($errors) > 5) { 58299ccd446eSAtari911 throw new Exception("Too many errors adding files to backup: " . implode(', ', array_slice($errors, 0, 5))); 58309ccd446eSAtari911 } 58319ccd446eSAtari911 58321d05cddcSAtari911 } catch (Exception $e) { 58339ccd446eSAtari911 error_log('Calendar plugin backup error: ' . $e->getMessage()); 58349ccd446eSAtari911 throw $e; 58351d05cddcSAtari911 } 58369ccd446eSAtari911 58379ccd446eSAtari911 return $fileCount; 58381d05cddcSAtari911 } 58391d05cddcSAtari911 58401d05cddcSAtari911 private function deleteDirectory($dir) { 58411d05cddcSAtari911 if (!is_dir($dir)) return; 58421d05cddcSAtari911 58431d05cddcSAtari911 try { 58441d05cddcSAtari911 $files = new RecursiveIteratorIterator( 58451d05cddcSAtari911 new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), 58461d05cddcSAtari911 RecursiveIteratorIterator::CHILD_FIRST 58471d05cddcSAtari911 ); 58481d05cddcSAtari911 58491d05cddcSAtari911 foreach ($files as $file) { 58501d05cddcSAtari911 if ($file->isDir()) { 58511d05cddcSAtari911 @rmdir($file->getRealPath()); 58521d05cddcSAtari911 } else { 58531d05cddcSAtari911 @unlink($file->getRealPath()); 58541d05cddcSAtari911 } 58551d05cddcSAtari911 } 58561d05cddcSAtari911 58571d05cddcSAtari911 @rmdir($dir); 58581d05cddcSAtari911 } catch (Exception $e) { 58591d05cddcSAtari911 error_log('Calendar plugin delete directory error: ' . $e->getMessage()); 58601d05cddcSAtari911 } 58611d05cddcSAtari911 } 58621d05cddcSAtari911 58631d05cddcSAtari911 private function deleteDirectoryContents($dir, $preserve = []) { 58641d05cddcSAtari911 if (!is_dir($dir)) return; 58651d05cddcSAtari911 58661d05cddcSAtari911 $items = scandir($dir); 58671d05cddcSAtari911 foreach ($items as $item) { 58681d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 58691d05cddcSAtari911 if (in_array($item, $preserve)) continue; 58701d05cddcSAtari911 58711d05cddcSAtari911 $path = $dir . $item; 58721d05cddcSAtari911 if (is_dir($path)) { 58731d05cddcSAtari911 $this->deleteDirectory($path); 58741d05cddcSAtari911 } else { 58751d05cddcSAtari911 unlink($path); 58761d05cddcSAtari911 } 58771d05cddcSAtari911 } 58781d05cddcSAtari911 } 58791d05cddcSAtari911 58801d05cddcSAtari911 private function recursiveCopy($src, $dst) { 58817e8ea635SAtari911 if (!is_dir($src)) { 58827e8ea635SAtari911 return false; 58837e8ea635SAtari911 } 58847e8ea635SAtari911 58851d05cddcSAtari911 $dir = opendir($src); 58867e8ea635SAtari911 if (!$dir) { 58877e8ea635SAtari911 return false; 58887e8ea635SAtari911 } 58897e8ea635SAtari911 58907e8ea635SAtari911 // Create destination directory with proper permissions (0755) 58917e8ea635SAtari911 if (!is_dir($dst)) { 58927e8ea635SAtari911 mkdir($dst, 0755, true); 58937e8ea635SAtari911 } 58941d05cddcSAtari911 58951d05cddcSAtari911 while (($file = readdir($dir)) !== false) { 58961d05cddcSAtari911 if ($file !== '.' && $file !== '..') { 58977e8ea635SAtari911 $srcPath = $src . '/' . $file; 58987e8ea635SAtari911 $dstPath = $dst . '/' . $file; 58997e8ea635SAtari911 59007e8ea635SAtari911 if (is_dir($srcPath)) { 59017e8ea635SAtari911 // Recursively copy subdirectory 59027e8ea635SAtari911 $this->recursiveCopy($srcPath, $dstPath); 59031d05cddcSAtari911 } else { 59047e8ea635SAtari911 // Copy file and preserve permissions 59057e8ea635SAtari911 if (copy($srcPath, $dstPath)) { 59067e8ea635SAtari911 // Try to preserve file permissions from source, fallback to 0644 59077e8ea635SAtari911 $perms = @fileperms($srcPath); 59087e8ea635SAtari911 if ($perms !== false) { 59097e8ea635SAtari911 @chmod($dstPath, $perms); 59107e8ea635SAtari911 } else { 59117e8ea635SAtari911 @chmod($dstPath, 0644); 59127e8ea635SAtari911 } 59137e8ea635SAtari911 } 59141d05cddcSAtari911 } 59151d05cddcSAtari911 } 59161d05cddcSAtari911 } 59171d05cddcSAtari911 59181d05cddcSAtari911 closedir($dir); 59197e8ea635SAtari911 return true; 59201d05cddcSAtari911 } 59211d05cddcSAtari911 59221d05cddcSAtari911 private function formatBytes($bytes) { 59231d05cddcSAtari911 if ($bytes >= 1073741824) { 59241d05cddcSAtari911 return number_format($bytes / 1073741824, 2) . ' GB'; 59251d05cddcSAtari911 } elseif ($bytes >= 1048576) { 59261d05cddcSAtari911 return number_format($bytes / 1048576, 2) . ' MB'; 59271d05cddcSAtari911 } elseif ($bytes >= 1024) { 59281d05cddcSAtari911 return number_format($bytes / 1024, 2) . ' KB'; 59291d05cddcSAtari911 } else { 59301d05cddcSAtari911 return $bytes . ' bytes'; 59311d05cddcSAtari911 } 59321d05cddcSAtari911 } 59331d05cddcSAtari911 59341d05cddcSAtari911 private function findPhpBinary() { 59351d05cddcSAtari911 // Try PHP_BINARY constant first (most reliable if available) 59361d05cddcSAtari911 if (defined('PHP_BINARY') && !empty(PHP_BINARY) && is_executable(PHP_BINARY)) { 593796df7d3eSAtari911 return PHP_BINARY; 59381d05cddcSAtari911 } 59391d05cddcSAtari911 59401d05cddcSAtari911 // Try common PHP binary locations 59411d05cddcSAtari911 $possiblePaths = [ 59421d05cddcSAtari911 '/usr/bin/php', 59431d05cddcSAtari911 '/usr/bin/php8.1', 59441d05cddcSAtari911 '/usr/bin/php8.2', 59451d05cddcSAtari911 '/usr/bin/php8.3', 59461d05cddcSAtari911 '/usr/bin/php7.4', 59471d05cddcSAtari911 '/usr/local/bin/php', 59481d05cddcSAtari911 ]; 59491d05cddcSAtari911 59501d05cddcSAtari911 foreach ($possiblePaths as $path) { 595196df7d3eSAtari911 if (is_executable($path)) { 595296df7d3eSAtari911 return $path; 59531d05cddcSAtari911 } 59541d05cddcSAtari911 } 59551d05cddcSAtari911 595696df7d3eSAtari911 // Try using 'which' to find php 595796df7d3eSAtari911 $which = trim(shell_exec('which php 2>/dev/null') ?? ''); 595896df7d3eSAtari911 if (!empty($which) && is_executable($which)) { 595996df7d3eSAtari911 return $which; 596096df7d3eSAtari911 } 596196df7d3eSAtari911 59621d05cddcSAtari911 // Fallback to 'php' and hope it's in PATH 59631d05cddcSAtari911 return 'php'; 59641d05cddcSAtari911 } 59651d05cddcSAtari911 59661d05cddcSAtari911 private function redirect($message, $type = 'success', $tab = null) { 59671d05cddcSAtari911 $url = '?do=admin&page=calendar'; 59681d05cddcSAtari911 if ($tab) { 59691d05cddcSAtari911 $url .= '&tab=' . $tab; 59701d05cddcSAtari911 } 59711d05cddcSAtari911 $url .= '&msg=' . urlencode($message) . '&msgtype=' . $type; 59721d05cddcSAtari911 header('Location: ' . $url); 59731d05cddcSAtari911 exit; 59741d05cddcSAtari911 } 59751d05cddcSAtari911 59761d05cddcSAtari911 private function getLog() { 59771d05cddcSAtari911 global $INPUT; 59781d05cddcSAtari911 59791d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 59801d05cddcSAtari911 header('Content-Type: application/json'); 59811d05cddcSAtari911 598296df7d3eSAtari911 $logFile = $this->getSyncLogPath(); 59831d05cddcSAtari911 $log = ''; 59841d05cddcSAtari911 59851d05cddcSAtari911 if (file_exists($logFile)) { 59861d05cddcSAtari911 // Get last 500 lines 59871d05cddcSAtari911 $lines = file($logFile); 59881d05cddcSAtari911 if ($lines !== false) { 59891d05cddcSAtari911 $lines = array_slice($lines, -500); 59901d05cddcSAtari911 $log = implode('', $lines); 59911d05cddcSAtari911 } 59921d05cddcSAtari911 } else { 5993da206178SAtari911 $log = "No log file found. Sync hasn't run yet."; 59941d05cddcSAtari911 } 59951d05cddcSAtari911 59961d05cddcSAtari911 echo json_encode(['log' => $log]); 59971d05cddcSAtari911 exit; 59981d05cddcSAtari911 } 59991d05cddcSAtari911 } 60001d05cddcSAtari911 60011d05cddcSAtari911 private function exportConfig() { 60021d05cddcSAtari911 global $INPUT; 60031d05cddcSAtari911 60041d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 60051d05cddcSAtari911 header('Content-Type: application/json'); 60061d05cddcSAtari911 60071d05cddcSAtari911 try { 6008*2866e827SAtari911 $configFile = $this->syncConfigPath(); 60091d05cddcSAtari911 60101d05cddcSAtari911 if (!file_exists($configFile)) { 60111d05cddcSAtari911 echo json_encode([ 60121d05cddcSAtari911 'success' => false, 6013da206178SAtari911 'message' => 'Config file not found' 60141d05cddcSAtari911 ]); 60151d05cddcSAtari911 exit; 60161d05cddcSAtari911 } 60171d05cddcSAtari911 60181d05cddcSAtari911 // Read config file 60191d05cddcSAtari911 $configContent = file_get_contents($configFile); 60201d05cddcSAtari911 60211d05cddcSAtari911 // Generate encryption key from DokuWiki secret 60221d05cddcSAtari911 $key = $this->getEncryptionKey(); 60231d05cddcSAtari911 60241d05cddcSAtari911 // Encrypt config 60251d05cddcSAtari911 $encrypted = $this->encryptData($configContent, $key); 60261d05cddcSAtari911 60271d05cddcSAtari911 echo json_encode([ 60281d05cddcSAtari911 'success' => true, 60291d05cddcSAtari911 'encrypted' => $encrypted, 6030da206178SAtari911 'message' => 'Config exported successfully' 60311d05cddcSAtari911 ]); 60321d05cddcSAtari911 exit; 60331d05cddcSAtari911 60341d05cddcSAtari911 } catch (Exception $e) { 60351d05cddcSAtari911 echo json_encode([ 60361d05cddcSAtari911 'success' => false, 60371d05cddcSAtari911 'message' => $e->getMessage() 60381d05cddcSAtari911 ]); 60391d05cddcSAtari911 exit; 60401d05cddcSAtari911 } 60411d05cddcSAtari911 } 60421d05cddcSAtari911 } 60431d05cddcSAtari911 60441d05cddcSAtari911 private function importConfig() { 60451d05cddcSAtari911 global $INPUT; 60461d05cddcSAtari911 60471d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 60481d05cddcSAtari911 header('Content-Type: application/json'); 60491d05cddcSAtari911 60501d05cddcSAtari911 try { 6051da206178SAtari911 $encrypted = $_POST['encrypted_config'] ?? ''; 60521d05cddcSAtari911 60531d05cddcSAtari911 if (empty($encrypted)) { 60541d05cddcSAtari911 echo json_encode([ 60551d05cddcSAtari911 'success' => false, 6056da206178SAtari911 'message' => 'No config data provided' 60571d05cddcSAtari911 ]); 60581d05cddcSAtari911 exit; 60591d05cddcSAtari911 } 60601d05cddcSAtari911 60611d05cddcSAtari911 // Generate encryption key from DokuWiki secret 60621d05cddcSAtari911 $key = $this->getEncryptionKey(); 60631d05cddcSAtari911 60641d05cddcSAtari911 // Decrypt config 60651d05cddcSAtari911 $configContent = $this->decryptData($encrypted, $key); 60661d05cddcSAtari911 6067da206178SAtari911 if ($configContent === false) { 60681d05cddcSAtari911 echo json_encode([ 60691d05cddcSAtari911 'success' => false, 6070da206178SAtari911 'message' => 'Decryption failed. Invalid key or corrupted file.' 60711d05cddcSAtari911 ]); 60721d05cddcSAtari911 exit; 60731d05cddcSAtari911 } 60741d05cddcSAtari911 60757e8ea635SAtari911 // Validate PHP config file structure (without using eval) 60767e8ea635SAtari911 // Check that it starts with <?php and contains a return statement with array 60777e8ea635SAtari911 if (strpos($configContent, '<?php') === false) { 60781d05cddcSAtari911 echo json_encode([ 60791d05cddcSAtari911 'success' => false, 6080da206178SAtari911 'message' => 'Invalid config file: missing PHP opening tag' 60817e8ea635SAtari911 ]); 60827e8ea635SAtari911 exit; 60837e8ea635SAtari911 } 60847e8ea635SAtari911 60857e8ea635SAtari911 // Check for dangerous patterns that shouldn't be in a config file 60867e8ea635SAtari911 $dangerousPatterns = [ 60877e8ea635SAtari911 '/\b(exec|shell_exec|system|passthru|popen|proc_open)\s*\(/i', 60887e8ea635SAtari911 '/\b(eval|assert|create_function)\s*\(/i', 60897e8ea635SAtari911 '/\b(file_get_contents|file_put_contents|fopen|fwrite|unlink|rmdir)\s*\(/i', 60907e8ea635SAtari911 '/\$_(GET|POST|REQUEST|SERVER|FILES|COOKIE|SESSION)\s*\[/i', 60917e8ea635SAtari911 '/`[^`]+`/', // Backtick execution 60927e8ea635SAtari911 ]; 60937e8ea635SAtari911 60947e8ea635SAtari911 foreach ($dangerousPatterns as $pattern) { 60957e8ea635SAtari911 if (preg_match($pattern, $configContent)) { 60967e8ea635SAtari911 echo json_encode([ 60977e8ea635SAtari911 'success' => false, 6098da206178SAtari911 'message' => 'Invalid config file: contains prohibited code patterns' 60997e8ea635SAtari911 ]); 61007e8ea635SAtari911 exit; 61017e8ea635SAtari911 } 61027e8ea635SAtari911 } 61037e8ea635SAtari911 61047e8ea635SAtari911 // Verify it looks like a valid config (has return array structure) 6105da206178SAtari911 // Accept both "return [" and "return array(" syntax 610622228b0eSAtari911 if (!preg_match('/return\s*(\[|array\s*\()/', $configContent)) { 61077e8ea635SAtari911 echo json_encode([ 61087e8ea635SAtari911 'success' => false, 6109da206178SAtari911 'message' => 'Invalid config file: must contain a return array statement' 61101d05cddcSAtari911 ]); 61111d05cddcSAtari911 exit; 61121d05cddcSAtari911 } 61131d05cddcSAtari911 61141d05cddcSAtari911 // Write to config file 6115*2866e827SAtari911 $configFile = $this->syncConfigPath(); 61161d05cddcSAtari911 61171d05cddcSAtari911 // Backup existing config 61181d05cddcSAtari911 if (file_exists($configFile)) { 61191d05cddcSAtari911 $backupFile = $configFile . '.backup.' . date('Y-m-d_H-i-s'); 61201d05cddcSAtari911 copy($configFile, $backupFile); 61211d05cddcSAtari911 } 61221d05cddcSAtari911 61231d05cddcSAtari911 // Write new config 61241d05cddcSAtari911 if (file_put_contents($configFile, $configContent) === false) { 61251d05cddcSAtari911 echo json_encode([ 61261d05cddcSAtari911 'success' => false, 6127da206178SAtari911 'message' => 'Failed to write config file' 61281d05cddcSAtari911 ]); 61291d05cddcSAtari911 exit; 61301d05cddcSAtari911 } 61311d05cddcSAtari911 61321d05cddcSAtari911 echo json_encode([ 61331d05cddcSAtari911 'success' => true, 6134da206178SAtari911 'message' => 'Config imported successfully' 61351d05cddcSAtari911 ]); 61361d05cddcSAtari911 exit; 61371d05cddcSAtari911 61381d05cddcSAtari911 } catch (Exception $e) { 61391d05cddcSAtari911 echo json_encode([ 61401d05cddcSAtari911 'success' => false, 61411d05cddcSAtari911 'message' => $e->getMessage() 61421d05cddcSAtari911 ]); 61431d05cddcSAtari911 exit; 61441d05cddcSAtari911 } 61451d05cddcSAtari911 } 61461d05cddcSAtari911 } 61471d05cddcSAtari911 61481d05cddcSAtari911 private function getEncryptionKey() { 61491d05cddcSAtari911 global $conf; 61501d05cddcSAtari911 // Use DokuWiki's secret as the base for encryption 61511d05cddcSAtari911 // This ensures the key is unique per installation 61521d05cddcSAtari911 return hash('sha256', $conf['secret'] . 'calendar_config_encryption', true); 61531d05cddcSAtari911 } 61541d05cddcSAtari911 61551d05cddcSAtari911 private function encryptData($data, $key) { 61561d05cddcSAtari911 // Use AES-256-CBC encryption 61571d05cddcSAtari911 $ivLength = openssl_cipher_iv_length('aes-256-cbc'); 61581d05cddcSAtari911 $iv = openssl_random_pseudo_bytes($ivLength); 61591d05cddcSAtari911 61601d05cddcSAtari911 $encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv); 61611d05cddcSAtari911 61621d05cddcSAtari911 // Combine IV and encrypted data, then base64 encode 61631d05cddcSAtari911 return base64_encode($iv . $encrypted); 61641d05cddcSAtari911 } 61651d05cddcSAtari911 61661d05cddcSAtari911 private function decryptData($encryptedData, $key) { 61671d05cddcSAtari911 // Decode base64 61681d05cddcSAtari911 $data = base64_decode($encryptedData); 61691d05cddcSAtari911 61701d05cddcSAtari911 if ($data === false) { 61711d05cddcSAtari911 return false; 61721d05cddcSAtari911 } 61731d05cddcSAtari911 61741d05cddcSAtari911 // Extract IV and encrypted content 61751d05cddcSAtari911 $ivLength = openssl_cipher_iv_length('aes-256-cbc'); 61761d05cddcSAtari911 $iv = substr($data, 0, $ivLength); 61771d05cddcSAtari911 $encrypted = substr($data, $ivLength); 61781d05cddcSAtari911 61791d05cddcSAtari911 // Decrypt 61801d05cddcSAtari911 $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv); 61811d05cddcSAtari911 61821d05cddcSAtari911 return $decrypted; 61831d05cddcSAtari911 } 61841d05cddcSAtari911 61851d05cddcSAtari911 private function clearLogFile() { 61861d05cddcSAtari911 global $INPUT; 61871d05cddcSAtari911 61881d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 61891d05cddcSAtari911 header('Content-Type: application/json'); 61901d05cddcSAtari911 619196df7d3eSAtari911 $logFile = $this->getSyncLogPath(); 61921d05cddcSAtari911 619396df7d3eSAtari911 // Check if file exists 619496df7d3eSAtari911 if (!file_exists($logFile)) { 619596df7d3eSAtari911 // Try to create empty file 619696df7d3eSAtari911 if (@touch($logFile)) { 6197da206178SAtari911 echo json_encode(['success' => true, 'message' => 'Log file created']); 619896df7d3eSAtari911 } else { 6199da206178SAtari911 echo json_encode(['success' => false, 'message' => 'Log file does not exist and cannot be created: ' . $logFile]); 620096df7d3eSAtari911 } 620196df7d3eSAtari911 exit; 620296df7d3eSAtari911 } 620396df7d3eSAtari911 620496df7d3eSAtari911 // Check if writable 620596df7d3eSAtari911 if (!is_writable($logFile)) { 6206da206178SAtari911 echo json_encode(['success' => false, 'message' => 'Log file not writable. Run: sudo chmod 666 ' . $logFile]); 620796df7d3eSAtari911 exit; 620896df7d3eSAtari911 } 620996df7d3eSAtari911 621096df7d3eSAtari911 // Try to clear it 621196df7d3eSAtari911 $result = file_put_contents($logFile, ''); 621296df7d3eSAtari911 if ($result !== false) { 62131d05cddcSAtari911 echo json_encode(['success' => true]); 62141d05cddcSAtari911 } else { 6215da206178SAtari911 echo json_encode(['success' => false, 'message' => 'file_put_contents failed on: ' . $logFile]); 62161d05cddcSAtari911 } 62171d05cddcSAtari911 exit; 62181d05cddcSAtari911 } 62191d05cddcSAtari911 } 62201d05cddcSAtari911 62211d05cddcSAtari911 private function downloadLog() { 622296df7d3eSAtari911 $logFile = $this->getSyncLogPath(); 62231d05cddcSAtari911 62241d05cddcSAtari911 if (file_exists($logFile)) { 62251d05cddcSAtari911 header('Content-Type: text/plain'); 62261d05cddcSAtari911 header('Content-Disposition: attachment; filename="calendar-sync-' . date('Y-m-d-His') . '.log"'); 62271d05cddcSAtari911 readfile($logFile); 62281d05cddcSAtari911 exit; 62291d05cddcSAtari911 } else { 6230da206178SAtari911 echo 'No log file found'; 62311d05cddcSAtari911 exit; 62321d05cddcSAtari911 } 62331d05cddcSAtari911 } 62341d05cddcSAtari911 62351d05cddcSAtari911 private function getEventStatistics() { 62361d05cddcSAtari911 $stats = [ 62371d05cddcSAtari911 'total_events' => 0, 62381d05cddcSAtari911 'total_namespaces' => 0, 62391d05cddcSAtari911 'total_files' => 0, 62401d05cddcSAtari911 'total_recurring' => 0, 62411d05cddcSAtari911 'by_namespace' => [], 62421d05cddcSAtari911 'last_scan' => '' 62431d05cddcSAtari911 ]; 62441d05cddcSAtari911 6245*2866e827SAtari911 $metaDir = $this->metaDir(); 6246*2866e827SAtari911 $cacheFile = $this->metaDir() . 'calendar/.event_stats_cache'; 62471d05cddcSAtari911 62481d05cddcSAtari911 // Check if we have cached stats (less than 5 minutes old) 62491d05cddcSAtari911 if (file_exists($cacheFile)) { 62501d05cddcSAtari911 $cacheData = json_decode(file_get_contents($cacheFile), true); 62511d05cddcSAtari911 if ($cacheData && (time() - $cacheData['timestamp']) < 300) { 62521d05cddcSAtari911 return $cacheData['stats']; 62531d05cddcSAtari911 } 62541d05cddcSAtari911 } 62551d05cddcSAtari911 62561d05cddcSAtari911 // Scan for events 62571d05cddcSAtari911 $this->scanDirectoryForStats($metaDir, '', $stats); 62581d05cddcSAtari911 62591d05cddcSAtari911 // Count recurring events 62601d05cddcSAtari911 $recurringEvents = $this->findRecurringEvents(); 62611d05cddcSAtari911 $stats['total_recurring'] = count($recurringEvents); 62621d05cddcSAtari911 62631d05cddcSAtari911 $stats['total_namespaces'] = count($stats['by_namespace']); 62641d05cddcSAtari911 $stats['last_scan'] = date('Y-m-d H:i:s'); 62651d05cddcSAtari911 62661d05cddcSAtari911 // Cache the results 62671d05cddcSAtari911 file_put_contents($cacheFile, json_encode([ 62681d05cddcSAtari911 'timestamp' => time(), 62691d05cddcSAtari911 'stats' => $stats 62701d05cddcSAtari911 ])); 62711d05cddcSAtari911 62721d05cddcSAtari911 return $stats; 62731d05cddcSAtari911 } 62741d05cddcSAtari911 62751d05cddcSAtari911 private function scanDirectoryForStats($dir, $namespace, &$stats) { 62761d05cddcSAtari911 if (!is_dir($dir)) return; 62771d05cddcSAtari911 62781d05cddcSAtari911 $items = scandir($dir); 62791d05cddcSAtari911 foreach ($items as $item) { 62801d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 62811d05cddcSAtari911 62821d05cddcSAtari911 $path = $dir . $item; 62831d05cddcSAtari911 62841d05cddcSAtari911 // Check if this is a calendar directory 62851d05cddcSAtari911 if ($item === 'calendar' && is_dir($path)) { 62861d05cddcSAtari911 $jsonFiles = glob($path . '/*.json'); 62871d05cddcSAtari911 $eventCount = 0; 62881d05cddcSAtari911 62891d05cddcSAtari911 foreach ($jsonFiles as $file) { 62901d05cddcSAtari911 $stats['total_files']++; 6291815440faSAtari911 $data = CalendarFileHandler::readJson($file); 62921d05cddcSAtari911 if ($data) { 629396df7d3eSAtari911 foreach ($data as $dateKey => $dateEvents) { 629496df7d3eSAtari911 // Skip non-date keys (like "mapping" or other metadata) 629596df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 629696df7d3eSAtari911 629796df7d3eSAtari911 if (is_array($dateEvents)) { 629896df7d3eSAtari911 // Only count events that have id and title 629996df7d3eSAtari911 foreach ($dateEvents as $event) { 630096df7d3eSAtari911 if (is_array($event) && !empty($event['id']) && !empty($event['title'])) { 630196df7d3eSAtari911 $eventCount++; 630296df7d3eSAtari911 } 630396df7d3eSAtari911 } 630496df7d3eSAtari911 } 63051d05cddcSAtari911 } 63061d05cddcSAtari911 } 63071d05cddcSAtari911 } 63081d05cddcSAtari911 63091d05cddcSAtari911 $stats['total_events'] += $eventCount; 63101d05cddcSAtari911 63111d05cddcSAtari911 if ($eventCount > 0) { 63121d05cddcSAtari911 $stats['by_namespace'][$namespace] = [ 63131d05cddcSAtari911 'events' => $eventCount, 63141d05cddcSAtari911 'files' => count($jsonFiles) 63151d05cddcSAtari911 ]; 63161d05cddcSAtari911 } 63171d05cddcSAtari911 } elseif (is_dir($path)) { 63181d05cddcSAtari911 // Recurse into subdirectories 63191d05cddcSAtari911 $newNamespace = $namespace ? $namespace . ':' . $item : $item; 63201d05cddcSAtari911 $this->scanDirectoryForStats($path . '/', $newNamespace, $stats); 63211d05cddcSAtari911 } 63221d05cddcSAtari911 } 63231d05cddcSAtari911 } 63241d05cddcSAtari911 63251d05cddcSAtari911 private function rescanEvents() { 63261d05cddcSAtari911 // Clear the cache to force a rescan 63279ccd446eSAtari911 $this->clearStatsCache(); 63281d05cddcSAtari911 63291d05cddcSAtari911 // Get fresh statistics 63301d05cddcSAtari911 $stats = $this->getEventStatistics(); 63311d05cddcSAtari911 63321d05cddcSAtari911 // Build absolute redirect URL 63331d05cddcSAtari911 $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'; 63341d05cddcSAtari911 63351d05cddcSAtari911 // Redirect with success message using absolute URL 63361d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 63371d05cddcSAtari911 exit; 63381d05cddcSAtari911 } 63391d05cddcSAtari911 63401d05cddcSAtari911 private function exportAllEvents() { 6341*2866e827SAtari911 $metaDir = $this->metaDir(); 63421d05cddcSAtari911 $allEvents = []; 63431d05cddcSAtari911 63441d05cddcSAtari911 // Collect all events 63451d05cddcSAtari911 $this->collectAllEvents($metaDir, '', $allEvents); 63461d05cddcSAtari911 63471d05cddcSAtari911 // Create export package 63489ccd446eSAtari911 // Get current version 63499ccd446eSAtari911 $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt'; 63509ccd446eSAtari911 $info = file_exists($pluginInfo) ? confToHash($pluginInfo) : []; 63519ccd446eSAtari911 $currentVersion = isset($info['version']) ? trim($info['version']) : 'unknown'; 63529ccd446eSAtari911 63531d05cddcSAtari911 $exportData = [ 63541d05cddcSAtari911 'export_date' => date('Y-m-d H:i:s'), 63559ccd446eSAtari911 'version' => $currentVersion, 63561d05cddcSAtari911 'total_events' => 0, 63571d05cddcSAtari911 'namespaces' => [] 63581d05cddcSAtari911 ]; 63591d05cddcSAtari911 63601d05cddcSAtari911 foreach ($allEvents as $namespace => $files) { 63611d05cddcSAtari911 $exportData['namespaces'][$namespace] = []; 63621d05cddcSAtari911 foreach ($files as $filename => $events) { 63631d05cddcSAtari911 $exportData['namespaces'][$namespace][$filename] = $events; 63641d05cddcSAtari911 foreach ($events as $dateEvents) { 636596df7d3eSAtari911 if (is_array($dateEvents)) { 63661d05cddcSAtari911 $exportData['total_events'] += count($dateEvents); 63671d05cddcSAtari911 } 63681d05cddcSAtari911 } 63691d05cddcSAtari911 } 637096df7d3eSAtari911 } 63711d05cddcSAtari911 63721d05cddcSAtari911 // Send as download 63731d05cddcSAtari911 header('Content-Type: application/json'); 63741d05cddcSAtari911 header('Content-Disposition: attachment; filename="calendar-events-export-' . date('Y-m-d-His') . '.json"'); 63751d05cddcSAtari911 echo json_encode($exportData, JSON_PRETTY_PRINT); 63761d05cddcSAtari911 exit; 63771d05cddcSAtari911 } 63781d05cddcSAtari911 63791d05cddcSAtari911 private function collectAllEvents($dir, $namespace, &$allEvents) { 63801d05cddcSAtari911 if (!is_dir($dir)) return; 63811d05cddcSAtari911 63821d05cddcSAtari911 $items = scandir($dir); 63831d05cddcSAtari911 foreach ($items as $item) { 63841d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 63851d05cddcSAtari911 63861d05cddcSAtari911 $path = $dir . $item; 63871d05cddcSAtari911 63881d05cddcSAtari911 // Check if this is a calendar directory 63891d05cddcSAtari911 if ($item === 'calendar' && is_dir($path)) { 63901d05cddcSAtari911 $jsonFiles = glob($path . '/*.json'); 63911d05cddcSAtari911 63921d05cddcSAtari911 if (!isset($allEvents[$namespace])) { 63931d05cddcSAtari911 $allEvents[$namespace] = []; 63941d05cddcSAtari911 } 63951d05cddcSAtari911 63961d05cddcSAtari911 foreach ($jsonFiles as $file) { 63971d05cddcSAtari911 $filename = basename($file); 6398815440faSAtari911 $data = CalendarFileHandler::readJson($file); 63991d05cddcSAtari911 if ($data) { 64001d05cddcSAtari911 $allEvents[$namespace][$filename] = $data; 64011d05cddcSAtari911 } 64021d05cddcSAtari911 } 64031d05cddcSAtari911 } elseif (is_dir($path)) { 64041d05cddcSAtari911 // Recurse into subdirectories 64051d05cddcSAtari911 $newNamespace = $namespace ? $namespace . ':' . $item : $item; 64061d05cddcSAtari911 $this->collectAllEvents($path . '/', $newNamespace, $allEvents); 64071d05cddcSAtari911 } 64081d05cddcSAtari911 } 64091d05cddcSAtari911 } 64101d05cddcSAtari911 64111d05cddcSAtari911 private function importAllEvents() { 64121d05cddcSAtari911 global $INPUT; 64131d05cddcSAtari911 64141d05cddcSAtari911 if (!isset($_FILES['import_file'])) { 64151d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('No file uploaded') . '&msgtype=error'; 64161d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 64171d05cddcSAtari911 exit; 64181d05cddcSAtari911 } 64191d05cddcSAtari911 64201d05cddcSAtari911 $file = $_FILES['import_file']; 64211d05cddcSAtari911 64221d05cddcSAtari911 if ($file['error'] !== UPLOAD_ERR_OK) { 64231d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Upload error: ' . $file['error']) . '&msgtype=error'; 64241d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 64251d05cddcSAtari911 exit; 64261d05cddcSAtari911 } 64271d05cddcSAtari911 64281d05cddcSAtari911 // Read and decode the import file 64291d05cddcSAtari911 $importData = json_decode(file_get_contents($file['tmp_name']), true); 64301d05cddcSAtari911 64311d05cddcSAtari911 if (!$importData || !isset($importData['namespaces'])) { 64321d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Invalid import file format') . '&msgtype=error'; 64331d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 64341d05cddcSAtari911 exit; 64351d05cddcSAtari911 } 64361d05cddcSAtari911 64371d05cddcSAtari911 $importedCount = 0; 64381d05cddcSAtari911 $mergedCount = 0; 64391d05cddcSAtari911 64401d05cddcSAtari911 // Import events 64411d05cddcSAtari911 foreach ($importData['namespaces'] as $namespace => $files) { 6442*2866e827SAtari911 $metaDir = $this->metaDir(); 64431d05cddcSAtari911 if ($namespace) { 64441d05cddcSAtari911 $metaDir .= str_replace(':', '/', $namespace) . '/'; 64451d05cddcSAtari911 } 64461d05cddcSAtari911 $calendarDir = $metaDir . 'calendar/'; 64471d05cddcSAtari911 64481d05cddcSAtari911 // Create directory if needed 64491d05cddcSAtari911 if (!is_dir($calendarDir)) { 64501d05cddcSAtari911 mkdir($calendarDir, 0755, true); 64511d05cddcSAtari911 } 64521d05cddcSAtari911 64531d05cddcSAtari911 foreach ($files as $filename => $events) { 64541d05cddcSAtari911 $targetFile = $calendarDir . $filename; 64551d05cddcSAtari911 64561d05cddcSAtari911 // If file exists, merge events 64571d05cddcSAtari911 if (file_exists($targetFile)) { 64581d05cddcSAtari911 $existing = json_decode(file_get_contents($targetFile), true); 64591d05cddcSAtari911 if ($existing) { 64601d05cddcSAtari911 foreach ($events as $date => $dateEvents) { 64611d05cddcSAtari911 if (!isset($existing[$date])) { 64621d05cddcSAtari911 $existing[$date] = []; 64631d05cddcSAtari911 } 64641d05cddcSAtari911 foreach ($dateEvents as $event) { 64651d05cddcSAtari911 // Check if event with same ID exists 64661d05cddcSAtari911 $found = false; 64671d05cddcSAtari911 foreach ($existing[$date] as $existingEvent) { 64681d05cddcSAtari911 if ($existingEvent['id'] === $event['id']) { 64691d05cddcSAtari911 $found = true; 64701d05cddcSAtari911 break; 64711d05cddcSAtari911 } 64721d05cddcSAtari911 } 64731d05cddcSAtari911 if (!$found) { 64741d05cddcSAtari911 $existing[$date][] = $event; 64751d05cddcSAtari911 $importedCount++; 64761d05cddcSAtari911 } else { 64771d05cddcSAtari911 $mergedCount++; 64781d05cddcSAtari911 } 64791d05cddcSAtari911 } 64801d05cddcSAtari911 } 6481815440faSAtari911 CalendarFileHandler::writeJson($targetFile, $existing); 64821d05cddcSAtari911 } 64831d05cddcSAtari911 } else { 64841d05cddcSAtari911 // New file 6485815440faSAtari911 CalendarFileHandler::writeJson($targetFile, $events); 64861d05cddcSAtari911 foreach ($events as $dateEvents) { 648796df7d3eSAtari911 if (is_array($dateEvents)) { 64881d05cddcSAtari911 $importedCount += count($dateEvents); 64891d05cddcSAtari911 } 64901d05cddcSAtari911 } 64911d05cddcSAtari911 } 64921d05cddcSAtari911 } 649396df7d3eSAtari911 } 64941d05cddcSAtari911 64951d05cddcSAtari911 // Clear cache 64969ccd446eSAtari911 $this->clearStatsCache(); 64971d05cddcSAtari911 6498da206178SAtari911 $message = "Import complete! Imported $importedCount new events"; 64991d05cddcSAtari911 if ($mergedCount > 0) { 6500da206178SAtari911 $message .= ", skipped $mergedCount duplicates"; 65011d05cddcSAtari911 } 65021d05cddcSAtari911 65031d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success'; 65041d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 65051d05cddcSAtari911 exit; 65061d05cddcSAtari911 } 65071d05cddcSAtari911 65081d05cddcSAtari911 private function previewCleanup() { 65091d05cddcSAtari911 global $INPUT; 65101d05cddcSAtari911 65111d05cddcSAtari911 $cleanupType = $INPUT->str('cleanup_type', 'age'); 65121d05cddcSAtari911 $namespaceFilter = $INPUT->str('namespace_filter', ''); 65131d05cddcSAtari911 65141d05cddcSAtari911 // Debug info 65151d05cddcSAtari911 $debug = []; 65161d05cddcSAtari911 $debug['cleanup_type'] = $cleanupType; 65171d05cddcSAtari911 $debug['namespace_filter'] = $namespaceFilter; 65181d05cddcSAtari911 $debug['age_value'] = $INPUT->int('age_value', 6); 65191d05cddcSAtari911 $debug['age_unit'] = $INPUT->str('age_unit', 'months'); 65201d05cddcSAtari911 $debug['range_start'] = $INPUT->str('range_start', ''); 65211d05cddcSAtari911 $debug['range_end'] = $INPUT->str('range_end', ''); 65221d05cddcSAtari911 $debug['delete_completed'] = $INPUT->bool('delete_completed', false); 65231d05cddcSAtari911 $debug['delete_past'] = $INPUT->bool('delete_past', false); 65241d05cddcSAtari911 6525*2866e827SAtari911 $dataDir = $this->metaDir(); 65261d05cddcSAtari911 $debug['data_dir'] = $dataDir; 65271d05cddcSAtari911 $debug['data_dir_exists'] = is_dir($dataDir); 65281d05cddcSAtari911 65291d05cddcSAtari911 $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); 65301d05cddcSAtari911 65311d05cddcSAtari911 // Merge with scan debug info 65321d05cddcSAtari911 if (isset($this->_cleanupDebug)) { 65331d05cddcSAtari911 $debug = array_merge($debug, $this->_cleanupDebug); 65341d05cddcSAtari911 } 65351d05cddcSAtari911 65361d05cddcSAtari911 // Return JSON for preview with debug info 65371d05cddcSAtari911 header('Content-Type: application/json'); 65381d05cddcSAtari911 echo json_encode([ 65391d05cddcSAtari911 'count' => count($eventsToDelete), 65401d05cddcSAtari911 'events' => array_slice($eventsToDelete, 0, 50), // Limit to 50 for preview 65411d05cddcSAtari911 'debug' => $debug 65421d05cddcSAtari911 ]); 65431d05cddcSAtari911 exit; 65441d05cddcSAtari911 } 65451d05cddcSAtari911 65461d05cddcSAtari911 private function cleanupEvents() { 65471d05cddcSAtari911 global $INPUT; 65481d05cddcSAtari911 65491d05cddcSAtari911 $cleanupType = $INPUT->str('cleanup_type', 'age'); 65501d05cddcSAtari911 $namespaceFilter = $INPUT->str('namespace_filter', ''); 65511d05cddcSAtari911 65521d05cddcSAtari911 // Create backup first 65531d05cddcSAtari911 $backupDir = DOKU_PLUGIN . 'calendar/backups/'; 65541d05cddcSAtari911 if (!is_dir($backupDir)) { 65551d05cddcSAtari911 mkdir($backupDir, 0755, true); 65561d05cddcSAtari911 } 65571d05cddcSAtari911 65581d05cddcSAtari911 $backupFile = $backupDir . 'before-cleanup-' . date('Y-m-d-His') . '.zip'; 65591d05cddcSAtari911 $this->createBackup($backupFile); 65601d05cddcSAtari911 65611d05cddcSAtari911 // Find events to delete 65621d05cddcSAtari911 $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); 65631d05cddcSAtari911 $deletedCount = 0; 65641d05cddcSAtari911 65651d05cddcSAtari911 // Group by file 65661d05cddcSAtari911 $fileGroups = []; 65671d05cddcSAtari911 foreach ($eventsToDelete as $evt) { 65681d05cddcSAtari911 $fileGroups[$evt['file']][] = $evt; 65691d05cddcSAtari911 } 65701d05cddcSAtari911 65711d05cddcSAtari911 // Delete from each file 65721d05cddcSAtari911 foreach ($fileGroups as $file => $events) { 65731d05cddcSAtari911 if (!file_exists($file)) continue; 65741d05cddcSAtari911 65751d05cddcSAtari911 $json = file_get_contents($file); 65761d05cddcSAtari911 $data = json_decode($json, true); 65771d05cddcSAtari911 65781d05cddcSAtari911 if (!$data) continue; 65791d05cddcSAtari911 65801d05cddcSAtari911 // Remove events 65811d05cddcSAtari911 foreach ($events as $evt) { 65821d05cddcSAtari911 if (isset($data[$evt['date']])) { 65831d05cddcSAtari911 $data[$evt['date']] = array_filter($data[$evt['date']], function($e) use ($evt) { 65841d05cddcSAtari911 return $e['id'] !== $evt['id']; 65851d05cddcSAtari911 }); 65861d05cddcSAtari911 65871d05cddcSAtari911 // Remove date key if empty 65881d05cddcSAtari911 if (empty($data[$evt['date']])) { 65891d05cddcSAtari911 unset($data[$evt['date']]); 65901d05cddcSAtari911 } 65911d05cddcSAtari911 65921d05cddcSAtari911 $deletedCount++; 65931d05cddcSAtari911 } 65941d05cddcSAtari911 } 65951d05cddcSAtari911 65961d05cddcSAtari911 // Save file or delete if empty 65971d05cddcSAtari911 if (empty($data)) { 65981d05cddcSAtari911 unlink($file); 65991d05cddcSAtari911 } else { 6600815440faSAtari911 CalendarFileHandler::writeJson($file, $data); 66011d05cddcSAtari911 } 66021d05cddcSAtari911 } 66031d05cddcSAtari911 66041d05cddcSAtari911 // Clear cache 66059ccd446eSAtari911 $this->clearStatsCache(); 66061d05cddcSAtari911 6607da206178SAtari911 $message = "Cleanup complete! Deleted $deletedCount event(s). Backup created: " . basename($backupFile); 66081d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success'; 66091d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 66101d05cddcSAtari911 exit; 66111d05cddcSAtari911 } 66121d05cddcSAtari911 66131d05cddcSAtari911 private function findEventsToCleanup($cleanupType, $namespaceFilter) { 66141d05cddcSAtari911 global $INPUT; 66151d05cddcSAtari911 66161d05cddcSAtari911 $eventsToDelete = []; 6617*2866e827SAtari911 $dataDir = $this->metaDir(); 66181d05cddcSAtari911 66191d05cddcSAtari911 $debug = []; 66201d05cddcSAtari911 $debug['scanned_dirs'] = []; 66211d05cddcSAtari911 $debug['found_files'] = []; 66221d05cddcSAtari911 66231d05cddcSAtari911 // Calculate cutoff date for age-based cleanup 66241d05cddcSAtari911 $cutoffDate = null; 66251d05cddcSAtari911 if ($cleanupType === 'age') { 66261d05cddcSAtari911 $ageValue = $INPUT->int('age_value', 6); 66271d05cddcSAtari911 $ageUnit = $INPUT->str('age_unit', 'months'); 66281d05cddcSAtari911 66291d05cddcSAtari911 if ($ageUnit === 'years') { 66301d05cddcSAtari911 $ageValue *= 12; // Convert to months 66311d05cddcSAtari911 } 66321d05cddcSAtari911 66331d05cddcSAtari911 $cutoffDate = date('Y-m-d', strtotime("-$ageValue months")); 66341d05cddcSAtari911 $debug['cutoff_date'] = $cutoffDate; 66351d05cddcSAtari911 } 66361d05cddcSAtari911 66371d05cddcSAtari911 // Get date range for range-based cleanup 66381d05cddcSAtari911 $rangeStart = $cleanupType === 'range' ? $INPUT->str('range_start', '') : null; 66391d05cddcSAtari911 $rangeEnd = $cleanupType === 'range' ? $INPUT->str('range_end', '') : null; 66401d05cddcSAtari911 66411d05cddcSAtari911 // Get status filters 66421d05cddcSAtari911 $deleteCompleted = $cleanupType === 'status' && $INPUT->bool('delete_completed', false); 66431d05cddcSAtari911 $deletePast = $cleanupType === 'status' && $INPUT->bool('delete_past', false); 66441d05cddcSAtari911 66451d05cddcSAtari911 // Check root calendar directory first (blank/default namespace) 66461d05cddcSAtari911 $rootCalendarDir = $dataDir . 'calendar'; 66471d05cddcSAtari911 $debug['root_calendar_dir'] = $rootCalendarDir; 66481d05cddcSAtari911 $debug['root_exists'] = is_dir($rootCalendarDir); 66491d05cddcSAtari911 66501d05cddcSAtari911 if (is_dir($rootCalendarDir)) { 66511d05cddcSAtari911 if (!$namespaceFilter || $namespaceFilter === '' || $namespaceFilter === 'default') { 66521d05cddcSAtari911 $debug['scanned_dirs'][] = $rootCalendarDir; 66531d05cddcSAtari911 $files = glob($rootCalendarDir . '/*.json'); 66541d05cddcSAtari911 $debug['found_files'] = array_merge($debug['found_files'], $files); 66551d05cddcSAtari911 $this->processCalendarFiles($rootCalendarDir, '', $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast); 66561d05cddcSAtari911 } 66571d05cddcSAtari911 } 66581d05cddcSAtari911 66591d05cddcSAtari911 // Scan all namespace directories 66601d05cddcSAtari911 $namespaceDirs = glob($dataDir . '*', GLOB_ONLYDIR); 66611d05cddcSAtari911 $debug['namespace_dirs_found'] = $namespaceDirs; 66621d05cddcSAtari911 66631d05cddcSAtari911 foreach ($namespaceDirs as $nsDir) { 66641d05cddcSAtari911 $namespace = basename($nsDir); 66651d05cddcSAtari911 66661d05cddcSAtari911 // Skip the root 'calendar' dir (already processed above) 66671d05cddcSAtari911 if ($namespace === 'calendar') continue; 66681d05cddcSAtari911 66691d05cddcSAtari911 // Check namespace filter 66701d05cddcSAtari911 if ($namespaceFilter && strpos($namespace, $namespaceFilter) === false) { 66711d05cddcSAtari911 continue; 66721d05cddcSAtari911 } 66731d05cddcSAtari911 66741d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 66751d05cddcSAtari911 $debug['checked_calendar_dirs'][] = $calendarDir; 66761d05cddcSAtari911 66771d05cddcSAtari911 if (!is_dir($calendarDir)) { 66781d05cddcSAtari911 $debug['missing_calendar_dirs'][] = $calendarDir; 66791d05cddcSAtari911 continue; 66801d05cddcSAtari911 } 66811d05cddcSAtari911 66821d05cddcSAtari911 $debug['scanned_dirs'][] = $calendarDir; 66831d05cddcSAtari911 $files = glob($calendarDir . '/*.json'); 66841d05cddcSAtari911 $debug['found_files'] = array_merge($debug['found_files'], $files); 66851d05cddcSAtari911 $this->processCalendarFiles($calendarDir, $namespace, $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast); 66861d05cddcSAtari911 } 66871d05cddcSAtari911 66881d05cddcSAtari911 // Store debug info globally for preview 66891d05cddcSAtari911 $this->_cleanupDebug = $debug; 66901d05cddcSAtari911 66911d05cddcSAtari911 return $eventsToDelete; 66921d05cddcSAtari911 } 66931d05cddcSAtari911 66941d05cddcSAtari911 private function processCalendarFiles($calendarDir, $namespace, &$eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast) { 66951d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 66961d05cddcSAtari911 $json = file_get_contents($file); 66971d05cddcSAtari911 $data = json_decode($json, true); 66981d05cddcSAtari911 66991d05cddcSAtari911 if (!$data) continue; 67001d05cddcSAtari911 67011d05cddcSAtari911 foreach ($data as $date => $dateEvents) { 67021d05cddcSAtari911 foreach ($dateEvents as $event) { 67031d05cddcSAtari911 $shouldDelete = false; 67041d05cddcSAtari911 67051d05cddcSAtari911 // Age-based 67061d05cddcSAtari911 if ($cleanupType === 'age' && $cutoffDate && $date < $cutoffDate) { 67071d05cddcSAtari911 $shouldDelete = true; 67081d05cddcSAtari911 } 67091d05cddcSAtari911 67101d05cddcSAtari911 // Range-based 67111d05cddcSAtari911 if ($cleanupType === 'range' && $rangeStart && $rangeEnd) { 67121d05cddcSAtari911 if ($date >= $rangeStart && $date <= $rangeEnd) { 67131d05cddcSAtari911 $shouldDelete = true; 67141d05cddcSAtari911 } 67151d05cddcSAtari911 } 67161d05cddcSAtari911 67171d05cddcSAtari911 // Status-based 67181d05cddcSAtari911 if ($cleanupType === 'status') { 67191d05cddcSAtari911 $isTask = isset($event['isTask']) && $event['isTask']; 67201d05cddcSAtari911 $isCompleted = isset($event['completed']) && $event['completed']; 67211d05cddcSAtari911 $isPast = $date < date('Y-m-d'); 67221d05cddcSAtari911 67231d05cddcSAtari911 if ($deleteCompleted && $isTask && $isCompleted) { 67241d05cddcSAtari911 $shouldDelete = true; 67251d05cddcSAtari911 } 67261d05cddcSAtari911 if ($deletePast && !$isTask && $isPast) { 67271d05cddcSAtari911 $shouldDelete = true; 67281d05cddcSAtari911 } 67291d05cddcSAtari911 } 67301d05cddcSAtari911 67311d05cddcSAtari911 if ($shouldDelete) { 67321d05cddcSAtari911 $eventsToDelete[] = [ 67331d05cddcSAtari911 'id' => $event['id'], 67341d05cddcSAtari911 'title' => $event['title'], 67351d05cddcSAtari911 'date' => $date, 67361d05cddcSAtari911 'namespace' => $namespace ?: 'default', 67371d05cddcSAtari911 'file' => $file 67381d05cddcSAtari911 ]; 67391d05cddcSAtari911 } 67401d05cddcSAtari911 } 67411d05cddcSAtari911 } 67421d05cddcSAtari911 } 67431d05cddcSAtari911 } 67449ccd446eSAtari911 67459ccd446eSAtari911 /** 6746815440faSAtari911 * Render Google Calendar Sync tab 6747815440faSAtari911 */ 6748815440faSAtari911 private function renderGoogleSyncTab($colors = null) { 6749815440faSAtari911 global $INPUT; 6750815440faSAtari911 6751815440faSAtari911 if ($colors === null) { 6752815440faSAtari911 $colors = $this->getTemplateColors(); 6753815440faSAtari911 } 6754815440faSAtari911 6755815440faSAtari911 // Load Google sync class 6756815440faSAtari911 require_once __DIR__ . '/classes/GoogleCalendarSync.php'; 6757815440faSAtari911 $googleSync = new GoogleCalendarSync(); 6758815440faSAtari911 $status = $googleSync->getStatus(); 6759815440faSAtari911 6760815440faSAtari911 // Handle config save 6761815440faSAtari911 if ($INPUT->str('action') === 'save_google_config') { 6762815440faSAtari911 $clientId = $INPUT->str('google_client_id'); 6763815440faSAtari911 $clientSecret = $INPUT->str('google_client_secret'); 6764815440faSAtari911 $calendarId = $INPUT->str('google_calendar_id', 'primary'); 6765815440faSAtari911 6766815440faSAtari911 if ($clientId && $clientSecret) { 6767815440faSAtari911 $googleSync->saveConfig($clientId, $clientSecret, $calendarId); 6768815440faSAtari911 echo '<div style="background:#d4edda;border:1px solid #c3e6cb;color:#155724;padding:12px;border-radius:6px;margin-bottom:20px;">✓ Google API credentials saved successfully!</div>'; 6769815440faSAtari911 $status = $googleSync->getStatus(); // Refresh status 6770815440faSAtari911 } 6771815440faSAtari911 } 6772815440faSAtari911 6773815440faSAtari911 // Handle calendar selection 6774815440faSAtari911 if ($INPUT->str('action') === 'select_google_calendar') { 6775815440faSAtari911 $calendarId = $INPUT->str('selected_calendar'); 6776815440faSAtari911 if ($calendarId) { 6777815440faSAtari911 $googleSync->setCalendarId($calendarId); 6778815440faSAtari911 echo '<div style="background:#d4edda;border:1px solid #c3e6cb;color:#155724;padding:12px;border-radius:6px;margin-bottom:20px;">✓ Calendar selected!</div>'; 6779815440faSAtari911 } 6780815440faSAtari911 } 6781815440faSAtari911 6782815440faSAtari911 $accentColor = '#00cc07'; 6783815440faSAtari911 6784815440faSAtari911 echo '<div style="max-width:800px;">'; 6785815440faSAtari911 echo '<h2 style="color:' . $colors['text'] . ';margin-bottom:20px;"> Google Calendar Sync</h2>'; 6786815440faSAtari911 6787815440faSAtari911 // Status indicator 6788815440faSAtari911 $statusColor = $status['authenticated'] ? '#28a745' : ($status['configured'] ? '#ffc107' : '#dc3545'); 6789815440faSAtari911 $statusText = $status['authenticated'] ? 'Connected' : ($status['configured'] ? 'Not Authenticated' : 'Not Configured'); 6790815440faSAtari911 $statusIcon = $status['authenticated'] ? '✓' : ($status['configured'] ? '⚠' : '✕'); 6791815440faSAtari911 6792815440faSAtari911 echo '<div style="background:' . $colors['bg'] . ';border:1px solid ' . $colors['border'] . ';border-radius:8px;padding:20px;margin-bottom:24px;">'; 6793815440faSAtari911 echo '<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">'; 6794815440faSAtari911 echo '<span style="display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;background:' . $statusColor . ';color:white;border-radius:50%;font-weight:bold;">' . $statusIcon . '</span>'; 6795815440faSAtari911 echo '<span style="font-size:18px;font-weight:600;color:' . $colors['text'] . ';">Status: ' . $statusText . '</span>'; 6796815440faSAtari911 echo '</div>'; 6797815440faSAtari911 6798815440faSAtari911 if ($status['authenticated']) { 6799815440faSAtari911 echo '<p style="color:' . $colors['textDim'] . ';margin:0;">Calendar: <strong>' . htmlspecialchars($status['calendar_id']) . '</strong></p>'; 6800815440faSAtari911 } 6801815440faSAtari911 echo '</div>'; 6802815440faSAtari911 6803815440faSAtari911 // Setup Instructions 6804815440faSAtari911 echo '<div style="background:' . $colors['bg'] . ';border:1px solid ' . $colors['border'] . ';border-radius:8px;padding:20px;margin-bottom:24px;">'; 6805815440faSAtari911 echo '<h3 style="color:' . $colors['text'] . ';margin:0 0 16px 0;">Setup Instructions</h3>'; 6806815440faSAtari911 echo '<ol style="color:' . $colors['textDim'] . ';margin:0;padding-left:20px;line-height:1.8;">'; 6807815440faSAtari911 echo '<li>Go to <a href="https://console.cloud.google.com/" target="_blank" style="color:' . $accentColor . ';">Google Cloud Console</a></li>'; 6808815440faSAtari911 echo '<li>Create a new project (or select existing)</li>'; 6809815440faSAtari911 echo '<li>Enable the <strong>Google Calendar API</strong></li>'; 6810815440faSAtari911 echo '<li>Go to Credentials → Create Credentials → OAuth 2.0 Client ID</li>'; 6811815440faSAtari911 echo '<li>Application type: <strong>Web application</strong></li>'; 6812815440faSAtari911 echo '<li>Add Authorized redirect URI: <code style="background:#f5f5f5;padding:2px 6px;border-radius:3px;">' . DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback</code></li>'; 6813815440faSAtari911 echo '<li>Copy Client ID and Client Secret below</li>'; 6814815440faSAtari911 echo '</ol>'; 6815815440faSAtari911 echo '</div>'; 6816815440faSAtari911 6817815440faSAtari911 // Configuration Form 6818815440faSAtari911 echo '<div style="background:' . $colors['bg'] . ';border:1px solid ' . $colors['border'] . ';border-radius:8px;padding:20px;margin-bottom:24px;">'; 6819815440faSAtari911 echo '<h3 style="color:' . $colors['text'] . ';margin:0 0 16px 0;">API Credentials</h3>'; 6820815440faSAtari911 6821815440faSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=google">'; 6822*2866e827SAtari911 echo formSecurityToken(false); 6823815440faSAtari911 echo '<input type="hidden" name="action" value="save_google_config">'; 6824815440faSAtari911 6825815440faSAtari911 echo '<div style="margin-bottom:16px;">'; 6826815440faSAtari911 echo '<label style="display:block;font-weight:600;color:' . $colors['text'] . ';margin-bottom:6px;">Client ID</label>'; 6827815440faSAtari911 echo '<input type="text" name="google_client_id" value="" placeholder="xxxx.apps.googleusercontent.com" style="width:100%;padding:10px 12px;border:1px solid ' . $colors['border'] . ';border-radius:6px;font-size:14px;background:' . $colors['inputBg'] . ';color:' . $colors['text'] . ';">'; 6828815440faSAtari911 echo '<small style="color:' . $colors['textDim'] . ';">Leave blank to keep existing value</small>'; 6829815440faSAtari911 echo '</div>'; 6830815440faSAtari911 6831815440faSAtari911 echo '<div style="margin-bottom:16px;">'; 6832815440faSAtari911 echo '<label style="display:block;font-weight:600;color:' . $colors['text'] . ';margin-bottom:6px;">Client Secret</label>'; 6833815440faSAtari911 echo '<input type="password" name="google_client_secret" value="" placeholder="Enter client secret" style="width:100%;padding:10px 12px;border:1px solid ' . $colors['border'] . ';border-radius:6px;font-size:14px;background:' . $colors['inputBg'] . ';color:' . $colors['text'] . ';">'; 6834815440faSAtari911 echo '<small style="color:' . $colors['textDim'] . ';">Leave blank to keep existing value</small>'; 6835815440faSAtari911 echo '</div>'; 6836815440faSAtari911 6837815440faSAtari911 echo '<button type="submit" style="background:' . $accentColor . ';color:white;border:none;padding:12px 24px;border-radius:6px;cursor:pointer;font-size:14px;font-weight:600;">Save Credentials</button>'; 6838815440faSAtari911 echo '</form>'; 6839815440faSAtari911 echo '</div>'; 6840815440faSAtari911 6841815440faSAtari911 // Authentication Section 6842815440faSAtari911 if ($status['configured']) { 6843815440faSAtari911 echo '<div style="background:' . $colors['bg'] . ';border:1px solid ' . $colors['border'] . ';border-radius:8px;padding:20px;margin-bottom:24px;">'; 6844815440faSAtari911 echo '<h3 style="color:' . $colors['text'] . ';margin:0 0 16px 0;">Authentication</h3>'; 6845815440faSAtari911 6846815440faSAtari911 if ($status['authenticated']) { 6847815440faSAtari911 echo '<p style="color:#28a745;margin:0 0 16px 0;">✓ Connected to Google Calendar</p>'; 6848815440faSAtari911 echo '<button onclick="googleDisconnect()" style="background:#dc3545;color:white;border:none;padding:10px 20px;border-radius:6px;cursor:pointer;font-size:14px;">Disconnect</button>'; 6849815440faSAtari911 } else { 6850815440faSAtari911 echo '<p style="color:' . $colors['textDim'] . ';margin:0 0 16px 0;">Click below to authorize access to your Google Calendar.</p>'; 6851815440faSAtari911 echo '<button onclick="googleConnect()" style="background:#4285f4;color:white;border:none;padding:12px 24px;border-radius:6px;cursor:pointer;font-size:14px;font-weight:600;">'; 6852815440faSAtari911 echo '<span style="margin-right:8px;"></span> Connect Google Calendar</button>'; 6853815440faSAtari911 } 6854815440faSAtari911 echo '</div>'; 6855815440faSAtari911 } 6856815440faSAtari911 6857815440faSAtari911 // Calendar Selection (if authenticated) 6858815440faSAtari911 if ($status['authenticated']) { 6859815440faSAtari911 echo '<div style="background:' . $colors['bg'] . ';border:1px solid ' . $colors['border'] . ';border-radius:8px;padding:20px;margin-bottom:24px;">'; 6860815440faSAtari911 echo '<h3 style="color:' . $colors['text'] . ';margin:0 0 16px 0;">Select Calendar</h3>'; 6861815440faSAtari911 echo '<div id="google-calendars-list">Loading calendars...</div>'; 6862815440faSAtari911 echo '</div>'; 6863815440faSAtari911 6864815440faSAtari911 // Import/Export Section 6865815440faSAtari911 echo '<div style="background:' . $colors['bg'] . ';border:1px solid ' . $colors['border'] . ';border-radius:8px;padding:20px;margin-bottom:24px;">'; 6866815440faSAtari911 echo '<h3 style="color:' . $colors['text'] . ';margin:0 0 16px 0;">Sync Events</h3>'; 6867815440faSAtari911 6868815440faSAtari911 echo '<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">'; 6869815440faSAtari911 6870815440faSAtari911 // Import section 6871815440faSAtari911 echo '<div style="padding:16px;border:1px solid ' . $colors['border'] . ';border-radius:8px;">'; 6872815440faSAtari911 echo '<h4 style="color:' . $colors['text'] . ';margin:0 0 12px 0;">⬇️ Import from Google</h4>'; 6873815440faSAtari911 echo '<p style="color:' . $colors['textDim'] . ';font-size:13px;margin:0 0 12px 0;">Import events from Google Calendar to DokuWiki.</p>'; 6874815440faSAtari911 echo '<div style="margin-bottom:12px;">'; 6875815440faSAtari911 echo '<label style="display:block;font-size:12px;color:' . $colors['textDim'] . ';margin-bottom:4px;">Namespace (optional)</label>'; 6876815440faSAtari911 echo '<input type="text" id="import-namespace" placeholder="e.g. team:meetings" style="width:100%;padding:8px;border:1px solid ' . $colors['border'] . ';border-radius:4px;font-size:13px;">'; 6877815440faSAtari911 echo '</div>'; 6878815440faSAtari911 echo '<button onclick="googleImport()" style="background:' . $accentColor . ';color:white;border:none;padding:10px 16px;border-radius:6px;cursor:pointer;font-size:13px;width:100%;">Import Events</button>'; 6879815440faSAtari911 echo '</div>'; 6880815440faSAtari911 6881815440faSAtari911 // Export section 6882815440faSAtari911 echo '<div style="padding:16px;border:1px solid ' . $colors['border'] . ';border-radius:8px;">'; 6883815440faSAtari911 echo '<h4 style="color:' . $colors['text'] . ';margin:0 0 12px 0;">⬆️ Export to Google</h4>'; 6884815440faSAtari911 echo '<p style="color:' . $colors['textDim'] . ';font-size:13px;margin:0 0 12px 0;">Export events from DokuWiki to Google Calendar.</p>'; 6885815440faSAtari911 echo '<div style="margin-bottom:12px;">'; 6886815440faSAtari911 echo '<label style="display:block;font-size:12px;color:' . $colors['textDim'] . ';margin-bottom:4px;">Namespace (optional)</label>'; 6887815440faSAtari911 echo '<input type="text" id="export-namespace" placeholder="e.g. team:meetings" style="width:100%;padding:8px;border:1px solid ' . $colors['border'] . ';border-radius:4px;font-size:13px;">'; 6888815440faSAtari911 echo '</div>'; 6889815440faSAtari911 echo '<button onclick="googleExport()" style="background:#17a2b8;color:white;border:none;padding:10px 16px;border-radius:6px;cursor:pointer;font-size:13px;width:100%;">Export Events</button>'; 6890815440faSAtari911 echo '</div>'; 6891815440faSAtari911 6892815440faSAtari911 echo '</div>'; 6893815440faSAtari911 6894815440faSAtari911 echo '<div id="sync-result" style="margin-top:16px;"></div>'; 6895815440faSAtari911 echo '</div>'; 6896815440faSAtari911 } 6897815440faSAtari911 6898815440faSAtari911 echo '</div>'; // End max-width container 6899815440faSAtari911 6900815440faSAtari911 // JavaScript for Google sync 6901815440faSAtari911 echo '<script> 6902815440faSAtari911 var DOKU_BASE = "' . DOKU_BASE . '"; 6903815440faSAtari911 6904815440faSAtari911 // Listen for OAuth callback 6905815440faSAtari911 window.addEventListener("message", function(e) { 6906815440faSAtari911 if (e.data && e.data.type === "google_auth_complete") { 6907815440faSAtari911 if (e.data.success) { 6908815440faSAtari911 location.reload(); 6909815440faSAtari911 } 6910815440faSAtari911 } 6911815440faSAtari911 }); 6912815440faSAtari911 6913815440faSAtari911 function googleConnect() { 6914815440faSAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php?call=plugin_calendar&action=google_auth_url") 6915815440faSAtari911 .then(r => r.json()) 6916815440faSAtari911 .then(data => { 6917815440faSAtari911 if (data.success && data.url) { 6918815440faSAtari911 // Open auth window 6919815440faSAtari911 var w = 600, h = 700; 6920815440faSAtari911 var left = (screen.width - w) / 2; 6921815440faSAtari911 var top = (screen.height - h) / 2; 6922815440faSAtari911 window.open(data.url, "google_auth", "width=" + w + ",height=" + h + ",left=" + left + ",top=" + top); 6923815440faSAtari911 } else { 6924815440faSAtari911 alert("Error: " + (data.error || "Could not get auth URL")); 6925815440faSAtari911 } 6926815440faSAtari911 }) 6927815440faSAtari911 .catch(err => alert("Error: " + err.message)); 6928815440faSAtari911 } 6929815440faSAtari911 6930815440faSAtari911 function googleDisconnect() { 6931815440faSAtari911 if (!confirm("Disconnect from Google Calendar?")) return; 6932815440faSAtari911 6933815440faSAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php?call=plugin_calendar&action=google_disconnect", { 6934815440faSAtari911 method: "POST", 6935815440faSAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"} 6936815440faSAtari911 }) 6937815440faSAtari911 .then(r => r.json()) 6938815440faSAtari911 .then(data => { 6939815440faSAtari911 if (data.success) { 6940815440faSAtari911 location.reload(); 6941815440faSAtari911 } 6942815440faSAtari911 }); 6943815440faSAtari911 } 6944815440faSAtari911 6945815440faSAtari911 function loadGoogleCalendars() { 6946815440faSAtari911 var container = document.getElementById("google-calendars-list"); 6947815440faSAtari911 if (!container) return; 6948815440faSAtari911 6949815440faSAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php?call=plugin_calendar&action=google_calendars") 6950815440faSAtari911 .then(r => r.json()) 6951815440faSAtari911 .then(data => { 6952815440faSAtari911 if (data.success && data.calendars) { 6953815440faSAtari911 var html = "<form method=\"post\" action=\"?do=admin&page=calendar&tab=google\">"; 6954*2866e827SAtari911 html += "<input type=\"hidden\" name=\"sectok\" value=\"" + JSINFO.sectok + "\">"; 6955815440faSAtari911 html += "<input type=\"hidden\" name=\"action\" value=\"select_google_calendar\">"; 6956815440faSAtari911 html += "<select name=\"selected_calendar\" style=\"width:100%;padding:10px;border:1px solid #ddd;border-radius:6px;margin-bottom:12px;\">"; 6957815440faSAtari911 6958815440faSAtari911 data.calendars.forEach(function(cal) { 6959815440faSAtari911 var selected = cal.primary ? " selected" : ""; 6960815440faSAtari911 html += "<option value=\"" + cal.id + "\"" + selected + ">" + cal.summary; 6961815440faSAtari911 if (cal.primary) html += " (Primary)"; 6962815440faSAtari911 html += "</option>"; 6963815440faSAtari911 }); 6964815440faSAtari911 6965815440faSAtari911 html += "</select>"; 6966815440faSAtari911 html += "<button type=\"submit\" style=\"background:#6c757d;color:white;border:none;padding:10px 20px;border-radius:6px;cursor:pointer;\">Select Calendar</button>"; 6967815440faSAtari911 html += "</form>"; 6968815440faSAtari911 6969815440faSAtari911 container.innerHTML = html; 6970815440faSAtari911 } else { 6971815440faSAtari911 container.innerHTML = "<p style=\"color:#dc3545;\">Error loading calendars: " + (data.error || "Unknown error") + "</p>"; 6972815440faSAtari911 } 6973815440faSAtari911 }) 6974815440faSAtari911 .catch(err => { 6975815440faSAtari911 container.innerHTML = "<p style=\"color:#dc3545;\">Error: " + err.message + "</p>"; 6976815440faSAtari911 }); 6977815440faSAtari911 } 6978815440faSAtari911 6979815440faSAtari911 function googleImport() { 6980815440faSAtari911 var namespace = document.getElementById("import-namespace").value; 6981815440faSAtari911 var resultDiv = document.getElementById("sync-result"); 6982815440faSAtari911 6983815440faSAtari911 resultDiv.innerHTML = "<p style=\"color:#666;\">⏳ Importing events...</p>"; 6984815440faSAtari911 6985815440faSAtari911 var params = new URLSearchParams({ 6986815440faSAtari911 call: "plugin_calendar", 6987815440faSAtari911 action: "google_import", 6988815440faSAtari911 namespace: namespace 6989815440faSAtari911 }); 6990815440faSAtari911 6991815440faSAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 6992815440faSAtari911 method: "POST", 6993815440faSAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 6994815440faSAtari911 body: params.toString() 6995815440faSAtari911 }) 6996815440faSAtari911 .then(r => r.json()) 6997815440faSAtari911 .then(data => { 6998815440faSAtari911 if (data.success) { 6999815440faSAtari911 resultDiv.innerHTML = "<div style=\"background:#d4edda;border:1px solid #c3e6cb;color:#155724;padding:12px;border-radius:6px;\">✓ Imported " + data.imported + " events, " + data.skipped + " skipped (duplicates)</div>"; 7000815440faSAtari911 } else { 7001815440faSAtari911 resultDiv.innerHTML = "<div style=\"background:#f8d7da;border:1px solid #f5c6cb;color:#721c24;padding:12px;border-radius:6px;\">Error: " + data.error + "</div>"; 7002815440faSAtari911 } 7003815440faSAtari911 }) 7004815440faSAtari911 .catch(err => { 7005815440faSAtari911 resultDiv.innerHTML = "<div style=\"background:#f8d7da;border:1px solid #f5c6cb;color:#721c24;padding:12px;border-radius:6px;\">Error: " + err.message + "</div>"; 7006815440faSAtari911 }); 7007815440faSAtari911 } 7008815440faSAtari911 7009815440faSAtari911 function googleExport() { 7010815440faSAtari911 var namespace = document.getElementById("export-namespace").value; 7011815440faSAtari911 var resultDiv = document.getElementById("sync-result"); 7012815440faSAtari911 7013815440faSAtari911 resultDiv.innerHTML = "<p style=\"color:#666;\">⏳ Exporting events...</p>"; 7014815440faSAtari911 7015815440faSAtari911 var params = new URLSearchParams({ 7016815440faSAtari911 call: "plugin_calendar", 7017815440faSAtari911 action: "google_export", 7018815440faSAtari911 namespace: namespace 7019815440faSAtari911 }); 7020815440faSAtari911 7021815440faSAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 7022815440faSAtari911 method: "POST", 7023815440faSAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 7024815440faSAtari911 body: params.toString() 7025815440faSAtari911 }) 7026815440faSAtari911 .then(r => r.json()) 7027815440faSAtari911 .then(data => { 7028815440faSAtari911 if (data.success) { 7029815440faSAtari911 resultDiv.innerHTML = "<div style=\"background:#d4edda;border:1px solid #c3e6cb;color:#155724;padding:12px;border-radius:6px;\">✓ Exported " + data.exported + " events, " + data.skipped + " skipped</div>"; 7030815440faSAtari911 } else { 7031815440faSAtari911 resultDiv.innerHTML = "<div style=\"background:#f8d7da;border:1px solid #f5c6cb;color:#721c24;padding:12px;border-radius:6px;\">Error: " + data.error + "</div>"; 7032815440faSAtari911 } 7033815440faSAtari911 }) 7034815440faSAtari911 .catch(err => { 7035815440faSAtari911 resultDiv.innerHTML = "<div style=\"background:#f8d7da;border:1px solid #f5c6cb;color:#721c24;padding:12px;border-radius:6px;\">Error: " + err.message + "</div>"; 7036815440faSAtari911 }); 7037815440faSAtari911 } 7038815440faSAtari911 7039815440faSAtari911 // Load calendars on page load if authenticated 7040815440faSAtari911 ' . ($status['authenticated'] ? 'loadGoogleCalendars();' : '') . ' 7041815440faSAtari911 </script>'; 7042815440faSAtari911 } 7043815440faSAtari911 7044815440faSAtari911 /** 70459ccd446eSAtari911 * Render Themes tab for sidebar widget theme selection 70469ccd446eSAtari911 */ 70479ccd446eSAtari911 private function renderThemesTab($colors = null) { 70489ccd446eSAtari911 global $INPUT; 70499ccd446eSAtari911 70509ccd446eSAtari911 // Use defaults if not provided 70519ccd446eSAtari911 if ($colors === null) { 70529ccd446eSAtari911 $colors = $this->getTemplateColors(); 70539ccd446eSAtari911 } 70549ccd446eSAtari911 70559ccd446eSAtari911 // Handle theme save 70569ccd446eSAtari911 if ($INPUT->str('action') === 'save_theme') { 70579ccd446eSAtari911 $theme = $INPUT->str('theme', 'matrix'); 70589ccd446eSAtari911 $weekStart = $INPUT->str('week_start', 'monday'); 705996df7d3eSAtari911 $itineraryCollapsed = $INPUT->str('itinerary_collapsed', 'no'); 70609ccd446eSAtari911 $this->saveSidebarTheme($theme); 70619ccd446eSAtari911 $this->saveWeekStartDay($weekStart); 706296df7d3eSAtari911 $this->saveItineraryCollapsed($itineraryCollapsed === 'yes'); 706364a96c92SAtari911 $searchDefault = $INPUT->str('search_default', 'month'); 706464a96c92SAtari911 $this->saveSearchDefault($searchDefault); 70659ccd446eSAtari911 echo '<div style="background:#d4edda; border:1px solid #c3e6cb; color:#155724; padding:12px; border-radius:4px; margin-bottom:20px;">'; 7066da206178SAtari911 echo '✓ Theme and settings saved successfully! Refresh any page with the sidebar to see changes.'; 70679ccd446eSAtari911 echo '</div>'; 70689ccd446eSAtari911 } 70699ccd446eSAtari911 70709ccd446eSAtari911 $currentTheme = $this->getSidebarTheme(); 70719ccd446eSAtari911 $currentWeekStart = $this->getWeekStartDay(); 707296df7d3eSAtari911 $currentItineraryCollapsed = $this->getItineraryCollapsed(); 707364a96c92SAtari911 $currentSearchDefault = $this->getSearchDefault(); 70749ccd446eSAtari911 7075da206178SAtari911 echo '<h2 style="margin:0 0 20px 0; color:' . $colors['text'] . ';"> Sidebar Widget Settings</h2>'; 7076da206178SAtari911 echo '<p style="color:' . $colors['text'] . '; margin-bottom:20px;">Customize the appearance and behavior of the sidebar calendar widget.</p>'; 70779ccd446eSAtari911 70789ccd446eSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=themes">'; 7079*2866e827SAtari911 echo formSecurityToken(false); 70809ccd446eSAtari911 echo '<input type="hidden" name="action" value="save_theme">'; 70819ccd446eSAtari911 70829ccd446eSAtari911 // Week Start Day Section 70839ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">'; 7084da206178SAtari911 echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;"> Week Start Day</h3>'; 7085da206178SAtari911 echo '<p style="color:' . $colors['text'] . '; margin-bottom:15px; font-size:13px;">Choose which day the week calendar grid starts with:</p>'; 70869ccd446eSAtari911 70879ccd446eSAtari911 echo '<div style="display:flex; gap:15px;">'; 70889ccd446eSAtari911 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;">'; 70899ccd446eSAtari911 echo '<input type="radio" name="week_start" value="monday" ' . ($currentWeekStart === 'monday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 70909ccd446eSAtari911 echo '<div>'; 7091da206178SAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Monday</div>'; 7092da206178SAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Monday (ISO standard)</div>'; 70939ccd446eSAtari911 echo '</div>'; 70949ccd446eSAtari911 echo '</label>'; 70959ccd446eSAtari911 70969ccd446eSAtari911 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;">'; 70979ccd446eSAtari911 echo '<input type="radio" name="week_start" value="sunday" ' . ($currentWeekStart === 'sunday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 70989ccd446eSAtari911 echo '<div>'; 7099da206178SAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Sunday</div>'; 7100da206178SAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Sunday (US/Canada standard)</div>'; 71019ccd446eSAtari911 echo '</div>'; 71029ccd446eSAtari911 echo '</label>'; 71039ccd446eSAtari911 echo '</div>'; 71049ccd446eSAtari911 echo '</div>'; 71059ccd446eSAtari911 710696df7d3eSAtari911 // Itinerary Default State Section 710796df7d3eSAtari911 echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">'; 7108da206178SAtari911 echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;"> Itinerary Section</h3>'; 7109da206178SAtari911 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>'; 711096df7d3eSAtari911 711196df7d3eSAtari911 echo '<div style="display:flex; gap:15px;">'; 711296df7d3eSAtari911 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;">'; 711396df7d3eSAtari911 echo '<input type="radio" name="itinerary_collapsed" value="no" ' . (!$currentItineraryCollapsed ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 711496df7d3eSAtari911 echo '<div>'; 7115da206178SAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Expanded</div>'; 7116da206178SAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Show itinerary sections by default</div>'; 711796df7d3eSAtari911 echo '</div>'; 711896df7d3eSAtari911 echo '</label>'; 711996df7d3eSAtari911 712096df7d3eSAtari911 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;">'; 712196df7d3eSAtari911 echo '<input type="radio" name="itinerary_collapsed" value="yes" ' . ($currentItineraryCollapsed ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 712296df7d3eSAtari911 echo '<div>'; 7123da206178SAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Collapsed</div>'; 7124da206178SAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Hide itinerary sections by default (click bar to expand)</div>'; 712596df7d3eSAtari911 echo '</div>'; 712696df7d3eSAtari911 echo '</label>'; 712796df7d3eSAtari911 echo '</div>'; 712896df7d3eSAtari911 echo '</div>'; 712996df7d3eSAtari911 713064a96c92SAtari911 // Default Search Scope Section 713164a96c92SAtari911 echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">'; 713264a96c92SAtari911 echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;"> ' . $this->getLang('search_default_title') . '</h3>'; 713364a96c92SAtari911 echo '<p style="color:' . $colors['text'] . '; margin-bottom:15px; font-size:13px;">' . $this->getLang('search_default_desc') . '</p>'; 713464a96c92SAtari911 713564a96c92SAtari911 echo '<div style="display:flex; gap:15px;">'; 713664a96c92SAtari911 echo '<label style="flex:1; padding:12px; border:2px solid ' . ($currentSearchDefault === 'month' ? '#00cc07' : $colors['border']) . '; border-radius:4px; background:' . ($currentSearchDefault === 'month' ? 'rgba(0, 204, 7, 0.05)' : $colors['bg']) . '; cursor:pointer; display:flex; align-items:center;">'; 713764a96c92SAtari911 echo '<input type="radio" name="search_default" value="month" ' . ($currentSearchDefault === 'month' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 713864a96c92SAtari911 echo '<div>'; 713964a96c92SAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;"> ' . $this->getLang('search_default_month') . '</div>'; 714064a96c92SAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">' . $this->getLang('search_default_month_desc') . '</div>'; 714164a96c92SAtari911 echo '</div>'; 714264a96c92SAtari911 echo '</label>'; 714364a96c92SAtari911 714464a96c92SAtari911 echo '<label style="flex:1; padding:12px; border:2px solid ' . ($currentSearchDefault === 'all' ? '#00cc07' : $colors['border']) . '; border-radius:4px; background:' . ($currentSearchDefault === 'all' ? 'rgba(0, 204, 7, 0.05)' : $colors['bg']) . '; cursor:pointer; display:flex; align-items:center;">'; 714564a96c92SAtari911 echo '<input type="radio" name="search_default" value="all" ' . ($currentSearchDefault === 'all' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 714664a96c92SAtari911 echo '<div>'; 714764a96c92SAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;"> ' . $this->getLang('search_default_all') . '</div>'; 714864a96c92SAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">' . $this->getLang('search_default_all_desc') . '</div>'; 714964a96c92SAtari911 echo '</div>'; 715064a96c92SAtari911 echo '</label>'; 715164a96c92SAtari911 echo '</div>'; 715264a96c92SAtari911 echo '</div>'; 715364a96c92SAtari911 71549ccd446eSAtari911 // Visual Theme Section 7155da206178SAtari911 echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;"> Visual Theme</h3>'; 71569ccd446eSAtari911 71579ccd446eSAtari911 // Matrix Theme 71589ccd446eSAtari911 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']) . ';">'; 71599ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 71609ccd446eSAtari911 echo '<input type="radio" name="theme" value="matrix" ' . ($currentTheme === 'matrix' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 71619ccd446eSAtari911 echo '<div style="flex:1;">'; 7162815440faSAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#00cc07; margin-bottom:8px;"> Matrix</div>'; 7163da206178SAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Dark green theme with Matrix-style glow effects and neon accents</div>'; 7164da206178SAtari911 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>'; 71659ccd446eSAtari911 echo '</div>'; 71669ccd446eSAtari911 echo '</label>'; 71679ccd446eSAtari911 echo '</div>'; 71689ccd446eSAtari911 71699ccd446eSAtari911 // Purple Theme 71709ccd446eSAtari911 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']) . ';">'; 71719ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 71729ccd446eSAtari911 echo '<input type="radio" name="theme" value="purple" ' . ($currentTheme === 'purple' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 71739ccd446eSAtari911 echo '<div style="flex:1;">'; 7174da206178SAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#9b59b6; margin-bottom:8px;"> Purple Dream</div>'; 7175da206178SAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Rich purple theme with elegant violet accents and soft glow</div>'; 7176da206178SAtari911 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>'; 71779ccd446eSAtari911 echo '</div>'; 71789ccd446eSAtari911 echo '</label>'; 71799ccd446eSAtari911 echo '</div>'; 71809ccd446eSAtari911 71819ccd446eSAtari911 // Professional Blue Theme 71829ccd446eSAtari911 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']) . ';">'; 71839ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 71849ccd446eSAtari911 echo '<input type="radio" name="theme" value="professional" ' . ($currentTheme === 'professional' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 71859ccd446eSAtari911 echo '<div style="flex:1;">'; 7186da206178SAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#4a90e2; margin-bottom:8px;"> Professional Blue</div>'; 7187da206178SAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Clean blue and grey theme with modern professional styling, no glow effects</div>'; 7188da206178SAtari911 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>'; 71899ccd446eSAtari911 echo '</div>'; 71909ccd446eSAtari911 echo '</label>'; 71919ccd446eSAtari911 echo '</div>'; 71929ccd446eSAtari911 71939ccd446eSAtari911 // Pink Bling Theme 71949ccd446eSAtari911 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']) . ';">'; 71959ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 71969ccd446eSAtari911 echo '<input type="radio" name="theme" value="pink" ' . ($currentTheme === 'pink' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 71979ccd446eSAtari911 echo '<div style="flex:1;">'; 7198da206178SAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#ff1493; margin-bottom:8px;"> Pink Bling</div>'; 7199da206178SAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Glamorous hot pink theme with maximum sparkle, hearts, and diamonds ✨</div>'; 7200da206178SAtari911 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>'; 72019ccd446eSAtari911 echo '</div>'; 72029ccd446eSAtari911 echo '</label>'; 72039ccd446eSAtari911 echo '</div>'; 72049ccd446eSAtari911 72059ccd446eSAtari911 // Wiki Default Theme 72069ccd446eSAtari911 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']) . ';">'; 72079ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 72089ccd446eSAtari911 echo '<input type="radio" name="theme" value="wiki" ' . ($currentTheme === 'wiki' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 72099ccd446eSAtari911 echo '<div style="flex:1;">'; 7210da206178SAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#2b73b7; margin-bottom:8px;"> Wiki Default</div>'; 7211da206178SAtari911 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>'; 7212da206178SAtari911 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>'; 72139ccd446eSAtari911 echo '</div>'; 72149ccd446eSAtari911 echo '</label>'; 72159ccd446eSAtari911 echo '</div>'; 72169ccd446eSAtari911 7217da206178SAtari911 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>'; 72189ccd446eSAtari911 echo '</form>'; 72199ccd446eSAtari911 } 72209ccd446eSAtari911 72219ccd446eSAtari911 /** 72229ccd446eSAtari911 * Get current sidebar theme 72239ccd446eSAtari911 */ 72249ccd446eSAtari911 private function getSidebarTheme() { 7225*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_theme.txt'; 72269ccd446eSAtari911 if (file_exists($configFile)) { 72279ccd446eSAtari911 return trim(file_get_contents($configFile)); 72289ccd446eSAtari911 } 72299ccd446eSAtari911 return 'matrix'; // Default 72309ccd446eSAtari911 } 72319ccd446eSAtari911 72329ccd446eSAtari911 /** 72339ccd446eSAtari911 * Save sidebar theme 72349ccd446eSAtari911 */ 72359ccd446eSAtari911 private function saveSidebarTheme($theme) { 7236*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_theme.txt'; 72379ccd446eSAtari911 $validThemes = ['matrix', 'purple', 'professional', 'pink', 'wiki']; 72389ccd446eSAtari911 72399ccd446eSAtari911 if (in_array($theme, $validThemes)) { 72409ccd446eSAtari911 file_put_contents($configFile, $theme); 72419ccd446eSAtari911 return true; 72429ccd446eSAtari911 } 72439ccd446eSAtari911 return false; 72449ccd446eSAtari911 } 72459ccd446eSAtari911 72469ccd446eSAtari911 /** 72479ccd446eSAtari911 * Get week start day 72489ccd446eSAtari911 */ 72499ccd446eSAtari911 private function getWeekStartDay() { 7250*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_week_start.txt'; 72519ccd446eSAtari911 if (file_exists($configFile)) { 72529ccd446eSAtari911 $start = trim(file_get_contents($configFile)); 72539ccd446eSAtari911 if (in_array($start, ['monday', 'sunday'])) { 72549ccd446eSAtari911 return $start; 72559ccd446eSAtari911 } 72569ccd446eSAtari911 } 72579ccd446eSAtari911 return 'sunday'; // Default to Sunday (US/Canada standard) 72589ccd446eSAtari911 } 72599ccd446eSAtari911 72609ccd446eSAtari911 /** 72619ccd446eSAtari911 * Save week start day 72629ccd446eSAtari911 */ 72639ccd446eSAtari911 private function saveWeekStartDay($weekStart) { 7264*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_week_start.txt'; 72659ccd446eSAtari911 $validStarts = ['monday', 'sunday']; 72669ccd446eSAtari911 72679ccd446eSAtari911 if (in_array($weekStart, $validStarts)) { 72689ccd446eSAtari911 file_put_contents($configFile, $weekStart); 72699ccd446eSAtari911 return true; 72709ccd446eSAtari911 } 72719ccd446eSAtari911 return false; 72729ccd446eSAtari911 } 72739ccd446eSAtari911 72749ccd446eSAtari911 /** 727596df7d3eSAtari911 * Get itinerary collapsed default state 727696df7d3eSAtari911 */ 727796df7d3eSAtari911 private function getItineraryCollapsed() { 7278*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_itinerary_collapsed.txt'; 727996df7d3eSAtari911 if (file_exists($configFile)) { 728096df7d3eSAtari911 return trim(file_get_contents($configFile)) === 'yes'; 728196df7d3eSAtari911 } 728296df7d3eSAtari911 return false; // Default to expanded 728396df7d3eSAtari911 } 728496df7d3eSAtari911 728596df7d3eSAtari911 /** 728696df7d3eSAtari911 * Save itinerary collapsed default state 728796df7d3eSAtari911 */ 728896df7d3eSAtari911 private function saveItineraryCollapsed($collapsed) { 7289*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_itinerary_collapsed.txt'; 729096df7d3eSAtari911 file_put_contents($configFile, $collapsed ? 'yes' : 'no'); 729196df7d3eSAtari911 return true; 729296df7d3eSAtari911 } 729396df7d3eSAtari911 729496df7d3eSAtari911 /** 729564a96c92SAtari911 * Get default search scope (month or all) 729664a96c92SAtari911 */ 729764a96c92SAtari911 private function getSearchDefault() { 7298*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_search_default.txt'; 729964a96c92SAtari911 if (file_exists($configFile)) { 730064a96c92SAtari911 $value = trim(file_get_contents($configFile)); 730164a96c92SAtari911 if (in_array($value, ['month', 'all'])) { 730264a96c92SAtari911 return $value; 730364a96c92SAtari911 } 730464a96c92SAtari911 } 730564a96c92SAtari911 return 'month'; // Default to month search 730664a96c92SAtari911 } 730764a96c92SAtari911 730864a96c92SAtari911 /** 730964a96c92SAtari911 * Save default search scope 731064a96c92SAtari911 */ 731164a96c92SAtari911 private function saveSearchDefault($scope) { 7312*2866e827SAtari911 $configFile = $this->metaDir() . 'calendar_search_default.txt'; 731364a96c92SAtari911 $validScopes = ['month', 'all']; 731464a96c92SAtari911 731564a96c92SAtari911 if (in_array($scope, $validScopes)) { 731664a96c92SAtari911 file_put_contents($configFile, $scope); 731764a96c92SAtari911 return true; 731864a96c92SAtari911 } 731964a96c92SAtari911 return false; 732064a96c92SAtari911 } 732164a96c92SAtari911 732264a96c92SAtari911 /** 73239ccd446eSAtari911 * Get colors from DokuWiki template's style.ini file 73249ccd446eSAtari911 */ 73259ccd446eSAtari911 private function getTemplateColors() { 73269ccd446eSAtari911 global $conf; 73279ccd446eSAtari911 73289ccd446eSAtari911 // Get current template name 73299ccd446eSAtari911 $template = $conf['template']; 73309ccd446eSAtari911 7331*2866e827SAtari911 // Try multiple possible locations for style.ini (farm-safe) 73329ccd446eSAtari911 $possiblePaths = [ 73339ccd446eSAtari911 DOKU_INC . 'lib/tpl/' . $template . '/style.ini', 73349ccd446eSAtari911 ]; 7335*2866e827SAtari911 // Add farm-specific conf override path if available 7336*2866e827SAtari911 if (!empty($conf['savedir'])) { 7337*2866e827SAtari911 array_unshift($possiblePaths, $conf['savedir'] . '/tpl/' . $template . '/style.ini'); 7338*2866e827SAtari911 } 7339*2866e827SAtari911 array_unshift($possiblePaths, DOKU_INC . 'conf/tpl/' . $template . '/style.ini'); 73409ccd446eSAtari911 73419ccd446eSAtari911 $styleIni = null; 73429ccd446eSAtari911 foreach ($possiblePaths as $path) { 73439ccd446eSAtari911 if (file_exists($path)) { 73449ccd446eSAtari911 $styleIni = parse_ini_file($path, true); 73459ccd446eSAtari911 break; 73469ccd446eSAtari911 } 73479ccd446eSAtari911 } 73489ccd446eSAtari911 73499ccd446eSAtari911 if (!$styleIni || !isset($styleIni['replacements'])) { 73509ccd446eSAtari911 // Return defaults 73519ccd446eSAtari911 return [ 73529ccd446eSAtari911 'bg' => '#fff', 73539ccd446eSAtari911 'bg_alt' => '#e8e8e8', 73549ccd446eSAtari911 'text' => '#333', 73559ccd446eSAtari911 'border' => '#ccc', 73569ccd446eSAtari911 'link' => '#2b73b7', 73579ccd446eSAtari911 ]; 73589ccd446eSAtari911 } 73599ccd446eSAtari911 73609ccd446eSAtari911 $r = $styleIni['replacements']; 73619ccd446eSAtari911 73629ccd446eSAtari911 return [ 73639ccd446eSAtari911 'bg' => isset($r['__background__']) ? $r['__background__'] : '#fff', 73649ccd446eSAtari911 'bg_alt' => isset($r['__background_alt__']) ? $r['__background_alt__'] : '#e8e8e8', 73659ccd446eSAtari911 'text' => isset($r['__text__']) ? $r['__text__'] : '#333', 73669ccd446eSAtari911 'border' => isset($r['__border__']) ? $r['__border__'] : '#ccc', 73679ccd446eSAtari911 'link' => isset($r['__link__']) ? $r['__link__'] : '#2b73b7', 73689ccd446eSAtari911 ]; 73699ccd446eSAtari911 } 73701d05cddcSAtari911} 7371