11d05cddcSAtari911<?php 21d05cddcSAtari911/** 31d05cddcSAtari911 * Calendar Plugin - Admin Interface 41d05cddcSAtari911 * Clean rewrite - Configuration only 51d05cddcSAtari911 * Version: 3.3 61d05cddcSAtari911 */ 71d05cddcSAtari911 81d05cddcSAtari911if(!defined('DOKU_INC')) die(); 91d05cddcSAtari911 101d05cddcSAtari911class admin_plugin_calendar extends DokuWiki_Admin_Plugin { 111d05cddcSAtari911 121d05cddcSAtari911 public function getMenuText($language) { 131d05cddcSAtari911 return 'Calendar Management'; 141d05cddcSAtari911 } 151d05cddcSAtari911 161d05cddcSAtari911 public function getMenuSort() { 171d05cddcSAtari911 return 100; 181d05cddcSAtari911 } 191d05cddcSAtari911 201d05cddcSAtari911 public function forAdminOnly() { 211d05cddcSAtari911 return true; 221d05cddcSAtari911 } 231d05cddcSAtari911 24*7e8ea635SAtari911 /** 25*7e8ea635SAtari911 * Public entry point for AJAX actions routed from action.php 26*7e8ea635SAtari911 */ 27*7e8ea635SAtari911 public function handleAjaxAction($action) { 28*7e8ea635SAtari911 // Verify admin privileges for all admin AJAX actions 29*7e8ea635SAtari911 if (!auth_isadmin()) { 30*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 31*7e8ea635SAtari911 return; 32*7e8ea635SAtari911 } 33*7e8ea635SAtari911 34*7e8ea635SAtari911 switch ($action) { 35*7e8ea635SAtari911 case 'cleanup_empty_namespaces': $this->handleCleanupEmptyNamespaces(); break; 36*7e8ea635SAtari911 case 'trim_all_past_recurring': $this->handleTrimAllPastRecurring(); break; 37*7e8ea635SAtari911 case 'rescan_recurring': $this->handleRescanRecurring(); break; 38*7e8ea635SAtari911 case 'extend_recurring': $this->handleExtendRecurring(); break; 39*7e8ea635SAtari911 case 'trim_recurring': $this->handleTrimRecurring(); break; 40*7e8ea635SAtari911 case 'pause_recurring': $this->handlePauseRecurring(); break; 41*7e8ea635SAtari911 case 'resume_recurring': $this->handleResumeRecurring(); break; 42*7e8ea635SAtari911 case 'change_start_recurring': $this->handleChangeStartRecurring(); break; 43*7e8ea635SAtari911 case 'change_pattern_recurring': $this->handleChangePatternRecurring(); break; 44*7e8ea635SAtari911 default: 45*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Unknown admin action']); 46*7e8ea635SAtari911 } 47*7e8ea635SAtari911 } 48*7e8ea635SAtari911 491d05cddcSAtari911 public function handle() { 501d05cddcSAtari911 global $INPUT; 511d05cddcSAtari911 521d05cddcSAtari911 $action = $INPUT->str('action'); 531d05cddcSAtari911 541d05cddcSAtari911 if ($action === 'clear_cache') { 551d05cddcSAtari911 $this->clearCache(); 561d05cddcSAtari911 } elseif ($action === 'save_config') { 571d05cddcSAtari911 $this->saveConfig(); 581d05cddcSAtari911 } elseif ($action === 'delete_recurring_series') { 591d05cddcSAtari911 $this->deleteRecurringSeries(); 601d05cddcSAtari911 } elseif ($action === 'edit_recurring_series') { 611d05cddcSAtari911 $this->editRecurringSeries(); 621d05cddcSAtari911 } elseif ($action === 'move_selected_events') { 631d05cddcSAtari911 $this->moveEvents(); 641d05cddcSAtari911 } elseif ($action === 'move_single_event') { 651d05cddcSAtari911 $this->moveSingleEvent(); 661d05cddcSAtari911 } elseif ($action === 'delete_selected_events') { 671d05cddcSAtari911 $this->deleteSelectedEvents(); 681d05cddcSAtari911 } elseif ($action === 'create_namespace') { 691d05cddcSAtari911 $this->createNamespace(); 701d05cddcSAtari911 } elseif ($action === 'delete_namespace') { 711d05cddcSAtari911 $this->deleteNamespace(); 729ccd446eSAtari911 } elseif ($action === 'rename_namespace') { 739ccd446eSAtari911 $this->renameNamespace(); 741d05cddcSAtari911 } elseif ($action === 'run_sync') { 751d05cddcSAtari911 $this->runSync(); 761d05cddcSAtari911 } elseif ($action === 'stop_sync') { 771d05cddcSAtari911 $this->stopSync(); 781d05cddcSAtari911 } elseif ($action === 'upload_update') { 791d05cddcSAtari911 $this->uploadUpdate(); 801d05cddcSAtari911 } elseif ($action === 'delete_backup') { 811d05cddcSAtari911 $this->deleteBackup(); 821d05cddcSAtari911 } elseif ($action === 'rename_backup') { 831d05cddcSAtari911 $this->renameBackup(); 841d05cddcSAtari911 } elseif ($action === 'restore_backup') { 851d05cddcSAtari911 $this->restoreBackup(); 869ccd446eSAtari911 } elseif ($action === 'create_manual_backup') { 879ccd446eSAtari911 $this->createManualBackup(); 881d05cddcSAtari911 } elseif ($action === 'export_config') { 891d05cddcSAtari911 $this->exportConfig(); 901d05cddcSAtari911 } elseif ($action === 'import_config') { 911d05cddcSAtari911 $this->importConfig(); 921d05cddcSAtari911 } elseif ($action === 'get_log') { 931d05cddcSAtari911 $this->getLog(); 94*7e8ea635SAtari911 } elseif ($action === 'cleanup_empty_namespaces') { 95*7e8ea635SAtari911 $this->handleCleanupEmptyNamespaces(); 96*7e8ea635SAtari911 } elseif ($action === 'trim_all_past_recurring') { 97*7e8ea635SAtari911 $this->handleTrimAllPastRecurring(); 98*7e8ea635SAtari911 } elseif ($action === 'rescan_recurring') { 99*7e8ea635SAtari911 $this->handleRescanRecurring(); 100*7e8ea635SAtari911 } elseif ($action === 'extend_recurring') { 101*7e8ea635SAtari911 $this->handleExtendRecurring(); 102*7e8ea635SAtari911 } elseif ($action === 'trim_recurring') { 103*7e8ea635SAtari911 $this->handleTrimRecurring(); 104*7e8ea635SAtari911 } elseif ($action === 'pause_recurring') { 105*7e8ea635SAtari911 $this->handlePauseRecurring(); 106*7e8ea635SAtari911 } elseif ($action === 'resume_recurring') { 107*7e8ea635SAtari911 $this->handleResumeRecurring(); 108*7e8ea635SAtari911 } elseif ($action === 'change_start_recurring') { 109*7e8ea635SAtari911 $this->handleChangeStartRecurring(); 110*7e8ea635SAtari911 } elseif ($action === 'change_pattern_recurring') { 111*7e8ea635SAtari911 $this->handleChangePatternRecurring(); 1121d05cddcSAtari911 } elseif ($action === 'clear_log') { 1131d05cddcSAtari911 $this->clearLogFile(); 1141d05cddcSAtari911 } elseif ($action === 'download_log') { 1151d05cddcSAtari911 $this->downloadLog(); 1161d05cddcSAtari911 } elseif ($action === 'rescan_events') { 1171d05cddcSAtari911 $this->rescanEvents(); 1181d05cddcSAtari911 } elseif ($action === 'export_all_events') { 1191d05cddcSAtari911 $this->exportAllEvents(); 1201d05cddcSAtari911 } elseif ($action === 'import_all_events') { 1211d05cddcSAtari911 $this->importAllEvents(); 1221d05cddcSAtari911 } elseif ($action === 'preview_cleanup') { 1231d05cddcSAtari911 $this->previewCleanup(); 1241d05cddcSAtari911 } elseif ($action === 'cleanup_events') { 1251d05cddcSAtari911 $this->cleanupEvents(); 1264590242dSAtari911 } elseif ($action === 'save_important_namespaces') { 1274590242dSAtari911 $this->saveImportantNamespaces(); 1281d05cddcSAtari911 } 1291d05cddcSAtari911 } 1301d05cddcSAtari911 1311d05cddcSAtari911 public function html() { 1321d05cddcSAtari911 global $INPUT; 1331d05cddcSAtari911 1349ccd446eSAtari911 // Get current tab - default to 'manage' (Manage Events tab) 1359ccd446eSAtari911 $tab = $INPUT->str('tab', 'manage'); 1361d05cddcSAtari911 1379ccd446eSAtari911 // Get template colors 1389ccd446eSAtari911 $colors = $this->getTemplateColors(); 1399ccd446eSAtari911 $accentColor = '#00cc07'; // Keep calendar plugin accent color 1409ccd446eSAtari911 1419ccd446eSAtari911 // Tab navigation (Manage Events, Update Plugin, Outlook Sync, Themes) 1429ccd446eSAtari911 echo '<div style="border-bottom:2px solid ' . $colors['border'] . '; margin:10px 0 15px 0;">'; 1439ccd446eSAtari911 echo '<a href="?do=admin&page=calendar&tab=manage" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'manage' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'manage' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'manage' ? 'bold' : 'normal') . ';"> Manage Events</a>'; 1449ccd446eSAtari911 echo '<a href="?do=admin&page=calendar&tab=update" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'update' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'update' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'update' ? 'bold' : 'normal') . ';"> Update Plugin</a>'; 1459ccd446eSAtari911 echo '<a href="?do=admin&page=calendar&tab=config" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'config' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'config' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'config' ? 'bold' : 'normal') . ';">⚙️ Outlook Sync</a>'; 1469ccd446eSAtari911 echo '<a href="?do=admin&page=calendar&tab=themes" style="display:inline-block; padding:8px 16px; text-decoration:none; color:' . ($tab === 'themes' ? $accentColor : $colors['text']) . '; border-bottom:3px solid ' . ($tab === 'themes' ? $accentColor : 'transparent') . '; font-weight:' . ($tab === 'themes' ? 'bold' : 'normal') . ';"> Themes</a>'; 1471d05cddcSAtari911 echo '</div>'; 1481d05cddcSAtari911 1491d05cddcSAtari911 // Render appropriate tab 1501d05cddcSAtari911 if ($tab === 'config') { 1519ccd446eSAtari911 $this->renderConfigTab($colors); 1521d05cddcSAtari911 } elseif ($tab === 'manage') { 1539ccd446eSAtari911 $this->renderManageTab($colors); 1549ccd446eSAtari911 } elseif ($tab === 'themes') { 1559ccd446eSAtari911 $this->renderThemesTab($colors); 1561d05cddcSAtari911 } else { 1579ccd446eSAtari911 $this->renderUpdateTab($colors); 1581d05cddcSAtari911 } 1591d05cddcSAtari911 } 1601d05cddcSAtari911 1619ccd446eSAtari911 private function renderConfigTab($colors = null) { 1621d05cddcSAtari911 global $INPUT; 1631d05cddcSAtari911 1649ccd446eSAtari911 // Use defaults if not provided 1659ccd446eSAtari911 if ($colors === null) { 1669ccd446eSAtari911 $colors = $this->getTemplateColors(); 1679ccd446eSAtari911 } 1689ccd446eSAtari911 1691d05cddcSAtari911 // Load current config 1701d05cddcSAtari911 $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; 1711d05cddcSAtari911 $config = []; 1721d05cddcSAtari911 if (file_exists($configFile)) { 1731d05cddcSAtari911 $config = include $configFile; 1741d05cddcSAtari911 } 1751d05cddcSAtari911 1761d05cddcSAtari911 // Show message if present 1771d05cddcSAtari911 if ($INPUT->has('msg')) { 1781d05cddcSAtari911 $msg = hsc($INPUT->str('msg')); 1791d05cddcSAtari911 $type = $INPUT->str('msgtype', 'success'); 1801d05cddcSAtari911 $class = ($type === 'success') ? 'msg success' : 'msg error'; 1811d05cddcSAtari911 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;\">"; 1821d05cddcSAtari911 echo $msg; 1831d05cddcSAtari911 echo "</div>"; 1841d05cddcSAtari911 } 1851d05cddcSAtari911 1861d05cddcSAtari911 echo '<h2 style="margin:10px 0; font-size:20px;">Outlook Sync Configuration</h2>'; 1871d05cddcSAtari911 1881d05cddcSAtari911 // Import/Export buttons 1891d05cddcSAtari911 echo '<div style="display:flex; gap:10px; margin-bottom:15px;">'; 1901d05cddcSAtari911 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>'; 1911d05cddcSAtari911 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>'; 1921d05cddcSAtari911 echo '<input type="file" id="importFileInput" accept=".enc" style="display:none;" onchange="importConfig(this)">'; 1931d05cddcSAtari911 echo '<span id="importStatus" style="margin-left:10px; font-size:12px;"></span>'; 1941d05cddcSAtari911 echo '</div>'; 1951d05cddcSAtari911 1961d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar" style="max-width:900px;">'; 1971d05cddcSAtari911 echo '<input type="hidden" name="action" value="save_config">'; 1981d05cddcSAtari911 1991d05cddcSAtari911 // Azure Credentials 2009ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">'; 2011d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Microsoft Azure App Credentials</h3>'; 2029ccd446eSAtari911 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>'; 2031d05cddcSAtari911 2041d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Tenant ID</label>'; 2059ccd446eSAtari911 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;">'; 2061d05cddcSAtari911 2071d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client ID (Application ID)</label>'; 2089ccd446eSAtari911 echo '<input type="text" name="client_id" value="' . hsc($config['client_id'] ?? '') . '" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">'; 2091d05cddcSAtari911 2101d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin:8px 0 3px; font-size:13px;">Client Secret</label>'; 2119ccd446eSAtari911 echo '<input type="password" name="client_secret" value="' . hsc($config['client_secret'] ?? '') . '" placeholder="Enter client secret" required style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">'; 2121d05cddcSAtari911 echo '<p style="color:#999; font-size:0.8em; margin:3px 0 0;">⚠️ Keep this secret safe!</p>'; 2131d05cddcSAtari911 echo '</div>'; 2141d05cddcSAtari911 2151d05cddcSAtari911 // Outlook Settings 2169ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">'; 2171d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Outlook Settings</h3>'; 2181d05cddcSAtari911 2191d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">'; 2201d05cddcSAtari911 2211d05cddcSAtari911 echo '<div>'; 2221d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">User Email</label>'; 2239ccd446eSAtari911 echo '<input type="email" name="user_email" value="' . hsc($config['user_email'] ?? '') . '" placeholder="your.email@company.com" required style="width:100%; padding:6px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:13px;">'; 2241d05cddcSAtari911 echo '</div>'; 2251d05cddcSAtari911 2261d05cddcSAtari911 echo '<div>'; 2271d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Timezone</label>'; 2289ccd446eSAtari911 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;">'; 2291d05cddcSAtari911 echo '</div>'; 2301d05cddcSAtari911 2311d05cddcSAtari911 echo '<div>'; 2321d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Default Category</label>'; 2339ccd446eSAtari911 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;">'; 2341d05cddcSAtari911 echo '</div>'; 2351d05cddcSAtari911 2361d05cddcSAtari911 echo '<div>'; 2371d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin:0 0 3px; font-size:13px;">Reminder (minutes)</label>'; 2389ccd446eSAtari911 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;">'; 2391d05cddcSAtari911 echo '</div>'; 2401d05cddcSAtari911 2411d05cddcSAtari911 echo '</div>'; // end grid 2421d05cddcSAtari911 echo '</div>'; 2431d05cddcSAtari911 2441d05cddcSAtari911 // Sync Options 2459ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px;">'; 2461d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Sync Options</h3>'; 2471d05cddcSAtari911 2481d05cddcSAtari911 $syncCompleted = isset($config['sync_completed_tasks']) ? $config['sync_completed_tasks'] : false; 2491d05cddcSAtari911 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>'; 2501d05cddcSAtari911 2511d05cddcSAtari911 $deleteOutlook = isset($config['delete_outlook_events']) ? $config['delete_outlook_events'] : true; 2521d05cddcSAtari911 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>'; 2531d05cddcSAtari911 2541d05cddcSAtari911 $syncAll = isset($config['sync_all_namespaces']) ? $config['sync_all_namespaces'] : true; 2551d05cddcSAtari911 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>'; 2561d05cddcSAtari911 2571d05cddcSAtari911 // Namespace selection (shown when sync_all is unchecked) 2581d05cddcSAtari911 echo '<div id="namespace_selection" style="margin-top:10px; ' . ($syncAll ? 'display:none;' : '') . '">'; 2591d05cddcSAtari911 echo '<label style="display:block; font-weight:bold; margin-bottom:5px; font-size:13px;">Select namespaces to sync:</label>'; 2601d05cddcSAtari911 2611d05cddcSAtari911 // Get available namespaces 2621d05cddcSAtari911 $availableNamespaces = $this->getAllNamespaces(); 2631d05cddcSAtari911 $selectedNamespaces = isset($config['sync_namespaces']) ? $config['sync_namespaces'] : []; 2641d05cddcSAtari911 2659ccd446eSAtari911 echo '<div style="max-height:150px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; padding:8px; background:' . $colors['bg'] . ';">'; 2661d05cddcSAtari911 echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value=""> (default)</label>'; 2671d05cddcSAtari911 foreach ($availableNamespaces as $ns) { 2681d05cddcSAtari911 if ($ns !== '') { 2691d05cddcSAtari911 $checked = in_array($ns, $selectedNamespaces) ? 'checked' : ''; 2701d05cddcSAtari911 echo '<label style="display:block; margin:3px 0;"><input type="checkbox" name="sync_namespaces[]" value="' . hsc($ns) . '" ' . $checked . '> ' . hsc($ns) . '</label>'; 2711d05cddcSAtari911 } 2721d05cddcSAtari911 } 2731d05cddcSAtari911 echo '</div>'; 2741d05cddcSAtari911 echo '</div>'; 2751d05cddcSAtari911 2761d05cddcSAtari911 echo '<script> 2771d05cddcSAtari911 function toggleNamespaceSelection(checkbox) { 2781d05cddcSAtari911 document.getElementById("namespace_selection").style.display = checkbox.checked ? "none" : "block"; 2791d05cddcSAtari911 } 2801d05cddcSAtari911 </script>'; 2811d05cddcSAtari911 2821d05cddcSAtari911 echo '</div>'; 2831d05cddcSAtari911 2841d05cddcSAtari911 // Namespace and Color Mapping - Side by Side 2851d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:10px 0;">'; 2861d05cddcSAtari911 2871d05cddcSAtari911 // Namespace Mapping 2889ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">'; 2891d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;">Namespace → Category</h3>'; 2909ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 5px;">One per line: namespace=Category</p>'; 2919ccd446eSAtari911 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">'; 2921d05cddcSAtari911 if (isset($config['category_mapping']) && is_array($config['category_mapping'])) { 2931d05cddcSAtari911 foreach ($config['category_mapping'] as $ns => $cat) { 2941d05cddcSAtari911 echo hsc($ns) . '=' . hsc($cat) . "\n"; 2951d05cddcSAtari911 } 2961d05cddcSAtari911 } 2971d05cddcSAtari911 echo '</textarea>'; 2981d05cddcSAtari911 echo '</div>'; 2991d05cddcSAtari911 3001d05cddcSAtari911 // Color Mapping with Color Picker 3019ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">'; 3021d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Event Color → Category</h3>'; 3039ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 8px;">Map calendar colors to Outlook categories</p>'; 3041d05cddcSAtari911 3051d05cddcSAtari911 // Define calendar colors and Outlook categories (only the main 6 colors) 3061d05cddcSAtari911 $calendarColors = [ 3071d05cddcSAtari911 '#3498db' => 'Blue', 3081d05cddcSAtari911 '#2ecc71' => 'Green', 3091d05cddcSAtari911 '#e74c3c' => 'Red', 3101d05cddcSAtari911 '#f39c12' => 'Orange', 3111d05cddcSAtari911 '#9b59b6' => 'Purple', 3121d05cddcSAtari911 '#1abc9c' => 'Teal' 3131d05cddcSAtari911 ]; 3141d05cddcSAtari911 3151d05cddcSAtari911 $outlookCategories = [ 3161d05cddcSAtari911 'Blue category', 3171d05cddcSAtari911 'Green category', 3181d05cddcSAtari911 'Orange category', 3191d05cddcSAtari911 'Red category', 3201d05cddcSAtari911 'Yellow category', 3211d05cddcSAtari911 'Purple category' 3221d05cddcSAtari911 ]; 3231d05cddcSAtari911 3241d05cddcSAtari911 // Load existing color mappings 3251d05cddcSAtari911 $existingMappings = isset($config['color_mapping']) && is_array($config['color_mapping']) 3261d05cddcSAtari911 ? $config['color_mapping'] 3271d05cddcSAtari911 : []; 3281d05cddcSAtari911 3291d05cddcSAtari911 // Display color mapping rows 3301d05cddcSAtari911 echo '<div id="colorMappings" style="max-height:200px; overflow-y:auto;">'; 3311d05cddcSAtari911 3321d05cddcSAtari911 $rowIndex = 0; 3331d05cddcSAtari911 foreach ($calendarColors as $hexColor => $colorName) { 3341d05cddcSAtari911 $selectedCategory = isset($existingMappings[$hexColor]) ? $existingMappings[$hexColor] : ''; 3351d05cddcSAtari911 3361d05cddcSAtari911 echo '<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px;">'; 3371d05cddcSAtari911 3381d05cddcSAtari911 // Color preview box 3391d05cddcSAtari911 echo '<div style="width:24px; height:24px; background:' . $hexColor . '; border:2px solid #ddd; border-radius:3px; flex-shrink:0;"></div>'; 3401d05cddcSAtari911 3411d05cddcSAtari911 // Color name 3429ccd446eSAtari911 echo '<span style="font-size:12px; min-width:90px; color:' . $colors['text'] . ';">' . $colorName . '</span>'; 3431d05cddcSAtari911 3441d05cddcSAtari911 // Arrow 3451d05cddcSAtari911 echo '<span style="color:#999; font-size:12px;">→</span>'; 3461d05cddcSAtari911 3471d05cddcSAtari911 // Outlook category dropdown 3489ccd446eSAtari911 echo '<select name="color_map_' . $rowIndex . '" style="flex:1; padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">'; 3491d05cddcSAtari911 echo '<option value="">-- None --</option>'; 3501d05cddcSAtari911 foreach ($outlookCategories as $category) { 3511d05cddcSAtari911 $selected = ($selectedCategory === $category) ? 'selected' : ''; 3521d05cddcSAtari911 echo '<option value="' . hsc($category) . '" ' . $selected . '>' . hsc($category) . '</option>'; 3531d05cddcSAtari911 } 3541d05cddcSAtari911 echo '</select>'; 3551d05cddcSAtari911 3561d05cddcSAtari911 // Hidden input for the hex color 3571d05cddcSAtari911 echo '<input type="hidden" name="color_hex_' . $rowIndex . '" value="' . $hexColor . '">'; 3581d05cddcSAtari911 3591d05cddcSAtari911 echo '</div>'; 3601d05cddcSAtari911 $rowIndex++; 3611d05cddcSAtari911 } 3621d05cddcSAtari911 3631d05cddcSAtari911 echo '</div>'; 3641d05cddcSAtari911 3651d05cddcSAtari911 // Hidden input to track number of color mappings 3661d05cddcSAtari911 echo '<input type="hidden" name="color_mapping_count" value="' . $rowIndex . '">'; 3671d05cddcSAtari911 3681d05cddcSAtari911 echo '</div>'; 3691d05cddcSAtari911 3701d05cddcSAtari911 echo '</div>'; // end grid 3711d05cddcSAtari911 3721d05cddcSAtari911 // Submit button 3731d05cddcSAtari911 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>'; 3741d05cddcSAtari911 echo '</form>'; 3751d05cddcSAtari911 3761d05cddcSAtari911 // JavaScript for Import/Export 3771d05cddcSAtari911 echo '<script> 3781d05cddcSAtari911 async function exportConfig() { 3791d05cddcSAtari911 try { 3801d05cddcSAtari911 const response = await fetch("?do=admin&page=calendar&action=export_config&call=ajax", { 3811d05cddcSAtari911 method: "POST" 3821d05cddcSAtari911 }); 3831d05cddcSAtari911 const data = await response.json(); 3841d05cddcSAtari911 3851d05cddcSAtari911 if (data.success) { 3861d05cddcSAtari911 // Create download link 3871d05cddcSAtari911 const blob = new Blob([data.encrypted], {type: "application/octet-stream"}); 3881d05cddcSAtari911 const url = URL.createObjectURL(blob); 3891d05cddcSAtari911 const a = document.createElement("a"); 3901d05cddcSAtari911 a.href = url; 3911d05cddcSAtari911 a.download = "sync_config_" + new Date().toISOString().split("T")[0] + ".enc"; 3921d05cddcSAtari911 document.body.appendChild(a); 3931d05cddcSAtari911 a.click(); 3941d05cddcSAtari911 document.body.removeChild(a); 3951d05cddcSAtari911 URL.revokeObjectURL(url); 3961d05cddcSAtari911 3971d05cddcSAtari911 alert("✅ Config exported successfully!\\n\\n⚠️ This file contains encrypted credentials.\\nKeep it secure!"); 3981d05cddcSAtari911 } else { 3991d05cddcSAtari911 alert("❌ Export failed: " + data.message); 4001d05cddcSAtari911 } 4011d05cddcSAtari911 } catch (error) { 4021d05cddcSAtari911 alert("❌ Error: " + error.message); 4031d05cddcSAtari911 } 4041d05cddcSAtari911 } 4051d05cddcSAtari911 4061d05cddcSAtari911 async function importConfig(input) { 4071d05cddcSAtari911 const file = input.files[0]; 4081d05cddcSAtari911 if (!file) return; 4091d05cddcSAtari911 4101d05cddcSAtari911 const status = document.getElementById("importStatus"); 4111d05cddcSAtari911 status.textContent = "⏳ Importing..."; 4121d05cddcSAtari911 status.style.color = "#00cc07"; 4131d05cddcSAtari911 4141d05cddcSAtari911 try { 4151d05cddcSAtari911 const encrypted = await file.text(); 4161d05cddcSAtari911 4171d05cddcSAtari911 const formData = new FormData(); 4181d05cddcSAtari911 formData.append("encrypted_config", encrypted); 4191d05cddcSAtari911 4201d05cddcSAtari911 const response = await fetch("?do=admin&page=calendar&action=import_config&call=ajax", { 4211d05cddcSAtari911 method: "POST", 4221d05cddcSAtari911 body: formData 4231d05cddcSAtari911 }); 4241d05cddcSAtari911 const data = await response.json(); 4251d05cddcSAtari911 4261d05cddcSAtari911 if (data.success) { 4271d05cddcSAtari911 status.textContent = "✅ Import successful! Reloading..."; 4281d05cddcSAtari911 status.style.color = "#28a745"; 4291d05cddcSAtari911 setTimeout(() => { 4301d05cddcSAtari911 window.location.reload(); 4311d05cddcSAtari911 }, 1500); 4321d05cddcSAtari911 } else { 4331d05cddcSAtari911 status.textContent = "❌ Import failed: " + data.message; 4341d05cddcSAtari911 status.style.color = "#dc3545"; 4351d05cddcSAtari911 } 4361d05cddcSAtari911 } catch (error) { 4371d05cddcSAtari911 status.textContent = "❌ Error: " + error.message; 4381d05cddcSAtari911 status.style.color = "#dc3545"; 4391d05cddcSAtari911 } 4401d05cddcSAtari911 4411d05cddcSAtari911 // Reset file input 4421d05cddcSAtari911 input.value = ""; 4431d05cddcSAtari911 } 4441d05cddcSAtari911 </script>'; 4451d05cddcSAtari911 4461d05cddcSAtari911 // Sync Controls Section 4479ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">'; 4481d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Sync Controls</h3>'; 4491d05cddcSAtari911 4501d05cddcSAtari911 // Check cron job status 4511d05cddcSAtari911 $cronStatus = $this->getCronStatus(); 4521d05cddcSAtari911 4531d05cddcSAtari911 // Check log file permissions 4541d05cddcSAtari911 $logFile = DOKU_PLUGIN . 'calendar/sync.log'; 4551d05cddcSAtari911 $logWritable = is_writable($logFile) || is_writable(dirname($logFile)); 4561d05cddcSAtari911 4571d05cddcSAtari911 echo '<div style="display:flex; gap:10px; align-items:center; margin-bottom:10px;">'; 4581d05cddcSAtari911 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>'; 4591d05cddcSAtari911 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>'; 4601d05cddcSAtari911 4611d05cddcSAtari911 if ($cronStatus['active']) { 4629ccd446eSAtari911 echo '<span style="color:' . $colors['text'] . '; font-size:12px;">⏰ ' . hsc($cronStatus['frequency']) . '</span>'; 4631d05cddcSAtari911 } else { 4641d05cddcSAtari911 echo '<span style="color:#999; font-size:12px;">⚠️ No cron job detected</span>'; 4651d05cddcSAtari911 } 4661d05cddcSAtari911 4679ccd446eSAtari911 echo '<span id="syncStatus" style="color:' . $colors['text'] . '; font-size:12px; margin-left:auto;"></span>'; 4681d05cddcSAtari911 echo '</div>'; 4691d05cddcSAtari911 4701d05cddcSAtari911 // Show permission warning if log not writable 4711d05cddcSAtari911 if (!$logWritable) { 4721d05cddcSAtari911 echo '<div style="background:#fff3e0; border-left:3px solid #ff9800; padding:8px; margin:8px 0; border-radius:3px;">'; 4731d05cddcSAtari911 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>'; 4741d05cddcSAtari911 echo '</div>'; 4751d05cddcSAtari911 } 4761d05cddcSAtari911 4771d05cddcSAtari911 // Show debug info if cron detected 4781d05cddcSAtari911 if ($cronStatus['active'] && !empty($cronStatus['full_line'])) { 4791d05cddcSAtari911 echo '<details style="margin-top:5px;">'; 4801d05cddcSAtari911 echo '<summary style="cursor:pointer; color:#999; font-size:11px;">Show cron details</summary>'; 4811d05cddcSAtari911 echo '<pre style="background:#f0f0f0; padding:8px; border-radius:3px; font-size:10px; margin:5px 0; overflow-x:auto;">' . hsc($cronStatus['full_line']) . '</pre>'; 4821d05cddcSAtari911 echo '</details>'; 4831d05cddcSAtari911 } 4841d05cddcSAtari911 4851d05cddcSAtari911 if (!$cronStatus['active']) { 4861d05cddcSAtari911 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>'; 4871d05cddcSAtari911 } 4881d05cddcSAtari911 4891d05cddcSAtari911 echo '</div>'; 4901d05cddcSAtari911 4911d05cddcSAtari911 // JavaScript for Run Sync Now 4921d05cddcSAtari911 echo '<script> 4931d05cddcSAtari911 let syncAbortController = null; 4941d05cddcSAtari911 4951d05cddcSAtari911 function runSyncNow() { 4961d05cddcSAtari911 const btn = document.getElementById("syncBtn"); 4971d05cddcSAtari911 const stopBtn = document.getElementById("stopBtn"); 4981d05cddcSAtari911 const status = document.getElementById("syncStatus"); 4991d05cddcSAtari911 5001d05cddcSAtari911 btn.disabled = true; 5011d05cddcSAtari911 btn.style.display = "none"; 5021d05cddcSAtari911 stopBtn.style.display = "inline-block"; 5031d05cddcSAtari911 btn.textContent = "⏳ Running..."; 5041d05cddcSAtari911 btn.style.background = "#999"; 5051d05cddcSAtari911 status.textContent = "Starting sync..."; 5061d05cddcSAtari911 status.style.color = "#00cc07"; 5071d05cddcSAtari911 5081d05cddcSAtari911 // Create abort controller for this sync 5091d05cddcSAtari911 syncAbortController = new AbortController(); 5101d05cddcSAtari911 5111d05cddcSAtari911 fetch("?do=admin&page=calendar&action=run_sync&call=ajax", { 5121d05cddcSAtari911 method: "POST", 5131d05cddcSAtari911 signal: syncAbortController.signal 5141d05cddcSAtari911 }) 5151d05cddcSAtari911 .then(response => response.json()) 5161d05cddcSAtari911 .then(data => { 5171d05cddcSAtari911 if (data.success) { 5181d05cddcSAtari911 status.textContent = "✅ " + data.message; 5191d05cddcSAtari911 status.style.color = "#28a745"; 5201d05cddcSAtari911 } else { 5211d05cddcSAtari911 status.textContent = "❌ " + data.message; 5221d05cddcSAtari911 status.style.color = "#dc3545"; 5231d05cddcSAtari911 } 5241d05cddcSAtari911 btn.disabled = false; 5251d05cddcSAtari911 btn.style.display = "inline-block"; 5261d05cddcSAtari911 stopBtn.style.display = "none"; 5271d05cddcSAtari911 btn.textContent = "▶️ Run Sync Now"; 5281d05cddcSAtari911 btn.style.background = "#00cc07"; 5291d05cddcSAtari911 syncAbortController = null; 5301d05cddcSAtari911 5311d05cddcSAtari911 // Clear status after 10 seconds 5321d05cddcSAtari911 setTimeout(() => { 5331d05cddcSAtari911 status.textContent = ""; 5341d05cddcSAtari911 }, 10000); 5351d05cddcSAtari911 }) 5361d05cddcSAtari911 .catch(error => { 5371d05cddcSAtari911 if (error.name === "AbortError") { 5381d05cddcSAtari911 status.textContent = "⏹️ Sync stopped by user"; 5391d05cddcSAtari911 status.style.color = "#ff9800"; 5401d05cddcSAtari911 } else { 5411d05cddcSAtari911 status.textContent = "❌ Error: " + error.message; 5421d05cddcSAtari911 status.style.color = "#dc3545"; 5431d05cddcSAtari911 } 5441d05cddcSAtari911 btn.disabled = false; 5451d05cddcSAtari911 btn.style.display = "inline-block"; 5461d05cddcSAtari911 stopBtn.style.display = "none"; 5471d05cddcSAtari911 btn.textContent = "▶️ Run Sync Now"; 5481d05cddcSAtari911 btn.style.background = "#00cc07"; 5491d05cddcSAtari911 syncAbortController = null; 5501d05cddcSAtari911 }); 5511d05cddcSAtari911 } 5521d05cddcSAtari911 5531d05cddcSAtari911 function stopSyncNow() { 5541d05cddcSAtari911 const status = document.getElementById("syncStatus"); 5551d05cddcSAtari911 5561d05cddcSAtari911 status.textContent = "⏹️ Sending stop signal..."; 5571d05cddcSAtari911 status.style.color = "#ff9800"; 5581d05cddcSAtari911 5591d05cddcSAtari911 // First, send stop signal to server 5601d05cddcSAtari911 fetch("?do=admin&page=calendar&action=stop_sync&call=ajax", { 5611d05cddcSAtari911 method: "POST" 5621d05cddcSAtari911 }) 5631d05cddcSAtari911 .then(response => response.json()) 5641d05cddcSAtari911 .then(data => { 5651d05cddcSAtari911 if (data.success) { 5661d05cddcSAtari911 status.textContent = "⏹️ Stop signal sent - sync will abort soon"; 5671d05cddcSAtari911 status.style.color = "#ff9800"; 5681d05cddcSAtari911 } else { 5691d05cddcSAtari911 status.textContent = "⚠️ " + data.message; 5701d05cddcSAtari911 status.style.color = "#ff9800"; 5711d05cddcSAtari911 } 5721d05cddcSAtari911 }) 5731d05cddcSAtari911 .catch(error => { 5741d05cddcSAtari911 status.textContent = "⚠️ Error sending stop signal: " + error.message; 5751d05cddcSAtari911 status.style.color = "#ff9800"; 5761d05cddcSAtari911 }); 5771d05cddcSAtari911 5781d05cddcSAtari911 // Also abort the fetch request 5791d05cddcSAtari911 if (syncAbortController) { 5801d05cddcSAtari911 syncAbortController.abort(); 5811d05cddcSAtari911 status.textContent = "⏹️ Stopping sync..."; 5821d05cddcSAtari911 status.style.color = "#ff9800"; 5831d05cddcSAtari911 } 5841d05cddcSAtari911 } 5851d05cddcSAtari911 </script>'; 5861d05cddcSAtari911 5871d05cddcSAtari911 // Log Viewer Section - More Compact 5889ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:15px 0 10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">'; 5891d05cddcSAtari911 echo '<h3 style="margin:0 0 5px 0; color:#00cc07; font-size:16px;"> Live Sync Log</h3>'; 5909ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:0.8em; margin:0 0 8px;">Updates every 2 seconds</p>'; 5911d05cddcSAtari911 5921d05cddcSAtari911 // Log viewer container 5931d05cddcSAtari911 echo '<div style="background:#1e1e1e; border-radius:5px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.3);">'; 5941d05cddcSAtari911 5951d05cddcSAtari911 // Log header - More compact 5961d05cddcSAtari911 echo '<div style="background:#2d2d2d; padding:6px 10px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #444;">'; 5971d05cddcSAtari911 echo '<span style="color:#00cc07; font-family:monospace; font-weight:bold; font-size:12px;">sync.log</span>'; 5981d05cddcSAtari911 echo '<div>'; 5991d05cddcSAtari911 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>'; 6001d05cddcSAtari911 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>'; 6011d05cddcSAtari911 echo '<button onclick="downloadLog()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:3px; cursor:pointer; font-size:11px;"> Download</button>'; 6021d05cddcSAtari911 echo '</div>'; 6031d05cddcSAtari911 echo '</div>'; 6041d05cddcSAtari911 6051d05cddcSAtari911 // Log content - Reduced height to 250px 6061d05cddcSAtari911 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>'; 6071d05cddcSAtari911 6081d05cddcSAtari911 echo '</div>'; 6091d05cddcSAtari911 echo '</div>'; 6101d05cddcSAtari911 6111d05cddcSAtari911 // JavaScript for log viewer 6121d05cddcSAtari911 echo '<script> 6131d05cddcSAtari911 let refreshInterval = null; 6141d05cddcSAtari911 let isPaused = false; 6151d05cddcSAtari911 6161d05cddcSAtari911 function refreshLog() { 6171d05cddcSAtari911 if (isPaused) return; 6181d05cddcSAtari911 6191d05cddcSAtari911 fetch("?do=admin&page=calendar&action=get_log&call=ajax") 6201d05cddcSAtari911 .then(response => response.json()) 6211d05cddcSAtari911 .then(data => { 6221d05cddcSAtari911 const logContent = document.getElementById("logContent"); 6231d05cddcSAtari911 if (logContent) { 6241d05cddcSAtari911 logContent.textContent = data.log || "No log data available"; 6251d05cddcSAtari911 logContent.scrollTop = logContent.scrollHeight; 6261d05cddcSAtari911 } 6271d05cddcSAtari911 }) 6281d05cddcSAtari911 .catch(error => { 6291d05cddcSAtari911 console.error("Error fetching log:", error); 6301d05cddcSAtari911 }); 6311d05cddcSAtari911 } 6321d05cddcSAtari911 6331d05cddcSAtari911 function togglePause() { 6341d05cddcSAtari911 isPaused = !isPaused; 6351d05cddcSAtari911 const btn = document.getElementById("pauseBtn"); 6361d05cddcSAtari911 if (isPaused) { 6371d05cddcSAtari911 btn.textContent = "▶ Resume"; 6381d05cddcSAtari911 btn.style.background = "#00cc07"; 6391d05cddcSAtari911 } else { 6401d05cddcSAtari911 btn.textContent = "⏸ Pause"; 6411d05cddcSAtari911 btn.style.background = "#666"; 6421d05cddcSAtari911 refreshLog(); 6431d05cddcSAtari911 } 6441d05cddcSAtari911 } 6451d05cddcSAtari911 6461d05cddcSAtari911 function clearLog() { 6471d05cddcSAtari911 if (!confirm("Clear the sync log file?\\n\\nThis will delete all log entries.")) { 6481d05cddcSAtari911 return; 6491d05cddcSAtari911 } 6501d05cddcSAtari911 6511d05cddcSAtari911 fetch("?do=admin&page=calendar&action=clear_log&call=ajax", { 6521d05cddcSAtari911 method: "POST" 6531d05cddcSAtari911 }) 6541d05cddcSAtari911 .then(response => response.json()) 6551d05cddcSAtari911 .then(data => { 6561d05cddcSAtari911 if (data.success) { 6571d05cddcSAtari911 refreshLog(); 6581d05cddcSAtari911 alert("Log cleared successfully"); 6591d05cddcSAtari911 } else { 6601d05cddcSAtari911 alert("Error clearing log: " + data.message); 6611d05cddcSAtari911 } 6621d05cddcSAtari911 }) 6631d05cddcSAtari911 .catch(error => { 6641d05cddcSAtari911 alert("Error: " + error.message); 6651d05cddcSAtari911 }); 6661d05cddcSAtari911 } 6671d05cddcSAtari911 6681d05cddcSAtari911 function downloadLog() { 6691d05cddcSAtari911 window.location.href = "?do=admin&page=calendar&action=download_log"; 6701d05cddcSAtari911 } 6711d05cddcSAtari911 6721d05cddcSAtari911 // Start auto-refresh 6731d05cddcSAtari911 refreshLog(); 6741d05cddcSAtari911 refreshInterval = setInterval(refreshLog, 2000); 6751d05cddcSAtari911 6761d05cddcSAtari911 // Cleanup on page unload 6771d05cddcSAtari911 window.addEventListener("beforeunload", function() { 6781d05cddcSAtari911 if (refreshInterval) { 6791d05cddcSAtari911 clearInterval(refreshInterval); 6801d05cddcSAtari911 } 6811d05cddcSAtari911 }); 6821d05cddcSAtari911 </script>'; 6831d05cddcSAtari911 } 6841d05cddcSAtari911 6859ccd446eSAtari911 private function renderManageTab($colors = null) { 6861d05cddcSAtari911 global $INPUT; 6871d05cddcSAtari911 6889ccd446eSAtari911 // Use defaults if not provided 6899ccd446eSAtari911 if ($colors === null) { 6909ccd446eSAtari911 $colors = $this->getTemplateColors(); 6919ccd446eSAtari911 } 6929ccd446eSAtari911 6931d05cddcSAtari911 // Show message if present 6941d05cddcSAtari911 if ($INPUT->has('msg')) { 6951d05cddcSAtari911 $msg = hsc($INPUT->str('msg')); 6961d05cddcSAtari911 $type = $INPUT->str('msgtype', 'success'); 6971d05cddcSAtari911 echo "<div style=\"padding:10px; margin:10px 0; border-left:3px solid " . ($type === 'success' ? '#28a745' : '#dc3545') . "; background:" . ($type === 'success' ? '#d4edda' : '#f8d7da') . "; border-radius:3px;\">"; 6981d05cddcSAtari911 echo $msg; 6991d05cddcSAtari911 echo "</div>"; 7001d05cddcSAtari911 } 7011d05cddcSAtari911 7021d05cddcSAtari911 echo '<h2 style="margin:10px 0; font-size:20px;">Manage Calendar Events</h2>'; 7031d05cddcSAtari911 7049ccd446eSAtari911 // Events Manager Section 7059ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 7061d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Events Manager</h3>'; 7079ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 10px;">Scan, export, and import all calendar events across all namespaces.</p>'; 7081d05cddcSAtari911 7091d05cddcSAtari911 // Get event statistics 7101d05cddcSAtari911 $stats = $this->getEventStatistics(); 7111d05cddcSAtari911 7121d05cddcSAtari911 // Statistics display 7139ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:10px; border-radius:3px; margin-bottom:10px; border:1px solid ' . $colors['border'] . ';">'; 7141d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:10px; font-size:12px;">'; 7151d05cddcSAtari911 7161d05cddcSAtari911 echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">'; 7171d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_events'] . '</div>'; 7189ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Total Events</div>'; 7191d05cddcSAtari911 echo '</div>'; 7201d05cddcSAtari911 7211d05cddcSAtari911 echo '<div style="background:#f3e5f5; padding:8px; border-radius:3px; text-align:center;">'; 7221d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#7b1fa2;">' . $stats['total_namespaces'] . '</div>'; 7239ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Namespaces</div>'; 7241d05cddcSAtari911 echo '</div>'; 7251d05cddcSAtari911 7261d05cddcSAtari911 echo '<div style="background:#e8f5e9; padding:8px; border-radius:3px; text-align:center;">'; 7271d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#388e3c;">' . $stats['total_files'] . '</div>'; 7289ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">JSON Files</div>'; 7291d05cddcSAtari911 echo '</div>'; 7301d05cddcSAtari911 7311d05cddcSAtari911 echo '<div style="background:#fff3e0; padding:8px; border-radius:3px; text-align:center;">'; 7321d05cddcSAtari911 echo '<div style="font-size:24px; font-weight:bold; color:#f57c00;">' . $stats['total_recurring'] . '</div>'; 7339ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; font-size:10px;">Recurring</div>'; 7341d05cddcSAtari911 echo '</div>'; 7351d05cddcSAtari911 7361d05cddcSAtari911 echo '</div>'; 7371d05cddcSAtari911 7381d05cddcSAtari911 // Last scan time 7391d05cddcSAtari911 if (!empty($stats['last_scan'])) { 7409ccd446eSAtari911 echo '<div style="margin-top:8px; color:' . $colors['text'] . '; font-size:10px;">Last scanned: ' . hsc($stats['last_scan']) . '</div>'; 7411d05cddcSAtari911 } 7421d05cddcSAtari911 7431d05cddcSAtari911 echo '</div>'; 7441d05cddcSAtari911 7451d05cddcSAtari911 // Action buttons 7461d05cddcSAtari911 echo '<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:10px;">'; 7471d05cddcSAtari911 7481d05cddcSAtari911 // Rescan button 7491d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">'; 7501d05cddcSAtari911 echo '<input type="hidden" name="action" value="rescan_events">'; 7511d05cddcSAtari911 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;">'; 7521d05cddcSAtari911 echo '<span></span><span>Re-scan Events</span>'; 7531d05cddcSAtari911 echo '</button>'; 7541d05cddcSAtari911 echo '</form>'; 7551d05cddcSAtari911 7561d05cddcSAtari911 // Export button 7571d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:inline;">'; 7581d05cddcSAtari911 echo '<input type="hidden" name="action" value="export_all_events">'; 7591d05cddcSAtari911 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;">'; 7601d05cddcSAtari911 echo '<span></span><span>Export All Events</span>'; 7611d05cddcSAtari911 echo '</button>'; 7621d05cddcSAtari911 echo '</form>'; 7631d05cddcSAtari911 7641d05cddcSAtari911 // Import button (with file upload) 7651d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" enctype="multipart/form-data" style="display:inline;" onsubmit="return confirm(\'Import will merge with existing events. Continue?\')">'; 7661d05cddcSAtari911 echo '<input type="hidden" name="action" value="import_all_events">'; 7671d05cddcSAtari911 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;">'; 7681d05cddcSAtari911 echo '<span></span><span>Import Events</span>'; 7691d05cddcSAtari911 echo '<input type="file" name="import_file" accept=".json,.zip" required style="display:none;" onchange="this.form.submit()">'; 7701d05cddcSAtari911 echo '</label>'; 7711d05cddcSAtari911 echo '</form>'; 7721d05cddcSAtari911 7731d05cddcSAtari911 echo '</div>'; 7741d05cddcSAtari911 7751d05cddcSAtari911 // Breakdown by namespace 7761d05cddcSAtari911 if (!empty($stats['by_namespace'])) { 7771d05cddcSAtari911 echo '<details style="margin-top:12px;">'; 7781d05cddcSAtari911 echo '<summary style="cursor:pointer; padding:6px; background:#e9e9e9; border-radius:3px; font-size:11px; font-weight:bold;">View Breakdown by Namespace</summary>'; 7799ccd446eSAtari911 echo '<div style="margin-top:8px; max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">'; 7801d05cddcSAtari911 echo '<table style="width:100%; border-collapse:collapse; font-size:11px;">'; 7811d05cddcSAtari911 echo '<thead style="position:sticky; top:0; background:#f5f5f5;">'; 7821d05cddcSAtari911 echo '<tr>'; 7831d05cddcSAtari911 echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">Namespace</th>'; 7841d05cddcSAtari911 echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">Events</th>'; 7851d05cddcSAtari911 echo '<th style="padding:4px 6px; text-align:right; border-bottom:2px solid #ddd;">Files</th>'; 7861d05cddcSAtari911 echo '</tr></thead><tbody>'; 7871d05cddcSAtari911 7881d05cddcSAtari911 foreach ($stats['by_namespace'] as $ns => $nsStats) { 7891d05cddcSAtari911 echo '<tr style="border-bottom:1px solid #eee;">'; 7901d05cddcSAtari911 echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($ns ?: '(default)') . '</code></td>'; 7911d05cddcSAtari911 echo '<td style="padding:4px 6px; text-align:right;"><strong>' . $nsStats['events'] . '</strong></td>'; 7921d05cddcSAtari911 echo '<td style="padding:4px 6px; text-align:right;">' . $nsStats['files'] . '</td>'; 7931d05cddcSAtari911 echo '</tr>'; 7941d05cddcSAtari911 } 7951d05cddcSAtari911 7961d05cddcSAtari911 echo '</tbody></table>'; 7971d05cddcSAtari911 echo '</div>'; 7981d05cddcSAtari911 echo '</details>'; 7991d05cddcSAtari911 } 8001d05cddcSAtari911 8011d05cddcSAtari911 echo '</div>'; 8021d05cddcSAtari911 8034590242dSAtari911 // Important Namespaces Section 8044590242dSAtari911 $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; 8054590242dSAtari911 $importantConfig = []; 8064590242dSAtari911 if (file_exists($configFile)) { 8074590242dSAtari911 $importantConfig = include $configFile; 8084590242dSAtari911 } 8094590242dSAtari911 $importantNsValue = isset($importantConfig['important_namespaces']) ? $importantConfig['important_namespaces'] : 'important'; 8104590242dSAtari911 8114590242dSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 8124590242dSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Important Namespaces (Sidebar Widget)</h3>'; 8134590242dSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">Events from these namespaces will be highlighted in purple in the sidebar widget\'s "Important Events" section.</p>'; 8144590242dSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" style="display:flex; gap:8px; align-items:center;">'; 8154590242dSAtari911 echo '<input type="hidden" name="action" value="save_important_namespaces">'; 8164590242dSAtari911 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">'; 8174590242dSAtari911 echo '<button type="submit" style="background:#00cc07; color:white; padding:6px 16px; border:none; border-radius:3px; cursor:pointer; font-size:12px; font-weight:bold; white-space:nowrap;">Save</button>'; 8184590242dSAtari911 echo '</form>'; 8194590242dSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:4px 0 0;">Comma-separated list of namespace names</p>'; 8204590242dSAtari911 echo '</div>'; 8214590242dSAtari911 8229ccd446eSAtari911 // Cleanup Events Section 8239ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 8249ccd446eSAtari911 echo '<h3 style="margin:0 0 6px 0; color:#00cc07; font-size:16px;"> Cleanup Old Events</h3>'; 8259ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 12px;">Delete events based on criteria below. Automatic backup created before deletion.</p>'; 8261d05cddcSAtari911 8271d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="cleanupForm">'; 8281d05cddcSAtari911 echo '<input type="hidden" name="action" value="cleanup_events">'; 8291d05cddcSAtari911 8301d05cddcSAtari911 // Compact options layout 8319ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; margin-bottom:10px;">'; 8321d05cddcSAtari911 8331d05cddcSAtari911 // Radio buttons in a row 8341d05cddcSAtari911 echo '<div style="display:flex; gap:20px; margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid #f0f0f0;">'; 8351d05cddcSAtari911 echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">'; 8361d05cddcSAtari911 echo '<input type="radio" name="cleanup_type" value="age" checked onchange="updateCleanupOptions()">'; 8371d05cddcSAtari911 echo '<span>By Age</span>'; 8381d05cddcSAtari911 echo '</label>'; 8391d05cddcSAtari911 echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">'; 8401d05cddcSAtari911 echo '<input type="radio" name="cleanup_type" value="status" onchange="updateCleanupOptions()">'; 8411d05cddcSAtari911 echo '<span>By Status</span>'; 8421d05cddcSAtari911 echo '</label>'; 8431d05cddcSAtari911 echo '<label style="cursor:pointer; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px;">'; 8441d05cddcSAtari911 echo '<input type="radio" name="cleanup_type" value="range" onchange="updateCleanupOptions()">'; 8451d05cddcSAtari911 echo '<span>By Date Range</span>'; 8461d05cddcSAtari911 echo '</label>'; 8471d05cddcSAtari911 echo '</div>'; 8481d05cddcSAtari911 8491d05cddcSAtari911 // Age options 8501d05cddcSAtari911 echo '<div id="age-options" style="padding:6px 0;">'; 8519ccd446eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">Delete events older than:</span>'; 8521d05cddcSAtari911 echo '<select name="age_value" style="width:50px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:4px;">'; 8531d05cddcSAtari911 for ($i = 1; $i <= 24; $i++) { 8541d05cddcSAtari911 $sel = $i === 6 ? ' selected' : ''; 8551d05cddcSAtari911 echo '<option value="' . $i . '"' . $sel . '>' . $i . '</option>'; 8561d05cddcSAtari911 } 8571d05cddcSAtari911 echo '</select>'; 8581d05cddcSAtari911 echo '<select name="age_unit" style="width:80px; padding:3px 4px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">'; 8591d05cddcSAtari911 echo '<option value="months" selected>months</option>'; 8601d05cddcSAtari911 echo '<option value="years">years</option>'; 8611d05cddcSAtari911 echo '</select>'; 8621d05cddcSAtari911 echo '</div>'; 8631d05cddcSAtari911 8641d05cddcSAtari911 // Status options 8651d05cddcSAtari911 echo '<div id="status-options" style="padding:6px 0; opacity:0.4;">'; 8669ccd446eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">Delete:</span>'; 8671d05cddcSAtari911 echo '<label style="display:inline-block; font-size:11px; margin-right:12px; cursor:pointer;"><input type="checkbox" name="delete_completed" value="1" style="margin-right:3px;"> Completed tasks</label>'; 8681d05cddcSAtari911 echo '<label style="display:inline-block; font-size:11px; cursor:pointer;"><input type="checkbox" name="delete_past" value="1" style="margin-right:3px;"> Past events</label>'; 8691d05cddcSAtari911 echo '</div>'; 8701d05cddcSAtari911 8711d05cddcSAtari911 // Range options 8721d05cddcSAtari911 echo '<div id="range-options" style="padding:6px 0; opacity:0.4;">'; 8739ccd446eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">From:</span>'; 8741d05cddcSAtari911 echo '<input type="date" name="range_start" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px; margin-right:10px;">'; 8759ccd446eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; margin-right:8px;">To:</span>'; 8761d05cddcSAtari911 echo '<input type="date" name="range_end" style="padding:3px 6px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">'; 8771d05cddcSAtari911 echo '</div>'; 8781d05cddcSAtari911 8791d05cddcSAtari911 echo '</div>'; 8801d05cddcSAtari911 8811d05cddcSAtari911 // Namespace filter - compact 8829ccd446eSAtari911 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;">'; 8831d05cddcSAtari911 echo '<label style="font-size:11px; font-weight:600; white-space:nowrap; color:#555;">Namespace:</label>'; 8841d05cddcSAtari911 echo '<input type="text" name="namespace_filter" placeholder="Leave empty for all, or specify: work, personal, etc." style="flex:1; padding:4px 8px; font-size:11px; border:1px solid #d0d0d0; border-radius:3px;">'; 8851d05cddcSAtari911 echo '</div>'; 8861d05cddcSAtari911 8871d05cddcSAtari911 // Action buttons - compact row 8881d05cddcSAtari911 echo '<div style="display:flex; gap:8px; align-items:center;">'; 8891d05cddcSAtari911 echo '<button type="button" onclick="previewCleanup()" style="background:#7b1fa2; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">️ Preview</button>'; 8901d05cddcSAtari911 echo '<button type="submit" onclick="return confirmCleanup()" style="background:#dc3545; color:white; border:none; padding:6px 14px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;">️ Delete</button>'; 8911d05cddcSAtari911 echo '<span style="font-size:10px; color:#999;">⚠️ Backup created automatically</span>'; 8921d05cddcSAtari911 echo '</div>'; 8931d05cddcSAtari911 8941d05cddcSAtari911 echo '</form>'; 8951d05cddcSAtari911 8961d05cddcSAtari911 // Preview results area 8971d05cddcSAtari911 echo '<div id="cleanup-preview" style="margin-top:10px; display:none;"></div>'; 8981d05cddcSAtari911 8991d05cddcSAtari911 echo '<script> 9001d05cddcSAtari911 function updateCleanupOptions() { 9011d05cddcSAtari911 const type = document.querySelector(\'input[name="cleanup_type"]:checked\').value; 9021d05cddcSAtari911 9031d05cddcSAtari911 // Show selected, gray out others 9041d05cddcSAtari911 document.getElementById(\'age-options\').style.opacity = type === \'age\' ? \'1\' : \'0.4\'; 9051d05cddcSAtari911 document.getElementById(\'status-options\').style.opacity = type === \'status\' ? \'1\' : \'0.4\'; 9061d05cddcSAtari911 document.getElementById(\'range-options\').style.opacity = type === \'range\' ? \'1\' : \'0.4\'; 9071d05cddcSAtari911 9081d05cddcSAtari911 // Enable/disable inputs 9091d05cddcSAtari911 document.querySelectorAll(\'#age-options select\').forEach(el => el.disabled = type !== \'age\'); 9101d05cddcSAtari911 document.querySelectorAll(\'#status-options input\').forEach(el => el.disabled = type !== \'status\'); 9111d05cddcSAtari911 document.querySelectorAll(\'#range-options input\').forEach(el => el.disabled = type !== \'range\'); 9121d05cddcSAtari911 } 9131d05cddcSAtari911 9141d05cddcSAtari911 function previewCleanup() { 9151d05cddcSAtari911 const form = document.getElementById(\'cleanupForm\'); 9161d05cddcSAtari911 const formData = new FormData(form); 9171d05cddcSAtari911 formData.set(\'action\', \'preview_cleanup\'); 9181d05cddcSAtari911 9191d05cddcSAtari911 const preview = document.getElementById(\'cleanup-preview\'); 9209ccd446eSAtari911 preview.innerHTML = \'<div style="text-align:center; padding:20px; color:' . $colors['text'] . ';">Loading preview...</div>\'; 9211d05cddcSAtari911 preview.style.display = \'block\'; 9221d05cddcSAtari911 9231d05cddcSAtari911 fetch(\'?do=admin&page=calendar&tab=manage\', { 9241d05cddcSAtari911 method: \'POST\', 9251d05cddcSAtari911 body: new URLSearchParams(formData) 9261d05cddcSAtari911 }) 9271d05cddcSAtari911 .then(r => r.json()) 9281d05cddcSAtari911 .then(data => { 9291d05cddcSAtari911 if (data.count === 0) { 9301d05cddcSAtari911 let html = \'<div style="background:#d4edda; border:1px solid #c3e6cb; padding:10px; border-radius:3px; font-size:12px; color:#155724;">✅ No events match the criteria. Nothing would be deleted.</div>\'; 9311d05cddcSAtari911 9321d05cddcSAtari911 // Show debug info if available 9331d05cddcSAtari911 if (data.debug) { 9349ccd446eSAtari911 html += \'<details style="margin-top:8px; font-size:11px; color:' . $colors['text'] . ';">\'; 9351d05cddcSAtari911 html += \'<summary style="cursor:pointer;">Debug Info</summary>\'; 9361d05cddcSAtari911 html += \'<pre style="background:#f5f5f5; padding:6px; margin-top:4px; border-radius:3px; overflow-x:auto;">\' + JSON.stringify(data.debug, null, 2) + \'</pre>\'; 9371d05cddcSAtari911 html += \'</details>\'; 9381d05cddcSAtari911 } 9391d05cddcSAtari911 9401d05cddcSAtari911 preview.innerHTML = html; 9411d05cddcSAtari911 } else { 9421d05cddcSAtari911 let html = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">\'; 9431d05cddcSAtari911 html += \'<strong>⚠️ Warning:</strong> The following \' + data.count + \' event(s) would be deleted:<br><br>\'; 9449ccd446eSAtari911 html += \'<div style="max-height:150px; overflow-y:auto; margin-top:6px; background:' . $colors['bg'] . '; padding:6px; border-radius:3px;">\'; 9451d05cddcSAtari911 data.events.forEach(evt => { 9461d05cddcSAtari911 html += \'<div style="padding:3px; border-bottom:1px solid #eee; font-size:11px;">\'; 9471d05cddcSAtari911 html += \'• \' + evt.title + \' (\' + evt.date + \')\'; 9481d05cddcSAtari911 if (evt.namespace) html += \' <span style="background:#e3f2fd; padding:1px 4px; border-radius:2px; font-size:9px;">\' + evt.namespace + \'</span>\'; 9491d05cddcSAtari911 html += \'</div>\'; 9501d05cddcSAtari911 }); 9511d05cddcSAtari911 html += \'</div></div>\'; 9521d05cddcSAtari911 preview.innerHTML = html; 9531d05cddcSAtari911 } 9541d05cddcSAtari911 }) 9551d05cddcSAtari911 .catch(err => { 9561d05cddcSAtari911 preview.innerHTML = \'<div style="background:#f8d7da; border:1px solid #f5c6cb; padding:10px; border-radius:3px; font-size:12px; color:#721c24;">Error loading preview</div>\'; 9571d05cddcSAtari911 }); 9581d05cddcSAtari911 } 9591d05cddcSAtari911 9601d05cddcSAtari911 function confirmCleanup() { 9611d05cddcSAtari911 return confirm(\'Are you sure you want to delete these events? A backup will be created first, but this action cannot be easily undone.\'); 9621d05cddcSAtari911 } 9631d05cddcSAtari911 9641d05cddcSAtari911 updateCleanupOptions(); 9651d05cddcSAtari911 </script>'; 9661d05cddcSAtari911 9671d05cddcSAtari911 echo '</div>'; 9681d05cddcSAtari911 9691d05cddcSAtari911 // Recurring Events Section 970*7e8ea635SAtari911 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;">'; 971*7e8ea635SAtari911 echo '<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">'; 972*7e8ea635SAtari911 echo '<h3 style="margin:0; color:#00cc07; font-size:16px;"> Recurring Events</h3>'; 973*7e8ea635SAtari911 echo '<div style="display:flex; gap:6px;">'; 974*7e8ea635SAtari911 echo '<button onclick="trimAllPastRecurring()" id="trim-all-past-btn" style="background:#e74c3c; color:#fff; border:none; padding:4px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600; transition:all 0.15s;" onmouseover="this.style.filter=\'brightness(1.2)\'" onmouseout="this.style.filter=\'none\'">✂️ Trim All Past</button>'; 975*7e8ea635SAtari911 echo '<button onclick="rescanRecurringEvents()" id="rescan-recurring-btn" style="background:#00cc07; color:#fff; border:none; padding:4px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600; transition:all 0.15s;" onmouseover="this.style.filter=\'brightness(1.2)\'" onmouseout="this.style.filter=\'none\'"> Rescan</button>'; 976*7e8ea635SAtari911 echo '</div>'; 977*7e8ea635SAtari911 echo '</div>'; 9781d05cddcSAtari911 9791d05cddcSAtari911 $recurringEvents = $this->findRecurringEvents(); 9801d05cddcSAtari911 981*7e8ea635SAtari911 echo '<div id="recurring-content">'; 982*7e8ea635SAtari911 $this->renderRecurringTable($recurringEvents, $colors); 9831d05cddcSAtari911 echo '</div>'; 9841d05cddcSAtari911 echo '</div>'; 9851d05cddcSAtari911 9861d05cddcSAtari911 // Compact Tree-based Namespace Manager 9879ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 9881d05cddcSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Namespace Explorer</h3>'; 9899ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:11px; margin:0 0 8px;">Select events and move between namespaces. Drag & drop also supported.</p>'; 9901d05cddcSAtari911 9911d05cddcSAtari911 // Search bar 9921d05cddcSAtari911 echo '<div style="margin-bottom:8px;">'; 9939ccd446eSAtari911 echo '<input type="text" id="searchEvents" onkeyup="filterEvents()" placeholder=" Search events by title..." style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">'; 9941d05cddcSAtari911 echo '</div>'; 9951d05cddcSAtari911 9961d05cddcSAtari911 $eventsByNamespace = $this->getEventsByNamespace(); 9971d05cddcSAtari911 9981d05cddcSAtari911 // Control bar 9991d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=manage" id="moveForm">'; 10001d05cddcSAtari911 echo '<input type="hidden" name="action" value="move_selected_events" id="formAction">'; 10011d05cddcSAtari911 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;">'; 10021d05cddcSAtari911 echo '<button type="button" onclick="selectAll()" style="background:#00cc07; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☑ All</button>'; 10031d05cddcSAtari911 echo '<button type="button" onclick="deselectAll()" style="background:#666; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px;">☐ None</button>'; 10041d05cddcSAtari911 echo '<button type="button" onclick="deleteSelected()" style="background:#e74c3c; color:white; border:none; padding:4px 8px; border-radius:2px; cursor:pointer; font-size:11px; margin-left:10px;">️ Delete</button>'; 10051d05cddcSAtari911 echo '<span style="margin-left:10px;">Move to:</span>'; 10069ccd446eSAtari911 echo '<input list="namespaceList" name="target_namespace" required style="padding:3px 6px; border:1px solid ' . $colors['border'] . '; border-radius:2px; font-size:11px; min-width:150px;" placeholder="Type or select...">'; 10071d05cddcSAtari911 echo '<datalist id="namespaceList">'; 10081d05cddcSAtari911 echo '<option value="">(default)</option>'; 10091d05cddcSAtari911 foreach (array_keys($eventsByNamespace) as $ns) { 10101d05cddcSAtari911 if ($ns !== '') { 10111d05cddcSAtari911 echo '<option value="' . hsc($ns) . '">' . hsc($ns) . '</option>'; 10121d05cddcSAtari911 } 10131d05cddcSAtari911 } 10141d05cddcSAtari911 echo '</datalist>'; 10151d05cddcSAtari911 echo '<button type="submit" style="background:#00cc07; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold;">➡️ Move</button>'; 10161d05cddcSAtari911 echo '<button type="button" onclick="createNewNamespace()" style="background:#7b1fa2; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold; margin-left:5px;">➕ New Namespace</button>'; 1017*7e8ea635SAtari911 echo '<button type="button" onclick="cleanupEmptyNamespaces()" id="cleanup-ns-btn" style="background:#e74c3c; color:white; border:none; padding:4px 10px; border-radius:2px; cursor:pointer; font-size:11px; font-weight:bold; margin-left:5px;"> Cleanup</button>'; 10181d05cddcSAtari911 echo '<span id="selectedCount" style="margin-left:auto; color:#00cc07; font-size:11px;">0 selected</span>'; 10191d05cddcSAtari911 echo '</div>'; 10201d05cddcSAtari911 1021*7e8ea635SAtari911 // Cleanup status message - displayed prominently after control bar 1022*7e8ea635SAtari911 echo '<div id="cleanup-ns-status" style="font-size:12px; margin-bottom:8px; min-height:18px;"></div>'; 1023*7e8ea635SAtari911 10241d05cddcSAtari911 echo '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">'; 10251d05cddcSAtari911 10261d05cddcSAtari911 // Event list with checkboxes 10271d05cddcSAtari911 echo '<div>'; 10289ccd446eSAtari911 echo '<div style="max-height:450px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">'; 10291d05cddcSAtari911 10301d05cddcSAtari911 foreach ($eventsByNamespace as $namespace => $data) { 10311d05cddcSAtari911 $nsId = 'ns_' . md5($namespace); 10321d05cddcSAtari911 $eventCount = count($data['events']); 10331d05cddcSAtari911 10341d05cddcSAtari911 echo '<div style="border-bottom:1px solid #ddd;">'; 10351d05cddcSAtari911 10361d05cddcSAtari911 // Namespace header - ultra compact 10371d05cddcSAtari911 echo '<div style="background:#f5f5f5; padding:3px 6px; display:flex; justify-content:space-between; align-items:center; font-size:11px;">'; 10381d05cddcSAtari911 echo '<div style="display:flex; align-items:center; gap:4px;">'; 10391d05cddcSAtari911 echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; width:12px; display:inline-block; font-size:10px;"><span id="' . $nsId . '_arrow">▶</span></span>'; 10401d05cddcSAtari911 echo '<input type="checkbox" onclick="toggleNamespaceSelect(\'' . $nsId . '\')" id="' . $nsId . '_check" style="margin:0; width:12px; height:12px;">'; 10411d05cddcSAtari911 echo '<span onclick="toggleNamespace(\'' . $nsId . '\')" style="cursor:pointer; font-weight:600; font-size:11px;"> ' . hsc($namespace ?: '(default)') . '</span>'; 10421d05cddcSAtari911 echo '</div>'; 10431d05cddcSAtari911 echo '<div style="display:flex; gap:3px; align-items:center;">'; 10441d05cddcSAtari911 echo '<span style="background:#00cc07; color:white; padding:0px 4px; border-radius:6px; font-size:9px; line-height:14px;">' . $eventCount . '</span>'; 10459ccd446eSAtari911 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>'; 10461d05cddcSAtari911 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>'; 10471d05cddcSAtari911 echo '</div>'; 10481d05cddcSAtari911 echo '</div>'; 10491d05cddcSAtari911 10501d05cddcSAtari911 // Events - ultra compact 10511d05cddcSAtari911 echo '<div id="' . $nsId . '" style="display:none; max-height:150px; overflow-y:auto;">'; 10521d05cddcSAtari911 foreach ($data['events'] as $event) { 10531d05cddcSAtari911 $eventId = $event['id'] . '|' . $namespace . '|' . $event['date'] . '|' . $event['month']; 10541d05cddcSAtari911 $checkId = 'evt_' . md5($eventId); 10551d05cddcSAtari911 10561d05cddcSAtari911 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\'">'; 10571d05cddcSAtari911 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;">'; 10581d05cddcSAtari911 echo '<div style="flex:1; min-width:0;">'; 10591d05cddcSAtari911 echo '<div style="font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:10px;">' . hsc($event['title']) . '</div>'; 10601d05cddcSAtari911 echo '<div style="color:#999; font-size:9px;">' . hsc($event['date']) . ($event['startTime'] ? ' • ' . hsc($event['startTime']) : '') . '</div>'; 10611d05cddcSAtari911 echo '</div>'; 10621d05cddcSAtari911 echo '</div>'; 10631d05cddcSAtari911 } 10641d05cddcSAtari911 echo '</div>'; 10651d05cddcSAtari911 echo '</div>'; 10661d05cddcSAtari911 } 10671d05cddcSAtari911 10681d05cddcSAtari911 echo '</div>'; 10691d05cddcSAtari911 echo '</div>'; 10701d05cddcSAtari911 10711d05cddcSAtari911 // Drop zones - ultra compact 10721d05cddcSAtari911 echo '<div>'; 10731d05cddcSAtari911 echo '<div style="background:#00cc07; color:white; padding:3px 6px; border-radius:3px 3px 0 0; font-size:11px; font-weight:bold;"> Drop Target</div>'; 10749ccd446eSAtari911 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'] . ';">'; 10751d05cddcSAtari911 10761d05cddcSAtari911 foreach (array_keys($eventsByNamespace) as $namespace) { 10779ccd446eSAtari911 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\'">'; 10781d05cddcSAtari911 echo '<div style="font-size:11px; font-weight:600; color:#00cc07;"> ' . hsc($namespace ?: '(default)') . '</div>'; 10791d05cddcSAtari911 echo '<div style="color:#999; font-size:9px; margin-top:1px;">Drop here</div>'; 10801d05cddcSAtari911 echo '</div>'; 10811d05cddcSAtari911 } 10821d05cddcSAtari911 10831d05cddcSAtari911 echo '</div>'; 10841d05cddcSAtari911 echo '</div>'; 10851d05cddcSAtari911 10861d05cddcSAtari911 echo '</div>'; // end grid 10871d05cddcSAtari911 echo '</form>'; 10881d05cddcSAtari911 10891d05cddcSAtari911 echo '</div>'; 10901d05cddcSAtari911 10911d05cddcSAtari911 // JavaScript 10921d05cddcSAtari911 echo '<script> 1093*7e8ea635SAtari911 var adminColors = { 1094*7e8ea635SAtari911 text: "' . $colors['text'] . '", 1095*7e8ea635SAtari911 bg: "' . $colors['bg'] . '", 1096*7e8ea635SAtari911 border: "' . $colors['border'] . '" 1097*7e8ea635SAtari911 }; 10981d05cddcSAtari911 // Table sorting functionality - defined early so onclick handlers work 10991d05cddcSAtari911 let sortDirection = {}; // Track sort direction for each column 11001d05cddcSAtari911 1101*7e8ea635SAtari911 function cleanupEmptyNamespaces() { 1102*7e8ea635SAtari911 var btn = document.getElementById("cleanup-ns-btn"); 1103*7e8ea635SAtari911 var status = document.getElementById("cleanup-ns-status"); 1104*7e8ea635SAtari911 if (btn) { btn.textContent = "⏳ Scanning..."; btn.disabled = true; } 1105*7e8ea635SAtari911 if (status) { status.innerHTML = ""; } 1106*7e8ea635SAtari911 1107*7e8ea635SAtari911 // Dry run first 1108*7e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 1109*7e8ea635SAtari911 method: "POST", 1110*7e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 1111*7e8ea635SAtari911 body: "call=plugin_calendar&action=cleanup_empty_namespaces&dry_run=1§ok=" + JSINFO.sectok 1112*7e8ea635SAtari911 }) 1113*7e8ea635SAtari911 .then(function(r) { return r.json(); }) 1114*7e8ea635SAtari911 .then(function(data) { 1115*7e8ea635SAtari911 if (btn) { btn.textContent = " Cleanup"; btn.disabled = false; } 1116*7e8ea635SAtari911 if (!data.success) { 1117*7e8ea635SAtari911 if (status) { status.innerHTML = "<span style=\\\'color:#e74c3c;\\\'>❌ " + (data.error || "Failed") + "</span>"; } 1118*7e8ea635SAtari911 return; 1119*7e8ea635SAtari911 } 1120*7e8ea635SAtari911 1121*7e8ea635SAtari911 var details = data.details || []; 1122*7e8ea635SAtari911 var totalActions = details.length; 1123*7e8ea635SAtari911 1124*7e8ea635SAtari911 if (totalActions === 0) { 1125*7e8ea635SAtari911 if (status) { status.innerHTML = "<span style=\\\'color:#00cc07;\\\'>✅ No empty namespaces or orphan calendar folders found.</span>"; } 1126*7e8ea635SAtari911 return; 1127*7e8ea635SAtari911 } 1128*7e8ea635SAtari911 1129*7e8ea635SAtari911 // Build detail list for confirm 1130*7e8ea635SAtari911 var msg = "Found " + totalActions + " item(s) to clean up:\\n\\n"; 1131*7e8ea635SAtari911 for (var i = 0; i < details.length; i++) { 1132*7e8ea635SAtari911 msg += "• " + details[i] + "\\n"; 1133*7e8ea635SAtari911 } 1134*7e8ea635SAtari911 msg += "\\nProceed with cleanup?"; 1135*7e8ea635SAtari911 1136*7e8ea635SAtari911 if (!confirm(msg)) return; 1137*7e8ea635SAtari911 1138*7e8ea635SAtari911 // Execute 1139*7e8ea635SAtari911 if (btn) { btn.textContent = "⏳ Cleaning..."; btn.disabled = true; } 1140*7e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 1141*7e8ea635SAtari911 method: "POST", 1142*7e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 1143*7e8ea635SAtari911 body: "call=plugin_calendar&action=cleanup_empty_namespaces§ok=" + JSINFO.sectok 1144*7e8ea635SAtari911 }) 1145*7e8ea635SAtari911 .then(function(r) { return r.json(); }) 1146*7e8ea635SAtari911 .then(function(data2) { 1147*7e8ea635SAtari911 var msgText = data2.message || "Cleanup complete"; 1148*7e8ea635SAtari911 if (data2.details && data2.details.length > 0) { 1149*7e8ea635SAtari911 msgText += " (" + data2.details.join(", ") + ")"; 1150*7e8ea635SAtari911 } 1151*7e8ea635SAtari911 window.location.href = "?do=admin&page=calendar&tab=manage&msg=" + encodeURIComponent(msgText) + "&msgtype=success"; 1152*7e8ea635SAtari911 }); 1153*7e8ea635SAtari911 }) 1154*7e8ea635SAtari911 .catch(function(err) { 1155*7e8ea635SAtari911 if (btn) { btn.textContent = " Cleanup"; btn.disabled = false; } 1156*7e8ea635SAtari911 if (status) { status.innerHTML = "<span style=\\\'color:#e74c3c;\\\'>❌ Error: " + err + "</span>"; } 1157*7e8ea635SAtari911 }); 1158*7e8ea635SAtari911 } 1159*7e8ea635SAtari911 function trimAllPastRecurring() { 1160*7e8ea635SAtari911 var btn = document.getElementById("trim-all-past-btn"); 1161*7e8ea635SAtari911 if (btn) { btn.textContent = "⏳ Counting..."; btn.disabled = true; } 1162*7e8ea635SAtari911 1163*7e8ea635SAtari911 // Step 1: dry run to get count 1164*7e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 1165*7e8ea635SAtari911 method: "POST", 1166*7e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 1167*7e8ea635SAtari911 body: "call=plugin_calendar&action=trim_all_past_recurring&dry_run=1§ok=" + JSINFO.sectok 1168*7e8ea635SAtari911 }) 1169*7e8ea635SAtari911 .then(function(r) { return r.json(); }) 1170*7e8ea635SAtari911 .then(function(data) { 1171*7e8ea635SAtari911 if (btn) { btn.textContent = "✂️ Trim All Past"; btn.disabled = false; } 1172*7e8ea635SAtari911 var count = data.count || 0; 1173*7e8ea635SAtari911 if (count === 0) { 1174*7e8ea635SAtari911 alert("No past recurring events found to remove."); 1175*7e8ea635SAtari911 return; 1176*7e8ea635SAtari911 } 1177*7e8ea635SAtari911 if (!confirm("Found " + count + " past recurring event" + (count !== 1 ? "s" : "") + " to remove.\n\nThis cannot be undone. Proceed?")) return; 1178*7e8ea635SAtari911 1179*7e8ea635SAtari911 // Step 2: actually delete 1180*7e8ea635SAtari911 if (btn) { btn.textContent = "⏳ Trimming..."; btn.disabled = true; } 1181*7e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 1182*7e8ea635SAtari911 method: "POST", 1183*7e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 1184*7e8ea635SAtari911 body: "call=plugin_calendar&action=trim_all_past_recurring§ok=" + JSINFO.sectok 1185*7e8ea635SAtari911 }) 1186*7e8ea635SAtari911 .then(function(r) { return r.json(); }) 1187*7e8ea635SAtari911 .then(function(data2) { 1188*7e8ea635SAtari911 if (btn) { 1189*7e8ea635SAtari911 btn.textContent = data2.success ? ("✅ Removed " + (data2.count || 0)) : "❌ Failed"; 1190*7e8ea635SAtari911 btn.disabled = false; 1191*7e8ea635SAtari911 } 1192*7e8ea635SAtari911 setTimeout(function() { if (btn) btn.textContent = "✂️ Trim All Past"; }, 3000); 1193*7e8ea635SAtari911 rescanRecurringEvents(); 1194*7e8ea635SAtari911 }); 1195*7e8ea635SAtari911 }) 1196*7e8ea635SAtari911 .catch(function(err) { 1197*7e8ea635SAtari911 if (btn) { btn.textContent = "✂️ Trim All Past"; btn.disabled = false; } 1198*7e8ea635SAtari911 }); 1199*7e8ea635SAtari911 } 1200*7e8ea635SAtari911 1201*7e8ea635SAtari911 function rescanRecurringEvents() { 1202*7e8ea635SAtari911 var btn = document.getElementById("rescan-recurring-btn"); 1203*7e8ea635SAtari911 var content = document.getElementById("recurring-content"); 1204*7e8ea635SAtari911 if (btn) { btn.textContent = "⏳ Scanning..."; btn.disabled = true; } 1205*7e8ea635SAtari911 1206*7e8ea635SAtari911 fetch(DOKU_BASE + "lib/exe/ajax.php", { 1207*7e8ea635SAtari911 method: "POST", 1208*7e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 1209*7e8ea635SAtari911 body: "call=plugin_calendar&action=rescan_recurring§ok=" + JSINFO.sectok 1210*7e8ea635SAtari911 }) 1211*7e8ea635SAtari911 .then(function(r) { return r.json(); }) 1212*7e8ea635SAtari911 .then(function(data) { 1213*7e8ea635SAtari911 if (data.success && content) { 1214*7e8ea635SAtari911 content.innerHTML = data.html; 1215*7e8ea635SAtari911 } 1216*7e8ea635SAtari911 if (btn) { btn.textContent = " Rescan (" + (data.count || 0) + " found)"; btn.disabled = false; } 1217*7e8ea635SAtari911 setTimeout(function() { if (btn) btn.textContent = " Rescan"; }, 3000); 1218*7e8ea635SAtari911 }) 1219*7e8ea635SAtari911 .catch(function(err) { 1220*7e8ea635SAtari911 if (btn) { btn.textContent = " Rescan"; btn.disabled = false; } 1221*7e8ea635SAtari911 console.error("Rescan failed:", err); 1222*7e8ea635SAtari911 }); 1223*7e8ea635SAtari911 } 1224*7e8ea635SAtari911 1225*7e8ea635SAtari911 function recurringAction(action, params, statusEl) { 1226*7e8ea635SAtari911 if (statusEl) statusEl.textContent = "⏳ Working..."; 1227*7e8ea635SAtari911 var body = "call=plugin_calendar&action=" + action + "§ok=" + JSINFO.sectok; 1228*7e8ea635SAtari911 for (var key in params) { 1229*7e8ea635SAtari911 body += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]); 1230*7e8ea635SAtari911 } 1231*7e8ea635SAtari911 return fetch(DOKU_BASE + "lib/exe/ajax.php", { 1232*7e8ea635SAtari911 method: "POST", 1233*7e8ea635SAtari911 headers: {"Content-Type": "application/x-www-form-urlencoded"}, 1234*7e8ea635SAtari911 body: body 1235*7e8ea635SAtari911 }) 1236*7e8ea635SAtari911 .then(function(r) { return r.json(); }) 1237*7e8ea635SAtari911 .then(function(data) { 1238*7e8ea635SAtari911 if (statusEl) { 1239*7e8ea635SAtari911 statusEl.textContent = data.success ? ("✅ " + data.message) : ("❌ " + (data.error || "Failed")); 1240*7e8ea635SAtari911 statusEl.style.color = data.success ? "#00cc07" : "#e74c3c"; 1241*7e8ea635SAtari911 } 1242*7e8ea635SAtari911 return data; 1243*7e8ea635SAtari911 }) 1244*7e8ea635SAtari911 .catch(function(err) { 1245*7e8ea635SAtari911 if (statusEl) { statusEl.textContent = "❌ Error: " + err; statusEl.style.color = "#e74c3c"; } 1246*7e8ea635SAtari911 }); 1247*7e8ea635SAtari911 } 1248*7e8ea635SAtari911 1249*7e8ea635SAtari911 function manageRecurringSeries(title, namespace, count, firstDate, pattern, hasFlag) { 1250*7e8ea635SAtari911 var isPaused = title.indexOf("⏸") === 0; 1251*7e8ea635SAtari911 var cleanTitle = title.replace(/^⏸\s*/, ""); 1252*7e8ea635SAtari911 var safeTitle = title.replace(/\x27/g, "\\\x27"); 1253*7e8ea635SAtari911 var todayStr = new Date().toISOString().split("T")[0]; 1254*7e8ea635SAtari911 1255*7e8ea635SAtari911 var dialog = document.createElement("div"); 1256*7e8ea635SAtari911 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;"; 1257*7e8ea635SAtari911 dialog.addEventListener("click", function(e) { if (e.target === dialog) dialog.remove(); }); 1258*7e8ea635SAtari911 1259*7e8ea635SAtari911 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;\">"; 1260*7e8ea635SAtari911 h += "<h3 style=\"margin:0 0 5px; color:#00cc07;\">⚙️ Manage Recurring Series</h3>"; 1261*7e8ea635SAtari911 h += "<p style=\"margin:0 0 15px; color:' . $colors['text'] . '; font-size:13px;\"><strong>" + cleanTitle + "</strong> — " + count + " occurrences, " + pattern + ", starts " + firstDate + "</p>"; 1262*7e8ea635SAtari911 h += "<div id=\"manage-status\" style=\"font-size:12px; min-height:18px; margin-bottom:10px;\"></div>"; 1263*7e8ea635SAtari911 1264*7e8ea635SAtari911 // Extend 1265*7e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 1266*7e8ea635SAtari911 h += "<div style=\"font-weight:700; color:#00cc07; font-size:12px; margin-bottom:6px;\"> Extend Series</div>"; 1267*7e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 1268*7e8ea635SAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Add occurrences:</label>"; 1269*7e8ea635SAtari911 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>"; 1270*7e8ea635SAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Days apart:</label>"; 1271*7e8ea635SAtari911 h += "<select id=\"manage-extend-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">"; 1272*7e8ea635SAtari911 h += "<option value=\"1\">Daily</option><option value=\"7\" selected>Weekly</option><option value=\"14\">Bi-weekly</option><option value=\"30\">Monthly</option><option value=\"90\">Quarterly</option><option value=\"365\">Yearly</option></select></div>"; 1273*7e8ea635SAtari911 h += "<button onclick=\"recurringAction(\x27extend_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, count:document.getElementById(\x27manage-extend-count\x27).value, interval_days:document.getElementById(\x27manage-extend-interval\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#00cc07; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Extend</button>"; 1274*7e8ea635SAtari911 h += "</div></div>"; 1275*7e8ea635SAtari911 1276*7e8ea635SAtari911 // Trim 1277*7e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 1278*7e8ea635SAtari911 h += "<div style=\"font-weight:700; color:#e74c3c; font-size:12px; margin-bottom:6px;\">✂️ Trim Past Events</div>"; 1279*7e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 1280*7e8ea635SAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Remove before:</label>"; 1281*7e8ea635SAtari911 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>"; 1282*7e8ea635SAtari911 h += "<button onclick=\"if(confirm(\x27Remove all occurrences before \x27 + document.getElementById(\x27manage-trim-date\x27).value + \x27?\x27)) recurringAction(\x27trim_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, cutoff_date:document.getElementById(\x27manage-trim-date\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#e74c3c; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Trim</button>"; 1283*7e8ea635SAtari911 h += "</div></div>"; 1284*7e8ea635SAtari911 1285*7e8ea635SAtari911 // Change Pattern 1286*7e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 1287*7e8ea635SAtari911 h += "<div style=\"font-weight:700; color:#ff9800; font-size:12px; margin-bottom:6px;\"> Change Pattern</div>"; 1288*7e8ea635SAtari911 h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">Respaces future occurrences only. Past events stay in place.</p>"; 1289*7e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 1290*7e8ea635SAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">New interval:</label>"; 1291*7e8ea635SAtari911 h += "<select id=\"manage-pattern-interval\" style=\"padding:4px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;\">"; 1292*7e8ea635SAtari911 h += "<option value=\"1\">Daily</option><option value=\"7\">Weekly</option><option value=\"14\">Bi-weekly</option><option value=\"30\">Monthly</option><option value=\"90\">Quarterly</option><option value=\"365\">Yearly</option></select></div>"; 1293*7e8ea635SAtari911 h += "<button onclick=\"if(confirm(\x27Respace all future occurrences?\x27)) recurringAction(\x27change_pattern_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, interval_days:document.getElementById(\x27manage-pattern-interval\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#ff9800; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Change</button>"; 1294*7e8ea635SAtari911 h += "</div></div>"; 1295*7e8ea635SAtari911 1296*7e8ea635SAtari911 // Change Start Date 1297*7e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 1298*7e8ea635SAtari911 h += "<div style=\"font-weight:700; color:#2196f3; font-size:12px; margin-bottom:6px;\"> Change Start Date</div>"; 1299*7e8ea635SAtari911 h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">Shifts ALL occurrences by the difference between old and new start date.</p>"; 1300*7e8ea635SAtari911 h += "<div style=\"display:flex; gap:8px; align-items:end;\">"; 1301*7e8ea635SAtari911 h += "<div><label style=\"font-size:11px; display:block; margin-bottom:2px;\">Current: " + firstDate + "</label>"; 1302*7e8ea635SAtari911 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>"; 1303*7e8ea635SAtari911 h += "<button onclick=\"if(confirm(\x27Shift all occurrences to new start date?\x27)) recurringAction(\x27change_start_recurring\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27, new_start_date:document.getElementById(\x27manage-start-date\x27).value}, document.getElementById(\x27manage-status\x27))\" style=\"background:#2196f3; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">Shift</button>"; 1304*7e8ea635SAtari911 h += "</div></div>"; 1305*7e8ea635SAtari911 1306*7e8ea635SAtari911 // Pause/Resume 1307*7e8ea635SAtari911 h += "<div style=\"border:1px solid ' . $colors['border'] . '; border-radius:4px; padding:10px; margin-bottom:10px;\">"; 1308*7e8ea635SAtari911 h += "<div style=\"font-weight:700; color:#9c27b0; font-size:12px; margin-bottom:6px;\">" + (isPaused ? "▶️ Resume Series" : "⏸ Pause Series") + "</div>"; 1309*7e8ea635SAtari911 h += "<p style=\"font-size:11px; color:' . $colors['text'] . '; margin:0 0 6px; opacity:0.7;\">" + (isPaused ? "Removes ⏸ prefix and paused flag from all occurrences." : "Adds ⏸ prefix to future occurrences. They remain in the calendar but are visually marked as paused.") + "</p>"; 1310*7e8ea635SAtari911 h += "<button onclick=\"recurringAction(\x27" + (isPaused ? "resume_recurring" : "pause_recurring") + "\x27, {title:\x27" + safeTitle + "\x27, namespace:\x27" + namespace + "\x27}, document.getElementById(\x27manage-status\x27))\" style=\"background:#9c27b0; color:#fff; border:none; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; font-weight:600;\">" + (isPaused ? "▶️ Resume" : "⏸ Pause") + "</button>"; 1311*7e8ea635SAtari911 h += "</div>"; 1312*7e8ea635SAtari911 1313*7e8ea635SAtari911 // Close 1314*7e8ea635SAtari911 h += "<div style=\"text-align:right; margin-top:10px;\">"; 1315*7e8ea635SAtari911 h += "<button onclick=\"this.closest(\x27[style*=fixed]\x27).remove(); rescanRecurringEvents();\" style=\"background:#666; color:#fff; border:none; padding:8px 20px; border-radius:3px; cursor:pointer; font-weight:600;\">Close</button>"; 1316*7e8ea635SAtari911 h += "</div></div>"; 1317*7e8ea635SAtari911 1318*7e8ea635SAtari911 dialog.innerHTML = h; 1319*7e8ea635SAtari911 document.body.appendChild(dialog); 1320*7e8ea635SAtari911 } 1321*7e8ea635SAtari911 13221d05cddcSAtari911 function sortRecurringTable(columnIndex) { 13231d05cddcSAtari911 const table = document.getElementById("recurringTable"); 13241d05cddcSAtari911 const tbody = document.getElementById("recurringTableBody"); 13251d05cddcSAtari911 13269ccd446eSAtari911 if (!table || !tbody) return; 13271d05cddcSAtari911 13281d05cddcSAtari911 const rows = Array.from(tbody.querySelectorAll("tr")); 13299ccd446eSAtari911 if (rows.length === 0) return; 13301d05cddcSAtari911 13311d05cddcSAtari911 // Toggle sort direction for this column 13321d05cddcSAtari911 if (!sortDirection[columnIndex]) { 13331d05cddcSAtari911 sortDirection[columnIndex] = "asc"; 13341d05cddcSAtari911 } else { 13351d05cddcSAtari911 sortDirection[columnIndex] = sortDirection[columnIndex] === "asc" ? "desc" : "asc"; 13361d05cddcSAtari911 } 13371d05cddcSAtari911 13381d05cddcSAtari911 const direction = sortDirection[columnIndex]; 13391d05cddcSAtari911 const isNumeric = columnIndex === 4; // Count column 13401d05cddcSAtari911 13411d05cddcSAtari911 // Sort rows 13421d05cddcSAtari911 rows.sort((a, b) => { 13431d05cddcSAtari911 let aValue = a.cells[columnIndex].textContent.trim(); 13441d05cddcSAtari911 let bValue = b.cells[columnIndex].textContent.trim(); 13451d05cddcSAtari911 13461d05cddcSAtari911 // Extract text from code elements for namespace column 13471d05cddcSAtari911 if (columnIndex === 1) { 13481d05cddcSAtari911 const aCode = a.cells[columnIndex].querySelector("code"); 13491d05cddcSAtari911 const bCode = b.cells[columnIndex].querySelector("code"); 13501d05cddcSAtari911 aValue = aCode ? aCode.textContent.trim() : aValue; 13511d05cddcSAtari911 bValue = bCode ? bCode.textContent.trim() : bValue; 13521d05cddcSAtari911 } 13531d05cddcSAtari911 13541d05cddcSAtari911 // Extract number from strong elements for count column 13551d05cddcSAtari911 if (isNumeric) { 13561d05cddcSAtari911 const aStrong = a.cells[columnIndex].querySelector("strong"); 13571d05cddcSAtari911 const bStrong = b.cells[columnIndex].querySelector("strong"); 13581d05cddcSAtari911 aValue = aStrong ? parseInt(aStrong.textContent.trim()) : 0; 13591d05cddcSAtari911 bValue = bStrong ? parseInt(bStrong.textContent.trim()) : 0; 13601d05cddcSAtari911 13611d05cddcSAtari911 return direction === "asc" ? aValue - bValue : bValue - aValue; 13621d05cddcSAtari911 } 13631d05cddcSAtari911 13641d05cddcSAtari911 // String comparison 13651d05cddcSAtari911 if (direction === "asc") { 13661d05cddcSAtari911 return aValue.localeCompare(bValue); 13671d05cddcSAtari911 } else { 13681d05cddcSAtari911 return bValue.localeCompare(aValue); 13691d05cddcSAtari911 } 13701d05cddcSAtari911 }); 13711d05cddcSAtari911 13721d05cddcSAtari911 // Update arrows 13731d05cddcSAtari911 const headers = table.querySelectorAll("th"); 13741d05cddcSAtari911 headers.forEach((header, index) => { 13751d05cddcSAtari911 const arrow = header.querySelector(".sort-arrow"); 13761d05cddcSAtari911 if (arrow) { 13771d05cddcSAtari911 if (index === columnIndex) { 13781d05cddcSAtari911 arrow.textContent = direction === "asc" ? "↑" : "↓"; 13791d05cddcSAtari911 arrow.style.color = "#00cc07"; 13801d05cddcSAtari911 } else { 13811d05cddcSAtari911 arrow.textContent = "⇅"; 13821d05cddcSAtari911 arrow.style.color = "#999"; 13831d05cddcSAtari911 } 13841d05cddcSAtari911 } 13851d05cddcSAtari911 }); 13861d05cddcSAtari911 13871d05cddcSAtari911 // Rebuild tbody 13881d05cddcSAtari911 rows.forEach(row => tbody.appendChild(row)); 13891d05cddcSAtari911 } 13901d05cddcSAtari911 13911d05cddcSAtari911 function filterRecurringEvents() { 13921d05cddcSAtari911 const searchInput = document.getElementById("searchRecurring"); 13931d05cddcSAtari911 const filter = normalizeText(searchInput.value); 13941d05cddcSAtari911 const tbody = document.getElementById("recurringTableBody"); 13951d05cddcSAtari911 const rows = tbody.getElementsByTagName("tr"); 13961d05cddcSAtari911 13971d05cddcSAtari911 for (let i = 0; i < rows.length; i++) { 13981d05cddcSAtari911 const row = rows[i]; 13991d05cddcSAtari911 const titleCell = row.getElementsByTagName("td")[0]; 14001d05cddcSAtari911 14011d05cddcSAtari911 if (titleCell) { 14021d05cddcSAtari911 const titleText = normalizeText(titleCell.textContent || titleCell.innerText); 14031d05cddcSAtari911 14041d05cddcSAtari911 if (titleText.indexOf(filter) > -1) { 14051d05cddcSAtari911 row.classList.remove("recurring-row-hidden"); 14061d05cddcSAtari911 } else { 14071d05cddcSAtari911 row.classList.add("recurring-row-hidden"); 14081d05cddcSAtari911 } 14091d05cddcSAtari911 } 14101d05cddcSAtari911 } 14111d05cddcSAtari911 } 14121d05cddcSAtari911 14131d05cddcSAtari911 function normalizeText(text) { 14141d05cddcSAtari911 // Convert to lowercase 14151d05cddcSAtari911 text = text.toLowerCase(); 14161d05cddcSAtari911 14171d05cddcSAtari911 // Remove apostrophes and quotes 14181d05cddcSAtari911 text = text.replace(/[\'\"]/g, ""); 14191d05cddcSAtari911 14201d05cddcSAtari911 // Replace accented characters with regular ones 14211d05cddcSAtari911 text = text.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 14221d05cddcSAtari911 14231d05cddcSAtari911 // Remove special characters except spaces and alphanumeric 14241d05cddcSAtari911 text = text.replace(/[^a-z0-9\s]/g, ""); 14251d05cddcSAtari911 14261d05cddcSAtari911 // Collapse multiple spaces 14271d05cddcSAtari911 text = text.replace(/\s+/g, " "); 14281d05cddcSAtari911 14291d05cddcSAtari911 return text.trim(); 14301d05cddcSAtari911 } 14311d05cddcSAtari911 14321d05cddcSAtari911 function filterEvents() { 14331d05cddcSAtari911 const searchText = normalizeText(document.getElementById("searchEvents").value); 14341d05cddcSAtari911 const eventRows = document.querySelectorAll(".event-row"); 14351d05cddcSAtari911 let visibleCount = 0; 14361d05cddcSAtari911 14371d05cddcSAtari911 eventRows.forEach(row => { 14381d05cddcSAtari911 const titleElement = row.querySelector("div div"); 14391d05cddcSAtari911 const originalTitle = titleElement.getAttribute("data-original-title") || titleElement.textContent; 14401d05cddcSAtari911 14411d05cddcSAtari911 // Store original title if not already stored 14421d05cddcSAtari911 if (!titleElement.getAttribute("data-original-title")) { 14431d05cddcSAtari911 titleElement.setAttribute("data-original-title", originalTitle); 14441d05cddcSAtari911 } 14451d05cddcSAtari911 14461d05cddcSAtari911 const normalizedTitle = normalizeText(originalTitle); 14471d05cddcSAtari911 14481d05cddcSAtari911 if (normalizedTitle.includes(searchText) || searchText === "") { 14491d05cddcSAtari911 row.style.display = "flex"; 14501d05cddcSAtari911 visibleCount++; 14511d05cddcSAtari911 } else { 14521d05cddcSAtari911 row.style.display = "none"; 14531d05cddcSAtari911 } 14541d05cddcSAtari911 }); 14551d05cddcSAtari911 14561d05cddcSAtari911 // Update namespace visibility and counts 14571d05cddcSAtari911 document.querySelectorAll("[id^=ns_]").forEach(nsDiv => { 14581d05cddcSAtari911 if (nsDiv.id.endsWith("_arrow") || nsDiv.id.endsWith("_check")) return; 14591d05cddcSAtari911 14601d05cddcSAtari911 const visibleEvents = nsDiv.querySelectorAll(".event-row[style*=\\"display: flex\\"], .event-row:not([style*=\\"display: none\\"])").length; 14611d05cddcSAtari911 const nsId = nsDiv.id; 14621d05cddcSAtari911 const arrow = document.getElementById(nsId + "_arrow"); 14631d05cddcSAtari911 14641d05cddcSAtari911 // Auto-expand namespaces with matches when searching 14651d05cddcSAtari911 if (searchText && visibleEvents > 0) { 14661d05cddcSAtari911 nsDiv.style.display = "block"; 14671d05cddcSAtari911 if (arrow) arrow.textContent = "▼"; 14681d05cddcSAtari911 } 14691d05cddcSAtari911 }); 14701d05cddcSAtari911 } 14711d05cddcSAtari911 14721d05cddcSAtari911 function toggleNamespace(id) { 14731d05cddcSAtari911 const elem = document.getElementById(id); 14741d05cddcSAtari911 const arrow = document.getElementById(id + "_arrow"); 14751d05cddcSAtari911 if (elem.style.display === "none") { 14761d05cddcSAtari911 elem.style.display = "block"; 14771d05cddcSAtari911 arrow.textContent = "▼"; 14781d05cddcSAtari911 } else { 14791d05cddcSAtari911 elem.style.display = "none"; 14801d05cddcSAtari911 arrow.textContent = "▶"; 14811d05cddcSAtari911 } 14821d05cddcSAtari911 } 14831d05cddcSAtari911 14841d05cddcSAtari911 function toggleNamespaceSelect(nsId) { 14851d05cddcSAtari911 const checkbox = document.getElementById(nsId + "_check"); 14861d05cddcSAtari911 const events = document.querySelectorAll("." + nsId + "_events"); 14871d05cddcSAtari911 14881d05cddcSAtari911 // Only select visible events (not hidden by search) 14891d05cddcSAtari911 events.forEach(cb => { 14901d05cddcSAtari911 const eventRow = cb.closest(".event-row"); 14911d05cddcSAtari911 if (eventRow && eventRow.style.display !== "none") { 14921d05cddcSAtari911 cb.checked = checkbox.checked; 14931d05cddcSAtari911 } 14941d05cddcSAtari911 }); 14951d05cddcSAtari911 updateCount(); 14961d05cddcSAtari911 } 14971d05cddcSAtari911 14981d05cddcSAtari911 function selectAll() { 14991d05cddcSAtari911 // Only select visible events 15001d05cddcSAtari911 document.querySelectorAll(".event-checkbox").forEach(cb => { 15011d05cddcSAtari911 const eventRow = cb.closest(".event-row"); 15021d05cddcSAtari911 if (eventRow && eventRow.style.display !== "none") { 15031d05cddcSAtari911 cb.checked = true; 15041d05cddcSAtari911 } 15051d05cddcSAtari911 }); 15061d05cddcSAtari911 // Update namespace checkboxes to indeterminate if partially selected 15071d05cddcSAtari911 document.querySelectorAll("input[id$=_check]").forEach(nsCheckbox => { 15081d05cddcSAtari911 const nsId = nsCheckbox.id.replace("_check", ""); 15091d05cddcSAtari911 const events = document.querySelectorAll("." + nsId + "_events"); 15101d05cddcSAtari911 const visibleEvents = Array.from(events).filter(cb => { 15111d05cddcSAtari911 const row = cb.closest(".event-row"); 15121d05cddcSAtari911 return row && row.style.display !== "none"; 15131d05cddcSAtari911 }); 15141d05cddcSAtari911 const checkedVisible = visibleEvents.filter(cb => cb.checked); 15151d05cddcSAtari911 15161d05cddcSAtari911 if (checkedVisible.length === visibleEvents.length && visibleEvents.length > 0) { 15171d05cddcSAtari911 nsCheckbox.checked = true; 15181d05cddcSAtari911 } else if (checkedVisible.length > 0) { 15191d05cddcSAtari911 nsCheckbox.indeterminate = true; 15201d05cddcSAtari911 } else { 15211d05cddcSAtari911 nsCheckbox.checked = false; 15221d05cddcSAtari911 } 15231d05cddcSAtari911 }); 15241d05cddcSAtari911 updateCount(); 15251d05cddcSAtari911 } 15261d05cddcSAtari911 15271d05cddcSAtari911 function deselectAll() { 15281d05cddcSAtari911 document.querySelectorAll(".event-checkbox").forEach(cb => cb.checked = false); 15291d05cddcSAtari911 document.querySelectorAll("input[id$=_check]").forEach(cb => { 15301d05cddcSAtari911 cb.checked = false; 15311d05cddcSAtari911 cb.indeterminate = false; 15321d05cddcSAtari911 }); 15331d05cddcSAtari911 updateCount(); 15341d05cddcSAtari911 } 15351d05cddcSAtari911 15361d05cddcSAtari911 function deleteSelected() { 15371d05cddcSAtari911 const checkedBoxes = document.querySelectorAll(".event-checkbox:checked"); 15381d05cddcSAtari911 if (checkedBoxes.length === 0) { 15391d05cddcSAtari911 alert("No events selected"); 15401d05cddcSAtari911 return; 15411d05cddcSAtari911 } 15421d05cddcSAtari911 15431d05cddcSAtari911 const count = checkedBoxes.length; 15441d05cddcSAtari911 if (!confirm(`Delete ${count} selected event(s)?\\n\\nThis cannot be undone!`)) { 15451d05cddcSAtari911 return; 15461d05cddcSAtari911 } 15471d05cddcSAtari911 15481d05cddcSAtari911 const form = document.createElement("form"); 15491d05cddcSAtari911 form.method = "POST"; 15501d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 15511d05cddcSAtari911 15521d05cddcSAtari911 const actionInput = document.createElement("input"); 15531d05cddcSAtari911 actionInput.type = "hidden"; 15541d05cddcSAtari911 actionInput.name = "action"; 15551d05cddcSAtari911 actionInput.value = "delete_selected_events"; 15561d05cddcSAtari911 form.appendChild(actionInput); 15571d05cddcSAtari911 15581d05cddcSAtari911 checkedBoxes.forEach(cb => { 15591d05cddcSAtari911 const eventInput = document.createElement("input"); 15601d05cddcSAtari911 eventInput.type = "hidden"; 15611d05cddcSAtari911 eventInput.name = "events[]"; 15621d05cddcSAtari911 eventInput.value = cb.value; 15631d05cddcSAtari911 form.appendChild(eventInput); 15641d05cddcSAtari911 }); 15651d05cddcSAtari911 15661d05cddcSAtari911 document.body.appendChild(form); 15671d05cddcSAtari911 form.submit(); 15681d05cddcSAtari911 } 15691d05cddcSAtari911 15701d05cddcSAtari911 function createNewNamespace() { 15711d05cddcSAtari911 const namespaceName = prompt("Enter new namespace name:\\n\\nExamples:\\n- work\\n- personal\\n- projects:alpha\\n- aspen:travel:2025"); 15721d05cddcSAtari911 15731d05cddcSAtari911 if (!namespaceName) { 15741d05cddcSAtari911 return; // Cancelled 15751d05cddcSAtari911 } 15761d05cddcSAtari911 15771d05cddcSAtari911 // Validate namespace name 15781d05cddcSAtari911 if (!/^[a-zA-Z0-9_:-]+$/.test(namespaceName)) { 15791d05cddcSAtari911 alert("Invalid namespace name.\\n\\nUse only letters, numbers, underscore, hyphen, and colon.\\nExample: work:projects:alpha"); 15801d05cddcSAtari911 return; 15811d05cddcSAtari911 } 15821d05cddcSAtari911 15831d05cddcSAtari911 // Submit form to create namespace 15841d05cddcSAtari911 const form = document.createElement("form"); 15851d05cddcSAtari911 form.method = "POST"; 15861d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 15871d05cddcSAtari911 15881d05cddcSAtari911 const actionInput = document.createElement("input"); 15891d05cddcSAtari911 actionInput.type = "hidden"; 15901d05cddcSAtari911 actionInput.name = "action"; 15911d05cddcSAtari911 actionInput.value = "create_namespace"; 15921d05cddcSAtari911 form.appendChild(actionInput); 15931d05cddcSAtari911 15941d05cddcSAtari911 const namespaceInput = document.createElement("input"); 15951d05cddcSAtari911 namespaceInput.type = "hidden"; 15961d05cddcSAtari911 namespaceInput.name = "namespace_name"; 15971d05cddcSAtari911 namespaceInput.value = namespaceName; 15981d05cddcSAtari911 form.appendChild(namespaceInput); 15991d05cddcSAtari911 16001d05cddcSAtari911 document.body.appendChild(form); 16011d05cddcSAtari911 form.submit(); 16021d05cddcSAtari911 } 16031d05cddcSAtari911 16041d05cddcSAtari911 function updateCount() { 16051d05cddcSAtari911 const count = document.querySelectorAll(".event-checkbox:checked").length; 16061d05cddcSAtari911 document.getElementById("selectedCount").textContent = count + " selected"; 16071d05cddcSAtari911 } 16081d05cddcSAtari911 16091d05cddcSAtari911 function deleteNamespace(namespace) { 16101d05cddcSAtari911 const displayName = namespace || "(default)"; 16111d05cddcSAtari911 if (!confirm("Delete ENTIRE namespace: " + displayName + "?\\n\\nThis will delete ALL events in this namespace!\\n\\nThis cannot be undone!")) { 16121d05cddcSAtari911 return; 16131d05cddcSAtari911 } 16141d05cddcSAtari911 const form = document.createElement("form"); 16151d05cddcSAtari911 form.method = "POST"; 16161d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 16171d05cddcSAtari911 const actionInput = document.createElement("input"); 16181d05cddcSAtari911 actionInput.type = "hidden"; 16191d05cddcSAtari911 actionInput.name = "action"; 16201d05cddcSAtari911 actionInput.value = "delete_namespace"; 16211d05cddcSAtari911 form.appendChild(actionInput); 16221d05cddcSAtari911 const nsInput = document.createElement("input"); 16231d05cddcSAtari911 nsInput.type = "hidden"; 16241d05cddcSAtari911 nsInput.name = "namespace"; 16251d05cddcSAtari911 nsInput.value = namespace; 16261d05cddcSAtari911 form.appendChild(nsInput); 16271d05cddcSAtari911 document.body.appendChild(form); 16281d05cddcSAtari911 form.submit(); 16291d05cddcSAtari911 } 16301d05cddcSAtari911 16319ccd446eSAtari911 function renameNamespace(oldNamespace) { 16329ccd446eSAtari911 const displayName = oldNamespace || "(default)"; 16339ccd446eSAtari911 const newName = prompt("Rename namespace: " + displayName + "\\n\\nEnter new name:", oldNamespace); 16349ccd446eSAtari911 if (newName === null || newName === oldNamespace) { 16359ccd446eSAtari911 return; // Cancelled or no change 16369ccd446eSAtari911 } 16379ccd446eSAtari911 const form = document.createElement("form"); 16389ccd446eSAtari911 form.method = "POST"; 16399ccd446eSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 16409ccd446eSAtari911 const actionInput = document.createElement("input"); 16419ccd446eSAtari911 actionInput.type = "hidden"; 16429ccd446eSAtari911 actionInput.name = "action"; 16439ccd446eSAtari911 actionInput.value = "rename_namespace"; 16449ccd446eSAtari911 form.appendChild(actionInput); 16459ccd446eSAtari911 const oldInput = document.createElement("input"); 16469ccd446eSAtari911 oldInput.type = "hidden"; 16479ccd446eSAtari911 oldInput.name = "old_namespace"; 16489ccd446eSAtari911 oldInput.value = oldNamespace; 16499ccd446eSAtari911 form.appendChild(oldInput); 16509ccd446eSAtari911 const newInput = document.createElement("input"); 16519ccd446eSAtari911 newInput.type = "hidden"; 16529ccd446eSAtari911 newInput.name = "new_namespace"; 16539ccd446eSAtari911 newInput.value = newName; 16549ccd446eSAtari911 form.appendChild(newInput); 16559ccd446eSAtari911 document.body.appendChild(form); 16569ccd446eSAtari911 form.submit(); 16579ccd446eSAtari911 } 16589ccd446eSAtari911 16591d05cddcSAtari911 let draggedEvent = null; 16601d05cddcSAtari911 16611d05cddcSAtari911 function dragStart(event, eventId) { 16621d05cddcSAtari911 const checkbox = event.target.closest(".event-row").querySelector(".event-checkbox"); 16631d05cddcSAtari911 16641d05cddcSAtari911 // If this event is checked, drag all checked events 16651d05cddcSAtari911 const checkedBoxes = document.querySelectorAll(".event-checkbox:checked"); 16661d05cddcSAtari911 if (checkbox && checkbox.checked && checkedBoxes.length > 1) { 16671d05cddcSAtari911 // Dragging multiple selected events 16681d05cddcSAtari911 draggedEvent = "MULTIPLE"; 16691d05cddcSAtari911 event.dataTransfer.setData("text/plain", "MULTIPLE"); 16701d05cddcSAtari911 } else { 16711d05cddcSAtari911 // Dragging single event 16721d05cddcSAtari911 draggedEvent = eventId; 16731d05cddcSAtari911 event.dataTransfer.setData("text/plain", eventId); 16741d05cddcSAtari911 } 16751d05cddcSAtari911 event.dataTransfer.effectAllowed = "move"; 16761d05cddcSAtari911 event.target.style.opacity = "0.5"; 16771d05cddcSAtari911 } 16781d05cddcSAtari911 16791d05cddcSAtari911 function allowDrop(event) { 16801d05cddcSAtari911 event.preventDefault(); 16811d05cddcSAtari911 event.dataTransfer.dropEffect = "move"; 16821d05cddcSAtari911 } 16831d05cddcSAtari911 16841d05cddcSAtari911 function drop(event, targetNamespace) { 16851d05cddcSAtari911 event.preventDefault(); 16861d05cddcSAtari911 16871d05cddcSAtari911 if (draggedEvent === "MULTIPLE") { 16881d05cddcSAtari911 // Move all selected events 16891d05cddcSAtari911 const checkedBoxes = document.querySelectorAll(".event-checkbox:checked"); 16901d05cddcSAtari911 if (checkedBoxes.length === 0) return; 16911d05cddcSAtari911 16921d05cddcSAtari911 const form = document.createElement("form"); 16931d05cddcSAtari911 form.method = "POST"; 16941d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 16951d05cddcSAtari911 16961d05cddcSAtari911 const actionInput = document.createElement("input"); 16971d05cddcSAtari911 actionInput.type = "hidden"; 16981d05cddcSAtari911 actionInput.name = "action"; 16991d05cddcSAtari911 actionInput.value = "move_selected_events"; 17001d05cddcSAtari911 form.appendChild(actionInput); 17011d05cddcSAtari911 17021d05cddcSAtari911 checkedBoxes.forEach(cb => { 17031d05cddcSAtari911 const eventInput = document.createElement("input"); 17041d05cddcSAtari911 eventInput.type = "hidden"; 17051d05cddcSAtari911 eventInput.name = "events[]"; 17061d05cddcSAtari911 eventInput.value = cb.value; 17071d05cddcSAtari911 form.appendChild(eventInput); 17081d05cddcSAtari911 }); 17091d05cddcSAtari911 17101d05cddcSAtari911 const targetInput = document.createElement("input"); 17111d05cddcSAtari911 targetInput.type = "hidden"; 17121d05cddcSAtari911 targetInput.name = "target_namespace"; 17131d05cddcSAtari911 targetInput.value = targetNamespace; 17141d05cddcSAtari911 form.appendChild(targetInput); 17151d05cddcSAtari911 17161d05cddcSAtari911 document.body.appendChild(form); 17171d05cddcSAtari911 form.submit(); 17181d05cddcSAtari911 } else { 17191d05cddcSAtari911 // Move single event 17201d05cddcSAtari911 if (!draggedEvent) return; 17211d05cddcSAtari911 const parts = draggedEvent.split("|"); 17221d05cddcSAtari911 const sourceNamespace = parts[1]; 17231d05cddcSAtari911 if (sourceNamespace === targetNamespace) return; 17241d05cddcSAtari911 17251d05cddcSAtari911 const form = document.createElement("form"); 17261d05cddcSAtari911 form.method = "POST"; 17271d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 17281d05cddcSAtari911 const actionInput = document.createElement("input"); 17291d05cddcSAtari911 actionInput.type = "hidden"; 17301d05cddcSAtari911 actionInput.name = "action"; 17311d05cddcSAtari911 actionInput.value = "move_single_event"; 17321d05cddcSAtari911 form.appendChild(actionInput); 17331d05cddcSAtari911 const eventInput = document.createElement("input"); 17341d05cddcSAtari911 eventInput.type = "hidden"; 17351d05cddcSAtari911 eventInput.name = "event"; 17361d05cddcSAtari911 eventInput.value = draggedEvent; 17371d05cddcSAtari911 form.appendChild(eventInput); 17381d05cddcSAtari911 const targetInput = document.createElement("input"); 17391d05cddcSAtari911 targetInput.type = "hidden"; 17401d05cddcSAtari911 targetInput.name = "target_namespace"; 17411d05cddcSAtari911 targetInput.value = targetNamespace; 17421d05cddcSAtari911 form.appendChild(targetInput); 17431d05cddcSAtari911 document.body.appendChild(form); 17441d05cddcSAtari911 form.submit(); 17451d05cddcSAtari911 } 17461d05cddcSAtari911 } 17471d05cddcSAtari911 17481d05cddcSAtari911 function editRecurringSeries(title, namespace) { 17499ccd446eSAtari911 // Get available namespaces from the namespace explorer 17509ccd446eSAtari911 const namespaces = new Set(); 17511d05cddcSAtari911 17529ccd446eSAtari911 // Method 1: Try to get from namespace explorer folder names 17539ccd446eSAtari911 document.querySelectorAll("[id^=ns_]").forEach(el => { 17549ccd446eSAtari911 const nsSpan = el.querySelector("span:nth-child(3)"); 17559ccd446eSAtari911 if (nsSpan) { 17569ccd446eSAtari911 let nsText = nsSpan.textContent.replace(" ", "").trim(); 17579ccd446eSAtari911 if (nsText && nsText !== "(default)") { 17589ccd446eSAtari911 namespaces.add(nsText); 17599ccd446eSAtari911 } 17609ccd446eSAtari911 } 17619ccd446eSAtari911 }); 17629ccd446eSAtari911 17639ccd446eSAtari911 // Method 2: Get from datalist if it exists 17649ccd446eSAtari911 document.querySelectorAll("#namespaceList option").forEach(opt => { 17659ccd446eSAtari911 if (opt.value && opt.value !== "") { 17669ccd446eSAtari911 namespaces.add(opt.value); 17679ccd446eSAtari911 } 17689ccd446eSAtari911 }); 17699ccd446eSAtari911 17709ccd446eSAtari911 // Convert to sorted array 17719ccd446eSAtari911 const nsArray = Array.from(namespaces).sort(); 17729ccd446eSAtari911 17739ccd446eSAtari911 // Build options - include current namespace AND all others 17749ccd446eSAtari911 let nsOptions = "<option value=\\"\\">(default)</option>"; 17759ccd446eSAtari911 17769ccd446eSAtari911 // Add current namespace if it\'s not default 17779ccd446eSAtari911 if (namespace && namespace !== "") { 17789ccd446eSAtari911 nsOptions += "<option value=\\"" + namespace + "\\" selected>" + namespace + " (current)</option>"; 17799ccd446eSAtari911 } 17809ccd446eSAtari911 17819ccd446eSAtari911 // Add all other namespaces 17829ccd446eSAtari911 for (const ns of nsArray) { 17839ccd446eSAtari911 if (ns !== namespace) { 17849ccd446eSAtari911 nsOptions += "<option value=\\"" + ns + "\\">" + ns + "</option>"; 17851d05cddcSAtari911 } 17861d05cddcSAtari911 } 17871d05cddcSAtari911 17881d05cddcSAtari911 // Show edit dialog for recurring events 17891d05cddcSAtari911 const dialog = document.createElement("div"); 17901d05cddcSAtari911 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;"; 17911d05cddcSAtari911 17921d05cddcSAtari911 // Close on clicking background 17931d05cddcSAtari911 dialog.addEventListener("click", function(e) { 17941d05cddcSAtari911 if (e.target === dialog) { 17951d05cddcSAtari911 dialog.remove(); 17961d05cddcSAtari911 } 17971d05cddcSAtari911 }); 17981d05cddcSAtari911 17991d05cddcSAtari911 dialog.innerHTML = ` 18009ccd446eSAtari911 <div style="background:' . $colors['bg'] . '; padding:20px; border-radius:8px; min-width:500px; max-width:700px; max-height:90vh; overflow-y:auto;"> 18011d05cddcSAtari911 <h3 style="margin:0 0 15px; color:#00cc07;">Edit Recurring Event</h3> 18029ccd446eSAtari911 <p style="margin:0 0 15px; color:' . $colors['text'] . '; font-size:13px;">Changes will apply to ALL occurrences of: <strong>${title}</strong></p> 18031d05cddcSAtari911 18041d05cddcSAtari911 <form id="editRecurringForm" style="display:flex; flex-direction:column; gap:12px;"> 18051d05cddcSAtari911 <div> 18061d05cddcSAtari911 <label style="display:block; font-weight:bold; margin-bottom:4px; font-size:13px;">New Title:</label> 18079ccd446eSAtari911 <input type="text" name="new_title" value="${title}" style="width:100%; padding:8px; border:1px solid ' . $colors['border'] . '; border-radius:3px;" required> 18081d05cddcSAtari911 </div> 18091d05cddcSAtari911 18101d05cddcSAtari911 <div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;"> 18111d05cddcSAtari911 <div> 18121d05cddcSAtari911 <label style="display:block; font-weight:bold; margin-bottom:4px; font-size:13px;">Start Time:</label> 18139ccd446eSAtari911 <input type="time" name="start_time" style="width:100%; padding:8px; border:1px solid ' . $colors['border'] . '; border-radius:3px;"> 18141d05cddcSAtari911 <small style="color:#999; font-size:11px;">Leave blank to keep current</small> 18151d05cddcSAtari911 </div> 18161d05cddcSAtari911 <div> 18171d05cddcSAtari911 <label style="display:block; font-weight:bold; margin-bottom:4px; font-size:13px;">End Time:</label> 18189ccd446eSAtari911 <input type="time" name="end_time" style="width:100%; padding:8px; border:1px solid ' . $colors['border'] . '; border-radius:3px;"> 18191d05cddcSAtari911 <small style="color:#999; font-size:11px;">Leave blank to keep current</small> 18201d05cddcSAtari911 </div> 18211d05cddcSAtari911 </div> 18221d05cddcSAtari911 18231d05cddcSAtari911 <div> 18241d05cddcSAtari911 <label style="display:block; font-weight:bold; margin-bottom:4px; font-size:13px;">Interval (days between occurrences):</label> 18259ccd446eSAtari911 <select name="interval" style="width:100%; padding:8px; border:1px solid ' . $colors['border'] . '; border-radius:3px;"> 18261d05cddcSAtari911 <option value="">Keep current interval</option> 18271d05cddcSAtari911 <option value="1">Daily (1 day)</option> 18281d05cddcSAtari911 <option value="7">Weekly (7 days)</option> 18291d05cddcSAtari911 <option value="14">Bi-weekly (14 days)</option> 18301d05cddcSAtari911 <option value="30">Monthly (30 days)</option> 18311d05cddcSAtari911 <option value="365">Yearly (365 days)</option> 18321d05cddcSAtari911 </select> 18331d05cddcSAtari911 </div> 18341d05cddcSAtari911 18351d05cddcSAtari911 <div> 18361d05cddcSAtari911 <label style="display:block; font-weight:bold; margin-bottom:4px; font-size:13px;">Move to Namespace:</label> 18379ccd446eSAtari911 <select name="new_namespace" style="width:100%; padding:8px; border:1px solid ' . $colors['border'] . '; border-radius:3px;"> 18381d05cddcSAtari911 ${nsOptions} 18391d05cddcSAtari911 </select> 18401d05cddcSAtari911 </div> 18411d05cddcSAtari911 18421d05cddcSAtari911 <div style="display:flex; gap:10px; margin-top:10px;"> 18431d05cddcSAtari911 <button type="submit" style="flex:1; background:#00cc07; color:white; padding:10px; border:none; border-radius:3px; cursor:pointer; font-weight:bold;">Save Changes</button> 18441d05cddcSAtari911 <button type="button" onclick="closeEditDialog()" style="flex:1; background:#999; color:white; padding:10px; border:none; border-radius:3px; cursor:pointer;">Cancel</button> 18451d05cddcSAtari911 </div> 18461d05cddcSAtari911 </form> 18471d05cddcSAtari911 </div> 18481d05cddcSAtari911 `; 18491d05cddcSAtari911 18501d05cddcSAtari911 document.body.appendChild(dialog); 18511d05cddcSAtari911 18521d05cddcSAtari911 // Add close function to window 18531d05cddcSAtari911 window.closeEditDialog = function() { 18541d05cddcSAtari911 dialog.remove(); 18551d05cddcSAtari911 }; 18561d05cddcSAtari911 18571d05cddcSAtari911 // Handle form submission 18581d05cddcSAtari911 dialog.querySelector("#editRecurringForm").addEventListener("submit", function(e) { 18591d05cddcSAtari911 e.preventDefault(); 18601d05cddcSAtari911 const formData = new FormData(this); 18611d05cddcSAtari911 18621d05cddcSAtari911 // Submit the edit 18631d05cddcSAtari911 const form = document.createElement("form"); 18641d05cddcSAtari911 form.method = "POST"; 18651d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 18661d05cddcSAtari911 18671d05cddcSAtari911 const actionInput = document.createElement("input"); 18681d05cddcSAtari911 actionInput.type = "hidden"; 18691d05cddcSAtari911 actionInput.name = "action"; 18701d05cddcSAtari911 actionInput.value = "edit_recurring_series"; 18711d05cddcSAtari911 form.appendChild(actionInput); 18721d05cddcSAtari911 18731d05cddcSAtari911 const oldTitleInput = document.createElement("input"); 18741d05cddcSAtari911 oldTitleInput.type = "hidden"; 18751d05cddcSAtari911 oldTitleInput.name = "old_title"; 18761d05cddcSAtari911 oldTitleInput.value = title; 18771d05cddcSAtari911 form.appendChild(oldTitleInput); 18781d05cddcSAtari911 18791d05cddcSAtari911 const oldNamespaceInput = document.createElement("input"); 18801d05cddcSAtari911 oldNamespaceInput.type = "hidden"; 18811d05cddcSAtari911 oldNamespaceInput.name = "old_namespace"; 18821d05cddcSAtari911 oldNamespaceInput.value = namespace; 18831d05cddcSAtari911 form.appendChild(oldNamespaceInput); 18841d05cddcSAtari911 18851d05cddcSAtari911 // Add all form fields 18861d05cddcSAtari911 for (let [key, value] of formData.entries()) { 18871d05cddcSAtari911 const input = document.createElement("input"); 18881d05cddcSAtari911 input.type = "hidden"; 18891d05cddcSAtari911 input.name = key; 18901d05cddcSAtari911 input.value = value; 18911d05cddcSAtari911 form.appendChild(input); 18921d05cddcSAtari911 } 18931d05cddcSAtari911 18941d05cddcSAtari911 document.body.appendChild(form); 18951d05cddcSAtari911 form.submit(); 18961d05cddcSAtari911 }); 18971d05cddcSAtari911 } 18981d05cddcSAtari911 18991d05cddcSAtari911 function deleteRecurringSeries(title, namespace) { 19001d05cddcSAtari911 const displayNs = namespace || "(default)"; 19011d05cddcSAtari911 if (!confirm("Delete ALL occurrences of: " + title + " (" + displayNs + ")?\\n\\nThis cannot be undone!")) { 19021d05cddcSAtari911 return; 19031d05cddcSAtari911 } 19041d05cddcSAtari911 const form = document.createElement("form"); 19051d05cddcSAtari911 form.method = "POST"; 19061d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=manage"; 19071d05cddcSAtari911 const actionInput = document.createElement("input"); 19081d05cddcSAtari911 actionInput.type = "hidden"; 19091d05cddcSAtari911 actionInput.name = "action"; 19101d05cddcSAtari911 actionInput.value = "delete_recurring_series"; 19111d05cddcSAtari911 form.appendChild(actionInput); 19121d05cddcSAtari911 const titleInput = document.createElement("input"); 19131d05cddcSAtari911 titleInput.type = "hidden"; 19141d05cddcSAtari911 titleInput.name = "event_title"; 19151d05cddcSAtari911 titleInput.value = title; 19161d05cddcSAtari911 form.appendChild(titleInput); 19171d05cddcSAtari911 const namespaceInput = document.createElement("input"); 19181d05cddcSAtari911 namespaceInput.type = "hidden"; 19191d05cddcSAtari911 namespaceInput.name = "namespace"; 19201d05cddcSAtari911 namespaceInput.value = namespace; 19211d05cddcSAtari911 form.appendChild(namespaceInput); 19221d05cddcSAtari911 document.body.appendChild(form); 19231d05cddcSAtari911 form.submit(); 19241d05cddcSAtari911 } 19251d05cddcSAtari911 19261d05cddcSAtari911 document.addEventListener("dragend", function(e) { 19271d05cddcSAtari911 if (e.target.draggable) { 19281d05cddcSAtari911 e.target.style.opacity = "1"; 19291d05cddcSAtari911 } 19301d05cddcSAtari911 }); 19311d05cddcSAtari911 </script>'; 19321d05cddcSAtari911 } 19331d05cddcSAtari911 19349ccd446eSAtari911 private function renderUpdateTab($colors = null) { 19351d05cddcSAtari911 global $INPUT; 19361d05cddcSAtari911 19379ccd446eSAtari911 // Use defaults if not provided 19389ccd446eSAtari911 if ($colors === null) { 19399ccd446eSAtari911 $colors = $this->getTemplateColors(); 19409ccd446eSAtari911 } 19411d05cddcSAtari911 19429ccd446eSAtari911 echo '<h2 style="margin:10px 0; font-size:20px;"> Update Plugin</h2>'; 19431d05cddcSAtari911 19441d05cddcSAtari911 // Show message if present 19451d05cddcSAtari911 if ($INPUT->has('msg')) { 19461d05cddcSAtari911 $msg = hsc($INPUT->str('msg')); 19471d05cddcSAtari911 $type = $INPUT->str('msgtype', 'success'); 19481d05cddcSAtari911 $class = ($type === 'success') ? 'msg success' : 'msg error'; 19499ccd446eSAtari911 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;\">"; 19501d05cddcSAtari911 echo $msg; 19511d05cddcSAtari911 echo "</div>"; 19521d05cddcSAtari911 } 19531d05cddcSAtari911 19549ccd446eSAtari911 // Show current version FIRST (MOVED TO TOP) 19551d05cddcSAtari911 $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt'; 19561d05cddcSAtari911 $info = ['version' => 'Unknown', 'date' => 'Unknown', 'name' => 'Calendar Plugin', 'author' => 'Unknown', 'email' => '', 'desc' => '']; 19571d05cddcSAtari911 if (file_exists($pluginInfo)) { 19581d05cddcSAtari911 $info = array_merge($info, confToHash($pluginInfo)); 19591d05cddcSAtari911 } 19601d05cddcSAtari911 19619ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 19629ccd446eSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Current Version</h3>'; 19631d05cddcSAtari911 echo '<div style="font-size:12px; line-height:1.6;">'; 19641d05cddcSAtari911 echo '<div style="margin:4px 0;"><strong>Version:</strong> ' . hsc($info['version']) . ' (' . hsc($info['date']) . ')</div>'; 19651d05cddcSAtari911 echo '<div style="margin:4px 0;"><strong>Author:</strong> ' . hsc($info['author']) . ($info['email'] ? ' <' . hsc($info['email']) . '>' : '') . '</div>'; 19661d05cddcSAtari911 if ($info['desc']) { 19671d05cddcSAtari911 echo '<div style="margin:4px 0;"><strong>Description:</strong> ' . hsc($info['desc']) . '</div>'; 19681d05cddcSAtari911 } 19691d05cddcSAtari911 echo '<div style="margin:4px 0;"><strong>Location:</strong> <code style="background:#f0f0f0; padding:2px 4px; border-radius:2px;">' . DOKU_PLUGIN . 'calendar/</code></div>'; 19701d05cddcSAtari911 echo '</div>'; 19711d05cddcSAtari911 19721d05cddcSAtari911 // Check permissions 19731d05cddcSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar/'; 19741d05cddcSAtari911 $pluginWritable = is_writable($pluginDir); 19751d05cddcSAtari911 $parentWritable = is_writable(DOKU_PLUGIN); 19761d05cddcSAtari911 19779ccd446eSAtari911 echo '<div style="margin-top:8px; padding-top:8px; border-top:1px solid ' . $colors['border'] . ';">'; 19781d05cddcSAtari911 if ($pluginWritable && $parentWritable) { 19791d05cddcSAtari911 echo '<p style="margin:5px 0; font-size:13px; color:#28a745;"><strong>✅ Permissions:</strong> OK - ready to update</p>'; 19801d05cddcSAtari911 } else { 19811d05cddcSAtari911 echo '<p style="margin:5px 0; font-size:13px; color:#dc3545;"><strong>❌ Permissions:</strong> Issues detected</p>'; 19821d05cddcSAtari911 if (!$pluginWritable) { 19831d05cddcSAtari911 echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Plugin directory not writable</p>'; 19841d05cddcSAtari911 } 19851d05cddcSAtari911 if (!$parentWritable) { 19861d05cddcSAtari911 echo '<p style="margin:2px 0 2px 20px; font-size:12px; color:#dc3545;">Parent directory not writable</p>'; 19871d05cddcSAtari911 } 19889ccd446eSAtari911 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>'; 19899ccd446eSAtari911 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>'; 19901d05cddcSAtari911 } 19911d05cddcSAtari911 echo '</div>'; 19921d05cddcSAtari911 19931d05cddcSAtari911 echo '</div>'; 19941d05cddcSAtari911 19959ccd446eSAtari911 // Combined upload and notes section (SIDE BY SIDE) 19969ccd446eSAtari911 echo '<div style="display:flex; gap:15px; max-width:1200px; margin:10px 0;">'; 19971d05cddcSAtari911 19989ccd446eSAtari911 // Left side - Upload form (60% width) 19999ccd446eSAtari911 echo '<div style="flex:1; min-width:0; background:' . $colors['bg'] . '; padding:12px; border-left:3px solid #00cc07; border-radius:3px;">'; 20009ccd446eSAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Upload New Version</h3>'; 20019ccd446eSAtari911 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>'; 20021d05cddcSAtari911 20031d05cddcSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=update" enctype="multipart/form-data" id="uploadForm">'; 20041d05cddcSAtari911 echo '<input type="hidden" name="action" value="upload_update">'; 20051d05cddcSAtari911 echo '<div style="margin:10px 0;">'; 20069ccd446eSAtari911 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%;">'; 20071d05cddcSAtari911 echo '</div>'; 20081d05cddcSAtari911 echo '<div style="margin:10px 0;">'; 20091d05cddcSAtari911 echo '<label style="display:flex; align-items:center; gap:8px; font-size:13px;">'; 20101d05cddcSAtari911 echo '<input type="checkbox" name="backup_first" value="1" checked>'; 20111d05cddcSAtari911 echo '<span>Create backup before updating (Recommended)</span>'; 20121d05cddcSAtari911 echo '</label>'; 20131d05cddcSAtari911 echo '</div>'; 20149ccd446eSAtari911 20159ccd446eSAtari911 // Buttons side by side 20169ccd446eSAtari911 echo '<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">'; 20171d05cddcSAtari911 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>'; 20181d05cddcSAtari911 echo '</form>'; 20199ccd446eSAtari911 20209ccd446eSAtari911 // Clear Cache button (next to Upload button) 20219ccd446eSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="display:inline; margin:0;">'; 20229ccd446eSAtari911 echo '<input type="hidden" name="action" value="clear_cache">'; 20239ccd446eSAtari911 echo '<input type="hidden" name="tab" value="update">'; 20249ccd446eSAtari911 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>'; 20259ccd446eSAtari911 echo '</form>'; 20261d05cddcSAtari911 echo '</div>'; 20271d05cddcSAtari911 20289ccd446eSAtari911 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>'; 20299ccd446eSAtari911 echo '</div>'; 20309ccd446eSAtari911 20319ccd446eSAtari911 // Right side - Important Notes (40% width) 20329ccd446eSAtari911 echo '<div style="flex:0 0 350px; min-width:0; background:#fff3e0; border-left:3px solid #ff9800; padding:12px; border-radius:3px;">'; 20331d05cddcSAtari911 echo '<h4 style="margin:0 0 5px 0; color:#e65100; font-size:14px;">⚠️ Important Notes</h4>'; 20349ccd446eSAtari911 echo '<ul style="margin:5px 0; padding-left:20px; font-size:12px; color:#e65100; line-height:1.6;">'; 20351d05cddcSAtari911 echo '<li>This will replace all plugin files</li>'; 20361d05cddcSAtari911 echo '<li>Configuration files (sync_config.php) will be preserved</li>'; 20371d05cddcSAtari911 echo '<li>Event data will not be affected</li>'; 20389ccd446eSAtari911 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>'; 20391d05cddcSAtari911 echo '<li>Make sure the ZIP file is a valid calendar plugin</li>'; 20401d05cddcSAtari911 echo '</ul>'; 20411d05cddcSAtari911 echo '</div>'; 20421d05cddcSAtari911 20439ccd446eSAtari911 echo '</div>'; // End flex container 20449ccd446eSAtari911 20459ccd446eSAtari911 // Changelog section - Timeline viewer 2046*7e8ea635SAtari911 echo '<div style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:1200px;">'; 2047*7e8ea635SAtari911 echo '<h3 style="margin:0 0 8px 0; color:#00cc07; font-size:16px;"> Version History</h3>'; 20489ccd446eSAtari911 20499ccd446eSAtari911 $changelogFile = DOKU_PLUGIN . 'calendar/CHANGELOG.md'; 20509ccd446eSAtari911 if (file_exists($changelogFile)) { 20519ccd446eSAtari911 $changelog = file_get_contents($changelogFile); 20529ccd446eSAtari911 20539ccd446eSAtari911 // Parse ALL versions into structured data 20549ccd446eSAtari911 $lines = explode("\n", $changelog); 20559ccd446eSAtari911 $versions = []; 20569ccd446eSAtari911 $currentVersion = null; 2057*7e8ea635SAtari911 $currentSubsection = ''; 20589ccd446eSAtari911 20599ccd446eSAtari911 foreach ($lines as $line) { 2060*7e8ea635SAtari911 $trimmed = trim($line); 20619ccd446eSAtari911 20629ccd446eSAtari911 // Version header (## Version X.X.X or ## Version X.X.X (date) - title) 2063*7e8ea635SAtari911 if (preg_match('/^## Version (.+?)(?:\s*\(([^)]+)\))?\s*(?:-\s*(.+))?$/', $trimmed, $matches)) { 20649ccd446eSAtari911 if ($currentVersion !== null) { 20659ccd446eSAtari911 $versions[] = $currentVersion; 20669ccd446eSAtari911 } 20679ccd446eSAtari911 $currentVersion = [ 20689ccd446eSAtari911 'number' => trim($matches[1]), 20699ccd446eSAtari911 'date' => isset($matches[2]) ? trim($matches[2]) : '', 20709ccd446eSAtari911 'title' => isset($matches[3]) ? trim($matches[3]) : '', 20719ccd446eSAtari911 'items' => [] 20729ccd446eSAtari911 ]; 2073*7e8ea635SAtari911 $currentSubsection = ''; 20749ccd446eSAtari911 } 2075*7e8ea635SAtari911 // Subsection header (### Something) 2076*7e8ea635SAtari911 elseif ($currentVersion !== null && preg_match('/^### (.+)$/', $trimmed, $matches)) { 2077*7e8ea635SAtari911 $currentSubsection = trim($matches[1]); 20789ccd446eSAtari911 $currentVersion['items'][] = [ 2079*7e8ea635SAtari911 'type' => 'section', 2080*7e8ea635SAtari911 'desc' => $currentSubsection 2081*7e8ea635SAtari911 ]; 2082*7e8ea635SAtari911 } 2083*7e8ea635SAtari911 // Formatted item (- **Type:** description) 2084*7e8ea635SAtari911 elseif ($currentVersion !== null && preg_match('/^- \*\*(.+?):\*\*\s*(.+)$/', $trimmed, $matches)) { 2085*7e8ea635SAtari911 $currentVersion['items'][] = [ 2086*7e8ea635SAtari911 'type' => trim($matches[1]), 2087*7e8ea635SAtari911 'desc' => trim($matches[2]) 2088*7e8ea635SAtari911 ]; 2089*7e8ea635SAtari911 } 2090*7e8ea635SAtari911 // Plain bullet item (- something) 2091*7e8ea635SAtari911 elseif ($currentVersion !== null && preg_match('/^- (.+)$/', $trimmed, $matches)) { 2092*7e8ea635SAtari911 $currentVersion['items'][] = [ 2093*7e8ea635SAtari911 'type' => $currentSubsection ?: 'Changed', 2094*7e8ea635SAtari911 'desc' => trim($matches[1]) 20959ccd446eSAtari911 ]; 20969ccd446eSAtari911 } 20979ccd446eSAtari911 } 2098*7e8ea635SAtari911 // Don't forget last version 20999ccd446eSAtari911 if ($currentVersion !== null) { 21009ccd446eSAtari911 $versions[] = $currentVersion; 21019ccd446eSAtari911 } 21029ccd446eSAtari911 21039ccd446eSAtari911 $totalVersions = count($versions); 21049ccd446eSAtari911 $uniqueId = 'changelog_' . substr(md5(microtime()), 0, 6); 21059ccd446eSAtari911 2106*7e8ea635SAtari911 // Find the index of the currently running version 2107*7e8ea635SAtari911 $runningVersion = trim($info['version']); 2108*7e8ea635SAtari911 $runningIndex = 0; 2109*7e8ea635SAtari911 foreach ($versions as $idx => $ver) { 2110*7e8ea635SAtari911 if (trim($ver['number']) === $runningVersion) { 2111*7e8ea635SAtari911 $runningIndex = $idx; 2112*7e8ea635SAtari911 break; 2113*7e8ea635SAtari911 } 2114*7e8ea635SAtari911 } 2115*7e8ea635SAtari911 21169ccd446eSAtari911 if ($totalVersions > 0) { 21179ccd446eSAtari911 // Timeline navigation bar 21189ccd446eSAtari911 echo '<div id="' . $uniqueId . '_wrap" style="position:relative;">'; 21199ccd446eSAtari911 21209ccd446eSAtari911 // Nav controls 21219ccd446eSAtari911 echo '<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">'; 2122*7e8ea635SAtari911 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>'; 2123*7e8ea635SAtari911 echo '<div style="flex:1; text-align:center; display:flex; align-items:center; justify-content:center; gap:10px;">'; 21249ccd446eSAtari911 echo '<span id="' . $uniqueId . '_counter" style="font-size:11px; color:' . $colors['text'] . '; opacity:0.7;">1 of ' . $totalVersions . '</span>'; 2125*7e8ea635SAtari911 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>'; 21269ccd446eSAtari911 echo '</div>'; 2127*7e8ea635SAtari911 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>'; 21289ccd446eSAtari911 echo '</div>'; 21299ccd446eSAtari911 21309ccd446eSAtari911 // Version cards (one per version, only first visible) 21319ccd446eSAtari911 foreach ($versions as $i => $ver) { 21329ccd446eSAtari911 $display = ($i === 0) ? 'block' : 'none'; 2133*7e8ea635SAtari911 $isRunning = (trim($ver['number']) === $runningVersion); 2134*7e8ea635SAtari911 $cardBorder = $isRunning ? '2px solid #00cc07' : '1px solid ' . $colors['border']; 2135*7e8ea635SAtari911 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;">'; 21369ccd446eSAtari911 21379ccd446eSAtari911 // Version header 21389ccd446eSAtari911 echo '<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px;">'; 2139*7e8ea635SAtari911 echo '<span style="font-weight:bold; color:#00cc07; font-size:14px;">v' . hsc($ver['number']) . '</span>'; 2140*7e8ea635SAtari911 if ($isRunning) { 2141*7e8ea635SAtari911 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>'; 2142*7e8ea635SAtari911 } 21439ccd446eSAtari911 if ($ver['date']) { 21449ccd446eSAtari911 echo '<span style="font-size:11px; color:' . $colors['text'] . '; opacity:0.6;">' . hsc($ver['date']) . '</span>'; 21459ccd446eSAtari911 } 21469ccd446eSAtari911 echo '</div>'; 21479ccd446eSAtari911 if ($ver['title']) { 21489ccd446eSAtari911 echo '<div style="font-size:12px; font-weight:600; color:' . $colors['text'] . '; margin-bottom:8px;">' . hsc($ver['title']) . '</div>'; 21499ccd446eSAtari911 } 21509ccd446eSAtari911 21519ccd446eSAtari911 // Change items 21529ccd446eSAtari911 if (!empty($ver['items'])) { 21539ccd446eSAtari911 echo '<div style="font-size:12px; line-height:1.7;">'; 21549ccd446eSAtari911 foreach ($ver['items'] as $item) { 2155*7e8ea635SAtari911 if ($item['type'] === 'section') { 2156*7e8ea635SAtari911 echo '<div style="margin:6px 0 2px 0; font-weight:700; color:#00cc07; font-size:11px; letter-spacing:0.3px;">' . hsc($item['desc']) . '</div>'; 2157*7e8ea635SAtari911 continue; 2158*7e8ea635SAtari911 } 21599ccd446eSAtari911 $color = '#666'; $icon = '•'; 21609ccd446eSAtari911 $t = $item['type']; 2161*7e8ea635SAtari911 if ($t === 'Added' || $t === 'New') { $color = '#28a745'; $icon = '✨'; } 2162*7e8ea635SAtari911 elseif ($t === 'Fixed' || $t === 'Fix' || $t === 'Bug Fix') { $color = '#dc3545'; $icon = ''; } 2163*7e8ea635SAtari911 elseif ($t === 'Changed' || $t === 'Change') { $color = '#00cc07'; $icon = ''; } 2164*7e8ea635SAtari911 elseif ($t === 'Improved' || $t === 'Enhancement') { $color = '#ff9800'; $icon = '⚡'; } 21659ccd446eSAtari911 elseif ($t === 'Removed') { $color = '#e91e63'; $icon = '️'; } 21669ccd446eSAtari911 elseif ($t === 'Development' || $t === 'Refactored') { $color = '#6c757d'; $icon = '️'; } 21679ccd446eSAtari911 elseif ($t === 'Result') { $color = '#2196f3'; $icon = '✅'; } 2168*7e8ea635SAtari911 else { $color = $colors['text']; $icon = '•'; } 21699ccd446eSAtari911 21709ccd446eSAtari911 echo '<div style="margin:2px 0; padding-left:4px;">'; 21719ccd446eSAtari911 echo '<span style="color:' . $color . '; font-weight:600;">' . $icon . ' ' . hsc($item['type']) . ':</span> '; 21729ccd446eSAtari911 echo '<span style="color:' . $colors['text'] . ';">' . hsc($item['desc']) . '</span>'; 21739ccd446eSAtari911 echo '</div>'; 21749ccd446eSAtari911 } 21759ccd446eSAtari911 echo '</div>'; 21769ccd446eSAtari911 } else { 21779ccd446eSAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . '; opacity:0.5; font-style:italic;">No detailed changes recorded</div>'; 21789ccd446eSAtari911 } 21799ccd446eSAtari911 21809ccd446eSAtari911 echo '</div>'; 21819ccd446eSAtari911 } 21829ccd446eSAtari911 21839ccd446eSAtari911 echo '</div>'; // wrap 21849ccd446eSAtari911 21859ccd446eSAtari911 // JavaScript for navigation 21869ccd446eSAtari911 echo '<script> 21879ccd446eSAtari911 (function() { 21889ccd446eSAtari911 var id = "' . $uniqueId . '"; 21899ccd446eSAtari911 var total = ' . $totalVersions . '; 21909ccd446eSAtari911 var current = 0; 21919ccd446eSAtari911 2192*7e8ea635SAtari911 function showCard(idx) { 21939ccd446eSAtari911 // Hide current 21949ccd446eSAtari911 var curCard = document.getElementById(id + "_card_" + current); 21959ccd446eSAtari911 if (curCard) curCard.style.display = "none"; 21969ccd446eSAtari911 2197*7e8ea635SAtari911 // Show target 2198*7e8ea635SAtari911 current = idx; 21999ccd446eSAtari911 var nextCard = document.getElementById(id + "_card_" + current); 22009ccd446eSAtari911 if (nextCard) nextCard.style.display = "block"; 22019ccd446eSAtari911 22029ccd446eSAtari911 // Update counter 22039ccd446eSAtari911 var counter = document.getElementById(id + "_counter"); 22049ccd446eSAtari911 if (counter) counter.textContent = (current + 1) + " of " + total; 22059ccd446eSAtari911 22069ccd446eSAtari911 // Update button states 22079ccd446eSAtari911 var prevBtn = document.getElementById(id + "_prev"); 22089ccd446eSAtari911 var nextBtn = document.getElementById(id + "_next"); 22099ccd446eSAtari911 if (prevBtn) prevBtn.style.opacity = (current === 0) ? "0.3" : "1"; 22109ccd446eSAtari911 if (nextBtn) nextBtn.style.opacity = (current === total - 1) ? "0.3" : "1"; 2211*7e8ea635SAtari911 } 2212*7e8ea635SAtari911 2213*7e8ea635SAtari911 window.changelogNav = function(uid, dir) { 2214*7e8ea635SAtari911 if (uid !== id) return; 2215*7e8ea635SAtari911 var next = current + dir; 2216*7e8ea635SAtari911 if (next < 0 || next >= total) return; 2217*7e8ea635SAtari911 showCard(next); 2218*7e8ea635SAtari911 }; 2219*7e8ea635SAtari911 2220*7e8ea635SAtari911 window.changelogJumpTo = function(uid, idx) { 2221*7e8ea635SAtari911 if (uid !== id) return; 2222*7e8ea635SAtari911 if (idx < 0 || idx >= total) return; 2223*7e8ea635SAtari911 showCard(idx); 22249ccd446eSAtari911 }; 22259ccd446eSAtari911 22269ccd446eSAtari911 // Initialize button states 22279ccd446eSAtari911 var prevBtn = document.getElementById(id + "_prev"); 22289ccd446eSAtari911 if (prevBtn) prevBtn.style.opacity = "0.3"; 22299ccd446eSAtari911 })(); 22309ccd446eSAtari911 </script>'; 22319ccd446eSAtari911 22329ccd446eSAtari911 } else { 22339ccd446eSAtari911 echo '<p style="color:#999; font-size:13px; font-style:italic;">No versions found in changelog</p>'; 22349ccd446eSAtari911 } 22359ccd446eSAtari911 } else { 22369ccd446eSAtari911 echo '<p style="color:#999; font-size:13px; font-style:italic;">Changelog not available</p>'; 22379ccd446eSAtari911 } 22389ccd446eSAtari911 22399ccd446eSAtari911 echo '</div>'; 22409ccd446eSAtari911 22419ccd446eSAtari911 // Backup list or manual backup section 22421d05cddcSAtari911 $backupDir = DOKU_PLUGIN; 22431d05cddcSAtari911 $backups = glob($backupDir . 'calendar*.zip'); 22441d05cddcSAtari911 22451d05cddcSAtari911 // Filter to only show files that look like backups (not the uploaded plugin files) 22461d05cddcSAtari911 $backups = array_filter($backups, function($file) { 22471d05cddcSAtari911 $name = basename($file); 22481d05cddcSAtari911 // Include files that start with "calendar" but exclude files that are just "calendar.zip" (uploaded plugin) 22491d05cddcSAtari911 return $name !== 'calendar.zip'; 22501d05cddcSAtari911 }); 22511d05cddcSAtari911 22529ccd446eSAtari911 // Always show backup section (even if no backups yet) 22539ccd446eSAtari911 echo '<div id="backupSection" style="background:' . $colors['bg'] . '; padding:12px; margin:10px 0; border-left:3px solid #00cc07; border-radius:3px; max-width:900px;">'; 22549ccd446eSAtari911 echo '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">'; 22559ccd446eSAtari911 echo '<h3 style="margin:0; color:#00cc07; font-size:16px;"> Backups</h3>'; 22569ccd446eSAtari911 22579ccd446eSAtari911 // Manual backup button 22589ccd446eSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=update" style="margin:0;">'; 22599ccd446eSAtari911 echo '<input type="hidden" name="action" value="create_manual_backup">'; 22609ccd446eSAtari911 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>'; 22619ccd446eSAtari911 echo '</form>'; 22629ccd446eSAtari911 echo '</div>'; 22639ccd446eSAtari911 22641d05cddcSAtari911 if (!empty($backups)) { 22651d05cddcSAtari911 rsort($backups); // Newest first 22669ccd446eSAtari911 echo '<div style="max-height:200px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px; background:' . $colors['bg'] . ';">'; 22679ccd446eSAtari911 echo '<table id="backupTable" style="width:100%; border-collapse:collapse; font-size:12px;">'; 22681d05cddcSAtari911 echo '<thead style="position:sticky; top:0; background:#e9e9e9;">'; 22691d05cddcSAtari911 echo '<tr>'; 22709ccd446eSAtari911 echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Backup File</th>'; 22719ccd446eSAtari911 echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Size</th>'; 22729ccd446eSAtari911 echo '<th style="padding:6px; text-align:left; border-bottom:2px solid ' . $colors['border'] . ';">Actions</th>'; 22731d05cddcSAtari911 echo '</tr></thead><tbody>'; 22741d05cddcSAtari911 22751d05cddcSAtari911 foreach ($backups as $backup) { 22761d05cddcSAtari911 $filename = basename($backup); 22771d05cddcSAtari911 $size = $this->formatBytes(filesize($backup)); 22781d05cddcSAtari911 echo '<tr style="border-bottom:1px solid #eee;">'; 22791d05cddcSAtari911 echo '<td style="padding:6px;"><code style="font-size:11px;">' . hsc($filename) . '</code></td>'; 22801d05cddcSAtari911 echo '<td style="padding:6px;">' . $size . '</td>'; 22811d05cddcSAtari911 echo '<td style="padding:6px; white-space:nowrap;">'; 22821d05cddcSAtari911 echo '<a href="' . DOKU_BASE . 'lib/plugins/' . hsc($filename) . '" download style="color:#00cc07; text-decoration:none; font-size:11px; margin-right:10px;"> Download</a>'; 22831d05cddcSAtari911 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; margin-right:5px;">✏️ Rename</button>'; 22841d05cddcSAtari911 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>'; 22851d05cddcSAtari911 echo '<button onclick="deleteBackup(\'' . hsc(addslashes($filename)) . '\')" style="background:#e74c3c; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px;">️ Delete</button>'; 22861d05cddcSAtari911 echo '</td>'; 22871d05cddcSAtari911 echo '</tr>'; 22881d05cddcSAtari911 } 22891d05cddcSAtari911 22901d05cddcSAtari911 echo '</tbody></table>'; 22911d05cddcSAtari911 echo '</div>'; 22929ccd446eSAtari911 } else { 22939ccd446eSAtari911 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>'; 22941d05cddcSAtari911 } 22959ccd446eSAtari911 echo '</div>'; 22961d05cddcSAtari911 22971d05cddcSAtari911 echo '<script> 22981d05cddcSAtari911 function confirmUpload() { 22991d05cddcSAtari911 const fileInput = document.querySelector(\'input[name="plugin_zip"]\'); 23001d05cddcSAtari911 if (!fileInput.files[0]) { 23011d05cddcSAtari911 alert("Please select a ZIP file"); 23021d05cddcSAtari911 return false; 23031d05cddcSAtari911 } 23041d05cddcSAtari911 23051d05cddcSAtari911 const fileName = fileInput.files[0].name; 23061d05cddcSAtari911 if (!fileName.endsWith(".zip")) { 23071d05cddcSAtari911 alert("Please select a ZIP file"); 23081d05cddcSAtari911 return false; 23091d05cddcSAtari911 } 23101d05cddcSAtari911 23111d05cddcSAtari911 return confirm("Upload and install: " + fileName + "?\\n\\nThis will replace all plugin files.\\nYour configuration and data will be preserved.\\n\\nContinue?"); 23121d05cddcSAtari911 } 23131d05cddcSAtari911 23141d05cddcSAtari911 function deleteBackup(filename) { 23151d05cddcSAtari911 if (!confirm("Delete backup: " + filename + "?\\n\\nThis cannot be undone!")) { 23161d05cddcSAtari911 return; 23171d05cddcSAtari911 } 23181d05cddcSAtari911 23199ccd446eSAtari911 // Use AJAX to delete without page refresh 23209ccd446eSAtari911 const formData = new FormData(); 23219ccd446eSAtari911 formData.append(\'action\', \'delete_backup\'); 23229ccd446eSAtari911 formData.append(\'backup_file\', filename); 23231d05cddcSAtari911 23249ccd446eSAtari911 fetch(\'?do=admin&page=calendar&tab=update\', { 23259ccd446eSAtari911 method: \'POST\', 23269ccd446eSAtari911 body: formData 23279ccd446eSAtari911 }) 23289ccd446eSAtari911 .then(response => response.text()) 23299ccd446eSAtari911 .then(data => { 23309ccd446eSAtari911 // Remove the row from the table 23319ccd446eSAtari911 const rows = document.querySelectorAll(\'tr\'); 23329ccd446eSAtari911 rows.forEach(row => { 23339ccd446eSAtari911 if (row.textContent.includes(filename)) { 23349ccd446eSAtari911 row.style.transition = \'opacity 0.3s\'; 23359ccd446eSAtari911 row.style.opacity = \'0\'; 23369ccd446eSAtari911 setTimeout(() => { 23379ccd446eSAtari911 row.remove(); 23389ccd446eSAtari911 // Check if table is now empty 23399ccd446eSAtari911 const tbody = document.querySelector(\'#backupTable tbody\'); 23409ccd446eSAtari911 if (tbody && tbody.children.length === 0) { 23419ccd446eSAtari911 const backupSection = document.querySelector(\'#backupSection\'); 23429ccd446eSAtari911 if (backupSection) { 23439ccd446eSAtari911 backupSection.style.transition = \'opacity 0.3s\'; 23449ccd446eSAtari911 backupSection.style.opacity = \'0\'; 23459ccd446eSAtari911 setTimeout(() => backupSection.remove(), 300); 23469ccd446eSAtari911 } 23479ccd446eSAtari911 } 23489ccd446eSAtari911 }, 300); 23499ccd446eSAtari911 } 23509ccd446eSAtari911 }); 23511d05cddcSAtari911 23529ccd446eSAtari911 // Show success message 23539ccd446eSAtari911 const msg = document.createElement(\'div\'); 23549ccd446eSAtari911 msg.style.cssText = \'padding:10px; margin:10px 0; border-left:3px solid #28a745; background:#d4edda; border-radius:3px; max-width:900px; transition:opacity 0.3s;\'; 23559ccd446eSAtari911 msg.textContent = \'✓ Backup deleted: \' + filename; 23569ccd446eSAtari911 document.querySelector(\'h2\').after(msg); 23579ccd446eSAtari911 setTimeout(() => { 23589ccd446eSAtari911 msg.style.opacity = \'0\'; 23599ccd446eSAtari911 setTimeout(() => msg.remove(), 300); 23609ccd446eSAtari911 }, 3000); 23619ccd446eSAtari911 }) 23629ccd446eSAtari911 .catch(error => { 23639ccd446eSAtari911 alert(\'Error deleting backup: \' + error); 23649ccd446eSAtari911 }); 23651d05cddcSAtari911 } 23661d05cddcSAtari911 23671d05cddcSAtari911 function restoreBackup(filename) { 23681d05cddcSAtari911 if (!confirm("Restore from backup: " + filename + "?\\n\\nThis will replace all current plugin files with the backup version.\\nYour current configuration will be replaced with the backed up configuration.\\n\\nContinue?")) { 23691d05cddcSAtari911 return; 23701d05cddcSAtari911 } 23711d05cddcSAtari911 23721d05cddcSAtari911 const form = document.createElement("form"); 23731d05cddcSAtari911 form.method = "POST"; 23741d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=update"; 23751d05cddcSAtari911 23761d05cddcSAtari911 const actionInput = document.createElement("input"); 23771d05cddcSAtari911 actionInput.type = "hidden"; 23781d05cddcSAtari911 actionInput.name = "action"; 23791d05cddcSAtari911 actionInput.value = "restore_backup"; 23801d05cddcSAtari911 form.appendChild(actionInput); 23811d05cddcSAtari911 23821d05cddcSAtari911 const filenameInput = document.createElement("input"); 23831d05cddcSAtari911 filenameInput.type = "hidden"; 23841d05cddcSAtari911 filenameInput.name = "backup_file"; 23851d05cddcSAtari911 filenameInput.value = filename; 23861d05cddcSAtari911 form.appendChild(filenameInput); 23871d05cddcSAtari911 23881d05cddcSAtari911 document.body.appendChild(form); 23891d05cddcSAtari911 form.submit(); 23901d05cddcSAtari911 } 23911d05cddcSAtari911 23921d05cddcSAtari911 function renameBackup(filename) { 23931d05cddcSAtari911 const newName = prompt("Enter new backup name (without .zip extension):\\n\\nCurrent: " + filename.replace(/\\.zip$/, ""), filename.replace(/\\.zip$/, "")); 23941d05cddcSAtari911 if (!newName || newName === filename.replace(/\\.zip$/, "")) { 23951d05cddcSAtari911 return; 23961d05cddcSAtari911 } 23971d05cddcSAtari911 23981d05cddcSAtari911 // Add .zip if not present 23991d05cddcSAtari911 const newFilename = newName.endsWith(".zip") ? newName : newName + ".zip"; 24001d05cddcSAtari911 24011d05cddcSAtari911 // Basic validation 24021d05cddcSAtari911 if (!/^[a-zA-Z0-9._-]+$/.test(newFilename.replace(/\\.zip$/, ""))) { 24031d05cddcSAtari911 alert("Invalid filename. Use only letters, numbers, dots, dashes, and underscores."); 24041d05cddcSAtari911 return; 24051d05cddcSAtari911 } 24061d05cddcSAtari911 24071d05cddcSAtari911 const form = document.createElement("form"); 24081d05cddcSAtari911 form.method = "POST"; 24091d05cddcSAtari911 form.action = "?do=admin&page=calendar&tab=update"; 24101d05cddcSAtari911 24111d05cddcSAtari911 const actionInput = document.createElement("input"); 24121d05cddcSAtari911 actionInput.type = "hidden"; 24131d05cddcSAtari911 actionInput.name = "action"; 24141d05cddcSAtari911 actionInput.value = "rename_backup"; 24151d05cddcSAtari911 form.appendChild(actionInput); 24161d05cddcSAtari911 24171d05cddcSAtari911 const oldNameInput = document.createElement("input"); 24181d05cddcSAtari911 oldNameInput.type = "hidden"; 24191d05cddcSAtari911 oldNameInput.name = "old_name"; 24201d05cddcSAtari911 oldNameInput.value = filename; 24211d05cddcSAtari911 form.appendChild(oldNameInput); 24221d05cddcSAtari911 24231d05cddcSAtari911 const newNameInput = document.createElement("input"); 24241d05cddcSAtari911 newNameInput.type = "hidden"; 24251d05cddcSAtari911 newNameInput.name = "new_name"; 24261d05cddcSAtari911 newNameInput.value = newFilename; 24271d05cddcSAtari911 form.appendChild(newNameInput); 24281d05cddcSAtari911 24291d05cddcSAtari911 document.body.appendChild(form); 24301d05cddcSAtari911 form.submit(); 24311d05cddcSAtari911 } 24321d05cddcSAtari911 </script>'; 24331d05cddcSAtari911 } 24341d05cddcSAtari911 24351d05cddcSAtari911 private function saveConfig() { 24361d05cddcSAtari911 global $INPUT; 24371d05cddcSAtari911 24381d05cddcSAtari911 // Load existing config to preserve all settings 24391d05cddcSAtari911 $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; 24401d05cddcSAtari911 $existingConfig = []; 24411d05cddcSAtari911 if (file_exists($configFile)) { 24421d05cddcSAtari911 $existingConfig = include $configFile; 24431d05cddcSAtari911 } 24441d05cddcSAtari911 24451d05cddcSAtari911 // Update only the fields from the form - preserve everything else 24461d05cddcSAtari911 $config = $existingConfig; 24471d05cddcSAtari911 24481d05cddcSAtari911 // Update basic fields 24491d05cddcSAtari911 $config['tenant_id'] = $INPUT->str('tenant_id'); 24501d05cddcSAtari911 $config['client_id'] = $INPUT->str('client_id'); 24511d05cddcSAtari911 $config['client_secret'] = $INPUT->str('client_secret'); 24521d05cddcSAtari911 $config['user_email'] = $INPUT->str('user_email'); 24531d05cddcSAtari911 $config['timezone'] = $INPUT->str('timezone', 'America/Los_Angeles'); 24541d05cddcSAtari911 $config['default_category'] = $INPUT->str('default_category', 'Blue category'); 24551d05cddcSAtari911 $config['reminder_minutes'] = $INPUT->int('reminder_minutes', 15); 24561d05cddcSAtari911 $config['sync_completed_tasks'] = $INPUT->bool('sync_completed_tasks'); 24571d05cddcSAtari911 $config['delete_outlook_events'] = $INPUT->bool('delete_outlook_events'); 24581d05cddcSAtari911 $config['sync_all_namespaces'] = $INPUT->bool('sync_all_namespaces'); 24591d05cddcSAtari911 $config['sync_namespaces'] = $INPUT->arr('sync_namespaces'); 24604590242dSAtari911 // important_namespaces is managed from the Manage tab, preserve existing value 24614590242dSAtari911 if (!isset($config['important_namespaces'])) { 24624590242dSAtari911 $config['important_namespaces'] = 'important'; 24634590242dSAtari911 } 24641d05cddcSAtari911 24651d05cddcSAtari911 // Parse category mapping 24661d05cddcSAtari911 $config['category_mapping'] = []; 24671d05cddcSAtari911 $mappingText = $INPUT->str('category_mapping'); 24681d05cddcSAtari911 if ($mappingText) { 24691d05cddcSAtari911 $lines = explode("\n", $mappingText); 24701d05cddcSAtari911 foreach ($lines as $line) { 24711d05cddcSAtari911 $line = trim($line); 24721d05cddcSAtari911 if (empty($line)) continue; 24731d05cddcSAtari911 $parts = explode('=', $line, 2); 24741d05cddcSAtari911 if (count($parts) === 2) { 24751d05cddcSAtari911 $config['category_mapping'][trim($parts[0])] = trim($parts[1]); 24761d05cddcSAtari911 } 24771d05cddcSAtari911 } 24781d05cddcSAtari911 } 24791d05cddcSAtari911 24801d05cddcSAtari911 // Parse color mapping from dropdown selections 24811d05cddcSAtari911 $config['color_mapping'] = []; 24821d05cddcSAtari911 $colorMappingCount = $INPUT->int('color_mapping_count', 0); 24831d05cddcSAtari911 for ($i = 0; $i < $colorMappingCount; $i++) { 24841d05cddcSAtari911 $hexColor = $INPUT->str('color_hex_' . $i); 24851d05cddcSAtari911 $category = $INPUT->str('color_map_' . $i); 24861d05cddcSAtari911 24871d05cddcSAtari911 if (!empty($hexColor) && !empty($category)) { 24881d05cddcSAtari911 $config['color_mapping'][$hexColor] = $category; 24891d05cddcSAtari911 } 24901d05cddcSAtari911 } 24911d05cddcSAtari911 24921d05cddcSAtari911 // Build file content using return format 24931d05cddcSAtari911 $content = "<?php\n"; 24941d05cddcSAtari911 $content .= "/**\n"; 24951d05cddcSAtari911 $content .= " * DokuWiki Calendar → Outlook Sync - Configuration\n"; 24961d05cddcSAtari911 $content .= " * \n"; 24971d05cddcSAtari911 $content .= " * SECURITY: Add this file to .gitignore!\n"; 24981d05cddcSAtari911 $content .= " * Never commit credentials to version control.\n"; 24991d05cddcSAtari911 $content .= " */\n\n"; 25001d05cddcSAtari911 $content .= "return " . var_export($config, true) . ";\n"; 25011d05cddcSAtari911 25021d05cddcSAtari911 // Save file 25031d05cddcSAtari911 if (file_put_contents($configFile, $content)) { 25041d05cddcSAtari911 $this->redirect('Configuration saved successfully!', 'success'); 25051d05cddcSAtari911 } else { 25061d05cddcSAtari911 $this->redirect('Error: Could not save configuration file', 'error'); 25071d05cddcSAtari911 } 25081d05cddcSAtari911 } 25091d05cddcSAtari911 25101d05cddcSAtari911 private function clearCache() { 25111d05cddcSAtari911 // Clear DokuWiki cache 25121d05cddcSAtari911 $cacheDir = DOKU_INC . 'data/cache'; 25131d05cddcSAtari911 25141d05cddcSAtari911 if (is_dir($cacheDir)) { 25151d05cddcSAtari911 $this->recursiveDelete($cacheDir, false); 25161d05cddcSAtari911 $this->redirect('Cache cleared successfully!', 'success', 'update'); 25171d05cddcSAtari911 } else { 25181d05cddcSAtari911 $this->redirect('Cache directory not found', 'error', 'update'); 25191d05cddcSAtari911 } 25201d05cddcSAtari911 } 25211d05cddcSAtari911 25221d05cddcSAtari911 private function recursiveDelete($dir, $deleteRoot = true) { 25231d05cddcSAtari911 if (!is_dir($dir)) return; 25241d05cddcSAtari911 25251d05cddcSAtari911 $files = array_diff(scandir($dir), array('.', '..')); 25261d05cddcSAtari911 foreach ($files as $file) { 25271d05cddcSAtari911 $path = $dir . '/' . $file; 25281d05cddcSAtari911 if (is_dir($path)) { 25291d05cddcSAtari911 $this->recursiveDelete($path, true); 25301d05cddcSAtari911 } else { 25311d05cddcSAtari911 @unlink($path); 25321d05cddcSAtari911 } 25331d05cddcSAtari911 } 25341d05cddcSAtari911 25351d05cddcSAtari911 if ($deleteRoot) { 25361d05cddcSAtari911 @rmdir($dir); 25371d05cddcSAtari911 } 25381d05cddcSAtari911 } 25391d05cddcSAtari911 25401d05cddcSAtari911 private function findRecurringEvents() { 25411d05cddcSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 25421d05cddcSAtari911 $recurring = []; 25431d05cddcSAtari911 $allEvents = []; // Track all events to detect patterns 2544*7e8ea635SAtari911 $flaggedSeries = []; // Track events with recurring flag by recurringId 25451d05cddcSAtari911 2546*7e8ea635SAtari911 // Helper to process events from a calendar directory 2547*7e8ea635SAtari911 $processCalendarDir = function($calDir, $fallbackNamespace) use (&$allEvents, &$flaggedSeries) { 2548*7e8ea635SAtari911 if (!is_dir($calDir)) return; 2549*7e8ea635SAtari911 2550*7e8ea635SAtari911 foreach (glob($calDir . '/*.json') as $file) { 25511d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 2552*7e8ea635SAtari911 if (!$data || !is_array($data)) continue; 25531d05cddcSAtari911 25541d05cddcSAtari911 foreach ($data as $dateKey => $events) { 2555*7e8ea635SAtari911 if (!is_array($events)) continue; 25561d05cddcSAtari911 foreach ($events as $event) { 2557*7e8ea635SAtari911 if (!isset($event['title']) || empty(trim($event['title']))) continue; 25581d05cddcSAtari911 2559*7e8ea635SAtari911 $ns = isset($event['namespace']) ? $event['namespace'] : $fallbackNamespace; 2560*7e8ea635SAtari911 2561*7e8ea635SAtari911 // If event has recurring flag, group by recurringId 2562*7e8ea635SAtari911 if (!empty($event['recurring']) && !empty($event['recurringId'])) { 2563*7e8ea635SAtari911 $rid = $event['recurringId']; 2564*7e8ea635SAtari911 if (!isset($flaggedSeries[$rid])) { 2565*7e8ea635SAtari911 $flaggedSeries[$rid] = [ 25661d05cddcSAtari911 'title' => $event['title'], 2567*7e8ea635SAtari911 'namespace' => $ns, 25681d05cddcSAtari911 'dates' => [], 25691d05cddcSAtari911 'events' => [] 25701d05cddcSAtari911 ]; 25711d05cddcSAtari911 } 2572*7e8ea635SAtari911 $flaggedSeries[$rid]['dates'][] = $dateKey; 2573*7e8ea635SAtari911 $flaggedSeries[$rid]['events'][] = $event; 25741d05cddcSAtari911 } 25751d05cddcSAtari911 2576*7e8ea635SAtari911 // Also group by title+namespace for pattern detection 2577*7e8ea635SAtari911 $groupKey = strtolower(trim($event['title'])) . '|' . $ns; 25781d05cddcSAtari911 25791d05cddcSAtari911 if (!isset($allEvents[$groupKey])) { 25801d05cddcSAtari911 $allEvents[$groupKey] = [ 25811d05cddcSAtari911 'title' => $event['title'], 2582*7e8ea635SAtari911 'namespace' => $ns, 25831d05cddcSAtari911 'dates' => [], 2584*7e8ea635SAtari911 'events' => [], 2585*7e8ea635SAtari911 'hasFlag' => false 25861d05cddcSAtari911 ]; 25871d05cddcSAtari911 } 25881d05cddcSAtari911 $allEvents[$groupKey]['dates'][] = $dateKey; 25891d05cddcSAtari911 $allEvents[$groupKey]['events'][] = $event; 2590*7e8ea635SAtari911 if (!empty($event['recurring'])) { 2591*7e8ea635SAtari911 $allEvents[$groupKey]['hasFlag'] = true; 25921d05cddcSAtari911 } 25931d05cddcSAtari911 } 25941d05cddcSAtari911 } 25951d05cddcSAtari911 } 2596*7e8ea635SAtari911 }; 2597*7e8ea635SAtari911 2598*7e8ea635SAtari911 // Check root calendar directory (blank/default namespace) 2599*7e8ea635SAtari911 $processCalendarDir($dataDir . 'calendar', ''); 2600*7e8ea635SAtari911 2601*7e8ea635SAtari911 // Scan all namespace directories (including nested) 2602*7e8ea635SAtari911 $this->scanNamespaceDirs($dataDir, $processCalendarDir); 2603*7e8ea635SAtari911 2604*7e8ea635SAtari911 // Deduplicate: remove from allEvents groups that are fully covered by flaggedSeries 2605*7e8ea635SAtari911 $flaggedTitleNs = []; 2606*7e8ea635SAtari911 foreach ($flaggedSeries as $rid => $series) { 2607*7e8ea635SAtari911 $key = strtolower(trim($series['title'])) . '|' . $series['namespace']; 2608*7e8ea635SAtari911 $flaggedTitleNs[$key] = $rid; 2609*7e8ea635SAtari911 } 26101d05cddcSAtari911 2611*7e8ea635SAtari911 // Build results from flaggedSeries first (known recurring) 2612*7e8ea635SAtari911 $seen = []; 2613*7e8ea635SAtari911 foreach ($flaggedSeries as $rid => $series) { 2614*7e8ea635SAtari911 sort($series['dates']); 2615*7e8ea635SAtari911 $dedupDates = array_unique($series['dates']); 2616*7e8ea635SAtari911 2617*7e8ea635SAtari911 $pattern = $this->detectRecurrencePattern($dedupDates); 2618*7e8ea635SAtari911 2619*7e8ea635SAtari911 $recurring[] = [ 2620*7e8ea635SAtari911 'baseId' => $rid, 2621*7e8ea635SAtari911 'title' => $series['title'], 2622*7e8ea635SAtari911 'namespace' => $series['namespace'], 2623*7e8ea635SAtari911 'pattern' => $pattern, 2624*7e8ea635SAtari911 'count' => count($dedupDates), 2625*7e8ea635SAtari911 'firstDate' => $dedupDates[0], 2626*7e8ea635SAtari911 'hasFlag' => true 2627*7e8ea635SAtari911 ]; 2628*7e8ea635SAtari911 $seen[strtolower(trim($series['title'])) . '|' . $series['namespace']] = true; 2629*7e8ea635SAtari911 } 2630*7e8ea635SAtari911 2631*7e8ea635SAtari911 // Add pattern-detected recurring (3+ occurrences, not already in flaggedSeries) 26321d05cddcSAtari911 foreach ($allEvents as $groupKey => $group) { 2633*7e8ea635SAtari911 if (isset($seen[$groupKey])) continue; 26341d05cddcSAtari911 2635*7e8ea635SAtari911 $dedupDates = array_unique($group['dates']); 2636*7e8ea635SAtari911 sort($dedupDates); 26371d05cddcSAtari911 2638*7e8ea635SAtari911 if (count($dedupDates) < 3) continue; 26391d05cddcSAtari911 2640*7e8ea635SAtari911 $pattern = $this->detectRecurrencePattern($dedupDates); 2641*7e8ea635SAtari911 26421d05cddcSAtari911 $baseId = isset($group['events'][0]['recurringId']) 26431d05cddcSAtari911 ? $group['events'][0]['recurringId'] 26441d05cddcSAtari911 : md5($group['title'] . $group['namespace']); 26451d05cddcSAtari911 26461d05cddcSAtari911 $recurring[] = [ 26471d05cddcSAtari911 'baseId' => $baseId, 26481d05cddcSAtari911 'title' => $group['title'], 26491d05cddcSAtari911 'namespace' => $group['namespace'], 26501d05cddcSAtari911 'pattern' => $pattern, 2651*7e8ea635SAtari911 'count' => count($dedupDates), 2652*7e8ea635SAtari911 'firstDate' => $dedupDates[0], 2653*7e8ea635SAtari911 'hasFlag' => $group['hasFlag'] 26541d05cddcSAtari911 ]; 26551d05cddcSAtari911 } 2656*7e8ea635SAtari911 2657*7e8ea635SAtari911 // Sort by title 2658*7e8ea635SAtari911 usort($recurring, function($a, $b) { 2659*7e8ea635SAtari911 return strcasecmp($a['title'], $b['title']); 2660*7e8ea635SAtari911 }); 2661*7e8ea635SAtari911 2662*7e8ea635SAtari911 return $recurring; 2663*7e8ea635SAtari911 } 2664*7e8ea635SAtari911 2665*7e8ea635SAtari911 /** 2666*7e8ea635SAtari911 * Recursively scan namespace directories for calendar data 2667*7e8ea635SAtari911 */ 2668*7e8ea635SAtari911 private function scanNamespaceDirs($baseDir, $callback) { 2669*7e8ea635SAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 2670*7e8ea635SAtari911 $namespace = basename($nsDir); 2671*7e8ea635SAtari911 2672*7e8ea635SAtari911 // Skip the root 'calendar' dir (already processed) 2673*7e8ea635SAtari911 if ($namespace === 'calendar') continue; 2674*7e8ea635SAtari911 2675*7e8ea635SAtari911 $calendarDir = $nsDir . '/calendar'; 2676*7e8ea635SAtari911 if (is_dir($calendarDir)) { 2677*7e8ea635SAtari911 // Derive namespace from path relative to meta dir 2678*7e8ea635SAtari911 $metaDir = DOKU_INC . 'data/meta/'; 2679*7e8ea635SAtari911 $relPath = str_replace($metaDir, '', $nsDir); 2680*7e8ea635SAtari911 $ns = str_replace('/', ':', trim($relPath, '/')); 2681*7e8ea635SAtari911 $callback($calendarDir, $ns); 2682*7e8ea635SAtari911 } 2683*7e8ea635SAtari911 2684*7e8ea635SAtari911 // Recurse into subdirectories for nested namespaces 2685*7e8ea635SAtari911 $this->scanNamespaceDirs($nsDir . '/', $callback); 2686*7e8ea635SAtari911 } 26871d05cddcSAtari911 } 26881d05cddcSAtari911 2689*7e8ea635SAtari911 /** 2690*7e8ea635SAtari911 * Detect recurrence pattern from sorted dates using median interval 2691*7e8ea635SAtari911 */ 2692*7e8ea635SAtari911 private function detectRecurrencePattern($dates) { 2693*7e8ea635SAtari911 if (count($dates) < 2) return 'Single'; 2694*7e8ea635SAtari911 2695*7e8ea635SAtari911 // Calculate all intervals between consecutive dates 2696*7e8ea635SAtari911 $intervals = []; 2697*7e8ea635SAtari911 for ($i = 1; $i < count($dates); $i++) { 2698*7e8ea635SAtari911 try { 2699*7e8ea635SAtari911 $d1 = new DateTime($dates[$i - 1]); 2700*7e8ea635SAtari911 $d2 = new DateTime($dates[$i]); 2701*7e8ea635SAtari911 $intervals[] = $d1->diff($d2)->days; 2702*7e8ea635SAtari911 } catch (Exception $e) { 2703*7e8ea635SAtari911 continue; 2704*7e8ea635SAtari911 } 2705*7e8ea635SAtari911 } 2706*7e8ea635SAtari911 2707*7e8ea635SAtari911 if (empty($intervals)) return 'Custom'; 2708*7e8ea635SAtari911 2709*7e8ea635SAtari911 // Use median interval (more robust than first pair) 2710*7e8ea635SAtari911 sort($intervals); 2711*7e8ea635SAtari911 $mid = floor(count($intervals) / 2); 2712*7e8ea635SAtari911 $median = (count($intervals) % 2 === 0) 2713*7e8ea635SAtari911 ? ($intervals[$mid - 1] + $intervals[$mid]) / 2 2714*7e8ea635SAtari911 : $intervals[$mid]; 2715*7e8ea635SAtari911 2716*7e8ea635SAtari911 if ($median <= 1) return 'Daily'; 2717*7e8ea635SAtari911 if ($median >= 6 && $median <= 8) return 'Weekly'; 2718*7e8ea635SAtari911 if ($median >= 13 && $median <= 16) return 'Bi-weekly'; 2719*7e8ea635SAtari911 if ($median >= 27 && $median <= 32) return 'Monthly'; 2720*7e8ea635SAtari911 if ($median >= 89 && $median <= 93) return 'Quarterly'; 2721*7e8ea635SAtari911 if ($median >= 180 && $median <= 186) return 'Semi-annual'; 2722*7e8ea635SAtari911 if ($median >= 363 && $median <= 368) return 'Yearly'; 2723*7e8ea635SAtari911 2724*7e8ea635SAtari911 return 'Every ~' . round($median) . ' days'; 2725*7e8ea635SAtari911 } 2726*7e8ea635SAtari911 2727*7e8ea635SAtari911 /** 2728*7e8ea635SAtari911 * Render the recurring events table HTML 2729*7e8ea635SAtari911 */ 2730*7e8ea635SAtari911 private function renderRecurringTable($recurringEvents, $colors) { 2731*7e8ea635SAtari911 if (empty($recurringEvents)) { 2732*7e8ea635SAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:13px; margin:5px 0;">No recurring events found.</p>'; 2733*7e8ea635SAtari911 return; 2734*7e8ea635SAtari911 } 2735*7e8ea635SAtari911 2736*7e8ea635SAtari911 // Search bar 2737*7e8ea635SAtari911 echo '<div style="margin-bottom:8px;">'; 2738*7e8ea635SAtari911 echo '<input type="text" id="searchRecurring" onkeyup="filterRecurringEvents()" placeholder=" Search recurring events..." style="width:100%; padding:6px 10px; border:1px solid ' . $colors['border'] . '; border-radius:3px; font-size:12px;">'; 2739*7e8ea635SAtari911 echo '</div>'; 2740*7e8ea635SAtari911 2741*7e8ea635SAtari911 echo '<style> 2742*7e8ea635SAtari911 .sort-arrow { 2743*7e8ea635SAtari911 color: #999; 2744*7e8ea635SAtari911 font-size: 10px; 2745*7e8ea635SAtari911 margin-left: 3px; 2746*7e8ea635SAtari911 display: inline-block; 2747*7e8ea635SAtari911 } 2748*7e8ea635SAtari911 #recurringTable th:hover { 2749*7e8ea635SAtari911 background: #ddd; 2750*7e8ea635SAtari911 } 2751*7e8ea635SAtari911 #recurringTable th:hover .sort-arrow { 2752*7e8ea635SAtari911 color: #00cc07; 2753*7e8ea635SAtari911 } 2754*7e8ea635SAtari911 .recurring-row-hidden { 2755*7e8ea635SAtari911 display: none; 2756*7e8ea635SAtari911 } 2757*7e8ea635SAtari911 </style>'; 2758*7e8ea635SAtari911 echo '<div style="max-height:250px; overflow-y:auto; border:1px solid ' . $colors['border'] . '; border-radius:3px;">'; 2759*7e8ea635SAtari911 echo '<table id="recurringTable" style="width:100%; border-collapse:collapse; font-size:11px;">'; 2760*7e8ea635SAtari911 echo '<thead style="position:sticky; top:0; background:#e9e9e9;">'; 2761*7e8ea635SAtari911 echo '<tr>'; 2762*7e8ea635SAtari911 echo '<th onclick="sortRecurringTable(0)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Title <span class="sort-arrow">⇅</span></th>'; 2763*7e8ea635SAtari911 echo '<th onclick="sortRecurringTable(1)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Namespace <span class="sort-arrow">⇅</span></th>'; 2764*7e8ea635SAtari911 echo '<th onclick="sortRecurringTable(2)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Pattern <span class="sort-arrow">⇅</span></th>'; 2765*7e8ea635SAtari911 echo '<th onclick="sortRecurringTable(3)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">First <span class="sort-arrow">⇅</span></th>'; 2766*7e8ea635SAtari911 echo '<th onclick="sortRecurringTable(4)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Count <span class="sort-arrow">⇅</span></th>'; 2767*7e8ea635SAtari911 echo '<th onclick="sortRecurringTable(5)" style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd; cursor:pointer; user-select:none;">Source <span class="sort-arrow">⇅</span></th>'; 2768*7e8ea635SAtari911 echo '<th style="padding:4px 6px; text-align:left; border-bottom:2px solid #ddd;">Actions</th>'; 2769*7e8ea635SAtari911 echo '</tr></thead><tbody id="recurringTableBody">'; 2770*7e8ea635SAtari911 2771*7e8ea635SAtari911 foreach ($recurringEvents as $series) { 2772*7e8ea635SAtari911 $sourceLabel = $series['hasFlag'] ? '️ Flagged' : ' Detected'; 2773*7e8ea635SAtari911 $sourceColor = $series['hasFlag'] ? '#00cc07' : '#ff9800'; 2774*7e8ea635SAtari911 echo '<tr style="border-bottom:1px solid #eee;">'; 2775*7e8ea635SAtari911 echo '<td style="padding:4px 6px;">' . hsc($series['title']) . '</td>'; 2776*7e8ea635SAtari911 echo '<td style="padding:4px 6px;"><code style="background:#f0f0f0; padding:1px 3px; border-radius:2px; font-size:10px;">' . hsc($series['namespace'] ?: '(default)') . '</code></td>'; 2777*7e8ea635SAtari911 echo '<td style="padding:4px 6px;">' . hsc($series['pattern']) . '</td>'; 2778*7e8ea635SAtari911 echo '<td style="padding:4px 6px;">' . hsc($series['firstDate']) . '</td>'; 2779*7e8ea635SAtari911 echo '<td style="padding:4px 6px;"><strong>' . $series['count'] . '</strong></td>'; 2780*7e8ea635SAtari911 echo '<td style="padding:4px 6px;"><span style="color:' . $sourceColor . '; font-size:10px;">' . $sourceLabel . '</span></td>'; 2781*7e8ea635SAtari911 echo '<td style="padding:4px 6px; white-space:nowrap;">'; 2782*7e8ea635SAtari911 $jsTitle = hsc(addslashes($series['title'])); 2783*7e8ea635SAtari911 $jsNs = hsc($series['namespace']); 2784*7e8ea635SAtari911 $jsCount = $series['count']; 2785*7e8ea635SAtari911 $jsFirst = hsc($series['firstDate']); 2786*7e8ea635SAtari911 $jsPattern = hsc($series['pattern']); 2787*7e8ea635SAtari911 $jsHasFlag = $series['hasFlag'] ? 'true' : 'false'; 2788*7e8ea635SAtari911 echo '<button onclick="editRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\')" style="background:#00cc07; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px; margin-right:2px;" title="Edit title, time, namespace, interval">Edit</button>'; 2789*7e8ea635SAtari911 echo '<button onclick="manageRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\', ' . $jsCount . ', \'' . $jsFirst . '\', \'' . $jsPattern . '\', ' . $jsHasFlag . ')" style="background:#ff9800; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px; margin-right:2px;" title="Extend, trim, pause, change start">Manage</button>'; 2790*7e8ea635SAtari911 echo '<button onclick="deleteRecurringSeries(\'' . $jsTitle . '\', \'' . $jsNs . '\')" style="background:#e74c3c; color:white; border:none; padding:2px 6px; border-radius:2px; cursor:pointer; font-size:10px;" title="Delete all occurrences">Del</button>'; 2791*7e8ea635SAtari911 echo '</td>'; 2792*7e8ea635SAtari911 echo '</tr>'; 2793*7e8ea635SAtari911 } 2794*7e8ea635SAtari911 2795*7e8ea635SAtari911 echo '</tbody></table>'; 2796*7e8ea635SAtari911 echo '</div>'; 2797*7e8ea635SAtari911 echo '<p style="color:' . $colors['text'] . '; font-size:10px; margin:5px 0 0;">Total: ' . count($recurringEvents) . ' series</p>'; 2798*7e8ea635SAtari911 } 2799*7e8ea635SAtari911 2800*7e8ea635SAtari911 /** 2801*7e8ea635SAtari911 * AJAX handler: rescan recurring events and return HTML 2802*7e8ea635SAtari911 */ 2803*7e8ea635SAtari911 private function handleCleanupEmptyNamespaces() { 2804*7e8ea635SAtari911 global $INPUT; 2805*7e8ea635SAtari911 $dryRun = $INPUT->bool('dry_run', false); 2806*7e8ea635SAtari911 2807*7e8ea635SAtari911 $metaDir = DOKU_INC . 'data/meta/'; 2808*7e8ea635SAtari911 $details = []; 2809*7e8ea635SAtari911 $removedDirs = 0; 2810*7e8ea635SAtari911 $removedCalDirs = 0; 2811*7e8ea635SAtari911 2812*7e8ea635SAtari911 // 1. Find all calendar/ subdirectories anywhere under data/meta/ 2813*7e8ea635SAtari911 $allCalDirs = []; 2814*7e8ea635SAtari911 $this->findAllCalendarDirsRecursive($metaDir, $allCalDirs); 2815*7e8ea635SAtari911 2816*7e8ea635SAtari911 // 2. Check each calendar dir for empty JSON files 2817*7e8ea635SAtari911 foreach ($allCalDirs as $calDir) { 2818*7e8ea635SAtari911 $jsonFiles = glob($calDir . '/*.json'); 2819*7e8ea635SAtari911 $hasEvents = false; 2820*7e8ea635SAtari911 2821*7e8ea635SAtari911 foreach ($jsonFiles as $jsonFile) { 2822*7e8ea635SAtari911 $data = json_decode(file_get_contents($jsonFile), true); 2823*7e8ea635SAtari911 if ($data && is_array($data)) { 2824*7e8ea635SAtari911 // Check if any date key has actual events 2825*7e8ea635SAtari911 foreach ($data as $dateKey => $events) { 2826*7e8ea635SAtari911 if (is_array($events) && !empty($events)) { 2827*7e8ea635SAtari911 $hasEvents = true; 2828*7e8ea635SAtari911 break 2; 2829*7e8ea635SAtari911 } 2830*7e8ea635SAtari911 } 2831*7e8ea635SAtari911 // JSON file has data but all dates are empty — remove it 2832*7e8ea635SAtari911 if (!$dryRun) unlink($jsonFile); 2833*7e8ea635SAtari911 } 2834*7e8ea635SAtari911 } 2835*7e8ea635SAtari911 2836*7e8ea635SAtari911 // Re-check after cleaning empty JSON files 2837*7e8ea635SAtari911 if (!$dryRun) { 2838*7e8ea635SAtari911 $jsonFiles = glob($calDir . '/*.json'); 2839*7e8ea635SAtari911 } 2840*7e8ea635SAtari911 2841*7e8ea635SAtari911 // Derive display name from path 2842*7e8ea635SAtari911 $relPath = str_replace($metaDir, '', $calDir); 2843*7e8ea635SAtari911 $relPath = rtrim(str_replace('/calendar', '', $relPath), '/'); 2844*7e8ea635SAtari911 $displayName = $relPath ?: '(root)'; 2845*7e8ea635SAtari911 2846*7e8ea635SAtari911 if ($displayName === '(root)') continue; // Never remove root calendar dir 2847*7e8ea635SAtari911 2848*7e8ea635SAtari911 if (!$hasEvents || empty($jsonFiles)) { 2849*7e8ea635SAtari911 $removedCalDirs++; 2850*7e8ea635SAtari911 $details[] = "Remove empty calendar folder: " . $displayName . "/calendar/ (0 events)"; 2851*7e8ea635SAtari911 2852*7e8ea635SAtari911 if (!$dryRun) { 2853*7e8ea635SAtari911 // Remove all remaining files in calendar dir 2854*7e8ea635SAtari911 foreach (glob($calDir . '/*') as $f) { 2855*7e8ea635SAtari911 if (is_file($f)) unlink($f); 2856*7e8ea635SAtari911 } 2857*7e8ea635SAtari911 @rmdir($calDir); 2858*7e8ea635SAtari911 2859*7e8ea635SAtari911 // Check if parent namespace dir is now empty too 2860*7e8ea635SAtari911 $parentDir = dirname($calDir); 2861*7e8ea635SAtari911 if ($parentDir !== $metaDir && is_dir($parentDir)) { 2862*7e8ea635SAtari911 $remaining = array_diff(scandir($parentDir), ['.', '..']); 2863*7e8ea635SAtari911 if (empty($remaining)) { 2864*7e8ea635SAtari911 @rmdir($parentDir); 2865*7e8ea635SAtari911 $removedDirs++; 2866*7e8ea635SAtari911 $details[] = "Removed empty namespace directory: " . $displayName . "/"; 2867*7e8ea635SAtari911 } 2868*7e8ea635SAtari911 } 2869*7e8ea635SAtari911 } 2870*7e8ea635SAtari911 } 2871*7e8ea635SAtari911 } 2872*7e8ea635SAtari911 2873*7e8ea635SAtari911 // 3. Also scan for namespace dirs that have a calendar/ subdir with 0 json files 2874*7e8ea635SAtari911 // (already covered above, but also check for namespace dirs without calendar/ at all 2875*7e8ea635SAtari911 // that are tracked in the event system) 2876*7e8ea635SAtari911 2877*7e8ea635SAtari911 $total = $removedCalDirs + $removedDirs; 2878*7e8ea635SAtari911 $message = $dryRun 2879*7e8ea635SAtari911 ? "Found $total item(s) to clean up" 2880*7e8ea635SAtari911 : "Cleaned up $removedCalDirs empty calendar folder(s)" . ($removedDirs > 0 ? " and $removedDirs empty namespace directory(ies)" : ""); 2881*7e8ea635SAtari911 2882*7e8ea635SAtari911 if (!$dryRun) $this->clearStatsCache(); 2883*7e8ea635SAtari911 2884*7e8ea635SAtari911 echo json_encode([ 2885*7e8ea635SAtari911 'success' => true, 2886*7e8ea635SAtari911 'count' => $total, 2887*7e8ea635SAtari911 'message' => $message, 2888*7e8ea635SAtari911 'details' => $details 2889*7e8ea635SAtari911 ]); 2890*7e8ea635SAtari911 } 2891*7e8ea635SAtari911 2892*7e8ea635SAtari911 /** 2893*7e8ea635SAtari911 * Recursively find all 'calendar' directories under a base path 2894*7e8ea635SAtari911 */ 2895*7e8ea635SAtari911 private function findAllCalendarDirsRecursive($baseDir, &$results) { 2896*7e8ea635SAtari911 $entries = glob($baseDir . '*', GLOB_ONLYDIR); 2897*7e8ea635SAtari911 if (!$entries) return; 2898*7e8ea635SAtari911 2899*7e8ea635SAtari911 foreach ($entries as $dir) { 2900*7e8ea635SAtari911 $name = basename($dir); 2901*7e8ea635SAtari911 if ($name === 'calendar') { 2902*7e8ea635SAtari911 $results[] = $dir; 2903*7e8ea635SAtari911 } else { 2904*7e8ea635SAtari911 // Check for calendar subdir 2905*7e8ea635SAtari911 if (is_dir($dir . '/calendar')) { 2906*7e8ea635SAtari911 $results[] = $dir . '/calendar'; 2907*7e8ea635SAtari911 } 2908*7e8ea635SAtari911 // Recurse into subdirectories for nested namespaces 2909*7e8ea635SAtari911 $this->findAllCalendarDirsRecursive($dir . '/', $results); 2910*7e8ea635SAtari911 } 2911*7e8ea635SAtari911 } 2912*7e8ea635SAtari911 } 2913*7e8ea635SAtari911 2914*7e8ea635SAtari911 private function handleTrimAllPastRecurring() { 2915*7e8ea635SAtari911 global $INPUT; 2916*7e8ea635SAtari911 $dryRun = $INPUT->bool('dry_run', false); 2917*7e8ea635SAtari911 $today = date('Y-m-d'); 2918*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 2919*7e8ea635SAtari911 $calendarDirs = []; 2920*7e8ea635SAtari911 2921*7e8ea635SAtari911 if (is_dir($dataDir . 'calendar')) { 2922*7e8ea635SAtari911 $calendarDirs[] = $dataDir . 'calendar'; 2923*7e8ea635SAtari911 } 2924*7e8ea635SAtari911 $this->findCalendarDirs($dataDir, $calendarDirs); 2925*7e8ea635SAtari911 2926*7e8ea635SAtari911 $removed = 0; 2927*7e8ea635SAtari911 2928*7e8ea635SAtari911 foreach ($calendarDirs as $calDir) { 2929*7e8ea635SAtari911 foreach (glob($calDir . '/*.json') as $file) { 2930*7e8ea635SAtari911 $data = json_decode(file_get_contents($file), true); 2931*7e8ea635SAtari911 if (!$data || !is_array($data)) continue; 2932*7e8ea635SAtari911 2933*7e8ea635SAtari911 $modified = false; 2934*7e8ea635SAtari911 foreach ($data as $dateKey => &$dayEvents) { 2935*7e8ea635SAtari911 if ($dateKey >= $today) continue; 2936*7e8ea635SAtari911 if (!is_array($dayEvents)) continue; 2937*7e8ea635SAtari911 2938*7e8ea635SAtari911 $filtered = []; 2939*7e8ea635SAtari911 foreach ($dayEvents as $event) { 2940*7e8ea635SAtari911 if (!empty($event['recurring']) || !empty($event['recurringId'])) { 2941*7e8ea635SAtari911 $removed++; 2942*7e8ea635SAtari911 if (!$dryRun) $modified = true; 2943*7e8ea635SAtari911 } else { 2944*7e8ea635SAtari911 $filtered[] = $event; 2945*7e8ea635SAtari911 } 2946*7e8ea635SAtari911 } 2947*7e8ea635SAtari911 if (!$dryRun) $dayEvents = $filtered; 2948*7e8ea635SAtari911 } 2949*7e8ea635SAtari911 unset($dayEvents); 2950*7e8ea635SAtari911 2951*7e8ea635SAtari911 if (!$dryRun && $modified) { 2952*7e8ea635SAtari911 foreach ($data as $dk => $evts) { 2953*7e8ea635SAtari911 if (empty($evts)) unset($data[$dk]); 2954*7e8ea635SAtari911 } 2955*7e8ea635SAtari911 if (empty($data)) { 2956*7e8ea635SAtari911 unlink($file); 2957*7e8ea635SAtari911 } else { 2958*7e8ea635SAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 2959*7e8ea635SAtari911 } 2960*7e8ea635SAtari911 } 2961*7e8ea635SAtari911 } 2962*7e8ea635SAtari911 } 2963*7e8ea635SAtari911 2964*7e8ea635SAtari911 if (!$dryRun) $this->clearStatsCache(); 2965*7e8ea635SAtari911 echo json_encode(['success' => true, 'count' => $removed, 'message' => "Removed $removed past recurring occurrences"]); 2966*7e8ea635SAtari911 } 2967*7e8ea635SAtari911 2968*7e8ea635SAtari911 private function handleRescanRecurring() { 2969*7e8ea635SAtari911 $colors = $this->getTemplateColors(); 2970*7e8ea635SAtari911 $recurringEvents = $this->findRecurringEvents(); 2971*7e8ea635SAtari911 2972*7e8ea635SAtari911 ob_start(); 2973*7e8ea635SAtari911 $this->renderRecurringTable($recurringEvents, $colors); 2974*7e8ea635SAtari911 $html = ob_get_clean(); 2975*7e8ea635SAtari911 2976*7e8ea635SAtari911 echo json_encode([ 2977*7e8ea635SAtari911 'success' => true, 2978*7e8ea635SAtari911 'html' => $html, 2979*7e8ea635SAtari911 'count' => count($recurringEvents) 2980*7e8ea635SAtari911 ]); 2981*7e8ea635SAtari911 } 2982*7e8ea635SAtari911 2983*7e8ea635SAtari911 /** 2984*7e8ea635SAtari911 * Helper: find all events matching a title in a namespace's calendar dir 2985*7e8ea635SAtari911 */ 2986*7e8ea635SAtari911 private function getRecurringSeriesEvents($title, $namespace) { 2987*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 2988*7e8ea635SAtari911 if ($namespace !== '') { 2989*7e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 2990*7e8ea635SAtari911 } 2991*7e8ea635SAtari911 $dataDir .= 'calendar/'; 2992*7e8ea635SAtari911 2993*7e8ea635SAtari911 $events = []; // ['date' => dateKey, 'file' => filepath, 'event' => eventData, 'index' => idx] 2994*7e8ea635SAtari911 2995*7e8ea635SAtari911 if (!is_dir($dataDir)) return $events; 2996*7e8ea635SAtari911 2997*7e8ea635SAtari911 foreach (glob($dataDir . '*.json') as $file) { 2998*7e8ea635SAtari911 $data = json_decode(file_get_contents($file), true); 2999*7e8ea635SAtari911 if (!$data || !is_array($data)) continue; 3000*7e8ea635SAtari911 3001*7e8ea635SAtari911 foreach ($data as $dateKey => $dayEvents) { 3002*7e8ea635SAtari911 if (!is_array($dayEvents)) continue; 3003*7e8ea635SAtari911 foreach ($dayEvents as $idx => $event) { 3004*7e8ea635SAtari911 if (strtolower(trim($event['title'])) === strtolower(trim($title))) { 3005*7e8ea635SAtari911 $events[] = [ 3006*7e8ea635SAtari911 'date' => $dateKey, 3007*7e8ea635SAtari911 'file' => $file, 3008*7e8ea635SAtari911 'event' => $event, 3009*7e8ea635SAtari911 'index' => $idx 3010*7e8ea635SAtari911 ]; 3011*7e8ea635SAtari911 } 3012*7e8ea635SAtari911 } 3013*7e8ea635SAtari911 } 3014*7e8ea635SAtari911 } 3015*7e8ea635SAtari911 3016*7e8ea635SAtari911 // Sort by date 3017*7e8ea635SAtari911 usort($events, function($a, $b) { 3018*7e8ea635SAtari911 return strcmp($a['date'], $b['date']); 3019*7e8ea635SAtari911 }); 3020*7e8ea635SAtari911 3021*7e8ea635SAtari911 return $events; 3022*7e8ea635SAtari911 } 3023*7e8ea635SAtari911 3024*7e8ea635SAtari911 /** 3025*7e8ea635SAtari911 * Extend series: add more future occurrences 3026*7e8ea635SAtari911 */ 3027*7e8ea635SAtari911 private function handleExtendRecurring() { 3028*7e8ea635SAtari911 global $INPUT; 3029*7e8ea635SAtari911 $title = $INPUT->str('title'); 3030*7e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 3031*7e8ea635SAtari911 $count = $INPUT->int('count', 4); 3032*7e8ea635SAtari911 $intervalDays = $INPUT->int('interval_days', 7); 3033*7e8ea635SAtari911 3034*7e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 3035*7e8ea635SAtari911 if (empty($events)) { 3036*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Series not found']); 3037*7e8ea635SAtari911 return; 3038*7e8ea635SAtari911 } 3039*7e8ea635SAtari911 3040*7e8ea635SAtari911 // Use last event as template 3041*7e8ea635SAtari911 $lastEvent = end($events); 3042*7e8ea635SAtari911 $lastDate = new DateTime($lastEvent['date']); 3043*7e8ea635SAtari911 $template = $lastEvent['event']; 3044*7e8ea635SAtari911 3045*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 3046*7e8ea635SAtari911 if ($namespace !== '') { 3047*7e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 3048*7e8ea635SAtari911 } 3049*7e8ea635SAtari911 $dataDir .= 'calendar/'; 3050*7e8ea635SAtari911 3051*7e8ea635SAtari911 if (!is_dir($dataDir)) mkdir($dataDir, 0755, true); 3052*7e8ea635SAtari911 3053*7e8ea635SAtari911 $added = 0; 3054*7e8ea635SAtari911 $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace); 3055*7e8ea635SAtari911 $maxExistingIdx = 0; 3056*7e8ea635SAtari911 foreach ($events as $e) { 3057*7e8ea635SAtari911 if (isset($e['event']['id']) && preg_match('/-(\d+)$/', $e['event']['id'], $m)) { 3058*7e8ea635SAtari911 $maxExistingIdx = max($maxExistingIdx, (int)$m[1]); 3059*7e8ea635SAtari911 } 3060*7e8ea635SAtari911 } 3061*7e8ea635SAtari911 3062*7e8ea635SAtari911 for ($i = 1; $i <= $count; $i++) { 3063*7e8ea635SAtari911 $newDate = clone $lastDate; 3064*7e8ea635SAtari911 $newDate->modify('+' . ($i * $intervalDays) . ' days'); 3065*7e8ea635SAtari911 $dateKey = $newDate->format('Y-m-d'); 3066*7e8ea635SAtari911 list($year, $month) = explode('-', $dateKey); 3067*7e8ea635SAtari911 3068*7e8ea635SAtari911 $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); 3069*7e8ea635SAtari911 $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : []; 3070*7e8ea635SAtari911 if (!is_array($fileData)) $fileData = []; 3071*7e8ea635SAtari911 3072*7e8ea635SAtari911 if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; 3073*7e8ea635SAtari911 3074*7e8ea635SAtari911 $newEvent = $template; 3075*7e8ea635SAtari911 $newEvent['id'] = $baseId . '-' . ($maxExistingIdx + $i); 3076*7e8ea635SAtari911 $newEvent['recurring'] = true; 3077*7e8ea635SAtari911 $newEvent['recurringId'] = $baseId; 3078*7e8ea635SAtari911 $newEvent['created'] = date('Y-m-d H:i:s'); 3079*7e8ea635SAtari911 unset($newEvent['completed']); 3080*7e8ea635SAtari911 $newEvent['completed'] = false; 3081*7e8ea635SAtari911 3082*7e8ea635SAtari911 $fileData[$dateKey][] = $newEvent; 3083*7e8ea635SAtari911 file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT)); 3084*7e8ea635SAtari911 $added++; 3085*7e8ea635SAtari911 } 3086*7e8ea635SAtari911 3087*7e8ea635SAtari911 $this->clearStatsCache(); 3088*7e8ea635SAtari911 echo json_encode(['success' => true, 'message' => "Added $added new occurrences"]); 3089*7e8ea635SAtari911 } 3090*7e8ea635SAtari911 3091*7e8ea635SAtari911 /** 3092*7e8ea635SAtari911 * Trim series: remove past occurrences before a cutoff date 3093*7e8ea635SAtari911 */ 3094*7e8ea635SAtari911 private function handleTrimRecurring() { 3095*7e8ea635SAtari911 global $INPUT; 3096*7e8ea635SAtari911 $title = $INPUT->str('title'); 3097*7e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 3098*7e8ea635SAtari911 $cutoffDate = $INPUT->str('cutoff_date', date('Y-m-d')); 3099*7e8ea635SAtari911 3100*7e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 3101*7e8ea635SAtari911 $removed = 0; 3102*7e8ea635SAtari911 3103*7e8ea635SAtari911 foreach ($events as $entry) { 3104*7e8ea635SAtari911 if ($entry['date'] < $cutoffDate) { 3105*7e8ea635SAtari911 // Remove this event from its file 3106*7e8ea635SAtari911 $data = json_decode(file_get_contents($entry['file']), true); 3107*7e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 3108*7e8ea635SAtari911 3109*7e8ea635SAtari911 // Find and remove by matching title 3110*7e8ea635SAtari911 foreach ($data[$entry['date']] as $k => $evt) { 3111*7e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 3112*7e8ea635SAtari911 unset($data[$entry['date']][$k]); 3113*7e8ea635SAtari911 $data[$entry['date']] = array_values($data[$entry['date']]); 3114*7e8ea635SAtari911 $removed++; 3115*7e8ea635SAtari911 break; 3116*7e8ea635SAtari911 } 3117*7e8ea635SAtari911 } 3118*7e8ea635SAtari911 3119*7e8ea635SAtari911 // Clean up empty dates 3120*7e8ea635SAtari911 if (empty($data[$entry['date']])) unset($data[$entry['date']]); 3121*7e8ea635SAtari911 3122*7e8ea635SAtari911 if (empty($data)) { 3123*7e8ea635SAtari911 unlink($entry['file']); 3124*7e8ea635SAtari911 } else { 3125*7e8ea635SAtari911 file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); 3126*7e8ea635SAtari911 } 3127*7e8ea635SAtari911 } 3128*7e8ea635SAtari911 } 3129*7e8ea635SAtari911 3130*7e8ea635SAtari911 $this->clearStatsCache(); 3131*7e8ea635SAtari911 echo json_encode(['success' => true, 'message' => "Removed $removed past occurrences before $cutoffDate"]); 3132*7e8ea635SAtari911 } 3133*7e8ea635SAtari911 3134*7e8ea635SAtari911 /** 3135*7e8ea635SAtari911 * Pause series: mark all future occurrences as paused 3136*7e8ea635SAtari911 */ 3137*7e8ea635SAtari911 private function handlePauseRecurring() { 3138*7e8ea635SAtari911 global $INPUT; 3139*7e8ea635SAtari911 $title = $INPUT->str('title'); 3140*7e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 3141*7e8ea635SAtari911 $today = date('Y-m-d'); 3142*7e8ea635SAtari911 3143*7e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 3144*7e8ea635SAtari911 $paused = 0; 3145*7e8ea635SAtari911 3146*7e8ea635SAtari911 foreach ($events as $entry) { 3147*7e8ea635SAtari911 if ($entry['date'] >= $today) { 3148*7e8ea635SAtari911 $data = json_decode(file_get_contents($entry['file']), true); 3149*7e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 3150*7e8ea635SAtari911 3151*7e8ea635SAtari911 foreach ($data[$entry['date']] as $k => &$evt) { 3152*7e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 3153*7e8ea635SAtari911 $evt['paused'] = true; 3154*7e8ea635SAtari911 $evt['title'] = '⏸ ' . preg_replace('/^⏸\s*/', '', $evt['title']); 3155*7e8ea635SAtari911 $paused++; 3156*7e8ea635SAtari911 break; 3157*7e8ea635SAtari911 } 3158*7e8ea635SAtari911 } 3159*7e8ea635SAtari911 unset($evt); 3160*7e8ea635SAtari911 3161*7e8ea635SAtari911 file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); 3162*7e8ea635SAtari911 } 3163*7e8ea635SAtari911 } 3164*7e8ea635SAtari911 3165*7e8ea635SAtari911 $this->clearStatsCache(); 3166*7e8ea635SAtari911 echo json_encode(['success' => true, 'message' => "Paused $paused future occurrences"]); 3167*7e8ea635SAtari911 } 3168*7e8ea635SAtari911 3169*7e8ea635SAtari911 /** 3170*7e8ea635SAtari911 * Resume series: unmark paused occurrences 3171*7e8ea635SAtari911 */ 3172*7e8ea635SAtari911 private function handleResumeRecurring() { 3173*7e8ea635SAtari911 global $INPUT; 3174*7e8ea635SAtari911 $title = $INPUT->str('title'); 3175*7e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 3176*7e8ea635SAtari911 3177*7e8ea635SAtari911 // Search for both paused and non-paused versions 3178*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 3179*7e8ea635SAtari911 if ($namespace !== '') { 3180*7e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 3181*7e8ea635SAtari911 } 3182*7e8ea635SAtari911 $dataDir .= 'calendar/'; 3183*7e8ea635SAtari911 3184*7e8ea635SAtari911 $resumed = 0; 3185*7e8ea635SAtari911 $cleanTitle = preg_replace('/^⏸\s*/', '', $title); 3186*7e8ea635SAtari911 3187*7e8ea635SAtari911 if (!is_dir($dataDir)) { 3188*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Directory not found']); 3189*7e8ea635SAtari911 return; 3190*7e8ea635SAtari911 } 3191*7e8ea635SAtari911 3192*7e8ea635SAtari911 foreach (glob($dataDir . '*.json') as $file) { 3193*7e8ea635SAtari911 $data = json_decode(file_get_contents($file), true); 3194*7e8ea635SAtari911 if (!$data) continue; 3195*7e8ea635SAtari911 3196*7e8ea635SAtari911 $modified = false; 3197*7e8ea635SAtari911 foreach ($data as $dateKey => &$dayEvents) { 3198*7e8ea635SAtari911 foreach ($dayEvents as $k => &$evt) { 3199*7e8ea635SAtari911 $evtCleanTitle = preg_replace('/^⏸\s*/', '', $evt['title']); 3200*7e8ea635SAtari911 if (strtolower(trim($evtCleanTitle)) === strtolower(trim($cleanTitle)) && 3201*7e8ea635SAtari911 (!empty($evt['paused']) || strpos($evt['title'], '⏸') === 0)) { 3202*7e8ea635SAtari911 $evt['paused'] = false; 3203*7e8ea635SAtari911 $evt['title'] = $cleanTitle; 3204*7e8ea635SAtari911 $resumed++; 3205*7e8ea635SAtari911 $modified = true; 3206*7e8ea635SAtari911 } 3207*7e8ea635SAtari911 } 3208*7e8ea635SAtari911 unset($evt); 3209*7e8ea635SAtari911 } 3210*7e8ea635SAtari911 unset($dayEvents); 3211*7e8ea635SAtari911 3212*7e8ea635SAtari911 if ($modified) { 3213*7e8ea635SAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 3214*7e8ea635SAtari911 } 3215*7e8ea635SAtari911 } 3216*7e8ea635SAtari911 3217*7e8ea635SAtari911 $this->clearStatsCache(); 3218*7e8ea635SAtari911 echo json_encode(['success' => true, 'message' => "Resumed $resumed occurrences"]); 3219*7e8ea635SAtari911 } 3220*7e8ea635SAtari911 3221*7e8ea635SAtari911 /** 3222*7e8ea635SAtari911 * Change start date: shift all occurrences by an offset 3223*7e8ea635SAtari911 */ 3224*7e8ea635SAtari911 private function handleChangeStartRecurring() { 3225*7e8ea635SAtari911 global $INPUT; 3226*7e8ea635SAtari911 $title = $INPUT->str('title'); 3227*7e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 3228*7e8ea635SAtari911 $newStartDate = $INPUT->str('new_start_date'); 3229*7e8ea635SAtari911 3230*7e8ea635SAtari911 if (empty($newStartDate)) { 3231*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'No start date provided']); 3232*7e8ea635SAtari911 return; 3233*7e8ea635SAtari911 } 3234*7e8ea635SAtari911 3235*7e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 3236*7e8ea635SAtari911 if (empty($events)) { 3237*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Series not found']); 3238*7e8ea635SAtari911 return; 3239*7e8ea635SAtari911 } 3240*7e8ea635SAtari911 3241*7e8ea635SAtari911 // Calculate offset from old first date to new first date 3242*7e8ea635SAtari911 $oldFirst = new DateTime($events[0]['date']); 3243*7e8ea635SAtari911 $newFirst = new DateTime($newStartDate); 3244*7e8ea635SAtari911 $offsetDays = (int)$oldFirst->diff($newFirst)->format('%r%a'); 3245*7e8ea635SAtari911 3246*7e8ea635SAtari911 if ($offsetDays === 0) { 3247*7e8ea635SAtari911 echo json_encode(['success' => true, 'message' => 'Start date unchanged']); 3248*7e8ea635SAtari911 return; 3249*7e8ea635SAtari911 } 3250*7e8ea635SAtari911 3251*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 3252*7e8ea635SAtari911 if ($namespace !== '') { 3253*7e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 3254*7e8ea635SAtari911 } 3255*7e8ea635SAtari911 $dataDir .= 'calendar/'; 3256*7e8ea635SAtari911 3257*7e8ea635SAtari911 // Collect all events to move 3258*7e8ea635SAtari911 $toMove = []; 3259*7e8ea635SAtari911 foreach ($events as $entry) { 3260*7e8ea635SAtari911 $oldDate = new DateTime($entry['date']); 3261*7e8ea635SAtari911 $newDate = clone $oldDate; 3262*7e8ea635SAtari911 $newDate->modify(($offsetDays > 0 ? '+' : '') . $offsetDays . ' days'); 3263*7e8ea635SAtari911 3264*7e8ea635SAtari911 $toMove[] = [ 3265*7e8ea635SAtari911 'oldDate' => $entry['date'], 3266*7e8ea635SAtari911 'newDate' => $newDate->format('Y-m-d'), 3267*7e8ea635SAtari911 'event' => $entry['event'], 3268*7e8ea635SAtari911 'file' => $entry['file'] 3269*7e8ea635SAtari911 ]; 3270*7e8ea635SAtari911 } 3271*7e8ea635SAtari911 3272*7e8ea635SAtari911 // Remove all from old positions 3273*7e8ea635SAtari911 foreach ($toMove as $move) { 3274*7e8ea635SAtari911 $data = json_decode(file_get_contents($move['file']), true); 3275*7e8ea635SAtari911 if (!$data || !isset($data[$move['oldDate']])) continue; 3276*7e8ea635SAtari911 3277*7e8ea635SAtari911 foreach ($data[$move['oldDate']] as $k => $evt) { 3278*7e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 3279*7e8ea635SAtari911 unset($data[$move['oldDate']][$k]); 3280*7e8ea635SAtari911 $data[$move['oldDate']] = array_values($data[$move['oldDate']]); 3281*7e8ea635SAtari911 break; 3282*7e8ea635SAtari911 } 3283*7e8ea635SAtari911 } 3284*7e8ea635SAtari911 if (empty($data[$move['oldDate']])) unset($data[$move['oldDate']]); 3285*7e8ea635SAtari911 if (empty($data)) { 3286*7e8ea635SAtari911 unlink($move['file']); 3287*7e8ea635SAtari911 } else { 3288*7e8ea635SAtari911 file_put_contents($move['file'], json_encode($data, JSON_PRETTY_PRINT)); 3289*7e8ea635SAtari911 } 3290*7e8ea635SAtari911 } 3291*7e8ea635SAtari911 3292*7e8ea635SAtari911 // Add to new positions 3293*7e8ea635SAtari911 $moved = 0; 3294*7e8ea635SAtari911 foreach ($toMove as $move) { 3295*7e8ea635SAtari911 list($year, $month) = explode('-', $move['newDate']); 3296*7e8ea635SAtari911 $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); 3297*7e8ea635SAtari911 $data = file_exists($file) ? json_decode(file_get_contents($file), true) : []; 3298*7e8ea635SAtari911 if (!is_array($data)) $data = []; 3299*7e8ea635SAtari911 3300*7e8ea635SAtari911 if (!isset($data[$move['newDate']])) $data[$move['newDate']] = []; 3301*7e8ea635SAtari911 $data[$move['newDate']][] = $move['event']; 3302*7e8ea635SAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 3303*7e8ea635SAtari911 $moved++; 3304*7e8ea635SAtari911 } 3305*7e8ea635SAtari911 3306*7e8ea635SAtari911 $dir = $offsetDays > 0 ? 'forward' : 'back'; 3307*7e8ea635SAtari911 $this->clearStatsCache(); 3308*7e8ea635SAtari911 echo json_encode(['success' => true, 'message' => "Shifted $moved occurrences $dir by " . abs($offsetDays) . " days"]); 3309*7e8ea635SAtari911 } 3310*7e8ea635SAtari911 3311*7e8ea635SAtari911 /** 3312*7e8ea635SAtari911 * Change pattern: re-space all future events with a new interval 3313*7e8ea635SAtari911 */ 3314*7e8ea635SAtari911 private function handleChangePatternRecurring() { 3315*7e8ea635SAtari911 global $INPUT; 3316*7e8ea635SAtari911 $title = $INPUT->str('title'); 3317*7e8ea635SAtari911 $namespace = $INPUT->str('namespace'); 3318*7e8ea635SAtari911 $newIntervalDays = $INPUT->int('interval_days', 7); 3319*7e8ea635SAtari911 3320*7e8ea635SAtari911 $events = $this->getRecurringSeriesEvents($title, $namespace); 3321*7e8ea635SAtari911 $today = date('Y-m-d'); 3322*7e8ea635SAtari911 3323*7e8ea635SAtari911 // Split into past and future 3324*7e8ea635SAtari911 $pastEvents = []; 3325*7e8ea635SAtari911 $futureEvents = []; 3326*7e8ea635SAtari911 foreach ($events as $e) { 3327*7e8ea635SAtari911 if ($e['date'] < $today) { 3328*7e8ea635SAtari911 $pastEvents[] = $e; 3329*7e8ea635SAtari911 } else { 3330*7e8ea635SAtari911 $futureEvents[] = $e; 3331*7e8ea635SAtari911 } 3332*7e8ea635SAtari911 } 3333*7e8ea635SAtari911 3334*7e8ea635SAtari911 if (empty($futureEvents)) { 3335*7e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'No future occurrences to respace']); 3336*7e8ea635SAtari911 return; 3337*7e8ea635SAtari911 } 3338*7e8ea635SAtari911 3339*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 3340*7e8ea635SAtari911 if ($namespace !== '') { 3341*7e8ea635SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 3342*7e8ea635SAtari911 } 3343*7e8ea635SAtari911 $dataDir .= 'calendar/'; 3344*7e8ea635SAtari911 3345*7e8ea635SAtari911 // Use first future event as anchor 3346*7e8ea635SAtari911 $anchorDate = new DateTime($futureEvents[0]['date']); 3347*7e8ea635SAtari911 3348*7e8ea635SAtari911 // Remove all future events from files 3349*7e8ea635SAtari911 foreach ($futureEvents as $entry) { 3350*7e8ea635SAtari911 $data = json_decode(file_get_contents($entry['file']), true); 3351*7e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 3352*7e8ea635SAtari911 3353*7e8ea635SAtari911 foreach ($data[$entry['date']] as $k => $evt) { 3354*7e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { 3355*7e8ea635SAtari911 unset($data[$entry['date']][$k]); 3356*7e8ea635SAtari911 $data[$entry['date']] = array_values($data[$entry['date']]); 3357*7e8ea635SAtari911 break; 3358*7e8ea635SAtari911 } 3359*7e8ea635SAtari911 } 3360*7e8ea635SAtari911 if (empty($data[$entry['date']])) unset($data[$entry['date']]); 3361*7e8ea635SAtari911 if (empty($data)) { 3362*7e8ea635SAtari911 unlink($entry['file']); 3363*7e8ea635SAtari911 } else { 3364*7e8ea635SAtari911 file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); 3365*7e8ea635SAtari911 } 3366*7e8ea635SAtari911 } 3367*7e8ea635SAtari911 3368*7e8ea635SAtari911 // Re-create with new spacing 3369*7e8ea635SAtari911 $template = $futureEvents[0]['event']; 3370*7e8ea635SAtari911 $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace); 3371*7e8ea635SAtari911 $count = count($futureEvents); 3372*7e8ea635SAtari911 $created = 0; 3373*7e8ea635SAtari911 3374*7e8ea635SAtari911 for ($i = 0; $i < $count; $i++) { 3375*7e8ea635SAtari911 $newDate = clone $anchorDate; 3376*7e8ea635SAtari911 $newDate->modify('+' . ($i * $newIntervalDays) . ' days'); 3377*7e8ea635SAtari911 $dateKey = $newDate->format('Y-m-d'); 3378*7e8ea635SAtari911 list($year, $month) = explode('-', $dateKey); 3379*7e8ea635SAtari911 3380*7e8ea635SAtari911 $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); 3381*7e8ea635SAtari911 $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : []; 3382*7e8ea635SAtari911 if (!is_array($fileData)) $fileData = []; 3383*7e8ea635SAtari911 3384*7e8ea635SAtari911 if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; 3385*7e8ea635SAtari911 3386*7e8ea635SAtari911 $newEvent = $template; 3387*7e8ea635SAtari911 $newEvent['id'] = $baseId . '-respace-' . $i; 3388*7e8ea635SAtari911 $newEvent['recurring'] = true; 3389*7e8ea635SAtari911 $newEvent['recurringId'] = $baseId; 3390*7e8ea635SAtari911 3391*7e8ea635SAtari911 $fileData[$dateKey][] = $newEvent; 3392*7e8ea635SAtari911 file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT)); 3393*7e8ea635SAtari911 $created++; 3394*7e8ea635SAtari911 } 3395*7e8ea635SAtari911 3396*7e8ea635SAtari911 $this->clearStatsCache(); 3397*7e8ea635SAtari911 $patternName = $this->intervalToPattern($newIntervalDays); 3398*7e8ea635SAtari911 echo json_encode(['success' => true, 'message' => "Respaced $created future occurrences to $patternName ($newIntervalDays days)"]); 3399*7e8ea635SAtari911 } 3400*7e8ea635SAtari911 3401*7e8ea635SAtari911 private function intervalToPattern($days) { 3402*7e8ea635SAtari911 if ($days == 1) return 'Daily'; 3403*7e8ea635SAtari911 if ($days == 7) return 'Weekly'; 3404*7e8ea635SAtari911 if ($days == 14) return 'Bi-weekly'; 3405*7e8ea635SAtari911 if ($days >= 28 && $days <= 31) return 'Monthly'; 3406*7e8ea635SAtari911 if ($days >= 89 && $days <= 93) return 'Quarterly'; 3407*7e8ea635SAtari911 if ($days >= 363 && $days <= 368) return 'Yearly'; 3408*7e8ea635SAtari911 return "Every $days days"; 34091d05cddcSAtari911 } 34101d05cddcSAtari911 34111d05cddcSAtari911 private function getEventsByNamespace() { 34121d05cddcSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 34131d05cddcSAtari911 $result = []; 34141d05cddcSAtari911 34151d05cddcSAtari911 // Check root calendar directory first (blank/default namespace) 34161d05cddcSAtari911 $rootCalendarDir = $dataDir . 'calendar'; 34171d05cddcSAtari911 if (is_dir($rootCalendarDir)) { 34181d05cddcSAtari911 $hasFiles = false; 34191d05cddcSAtari911 $events = []; 34201d05cddcSAtari911 34211d05cddcSAtari911 foreach (glob($rootCalendarDir . '/*.json') as $file) { 34221d05cddcSAtari911 $hasFiles = true; 34231d05cddcSAtari911 $month = basename($file, '.json'); 34241d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 34251d05cddcSAtari911 if (!$data) continue; 34261d05cddcSAtari911 34271d05cddcSAtari911 foreach ($data as $dateKey => $eventList) { 34281d05cddcSAtari911 foreach ($eventList as $event) { 34291d05cddcSAtari911 $events[] = [ 34301d05cddcSAtari911 'id' => $event['id'], 34311d05cddcSAtari911 'title' => $event['title'], 34321d05cddcSAtari911 'date' => $dateKey, 34331d05cddcSAtari911 'startTime' => $event['startTime'] ?? null, 34341d05cddcSAtari911 'month' => $month 34351d05cddcSAtari911 ]; 34361d05cddcSAtari911 } 34371d05cddcSAtari911 } 34381d05cddcSAtari911 } 34391d05cddcSAtari911 34401d05cddcSAtari911 // Add if it has JSON files (even if empty) 34411d05cddcSAtari911 if ($hasFiles) { 34421d05cddcSAtari911 $result[''] = ['events' => $events]; 34431d05cddcSAtari911 } 34441d05cddcSAtari911 } 34451d05cddcSAtari911 34461d05cddcSAtari911 // Recursively scan all namespace directories including sub-namespaces 34471d05cddcSAtari911 $this->scanNamespaceRecursive($dataDir, '', $result); 34481d05cddcSAtari911 34491d05cddcSAtari911 // Sort namespaces, but keep '' (default) first 34501d05cddcSAtari911 uksort($result, function($a, $b) { 34511d05cddcSAtari911 if ($a === '') return -1; 34521d05cddcSAtari911 if ($b === '') return 1; 34531d05cddcSAtari911 return strcmp($a, $b); 34541d05cddcSAtari911 }); 34551d05cddcSAtari911 34561d05cddcSAtari911 return $result; 34571d05cddcSAtari911 } 34581d05cddcSAtari911 34591d05cddcSAtari911 private function scanNamespaceRecursive($baseDir, $parentNamespace, &$result) { 34601d05cddcSAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 34611d05cddcSAtari911 $dirName = basename($nsDir); 34621d05cddcSAtari911 34631d05cddcSAtari911 // Skip the root 'calendar' dir 34641d05cddcSAtari911 if ($dirName === 'calendar' && empty($parentNamespace)) continue; 34651d05cddcSAtari911 34661d05cddcSAtari911 // Build namespace path 34671d05cddcSAtari911 $namespace = empty($parentNamespace) ? $dirName : $parentNamespace . ':' . $dirName; 34681d05cddcSAtari911 34691d05cddcSAtari911 // Check for calendar directory 34701d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 34711d05cddcSAtari911 if (is_dir($calendarDir)) { 34721d05cddcSAtari911 $hasFiles = false; 34731d05cddcSAtari911 $events = []; 34741d05cddcSAtari911 34751d05cddcSAtari911 // Scan all calendar files 34761d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 34771d05cddcSAtari911 $hasFiles = true; 34781d05cddcSAtari911 $month = basename($file, '.json'); 34791d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 34801d05cddcSAtari911 if (!$data) continue; 34811d05cddcSAtari911 34821d05cddcSAtari911 foreach ($data as $dateKey => $eventList) { 34831d05cddcSAtari911 foreach ($eventList as $event) { 34841d05cddcSAtari911 $events[] = [ 34851d05cddcSAtari911 'id' => $event['id'], 34861d05cddcSAtari911 'title' => $event['title'], 34871d05cddcSAtari911 'date' => $dateKey, 34881d05cddcSAtari911 'startTime' => $event['startTime'] ?? null, 34891d05cddcSAtari911 'month' => $month 34901d05cddcSAtari911 ]; 34911d05cddcSAtari911 } 34921d05cddcSAtari911 } 34931d05cddcSAtari911 } 34941d05cddcSAtari911 34951d05cddcSAtari911 // Add namespace if it has JSON files (even if empty) 34961d05cddcSAtari911 if ($hasFiles) { 34971d05cddcSAtari911 $result[$namespace] = ['events' => $events]; 34981d05cddcSAtari911 } 34991d05cddcSAtari911 } 35001d05cddcSAtari911 35011d05cddcSAtari911 // Recursively scan sub-directories 35021d05cddcSAtari911 $this->scanNamespaceRecursive($nsDir . '/', $namespace, $result); 35031d05cddcSAtari911 } 35041d05cddcSAtari911 } 35051d05cddcSAtari911 35061d05cddcSAtari911 private function getAllNamespaces() { 35071d05cddcSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 35081d05cddcSAtari911 $namespaces = []; 35091d05cddcSAtari911 35101d05cddcSAtari911 // Check root calendar directory first 35111d05cddcSAtari911 $rootCalendarDir = $dataDir . 'calendar'; 35121d05cddcSAtari911 if (is_dir($rootCalendarDir)) { 35131d05cddcSAtari911 $namespaces[] = ''; // Blank/default namespace 35141d05cddcSAtari911 } 35151d05cddcSAtari911 35161d05cddcSAtari911 // Check all other namespace directories 35171d05cddcSAtari911 foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { 35181d05cddcSAtari911 $namespace = basename($nsDir); 35191d05cddcSAtari911 35201d05cddcSAtari911 // Skip the root 'calendar' dir (already added as '') 35211d05cddcSAtari911 if ($namespace === 'calendar') continue; 35221d05cddcSAtari911 35231d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 35241d05cddcSAtari911 if (is_dir($calendarDir)) { 35251d05cddcSAtari911 $namespaces[] = $namespace; 35261d05cddcSAtari911 } 35271d05cddcSAtari911 } 35281d05cddcSAtari911 35291d05cddcSAtari911 return $namespaces; 35301d05cddcSAtari911 } 35311d05cddcSAtari911 35321d05cddcSAtari911 private function searchEvents($search, $filterNamespace) { 35331d05cddcSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 35341d05cddcSAtari911 $results = []; 35351d05cddcSAtari911 35361d05cddcSAtari911 $search = strtolower(trim($search)); 35371d05cddcSAtari911 35381d05cddcSAtari911 foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { 35391d05cddcSAtari911 $namespace = basename($nsDir); 35401d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 35411d05cddcSAtari911 35421d05cddcSAtari911 if (!is_dir($calendarDir)) continue; 35431d05cddcSAtari911 if ($filterNamespace !== '' && $namespace !== $filterNamespace) continue; 35441d05cddcSAtari911 35451d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 35461d05cddcSAtari911 $month = basename($file, '.json'); 35471d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 35481d05cddcSAtari911 if (!$data) continue; 35491d05cddcSAtari911 35501d05cddcSAtari911 foreach ($data as $dateKey => $events) { 35511d05cddcSAtari911 foreach ($events as $event) { 35521d05cddcSAtari911 if ($search === '' || strpos(strtolower($event['title']), $search) !== false) { 35531d05cddcSAtari911 $results[] = [ 35541d05cddcSAtari911 'id' => $event['id'], 35551d05cddcSAtari911 'title' => $event['title'], 35561d05cddcSAtari911 'date' => $dateKey, 35571d05cddcSAtari911 'startTime' => $event['startTime'] ?? null, 35581d05cddcSAtari911 'namespace' => $event['namespace'] ?? '', 35591d05cddcSAtari911 'month' => $month 35601d05cddcSAtari911 ]; 35611d05cddcSAtari911 } 35621d05cddcSAtari911 } 35631d05cddcSAtari911 } 35641d05cddcSAtari911 } 35651d05cddcSAtari911 } 35661d05cddcSAtari911 35671d05cddcSAtari911 return $results; 35681d05cddcSAtari911 } 35691d05cddcSAtari911 35701d05cddcSAtari911 private function deleteRecurringSeries() { 35711d05cddcSAtari911 global $INPUT; 35721d05cddcSAtari911 35731d05cddcSAtari911 $eventTitle = $INPUT->str('event_title'); 35741d05cddcSAtari911 $namespace = $INPUT->str('namespace'); 35751d05cddcSAtari911 3576*7e8ea635SAtari911 // Collect ALL calendar directories 3577*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 3578*7e8ea635SAtari911 $calendarDirs = []; 3579*7e8ea635SAtari911 if (is_dir($dataDir . 'calendar')) { 3580*7e8ea635SAtari911 $calendarDirs[] = $dataDir . 'calendar'; 35811d05cddcSAtari911 } 3582*7e8ea635SAtari911 $this->findCalendarDirs($dataDir, $calendarDirs); 35831d05cddcSAtari911 35841d05cddcSAtari911 $count = 0; 35851d05cddcSAtari911 3586*7e8ea635SAtari911 foreach ($calendarDirs as $calDir) { 3587*7e8ea635SAtari911 foreach (glob($calDir . '/*.json') as $file) { 35881d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 3589*7e8ea635SAtari911 if (!$data || !is_array($data)) continue; 35901d05cddcSAtari911 35911d05cddcSAtari911 $modified = false; 35921d05cddcSAtari911 foreach ($data as $dateKey => $events) { 35931d05cddcSAtari911 $filtered = []; 35941d05cddcSAtari911 foreach ($events as $event) { 3595*7e8ea635SAtari911 $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; 3596*7e8ea635SAtari911 // Match by title AND namespace field 3597*7e8ea635SAtari911 if (strtolower(trim($event['title'])) === strtolower(trim($eventTitle)) && 3598*7e8ea635SAtari911 strtolower(trim($eventNs)) === strtolower(trim($namespace))) { 35991d05cddcSAtari911 $count++; 36001d05cddcSAtari911 $modified = true; 36011d05cddcSAtari911 } else { 36021d05cddcSAtari911 $filtered[] = $event; 36031d05cddcSAtari911 } 36041d05cddcSAtari911 } 36051d05cddcSAtari911 $data[$dateKey] = $filtered; 36061d05cddcSAtari911 } 36071d05cddcSAtari911 36081d05cddcSAtari911 if ($modified) { 36099ccd446eSAtari911 foreach ($data as $dk => $evts) { 36109ccd446eSAtari911 if (empty($evts)) unset($data[$dk]); 36119ccd446eSAtari911 } 36129ccd446eSAtari911 36139ccd446eSAtari911 if (empty($data)) { 36149ccd446eSAtari911 unlink($file); 36159ccd446eSAtari911 } else { 36161d05cddcSAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 36171d05cddcSAtari911 } 36181d05cddcSAtari911 } 36191d05cddcSAtari911 } 36209ccd446eSAtari911 } 36211d05cddcSAtari911 36229ccd446eSAtari911 $this->clearStatsCache(); 36231d05cddcSAtari911 $this->redirect("Deleted $count occurrences of recurring event: " . $eventTitle, 'success', 'manage'); 36241d05cddcSAtari911 } 36251d05cddcSAtari911 36261d05cddcSAtari911 private function editRecurringSeries() { 36271d05cddcSAtari911 global $INPUT; 36281d05cddcSAtari911 36291d05cddcSAtari911 $oldTitle = $INPUT->str('old_title'); 36301d05cddcSAtari911 $oldNamespace = $INPUT->str('old_namespace'); 36311d05cddcSAtari911 $newTitle = $INPUT->str('new_title'); 36321d05cddcSAtari911 $startTime = $INPUT->str('start_time'); 36331d05cddcSAtari911 $endTime = $INPUT->str('end_time'); 36341d05cddcSAtari911 $interval = $INPUT->int('interval', 0); 36351d05cddcSAtari911 $newNamespace = $INPUT->str('new_namespace'); 36361d05cddcSAtari911 36371d05cddcSAtari911 // Use old namespace if new namespace is empty (keep current) 36381d05cddcSAtari911 if (empty($newNamespace) && !isset($_POST['new_namespace'])) { 36391d05cddcSAtari911 $newNamespace = $oldNamespace; 36401d05cddcSAtari911 } 36411d05cddcSAtari911 3642*7e8ea635SAtari911 // Collect ALL calendar directories to search 3643*7e8ea635SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 3644*7e8ea635SAtari911 $calendarDirs = []; 3645*7e8ea635SAtari911 3646*7e8ea635SAtari911 // Root calendar dir 3647*7e8ea635SAtari911 if (is_dir($dataDir . 'calendar')) { 3648*7e8ea635SAtari911 $calendarDirs[] = $dataDir . 'calendar'; 36491d05cddcSAtari911 } 36501d05cddcSAtari911 3651*7e8ea635SAtari911 // All namespace dirs 3652*7e8ea635SAtari911 $this->findCalendarDirs($dataDir, $calendarDirs); 36531d05cddcSAtari911 3654*7e8ea635SAtari911 $count = 0; 3655*7e8ea635SAtari911 3656*7e8ea635SAtari911 // Pass 1: Rename title, update time, update namespace field in ALL matching events 3657*7e8ea635SAtari911 foreach ($calendarDirs as $calDir) { 3658*7e8ea635SAtari911 if (is_string($calDir)) { 3659*7e8ea635SAtari911 $dir = $calDir; 3660*7e8ea635SAtari911 } else { 3661*7e8ea635SAtari911 $dir = $calDir['dir']; 3662*7e8ea635SAtari911 } 3663*7e8ea635SAtari911 3664*7e8ea635SAtari911 foreach (glob($dir . '/*.json') as $file) { 36651d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 3666*7e8ea635SAtari911 if (!$data || !is_array($data)) continue; 36671d05cddcSAtari911 36681d05cddcSAtari911 $modified = false; 3669*7e8ea635SAtari911 foreach ($data as $dateKey => &$dayEvents) { 3670*7e8ea635SAtari911 if (!is_array($dayEvents)) continue; 3671*7e8ea635SAtari911 foreach ($dayEvents as $key => &$event) { 3672*7e8ea635SAtari911 // Match by old title (case-insensitive) AND namespace field 3673*7e8ea635SAtari911 $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; 3674*7e8ea635SAtari911 if (strtolower(trim($event['title'])) !== strtolower(trim($oldTitle))) continue; 3675*7e8ea635SAtari911 if (strtolower(trim($eventNs)) !== strtolower(trim($oldNamespace))) continue; 3676*7e8ea635SAtari911 3677*7e8ea635SAtari911 // Update title 3678*7e8ea635SAtari911 $event['title'] = $newTitle; 36791d05cddcSAtari911 36801d05cddcSAtari911 // Update start time if provided 36811d05cddcSAtari911 if (!empty($startTime)) { 3682*7e8ea635SAtari911 $event['time'] = $startTime; 36831d05cddcSAtari911 } 36841d05cddcSAtari911 36851d05cddcSAtari911 // Update end time if provided 36861d05cddcSAtari911 if (!empty($endTime)) { 3687*7e8ea635SAtari911 $event['endTime'] = $endTime; 36881d05cddcSAtari911 } 36891d05cddcSAtari911 3690*7e8ea635SAtari911 // Update namespace field 3691*7e8ea635SAtari911 $event['namespace'] = $newNamespace; 36921d05cddcSAtari911 36931d05cddcSAtari911 $count++; 36941d05cddcSAtari911 $modified = true; 36951d05cddcSAtari911 } 3696*7e8ea635SAtari911 unset($event); 36971d05cddcSAtari911 } 3698*7e8ea635SAtari911 unset($dayEvents); 36991d05cddcSAtari911 37001d05cddcSAtari911 if ($modified) { 37011d05cddcSAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 37021d05cddcSAtari911 } 37031d05cddcSAtari911 } 3704*7e8ea635SAtari911 } 37051d05cddcSAtari911 3706*7e8ea635SAtari911 // Pass 2: Handle interval changes (respace events from first date) 3707*7e8ea635SAtari911 if ($interval > 0 && $count > 0) { 3708*7e8ea635SAtari911 // Use getRecurringSeriesEvents to find all events with the NEW title 3709*7e8ea635SAtari911 $allEvents = $this->getRecurringSeriesEvents($newTitle, $newNamespace); 37101d05cddcSAtari911 3711*7e8ea635SAtari911 if (count($allEvents) > 1) { 3712*7e8ea635SAtari911 $firstDate = new DateTime($allEvents[0]['date']); 37131d05cddcSAtari911 3714*7e8ea635SAtari911 // Remove all except first, then re-create with new spacing 3715*7e8ea635SAtari911 for ($i = 1; $i < count($allEvents); $i++) { 3716*7e8ea635SAtari911 $entry = $allEvents[$i]; 3717*7e8ea635SAtari911 $data = json_decode(file_get_contents($entry['file']), true); 3718*7e8ea635SAtari911 if (!$data || !isset($data[$entry['date']])) continue; 3719*7e8ea635SAtari911 3720*7e8ea635SAtari911 foreach ($data[$entry['date']] as $k => $evt) { 3721*7e8ea635SAtari911 if (strtolower(trim($evt['title'])) === strtolower(trim($newTitle))) { 3722*7e8ea635SAtari911 unset($data[$entry['date']][$k]); 3723*7e8ea635SAtari911 $data[$entry['date']] = array_values($data[$entry['date']]); 3724*7e8ea635SAtari911 break; 37251d05cddcSAtari911 } 37261d05cddcSAtari911 } 3727*7e8ea635SAtari911 if (empty($data[$entry['date']])) unset($data[$entry['date']]); 3728*7e8ea635SAtari911 if (empty($data)) { 3729*7e8ea635SAtari911 unlink($entry['file']); 3730*7e8ea635SAtari911 } else { 3731*7e8ea635SAtari911 file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); 37321d05cddcSAtari911 } 37331d05cddcSAtari911 } 37341d05cddcSAtari911 3735*7e8ea635SAtari911 // Re-create with new interval 3736*7e8ea635SAtari911 $template = $allEvents[0]['event']; 3737*7e8ea635SAtari911 $targetDir = ($newNamespace === '') 3738*7e8ea635SAtari911 ? DOKU_INC . 'data/meta/calendar' 3739*7e8ea635SAtari911 : DOKU_INC . 'data/meta/' . str_replace(':', '/', $newNamespace) . '/calendar'; 3740*7e8ea635SAtari911 if (!is_dir($targetDir)) mkdir($targetDir, 0755, true); 37411d05cddcSAtari911 3742*7e8ea635SAtari911 $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($newTitle . $newNamespace); 37431d05cddcSAtari911 3744*7e8ea635SAtari911 for ($i = 1; $i < count($allEvents); $i++) { 3745*7e8ea635SAtari911 $newDate = clone $firstDate; 3746*7e8ea635SAtari911 $newDate->modify('+' . ($i * $interval) . ' days'); 3747*7e8ea635SAtari911 $dateKey = $newDate->format('Y-m-d'); 3748*7e8ea635SAtari911 list($year, $month) = explode('-', $dateKey); 37491d05cddcSAtari911 3750*7e8ea635SAtari911 $file = $targetDir . '/' . sprintf('%04d-%02d.json', $year, $month); 3751*7e8ea635SAtari911 $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : []; 3752*7e8ea635SAtari911 if (!is_array($fileData)) $fileData = []; 3753*7e8ea635SAtari911 if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; 37541d05cddcSAtari911 3755*7e8ea635SAtari911 $newEvent = $template; 3756*7e8ea635SAtari911 $newEvent['id'] = $baseId . '-respace-' . $i; 3757*7e8ea635SAtari911 $fileData[$dateKey][] = $newEvent; 3758*7e8ea635SAtari911 file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT)); 37591d05cddcSAtari911 } 37601d05cddcSAtari911 } 37611d05cddcSAtari911 } 37621d05cddcSAtari911 37631d05cddcSAtari911 $changes = []; 37641d05cddcSAtari911 if ($oldTitle !== $newTitle) $changes[] = "title"; 37651d05cddcSAtari911 if (!empty($startTime) || !empty($endTime)) $changes[] = "time"; 37661d05cddcSAtari911 if ($interval > 0) $changes[] = "interval"; 37671d05cddcSAtari911 if ($newNamespace !== $oldNamespace) $changes[] = "namespace"; 37681d05cddcSAtari911 37691d05cddcSAtari911 $changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : ""; 37709ccd446eSAtari911 $this->clearStatsCache(); 37711d05cddcSAtari911 $this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage'); 37721d05cddcSAtari911 } 37731d05cddcSAtari911 3774*7e8ea635SAtari911 /** 3775*7e8ea635SAtari911 * Find all calendar directories recursively 3776*7e8ea635SAtari911 */ 3777*7e8ea635SAtari911 private function findCalendarDirs($baseDir, &$dirs) { 3778*7e8ea635SAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 3779*7e8ea635SAtari911 $name = basename($nsDir); 3780*7e8ea635SAtari911 if ($name === 'calendar') continue; // Skip root calendar (added separately) 3781*7e8ea635SAtari911 3782*7e8ea635SAtari911 $calDir = $nsDir . '/calendar'; 3783*7e8ea635SAtari911 if (is_dir($calDir)) { 3784*7e8ea635SAtari911 $dirs[] = $calDir; 3785*7e8ea635SAtari911 } 3786*7e8ea635SAtari911 3787*7e8ea635SAtari911 // Recurse 3788*7e8ea635SAtari911 $this->findCalendarDirs($nsDir . '/', $dirs); 3789*7e8ea635SAtari911 } 3790*7e8ea635SAtari911 } 3791*7e8ea635SAtari911 37921d05cddcSAtari911 private function moveEvents() { 37931d05cddcSAtari911 global $INPUT; 37941d05cddcSAtari911 37951d05cddcSAtari911 $events = $INPUT->arr('events'); 37961d05cddcSAtari911 $targetNamespace = $INPUT->str('target_namespace'); 37971d05cddcSAtari911 37981d05cddcSAtari911 if (empty($events)) { 37991d05cddcSAtari911 $this->redirect('No events selected', 'error', 'manage'); 38001d05cddcSAtari911 } 38011d05cddcSAtari911 38021d05cddcSAtari911 $moved = 0; 38031d05cddcSAtari911 38041d05cddcSAtari911 foreach ($events as $eventData) { 38051d05cddcSAtari911 list($id, $namespace, $date, $month) = explode('|', $eventData); 38061d05cddcSAtari911 38071d05cddcSAtari911 // Determine old file path 38081d05cddcSAtari911 if ($namespace === '') { 38091d05cddcSAtari911 $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; 38101d05cddcSAtari911 } else { 38111d05cddcSAtari911 $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; 38121d05cddcSAtari911 } 38131d05cddcSAtari911 38141d05cddcSAtari911 if (!file_exists($oldFile)) continue; 38151d05cddcSAtari911 38161d05cddcSAtari911 $oldData = json_decode(file_get_contents($oldFile), true); 38171d05cddcSAtari911 if (!$oldData) continue; 38181d05cddcSAtari911 38191d05cddcSAtari911 // Find and remove event from old file 38201d05cddcSAtari911 $event = null; 38219ccd446eSAtari911 if (isset($oldData[$date])) { 38221d05cddcSAtari911 foreach ($oldData[$date] as $key => $evt) { 38231d05cddcSAtari911 if ($evt['id'] === $id) { 38241d05cddcSAtari911 $event = $evt; 38251d05cddcSAtari911 unset($oldData[$date][$key]); 38261d05cddcSAtari911 $oldData[$date] = array_values($oldData[$date]); 38271d05cddcSAtari911 break; 38281d05cddcSAtari911 } 38291d05cddcSAtari911 } 38301d05cddcSAtari911 38319ccd446eSAtari911 // Remove empty date arrays 38329ccd446eSAtari911 if (empty($oldData[$date])) { 38339ccd446eSAtari911 unset($oldData[$date]); 38349ccd446eSAtari911 } 38359ccd446eSAtari911 } 38369ccd446eSAtari911 38371d05cddcSAtari911 if (!$event) continue; 38381d05cddcSAtari911 38391d05cddcSAtari911 // Save old file 38401d05cddcSAtari911 file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT)); 38411d05cddcSAtari911 38421d05cddcSAtari911 // Update event namespace 38431d05cddcSAtari911 $event['namespace'] = $targetNamespace; 38441d05cddcSAtari911 38451d05cddcSAtari911 // Determine new file path 38461d05cddcSAtari911 if ($targetNamespace === '') { 38471d05cddcSAtari911 $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; 38481d05cddcSAtari911 $newDir = dirname($newFile); 38491d05cddcSAtari911 } else { 38501d05cddcSAtari911 $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json'; 38511d05cddcSAtari911 $newDir = dirname($newFile); 38521d05cddcSAtari911 } 38531d05cddcSAtari911 38541d05cddcSAtari911 if (!is_dir($newDir)) { 38551d05cddcSAtari911 mkdir($newDir, 0755, true); 38561d05cddcSAtari911 } 38571d05cddcSAtari911 38581d05cddcSAtari911 $newData = []; 38591d05cddcSAtari911 if (file_exists($newFile)) { 38601d05cddcSAtari911 $newData = json_decode(file_get_contents($newFile), true) ?: []; 38611d05cddcSAtari911 } 38621d05cddcSAtari911 38631d05cddcSAtari911 if (!isset($newData[$date])) { 38641d05cddcSAtari911 $newData[$date] = []; 38651d05cddcSAtari911 } 38661d05cddcSAtari911 $newData[$date][] = $event; 38671d05cddcSAtari911 38681d05cddcSAtari911 file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT)); 38691d05cddcSAtari911 $moved++; 38701d05cddcSAtari911 } 38711d05cddcSAtari911 38721d05cddcSAtari911 $displayTarget = $targetNamespace ?: '(default)'; 38739ccd446eSAtari911 $this->clearStatsCache(); 38741d05cddcSAtari911 $this->redirect("Moved $moved event(s) to namespace: " . $displayTarget, 'success', 'manage'); 38751d05cddcSAtari911 } 38761d05cddcSAtari911 38771d05cddcSAtari911 private function moveSingleEvent() { 38781d05cddcSAtari911 global $INPUT; 38791d05cddcSAtari911 38801d05cddcSAtari911 $eventData = $INPUT->str('event'); 38811d05cddcSAtari911 $targetNamespace = $INPUT->str('target_namespace'); 38821d05cddcSAtari911 38831d05cddcSAtari911 list($id, $namespace, $date, $month) = explode('|', $eventData); 38841d05cddcSAtari911 38851d05cddcSAtari911 // Determine old file path 38861d05cddcSAtari911 if ($namespace === '') { 38871d05cddcSAtari911 $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; 38881d05cddcSAtari911 } else { 38891d05cddcSAtari911 $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; 38901d05cddcSAtari911 } 38911d05cddcSAtari911 38921d05cddcSAtari911 if (!file_exists($oldFile)) { 38931d05cddcSAtari911 $this->redirect('Event file not found', 'error', 'manage'); 38941d05cddcSAtari911 } 38951d05cddcSAtari911 38961d05cddcSAtari911 $oldData = json_decode(file_get_contents($oldFile), true); 38971d05cddcSAtari911 if (!$oldData) { 38981d05cddcSAtari911 $this->redirect('Could not read event file', 'error', 'manage'); 38991d05cddcSAtari911 } 39001d05cddcSAtari911 39011d05cddcSAtari911 // Find and remove event from old file 39021d05cddcSAtari911 $event = null; 39039ccd446eSAtari911 if (isset($oldData[$date])) { 39041d05cddcSAtari911 foreach ($oldData[$date] as $key => $evt) { 39051d05cddcSAtari911 if ($evt['id'] === $id) { 39061d05cddcSAtari911 $event = $evt; 39071d05cddcSAtari911 unset($oldData[$date][$key]); 39081d05cddcSAtari911 $oldData[$date] = array_values($oldData[$date]); 39091d05cddcSAtari911 break; 39101d05cddcSAtari911 } 39111d05cddcSAtari911 } 39121d05cddcSAtari911 39139ccd446eSAtari911 // Remove empty date arrays 39149ccd446eSAtari911 if (empty($oldData[$date])) { 39159ccd446eSAtari911 unset($oldData[$date]); 39169ccd446eSAtari911 } 39179ccd446eSAtari911 } 39189ccd446eSAtari911 39191d05cddcSAtari911 if (!$event) { 39201d05cddcSAtari911 $this->redirect('Event not found', 'error', 'manage'); 39211d05cddcSAtari911 } 39221d05cddcSAtari911 39239ccd446eSAtari911 // Save old file (or delete if empty) 39249ccd446eSAtari911 if (empty($oldData)) { 39259ccd446eSAtari911 unlink($oldFile); 39269ccd446eSAtari911 } else { 39271d05cddcSAtari911 file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT)); 39289ccd446eSAtari911 } 39291d05cddcSAtari911 39301d05cddcSAtari911 // Update event namespace 39311d05cddcSAtari911 $event['namespace'] = $targetNamespace; 39321d05cddcSAtari911 39331d05cddcSAtari911 // Determine new file path 39341d05cddcSAtari911 if ($targetNamespace === '') { 39351d05cddcSAtari911 $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; 39361d05cddcSAtari911 $newDir = dirname($newFile); 39371d05cddcSAtari911 } else { 39381d05cddcSAtari911 $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json'; 39391d05cddcSAtari911 $newDir = dirname($newFile); 39401d05cddcSAtari911 } 39411d05cddcSAtari911 39421d05cddcSAtari911 if (!is_dir($newDir)) { 39431d05cddcSAtari911 mkdir($newDir, 0755, true); 39441d05cddcSAtari911 } 39451d05cddcSAtari911 39461d05cddcSAtari911 $newData = []; 39471d05cddcSAtari911 if (file_exists($newFile)) { 39481d05cddcSAtari911 $newData = json_decode(file_get_contents($newFile), true) ?: []; 39491d05cddcSAtari911 } 39501d05cddcSAtari911 39511d05cddcSAtari911 if (!isset($newData[$date])) { 39521d05cddcSAtari911 $newData[$date] = []; 39531d05cddcSAtari911 } 39541d05cddcSAtari911 $newData[$date][] = $event; 39551d05cddcSAtari911 39561d05cddcSAtari911 file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT)); 39571d05cddcSAtari911 39581d05cddcSAtari911 $displayTarget = $targetNamespace ?: '(default)'; 39599ccd446eSAtari911 $this->clearStatsCache(); 39601d05cddcSAtari911 $this->redirect('Moved "' . $event['title'] . '" to ' . $displayTarget, 'success', 'manage'); 39611d05cddcSAtari911 } 39621d05cddcSAtari911 39631d05cddcSAtari911 private function createNamespace() { 39641d05cddcSAtari911 global $INPUT; 39651d05cddcSAtari911 39661d05cddcSAtari911 $namespaceName = $INPUT->str('namespace_name'); 39671d05cddcSAtari911 39681d05cddcSAtari911 // Validate namespace name 39691d05cddcSAtari911 if (empty($namespaceName)) { 39701d05cddcSAtari911 $this->redirect('Namespace name cannot be empty', 'error', 'manage'); 39711d05cddcSAtari911 } 39721d05cddcSAtari911 39731d05cddcSAtari911 if (!preg_match('/^[a-zA-Z0-9_:-]+$/', $namespaceName)) { 39741d05cddcSAtari911 $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 39751d05cddcSAtari911 } 39761d05cddcSAtari911 39771d05cddcSAtari911 // Convert namespace to directory path 39781d05cddcSAtari911 $namespacePath = str_replace(':', '/', $namespaceName); 39791d05cddcSAtari911 $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar'; 39801d05cddcSAtari911 39811d05cddcSAtari911 // Check if already exists 39821d05cddcSAtari911 if (is_dir($calendarDir)) { 39831d05cddcSAtari911 // Check if it has any JSON files 39841d05cddcSAtari911 $hasFiles = !empty(glob($calendarDir . '/*.json')); 39851d05cddcSAtari911 if ($hasFiles) { 39861d05cddcSAtari911 $this->redirect("Namespace '$namespaceName' already exists with events", 'info', 'manage'); 39871d05cddcSAtari911 } 39881d05cddcSAtari911 // If directory exists but empty, continue to create placeholder 39891d05cddcSAtari911 } 39901d05cddcSAtari911 39911d05cddcSAtari911 // Create the directory 39921d05cddcSAtari911 if (!is_dir($calendarDir)) { 39931d05cddcSAtari911 if (!mkdir($calendarDir, 0755, true)) { 39941d05cddcSAtari911 $this->redirect("Failed to create namespace directory", 'error', 'manage'); 39951d05cddcSAtari911 } 39961d05cddcSAtari911 } 39971d05cddcSAtari911 39981d05cddcSAtari911 // Create a placeholder JSON file with an empty structure for current month 39991d05cddcSAtari911 // This ensures the namespace appears in the list immediately 40001d05cddcSAtari911 $currentMonth = date('Y-m'); 40011d05cddcSAtari911 $placeholderFile = $calendarDir . '/' . $currentMonth . '.json'; 40021d05cddcSAtari911 40031d05cddcSAtari911 if (!file_exists($placeholderFile)) { 40041d05cddcSAtari911 file_put_contents($placeholderFile, json_encode([], JSON_PRETTY_PRINT)); 40051d05cddcSAtari911 } 40061d05cddcSAtari911 40071d05cddcSAtari911 $this->redirect("Created namespace: $namespaceName", 'success', 'manage'); 40081d05cddcSAtari911 } 40091d05cddcSAtari911 40101d05cddcSAtari911 private function deleteNamespace() { 40111d05cddcSAtari911 global $INPUT; 40121d05cddcSAtari911 40131d05cddcSAtari911 $namespace = $INPUT->str('namespace'); 40141d05cddcSAtari911 4015*7e8ea635SAtari911 // Validate namespace name to prevent path traversal 4016*7e8ea635SAtari911 if ($namespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $namespace)) { 4017*7e8ea635SAtari911 $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 4018*7e8ea635SAtari911 return; 4019*7e8ea635SAtari911 } 4020*7e8ea635SAtari911 4021*7e8ea635SAtari911 // Additional safety: ensure no path traversal sequences 4022*7e8ea635SAtari911 if (strpos($namespace, '..') !== false || strpos($namespace, '/') !== false || strpos($namespace, '\\') !== false) { 4023*7e8ea635SAtari911 $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage'); 4024*7e8ea635SAtari911 return; 4025*7e8ea635SAtari911 } 4026*7e8ea635SAtari911 40271d05cddcSAtari911 // Convert namespace to directory path (e.g., "work:projects" → "work/projects") 40281d05cddcSAtari911 $namespacePath = str_replace(':', '/', $namespace); 40291d05cddcSAtari911 40301d05cddcSAtari911 // Determine calendar directory 40311d05cddcSAtari911 if ($namespace === '') { 40321d05cddcSAtari911 $calendarDir = DOKU_INC . 'data/meta/calendar'; 40331d05cddcSAtari911 $namespaceDir = null; // Don't delete root 40341d05cddcSAtari911 } else { 40351d05cddcSAtari911 $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar'; 40361d05cddcSAtari911 $namespaceDir = DOKU_INC . 'data/meta/' . $namespacePath; 40371d05cddcSAtari911 } 40381d05cddcSAtari911 40391d05cddcSAtari911 // Check if directory exists 40401d05cddcSAtari911 if (!is_dir($calendarDir)) { 40411d05cddcSAtari911 // Maybe it was never created or already deleted 40421d05cddcSAtari911 $this->redirect("Namespace directory not found: $calendarDir", 'error', 'manage'); 40431d05cddcSAtari911 return; 40441d05cddcSAtari911 } 40451d05cddcSAtari911 40461d05cddcSAtari911 $filesDeleted = 0; 40471d05cddcSAtari911 $eventsDeleted = 0; 40481d05cddcSAtari911 40491d05cddcSAtari911 // Delete all calendar JSON files (including empty ones) 40501d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 40511d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 40521d05cddcSAtari911 if ($data) { 40531d05cddcSAtari911 foreach ($data as $events) { 40541d05cddcSAtari911 $eventsDeleted += count($events); 40551d05cddcSAtari911 } 40561d05cddcSAtari911 } 40571d05cddcSAtari911 unlink($file); 40581d05cddcSAtari911 $filesDeleted++; 40591d05cddcSAtari911 } 40601d05cddcSAtari911 40611d05cddcSAtari911 // Delete any other files in calendar directory 40621d05cddcSAtari911 foreach (glob($calendarDir . '/*') as $file) { 40631d05cddcSAtari911 if (is_file($file)) { 40641d05cddcSAtari911 unlink($file); 40651d05cddcSAtari911 } 40661d05cddcSAtari911 } 40671d05cddcSAtari911 40681d05cddcSAtari911 // Remove the calendar directory 40691d05cddcSAtari911 if ($namespace !== '') { 40701d05cddcSAtari911 @rmdir($calendarDir); 40711d05cddcSAtari911 40721d05cddcSAtari911 // Try to remove parent directories if they're empty 40731d05cddcSAtari911 // This handles nested namespaces like work:projects:alpha 40741d05cddcSAtari911 $currentDir = dirname($calendarDir); 40751d05cddcSAtari911 $metaDir = DOKU_INC . 'data/meta'; 40761d05cddcSAtari911 40771d05cddcSAtari911 while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) { 40781d05cddcSAtari911 if (is_dir($currentDir)) { 40791d05cddcSAtari911 // Check if directory is empty 40801d05cddcSAtari911 $contents = scandir($currentDir); 40811d05cddcSAtari911 $isEmpty = count($contents) === 2; // Only . and .. 40821d05cddcSAtari911 40831d05cddcSAtari911 if ($isEmpty) { 40841d05cddcSAtari911 @rmdir($currentDir); 40851d05cddcSAtari911 $currentDir = dirname($currentDir); 40861d05cddcSAtari911 } else { 40871d05cddcSAtari911 break; // Directory not empty, stop 40881d05cddcSAtari911 } 40891d05cddcSAtari911 } else { 40901d05cddcSAtari911 break; 40911d05cddcSAtari911 } 40921d05cddcSAtari911 } 40931d05cddcSAtari911 } 40941d05cddcSAtari911 40951d05cddcSAtari911 $displayName = $namespace ?: '(default)'; 40969ccd446eSAtari911 $this->clearStatsCache(); 40971d05cddcSAtari911 $this->redirect("Deleted namespace '$displayName': $eventsDeleted events in $filesDeleted files", 'success', 'manage'); 40981d05cddcSAtari911 } 40991d05cddcSAtari911 41009ccd446eSAtari911 private function renameNamespace() { 41019ccd446eSAtari911 global $INPUT; 41029ccd446eSAtari911 41039ccd446eSAtari911 $oldNamespace = $INPUT->str('old_namespace'); 41049ccd446eSAtari911 $newNamespace = $INPUT->str('new_namespace'); 41059ccd446eSAtari911 4106*7e8ea635SAtari911 // Validate namespace names to prevent path traversal 4107*7e8ea635SAtari911 if ($oldNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $oldNamespace)) { 4108*7e8ea635SAtari911 $this->redirect('Invalid old namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 4109*7e8ea635SAtari911 return; 4110*7e8ea635SAtari911 } 4111*7e8ea635SAtari911 4112*7e8ea635SAtari911 if ($newNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $newNamespace)) { 4113*7e8ea635SAtari911 $this->redirect('Invalid new namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); 4114*7e8ea635SAtari911 return; 4115*7e8ea635SAtari911 } 4116*7e8ea635SAtari911 4117*7e8ea635SAtari911 // Additional safety: ensure no path traversal sequences 4118*7e8ea635SAtari911 if (strpos($oldNamespace, '..') !== false || strpos($oldNamespace, '/') !== false || strpos($oldNamespace, '\\') !== false || 4119*7e8ea635SAtari911 strpos($newNamespace, '..') !== false || strpos($newNamespace, '/') !== false || strpos($newNamespace, '\\') !== false) { 4120*7e8ea635SAtari911 $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage'); 4121*7e8ea635SAtari911 return; 4122*7e8ea635SAtari911 } 4123*7e8ea635SAtari911 41249ccd446eSAtari911 // Validate new namespace name 41259ccd446eSAtari911 if ($newNamespace === '') { 41269ccd446eSAtari911 $this->redirect("Cannot rename to empty namespace", 'error', 'manage'); 41279ccd446eSAtari911 return; 41289ccd446eSAtari911 } 41299ccd446eSAtari911 41309ccd446eSAtari911 // Convert namespaces to directory paths 41319ccd446eSAtari911 $oldPath = str_replace(':', '/', $oldNamespace); 41329ccd446eSAtari911 $newPath = str_replace(':', '/', $newNamespace); 41339ccd446eSAtari911 41349ccd446eSAtari911 // Determine source and destination directories 41359ccd446eSAtari911 if ($oldNamespace === '') { 41369ccd446eSAtari911 $sourceDir = DOKU_INC . 'data/meta/calendar'; 41379ccd446eSAtari911 } else { 41389ccd446eSAtari911 $sourceDir = DOKU_INC . 'data/meta/' . $oldPath . '/calendar'; 41399ccd446eSAtari911 } 41409ccd446eSAtari911 41419ccd446eSAtari911 if ($newNamespace === '') { 41429ccd446eSAtari911 $targetDir = DOKU_INC . 'data/meta/calendar'; 41439ccd446eSAtari911 } else { 41449ccd446eSAtari911 $targetDir = DOKU_INC . 'data/meta/' . $newPath . '/calendar'; 41459ccd446eSAtari911 } 41469ccd446eSAtari911 41479ccd446eSAtari911 // Check if source exists 41489ccd446eSAtari911 if (!is_dir($sourceDir)) { 41499ccd446eSAtari911 $this->redirect("Source namespace not found: $oldNamespace", 'error', 'manage'); 41509ccd446eSAtari911 return; 41519ccd446eSAtari911 } 41529ccd446eSAtari911 41539ccd446eSAtari911 // Check if target already exists 41549ccd446eSAtari911 if (is_dir($targetDir)) { 41559ccd446eSAtari911 $this->redirect("Target namespace already exists: $newNamespace", 'error', 'manage'); 41569ccd446eSAtari911 return; 41579ccd446eSAtari911 } 41589ccd446eSAtari911 41599ccd446eSAtari911 // Create target directory 41609ccd446eSAtari911 if (!file_exists(dirname($targetDir))) { 41619ccd446eSAtari911 mkdir(dirname($targetDir), 0755, true); 41629ccd446eSAtari911 } 41639ccd446eSAtari911 41649ccd446eSAtari911 // Rename directory 41659ccd446eSAtari911 if (!rename($sourceDir, $targetDir)) { 41669ccd446eSAtari911 $this->redirect("Failed to rename namespace", 'error', 'manage'); 41679ccd446eSAtari911 return; 41689ccd446eSAtari911 } 41699ccd446eSAtari911 41709ccd446eSAtari911 // Update event namespace field in all JSON files 41719ccd446eSAtari911 $eventsUpdated = 0; 41729ccd446eSAtari911 foreach (glob($targetDir . '/*.json') as $file) { 41739ccd446eSAtari911 $data = json_decode(file_get_contents($file), true); 41749ccd446eSAtari911 if ($data) { 41759ccd446eSAtari911 foreach ($data as $date => &$events) { 41769ccd446eSAtari911 foreach ($events as &$event) { 41779ccd446eSAtari911 if (isset($event['namespace']) && $event['namespace'] === $oldNamespace) { 41789ccd446eSAtari911 $event['namespace'] = $newNamespace; 41799ccd446eSAtari911 $eventsUpdated++; 41809ccd446eSAtari911 } 41819ccd446eSAtari911 } 41829ccd446eSAtari911 } 41839ccd446eSAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 41849ccd446eSAtari911 } 41859ccd446eSAtari911 } 41869ccd446eSAtari911 41879ccd446eSAtari911 // Clean up old directory structure if empty 41889ccd446eSAtari911 if ($oldNamespace !== '') { 41899ccd446eSAtari911 $currentDir = dirname($sourceDir); 41909ccd446eSAtari911 $metaDir = DOKU_INC . 'data/meta'; 41919ccd446eSAtari911 41929ccd446eSAtari911 while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) { 41939ccd446eSAtari911 if (is_dir($currentDir)) { 41949ccd446eSAtari911 $contents = scandir($currentDir); 41959ccd446eSAtari911 $isEmpty = count($contents) === 2; // Only . and .. 41969ccd446eSAtari911 41979ccd446eSAtari911 if ($isEmpty) { 41989ccd446eSAtari911 @rmdir($currentDir); 41999ccd446eSAtari911 $currentDir = dirname($currentDir); 42009ccd446eSAtari911 } else { 42019ccd446eSAtari911 break; 42029ccd446eSAtari911 } 42039ccd446eSAtari911 } else { 42049ccd446eSAtari911 break; 42059ccd446eSAtari911 } 42069ccd446eSAtari911 } 42079ccd446eSAtari911 } 42089ccd446eSAtari911 42099ccd446eSAtari911 $this->clearStatsCache(); 42109ccd446eSAtari911 $this->redirect("Renamed namespace from '$oldNamespace' to '$newNamespace' ($eventsUpdated events updated)", 'success', 'manage'); 42119ccd446eSAtari911 } 42129ccd446eSAtari911 42131d05cddcSAtari911 private function deleteSelectedEvents() { 42141d05cddcSAtari911 global $INPUT; 42151d05cddcSAtari911 42161d05cddcSAtari911 $events = $INPUT->arr('events'); 42171d05cddcSAtari911 42181d05cddcSAtari911 if (empty($events)) { 42191d05cddcSAtari911 $this->redirect('No events selected', 'error', 'manage'); 42201d05cddcSAtari911 } 42211d05cddcSAtari911 42221d05cddcSAtari911 $deletedCount = 0; 42231d05cddcSAtari911 42241d05cddcSAtari911 foreach ($events as $eventData) { 42251d05cddcSAtari911 list($id, $namespace, $date, $month) = explode('|', $eventData); 42261d05cddcSAtari911 42271d05cddcSAtari911 // Determine file path 42281d05cddcSAtari911 if ($namespace === '') { 42291d05cddcSAtari911 $file = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; 42301d05cddcSAtari911 } else { 42311d05cddcSAtari911 $file = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; 42321d05cddcSAtari911 } 42331d05cddcSAtari911 42341d05cddcSAtari911 if (!file_exists($file)) continue; 42351d05cddcSAtari911 42361d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 42371d05cddcSAtari911 if (!$data) continue; 42381d05cddcSAtari911 42391d05cddcSAtari911 // Find and remove event 42401d05cddcSAtari911 if (isset($data[$date])) { 42411d05cddcSAtari911 foreach ($data[$date] as $key => $evt) { 42421d05cddcSAtari911 if ($evt['id'] === $id) { 42431d05cddcSAtari911 unset($data[$date][$key]); 42441d05cddcSAtari911 $data[$date] = array_values($data[$date]); 42451d05cddcSAtari911 $deletedCount++; 42461d05cddcSAtari911 break; 42471d05cddcSAtari911 } 42481d05cddcSAtari911 } 42491d05cddcSAtari911 42501d05cddcSAtari911 // Remove empty date arrays 42511d05cddcSAtari911 if (empty($data[$date])) { 42521d05cddcSAtari911 unset($data[$date]); 42531d05cddcSAtari911 } 42541d05cddcSAtari911 42551d05cddcSAtari911 // Save file 42561d05cddcSAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 42571d05cddcSAtari911 } 42581d05cddcSAtari911 } 42591d05cddcSAtari911 42609ccd446eSAtari911 $this->clearStatsCache(); 42611d05cddcSAtari911 $this->redirect("Deleted $deletedCount event(s)", 'success', 'manage'); 42621d05cddcSAtari911 } 42631d05cddcSAtari911 42649ccd446eSAtari911 /** 42659ccd446eSAtari911 * Clear the event statistics cache so counts refresh after mutations 42669ccd446eSAtari911 */ 42674590242dSAtari911 private function saveImportantNamespaces() { 42684590242dSAtari911 global $INPUT; 42694590242dSAtari911 42704590242dSAtari911 $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; 42714590242dSAtari911 $config = []; 42724590242dSAtari911 if (file_exists($configFile)) { 42734590242dSAtari911 $config = include $configFile; 42744590242dSAtari911 } 42754590242dSAtari911 42764590242dSAtari911 $config['important_namespaces'] = $INPUT->str('important_namespaces', 'important'); 42774590242dSAtari911 42784590242dSAtari911 $content = "<?php\nreturn " . var_export($config, true) . ";\n"; 42794590242dSAtari911 if (file_put_contents($configFile, $content)) { 42804590242dSAtari911 $this->redirect('Important namespaces saved', 'success', 'manage'); 42814590242dSAtari911 } else { 42824590242dSAtari911 $this->redirect('Error: Could not save configuration', 'error', 'manage'); 42834590242dSAtari911 } 42844590242dSAtari911 } 42854590242dSAtari911 42869ccd446eSAtari911 private function clearStatsCache() { 42879ccd446eSAtari911 $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; 42889ccd446eSAtari911 if (file_exists($cacheFile)) { 42899ccd446eSAtari911 unlink($cacheFile); 42909ccd446eSAtari911 } 42919ccd446eSAtari911 } 42929ccd446eSAtari911 42931d05cddcSAtari911 private function getCronStatus() { 42941d05cddcSAtari911 // Try to read root's crontab first, then current user 42951d05cddcSAtari911 $output = []; 42961d05cddcSAtari911 exec('sudo crontab -l 2>/dev/null', $output); 42971d05cddcSAtari911 42981d05cddcSAtari911 // If sudo doesn't work, try current user 42991d05cddcSAtari911 if (empty($output)) { 43001d05cddcSAtari911 exec('crontab -l 2>/dev/null', $output); 43011d05cddcSAtari911 } 43021d05cddcSAtari911 43031d05cddcSAtari911 // Also check system crontab files 43041d05cddcSAtari911 if (empty($output)) { 43051d05cddcSAtari911 $cronFiles = [ 43061d05cddcSAtari911 '/etc/crontab', 43071d05cddcSAtari911 '/etc/cron.d/calendar', 43081d05cddcSAtari911 '/var/spool/cron/root', 43091d05cddcSAtari911 '/var/spool/cron/crontabs/root' 43101d05cddcSAtari911 ]; 43111d05cddcSAtari911 43121d05cddcSAtari911 foreach ($cronFiles as $file) { 43131d05cddcSAtari911 if (file_exists($file) && is_readable($file)) { 43141d05cddcSAtari911 $content = file_get_contents($file); 43151d05cddcSAtari911 $output = explode("\n", $content); 43161d05cddcSAtari911 break; 43171d05cddcSAtari911 } 43181d05cddcSAtari911 } 43191d05cddcSAtari911 } 43201d05cddcSAtari911 43211d05cddcSAtari911 // Look for sync_outlook.php in the cron entries 43221d05cddcSAtari911 foreach ($output as $line) { 43231d05cddcSAtari911 $line = trim($line); 43241d05cddcSAtari911 43251d05cddcSAtari911 // Skip empty lines and comments 43261d05cddcSAtari911 if (empty($line) || $line[0] === '#') continue; 43271d05cddcSAtari911 43281d05cddcSAtari911 // Check if line contains sync_outlook.php 43291d05cddcSAtari911 if (strpos($line, 'sync_outlook.php') !== false) { 43301d05cddcSAtari911 // Parse cron expression 43311d05cddcSAtari911 // Format: minute hour day month weekday [user] command 43321d05cddcSAtari911 $parts = preg_split('/\s+/', $line, 7); 43331d05cddcSAtari911 43341d05cddcSAtari911 if (count($parts) >= 5) { 43351d05cddcSAtari911 // Determine if this has a user field (system crontab format) 43361d05cddcSAtari911 $hasUser = (count($parts) >= 6 && !preg_match('/^[\/\*]/', $parts[5])); 43371d05cddcSAtari911 $offset = $hasUser ? 1 : 0; 43381d05cddcSAtari911 43391d05cddcSAtari911 $frequency = $this->parseCronExpression($parts[0], $parts[1], $parts[2], $parts[3], $parts[4]); 43401d05cddcSAtari911 return [ 43411d05cddcSAtari911 'active' => true, 43421d05cddcSAtari911 'frequency' => $frequency, 43431d05cddcSAtari911 'expression' => implode(' ', array_slice($parts, 0, 5)), 43441d05cddcSAtari911 'full_line' => $line 43451d05cddcSAtari911 ]; 43461d05cddcSAtari911 } 43471d05cddcSAtari911 } 43481d05cddcSAtari911 } 43491d05cddcSAtari911 43501d05cddcSAtari911 return ['active' => false, 'frequency' => '', 'expression' => '', 'full_line' => '']; 43511d05cddcSAtari911 } 43521d05cddcSAtari911 43531d05cddcSAtari911 private function parseCronExpression($minute, $hour, $day, $month, $weekday) { 43541d05cddcSAtari911 // Parse minute field 43551d05cddcSAtari911 if ($minute === '*') { 43561d05cddcSAtari911 return 'Runs every minute'; 43571d05cddcSAtari911 } elseif (strpos($minute, '*/') === 0) { 43581d05cddcSAtari911 $interval = substr($minute, 2); 43591d05cddcSAtari911 if ($interval == 1) { 43601d05cddcSAtari911 return 'Runs every minute'; 43611d05cddcSAtari911 } elseif ($interval == 5) { 43621d05cddcSAtari911 return 'Runs every 5 minutes'; 43631d05cddcSAtari911 } elseif ($interval == 8) { 43641d05cddcSAtari911 return 'Runs every 8 minutes'; 43651d05cddcSAtari911 } elseif ($interval == 10) { 43661d05cddcSAtari911 return 'Runs every 10 minutes'; 43671d05cddcSAtari911 } elseif ($interval == 15) { 43681d05cddcSAtari911 return 'Runs every 15 minutes'; 43691d05cddcSAtari911 } elseif ($interval == 30) { 43701d05cddcSAtari911 return 'Runs every 30 minutes'; 43711d05cddcSAtari911 } else { 43721d05cddcSAtari911 return "Runs every $interval minutes"; 43731d05cddcSAtari911 } 43741d05cddcSAtari911 } 43751d05cddcSAtari911 43761d05cddcSAtari911 // Parse hour field 43771d05cddcSAtari911 if ($hour === '*' && $minute !== '*') { 43781d05cddcSAtari911 return 'Runs hourly'; 43791d05cddcSAtari911 } elseif (strpos($hour, '*/') === 0 && $minute !== '*') { 43801d05cddcSAtari911 $interval = substr($hour, 2); 43811d05cddcSAtari911 if ($interval == 1) { 43821d05cddcSAtari911 return 'Runs every hour'; 43831d05cddcSAtari911 } else { 43841d05cddcSAtari911 return "Runs every $interval hours"; 43851d05cddcSAtari911 } 43861d05cddcSAtari911 } 43871d05cddcSAtari911 43881d05cddcSAtari911 // Parse day field 43891d05cddcSAtari911 if ($day === '*' && $hour !== '*' && $minute !== '*') { 43901d05cddcSAtari911 return 'Runs daily'; 43911d05cddcSAtari911 } 43921d05cddcSAtari911 43931d05cddcSAtari911 // Default 43941d05cddcSAtari911 return 'Custom schedule'; 43951d05cddcSAtari911 } 43961d05cddcSAtari911 43971d05cddcSAtari911 private function runSync() { 43981d05cddcSAtari911 global $INPUT; 43991d05cddcSAtari911 44001d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 44011d05cddcSAtari911 header('Content-Type: application/json'); 44021d05cddcSAtari911 44031d05cddcSAtari911 $syncScript = DOKU_PLUGIN . 'calendar/sync_outlook.php'; 44041d05cddcSAtari911 $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort'; 44051d05cddcSAtari911 44061d05cddcSAtari911 // Remove any existing abort flag 44071d05cddcSAtari911 if (file_exists($abortFile)) { 44081d05cddcSAtari911 @unlink($abortFile); 44091d05cddcSAtari911 } 44101d05cddcSAtari911 44111d05cddcSAtari911 if (!file_exists($syncScript)) { 44121d05cddcSAtari911 echo json_encode(['success' => false, 'message' => 'Sync script not found at: ' . $syncScript]); 44131d05cddcSAtari911 exit; 44141d05cddcSAtari911 } 44151d05cddcSAtari911 44161d05cddcSAtari911 // Change to plugin directory 44171d05cddcSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar'; 44181d05cddcSAtari911 $logFile = $pluginDir . '/sync.log'; 44191d05cddcSAtari911 44201d05cddcSAtari911 // Ensure log file exists and is writable 44211d05cddcSAtari911 if (!file_exists($logFile)) { 44221d05cddcSAtari911 @touch($logFile); 44231d05cddcSAtari911 @chmod($logFile, 0666); 44241d05cddcSAtari911 } 44251d05cddcSAtari911 44261d05cddcSAtari911 // Try to log the execution (but don't fail if we can't) 44271d05cddcSAtari911 if (is_writable($logFile)) { 44281d05cddcSAtari911 $tz = new DateTimeZone('America/Los_Angeles'); 44291d05cddcSAtari911 $now = new DateTime('now', $tz); 44301d05cddcSAtari911 $timestamp = $now->format('Y-m-d H:i:s'); 44311d05cddcSAtari911 @file_put_contents($logFile, "[$timestamp] [ADMIN] Manual sync triggered via admin panel\n", FILE_APPEND); 44321d05cddcSAtari911 } 44331d05cddcSAtari911 44341d05cddcSAtari911 // Find PHP binary - try multiple methods 44351d05cddcSAtari911 $phpPath = $this->findPhpBinary(); 44361d05cddcSAtari911 44371d05cddcSAtari911 // Build command 44381d05cddcSAtari911 $command = sprintf( 44391d05cddcSAtari911 'cd %s && %s %s 2>&1', 44401d05cddcSAtari911 escapeshellarg($pluginDir), 44411d05cddcSAtari911 $phpPath, 44421d05cddcSAtari911 escapeshellarg(basename($syncScript)) 44431d05cddcSAtari911 ); 44441d05cddcSAtari911 44451d05cddcSAtari911 // Execute and capture output 44461d05cddcSAtari911 $output = []; 44471d05cddcSAtari911 $returnCode = 0; 44481d05cddcSAtari911 exec($command, $output, $returnCode); 44491d05cddcSAtari911 44501d05cddcSAtari911 // Check if sync completed 44511d05cddcSAtari911 $lastLines = array_slice($output, -5); 44521d05cddcSAtari911 $completed = false; 44531d05cddcSAtari911 foreach ($lastLines as $line) { 44541d05cddcSAtari911 if (strpos($line, 'Sync Complete') !== false || strpos($line, 'Created:') !== false) { 44551d05cddcSAtari911 $completed = true; 44561d05cddcSAtari911 break; 44571d05cddcSAtari911 } 44581d05cddcSAtari911 } 44591d05cddcSAtari911 44601d05cddcSAtari911 if ($returnCode === 0 && $completed) { 44611d05cddcSAtari911 echo json_encode([ 44621d05cddcSAtari911 'success' => true, 44631d05cddcSAtari911 'message' => 'Sync completed successfully! Check log below.' 44641d05cddcSAtari911 ]); 44651d05cddcSAtari911 } elseif ($returnCode === 0) { 44661d05cddcSAtari911 echo json_encode([ 44671d05cddcSAtari911 'success' => true, 44681d05cddcSAtari911 'message' => 'Sync started. Check log below for progress.' 44691d05cddcSAtari911 ]); 44701d05cddcSAtari911 } else { 44711d05cddcSAtari911 // Include output for debugging 44721d05cddcSAtari911 $errorMsg = 'Sync failed with error code: ' . $returnCode; 44731d05cddcSAtari911 if (!empty($output)) { 44741d05cddcSAtari911 $errorMsg .= ' | ' . implode(' | ', array_slice($output, -3)); 44751d05cddcSAtari911 } 44761d05cddcSAtari911 echo json_encode([ 44771d05cddcSAtari911 'success' => false, 44781d05cddcSAtari911 'message' => $errorMsg 44791d05cddcSAtari911 ]); 44801d05cddcSAtari911 } 44811d05cddcSAtari911 exit; 44821d05cddcSAtari911 } 44831d05cddcSAtari911 } 44841d05cddcSAtari911 44851d05cddcSAtari911 private function stopSync() { 44861d05cddcSAtari911 global $INPUT; 44871d05cddcSAtari911 44881d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 44891d05cddcSAtari911 header('Content-Type: application/json'); 44901d05cddcSAtari911 44911d05cddcSAtari911 $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort'; 44921d05cddcSAtari911 44931d05cddcSAtari911 // Create abort flag file 44941d05cddcSAtari911 if (file_put_contents($abortFile, date('Y-m-d H:i:s')) !== false) { 44951d05cddcSAtari911 echo json_encode([ 44961d05cddcSAtari911 'success' => true, 44971d05cddcSAtari911 'message' => 'Stop signal sent to sync process' 44981d05cddcSAtari911 ]); 44991d05cddcSAtari911 } else { 45001d05cddcSAtari911 echo json_encode([ 45011d05cddcSAtari911 'success' => false, 45021d05cddcSAtari911 'message' => 'Failed to create abort flag' 45031d05cddcSAtari911 ]); 45041d05cddcSAtari911 } 45051d05cddcSAtari911 exit; 45061d05cddcSAtari911 } 45071d05cddcSAtari911 } 45081d05cddcSAtari911 45091d05cddcSAtari911 private function uploadUpdate() { 45101d05cddcSAtari911 if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) { 45111d05cddcSAtari911 $this->redirect('Upload failed: ' . ($_FILES['plugin_zip']['error'] ?? 'No file uploaded'), 'error', 'update'); 45121d05cddcSAtari911 return; 45131d05cddcSAtari911 } 45141d05cddcSAtari911 45151d05cddcSAtari911 $uploadedFile = $_FILES['plugin_zip']['tmp_name']; 45161d05cddcSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar/'; 45171d05cddcSAtari911 $backupFirst = isset($_POST['backup_first']); 45181d05cddcSAtari911 45191d05cddcSAtari911 // Check if plugin directory is writable 45201d05cddcSAtari911 if (!is_writable($pluginDir)) { 45211d05cddcSAtari911 $this->redirect('Plugin directory is not writable. Please check permissions: ' . $pluginDir, 'error', 'update'); 45221d05cddcSAtari911 return; 45231d05cddcSAtari911 } 45241d05cddcSAtari911 45251d05cddcSAtari911 // Check if parent directory is writable (for backup and temp files) 45261d05cddcSAtari911 if (!is_writable(DOKU_PLUGIN)) { 45271d05cddcSAtari911 $this->redirect('Plugin parent directory is not writable. Please check permissions: ' . DOKU_PLUGIN, 'error', 'update'); 45281d05cddcSAtari911 return; 45291d05cddcSAtari911 } 45301d05cddcSAtari911 45311d05cddcSAtari911 // Verify it's a ZIP file 45321d05cddcSAtari911 $finfo = finfo_open(FILEINFO_MIME_TYPE); 45331d05cddcSAtari911 $mimeType = finfo_file($finfo, $uploadedFile); 45341d05cddcSAtari911 finfo_close($finfo); 45351d05cddcSAtari911 45361d05cddcSAtari911 if ($mimeType !== 'application/zip' && $mimeType !== 'application/x-zip-compressed') { 45371d05cddcSAtari911 $this->redirect('Invalid file type. Please upload a ZIP file.', 'error', 'update'); 45381d05cddcSAtari911 return; 45391d05cddcSAtari911 } 45401d05cddcSAtari911 45411d05cddcSAtari911 // Create backup if requested 45421d05cddcSAtari911 if ($backupFirst) { 45431d05cddcSAtari911 // Get current version 45441d05cddcSAtari911 $pluginInfo = $pluginDir . 'plugin.info.txt'; 45451d05cddcSAtari911 $version = 'unknown'; 45461d05cddcSAtari911 if (file_exists($pluginInfo)) { 45471d05cddcSAtari911 $info = confToHash($pluginInfo); 45481d05cddcSAtari911 $version = $info['version'] ?? ($info['date'] ?? 'unknown'); 45491d05cddcSAtari911 } 45501d05cddcSAtari911 45511d05cddcSAtari911 $backupName = 'calendar.backup.v' . $version . '.' . date('Y-m-d_H-i-s') . '.zip'; 45521d05cddcSAtari911 $backupPath = DOKU_PLUGIN . $backupName; 45531d05cddcSAtari911 45541d05cddcSAtari911 try { 45551d05cddcSAtari911 $zip = new ZipArchive(); 45561d05cddcSAtari911 if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) { 45579ccd446eSAtari911 $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/'); 45581d05cddcSAtari911 $zip->close(); 45599ccd446eSAtari911 45609ccd446eSAtari911 // Verify backup was created and has content 45619ccd446eSAtari911 if (!file_exists($backupPath)) { 45629ccd446eSAtari911 $this->redirect('Backup file was not created', 'error', 'update'); 45639ccd446eSAtari911 return; 45649ccd446eSAtari911 } 45659ccd446eSAtari911 45669ccd446eSAtari911 $backupSize = filesize($backupPath); 45679ccd446eSAtari911 if ($backupSize < 1000) { // Backup should be at least 1KB 45689ccd446eSAtari911 @unlink($backupPath); 45699ccd446eSAtari911 $this->redirect('Backup file is too small (' . $backupSize . ' bytes). Only ' . $fileCount . ' files were added. Backup aborted.', 'error', 'update'); 45709ccd446eSAtari911 return; 45719ccd446eSAtari911 } 45729ccd446eSAtari911 45739ccd446eSAtari911 if ($fileCount < 10) { // Should have at least 10 files 45749ccd446eSAtari911 @unlink($backupPath); 45759ccd446eSAtari911 $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup aborted.', 'error', 'update'); 45769ccd446eSAtari911 return; 45779ccd446eSAtari911 } 45781d05cddcSAtari911 } else { 45791d05cddcSAtari911 $this->redirect('Failed to create backup ZIP file', 'error', 'update'); 45801d05cddcSAtari911 return; 45811d05cddcSAtari911 } 45821d05cddcSAtari911 } catch (Exception $e) { 45839ccd446eSAtari911 if (file_exists($backupPath)) { 45849ccd446eSAtari911 @unlink($backupPath); 45859ccd446eSAtari911 } 45861d05cddcSAtari911 $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update'); 45871d05cddcSAtari911 return; 45881d05cddcSAtari911 } 45891d05cddcSAtari911 } 45901d05cddcSAtari911 45911d05cddcSAtari911 // Extract uploaded ZIP 45921d05cddcSAtari911 $zip = new ZipArchive(); 45931d05cddcSAtari911 if ($zip->open($uploadedFile) !== TRUE) { 45941d05cddcSAtari911 $this->redirect('Failed to open ZIP file', 'error', 'update'); 45951d05cddcSAtari911 return; 45961d05cddcSAtari911 } 45971d05cddcSAtari911 45981d05cddcSAtari911 // Check if ZIP contains calendar folder 45991d05cddcSAtari911 $hasCalendarFolder = false; 46001d05cddcSAtari911 for ($i = 0; $i < $zip->numFiles; $i++) { 46011d05cddcSAtari911 $filename = $zip->getNameIndex($i); 46021d05cddcSAtari911 if (strpos($filename, 'calendar/') === 0) { 46031d05cddcSAtari911 $hasCalendarFolder = true; 46041d05cddcSAtari911 break; 46051d05cddcSAtari911 } 46061d05cddcSAtari911 } 46071d05cddcSAtari911 46081d05cddcSAtari911 // Extract to temp directory first 46091d05cddcSAtari911 $tempDir = DOKU_PLUGIN . 'calendar_update_temp/'; 46101d05cddcSAtari911 if (is_dir($tempDir)) { 46111d05cddcSAtari911 $this->deleteDirectory($tempDir); 46121d05cddcSAtari911 } 46131d05cddcSAtari911 mkdir($tempDir); 46141d05cddcSAtari911 46151d05cddcSAtari911 $zip->extractTo($tempDir); 46161d05cddcSAtari911 $zip->close(); 46171d05cddcSAtari911 46181d05cddcSAtari911 // Determine source directory 46191d05cddcSAtari911 if ($hasCalendarFolder) { 46201d05cddcSAtari911 $sourceDir = $tempDir . 'calendar/'; 46211d05cddcSAtari911 } else { 46221d05cddcSAtari911 $sourceDir = $tempDir; 46231d05cddcSAtari911 } 46241d05cddcSAtari911 46251d05cddcSAtari911 // Preserve configuration files 46261d05cddcSAtari911 $preserveFiles = ['sync_config.php', 'sync_state.json', 'sync.log']; 46271d05cddcSAtari911 $preserved = []; 46281d05cddcSAtari911 foreach ($preserveFiles as $file) { 46291d05cddcSAtari911 $oldFile = $pluginDir . $file; 46301d05cddcSAtari911 if (file_exists($oldFile)) { 46311d05cddcSAtari911 $preserved[$file] = file_get_contents($oldFile); 46321d05cddcSAtari911 } 46331d05cddcSAtari911 } 46341d05cddcSAtari911 46351d05cddcSAtari911 // Delete old plugin files (except data files) 46361d05cddcSAtari911 $this->deleteDirectoryContents($pluginDir, $preserveFiles); 46371d05cddcSAtari911 46381d05cddcSAtari911 // Copy new files 46391d05cddcSAtari911 $this->recursiveCopy($sourceDir, $pluginDir); 46401d05cddcSAtari911 46411d05cddcSAtari911 // Restore preserved files 46421d05cddcSAtari911 foreach ($preserved as $file => $content) { 46431d05cddcSAtari911 file_put_contents($pluginDir . $file, $content); 46441d05cddcSAtari911 } 46451d05cddcSAtari911 46461d05cddcSAtari911 // Update version and date in plugin.info.txt 46471d05cddcSAtari911 $pluginInfo = $pluginDir . 'plugin.info.txt'; 46481d05cddcSAtari911 if (file_exists($pluginInfo)) { 46491d05cddcSAtari911 $info = confToHash($pluginInfo); 46501d05cddcSAtari911 46511d05cddcSAtari911 // Get new version from uploaded plugin 46521d05cddcSAtari911 $newVersion = $info['version'] ?? 'unknown'; 46531d05cddcSAtari911 46541d05cddcSAtari911 // Update date to current 46551d05cddcSAtari911 $info['date'] = date('Y-m-d'); 46561d05cddcSAtari911 46571d05cddcSAtari911 // Write updated info back 46581d05cddcSAtari911 $lines = []; 46591d05cddcSAtari911 foreach ($info as $key => $value) { 46601d05cddcSAtari911 $lines[] = str_pad($key, 8) . ' ' . $value; 46611d05cddcSAtari911 } 46621d05cddcSAtari911 file_put_contents($pluginInfo, implode("\n", $lines) . "\n"); 46631d05cddcSAtari911 } 46641d05cddcSAtari911 46651d05cddcSAtari911 // Cleanup temp directory 46661d05cddcSAtari911 $this->deleteDirectory($tempDir); 46671d05cddcSAtari911 46681d05cddcSAtari911 $message = 'Plugin updated successfully!'; 46691d05cddcSAtari911 if ($backupFirst) { 46701d05cddcSAtari911 $message .= ' Backup saved as: ' . $backupName; 46711d05cddcSAtari911 } 46721d05cddcSAtari911 $this->redirect($message, 'success', 'update'); 46731d05cddcSAtari911 } 46741d05cddcSAtari911 46751d05cddcSAtari911 private function deleteBackup() { 46761d05cddcSAtari911 global $INPUT; 46771d05cddcSAtari911 46781d05cddcSAtari911 $filename = $INPUT->str('backup_file'); 46791d05cddcSAtari911 46801d05cddcSAtari911 if (empty($filename)) { 46811d05cddcSAtari911 $this->redirect('No backup file specified', 'error', 'update'); 46821d05cddcSAtari911 return; 46831d05cddcSAtari911 } 46841d05cddcSAtari911 46851d05cddcSAtari911 // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal 46861d05cddcSAtari911 if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { 46871d05cddcSAtari911 $this->redirect('Invalid backup filename', 'error', 'update'); 46881d05cddcSAtari911 return; 46891d05cddcSAtari911 } 46901d05cddcSAtari911 46911d05cddcSAtari911 $backupPath = DOKU_PLUGIN . $filename; 46921d05cddcSAtari911 46931d05cddcSAtari911 if (!file_exists($backupPath)) { 46941d05cddcSAtari911 $this->redirect('Backup file not found', 'error', 'update'); 46951d05cddcSAtari911 return; 46961d05cddcSAtari911 } 46971d05cddcSAtari911 46981d05cddcSAtari911 if (@unlink($backupPath)) { 46991d05cddcSAtari911 $this->redirect('Backup deleted: ' . $filename, 'success', 'update'); 47001d05cddcSAtari911 } else { 47011d05cddcSAtari911 $this->redirect('Failed to delete backup. Check file permissions.', 'error', 'update'); 47021d05cddcSAtari911 } 47031d05cddcSAtari911 } 47041d05cddcSAtari911 47051d05cddcSAtari911 private function renameBackup() { 47061d05cddcSAtari911 global $INPUT; 47071d05cddcSAtari911 47081d05cddcSAtari911 $oldName = $INPUT->str('old_name'); 47091d05cddcSAtari911 $newName = $INPUT->str('new_name'); 47101d05cddcSAtari911 47111d05cddcSAtari911 if (empty($oldName) || empty($newName)) { 47121d05cddcSAtari911 $this->redirect('Missing filename(s)', 'error', 'update'); 47131d05cddcSAtari911 return; 47141d05cddcSAtari911 } 47151d05cddcSAtari911 47161d05cddcSAtari911 // Security: validate filenames 47171d05cddcSAtari911 if (!preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $oldName) || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $newName)) { 47181d05cddcSAtari911 $this->redirect('Invalid filename format', 'error', 'update'); 47191d05cddcSAtari911 return; 47201d05cddcSAtari911 } 47211d05cddcSAtari911 47221d05cddcSAtari911 $oldPath = DOKU_PLUGIN . $oldName; 47231d05cddcSAtari911 $newPath = DOKU_PLUGIN . $newName; 47241d05cddcSAtari911 47251d05cddcSAtari911 if (!file_exists($oldPath)) { 47261d05cddcSAtari911 $this->redirect('Backup file not found', 'error', 'update'); 47271d05cddcSAtari911 return; 47281d05cddcSAtari911 } 47291d05cddcSAtari911 47301d05cddcSAtari911 if (file_exists($newPath)) { 47311d05cddcSAtari911 $this->redirect('A file with the new name already exists', 'error', 'update'); 47321d05cddcSAtari911 return; 47331d05cddcSAtari911 } 47341d05cddcSAtari911 47351d05cddcSAtari911 if (@rename($oldPath, $newPath)) { 47361d05cddcSAtari911 $this->redirect('Backup renamed: ' . $oldName . ' → ' . $newName, 'success', 'update'); 47371d05cddcSAtari911 } else { 47381d05cddcSAtari911 $this->redirect('Failed to rename backup. Check file permissions.', 'error', 'update'); 47391d05cddcSAtari911 } 47401d05cddcSAtari911 } 47411d05cddcSAtari911 47421d05cddcSAtari911 private function restoreBackup() { 47431d05cddcSAtari911 global $INPUT; 47441d05cddcSAtari911 47451d05cddcSAtari911 $filename = $INPUT->str('backup_file'); 47461d05cddcSAtari911 47471d05cddcSAtari911 if (empty($filename)) { 47481d05cddcSAtari911 $this->redirect('No backup file specified', 'error', 'update'); 47491d05cddcSAtari911 return; 47501d05cddcSAtari911 } 47511d05cddcSAtari911 47521d05cddcSAtari911 // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal 47531d05cddcSAtari911 if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { 47541d05cddcSAtari911 $this->redirect('Invalid backup filename', 'error', 'update'); 47551d05cddcSAtari911 return; 47561d05cddcSAtari911 } 47571d05cddcSAtari911 47581d05cddcSAtari911 $backupPath = DOKU_PLUGIN . $filename; 47591d05cddcSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar/'; 47601d05cddcSAtari911 47611d05cddcSAtari911 if (!file_exists($backupPath)) { 47621d05cddcSAtari911 $this->redirect('Backup file not found', 'error', 'update'); 47631d05cddcSAtari911 return; 47641d05cddcSAtari911 } 47651d05cddcSAtari911 47661d05cddcSAtari911 // Check if plugin directory is writable 47671d05cddcSAtari911 if (!is_writable($pluginDir)) { 47681d05cddcSAtari911 $this->redirect('Plugin directory is not writable. Please check permissions.', 'error', 'update'); 47691d05cddcSAtari911 return; 47701d05cddcSAtari911 } 47711d05cddcSAtari911 47721d05cddcSAtari911 // Extract backup to temp directory 47731d05cddcSAtari911 $tempDir = DOKU_PLUGIN . 'calendar_restore_temp/'; 47741d05cddcSAtari911 if (is_dir($tempDir)) { 47751d05cddcSAtari911 $this->deleteDirectory($tempDir); 47761d05cddcSAtari911 } 47771d05cddcSAtari911 mkdir($tempDir); 47781d05cddcSAtari911 47791d05cddcSAtari911 $zip = new ZipArchive(); 47801d05cddcSAtari911 if ($zip->open($backupPath) !== TRUE) { 47811d05cddcSAtari911 $this->redirect('Failed to open backup ZIP file', 'error', 'update'); 47821d05cddcSAtari911 return; 47831d05cddcSAtari911 } 47841d05cddcSAtari911 47851d05cddcSAtari911 $zip->extractTo($tempDir); 47861d05cddcSAtari911 $zip->close(); 47871d05cddcSAtari911 47881d05cddcSAtari911 // The backup contains a "calendar/" folder 47891d05cddcSAtari911 $sourceDir = $tempDir . 'calendar/'; 47901d05cddcSAtari911 47911d05cddcSAtari911 if (!is_dir($sourceDir)) { 47921d05cddcSAtari911 $this->deleteDirectory($tempDir); 47931d05cddcSAtari911 $this->redirect('Invalid backup structure', 'error', 'update'); 47941d05cddcSAtari911 return; 47951d05cddcSAtari911 } 47961d05cddcSAtari911 47971d05cddcSAtari911 // Delete current plugin directory contents 47981d05cddcSAtari911 $this->deleteDirectoryContents($pluginDir, []); 47991d05cddcSAtari911 48001d05cddcSAtari911 // Copy backup files to plugin directory 48011d05cddcSAtari911 $this->recursiveCopy($sourceDir, $pluginDir); 48021d05cddcSAtari911 48031d05cddcSAtari911 // Cleanup temp directory 48041d05cddcSAtari911 $this->deleteDirectory($tempDir); 48051d05cddcSAtari911 48061d05cddcSAtari911 $this->redirect('Plugin restored from backup: ' . $filename, 'success', 'update'); 48071d05cddcSAtari911 } 48081d05cddcSAtari911 48099ccd446eSAtari911 private function createManualBackup() { 48109ccd446eSAtari911 $pluginDir = DOKU_PLUGIN . 'calendar/'; 48119ccd446eSAtari911 48129ccd446eSAtari911 // Check if plugin directory is readable 48139ccd446eSAtari911 if (!is_readable($pluginDir)) { 48149ccd446eSAtari911 $this->redirect('Plugin directory is not readable. Please check permissions.', 'error', 'update'); 48159ccd446eSAtari911 return; 48169ccd446eSAtari911 } 48179ccd446eSAtari911 48189ccd446eSAtari911 // Check if parent directory is writable (for saving backup) 48199ccd446eSAtari911 if (!is_writable(DOKU_PLUGIN)) { 48209ccd446eSAtari911 $this->redirect('Plugin parent directory is not writable. Cannot save backup.', 'error', 'update'); 48219ccd446eSAtari911 return; 48229ccd446eSAtari911 } 48239ccd446eSAtari911 48249ccd446eSAtari911 // Get current version 48259ccd446eSAtari911 $pluginInfo = $pluginDir . 'plugin.info.txt'; 48269ccd446eSAtari911 $version = 'unknown'; 48279ccd446eSAtari911 if (file_exists($pluginInfo)) { 48289ccd446eSAtari911 $info = confToHash($pluginInfo); 48299ccd446eSAtari911 $version = $info['version'] ?? ($info['date'] ?? 'unknown'); 48309ccd446eSAtari911 } 48319ccd446eSAtari911 48329ccd446eSAtari911 $backupName = 'calendar.backup.v' . $version . '.manual.' . date('Y-m-d_H-i-s') . '.zip'; 48339ccd446eSAtari911 $backupPath = DOKU_PLUGIN . $backupName; 48349ccd446eSAtari911 48359ccd446eSAtari911 try { 48369ccd446eSAtari911 $zip = new ZipArchive(); 48379ccd446eSAtari911 if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) { 48389ccd446eSAtari911 $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/'); 48399ccd446eSAtari911 $zip->close(); 48409ccd446eSAtari911 48419ccd446eSAtari911 // Verify backup was created and has content 48429ccd446eSAtari911 if (!file_exists($backupPath)) { 48439ccd446eSAtari911 $this->redirect('Backup file was not created', 'error', 'update'); 48449ccd446eSAtari911 return; 48459ccd446eSAtari911 } 48469ccd446eSAtari911 48479ccd446eSAtari911 $backupSize = filesize($backupPath); 48489ccd446eSAtari911 if ($backupSize < 1000) { // Backup should be at least 1KB 48499ccd446eSAtari911 @unlink($backupPath); 48509ccd446eSAtari911 $this->redirect('Backup file is too small (' . $this->formatBytes($backupSize) . '). Only ' . $fileCount . ' files were added. Backup failed.', 'error', 'update'); 48519ccd446eSAtari911 return; 48529ccd446eSAtari911 } 48539ccd446eSAtari911 48549ccd446eSAtari911 if ($fileCount < 10) { // Should have at least 10 files 48559ccd446eSAtari911 @unlink($backupPath); 48569ccd446eSAtari911 $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup failed.', 'error', 'update'); 48579ccd446eSAtari911 return; 48589ccd446eSAtari911 } 48599ccd446eSAtari911 48609ccd446eSAtari911 // Success! 48619ccd446eSAtari911 $this->redirect('✓ Manual backup created successfully: ' . $backupName . ' (' . $this->formatBytes($backupSize) . ', ' . $fileCount . ' files)', 'success', 'update'); 48629ccd446eSAtari911 48639ccd446eSAtari911 } else { 48649ccd446eSAtari911 $this->redirect('Failed to create backup ZIP file', 'error', 'update'); 48659ccd446eSAtari911 return; 48669ccd446eSAtari911 } 48679ccd446eSAtari911 } catch (Exception $e) { 48689ccd446eSAtari911 if (file_exists($backupPath)) { 48699ccd446eSAtari911 @unlink($backupPath); 48709ccd446eSAtari911 } 48719ccd446eSAtari911 $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update'); 48729ccd446eSAtari911 return; 48739ccd446eSAtari911 } 48749ccd446eSAtari911 } 48759ccd446eSAtari911 48761d05cddcSAtari911 private function addDirectoryToZip($zip, $dir, $zipPath = '') { 48779ccd446eSAtari911 $fileCount = 0; 48789ccd446eSAtari911 $errors = []; 48799ccd446eSAtari911 4880*7e8ea635SAtari911 // Ensure dir has trailing slash 4881*7e8ea635SAtari911 $dir = rtrim($dir, '/') . '/'; 4882*7e8ea635SAtari911 48839ccd446eSAtari911 if (!is_dir($dir)) { 48849ccd446eSAtari911 throw new Exception("Directory does not exist: $dir"); 48859ccd446eSAtari911 } 48869ccd446eSAtari911 48879ccd446eSAtari911 if (!is_readable($dir)) { 48889ccd446eSAtari911 throw new Exception("Directory is not readable: $dir"); 48899ccd446eSAtari911 } 48909ccd446eSAtari911 48911d05cddcSAtari911 try { 4892*7e8ea635SAtari911 // First, add all directories to preserve structure (including empty ones) 4893*7e8ea635SAtari911 $dirs = new RecursiveIteratorIterator( 48941d05cddcSAtari911 new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), 4895*7e8ea635SAtari911 RecursiveIteratorIterator::SELF_FIRST // Process directories before their contents 48961d05cddcSAtari911 ); 48971d05cddcSAtari911 4898*7e8ea635SAtari911 foreach ($dirs as $item) { 4899*7e8ea635SAtari911 $itemPath = $item->getRealPath(); 4900*7e8ea635SAtari911 if (!$itemPath) continue; 49019ccd446eSAtari911 4902*7e8ea635SAtari911 // Calculate relative path from the source directory 4903*7e8ea635SAtari911 $relativePath = $zipPath . substr($itemPath, strlen($dir)); 4904*7e8ea635SAtari911 4905*7e8ea635SAtari911 if ($item->isDir()) { 4906*7e8ea635SAtari911 // Add directory to ZIP (preserves empty directories and structure) 4907*7e8ea635SAtari911 $dirInZip = rtrim($relativePath, '/') . '/'; 4908*7e8ea635SAtari911 $zip->addEmptyDir($dirInZip); 4909*7e8ea635SAtari911 } else { 4910*7e8ea635SAtari911 // Add file to ZIP 4911*7e8ea635SAtari911 if (is_readable($itemPath)) { 4912*7e8ea635SAtari911 if ($zip->addFile($itemPath, $relativePath)) { 49139ccd446eSAtari911 $fileCount++; 49149ccd446eSAtari911 } else { 4915*7e8ea635SAtari911 $errors[] = "Failed to add: " . basename($itemPath); 49169ccd446eSAtari911 } 49179ccd446eSAtari911 } else { 4918*7e8ea635SAtari911 $errors[] = "Cannot read: " . basename($itemPath); 49191d05cddcSAtari911 } 49201d05cddcSAtari911 } 49211d05cddcSAtari911 } 49229ccd446eSAtari911 49239ccd446eSAtari911 // Log any errors but don't fail if we got most files 49249ccd446eSAtari911 if (!empty($errors) && count($errors) < 5) { 49259ccd446eSAtari911 foreach ($errors as $error) { 49269ccd446eSAtari911 error_log('Calendar plugin backup warning: ' . $error); 49279ccd446eSAtari911 } 49289ccd446eSAtari911 } 49299ccd446eSAtari911 49309ccd446eSAtari911 // If too many errors, fail 49319ccd446eSAtari911 if (count($errors) > 5) { 49329ccd446eSAtari911 throw new Exception("Too many errors adding files to backup: " . implode(', ', array_slice($errors, 0, 5))); 49339ccd446eSAtari911 } 49349ccd446eSAtari911 49351d05cddcSAtari911 } catch (Exception $e) { 49369ccd446eSAtari911 error_log('Calendar plugin backup error: ' . $e->getMessage()); 49379ccd446eSAtari911 throw $e; 49381d05cddcSAtari911 } 49399ccd446eSAtari911 49409ccd446eSAtari911 return $fileCount; 49411d05cddcSAtari911 } 49421d05cddcSAtari911 49431d05cddcSAtari911 private function deleteDirectory($dir) { 49441d05cddcSAtari911 if (!is_dir($dir)) return; 49451d05cddcSAtari911 49461d05cddcSAtari911 try { 49471d05cddcSAtari911 $files = new RecursiveIteratorIterator( 49481d05cddcSAtari911 new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), 49491d05cddcSAtari911 RecursiveIteratorIterator::CHILD_FIRST 49501d05cddcSAtari911 ); 49511d05cddcSAtari911 49521d05cddcSAtari911 foreach ($files as $file) { 49531d05cddcSAtari911 if ($file->isDir()) { 49541d05cddcSAtari911 @rmdir($file->getRealPath()); 49551d05cddcSAtari911 } else { 49561d05cddcSAtari911 @unlink($file->getRealPath()); 49571d05cddcSAtari911 } 49581d05cddcSAtari911 } 49591d05cddcSAtari911 49601d05cddcSAtari911 @rmdir($dir); 49611d05cddcSAtari911 } catch (Exception $e) { 49621d05cddcSAtari911 error_log('Calendar plugin delete directory error: ' . $e->getMessage()); 49631d05cddcSAtari911 } 49641d05cddcSAtari911 } 49651d05cddcSAtari911 49661d05cddcSAtari911 private function deleteDirectoryContents($dir, $preserve = []) { 49671d05cddcSAtari911 if (!is_dir($dir)) return; 49681d05cddcSAtari911 49691d05cddcSAtari911 $items = scandir($dir); 49701d05cddcSAtari911 foreach ($items as $item) { 49711d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 49721d05cddcSAtari911 if (in_array($item, $preserve)) continue; 49731d05cddcSAtari911 49741d05cddcSAtari911 $path = $dir . $item; 49751d05cddcSAtari911 if (is_dir($path)) { 49761d05cddcSAtari911 $this->deleteDirectory($path); 49771d05cddcSAtari911 } else { 49781d05cddcSAtari911 unlink($path); 49791d05cddcSAtari911 } 49801d05cddcSAtari911 } 49811d05cddcSAtari911 } 49821d05cddcSAtari911 49831d05cddcSAtari911 private function recursiveCopy($src, $dst) { 4984*7e8ea635SAtari911 if (!is_dir($src)) { 4985*7e8ea635SAtari911 return false; 4986*7e8ea635SAtari911 } 4987*7e8ea635SAtari911 49881d05cddcSAtari911 $dir = opendir($src); 4989*7e8ea635SAtari911 if (!$dir) { 4990*7e8ea635SAtari911 return false; 4991*7e8ea635SAtari911 } 4992*7e8ea635SAtari911 4993*7e8ea635SAtari911 // Create destination directory with proper permissions (0755) 4994*7e8ea635SAtari911 if (!is_dir($dst)) { 4995*7e8ea635SAtari911 mkdir($dst, 0755, true); 4996*7e8ea635SAtari911 } 49971d05cddcSAtari911 49981d05cddcSAtari911 while (($file = readdir($dir)) !== false) { 49991d05cddcSAtari911 if ($file !== '.' && $file !== '..') { 5000*7e8ea635SAtari911 $srcPath = $src . '/' . $file; 5001*7e8ea635SAtari911 $dstPath = $dst . '/' . $file; 5002*7e8ea635SAtari911 5003*7e8ea635SAtari911 if (is_dir($srcPath)) { 5004*7e8ea635SAtari911 // Recursively copy subdirectory 5005*7e8ea635SAtari911 $this->recursiveCopy($srcPath, $dstPath); 50061d05cddcSAtari911 } else { 5007*7e8ea635SAtari911 // Copy file and preserve permissions 5008*7e8ea635SAtari911 if (copy($srcPath, $dstPath)) { 5009*7e8ea635SAtari911 // Try to preserve file permissions from source, fallback to 0644 5010*7e8ea635SAtari911 $perms = @fileperms($srcPath); 5011*7e8ea635SAtari911 if ($perms !== false) { 5012*7e8ea635SAtari911 @chmod($dstPath, $perms); 5013*7e8ea635SAtari911 } else { 5014*7e8ea635SAtari911 @chmod($dstPath, 0644); 5015*7e8ea635SAtari911 } 5016*7e8ea635SAtari911 } 50171d05cddcSAtari911 } 50181d05cddcSAtari911 } 50191d05cddcSAtari911 } 50201d05cddcSAtari911 50211d05cddcSAtari911 closedir($dir); 5022*7e8ea635SAtari911 return true; 50231d05cddcSAtari911 } 50241d05cddcSAtari911 50251d05cddcSAtari911 private function formatBytes($bytes) { 50261d05cddcSAtari911 if ($bytes >= 1073741824) { 50271d05cddcSAtari911 return number_format($bytes / 1073741824, 2) . ' GB'; 50281d05cddcSAtari911 } elseif ($bytes >= 1048576) { 50291d05cddcSAtari911 return number_format($bytes / 1048576, 2) . ' MB'; 50301d05cddcSAtari911 } elseif ($bytes >= 1024) { 50311d05cddcSAtari911 return number_format($bytes / 1024, 2) . ' KB'; 50321d05cddcSAtari911 } else { 50331d05cddcSAtari911 return $bytes . ' bytes'; 50341d05cddcSAtari911 } 50351d05cddcSAtari911 } 50361d05cddcSAtari911 50371d05cddcSAtari911 private function findPhpBinary() { 50381d05cddcSAtari911 // Try PHP_BINARY constant first (most reliable if available) 50391d05cddcSAtari911 if (defined('PHP_BINARY') && !empty(PHP_BINARY) && is_executable(PHP_BINARY)) { 50401d05cddcSAtari911 return escapeshellarg(PHP_BINARY); 50411d05cddcSAtari911 } 50421d05cddcSAtari911 50431d05cddcSAtari911 // Try common PHP binary locations 50441d05cddcSAtari911 $possiblePaths = [ 50451d05cddcSAtari911 '/usr/bin/php', 50461d05cddcSAtari911 '/usr/bin/php8.1', 50471d05cddcSAtari911 '/usr/bin/php8.2', 50481d05cddcSAtari911 '/usr/bin/php8.3', 50491d05cddcSAtari911 '/usr/bin/php7.4', 50501d05cddcSAtari911 '/usr/local/bin/php', 50511d05cddcSAtari911 'php' // Last resort - rely on PATH 50521d05cddcSAtari911 ]; 50531d05cddcSAtari911 50541d05cddcSAtari911 foreach ($possiblePaths as $path) { 50551d05cddcSAtari911 // Test if this PHP binary works 50561d05cddcSAtari911 $testOutput = []; 50571d05cddcSAtari911 $testReturn = 0; 50581d05cddcSAtari911 exec($path . ' -v 2>&1', $testOutput, $testReturn); 50591d05cddcSAtari911 50601d05cddcSAtari911 if ($testReturn === 0) { 50611d05cddcSAtari911 return ($path === 'php') ? 'php' : escapeshellarg($path); 50621d05cddcSAtari911 } 50631d05cddcSAtari911 } 50641d05cddcSAtari911 50651d05cddcSAtari911 // Fallback to 'php' and hope it's in PATH 50661d05cddcSAtari911 return 'php'; 50671d05cddcSAtari911 } 50681d05cddcSAtari911 50691d05cddcSAtari911 private function redirect($message, $type = 'success', $tab = null) { 50701d05cddcSAtari911 $url = '?do=admin&page=calendar'; 50711d05cddcSAtari911 if ($tab) { 50721d05cddcSAtari911 $url .= '&tab=' . $tab; 50731d05cddcSAtari911 } 50741d05cddcSAtari911 $url .= '&msg=' . urlencode($message) . '&msgtype=' . $type; 50751d05cddcSAtari911 header('Location: ' . $url); 50761d05cddcSAtari911 exit; 50771d05cddcSAtari911 } 50781d05cddcSAtari911 50791d05cddcSAtari911 private function getLog() { 50801d05cddcSAtari911 global $INPUT; 50811d05cddcSAtari911 50821d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 50831d05cddcSAtari911 header('Content-Type: application/json'); 50841d05cddcSAtari911 50851d05cddcSAtari911 $logFile = DOKU_PLUGIN . 'calendar/sync.log'; 50861d05cddcSAtari911 $log = ''; 50871d05cddcSAtari911 50881d05cddcSAtari911 if (file_exists($logFile)) { 50891d05cddcSAtari911 // Get last 500 lines 50901d05cddcSAtari911 $lines = file($logFile); 50911d05cddcSAtari911 if ($lines !== false) { 50921d05cddcSAtari911 $lines = array_slice($lines, -500); 50931d05cddcSAtari911 $log = implode('', $lines); 50941d05cddcSAtari911 } 50951d05cddcSAtari911 } else { 50961d05cddcSAtari911 $log = "No log file found. Sync hasn't run yet."; 50971d05cddcSAtari911 } 50981d05cddcSAtari911 50991d05cddcSAtari911 echo json_encode(['log' => $log]); 51001d05cddcSAtari911 exit; 51011d05cddcSAtari911 } 51021d05cddcSAtari911 } 51031d05cddcSAtari911 51041d05cddcSAtari911 private function exportConfig() { 51051d05cddcSAtari911 global $INPUT; 51061d05cddcSAtari911 51071d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 51081d05cddcSAtari911 header('Content-Type: application/json'); 51091d05cddcSAtari911 51101d05cddcSAtari911 try { 51111d05cddcSAtari911 $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; 51121d05cddcSAtari911 51131d05cddcSAtari911 if (!file_exists($configFile)) { 51141d05cddcSAtari911 echo json_encode([ 51151d05cddcSAtari911 'success' => false, 51161d05cddcSAtari911 'message' => 'Config file not found' 51171d05cddcSAtari911 ]); 51181d05cddcSAtari911 exit; 51191d05cddcSAtari911 } 51201d05cddcSAtari911 51211d05cddcSAtari911 // Read config file 51221d05cddcSAtari911 $configContent = file_get_contents($configFile); 51231d05cddcSAtari911 51241d05cddcSAtari911 // Generate encryption key from DokuWiki secret 51251d05cddcSAtari911 $key = $this->getEncryptionKey(); 51261d05cddcSAtari911 51271d05cddcSAtari911 // Encrypt config 51281d05cddcSAtari911 $encrypted = $this->encryptData($configContent, $key); 51291d05cddcSAtari911 51301d05cddcSAtari911 echo json_encode([ 51311d05cddcSAtari911 'success' => true, 51321d05cddcSAtari911 'encrypted' => $encrypted, 51331d05cddcSAtari911 'message' => 'Config exported successfully' 51341d05cddcSAtari911 ]); 51351d05cddcSAtari911 exit; 51361d05cddcSAtari911 51371d05cddcSAtari911 } catch (Exception $e) { 51381d05cddcSAtari911 echo json_encode([ 51391d05cddcSAtari911 'success' => false, 51401d05cddcSAtari911 'message' => $e->getMessage() 51411d05cddcSAtari911 ]); 51421d05cddcSAtari911 exit; 51431d05cddcSAtari911 } 51441d05cddcSAtari911 } 51451d05cddcSAtari911 } 51461d05cddcSAtari911 51471d05cddcSAtari911 private function importConfig() { 51481d05cddcSAtari911 global $INPUT; 51491d05cddcSAtari911 51501d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 51511d05cddcSAtari911 header('Content-Type: application/json'); 51521d05cddcSAtari911 51531d05cddcSAtari911 try { 51541d05cddcSAtari911 $encrypted = $_POST['encrypted_config'] ?? ''; 51551d05cddcSAtari911 51561d05cddcSAtari911 if (empty($encrypted)) { 51571d05cddcSAtari911 echo json_encode([ 51581d05cddcSAtari911 'success' => false, 51591d05cddcSAtari911 'message' => 'No config data provided' 51601d05cddcSAtari911 ]); 51611d05cddcSAtari911 exit; 51621d05cddcSAtari911 } 51631d05cddcSAtari911 51641d05cddcSAtari911 // Generate encryption key from DokuWiki secret 51651d05cddcSAtari911 $key = $this->getEncryptionKey(); 51661d05cddcSAtari911 51671d05cddcSAtari911 // Decrypt config 51681d05cddcSAtari911 $configContent = $this->decryptData($encrypted, $key); 51691d05cddcSAtari911 51701d05cddcSAtari911 if ($configContent === false) { 51711d05cddcSAtari911 echo json_encode([ 51721d05cddcSAtari911 'success' => false, 51731d05cddcSAtari911 'message' => 'Decryption failed. Invalid key or corrupted file.' 51741d05cddcSAtari911 ]); 51751d05cddcSAtari911 exit; 51761d05cddcSAtari911 } 51771d05cddcSAtari911 5178*7e8ea635SAtari911 // Validate PHP config file structure (without using eval) 5179*7e8ea635SAtari911 // Check that it starts with <?php and contains a return statement with array 5180*7e8ea635SAtari911 if (strpos($configContent, '<?php') === false) { 51811d05cddcSAtari911 echo json_encode([ 51821d05cddcSAtari911 'success' => false, 5183*7e8ea635SAtari911 'message' => 'Invalid config file: missing PHP opening tag' 5184*7e8ea635SAtari911 ]); 5185*7e8ea635SAtari911 exit; 5186*7e8ea635SAtari911 } 5187*7e8ea635SAtari911 5188*7e8ea635SAtari911 // Check for dangerous patterns that shouldn't be in a config file 5189*7e8ea635SAtari911 $dangerousPatterns = [ 5190*7e8ea635SAtari911 '/\b(exec|shell_exec|system|passthru|popen|proc_open)\s*\(/i', 5191*7e8ea635SAtari911 '/\b(eval|assert|create_function)\s*\(/i', 5192*7e8ea635SAtari911 '/\b(file_get_contents|file_put_contents|fopen|fwrite|unlink|rmdir)\s*\(/i', 5193*7e8ea635SAtari911 '/\$_(GET|POST|REQUEST|SERVER|FILES|COOKIE|SESSION)\s*\[/i', 5194*7e8ea635SAtari911 '/`[^`]+`/', // Backtick execution 5195*7e8ea635SAtari911 ]; 5196*7e8ea635SAtari911 5197*7e8ea635SAtari911 foreach ($dangerousPatterns as $pattern) { 5198*7e8ea635SAtari911 if (preg_match($pattern, $configContent)) { 5199*7e8ea635SAtari911 echo json_encode([ 5200*7e8ea635SAtari911 'success' => false, 5201*7e8ea635SAtari911 'message' => 'Invalid config file: contains prohibited code patterns' 5202*7e8ea635SAtari911 ]); 5203*7e8ea635SAtari911 exit; 5204*7e8ea635SAtari911 } 5205*7e8ea635SAtari911 } 5206*7e8ea635SAtari911 5207*7e8ea635SAtari911 // Verify it looks like a valid config (has return array structure) 5208*7e8ea635SAtari911 if (!preg_match('/return\s*\[/', $configContent)) { 5209*7e8ea635SAtari911 echo json_encode([ 5210*7e8ea635SAtari911 'success' => false, 5211*7e8ea635SAtari911 'message' => 'Invalid config file: must contain a return array statement' 52121d05cddcSAtari911 ]); 52131d05cddcSAtari911 exit; 52141d05cddcSAtari911 } 52151d05cddcSAtari911 52161d05cddcSAtari911 // Write to config file 52171d05cddcSAtari911 $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; 52181d05cddcSAtari911 52191d05cddcSAtari911 // Backup existing config 52201d05cddcSAtari911 if (file_exists($configFile)) { 52211d05cddcSAtari911 $backupFile = $configFile . '.backup.' . date('Y-m-d_H-i-s'); 52221d05cddcSAtari911 copy($configFile, $backupFile); 52231d05cddcSAtari911 } 52241d05cddcSAtari911 52251d05cddcSAtari911 // Write new config 52261d05cddcSAtari911 if (file_put_contents($configFile, $configContent) === false) { 52271d05cddcSAtari911 echo json_encode([ 52281d05cddcSAtari911 'success' => false, 52291d05cddcSAtari911 'message' => 'Failed to write config file' 52301d05cddcSAtari911 ]); 52311d05cddcSAtari911 exit; 52321d05cddcSAtari911 } 52331d05cddcSAtari911 52341d05cddcSAtari911 echo json_encode([ 52351d05cddcSAtari911 'success' => true, 52361d05cddcSAtari911 'message' => 'Config imported successfully' 52371d05cddcSAtari911 ]); 52381d05cddcSAtari911 exit; 52391d05cddcSAtari911 52401d05cddcSAtari911 } catch (Exception $e) { 52411d05cddcSAtari911 echo json_encode([ 52421d05cddcSAtari911 'success' => false, 52431d05cddcSAtari911 'message' => $e->getMessage() 52441d05cddcSAtari911 ]); 52451d05cddcSAtari911 exit; 52461d05cddcSAtari911 } 52471d05cddcSAtari911 } 52481d05cddcSAtari911 } 52491d05cddcSAtari911 52501d05cddcSAtari911 private function getEncryptionKey() { 52511d05cddcSAtari911 global $conf; 52521d05cddcSAtari911 // Use DokuWiki's secret as the base for encryption 52531d05cddcSAtari911 // This ensures the key is unique per installation 52541d05cddcSAtari911 return hash('sha256', $conf['secret'] . 'calendar_config_encryption', true); 52551d05cddcSAtari911 } 52561d05cddcSAtari911 52571d05cddcSAtari911 private function encryptData($data, $key) { 52581d05cddcSAtari911 // Use AES-256-CBC encryption 52591d05cddcSAtari911 $ivLength = openssl_cipher_iv_length('aes-256-cbc'); 52601d05cddcSAtari911 $iv = openssl_random_pseudo_bytes($ivLength); 52611d05cddcSAtari911 52621d05cddcSAtari911 $encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv); 52631d05cddcSAtari911 52641d05cddcSAtari911 // Combine IV and encrypted data, then base64 encode 52651d05cddcSAtari911 return base64_encode($iv . $encrypted); 52661d05cddcSAtari911 } 52671d05cddcSAtari911 52681d05cddcSAtari911 private function decryptData($encryptedData, $key) { 52691d05cddcSAtari911 // Decode base64 52701d05cddcSAtari911 $data = base64_decode($encryptedData); 52711d05cddcSAtari911 52721d05cddcSAtari911 if ($data === false) { 52731d05cddcSAtari911 return false; 52741d05cddcSAtari911 } 52751d05cddcSAtari911 52761d05cddcSAtari911 // Extract IV and encrypted content 52771d05cddcSAtari911 $ivLength = openssl_cipher_iv_length('aes-256-cbc'); 52781d05cddcSAtari911 $iv = substr($data, 0, $ivLength); 52791d05cddcSAtari911 $encrypted = substr($data, $ivLength); 52801d05cddcSAtari911 52811d05cddcSAtari911 // Decrypt 52821d05cddcSAtari911 $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv); 52831d05cddcSAtari911 52841d05cddcSAtari911 return $decrypted; 52851d05cddcSAtari911 } 52861d05cddcSAtari911 52871d05cddcSAtari911 private function clearLogFile() { 52881d05cddcSAtari911 global $INPUT; 52891d05cddcSAtari911 52901d05cddcSAtari911 if ($INPUT->str('call') === 'ajax') { 52911d05cddcSAtari911 header('Content-Type: application/json'); 52921d05cddcSAtari911 52931d05cddcSAtari911 $logFile = DOKU_PLUGIN . 'calendar/sync.log'; 52941d05cddcSAtari911 52951d05cddcSAtari911 if (file_exists($logFile)) { 52961d05cddcSAtari911 if (file_put_contents($logFile, '')) { 52971d05cddcSAtari911 echo json_encode(['success' => true]); 52981d05cddcSAtari911 } else { 52991d05cddcSAtari911 echo json_encode(['success' => false, 'message' => 'Could not clear log file']); 53001d05cddcSAtari911 } 53011d05cddcSAtari911 } else { 53021d05cddcSAtari911 echo json_encode(['success' => true, 'message' => 'No log file to clear']); 53031d05cddcSAtari911 } 53041d05cddcSAtari911 exit; 53051d05cddcSAtari911 } 53061d05cddcSAtari911 } 53071d05cddcSAtari911 53081d05cddcSAtari911 private function downloadLog() { 53091d05cddcSAtari911 $logFile = DOKU_PLUGIN . 'calendar/sync.log'; 53101d05cddcSAtari911 53111d05cddcSAtari911 if (file_exists($logFile)) { 53121d05cddcSAtari911 header('Content-Type: text/plain'); 53131d05cddcSAtari911 header('Content-Disposition: attachment; filename="calendar-sync-' . date('Y-m-d-His') . '.log"'); 53141d05cddcSAtari911 readfile($logFile); 53151d05cddcSAtari911 exit; 53161d05cddcSAtari911 } else { 53171d05cddcSAtari911 echo 'No log file found'; 53181d05cddcSAtari911 exit; 53191d05cddcSAtari911 } 53201d05cddcSAtari911 } 53211d05cddcSAtari911 53221d05cddcSAtari911 private function getEventStatistics() { 53231d05cddcSAtari911 $stats = [ 53241d05cddcSAtari911 'total_events' => 0, 53251d05cddcSAtari911 'total_namespaces' => 0, 53261d05cddcSAtari911 'total_files' => 0, 53271d05cddcSAtari911 'total_recurring' => 0, 53281d05cddcSAtari911 'by_namespace' => [], 53291d05cddcSAtari911 'last_scan' => '' 53301d05cddcSAtari911 ]; 53311d05cddcSAtari911 53321d05cddcSAtari911 $metaDir = DOKU_INC . 'data/meta/'; 53331d05cddcSAtari911 $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; 53341d05cddcSAtari911 53351d05cddcSAtari911 // Check if we have cached stats (less than 5 minutes old) 53361d05cddcSAtari911 if (file_exists($cacheFile)) { 53371d05cddcSAtari911 $cacheData = json_decode(file_get_contents($cacheFile), true); 53381d05cddcSAtari911 if ($cacheData && (time() - $cacheData['timestamp']) < 300) { 53391d05cddcSAtari911 return $cacheData['stats']; 53401d05cddcSAtari911 } 53411d05cddcSAtari911 } 53421d05cddcSAtari911 53431d05cddcSAtari911 // Scan for events 53441d05cddcSAtari911 $this->scanDirectoryForStats($metaDir, '', $stats); 53451d05cddcSAtari911 53461d05cddcSAtari911 // Count recurring events 53471d05cddcSAtari911 $recurringEvents = $this->findRecurringEvents(); 53481d05cddcSAtari911 $stats['total_recurring'] = count($recurringEvents); 53491d05cddcSAtari911 53501d05cddcSAtari911 $stats['total_namespaces'] = count($stats['by_namespace']); 53511d05cddcSAtari911 $stats['last_scan'] = date('Y-m-d H:i:s'); 53521d05cddcSAtari911 53531d05cddcSAtari911 // Cache the results 53541d05cddcSAtari911 file_put_contents($cacheFile, json_encode([ 53551d05cddcSAtari911 'timestamp' => time(), 53561d05cddcSAtari911 'stats' => $stats 53571d05cddcSAtari911 ])); 53581d05cddcSAtari911 53591d05cddcSAtari911 return $stats; 53601d05cddcSAtari911 } 53611d05cddcSAtari911 53621d05cddcSAtari911 private function scanDirectoryForStats($dir, $namespace, &$stats) { 53631d05cddcSAtari911 if (!is_dir($dir)) return; 53641d05cddcSAtari911 53651d05cddcSAtari911 $items = scandir($dir); 53661d05cddcSAtari911 foreach ($items as $item) { 53671d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 53681d05cddcSAtari911 53691d05cddcSAtari911 $path = $dir . $item; 53701d05cddcSAtari911 53711d05cddcSAtari911 // Check if this is a calendar directory 53721d05cddcSAtari911 if ($item === 'calendar' && is_dir($path)) { 53731d05cddcSAtari911 $jsonFiles = glob($path . '/*.json'); 53741d05cddcSAtari911 $eventCount = 0; 53751d05cddcSAtari911 53761d05cddcSAtari911 foreach ($jsonFiles as $file) { 53771d05cddcSAtari911 $stats['total_files']++; 53781d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 53791d05cddcSAtari911 if ($data) { 53801d05cddcSAtari911 foreach ($data as $dateEvents) { 53811d05cddcSAtari911 $eventCount += count($dateEvents); 53821d05cddcSAtari911 } 53831d05cddcSAtari911 } 53841d05cddcSAtari911 } 53851d05cddcSAtari911 53861d05cddcSAtari911 $stats['total_events'] += $eventCount; 53871d05cddcSAtari911 53881d05cddcSAtari911 if ($eventCount > 0) { 53891d05cddcSAtari911 $stats['by_namespace'][$namespace] = [ 53901d05cddcSAtari911 'events' => $eventCount, 53911d05cddcSAtari911 'files' => count($jsonFiles) 53921d05cddcSAtari911 ]; 53931d05cddcSAtari911 } 53941d05cddcSAtari911 } elseif (is_dir($path)) { 53951d05cddcSAtari911 // Recurse into subdirectories 53961d05cddcSAtari911 $newNamespace = $namespace ? $namespace . ':' . $item : $item; 53971d05cddcSAtari911 $this->scanDirectoryForStats($path . '/', $newNamespace, $stats); 53981d05cddcSAtari911 } 53991d05cddcSAtari911 } 54001d05cddcSAtari911 } 54011d05cddcSAtari911 54021d05cddcSAtari911 private function rescanEvents() { 54031d05cddcSAtari911 // Clear the cache to force a rescan 54049ccd446eSAtari911 $this->clearStatsCache(); 54051d05cddcSAtari911 54061d05cddcSAtari911 // Get fresh statistics 54071d05cddcSAtari911 $stats = $this->getEventStatistics(); 54081d05cddcSAtari911 54091d05cddcSAtari911 // Build absolute redirect URL 54101d05cddcSAtari911 $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'; 54111d05cddcSAtari911 54121d05cddcSAtari911 // Redirect with success message using absolute URL 54131d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 54141d05cddcSAtari911 exit; 54151d05cddcSAtari911 } 54161d05cddcSAtari911 54171d05cddcSAtari911 private function exportAllEvents() { 54181d05cddcSAtari911 $metaDir = DOKU_INC . 'data/meta/'; 54191d05cddcSAtari911 $allEvents = []; 54201d05cddcSAtari911 54211d05cddcSAtari911 // Collect all events 54221d05cddcSAtari911 $this->collectAllEvents($metaDir, '', $allEvents); 54231d05cddcSAtari911 54241d05cddcSAtari911 // Create export package 54259ccd446eSAtari911 // Get current version 54269ccd446eSAtari911 $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt'; 54279ccd446eSAtari911 $info = file_exists($pluginInfo) ? confToHash($pluginInfo) : []; 54289ccd446eSAtari911 $currentVersion = isset($info['version']) ? trim($info['version']) : 'unknown'; 54299ccd446eSAtari911 54301d05cddcSAtari911 $exportData = [ 54311d05cddcSAtari911 'export_date' => date('Y-m-d H:i:s'), 54329ccd446eSAtari911 'version' => $currentVersion, 54331d05cddcSAtari911 'total_events' => 0, 54341d05cddcSAtari911 'namespaces' => [] 54351d05cddcSAtari911 ]; 54361d05cddcSAtari911 54371d05cddcSAtari911 foreach ($allEvents as $namespace => $files) { 54381d05cddcSAtari911 $exportData['namespaces'][$namespace] = []; 54391d05cddcSAtari911 foreach ($files as $filename => $events) { 54401d05cddcSAtari911 $exportData['namespaces'][$namespace][$filename] = $events; 54411d05cddcSAtari911 foreach ($events as $dateEvents) { 54421d05cddcSAtari911 $exportData['total_events'] += count($dateEvents); 54431d05cddcSAtari911 } 54441d05cddcSAtari911 } 54451d05cddcSAtari911 } 54461d05cddcSAtari911 54471d05cddcSAtari911 // Send as download 54481d05cddcSAtari911 header('Content-Type: application/json'); 54491d05cddcSAtari911 header('Content-Disposition: attachment; filename="calendar-events-export-' . date('Y-m-d-His') . '.json"'); 54501d05cddcSAtari911 echo json_encode($exportData, JSON_PRETTY_PRINT); 54511d05cddcSAtari911 exit; 54521d05cddcSAtari911 } 54531d05cddcSAtari911 54541d05cddcSAtari911 private function collectAllEvents($dir, $namespace, &$allEvents) { 54551d05cddcSAtari911 if (!is_dir($dir)) return; 54561d05cddcSAtari911 54571d05cddcSAtari911 $items = scandir($dir); 54581d05cddcSAtari911 foreach ($items as $item) { 54591d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 54601d05cddcSAtari911 54611d05cddcSAtari911 $path = $dir . $item; 54621d05cddcSAtari911 54631d05cddcSAtari911 // Check if this is a calendar directory 54641d05cddcSAtari911 if ($item === 'calendar' && is_dir($path)) { 54651d05cddcSAtari911 $jsonFiles = glob($path . '/*.json'); 54661d05cddcSAtari911 54671d05cddcSAtari911 if (!isset($allEvents[$namespace])) { 54681d05cddcSAtari911 $allEvents[$namespace] = []; 54691d05cddcSAtari911 } 54701d05cddcSAtari911 54711d05cddcSAtari911 foreach ($jsonFiles as $file) { 54721d05cddcSAtari911 $filename = basename($file); 54731d05cddcSAtari911 $data = json_decode(file_get_contents($file), true); 54741d05cddcSAtari911 if ($data) { 54751d05cddcSAtari911 $allEvents[$namespace][$filename] = $data; 54761d05cddcSAtari911 } 54771d05cddcSAtari911 } 54781d05cddcSAtari911 } elseif (is_dir($path)) { 54791d05cddcSAtari911 // Recurse into subdirectories 54801d05cddcSAtari911 $newNamespace = $namespace ? $namespace . ':' . $item : $item; 54811d05cddcSAtari911 $this->collectAllEvents($path . '/', $newNamespace, $allEvents); 54821d05cddcSAtari911 } 54831d05cddcSAtari911 } 54841d05cddcSAtari911 } 54851d05cddcSAtari911 54861d05cddcSAtari911 private function importAllEvents() { 54871d05cddcSAtari911 global $INPUT; 54881d05cddcSAtari911 54891d05cddcSAtari911 if (!isset($_FILES['import_file'])) { 54901d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('No file uploaded') . '&msgtype=error'; 54911d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 54921d05cddcSAtari911 exit; 54931d05cddcSAtari911 } 54941d05cddcSAtari911 54951d05cddcSAtari911 $file = $_FILES['import_file']; 54961d05cddcSAtari911 54971d05cddcSAtari911 if ($file['error'] !== UPLOAD_ERR_OK) { 54981d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Upload error: ' . $file['error']) . '&msgtype=error'; 54991d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 55001d05cddcSAtari911 exit; 55011d05cddcSAtari911 } 55021d05cddcSAtari911 55031d05cddcSAtari911 // Read and decode the import file 55041d05cddcSAtari911 $importData = json_decode(file_get_contents($file['tmp_name']), true); 55051d05cddcSAtari911 55061d05cddcSAtari911 if (!$importData || !isset($importData['namespaces'])) { 55071d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Invalid import file format') . '&msgtype=error'; 55081d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 55091d05cddcSAtari911 exit; 55101d05cddcSAtari911 } 55111d05cddcSAtari911 55121d05cddcSAtari911 $importedCount = 0; 55131d05cddcSAtari911 $mergedCount = 0; 55141d05cddcSAtari911 55151d05cddcSAtari911 // Import events 55161d05cddcSAtari911 foreach ($importData['namespaces'] as $namespace => $files) { 55171d05cddcSAtari911 $metaDir = DOKU_INC . 'data/meta/'; 55181d05cddcSAtari911 if ($namespace) { 55191d05cddcSAtari911 $metaDir .= str_replace(':', '/', $namespace) . '/'; 55201d05cddcSAtari911 } 55211d05cddcSAtari911 $calendarDir = $metaDir . 'calendar/'; 55221d05cddcSAtari911 55231d05cddcSAtari911 // Create directory if needed 55241d05cddcSAtari911 if (!is_dir($calendarDir)) { 55251d05cddcSAtari911 mkdir($calendarDir, 0755, true); 55261d05cddcSAtari911 } 55271d05cddcSAtari911 55281d05cddcSAtari911 foreach ($files as $filename => $events) { 55291d05cddcSAtari911 $targetFile = $calendarDir . $filename; 55301d05cddcSAtari911 55311d05cddcSAtari911 // If file exists, merge events 55321d05cddcSAtari911 if (file_exists($targetFile)) { 55331d05cddcSAtari911 $existing = json_decode(file_get_contents($targetFile), true); 55341d05cddcSAtari911 if ($existing) { 55351d05cddcSAtari911 foreach ($events as $date => $dateEvents) { 55361d05cddcSAtari911 if (!isset($existing[$date])) { 55371d05cddcSAtari911 $existing[$date] = []; 55381d05cddcSAtari911 } 55391d05cddcSAtari911 foreach ($dateEvents as $event) { 55401d05cddcSAtari911 // Check if event with same ID exists 55411d05cddcSAtari911 $found = false; 55421d05cddcSAtari911 foreach ($existing[$date] as $existingEvent) { 55431d05cddcSAtari911 if ($existingEvent['id'] === $event['id']) { 55441d05cddcSAtari911 $found = true; 55451d05cddcSAtari911 break; 55461d05cddcSAtari911 } 55471d05cddcSAtari911 } 55481d05cddcSAtari911 if (!$found) { 55491d05cddcSAtari911 $existing[$date][] = $event; 55501d05cddcSAtari911 $importedCount++; 55511d05cddcSAtari911 } else { 55521d05cddcSAtari911 $mergedCount++; 55531d05cddcSAtari911 } 55541d05cddcSAtari911 } 55551d05cddcSAtari911 } 55561d05cddcSAtari911 file_put_contents($targetFile, json_encode($existing, JSON_PRETTY_PRINT)); 55571d05cddcSAtari911 } 55581d05cddcSAtari911 } else { 55591d05cddcSAtari911 // New file 55601d05cddcSAtari911 file_put_contents($targetFile, json_encode($events, JSON_PRETTY_PRINT)); 55611d05cddcSAtari911 foreach ($events as $dateEvents) { 55621d05cddcSAtari911 $importedCount += count($dateEvents); 55631d05cddcSAtari911 } 55641d05cddcSAtari911 } 55651d05cddcSAtari911 } 55661d05cddcSAtari911 } 55671d05cddcSAtari911 55681d05cddcSAtari911 // Clear cache 55699ccd446eSAtari911 $this->clearStatsCache(); 55701d05cddcSAtari911 55711d05cddcSAtari911 $message = "Import complete! Imported $importedCount new events"; 55721d05cddcSAtari911 if ($mergedCount > 0) { 55731d05cddcSAtari911 $message .= ", skipped $mergedCount duplicates"; 55741d05cddcSAtari911 } 55751d05cddcSAtari911 55761d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success'; 55771d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 55781d05cddcSAtari911 exit; 55791d05cddcSAtari911 } 55801d05cddcSAtari911 55811d05cddcSAtari911 private function previewCleanup() { 55821d05cddcSAtari911 global $INPUT; 55831d05cddcSAtari911 55841d05cddcSAtari911 $cleanupType = $INPUT->str('cleanup_type', 'age'); 55851d05cddcSAtari911 $namespaceFilter = $INPUT->str('namespace_filter', ''); 55861d05cddcSAtari911 55871d05cddcSAtari911 // Debug info 55881d05cddcSAtari911 $debug = []; 55891d05cddcSAtari911 $debug['cleanup_type'] = $cleanupType; 55901d05cddcSAtari911 $debug['namespace_filter'] = $namespaceFilter; 55911d05cddcSAtari911 $debug['age_value'] = $INPUT->int('age_value', 6); 55921d05cddcSAtari911 $debug['age_unit'] = $INPUT->str('age_unit', 'months'); 55931d05cddcSAtari911 $debug['range_start'] = $INPUT->str('range_start', ''); 55941d05cddcSAtari911 $debug['range_end'] = $INPUT->str('range_end', ''); 55951d05cddcSAtari911 $debug['delete_completed'] = $INPUT->bool('delete_completed', false); 55961d05cddcSAtari911 $debug['delete_past'] = $INPUT->bool('delete_past', false); 55971d05cddcSAtari911 55981d05cddcSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 55991d05cddcSAtari911 $debug['data_dir'] = $dataDir; 56001d05cddcSAtari911 $debug['data_dir_exists'] = is_dir($dataDir); 56011d05cddcSAtari911 56021d05cddcSAtari911 $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); 56031d05cddcSAtari911 56041d05cddcSAtari911 // Merge with scan debug info 56051d05cddcSAtari911 if (isset($this->_cleanupDebug)) { 56061d05cddcSAtari911 $debug = array_merge($debug, $this->_cleanupDebug); 56071d05cddcSAtari911 } 56081d05cddcSAtari911 56091d05cddcSAtari911 // Return JSON for preview with debug info 56101d05cddcSAtari911 header('Content-Type: application/json'); 56111d05cddcSAtari911 echo json_encode([ 56121d05cddcSAtari911 'count' => count($eventsToDelete), 56131d05cddcSAtari911 'events' => array_slice($eventsToDelete, 0, 50), // Limit to 50 for preview 56141d05cddcSAtari911 'debug' => $debug 56151d05cddcSAtari911 ]); 56161d05cddcSAtari911 exit; 56171d05cddcSAtari911 } 56181d05cddcSAtari911 56191d05cddcSAtari911 private function cleanupEvents() { 56201d05cddcSAtari911 global $INPUT; 56211d05cddcSAtari911 56221d05cddcSAtari911 $cleanupType = $INPUT->str('cleanup_type', 'age'); 56231d05cddcSAtari911 $namespaceFilter = $INPUT->str('namespace_filter', ''); 56241d05cddcSAtari911 56251d05cddcSAtari911 // Create backup first 56261d05cddcSAtari911 $backupDir = DOKU_PLUGIN . 'calendar/backups/'; 56271d05cddcSAtari911 if (!is_dir($backupDir)) { 56281d05cddcSAtari911 mkdir($backupDir, 0755, true); 56291d05cddcSAtari911 } 56301d05cddcSAtari911 56311d05cddcSAtari911 $backupFile = $backupDir . 'before-cleanup-' . date('Y-m-d-His') . '.zip'; 56321d05cddcSAtari911 $this->createBackup($backupFile); 56331d05cddcSAtari911 56341d05cddcSAtari911 // Find events to delete 56351d05cddcSAtari911 $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); 56361d05cddcSAtari911 $deletedCount = 0; 56371d05cddcSAtari911 56381d05cddcSAtari911 // Group by file 56391d05cddcSAtari911 $fileGroups = []; 56401d05cddcSAtari911 foreach ($eventsToDelete as $evt) { 56411d05cddcSAtari911 $fileGroups[$evt['file']][] = $evt; 56421d05cddcSAtari911 } 56431d05cddcSAtari911 56441d05cddcSAtari911 // Delete from each file 56451d05cddcSAtari911 foreach ($fileGroups as $file => $events) { 56461d05cddcSAtari911 if (!file_exists($file)) continue; 56471d05cddcSAtari911 56481d05cddcSAtari911 $json = file_get_contents($file); 56491d05cddcSAtari911 $data = json_decode($json, true); 56501d05cddcSAtari911 56511d05cddcSAtari911 if (!$data) continue; 56521d05cddcSAtari911 56531d05cddcSAtari911 // Remove events 56541d05cddcSAtari911 foreach ($events as $evt) { 56551d05cddcSAtari911 if (isset($data[$evt['date']])) { 56561d05cddcSAtari911 $data[$evt['date']] = array_filter($data[$evt['date']], function($e) use ($evt) { 56571d05cddcSAtari911 return $e['id'] !== $evt['id']; 56581d05cddcSAtari911 }); 56591d05cddcSAtari911 56601d05cddcSAtari911 // Remove date key if empty 56611d05cddcSAtari911 if (empty($data[$evt['date']])) { 56621d05cddcSAtari911 unset($data[$evt['date']]); 56631d05cddcSAtari911 } 56641d05cddcSAtari911 56651d05cddcSAtari911 $deletedCount++; 56661d05cddcSAtari911 } 56671d05cddcSAtari911 } 56681d05cddcSAtari911 56691d05cddcSAtari911 // Save file or delete if empty 56701d05cddcSAtari911 if (empty($data)) { 56711d05cddcSAtari911 unlink($file); 56721d05cddcSAtari911 } else { 56731d05cddcSAtari911 file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); 56741d05cddcSAtari911 } 56751d05cddcSAtari911 } 56761d05cddcSAtari911 56771d05cddcSAtari911 // Clear cache 56789ccd446eSAtari911 $this->clearStatsCache(); 56791d05cddcSAtari911 56801d05cddcSAtari911 $message = "Cleanup complete! Deleted $deletedCount event(s). Backup created: " . basename($backupFile); 56811d05cddcSAtari911 $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success'; 56821d05cddcSAtari911 header('Location: ' . $redirectUrl, true, 303); 56831d05cddcSAtari911 exit; 56841d05cddcSAtari911 } 56851d05cddcSAtari911 56861d05cddcSAtari911 private function findEventsToCleanup($cleanupType, $namespaceFilter) { 56871d05cddcSAtari911 global $INPUT; 56881d05cddcSAtari911 56891d05cddcSAtari911 $eventsToDelete = []; 56901d05cddcSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 56911d05cddcSAtari911 56921d05cddcSAtari911 $debug = []; 56931d05cddcSAtari911 $debug['scanned_dirs'] = []; 56941d05cddcSAtari911 $debug['found_files'] = []; 56951d05cddcSAtari911 56961d05cddcSAtari911 // Calculate cutoff date for age-based cleanup 56971d05cddcSAtari911 $cutoffDate = null; 56981d05cddcSAtari911 if ($cleanupType === 'age') { 56991d05cddcSAtari911 $ageValue = $INPUT->int('age_value', 6); 57001d05cddcSAtari911 $ageUnit = $INPUT->str('age_unit', 'months'); 57011d05cddcSAtari911 57021d05cddcSAtari911 if ($ageUnit === 'years') { 57031d05cddcSAtari911 $ageValue *= 12; // Convert to months 57041d05cddcSAtari911 } 57051d05cddcSAtari911 57061d05cddcSAtari911 $cutoffDate = date('Y-m-d', strtotime("-$ageValue months")); 57071d05cddcSAtari911 $debug['cutoff_date'] = $cutoffDate; 57081d05cddcSAtari911 } 57091d05cddcSAtari911 57101d05cddcSAtari911 // Get date range for range-based cleanup 57111d05cddcSAtari911 $rangeStart = $cleanupType === 'range' ? $INPUT->str('range_start', '') : null; 57121d05cddcSAtari911 $rangeEnd = $cleanupType === 'range' ? $INPUT->str('range_end', '') : null; 57131d05cddcSAtari911 57141d05cddcSAtari911 // Get status filters 57151d05cddcSAtari911 $deleteCompleted = $cleanupType === 'status' && $INPUT->bool('delete_completed', false); 57161d05cddcSAtari911 $deletePast = $cleanupType === 'status' && $INPUT->bool('delete_past', false); 57171d05cddcSAtari911 57181d05cddcSAtari911 // Check root calendar directory first (blank/default namespace) 57191d05cddcSAtari911 $rootCalendarDir = $dataDir . 'calendar'; 57201d05cddcSAtari911 $debug['root_calendar_dir'] = $rootCalendarDir; 57211d05cddcSAtari911 $debug['root_exists'] = is_dir($rootCalendarDir); 57221d05cddcSAtari911 57231d05cddcSAtari911 if (is_dir($rootCalendarDir)) { 57241d05cddcSAtari911 if (!$namespaceFilter || $namespaceFilter === '' || $namespaceFilter === 'default') { 57251d05cddcSAtari911 $debug['scanned_dirs'][] = $rootCalendarDir; 57261d05cddcSAtari911 $files = glob($rootCalendarDir . '/*.json'); 57271d05cddcSAtari911 $debug['found_files'] = array_merge($debug['found_files'], $files); 57281d05cddcSAtari911 $this->processCalendarFiles($rootCalendarDir, '', $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast); 57291d05cddcSAtari911 } 57301d05cddcSAtari911 } 57311d05cddcSAtari911 57321d05cddcSAtari911 // Scan all namespace directories 57331d05cddcSAtari911 $namespaceDirs = glob($dataDir . '*', GLOB_ONLYDIR); 57341d05cddcSAtari911 $debug['namespace_dirs_found'] = $namespaceDirs; 57351d05cddcSAtari911 57361d05cddcSAtari911 foreach ($namespaceDirs as $nsDir) { 57371d05cddcSAtari911 $namespace = basename($nsDir); 57381d05cddcSAtari911 57391d05cddcSAtari911 // Skip the root 'calendar' dir (already processed above) 57401d05cddcSAtari911 if ($namespace === 'calendar') continue; 57411d05cddcSAtari911 57421d05cddcSAtari911 // Check namespace filter 57431d05cddcSAtari911 if ($namespaceFilter && strpos($namespace, $namespaceFilter) === false) { 57441d05cddcSAtari911 continue; 57451d05cddcSAtari911 } 57461d05cddcSAtari911 57471d05cddcSAtari911 $calendarDir = $nsDir . '/calendar'; 57481d05cddcSAtari911 $debug['checked_calendar_dirs'][] = $calendarDir; 57491d05cddcSAtari911 57501d05cddcSAtari911 if (!is_dir($calendarDir)) { 57511d05cddcSAtari911 $debug['missing_calendar_dirs'][] = $calendarDir; 57521d05cddcSAtari911 continue; 57531d05cddcSAtari911 } 57541d05cddcSAtari911 57551d05cddcSAtari911 $debug['scanned_dirs'][] = $calendarDir; 57561d05cddcSAtari911 $files = glob($calendarDir . '/*.json'); 57571d05cddcSAtari911 $debug['found_files'] = array_merge($debug['found_files'], $files); 57581d05cddcSAtari911 $this->processCalendarFiles($calendarDir, $namespace, $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast); 57591d05cddcSAtari911 } 57601d05cddcSAtari911 57611d05cddcSAtari911 // Store debug info globally for preview 57621d05cddcSAtari911 $this->_cleanupDebug = $debug; 57631d05cddcSAtari911 57641d05cddcSAtari911 return $eventsToDelete; 57651d05cddcSAtari911 } 57661d05cddcSAtari911 57671d05cddcSAtari911 private function processCalendarFiles($calendarDir, $namespace, &$eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast) { 57681d05cddcSAtari911 foreach (glob($calendarDir . '/*.json') as $file) { 57691d05cddcSAtari911 $json = file_get_contents($file); 57701d05cddcSAtari911 $data = json_decode($json, true); 57711d05cddcSAtari911 57721d05cddcSAtari911 if (!$data) continue; 57731d05cddcSAtari911 57741d05cddcSAtari911 foreach ($data as $date => $dateEvents) { 57751d05cddcSAtari911 foreach ($dateEvents as $event) { 57761d05cddcSAtari911 $shouldDelete = false; 57771d05cddcSAtari911 57781d05cddcSAtari911 // Age-based 57791d05cddcSAtari911 if ($cleanupType === 'age' && $cutoffDate && $date < $cutoffDate) { 57801d05cddcSAtari911 $shouldDelete = true; 57811d05cddcSAtari911 } 57821d05cddcSAtari911 57831d05cddcSAtari911 // Range-based 57841d05cddcSAtari911 if ($cleanupType === 'range' && $rangeStart && $rangeEnd) { 57851d05cddcSAtari911 if ($date >= $rangeStart && $date <= $rangeEnd) { 57861d05cddcSAtari911 $shouldDelete = true; 57871d05cddcSAtari911 } 57881d05cddcSAtari911 } 57891d05cddcSAtari911 57901d05cddcSAtari911 // Status-based 57911d05cddcSAtari911 if ($cleanupType === 'status') { 57921d05cddcSAtari911 $isTask = isset($event['isTask']) && $event['isTask']; 57931d05cddcSAtari911 $isCompleted = isset($event['completed']) && $event['completed']; 57941d05cddcSAtari911 $isPast = $date < date('Y-m-d'); 57951d05cddcSAtari911 57961d05cddcSAtari911 if ($deleteCompleted && $isTask && $isCompleted) { 57971d05cddcSAtari911 $shouldDelete = true; 57981d05cddcSAtari911 } 57991d05cddcSAtari911 if ($deletePast && !$isTask && $isPast) { 58001d05cddcSAtari911 $shouldDelete = true; 58011d05cddcSAtari911 } 58021d05cddcSAtari911 } 58031d05cddcSAtari911 58041d05cddcSAtari911 if ($shouldDelete) { 58051d05cddcSAtari911 $eventsToDelete[] = [ 58061d05cddcSAtari911 'id' => $event['id'], 58071d05cddcSAtari911 'title' => $event['title'], 58081d05cddcSAtari911 'date' => $date, 58091d05cddcSAtari911 'namespace' => $namespace ?: 'default', 58101d05cddcSAtari911 'file' => $file 58111d05cddcSAtari911 ]; 58121d05cddcSAtari911 } 58131d05cddcSAtari911 } 58141d05cddcSAtari911 } 58151d05cddcSAtari911 } 58161d05cddcSAtari911 } 58179ccd446eSAtari911 58189ccd446eSAtari911 /** 58199ccd446eSAtari911 * Render Themes tab for sidebar widget theme selection 58209ccd446eSAtari911 */ 58219ccd446eSAtari911 private function renderThemesTab($colors = null) { 58229ccd446eSAtari911 global $INPUT; 58239ccd446eSAtari911 58249ccd446eSAtari911 // Use defaults if not provided 58259ccd446eSAtari911 if ($colors === null) { 58269ccd446eSAtari911 $colors = $this->getTemplateColors(); 58279ccd446eSAtari911 } 58289ccd446eSAtari911 58299ccd446eSAtari911 // Handle theme save 58309ccd446eSAtari911 if ($INPUT->str('action') === 'save_theme') { 58319ccd446eSAtari911 $theme = $INPUT->str('theme', 'matrix'); 58329ccd446eSAtari911 $weekStart = $INPUT->str('week_start', 'monday'); 58339ccd446eSAtari911 $this->saveSidebarTheme($theme); 58349ccd446eSAtari911 $this->saveWeekStartDay($weekStart); 58359ccd446eSAtari911 echo '<div style="background:#d4edda; border:1px solid #c3e6cb; color:#155724; padding:12px; border-radius:4px; margin-bottom:20px;">'; 58369ccd446eSAtari911 echo '✓ Theme and settings saved successfully! Refresh any page with the sidebar to see changes.'; 58379ccd446eSAtari911 echo '</div>'; 58389ccd446eSAtari911 } 58399ccd446eSAtari911 58409ccd446eSAtari911 $currentTheme = $this->getSidebarTheme(); 58419ccd446eSAtari911 $currentWeekStart = $this->getWeekStartDay(); 58429ccd446eSAtari911 58439ccd446eSAtari911 echo '<h2 style="margin:0 0 20px 0; color:' . $colors['text'] . ';"> Sidebar Widget Settings</h2>'; 58449ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; margin-bottom:20px;">Customize the appearance and behavior of the sidebar calendar widget.</p>'; 58459ccd446eSAtari911 58469ccd446eSAtari911 echo '<form method="post" action="?do=admin&page=calendar&tab=themes">'; 58479ccd446eSAtari911 echo '<input type="hidden" name="action" value="save_theme">'; 58489ccd446eSAtari911 58499ccd446eSAtari911 // Week Start Day Section 58509ccd446eSAtari911 echo '<div style="background:' . $colors['bg'] . '; border:1px solid ' . $colors['border'] . '; border-radius:6px; padding:20px; margin-bottom:30px;">'; 58519ccd446eSAtari911 echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;"> Week Start Day</h3>'; 58529ccd446eSAtari911 echo '<p style="color:' . $colors['text'] . '; margin-bottom:15px; font-size:13px;">Choose which day the week calendar grid starts with:</p>'; 58539ccd446eSAtari911 58549ccd446eSAtari911 echo '<div style="display:flex; gap:15px;">'; 58559ccd446eSAtari911 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;">'; 58569ccd446eSAtari911 echo '<input type="radio" name="week_start" value="monday" ' . ($currentWeekStart === 'monday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 58579ccd446eSAtari911 echo '<div>'; 58589ccd446eSAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Monday</div>'; 58599ccd446eSAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Monday (ISO standard)</div>'; 58609ccd446eSAtari911 echo '</div>'; 58619ccd446eSAtari911 echo '</label>'; 58629ccd446eSAtari911 58639ccd446eSAtari911 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;">'; 58649ccd446eSAtari911 echo '<input type="radio" name="week_start" value="sunday" ' . ($currentWeekStart === 'sunday' ? 'checked' : '') . ' style="margin-right:10px; width:18px; height:18px;">'; 58659ccd446eSAtari911 echo '<div>'; 58669ccd446eSAtari911 echo '<div style="font-weight:bold; color:' . $colors['text'] . '; margin-bottom:3px;">Sunday</div>'; 58679ccd446eSAtari911 echo '<div style="font-size:11px; color:' . $colors['text'] . ';">Week starts on Sunday (US/Canada standard)</div>'; 58689ccd446eSAtari911 echo '</div>'; 58699ccd446eSAtari911 echo '</label>'; 58709ccd446eSAtari911 echo '</div>'; 58719ccd446eSAtari911 echo '</div>'; 58729ccd446eSAtari911 58739ccd446eSAtari911 // Visual Theme Section 58749ccd446eSAtari911 echo '<h3 style="margin:0 0 15px 0; color:' . $colors['text'] . '; font-size:16px;"> Visual Theme</h3>'; 58759ccd446eSAtari911 58769ccd446eSAtari911 // Matrix Theme 58779ccd446eSAtari911 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']) . ';">'; 58789ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 58799ccd446eSAtari911 echo '<input type="radio" name="theme" value="matrix" ' . ($currentTheme === 'matrix' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 58809ccd446eSAtari911 echo '<div style="flex:1;">'; 58819ccd446eSAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#00cc07; margin-bottom:8px;"> Matrix Edition</div>'; 58829ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Dark green theme with Matrix-style glow effects and neon accents</div>'; 58839ccd446eSAtari911 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>'; 58849ccd446eSAtari911 echo '</div>'; 58859ccd446eSAtari911 echo '</label>'; 58869ccd446eSAtari911 echo '</div>'; 58879ccd446eSAtari911 58889ccd446eSAtari911 // Purple Theme 58899ccd446eSAtari911 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']) . ';">'; 58909ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 58919ccd446eSAtari911 echo '<input type="radio" name="theme" value="purple" ' . ($currentTheme === 'purple' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 58929ccd446eSAtari911 echo '<div style="flex:1;">'; 58939ccd446eSAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#9b59b6; margin-bottom:8px;"> Purple Dream</div>'; 58949ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Rich purple theme with elegant violet accents and soft glow</div>'; 58959ccd446eSAtari911 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>'; 58969ccd446eSAtari911 echo '</div>'; 58979ccd446eSAtari911 echo '</label>'; 58989ccd446eSAtari911 echo '</div>'; 58999ccd446eSAtari911 59009ccd446eSAtari911 // Professional Blue Theme 59019ccd446eSAtari911 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']) . ';">'; 59029ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 59039ccd446eSAtari911 echo '<input type="radio" name="theme" value="professional" ' . ($currentTheme === 'professional' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 59049ccd446eSAtari911 echo '<div style="flex:1;">'; 59059ccd446eSAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#4a90e2; margin-bottom:8px;"> Professional Blue</div>'; 59069ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Clean blue and grey theme with modern professional styling, no glow effects</div>'; 59079ccd446eSAtari911 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>'; 59089ccd446eSAtari911 echo '</div>'; 59099ccd446eSAtari911 echo '</label>'; 59109ccd446eSAtari911 echo '</div>'; 59119ccd446eSAtari911 59129ccd446eSAtari911 // Pink Bling Theme 59139ccd446eSAtari911 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']) . ';">'; 59149ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 59159ccd446eSAtari911 echo '<input type="radio" name="theme" value="pink" ' . ($currentTheme === 'pink' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 59169ccd446eSAtari911 echo '<div style="flex:1;">'; 59179ccd446eSAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#ff1493; margin-bottom:8px;"> Pink Bling</div>'; 59189ccd446eSAtari911 echo '<div style="color:' . $colors['text'] . '; margin-bottom:12px;">Glamorous hot pink theme with maximum sparkle, hearts, and diamonds ✨</div>'; 59199ccd446eSAtari911 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>'; 59209ccd446eSAtari911 echo '</div>'; 59219ccd446eSAtari911 echo '</label>'; 59229ccd446eSAtari911 echo '</div>'; 59239ccd446eSAtari911 59249ccd446eSAtari911 // Wiki Default Theme 59259ccd446eSAtari911 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']) . ';">'; 59269ccd446eSAtari911 echo '<label style="display:flex; align-items:center; cursor:pointer;">'; 59279ccd446eSAtari911 echo '<input type="radio" name="theme" value="wiki" ' . ($currentTheme === 'wiki' ? 'checked' : '') . ' style="margin-right:12px; width:20px; height:20px;">'; 59289ccd446eSAtari911 echo '<div style="flex:1;">'; 59299ccd446eSAtari911 echo '<div style="font-size:18px; font-weight:bold; color:#2b73b7; margin-bottom:8px;"> Wiki Default</div>'; 59309ccd446eSAtari911 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>'; 59319ccd446eSAtari911 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>'; 59329ccd446eSAtari911 echo '</div>'; 59339ccd446eSAtari911 echo '</label>'; 59349ccd446eSAtari911 echo '</div>'; 59359ccd446eSAtari911 59369ccd446eSAtari911 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>'; 59379ccd446eSAtari911 echo '</form>'; 59389ccd446eSAtari911 } 59399ccd446eSAtari911 59409ccd446eSAtari911 /** 59419ccd446eSAtari911 * Get current sidebar theme 59429ccd446eSAtari911 */ 59439ccd446eSAtari911 private function getSidebarTheme() { 59449ccd446eSAtari911 $configFile = DOKU_INC . 'data/meta/calendar_theme.txt'; 59459ccd446eSAtari911 if (file_exists($configFile)) { 59469ccd446eSAtari911 return trim(file_get_contents($configFile)); 59479ccd446eSAtari911 } 59489ccd446eSAtari911 return 'matrix'; // Default 59499ccd446eSAtari911 } 59509ccd446eSAtari911 59519ccd446eSAtari911 /** 59529ccd446eSAtari911 * Save sidebar theme 59539ccd446eSAtari911 */ 59549ccd446eSAtari911 private function saveSidebarTheme($theme) { 59559ccd446eSAtari911 $configFile = DOKU_INC . 'data/meta/calendar_theme.txt'; 59569ccd446eSAtari911 $validThemes = ['matrix', 'purple', 'professional', 'pink', 'wiki']; 59579ccd446eSAtari911 59589ccd446eSAtari911 if (in_array($theme, $validThemes)) { 59599ccd446eSAtari911 file_put_contents($configFile, $theme); 59609ccd446eSAtari911 return true; 59619ccd446eSAtari911 } 59629ccd446eSAtari911 return false; 59639ccd446eSAtari911 } 59649ccd446eSAtari911 59659ccd446eSAtari911 /** 59669ccd446eSAtari911 * Get week start day 59679ccd446eSAtari911 */ 59689ccd446eSAtari911 private function getWeekStartDay() { 59699ccd446eSAtari911 $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt'; 59709ccd446eSAtari911 if (file_exists($configFile)) { 59719ccd446eSAtari911 $start = trim(file_get_contents($configFile)); 59729ccd446eSAtari911 if (in_array($start, ['monday', 'sunday'])) { 59739ccd446eSAtari911 return $start; 59749ccd446eSAtari911 } 59759ccd446eSAtari911 } 59769ccd446eSAtari911 return 'sunday'; // Default to Sunday (US/Canada standard) 59779ccd446eSAtari911 } 59789ccd446eSAtari911 59799ccd446eSAtari911 /** 59809ccd446eSAtari911 * Save week start day 59819ccd446eSAtari911 */ 59829ccd446eSAtari911 private function saveWeekStartDay($weekStart) { 59839ccd446eSAtari911 $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt'; 59849ccd446eSAtari911 $validStarts = ['monday', 'sunday']; 59859ccd446eSAtari911 59869ccd446eSAtari911 if (in_array($weekStart, $validStarts)) { 59879ccd446eSAtari911 file_put_contents($configFile, $weekStart); 59889ccd446eSAtari911 return true; 59899ccd446eSAtari911 } 59909ccd446eSAtari911 return false; 59919ccd446eSAtari911 } 59929ccd446eSAtari911 59939ccd446eSAtari911 /** 59949ccd446eSAtari911 * Get colors from DokuWiki template's style.ini file 59959ccd446eSAtari911 */ 59969ccd446eSAtari911 private function getTemplateColors() { 59979ccd446eSAtari911 global $conf; 59989ccd446eSAtari911 59999ccd446eSAtari911 // Get current template name 60009ccd446eSAtari911 $template = $conf['template']; 60019ccd446eSAtari911 60029ccd446eSAtari911 // Try multiple possible locations for style.ini 60039ccd446eSAtari911 $possiblePaths = [ 60049ccd446eSAtari911 DOKU_INC . 'conf/tpl/' . $template . '/style.ini', 60059ccd446eSAtari911 DOKU_INC . 'lib/tpl/' . $template . '/style.ini', 60069ccd446eSAtari911 ]; 60079ccd446eSAtari911 60089ccd446eSAtari911 $styleIni = null; 60099ccd446eSAtari911 foreach ($possiblePaths as $path) { 60109ccd446eSAtari911 if (file_exists($path)) { 60119ccd446eSAtari911 $styleIni = parse_ini_file($path, true); 60129ccd446eSAtari911 break; 60139ccd446eSAtari911 } 60149ccd446eSAtari911 } 60159ccd446eSAtari911 60169ccd446eSAtari911 if (!$styleIni || !isset($styleIni['replacements'])) { 60179ccd446eSAtari911 // Return defaults 60189ccd446eSAtari911 return [ 60199ccd446eSAtari911 'bg' => '#fff', 60209ccd446eSAtari911 'bg_alt' => '#e8e8e8', 60219ccd446eSAtari911 'text' => '#333', 60229ccd446eSAtari911 'border' => '#ccc', 60239ccd446eSAtari911 'link' => '#2b73b7', 60249ccd446eSAtari911 ]; 60259ccd446eSAtari911 } 60269ccd446eSAtari911 60279ccd446eSAtari911 $r = $styleIni['replacements']; 60289ccd446eSAtari911 60299ccd446eSAtari911 return [ 60309ccd446eSAtari911 'bg' => isset($r['__background__']) ? $r['__background__'] : '#fff', 60319ccd446eSAtari911 'bg_alt' => isset($r['__background_alt__']) ? $r['__background_alt__'] : '#e8e8e8', 60329ccd446eSAtari911 'text' => isset($r['__text__']) ? $r['__text__'] : '#333', 60339ccd446eSAtari911 'border' => isset($r['__border__']) ? $r['__border__'] : '#ccc', 60349ccd446eSAtari911 'link' => isset($r['__link__']) ? $r['__link__'] : '#2b73b7', 60359ccd446eSAtari911 ]; 60369ccd446eSAtari911 } 60371d05cddcSAtari911} 6038