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