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