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