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