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