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