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