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