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