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