1<?php 2/** 3 * DokuWiki Plugin calendar (Syntax Component) 4 * Compact design with integrated event list 5 * 6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 7 * @author DokuWiki Community 8 */ 9 10if (!defined('DOKU_INC')) die(); 11 12class syntax_plugin_calendar extends DokuWiki_Syntax_Plugin { 13 14 /** 15 * Get the meta directory path (farm-safe) 16 * Uses $conf['metadir'] instead of hardcoded DOKU_INC . 'data/meta/' 17 */ 18 private function metaDir() { 19 global $conf; 20 return rtrim($conf['metadir'], '/') . '/'; 21 } 22 23 /** 24 * Check if the current user has read access to a namespace 25 * 26 * @param string $namespace Namespace to check (empty = root) 27 * @return bool True if user has at least AUTH_READ 28 */ 29 private function checkNamespaceRead($namespace) { 30 if (empty($namespace) || $namespace === '*') return true; 31 $ns = str_replace(['*', ';'], '', $namespace); 32 if (empty($ns)) return true; 33 $perm = auth_quickaclcheck($ns . ':*'); 34 return ($perm >= AUTH_READ); 35 } 36 37 /** 38 * Get sync config file path (farm-safe) 39 * Checks per-animal metadir first, falls back to shared plugin dir 40 */ 41 private function syncConfigPath() { 42 $perAnimal = $this->metaDir() . 'calendar/sync_config.php'; 43 if (file_exists($perAnimal)) return $perAnimal; 44 return DOKU_PLUGIN . 'calendar/sync_config.php'; 45 } 46 47 public function getType() { 48 return 'substition'; 49 } 50 51 public function getPType() { 52 return 'block'; 53 } 54 55 public function getSort() { 56 return 155; 57 } 58 59 public function connectTo($mode) { 60 $this->Lexer->addSpecialPattern('\{\{calendar(?:[^\}]*)\}\}', $mode, 'plugin_calendar'); 61 $this->Lexer->addSpecialPattern('\{\{eventlist(?:[^\}]*)\}\}', $mode, 'plugin_calendar'); 62 $this->Lexer->addSpecialPattern('\{\{eventpanel(?:[^\}]*)\}\}', $mode, 'plugin_calendar'); 63 } 64 65 public function handle($match, $state, $pos, Doku_Handler $handler) { 66 $isEventList = (strpos($match, '{{eventlist') === 0); 67 $isEventPanel = (strpos($match, '{{eventpanel') === 0); 68 69 if ($isEventList) { 70 $match = substr($match, 12, -2); 71 } elseif ($isEventPanel) { 72 $match = substr($match, 13, -2); 73 } else { 74 $match = substr($match, 10, -2); 75 } 76 77 $params = array( 78 'type' => $isEventPanel ? 'eventpanel' : ($isEventList ? 'eventlist' : 'calendar'), 79 'year' => date('Y'), 80 'month' => date('n'), 81 'namespace' => '', 82 'daterange' => '', 83 'date' => '', 84 'range' => '', 85 'static' => false, 86 'title' => '', 87 'noprint' => false, 88 'theme' => '', 89 'locked' => false // Will be set true if month/year specified 90 ); 91 92 // Track if user explicitly set month or year 93 $userSetMonth = false; 94 $userSetYear = false; 95 96 if (trim($match)) { 97 // Parse parameters, handling quoted strings properly 98 // Match: key="value with spaces" OR key=value OR standalone_flag 99 preg_match_all('/(\w+)=["\']([^"\']+)["\']|(\w+)=(\S+)|(\w+)/', trim($match), $matches, PREG_SET_ORDER); 100 101 foreach ($matches as $m) { 102 if (!empty($m[1]) && isset($m[2])) { 103 // key="quoted value" 104 $key = $m[1]; 105 $value = $m[2]; 106 $params[$key] = $value; 107 if ($key === 'month') $userSetMonth = true; 108 if ($key === 'year') $userSetYear = true; 109 } elseif (!empty($m[3]) && isset($m[4])) { 110 // key=unquoted_value 111 $key = $m[3]; 112 $value = $m[4]; 113 $params[$key] = $value; 114 if ($key === 'month') $userSetMonth = true; 115 if ($key === 'year') $userSetYear = true; 116 } elseif (!empty($m[5])) { 117 // standalone flag 118 $params[$m[5]] = true; 119 } 120 } 121 } 122 123 // If user explicitly set month or year, lock navigation 124 if ($userSetMonth || $userSetYear) { 125 $params['locked'] = true; 126 } 127 128 return $params; 129 } 130 131 public function render($mode, Doku_Renderer $renderer, $data) { 132 if ($mode !== 'xhtml') return false; 133 134 // Disable caching - theme can change via admin without page edit 135 $renderer->nocache(); 136 137 if ($data['type'] === 'eventlist') { 138 $html = $this->renderStandaloneEventList($data); 139 } elseif ($data['type'] === 'eventpanel') { 140 $html = $this->renderEventPanelOnly($data); 141 } elseif ($data['static']) { 142 $html = $this->renderStaticCalendar($data); 143 } else { 144 $html = $this->renderCompactCalendar($data); 145 } 146 147 $renderer->doc .= $html; 148 return true; 149 } 150 151 private function renderCompactCalendar($data) { 152 $year = (int)$data['year']; 153 $month = (int)$data['month']; 154 $namespace = $data['namespace']; 155 $exclude = isset($data['exclude']) ? $data['exclude'] : ''; 156 $excludeList = $this->parseExcludeList($exclude); 157 158 // Get theme - prefer inline theme= parameter, fall back to admin default 159 $theme = !empty($data['theme']) ? $data['theme'] : $this->getSidebarTheme(); 160 $themeStyles = $this->getSidebarThemeStyles($theme); 161 $themeClass = 'calendar-theme-' . $theme; 162 163 // Determine button text color: professional uses white, others use bg color 164 $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg']; 165 166 // Check if multiple namespaces or wildcard specified 167 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 168 169 if ($isMultiNamespace) { 170 $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList); 171 } else { 172 $events = $this->loadEvents($namespace, $year, $month); 173 } 174 $calId = 'cal_' . md5(serialize($data) . microtime()); 175 176 $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year)); 177 178 $prevMonth = $month - 1; 179 $prevYear = $year; 180 if ($prevMonth < 1) { 181 $prevMonth = 12; 182 $prevYear--; 183 } 184 185 $nextMonth = $month + 1; 186 $nextYear = $year; 187 if ($nextMonth > 12) { 188 $nextMonth = 1; 189 $nextYear++; 190 } 191 192 // Get important namespaces from config for highlighting 193 $configFile = $this->syncConfigPath(); 194 $importantNsList = ['important']; // default 195 if (file_exists($configFile)) { 196 $config = include $configFile; 197 if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) { 198 $importantNsList = array_map('trim', explode(',', $config['important_namespaces'])); 199 } 200 } 201 202 // Container - all styling via CSS variables 203 $html = '<div class="calendar-compact-container ' . $themeClass . '" id="' . $calId . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '" data-exclude="' . htmlspecialchars($exclude) . '" data-year="' . $year . '" data-month="' . $month . '" data-theme="' . $theme . '" data-theme-styles="' . htmlspecialchars(json_encode($themeStyles)) . '" data-important-namespaces="' . htmlspecialchars(json_encode($importantNsList)) . '">'; 204 205 // Inject CSS variables for this calendar instance - all theming flows from here 206 $html .= '<style> 207 #' . $calId . ' { 208 --background-site: ' . $themeStyles['bg'] . '; 209 --background-alt: ' . $themeStyles['cell_bg'] . '; 210 --background-header: ' . $themeStyles['header_bg'] . '; 211 --text-primary: ' . $themeStyles['text_primary'] . '; 212 --text-dim: ' . $themeStyles['text_dim'] . '; 213 --text-bright: ' . $themeStyles['text_bright'] . '; 214 --border-color: ' . $themeStyles['grid_border'] . '; 215 --border-main: ' . $themeStyles['border'] . '; 216 --cell-bg: ' . $themeStyles['cell_bg'] . '; 217 --cell-today-bg: ' . $themeStyles['cell_today_bg'] . '; 218 --shadow-color: ' . $themeStyles['shadow'] . '; 219 --header-border: ' . $themeStyles['header_border'] . '; 220 --header-shadow: ' . $themeStyles['header_shadow'] . '; 221 --grid-bg: ' . $themeStyles['grid_bg'] . '; 222 --btn-text: ' . $btnTextColor . '; 223 --pastdue-color: ' . $themeStyles['pastdue_color'] . '; 224 --pastdue-bg: ' . $themeStyles['pastdue_bg'] . '; 225 --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . '; 226 --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . '; 227 --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . '; 228 --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . '; 229 --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . '; 230 } 231 #event-search-' . $calId . '::placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; } 232 #event-search-' . $calId . '::-webkit-input-placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; } 233 #event-search-' . $calId . '::-moz-placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; } 234 #event-search-' . $calId . ':-ms-input-placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; } 235 </style>'; 236 237 // Load calendar JavaScript manually (not through DokuWiki concatenation) 238 $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>'; 239 240 // Initialize DOKU_BASE for JavaScript 241 $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>'; 242 243 // Embed events data as JSON for JavaScript access 244 $html .= '<script type="application/json" id="events-data-' . $calId . '">' . json_encode($events) . '</script>'; 245 246 // Left side: Calendar 247 $html .= '<div class="calendar-compact-left">'; 248 249 // Header with navigation 250 $html .= '<div class="calendar-compact-header">'; 251 $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>'; 252 $html .= '<h3 class="calendar-month-picker" onclick="openMonthPicker(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . '</h3>'; 253 $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>'; 254 $html .= '<button class="cal-today-btn" onclick="jumpToToday(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>'; 255 $html .= '</div>'; 256 257 // Calendar grid - day name headers as a separate div (avoids Firefox th height issues) 258 $html .= '<div class="calendar-day-headers">'; 259 $html .= '<span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span>'; 260 $html .= '</div>'; 261 $html .= '<table class="calendar-compact-grid">'; 262 $html .= '<tbody>'; 263 264 $firstDay = mktime(0, 0, 0, $month, 1, $year); 265 $daysInMonth = date('t', $firstDay); 266 $dayOfWeek = date('w', $firstDay); 267 268 // Build a map of all events with their date ranges for the calendar grid 269 $eventRanges = array(); 270 foreach ($events as $dateKey => $dayEvents) { 271 foreach ($dayEvents as $evt) { 272 $eventId = isset($evt['id']) ? $evt['id'] : ''; 273 $startDate = $dateKey; 274 $endDate = isset($evt['endDate']) && $evt['endDate'] ? $evt['endDate'] : $dateKey; 275 276 // Only process events that touch this month 277 $eventStart = new DateTime($startDate); 278 $eventEnd = new DateTime($endDate); 279 $monthStart = new DateTime(sprintf('%04d-%02d-01', $year, $month)); 280 $monthEnd = new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth)); 281 282 // Skip if event doesn't overlap with current month 283 if ($eventEnd < $monthStart || $eventStart > $monthEnd) { 284 continue; 285 } 286 287 // Create entry for each day the event spans 288 $current = clone $eventStart; 289 while ($current <= $eventEnd) { 290 $currentKey = $current->format('Y-m-d'); 291 292 // Check if this date is in current month 293 $currentDate = DateTime::createFromFormat('Y-m-d', $currentKey); 294 if ($currentDate && $currentDate->format('Y-m') === sprintf('%04d-%02d', $year, $month)) { 295 if (!isset($eventRanges[$currentKey])) { 296 $eventRanges[$currentKey] = array(); 297 } 298 299 // Add event with span information 300 $evt['_span_start'] = $startDate; 301 $evt['_span_end'] = $endDate; 302 $evt['_is_first_day'] = ($currentKey === $startDate); 303 $evt['_is_last_day'] = ($currentKey === $endDate); 304 $evt['_original_date'] = $dateKey; // Keep track of original date 305 306 // Check if event continues from previous month or to next month 307 $evt['_continues_from_prev'] = ($eventStart < $monthStart); 308 $evt['_continues_to_next'] = ($eventEnd > $monthEnd); 309 310 $eventRanges[$currentKey][] = $evt; 311 } 312 313 $current->modify('+1 day'); 314 } 315 } 316 } 317 318 // Assign stable row slots to multi-day events (same algorithm as JS rebuildCalendar) 319 $slotAssignments = array(); // dateKey -> array of {id, slot} 320 $eventSlots = array(); // eventId -> slot number 321 322 $allDates = array_keys($eventRanges); 323 sort($allDates); 324 325 // First pass: multi-day events get stable slots across all days they span 326 foreach ($allDates as $dk) { 327 foreach ($eventRanges[$dk] as $evt) { 328 $eid = isset($evt['id']) ? $evt['id'] : $evt['title']; 329 $isMultiDay = (isset($evt['_span_start']) && isset($evt['_span_end']) && $evt['_span_start'] !== $evt['_span_end']); 330 331 if ($isMultiDay && !isset($eventSlots[$eid])) { 332 // Find lowest slot free across all days this event spans 333 $slot = 0; 334 $found = false; 335 while (!$found) { 336 $found = true; 337 $checkCurrent = new DateTime($evt['_span_start']); 338 $checkEnd = new DateTime($evt['_span_end']); 339 while ($checkCurrent <= $checkEnd) { 340 $checkKey = $checkCurrent->format('Y-m-d'); 341 if (isset($slotAssignments[$checkKey])) { 342 foreach ($slotAssignments[$checkKey] as $assigned) { 343 if ($assigned['slot'] === $slot) { 344 $found = false; 345 break; 346 } 347 } 348 } 349 if (!$found) break; 350 $checkCurrent->modify('+1 day'); 351 } 352 if (!$found) $slot++; 353 } 354 $eventSlots[$eid] = $slot; 355 356 // Reserve on all days 357 $resCurrent = new DateTime($evt['_span_start']); 358 $resEnd = new DateTime($evt['_span_end']); 359 while ($resCurrent <= $resEnd) { 360 $resKey = $resCurrent->format('Y-m-d'); 361 if (!isset($slotAssignments[$resKey])) $slotAssignments[$resKey] = array(); 362 $slotAssignments[$resKey][] = array('id' => $eid, 'slot' => $slot); 363 $resCurrent->modify('+1 day'); 364 } 365 } 366 } 367 } 368 369 // Second pass: single-day events fill remaining slots 370 foreach ($allDates as $dk) { 371 $singleDay = array(); 372 foreach ($eventRanges[$dk] as $evt) { 373 $eid = isset($evt['id']) ? $evt['id'] : $evt['title']; 374 if (!isset($eventSlots[$eid])) { 375 $singleDay[] = $evt; 376 } 377 } 378 // Sort single-day by time 379 usort($singleDay, function($a, $b) { 380 $timeA = isset($a['time']) ? $a['time'] : ''; 381 $timeB = isset($b['time']) ? $b['time'] : ''; 382 if (empty($timeA) && !empty($timeB)) return -1; 383 if (!empty($timeA) && empty($timeB)) return 1; 384 return strcmp($timeA, $timeB); 385 }); 386 foreach ($singleDay as $evt) { 387 $eid = isset($evt['id']) ? $evt['id'] : $evt['title']; 388 $slot = 0; 389 while (true) { 390 $taken = false; 391 if (isset($slotAssignments[$dk])) { 392 foreach ($slotAssignments[$dk] as $a) { 393 if ($a['slot'] === $slot) { $taken = true; break; } 394 } 395 } 396 if (!$taken) break; 397 $slot++; 398 } 399 $eventSlots[$eid] = $slot; 400 if (!isset($slotAssignments[$dk])) $slotAssignments[$dk] = array(); 401 $slotAssignments[$dk][] = array('id' => $eid, 'slot' => $slot); 402 } 403 } 404 405 $currentDay = 1; 406 $rowCount = ceil(($daysInMonth + $dayOfWeek) / 7); 407 408 for ($row = 0; $row < $rowCount; $row++) { 409 $html .= '<tr>'; 410 for ($col = 0; $col < 7; $col++) { 411 if (($row === 0 && $col < $dayOfWeek) || $currentDay > $daysInMonth) { 412 $html .= '<td class="cal-empty"></td>'; 413 } else { 414 $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $currentDay); 415 $isToday = ($dateKey === date('Y-m-d')); 416 $hasEvents = isset($eventRanges[$dateKey]) && !empty($eventRanges[$dateKey]); 417 418 $classes = 'cal-day'; 419 if ($isToday) $classes .= ' cal-today'; 420 if ($hasEvents) $classes .= ' cal-has-events'; 421 422 $html .= '<td class="' . $classes . '" data-date="' . $dateKey . '" tabindex="0" role="gridcell" aria-label="' . date('F j, Y', strtotime($dateKey)) . ($hasEvents ? ', has events' : '') . '" onclick="showDayPopup(\'' . $calId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">'; 423 424 $dayNumClass = $isToday ? 'day-num day-num-today' : 'day-num'; 425 $html .= '<span class="' . $dayNumClass . '">' . $currentDay . '</span>'; 426 427 if ($hasEvents) { 428 // Build slot map for this day 429 $daySlotList = isset($slotAssignments[$dateKey]) ? $slotAssignments[$dateKey] : array(); 430 $maxSlot = -1; 431 foreach ($daySlotList as $ds) { 432 if ($ds['slot'] > $maxSlot) $maxSlot = $ds['slot']; 433 } 434 435 $slotMap = array(); 436 foreach ($eventRanges[$dateKey] as $evt) { 437 $eid = isset($evt['id']) ? $evt['id'] : $evt['title']; 438 if (isset($eventSlots[$eid])) { 439 $slotMap[$eventSlots[$eid]] = $evt; 440 } 441 } 442 443 $html .= '<div class="event-indicators">'; 444 for ($s = 0; $s <= $maxSlot; $s++) { 445 if (!isset($slotMap[$s])) { 446 // Spacer 447 $html .= '<span style="display:block;width:100%;height:6px;min-height:6px;flex-shrink:0;"></span>'; 448 continue; 449 } 450 451 $evt = $slotMap[$s]; 452 $eventId = isset($evt['id']) ? $evt['id'] : ''; 453 $eventColor = isset($evt['color']) ? hsc($evt['color']) : '#3498db'; 454 $eventTime = isset($evt['time']) ? $evt['time'] : ''; 455 $eventTitle = isset($evt['title']) ? hsc($evt['title']) : 'Event'; 456 $originalDate = isset($evt['_original_date']) ? $evt['_original_date'] : $dateKey; 457 $isFirstDay = isset($evt['_is_first_day']) ? $evt['_is_first_day'] : true; 458 $isLastDay = isset($evt['_is_last_day']) ? $evt['_is_last_day'] : true; 459 460 $evtNs = isset($evt['namespace']) ? $evt['namespace'] : ''; 461 if (!$evtNs && isset($evt['_namespace'])) $evtNs = $evt['_namespace']; 462 $isImportantEvent = false; 463 foreach ($importantNsList as $impNs) { 464 if ($evtNs === $impNs || strpos($evtNs, $impNs . ':') === 0) { 465 $isImportantEvent = true; 466 break; 467 } 468 } 469 470 $barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed'; 471 if (!$isFirstDay) $barClass .= ' event-bar-continues'; 472 if (!$isLastDay) $barClass .= ' event-bar-continuing'; 473 if ($isImportantEvent) { 474 $barClass .= ' event-bar-important'; 475 if ($isFirstDay) $barClass .= ' event-bar-has-star'; 476 } 477 478 $titlePrefix = $isImportantEvent ? '⭐ ' : ''; 479 480 $html .= '<span class="event-bar ' . $barClass . '" '; 481 $html .= 'style="background: ' . $eventColor . ';" '; 482 $html .= 'title="' . $titlePrefix . $eventTitle . ($eventTime ? ' @ ' . $eventTime : '') . '" '; 483 $html .= 'onclick="event.stopPropagation(); highlightEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $originalDate . '\');">'; 484 $html .= '</span>'; 485 } 486 $html .= '</div>'; 487 } 488 489 $html .= '</td>'; 490 $currentDay++; 491 } 492 } 493 $html .= '</tr>'; 494 } 495 496 $html .= '</tbody></table>'; 497 $html .= '</div>'; // End calendar-left 498 499 // Right side: Event list 500 $html .= '<div class="calendar-compact-right">'; 501 $html .= '<div class="event-list-header">'; 502 $html .= '<div class="event-list-header-content">'; 503 $html .= '<h4 id="eventlist-title-' . $calId . '">Events</h4>'; 504 if ($namespace) { 505 $html .= '<span class="namespace-badge">' . htmlspecialchars($namespace) . '</span>'; 506 } 507 $html .= '</div>'; 508 509 // Search bar in header 510 $searchDefault = $this->getSearchDefault(); 511 $searchAllClass = $searchDefault === 'all' ? ' all-dates' : ''; 512 $searchIcon = $searchDefault === 'all' ? '' : ''; 513 $searchTitle = $searchDefault === 'all' ? 'Searching all dates' : 'Search this month only'; 514 $searchPlaceholder = $searchDefault === 'all' ? 'Search all dates...' : ' Search...'; 515 $html .= '<div class="event-search-container-inline">'; 516 $html .= '<input type="text" class="event-search-input-inline" id="event-search-' . $calId . '" placeholder="' . htmlspecialchars($searchPlaceholder) . '" oninput="filterEvents(\'' . $calId . '\', this.value)">'; 517 $html .= '<button class="event-search-clear-inline" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>'; 518 $html .= '<button class="event-search-mode-inline' . $searchAllClass . '" id="search-mode-' . $calId . '" onclick="toggleSearchMode(\'' . $calId . '\', \'' . $namespace . '\')" title="' . htmlspecialchars($searchTitle) . '">' . $searchIcon . '</button>'; 519 $html .= '</div>'; 520 521 $html .= '<button class="add-event-compact" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>'; 522 $html .= '</div>'; 523 524 $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '">'; 525 $html .= $this->renderEventListContent($events, $calId, $namespace, $themeStyles); 526 $html .= '</div>'; 527 528 $html .= '</div>'; // End calendar-right 529 530 // Event dialog 531 $html .= $this->renderEventDialog($calId, $namespace, $theme); 532 533 // Month/Year picker dialog (at container level for proper overlay) 534 $html .= $this->renderMonthPicker($calId, $year, $month, $namespace, $theme, $themeStyles); 535 536 $html .= '</div>'; // End container 537 538 return $html; 539 } 540 541 /** 542 * Render a static/read-only calendar for presentation and printing 543 * No edit buttons, clean layout, print-friendly itinerary 544 */ 545 private function renderStaticCalendar($data) { 546 $year = (int)$data['year']; 547 $month = (int)$data['month']; 548 $namespace = isset($data['namespace']) ? $data['namespace'] : ''; 549 $exclude = isset($data['exclude']) ? $data['exclude'] : ''; 550 $excludeList = $this->parseExcludeList($exclude); 551 $customTitle = isset($data['title']) ? $data['title'] : ''; 552 $noprint = isset($data['noprint']) && $data['noprint']; 553 $locked = isset($data['locked']) && $data['locked']; 554 $themeOverride = isset($data['theme']) ? $data['theme'] : ''; 555 556 // Generate unique ID for this static calendar 557 $calId = 'static-cal-' . substr(md5($namespace . $year . $month . uniqid()), 0, 8); 558 559 // Get theme settings 560 if ($themeOverride && in_array($themeOverride, ['matrix', 'pink', 'purple', 'professional', 'wiki', 'dark', 'light'])) { 561 $theme = $themeOverride; 562 } else { 563 $theme = $this->getSidebarTheme(); 564 } 565 $themeStyles = $this->getSidebarThemeStyles($theme); 566 567 // Get important namespaces 568 $importantNsList = $this->getImportantNamespaces(); 569 570 // Load events - check for multi-namespace or wildcard 571 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 572 if ($isMultiNamespace) { 573 $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList); 574 } else { 575 $events = $this->loadEvents($namespace, $year, $month); 576 } 577 578 // Month info 579 $firstDay = mktime(0, 0, 0, $month, 1, $year); 580 $daysInMonth = date('t', $firstDay); 581 $startDayOfWeek = (int)date('w', $firstDay); 582 $monthName = date('F', $firstDay); 583 584 // Display title - custom or default month/year 585 $displayTitle = $customTitle ? $customTitle : $monthName . ' ' . $year; 586 587 // Theme class for styling 588 $themeClass = 'static-theme-' . $theme; 589 590 // Build HTML 591 $html = '<div class="calendar-static ' . $themeClass . '" id="' . $calId . '" data-year="' . $year . '" data-month="' . $month . '" data-namespace="' . hsc($namespace) . '" data-exclude="' . hsc($exclude) . '" data-locked="' . ($locked ? '1' : '0') . '">'; 592 593 // Screen view: Calendar Grid 594 $html .= '<div class="static-screen-view">'; 595 596 // Header with navigation (hide nav buttons if locked) 597 $html .= '<div class="static-header">'; 598 if (!$locked) { 599 $html .= '<button class="static-nav-btn" onclick="navStaticCalendar(\'' . $calId . '\', -1)" title="' . $this->getLang('previous_month') . '">◀</button>'; 600 } 601 $html .= '<h2 class="static-month-title">' . hsc($displayTitle) . '</h2>'; 602 if (!$locked) { 603 $html .= '<button class="static-nav-btn" onclick="navStaticCalendar(\'' . $calId . '\', 1)" title="' . $this->getLang('next_month') . '">▶</button>'; 604 } 605 if (!$noprint) { 606 $html .= '<button class="static-print-btn" onclick="printStaticCalendar(\'' . $calId . '\')" title="' . $this->getLang('print_calendar') . '">️</button>'; 607 } 608 $html .= '</div>'; 609 610 // Calendar grid 611 $html .= '<table class="static-calendar-grid">'; 612 $html .= '<thead><tr>'; 613 $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 614 foreach ($dayNames as $day) { 615 $html .= '<th>' . $day . '</th>'; 616 } 617 $html .= '</tr></thead>'; 618 $html .= '<tbody>'; 619 620 $dayCount = 1; 621 $totalCells = $startDayOfWeek + $daysInMonth; 622 $rows = ceil($totalCells / 7); 623 624 for ($row = 0; $row < $rows; $row++) { 625 $html .= '<tr>'; 626 for ($col = 0; $col < 7; $col++) { 627 $cellNum = $row * 7 + $col; 628 629 if ($cellNum < $startDayOfWeek || $dayCount > $daysInMonth) { 630 $html .= '<td class="static-day-empty"></td>'; 631 } else { 632 $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $dayCount); 633 $dayEvents = isset($events[$dateKey]) ? $events[$dateKey] : []; 634 $isToday = ($dateKey === date('Y-m-d')); 635 $isWeekend = ($col === 0 || $col === 6); 636 637 $cellClass = 'static-day'; 638 if ($isToday) $cellClass .= ' static-day-today'; 639 if ($isWeekend) $cellClass .= ' static-day-weekend'; 640 if (!empty($dayEvents)) $cellClass .= ' static-day-has-events'; 641 642 $html .= '<td class="' . $cellClass . '">'; 643 $html .= '<div class="static-day-number">' . $dayCount . '</div>'; 644 645 if (!empty($dayEvents)) { 646 $html .= '<div class="static-day-events">'; 647 foreach ($dayEvents as $event) { 648 $color = isset($event['color']) ? $event['color'] : '#3498db'; 649 $title = hsc($event['title']); 650 $time = isset($event['time']) && $event['time'] ? $event['time'] : ''; 651 $desc = isset($event['description']) ? $event['description'] : ''; 652 $eventNs = isset($event['namespace']) ? $event['namespace'] : $namespace; 653 654 // Check if important 655 $isImportant = false; 656 foreach ($importantNsList as $impNs) { 657 if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) { 658 $isImportant = true; 659 break; 660 } 661 } 662 663 // Build tooltip - plain text with basic formatting indicators 664 $tooltipText = $event['title']; 665 if ($time) { 666 $tooltipText .= "\n " . $this->formatTime12Hour($time); 667 if (isset($event['endTime']) && $event['endTime']) { 668 $tooltipText .= ' - ' . $this->formatTime12Hour($event['endTime']); 669 } 670 } 671 if ($desc) { 672 // Convert formatting to plain text equivalents 673 $plainDesc = $desc; 674 $plainDesc = preg_replace('/\*\*(.+?)\*\*/', '*$1*', $plainDesc); 675 $plainDesc = preg_replace('/__(.+?)__/', '*$1*', $plainDesc); 676 $plainDesc = preg_replace('/\/\/(.+?)\/\//', '_$1_', $plainDesc); 677 $plainDesc = preg_replace('/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/', '$2 ($1)', $plainDesc); 678 $plainDesc = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '$1 ($2)', $plainDesc); 679 $tooltipText .= "\n\n" . $plainDesc; 680 } 681 682 $eventClass = 'static-event'; 683 if ($isImportant) $eventClass .= ' static-event-important'; 684 685 $html .= '<div class="' . $eventClass . '" style="border-left-color: ' . $color . ';" title="' . hsc($tooltipText) . '">'; 686 if ($isImportant) { 687 $html .= '<span class="static-event-star">⭐</span>'; 688 } 689 if ($time) { 690 $html .= '<span class="static-event-time">' . $this->formatTime12Hour($time) . '</span> '; 691 } 692 $html .= '<span class="static-event-title">' . $title . '</span>'; 693 $html .= '</div>'; 694 } 695 $html .= '</div>'; 696 } 697 698 $html .= '</td>'; 699 $dayCount++; 700 } 701 } 702 $html .= '</tr>'; 703 } 704 705 $html .= '</tbody></table>'; 706 $html .= '</div>'; // End screen view 707 708 // Print view: Itinerary format (skip if noprint) 709 if (!$noprint) { 710 $html .= '<div class="static-print-view">'; 711 $html .= '<h2 class="static-print-title">' . hsc($displayTitle) . '</h2>'; 712 713 if (!empty($namespace)) { 714 $html .= '<p class="static-print-namespace">' . $this->getLang('calendar_label') . ': ' . hsc($namespace) . '</p>'; 715 } 716 717 // Collect all events sorted by date 718 $allEvents = []; 719 foreach ($events as $dateKey => $dayEvents) { 720 foreach ($dayEvents as $event) { 721 $event['_date'] = $dateKey; 722 $allEvents[] = $event; 723 } 724 } 725 726 // Sort by date, then time 727 usort($allEvents, function($a, $b) { 728 $dateCompare = strcmp($a['_date'], $b['_date']); 729 if ($dateCompare !== 0) return $dateCompare; 730 $timeA = isset($a['time']) ? $a['time'] : '99:99'; 731 $timeB = isset($b['time']) ? $b['time'] : '99:99'; 732 return strcmp($timeA, $timeB); 733 }); 734 735 if (empty($allEvents)) { 736 $html .= '<p class="static-print-empty">' . $this->getLang('no_events_scheduled') . '</p>'; 737 } else { 738 $html .= '<table class="static-itinerary">'; 739 $html .= '<thead><tr><th>Date</th><th>Time</th><th>Event</th><th>Details</th></tr></thead>'; 740 $html .= '<tbody>'; 741 742 $lastDate = ''; 743 foreach ($allEvents as $event) { 744 $dateKey = $event['_date']; 745 $dateObj = new \DateTime($dateKey); 746 $dateDisplay = $dateObj->format('D, M j'); 747 $eventNs = isset($event['namespace']) ? $event['namespace'] : $namespace; 748 749 // Check if important 750 $isImportant = false; 751 foreach ($importantNsList as $impNs) { 752 if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) { 753 $isImportant = true; 754 break; 755 } 756 } 757 758 $rowClass = $isImportant ? 'static-itinerary-important' : ''; 759 760 $html .= '<tr class="' . $rowClass . '">'; 761 762 // Only show date if different from previous row 763 if ($dateKey !== $lastDate) { 764 $html .= '<td class="static-itinerary-date">' . $dateDisplay . '</td>'; 765 $lastDate = $dateKey; 766 } else { 767 $html .= '<td></td>'; 768 } 769 770 // Time 771 $time = isset($event['time']) && $event['time'] ? $this->formatTime12Hour($event['time']) : $this->getLang('all_day'); 772 if (isset($event['endTime']) && $event['endTime'] && isset($event['time']) && $event['time']) { 773 $time .= ' - ' . $this->formatTime12Hour($event['endTime']); 774 } 775 $html .= '<td class="static-itinerary-time">' . $time . '</td>'; 776 777 // Title with star for important 778 $html .= '<td class="static-itinerary-title">'; 779 if ($isImportant) { 780 $html .= '⭐ '; 781 } 782 $html .= hsc($event['title']); 783 $html .= '</td>'; 784 785 // Description - with formatting 786 $desc = isset($event['description']) ? $this->renderDescription($event['description']) : ''; 787 $html .= '<td class="static-itinerary-desc">' . $desc . '</td>'; 788 789 $html .= '</tr>'; 790 } 791 792 $html .= '</tbody></table>'; 793 } 794 795 $html .= '</div>'; // End print view 796 } // End noprint check 797 798 $html .= '</div>'; // End container 799 800 return $html; 801 } 802 803 /** 804 * Format time to 12-hour format 805 */ 806 private function formatTime12Hour($time) { 807 if (!$time) return ''; 808 $parts = explode(':', $time); 809 $hour = (int)$parts[0]; 810 $minute = isset($parts[1]) ? $parts[1] : '00'; 811 $ampm = $hour >= 12 ? 'PM' : 'AM'; 812 $hour12 = $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour); 813 return $hour12 . ':' . $minute . ' ' . $ampm; 814 } 815 816 /** 817 * Get list of important namespaces from config 818 */ 819 private function getImportantNamespaces() { 820 $configFile = $this->syncConfigPath(); 821 $importantNsList = ['important']; // default 822 if (file_exists($configFile)) { 823 $config = include $configFile; 824 if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) { 825 $importantNsList = array_map('trim', explode(',', $config['important_namespaces'])); 826 } 827 } 828 return $importantNsList; 829 } 830 831 private function renderEventListContent($events, $calId, $namespace, $themeStyles = null) { 832 if (empty($events)) { 833 return '<p class="no-events-msg">No events this month</p>'; 834 } 835 836 // Default theme styles if not provided 837 if ($themeStyles === null) { 838 $theme = $this->getSidebarTheme(); 839 $themeStyles = $this->getSidebarThemeStyles($theme); 840 } else { 841 $theme = $this->getSidebarTheme(); 842 } 843 844 // Get important namespaces from config 845 $configFile = $this->syncConfigPath(); 846 $importantNsList = ['important']; // default 847 if (file_exists($configFile)) { 848 $config = include $configFile; 849 if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) { 850 $importantNsList = array_map('trim', explode(',', $config['important_namespaces'])); 851 } 852 } 853 854 // Check for time conflicts 855 $events = $this->checkTimeConflicts($events); 856 857 // Sort by date ascending (chronological order - oldest first) 858 ksort($events); 859 860 // Sort events within each day by time 861 foreach ($events as $dateKey => &$dayEvents) { 862 usort($dayEvents, function($a, $b) { 863 $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null; 864 $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null; 865 866 // All-day events (no time) go to the TOP 867 if ($timeA === null && $timeB !== null) return -1; // A before B 868 if ($timeA !== null && $timeB === null) return 1; // A after B 869 if ($timeA === null && $timeB === null) return 0; // Both all-day, equal 870 871 // Both have times, sort chronologically 872 return strcmp($timeA, $timeB); 873 }); 874 } 875 unset($dayEvents); // Break reference 876 877 // Get today's date for comparison 878 $today = date('Y-m-d'); 879 $firstFutureEventId = null; 880 881 // Helper function to check if event is past (with 15-minute grace period for timed events) 882 $isEventPast = function($dateKey, $time) use ($today) { 883 // If event is on a past date, it's definitely past 884 if ($dateKey < $today) { 885 return true; 886 } 887 888 // If event is on a future date, it's definitely not past 889 if ($dateKey > $today) { 890 return false; 891 } 892 893 // Event is today - check time with grace period 894 if ($time && $time !== '') { 895 try { 896 $currentDateTime = new DateTime(); 897 $eventDateTime = new DateTime($dateKey . ' ' . $time); 898 899 // Add 15-minute grace period 900 $eventDateTime->modify('+15 minutes'); 901 902 // Event is past if current time > event time + 15 minutes 903 return $currentDateTime > $eventDateTime; 904 } catch (Exception $e) { 905 // If time parsing fails, fall back to date-only comparison 906 return false; 907 } 908 } 909 910 // No time specified for today's event, treat as future 911 return false; 912 }; 913 914 // Build HTML for each event - separate past/completed from future 915 $pastHtml = ''; 916 $futureHtml = ''; 917 $pastCount = 0; 918 919 foreach ($events as $dateKey => $dayEvents) { 920 921 foreach ($dayEvents as $event) { 922 // Track first future/today event for auto-scroll 923 if (!$firstFutureEventId && $dateKey >= $today) { 924 $firstFutureEventId = isset($event['id']) ? $event['id'] : ''; 925 } 926 $eventId = isset($event['id']) ? $event['id'] : ''; 927 $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled'; 928 $timeRaw = isset($event['time']) ? $event['time'] : ''; 929 $time = htmlspecialchars($timeRaw); 930 $endTime = isset($event['endTime']) ? htmlspecialchars($event['endTime']) : ''; 931 $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db'; 932 $description = isset($event['description']) ? $event['description'] : ''; 933 $isTask = isset($event['isTask']) ? $event['isTask'] : false; 934 $completed = isset($event['completed']) ? $event['completed'] : false; 935 $endDate = isset($event['endDate']) ? $event['endDate'] : ''; 936 937 // Use helper function to determine if event is past (with grace period) 938 $isPast = $isEventPast($dateKey, $timeRaw); 939 $isToday = $dateKey === $today; 940 941 // Check if event should be in past section 942 // EXCEPTION: Uncompleted tasks (isTask && !completed) should stay visible even if past 943 $isPastOrCompleted = ($isPast && (!$isTask || $completed)) || $completed; 944 if ($isPastOrCompleted) { 945 $pastCount++; 946 } 947 948 // Determine if task is past due (past date, is task, not completed) 949 $isPastDue = $isPast && $isTask && !$completed; 950 951 // Process description for wiki syntax, HTML, images, and links 952 $renderedDescription = $this->renderDescription($description, $themeStyles); 953 954 // Convert to 12-hour format and handle time ranges 955 $displayTime = ''; 956 if ($time) { 957 $timeObj = DateTime::createFromFormat('H:i', $time); 958 if ($timeObj) { 959 $displayTime = $timeObj->format('g:i A'); 960 961 // Add end time if present and different from start time 962 if ($endTime && $endTime !== $time) { 963 $endTimeObj = DateTime::createFromFormat('H:i', $endTime); 964 if ($endTimeObj) { 965 $displayTime .= ' - ' . $endTimeObj->format('g:i A'); 966 } 967 } 968 } else { 969 $displayTime = $time; 970 } 971 } 972 973 // Format date display with day of week 974 // Use originalStartDate if this is a multi-month event continuation 975 $displayDateKey = isset($event['originalStartDate']) ? $event['originalStartDate'] : $dateKey; 976 $dateObj = new DateTime($displayDateKey); 977 $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Jan 24" 978 979 // Multi-day indicator 980 $multiDay = ''; 981 if ($endDate && $endDate !== $displayDateKey) { 982 $endObj = new DateTime($endDate); 983 $multiDay = ' → ' . $endObj->format('D, M j'); 984 } 985 986 $completedClass = $completed ? ' event-completed' : ''; 987 // Don't grey out past due tasks - they need attention! 988 $pastClass = ($isPast && !$isPastDue) ? ' event-past' : ''; 989 $pastDueClass = $isPastDue ? ' event-pastdue' : ''; 990 $firstFutureAttr = ($firstFutureEventId === $eventId) ? ' data-first-future="true"' : ''; 991 992 // Check if this is an important namespace event 993 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 994 if (!$eventNamespace && isset($event['_namespace'])) { 995 $eventNamespace = $event['_namespace']; 996 } 997 $isImportantNs = false; 998 foreach ($importantNsList as $impNs) { 999 if ($eventNamespace === $impNs || strpos($eventNamespace, $impNs . ':') === 0) { 1000 $isImportantNs = true; 1001 break; 1002 } 1003 } 1004 $importantClass = $isImportantNs ? ' event-important' : ''; 1005 1006 // For all themes: use CSS variables, only keep border-left-color as inline 1007 $pastClickHandler = ($isPast && !$isPastDue) ? ' onclick="togglePastEventExpand(this)"' : ''; 1008 $eventHtml = '<div class="event-compact-item' . $completedClass . $pastClass . $pastDueClass . $importantClass . '" data-event-id="' . $eventId . '" data-date="' . $dateKey . '" style="border-left-color: ' . $color . ' !important;"' . $pastClickHandler . $firstFutureAttr . '>'; 1009 $eventHtml .= '<div class="event-info">'; 1010 1011 $eventHtml .= '<div class="event-title-row">'; 1012 // Add star for important namespace events 1013 if ($isImportantNs) { 1014 $eventHtml .= '<span class="event-important-star" title="Important">⭐</span> '; 1015 } 1016 $eventHtml .= '<span class="event-title-compact">' . $title . '</span>'; 1017 $eventHtml .= '</div>'; 1018 1019 // For past events, hide meta and description (collapsed) 1020 // EXCEPTION: Past due tasks should show their details 1021 if (!$isPast || $isPastDue) { 1022 $eventHtml .= '<div class="event-meta-compact">'; 1023 $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay; 1024 if ($displayTime) { 1025 $eventHtml .= ' • ' . $displayTime; 1026 } 1027 // Add TODAY badge for today's events OR PAST DUE for uncompleted past tasks 1028 if ($isPastDue) { 1029 $eventHtml .= ' <span class="event-pastdue-badge" style="background:' . $themeStyles['pastdue_color'] . ' !important; color:white !important; -webkit-text-fill-color:white !important;">' . 'PAST DUE</span>'; 1030 } elseif ($isToday) { 1031 $eventHtml .= ' <span class="event-today-badge" style="background:' . $themeStyles['border'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;">' . 'TODAY</span>'; 1032 } 1033 // Add namespace badge - ALWAYS show if event has a namespace 1034 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 1035 if (!$eventNamespace && isset($event['_namespace'])) { 1036 $eventNamespace = $event['_namespace']; // Fallback to _namespace for backward compatibility 1037 } 1038 // Show badge if namespace exists and is not empty 1039 if ($eventNamespace && $eventNamespace !== '') { 1040 $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer; background:' . $themeStyles['text_bright'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>'; 1041 } 1042 1043 // Add conflict warning if event has time conflicts 1044 if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) { 1045 $conflictList = []; 1046 foreach ($event['conflictsWith'] as $conflict) { 1047 $conflictText = $conflict['title']; 1048 if (!empty($conflict['time'])) { 1049 // Format time range 1050 $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']); 1051 $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time']; 1052 1053 if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) { 1054 $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']); 1055 $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime']; 1056 $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')'; 1057 } else { 1058 $conflictText .= ' (' . $startTimeFormatted . ')'; 1059 } 1060 } 1061 $conflictList[] = $conflictText; 1062 } 1063 $conflictCount = count($event['conflictsWith']); 1064 $conflictJson = base64_encode(json_encode($conflictList)); 1065 $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>'; 1066 } 1067 1068 $eventHtml .= '</span>'; 1069 $eventHtml .= '</div>'; 1070 1071 if ($description) { 1072 $eventHtml .= '<div class="event-desc-compact">' . $renderedDescription . '</div>'; 1073 } 1074 } else { 1075 // Past events: render with display:none for click-to-expand 1076 $eventHtml .= '<div class="event-meta-compact" style="display:none;">'; 1077 $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay; 1078 if ($displayTime) { 1079 $eventHtml .= ' • ' . $displayTime; 1080 } 1081 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 1082 if (!$eventNamespace && isset($event['_namespace'])) { 1083 $eventNamespace = $event['_namespace']; 1084 } 1085 if ($eventNamespace && $eventNamespace !== '') { 1086 $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer; background:' . $themeStyles['text_bright'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>'; 1087 } 1088 1089 // Add conflict warning if event has time conflicts 1090 if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) { 1091 $conflictList = []; 1092 foreach ($event['conflictsWith'] as $conflict) { 1093 $conflictText = $conflict['title']; 1094 if (!empty($conflict['time'])) { 1095 $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']); 1096 $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time']; 1097 1098 if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) { 1099 $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']); 1100 $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime']; 1101 $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')'; 1102 } else { 1103 $conflictText .= ' (' . $startTimeFormatted . ')'; 1104 } 1105 } 1106 $conflictList[] = $conflictText; 1107 } 1108 $conflictCount = count($event['conflictsWith']); 1109 $conflictJson = base64_encode(json_encode($conflictList)); 1110 $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>'; 1111 } 1112 1113 $eventHtml .= '</span>'; 1114 $eventHtml .= '</div>'; 1115 1116 if ($description) { 1117 $eventHtml .= '<div class="event-desc-compact" style="display:none;">' . $renderedDescription . '</div>'; 1118 } 1119 } 1120 1121 $eventHtml .= '</div>'; // event-info 1122 1123 // Use stored namespace from event, fallback to passed namespace 1124 $buttonNamespace = isset($event['namespace']) ? $event['namespace'] : $namespace; 1125 1126 $eventHtml .= '<div class="event-actions-compact">'; 1127 $eventHtml .= '<button class="event-action-btn" onclick="deleteEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">️</button>'; 1128 $eventHtml .= '<button class="event-action-btn" onclick="editEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">✏️</button>'; 1129 $eventHtml .= '</div>'; 1130 1131 // Checkbox for tasks - ON THE FAR RIGHT 1132 if ($isTask) { 1133 $checked = $completed ? 'checked' : ''; 1134 $eventHtml .= '<input type="checkbox" class="task-checkbox" ' . $checked . ' onclick="toggleTaskComplete(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\', this.checked)">'; 1135 } 1136 1137 $eventHtml .= '</div>'; 1138 1139 // Add to appropriate section 1140 if ($isPastOrCompleted) { 1141 $pastHtml .= $eventHtml; 1142 } else { 1143 $futureHtml .= $eventHtml; 1144 } 1145 } 1146 } 1147 1148 // Build final HTML with collapsible past events section 1149 $html = ''; 1150 1151 // Add collapsible past events section if any exist 1152 if ($pastCount > 0) { 1153 $html .= '<div class="past-events-section">'; 1154 $html .= '<div class="past-events-toggle" onclick="togglePastEvents(\'' . $calId . '\')">'; 1155 $html .= '<span class="past-events-arrow" id="past-arrow-' . $calId . '">▶</span> '; 1156 $html .= '<span class="past-events-label">Past Events (' . $pastCount . ')</span>'; 1157 $html .= '</div>'; 1158 $html .= '<div class="past-events-content" id="past-events-' . $calId . '" style="display:none;">'; 1159 $html .= $pastHtml; 1160 $html .= '</div>'; 1161 $html .= '</div>'; 1162 } 1163 1164 // Add future events 1165 $html .= $futureHtml; 1166 1167 return $html; 1168 } 1169 1170 /** 1171 * Check for time conflicts between events 1172 */ 1173 private function checkTimeConflicts($events) { 1174 // Group events by date 1175 $eventsByDate = []; 1176 foreach ($events as $date => $dateEvents) { 1177 if (!is_array($dateEvents)) continue; 1178 1179 foreach ($dateEvents as $evt) { 1180 if (empty($evt['time'])) continue; // Skip all-day events 1181 1182 if (!isset($eventsByDate[$date])) { 1183 $eventsByDate[$date] = []; 1184 } 1185 $eventsByDate[$date][] = $evt; 1186 } 1187 } 1188 1189 // Check for overlaps on each date 1190 foreach ($eventsByDate as $date => $dateEvents) { 1191 for ($i = 0; $i < count($dateEvents); $i++) { 1192 for ($j = $i + 1; $j < count($dateEvents); $j++) { 1193 if ($this->eventsOverlap($dateEvents[$i], $dateEvents[$j])) { 1194 // Mark both events as conflicting 1195 $dateEvents[$i]['hasConflict'] = true; 1196 $dateEvents[$j]['hasConflict'] = true; 1197 1198 // Store conflict info 1199 if (!isset($dateEvents[$i]['conflictsWith'])) { 1200 $dateEvents[$i]['conflictsWith'] = []; 1201 } 1202 if (!isset($dateEvents[$j]['conflictsWith'])) { 1203 $dateEvents[$j]['conflictsWith'] = []; 1204 } 1205 1206 $dateEvents[$i]['conflictsWith'][] = [ 1207 'id' => $dateEvents[$j]['id'], 1208 'title' => $dateEvents[$j]['title'], 1209 'time' => $dateEvents[$j]['time'], 1210 'endTime' => isset($dateEvents[$j]['endTime']) ? $dateEvents[$j]['endTime'] : '' 1211 ]; 1212 1213 $dateEvents[$j]['conflictsWith'][] = [ 1214 'id' => $dateEvents[$i]['id'], 1215 'title' => $dateEvents[$i]['title'], 1216 'time' => $dateEvents[$i]['time'], 1217 'endTime' => isset($dateEvents[$i]['endTime']) ? $dateEvents[$i]['endTime'] : '' 1218 ]; 1219 } 1220 } 1221 } 1222 1223 // Update the events array with conflict information 1224 foreach ($events[$date] as &$evt) { 1225 foreach ($dateEvents as $checkedEvt) { 1226 if ($evt['id'] === $checkedEvt['id']) { 1227 if (isset($checkedEvt['hasConflict'])) { 1228 $evt['hasConflict'] = $checkedEvt['hasConflict']; 1229 } 1230 if (isset($checkedEvt['conflictsWith'])) { 1231 $evt['conflictsWith'] = $checkedEvt['conflictsWith']; 1232 } 1233 break; 1234 } 1235 } 1236 } 1237 } 1238 1239 return $events; 1240 } 1241 1242 /** 1243 * Check if two events overlap in time 1244 */ 1245 private function eventsOverlap($evt1, $evt2) { 1246 if (empty($evt1['time']) || empty($evt2['time'])) { 1247 return false; // All-day events don't conflict 1248 } 1249 1250 $start1 = $evt1['time']; 1251 $end1 = isset($evt1['endTime']) && !empty($evt1['endTime']) ? $evt1['endTime'] : $evt1['time']; 1252 1253 $start2 = $evt2['time']; 1254 $end2 = isset($evt2['endTime']) && !empty($evt2['endTime']) ? $evt2['endTime'] : $evt2['time']; 1255 1256 // Convert to minutes for easier comparison 1257 $start1Mins = $this->timeToMinutes($start1); 1258 $end1Mins = $this->timeToMinutes($end1); 1259 $start2Mins = $this->timeToMinutes($start2); 1260 $end2Mins = $this->timeToMinutes($end2); 1261 1262 // Check for overlap: start1 < end2 AND start2 < end1 1263 return $start1Mins < $end2Mins && $start2Mins < $end1Mins; 1264 } 1265 1266 /** 1267 * Convert HH:MM time to minutes since midnight 1268 */ 1269 private function timeToMinutes($timeStr) { 1270 $parts = explode(':', $timeStr); 1271 if (count($parts) !== 2) return 0; 1272 1273 return (int)$parts[0] * 60 + (int)$parts[1]; 1274 } 1275 1276 private function renderEventPanelOnly($data) { 1277 $year = (int)$data['year']; 1278 $month = (int)$data['month']; 1279 $namespace = $data['namespace']; 1280 $exclude = isset($data['exclude']) ? $data['exclude'] : ''; 1281 $excludeList = $this->parseExcludeList($exclude); 1282 $height = isset($data['height']) ? $data['height'] : '400px'; 1283 1284 // Validate height format (must be px, em, rem, vh, or %) 1285 if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) { 1286 $height = '400px'; // Default fallback 1287 } 1288 1289 // Get theme - prefer inline theme= parameter, fall back to admin default 1290 $theme = !empty($data['theme']) ? $data['theme'] : $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); 1291 1292 // Check if multiple namespaces or wildcard specified 1293 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 1294 1295 if ($isMultiNamespace) { 1296 $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList); 1297 } else { 1298 $events = $this->loadEvents($namespace, $year, $month); 1299 } 1300 $calId = 'panel_' . md5(serialize($data) . microtime()); 1301 1302 $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year)); 1303 1304 $prevMonth = $month - 1; 1305 $prevYear = $year; 1306 if ($prevMonth < 1) { 1307 $prevMonth = 12; 1308 $prevYear--; 1309 } 1310 1311 $nextMonth = $month + 1; 1312 $nextYear = $year; 1313 if ($nextMonth > 12) { 1314 $nextMonth = 1; 1315 $nextYear++; 1316 } 1317 1318 // Determine button text color based on theme 1319 $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg']; 1320 1321 // Get important namespaces from config for highlighting 1322 $configFile = $this->syncConfigPath(); 1323 $importantNsList = ['important']; // default 1324 if (file_exists($configFile)) { 1325 $config = include $configFile; 1326 if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) { 1327 $importantNsList = array_map('trim', explode(',', $config['important_namespaces'])); 1328 } 1329 } 1330 1331 $html = '<div class="event-panel-standalone" id="' . $calId . '" data-height="' . htmlspecialchars($height) . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '" data-exclude="' . htmlspecialchars($exclude) . '" data-theme="' . $theme . '" data-theme-styles="' . htmlspecialchars(json_encode($themeStyles)) . '" data-important-namespaces="' . htmlspecialchars(json_encode($importantNsList)) . '">'; 1332 1333 // Inject CSS variables for this panel instance - same as main calendar 1334 $html .= '<style> 1335 #' . $calId . ' { 1336 --background-site: ' . $themeStyles['bg'] . '; 1337 --background-alt: ' . $themeStyles['cell_bg'] . '; 1338 --background-header: ' . $themeStyles['header_bg'] . '; 1339 --text-primary: ' . $themeStyles['text_primary'] . '; 1340 --text-dim: ' . $themeStyles['text_dim'] . '; 1341 --text-bright: ' . $themeStyles['text_bright'] . '; 1342 --border-color: ' . $themeStyles['grid_border'] . '; 1343 --border-main: ' . $themeStyles['border'] . '; 1344 --cell-bg: ' . $themeStyles['cell_bg'] . '; 1345 --cell-today-bg: ' . $themeStyles['cell_today_bg'] . '; 1346 --shadow-color: ' . $themeStyles['shadow'] . '; 1347 --header-border: ' . $themeStyles['header_border'] . '; 1348 --header-shadow: ' . $themeStyles['header_shadow'] . '; 1349 --grid-bg: ' . $themeStyles['grid_bg'] . '; 1350 --btn-text: ' . $btnTextColor . '; 1351 --pastdue-color: ' . $themeStyles['pastdue_color'] . '; 1352 --pastdue-bg: ' . $themeStyles['pastdue_bg'] . '; 1353 --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . '; 1354 --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . '; 1355 --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . '; 1356 --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . '; 1357 --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . '; 1358 } 1359 #event-search-' . $calId . '::placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; } 1360 </style>'; 1361 1362 // Load calendar JavaScript manually (not through DokuWiki concatenation) 1363 $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>'; 1364 1365 // Initialize DOKU_BASE for JavaScript 1366 $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>'; 1367 1368 // Compact two-row header designed for ~500px width 1369 $html .= '<div class="panel-header-compact">'; 1370 1371 // Row 1: Navigation and title 1372 $html .= '<div class="panel-header-row-1">'; 1373 $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>'; 1374 1375 // Compact month name (e.g. "Feb 2026" instead of "February 2026 Events") 1376 $shortMonthName = date('M Y', mktime(0, 0, 0, $month, 1, $year)); 1377 $html .= '<h3 class="panel-month-title" onclick="openMonthPickerPanel(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $shortMonthName . '</h3>'; 1378 1379 $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>'; 1380 1381 // Namespace badge (if applicable) 1382 if ($namespace) { 1383 if ($isMultiNamespace) { 1384 if (strpos($namespace, '*') !== false) { 1385 $html .= '<span class="panel-ns-badge" style="background:var(--cell-today-bg) !important; color:var(--text-bright) !important; -webkit-text-fill-color:var(--text-bright) !important;" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>'; 1386 } else { 1387 $namespaceList = array_map('trim', explode(';', $namespace)); 1388 $nsCount = count($namespaceList); 1389 $html .= '<span class="panel-ns-badge" style="background:var(--cell-today-bg) !important; color:var(--text-bright) !important; -webkit-text-fill-color:var(--text-bright) !important;" title="' . htmlspecialchars(implode(', ', $namespaceList)) . '">' . $nsCount . ' NS</span>'; 1390 } 1391 } else { 1392 $isFiltering = ($namespace !== '*' && strpos($namespace, '*') === false && strpos($namespace, ';') === false); 1393 if ($isFiltering) { 1394 $html .= '<span class="panel-ns-badge filter-on" style="background:var(--text-bright) !important; color:var(--background-site) !important; -webkit-text-fill-color:var(--background-site) !important;" title="Filtering by ' . htmlspecialchars($namespace) . ' - click to clear" onclick="clearNamespaceFilterPanel(\'' . $calId . '\')">' . htmlspecialchars($namespace) . ' ✕</span>'; 1395 } else { 1396 $html .= '<span class="panel-ns-badge" style="background:var(--cell-today-bg) !important; color:var(--text-bright) !important; -webkit-text-fill-color:var(--text-bright) !important;" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>'; 1397 } 1398 } 1399 } 1400 1401 $html .= '<button class="panel-today-btn" onclick="jumpTodayPanel(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>'; 1402 $html .= '</div>'; 1403 1404 // Row 2: Search and add button 1405 $searchDefault = $this->getSearchDefault(); 1406 $searchAllClass = $searchDefault === 'all' ? ' all-dates' : ''; 1407 $searchIcon = $searchDefault === 'all' ? '' : ''; 1408 $searchTitle = $searchDefault === 'all' ? 'Searching all dates' : 'Search this month only'; 1409 $searchPlaceholder = $searchDefault === 'all' ? 'Search all dates...' : 'Search this month...'; 1410 $html .= '<div class="panel-header-row-2">'; 1411 $html .= '<div class="panel-search-box">'; 1412 $html .= '<input type="text" class="panel-search-input" id="event-search-' . $calId . '" placeholder="' . htmlspecialchars($searchPlaceholder) . '" oninput="filterEvents(\'' . $calId . '\', this.value)">'; 1413 $html .= '<button class="panel-search-clear" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>'; 1414 $html .= '<button class="panel-search-mode' . $searchAllClass . '" id="search-mode-' . $calId . '" onclick="toggleSearchMode(\'' . $calId . '\', \'' . $namespace . '\')" title="' . htmlspecialchars($searchTitle) . '">' . $searchIcon . '</button>'; 1415 $html .= '</div>'; 1416 $html .= '<button class="panel-add-btn" onclick="openAddEventPanel(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>'; 1417 $html .= '</div>'; 1418 1419 $html .= '</div>'; 1420 1421 $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '" style="max-height: ' . htmlspecialchars($height) . ';">'; 1422 $html .= $this->renderEventListContent($events, $calId, $namespace); 1423 $html .= '</div>'; 1424 1425 $html .= $this->renderEventDialog($calId, $namespace, $theme); 1426 1427 // Month/Year picker for event panel 1428 $html .= $this->renderMonthPicker($calId, $year, $month, $namespace, $theme, $themeStyles); 1429 1430 $html .= '</div>'; 1431 1432 return $html; 1433 } 1434 1435 private function renderStandaloneEventList($data) { 1436 $namespace = $data['namespace']; 1437 // If no namespace specified, show all namespaces 1438 if (empty($namespace)) { 1439 $namespace = '*'; 1440 } 1441 $exclude = isset($data['exclude']) ? $data['exclude'] : ''; 1442 $excludeList = $this->parseExcludeList($exclude); 1443 $daterange = $data['daterange']; 1444 $date = $data['date']; 1445 $range = isset($data['range']) ? strtolower($data['range']) : ''; 1446 $today = isset($data['today']) ? true : false; 1447 $sidebar = isset($data['sidebar']) ? true : false; 1448 $showchecked = isset($data['showchecked']) ? true : false; // New parameter 1449 $noheader = isset($data['noheader']) ? true : false; // New parameter to hide header 1450 1451 // Handle "range" parameter - day, week, month, or extended format (>3m, >100d, >2w, >1y) 1452 if ($range === 'day') { 1453 $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks 1454 $endDate = date('Y-m-d'); 1455 $headerText = 'Today'; 1456 } elseif ($range === 'week') { 1457 $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks 1458 $endDateTime = new DateTime(); 1459 $endDateTime->modify('+7 days'); 1460 $endDate = $endDateTime->format('Y-m-d'); 1461 $headerText = 'This Week'; 1462 } elseif ($range === 'month') { 1463 $startDate = date('Y-m-01', strtotime('-1 month')); // Include previous month for past due tasks 1464 $endDate = date('Y-m-t'); // Last of current month 1465 $dt = new DateTime(); 1466 $headerText = $dt->format('F Y'); 1467 } elseif (preg_match('/^>(\d+)(d|w|m|y)$/i', $range, $rangeMatch)) { 1468 // Extended range: >3m = next 3 months, >100d = next 100 days, >2w = next 2 weeks, >1y = next 1 year 1469 $rangeNum = (int)$rangeMatch[1]; 1470 $rangeUnit = strtolower($rangeMatch[2]); 1471 1472 $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks 1473 $endDateTime = new DateTime(); 1474 1475 switch ($rangeUnit) { 1476 case 'd': 1477 $endDateTime->modify('+' . $rangeNum . ' days'); 1478 $headerText = $this->getLang('next') . ' ' . $rangeNum . ' ' . ($rangeNum === 1 ? $this->getLang('range_day') : $this->getLang('range_days')); 1479 break; 1480 case 'w': 1481 $endDateTime->modify('+' . $rangeNum . ' weeks'); 1482 $headerText = $this->getLang('next') . ' ' . $rangeNum . ' ' . ($rangeNum === 1 ? $this->getLang('range_week') : $this->getLang('range_weeks')); 1483 break; 1484 case 'm': 1485 $endDateTime->modify('+' . $rangeNum . ' months'); 1486 $headerText = $this->getLang('next') . ' ' . $rangeNum . ' ' . ($rangeNum === 1 ? $this->getLang('range_month') : $this->getLang('range_months')); 1487 break; 1488 case 'y': 1489 $endDateTime->modify('+' . $rangeNum . ' years'); 1490 $headerText = $this->getLang('next') . ' ' . $rangeNum . ' ' . ($rangeNum === 1 ? $this->getLang('range_year') : $this->getLang('range_years')); 1491 break; 1492 } 1493 $endDate = $endDateTime->format('Y-m-d'); 1494 } elseif ($sidebar) { 1495 // NEW: Sidebar widget - load current week's events 1496 $weekStartDay = $this->getWeekStartDay(); // Get saved preference 1497 1498 if ($weekStartDay === 'monday') { 1499 // Monday start 1500 $weekStart = date('Y-m-d', strtotime('monday this week')); 1501 $weekEnd = date('Y-m-d', strtotime('sunday this week')); 1502 } else { 1503 // Sunday start (default - US/Canada standard) 1504 $today = date('w'); // 0 (Sun) to 6 (Sat) 1505 if ($today == 0) { 1506 // Today is Sunday 1507 $weekStart = date('Y-m-d'); 1508 } else { 1509 // Monday-Saturday: go back to last Sunday 1510 $weekStart = date('Y-m-d', strtotime('-' . $today . ' days')); 1511 } 1512 $weekEnd = date('Y-m-d', strtotime($weekStart . ' +6 days')); 1513 } 1514 1515 // Load events for the entire week PLUS tomorrow (if tomorrow is outside week) 1516 // PLUS next 2 weeks for Important events 1517 $start = new DateTime($weekStart); 1518 $end = new DateTime($weekEnd); 1519 1520 // Check if we need to extend to include tomorrow 1521 $tomorrowDate = date('Y-m-d', strtotime('+1 day')); 1522 if ($tomorrowDate > $weekEnd) { 1523 // Tomorrow is outside the week, extend end date to include it 1524 $end = new DateTime($tomorrowDate); 1525 } 1526 1527 // Extend 2 weeks into the future for Important events 1528 $twoWeeksOut = date('Y-m-d', strtotime($weekEnd . ' +14 days')); 1529 $end = new DateTime($twoWeeksOut); 1530 1531 $end->modify('+1 day'); // DatePeriod excludes end date 1532 $interval = new DateInterval('P1D'); 1533 $period = new DatePeriod($start, $interval, $end); 1534 1535 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 1536 $allEvents = []; 1537 $loadedMonths = []; 1538 1539 foreach ($period as $dt) { 1540 $year = (int)$dt->format('Y'); 1541 $month = (int)$dt->format('n'); 1542 $dateKey = $dt->format('Y-m-d'); 1543 1544 $monthKey = $year . '-' . $month . '-' . $namespace; 1545 1546 if (!isset($loadedMonths[$monthKey])) { 1547 if ($isMultiNamespace) { 1548 $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList); 1549 } else { 1550 $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month); 1551 } 1552 } 1553 1554 $monthEvents = $loadedMonths[$monthKey]; 1555 1556 if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) { 1557 $allEvents[$dateKey] = $monthEvents[$dateKey]; 1558 } 1559 } 1560 1561 // Apply time conflict detection 1562 $allEvents = $this->checkTimeConflicts($allEvents); 1563 1564 $calId = 'sidebar-' . substr(md5($namespace . $weekStart), 0, 8); 1565 1566 // Render sidebar widget and return immediately 1567 $themeOverride = !empty($data['theme']) ? $data['theme'] : null; 1568 return $this->renderSidebarWidget($allEvents, $namespace, $calId, $themeOverride); 1569 } elseif ($today) { 1570 $startDate = date('Y-m-d'); 1571 $endDate = date('Y-m-d'); 1572 $headerText = 'Today'; 1573 } elseif ($daterange) { 1574 list($startDate, $endDate) = explode(':', $daterange); 1575 $start = new DateTime($startDate); 1576 $end = new DateTime($endDate); 1577 $headerText = $start->format('M j') . ' - ' . $end->format('M j, Y'); 1578 } elseif ($date) { 1579 $startDate = $date; 1580 $endDate = $date; 1581 $dt = new DateTime($date); 1582 $headerText = $dt->format('l, F j, Y'); 1583 } else { 1584 $startDate = date('Y-m-01'); 1585 $endDate = date('Y-m-t'); 1586 $dt = new DateTime($startDate); 1587 $headerText = $dt->format('F Y'); 1588 } 1589 1590 // Load all events in date range 1591 $allEvents = array(); 1592 $start = new DateTime($startDate); 1593 $end = new DateTime($endDate); 1594 $end->modify('+1 day'); 1595 1596 $interval = new DateInterval('P1D'); 1597 $period = new DatePeriod($start, $interval, $end); 1598 1599 // Check if multiple namespaces or wildcard specified 1600 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 1601 1602 static $loadedMonths = array(); 1603 1604 foreach ($period as $dt) { 1605 $year = (int)$dt->format('Y'); 1606 $month = (int)$dt->format('n'); 1607 $dateKey = $dt->format('Y-m-d'); 1608 1609 $monthKey = $year . '-' . $month . '-' . $namespace; 1610 1611 if (!isset($loadedMonths[$monthKey])) { 1612 if ($isMultiNamespace) { 1613 $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList); 1614 } else { 1615 $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month); 1616 } 1617 } 1618 1619 $monthEvents = $loadedMonths[$monthKey]; 1620 1621 if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) { 1622 $allEvents[$dateKey] = $monthEvents[$dateKey]; 1623 } 1624 } 1625 1626 // Sort events by date (already sorted by dateKey), then by time within each day 1627 foreach ($allEvents as $dateKey => &$dayEvents) { 1628 usort($dayEvents, function($a, $b) { 1629 $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null; 1630 $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null; 1631 1632 // All-day events (no time) go to the TOP 1633 if ($timeA === null && $timeB !== null) return -1; // A before B 1634 if ($timeA !== null && $timeB === null) return 1; // A after B 1635 if ($timeA === null && $timeB === null) return 0; // Both all-day, equal 1636 1637 // Both have times, sort chronologically 1638 return strcmp($timeA, $timeB); 1639 }); 1640 } 1641 unset($dayEvents); // Break reference 1642 1643 // Simple 2-line display widget 1644 $calId = 'eventlist_' . uniqid(); 1645 $theme = !empty($data['theme']) ? $data['theme'] : $this->getSidebarTheme(); 1646 $themeStyles = $this->getSidebarThemeStyles($theme); 1647 $isDark = in_array($theme, ['matrix', 'purple', 'pink']); 1648 $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg']; 1649 1650 // Theme class for CSS targeting 1651 $themeClass = 'eventlist-theme-' . $theme; 1652 1653 // Container styling - dark themes get border + glow, light themes get subtle border 1654 $containerStyle = 'background:' . $themeStyles['bg'] . ' !important;'; 1655 if ($isDark) { 1656 $containerStyle .= ' border:2px solid ' . $themeStyles['border'] . ';'; 1657 $containerStyle .= ' border-radius:4px;'; 1658 $containerStyle .= ' box-shadow:0 0 10px ' . $themeStyles['shadow'] . ';'; 1659 } else { 1660 $containerStyle .= ' border:1px solid ' . $themeStyles['grid_border'] . ';'; 1661 $containerStyle .= ' border-radius:4px;'; 1662 } 1663 1664 $html = '<div class="eventlist-simple ' . $themeClass . '" id="' . $calId . '" style="' . $containerStyle . '" data-exclude="' . htmlspecialchars($exclude) . '">'; 1665 1666 // Inject CSS variables for this eventlist instance 1667 $html .= '<style> 1668 #' . $calId . ' { 1669 --background-site: ' . $themeStyles['bg'] . '; 1670 --background-alt: ' . $themeStyles['cell_bg'] . '; 1671 --text-primary: ' . $themeStyles['text_primary'] . '; 1672 --text-dim: ' . $themeStyles['text_dim'] . '; 1673 --text-bright: ' . $themeStyles['text_bright'] . '; 1674 --border-color: ' . $themeStyles['grid_border'] . '; 1675 --border-main: ' . $themeStyles['border'] . '; 1676 --cell-bg: ' . $themeStyles['cell_bg'] . '; 1677 --cell-today-bg: ' . $themeStyles['cell_today_bg'] . '; 1678 --shadow-color: ' . $themeStyles['shadow'] . '; 1679 --grid-bg: ' . $themeStyles['grid_bg'] . '; 1680 --btn-text: ' . $btnTextColor . '; 1681 --pastdue-color: ' . $themeStyles['pastdue_color'] . '; 1682 --pastdue-bg: ' . $themeStyles['pastdue_bg'] . '; 1683 --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . '; 1684 --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . '; 1685 --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . '; 1686 --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . '; 1687 --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . '; 1688 } 1689 </style>'; 1690 1691 // Load calendar JavaScript manually (not through DokuWiki concatenation) 1692 $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>'; 1693 1694 // Initialize DOKU_BASE for JavaScript 1695 $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>'; 1696 1697 // Add compact header with date and clock for "today" mode (unless noheader is set) 1698 if ($today && !empty($allEvents) && !$noheader) { 1699 $todayDate = new DateTime(); 1700 $displayDate = $todayDate->format('D, M j, Y'); // "Fri, Jan 30, 2026" 1701 $currentTime = $todayDate->format('g:i:s A'); // "2:45:30 PM" 1702 1703 $html .= '<div class="eventlist-today-header">'; 1704 $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '">' . $currentTime . '</span>'; 1705 $html .= '<div class="eventlist-bottom-info">'; 1706 $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">️</span> <span id="weather-temp-' . $calId . '">--°</span></span>'; 1707 $html .= '<span class="eventlist-today-date">' . $displayDate . '</span>'; 1708 $html .= '</div>'; 1709 1710 $html .= '</div>'; 1711 1712 // Add JavaScript to update clock and weather 1713 $html .= '<script> 1714(function() { 1715 // Update clock every second 1716 function updateClock() { 1717 const now = new Date(); 1718 let hours = now.getHours(); 1719 const minutes = String(now.getMinutes()).padStart(2, "0"); 1720 const seconds = String(now.getSeconds()).padStart(2, "0"); 1721 const ampm = hours >= 12 ? "PM" : "AM"; 1722 hours = hours % 12 || 12; 1723 const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm; 1724 const clockEl = document.getElementById("clock-' . $calId . '"); 1725 if (clockEl) clockEl.textContent = timeStr; 1726 } 1727 setInterval(updateClock, 1000); 1728 1729 // Fetch weather - uses default location, click weather to get local 1730 var userLocationGranted = false; 1731 var userLat = 38.5816; // Sacramento default 1732 var userLon = -121.4944; 1733 1734 function fetchWeatherData(lat, lon) { 1735 fetch("https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon + "¤t_weather=true&temperature_unit=fahrenheit") 1736 .then(response => response.json()) 1737 .then(data => { 1738 if (data.current_weather) { 1739 const temp = Math.round(data.current_weather.temperature); 1740 const weatherCode = data.current_weather.weathercode; 1741 const icon = getWeatherIcon(weatherCode); 1742 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 1743 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 1744 if (iconEl) iconEl.textContent = icon; 1745 if (tempEl) tempEl.innerHTML = temp + "°"; 1746 } 1747 }) 1748 .catch(error => { 1749 console.log("Weather fetch error:", error); 1750 }); 1751 } 1752 1753 function updateWeather() { 1754 fetchWeatherData(userLat, userLon); 1755 } 1756 1757 // Allow user to click weather to get local weather (requires user gesture) 1758 function requestLocalWeather() { 1759 if (userLocationGranted) return; // Already have permission 1760 1761 if ("geolocation" in navigator) { 1762 navigator.geolocation.getCurrentPosition(function(position) { 1763 userLat = position.coords.latitude; 1764 userLon = position.coords.longitude; 1765 userLocationGranted = true; 1766 fetchWeatherData(userLat, userLon); 1767 }, function(error) { 1768 console.log("Geolocation denied or unavailable, using default location"); 1769 }); 1770 } 1771 } 1772 1773 // Add click handler to weather widget for local weather 1774 setTimeout(function() { 1775 var weatherEl = document.querySelector("#weather-icon-' . $calId . '"); 1776 if (weatherEl) { 1777 weatherEl.style.cursor = "pointer"; 1778 weatherEl.title = "Click for local weather"; 1779 weatherEl.addEventListener("click", requestLocalWeather); 1780 } 1781 }, 100); 1782 1783 // WMO Weather interpretation codes 1784 function getWeatherIcon(code) { 1785 const icons = { 1786 0: "☀️", // Clear sky 1787 1: "️", // Mainly clear 1788 2: "⛅", // Partly cloudy 1789 3: "☁️", // Overcast 1790 45: "️", // Fog 1791 48: "️", // Depositing rime fog 1792 51: "️", // Light drizzle 1793 53: "️", // Moderate drizzle 1794 55: "️", // Dense drizzle 1795 61: "️", // Slight rain 1796 63: "️", // Moderate rain 1797 65: "⛈️", // Heavy rain 1798 71: "️", // Slight snow 1799 73: "️", // Moderate snow 1800 75: "❄️", // Heavy snow 1801 77: "️", // Snow grains 1802 80: "️", // Slight rain showers 1803 81: "️", // Moderate rain showers 1804 82: "⛈️", // Violent rain showers 1805 85: "️", // Slight snow showers 1806 86: "❄️", // Heavy snow showers 1807 95: "⛈️", // Thunderstorm 1808 96: "⛈️", // Thunderstorm with slight hail 1809 99: "⛈️" // Thunderstorm with heavy hail 1810 }; 1811 return icons[code] || "️"; 1812 } 1813 1814 // Update weather immediately and every 10 minutes 1815 updateWeather(); 1816 setInterval(updateWeather, 600000); 1817})(); 1818</script>'; 1819 } 1820 1821 if (empty($allEvents)) { 1822 $html .= '<div class="eventlist-simple-empty">'; 1823 $html .= '<div class="eventlist-simple-header">' . htmlspecialchars($headerText); 1824 if ($namespace) { 1825 $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($namespace) . '</span>'; 1826 } 1827 $html .= '</div>'; 1828 $html .= '<div class="eventlist-simple-body">No events</div>'; 1829 $html .= '</div>'; 1830 } else { 1831 // Calculate today and tomorrow's dates for highlighting 1832 $todayStr = date('Y-m-d'); 1833 $tomorrow = date('Y-m-d', strtotime('+1 day')); 1834 1835 foreach ($allEvents as $dateKey => $dayEvents) { 1836 $dateObj = new DateTime($dateKey); 1837 $displayDate = $dateObj->format('D, M j'); 1838 1839 // Check if this date is today or tomorrow or past 1840 // Enable highlighting for sidebar mode AND range modes (day, week, month) 1841 $enableHighlighting = $sidebar || !empty($range); 1842 $isToday = $enableHighlighting && ($dateKey === $todayStr); 1843 $isTomorrow = $enableHighlighting && ($dateKey === $tomorrow); 1844 $isPast = $dateKey < $todayStr; 1845 1846 foreach ($dayEvents as $event) { 1847 // Check if this is a task and if it's completed 1848 $isTask = !empty($event['isTask']); 1849 $completed = !empty($event['completed']); 1850 1851 // ALWAYS skip completed tasks UNLESS showchecked is explicitly set 1852 if (!$showchecked && $isTask && $completed) { 1853 continue; 1854 } 1855 1856 // Skip past events that are NOT tasks (only show past due tasks from the past) 1857 if ($isPast && !$isTask) { 1858 continue; 1859 } 1860 1861 // Determine if task is past due (past date, is task, not completed) 1862 $isPastDue = $isPast && $isTask && !$completed; 1863 1864 // Line 1: Header (Title, Time, Date, Namespace) 1865 $todayClass = $isToday ? ' eventlist-simple-today' : ''; 1866 $tomorrowClass = $isTomorrow ? ' eventlist-simple-tomorrow' : ''; 1867 $pastDueClass = $isPastDue ? ' eventlist-simple-pastdue' : ''; 1868 $html .= '<div class="eventlist-simple-item' . $todayClass . $tomorrowClass . $pastDueClass . '">'; 1869 $html .= '<div class="eventlist-simple-header">'; 1870 1871 // Title 1872 $html .= '<span class="eventlist-simple-title">' . htmlspecialchars($event['title']) . '</span>'; 1873 1874 // Time (12-hour format) 1875 if (!empty($event['time'])) { 1876 $timeParts = explode(':', $event['time']); 1877 if (count($timeParts) === 2) { 1878 $hour = (int)$timeParts[0]; 1879 $minute = $timeParts[1]; 1880 $ampm = $hour >= 12 ? 'PM' : 'AM'; 1881 $hour = $hour % 12 ?: 12; 1882 $displayTime = $hour . ':' . $minute . ' ' . $ampm; 1883 $html .= ' <span class="eventlist-simple-time">' . $displayTime . '</span>'; 1884 } 1885 } 1886 1887 // Date 1888 $html .= ' <span class="eventlist-simple-date">' . $displayDate . '</span>'; 1889 1890 // Badge: PAST DUE, TODAY, or nothing 1891 if ($isPastDue) { 1892 $html .= ' <span class="eventlist-simple-pastdue-badge" style="background:' . $themeStyles['pastdue_color'] . ' !important; color:white !important; -webkit-text-fill-color:white !important;">PAST DUE</span>'; 1893 } elseif ($isToday) { 1894 $html .= ' <span class="eventlist-simple-today-badge" style="background:' . $themeStyles['border'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;">TODAY</span>'; 1895 } 1896 1897 // Namespace badge (show individual event's namespace) 1898 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 1899 if (!$eventNamespace && isset($event['_namespace'])) { 1900 $eventNamespace = $event['_namespace']; // Fallback to _namespace for multi-namespace loading 1901 } 1902 if ($eventNamespace) { 1903 $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($eventNamespace) . '</span>'; 1904 } 1905 1906 $html .= '</div>'; // header 1907 1908 // Line 2: Body (Description only) - only show if description exists 1909 if (!empty($event['description'])) { 1910 $html .= '<div class="eventlist-simple-body">' . $this->renderDescription($event['description']) . '</div>'; 1911 } 1912 1913 $html .= '</div>'; // item 1914 } 1915 } 1916 } 1917 1918 $html .= '</div>'; // eventlist-simple 1919 1920 return $html; 1921 } 1922 1923 private function renderEventDialog($calId, $namespace, $theme = null) { 1924 // Get theme for dialog 1925 if ($theme === null) { 1926 $theme = $this->getSidebarTheme(); 1927 } 1928 $themeStyles = $this->getSidebarThemeStyles($theme); 1929 1930 $html = '<div class="event-dialog-compact" id="dialog-' . $calId . '" style="display:none;">'; 1931 $html .= '<div class="dialog-overlay" onclick="closeEventDialog(\'' . $calId . '\')"></div>'; 1932 1933 // Draggable dialog with theme 1934 $html .= '<div class="dialog-content-sleek" id="dialog-content-' . $calId . '">'; 1935 1936 // Header with drag handle and close button 1937 $html .= '<div class="dialog-header-sleek dialog-drag-handle" id="drag-handle-' . $calId . '">'; 1938 $html .= '<h3 id="dialog-title-' . $calId . '">Add Event</h3>'; 1939 $html .= '<button type="button" class="dialog-close-btn" onclick="closeEventDialog(\'' . $calId . '\')">×</button>'; 1940 $html .= '</div>'; 1941 1942 // Form content 1943 $html .= '<form id="eventform-' . $calId . '" onsubmit="saveEventCompact(\'' . $calId . '\', \'' . $namespace . '\'); return false;" class="sleek-form">'; 1944 1945 // Hidden ID field 1946 $html .= '<input type="hidden" id="event-id-' . $calId . '" name="eventId" value="">'; 1947 1948 // 1. TITLE 1949 $html .= '<div class="form-field">'; 1950 $html .= '<label class="field-label"> Title</label>'; 1951 $html .= '<input type="text" id="event-title-' . $calId . '" name="title" required class="input-sleek input-compact" placeholder="Event or task title...">'; 1952 $html .= '</div>'; 1953 1954 // 1.5 NAMESPACE SELECTOR (Searchable with fuzzy matching) 1955 $html .= '<div class="form-field">'; 1956 $html .= '<label class="field-label"> Namespace</label>'; 1957 1958 // Hidden field to store actual selected namespace 1959 $html .= '<input type="hidden" id="event-namespace-' . $calId . '" name="namespace" value="">'; 1960 1961 // Searchable input 1962 $html .= '<div class="namespace-search-wrapper">'; 1963 $html .= '<input type="text" id="event-namespace-search-' . $calId . '" class="input-sleek input-compact namespace-search-input" placeholder="Type to search or leave empty for default..." autocomplete="off">'; 1964 $html .= '<div class="namespace-dropdown" id="event-namespace-dropdown-' . $calId . '" style="display:none;"></div>'; 1965 $html .= '</div>'; 1966 1967 // Store namespaces as JSON for JavaScript 1968 $allNamespaces = $this->getAllNamespaces(); 1969 $html .= '<script type="application/json" id="namespaces-data-' . $calId . '">' . json_encode($allNamespaces) . '</script>'; 1970 1971 $html .= '</div>'; 1972 1973 // 2. DESCRIPTION 1974 $html .= '<div class="form-field">'; 1975 $html .= '<label class="field-label"> Description</label>'; 1976 $html .= '<textarea id="event-desc-' . $calId . '" name="description" rows="2" class="input-sleek textarea-sleek textarea-compact" placeholder="Optional details..."></textarea>'; 1977 $html .= '</div>'; 1978 1979 // 3. START DATE - END DATE (inline) 1980 $html .= '<div class="form-row-group">'; 1981 1982 $html .= '<div class="form-field form-field-half">'; 1983 $html .= '<label class="field-label-compact"> Start Date</label>'; 1984 $html .= '<div class="date-picker-wrapper">'; 1985 $html .= '<input type="hidden" id="event-date-' . $calId . '" name="date" required value="">'; 1986 $html .= '<button type="button" class="custom-date-picker input-sleek input-compact" id="date-picker-btn-' . $calId . '" data-target="event-date-' . $calId . '" data-type="start">'; 1987 $html .= '<span class="date-display">Select date</span>'; 1988 $html .= '<span class="date-arrow">▼</span>'; 1989 $html .= '</button>'; 1990 $html .= '<div class="date-dropdown" id="date-dropdown-' . $calId . '"></div>'; 1991 $html .= '</div>'; 1992 $html .= '</div>'; 1993 1994 $html .= '<div class="form-field form-field-half">'; 1995 $html .= '<label class="field-label-compact"> End Date</label>'; 1996 $html .= '<div class="date-picker-wrapper">'; 1997 $html .= '<input type="hidden" id="event-end-date-' . $calId . '" name="endDate" value="">'; 1998 $html .= '<button type="button" class="custom-date-picker input-sleek input-compact" id="end-date-picker-btn-' . $calId . '" data-target="event-end-date-' . $calId . '" data-type="end">'; 1999 $html .= '<span class="date-display">Optional</span>'; 2000 $html .= '<span class="date-arrow">▼</span>'; 2001 $html .= '</button>'; 2002 $html .= '<div class="date-dropdown" id="end-date-dropdown-' . $calId . '"></div>'; 2003 $html .= '</div>'; 2004 $html .= '</div>'; 2005 2006 $html .= '</div>'; // End row 2007 2008 // 4. IS REPEATING CHECKBOX 2009 $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">'; 2010 $html .= '<label class="checkbox-label checkbox-label-compact">'; 2011 $html .= '<input type="checkbox" id="event-recurring-' . $calId . '" name="isRecurring" class="recurring-toggle" onchange="toggleRecurringOptions(\'' . $calId . '\')">'; 2012 $html .= '<span> Repeating Event</span>'; 2013 $html .= '</label>'; 2014 $html .= '</div>'; 2015 2016 // Recurring options (shown when checkbox is checked) 2017 $html .= '<div id="recurring-options-' . $calId . '" class="recurring-options" style="display:none; border:1px solid var(--border-color, #333); border-radius:4px; padding:8px; margin:4px 0; background:var(--background-alt, rgba(0,0,0,0.2));">'; 2018 2019 // Row 1: Repeat every [N] [period] 2020 $html .= '<div class="form-row-group" style="margin-bottom:6px;">'; 2021 2022 $html .= '<div class="form-field" style="flex:0 0 auto; min-width:0;">'; 2023 $html .= '<label class="field-label-compact">Repeat every</label>'; 2024 $html .= '<input type="number" id="event-recurrence-interval-' . $calId . '" name="recurrenceInterval" class="input-sleek input-compact" value="1" min="1" max="99" style="width:50px;">'; 2025 $html .= '</div>'; 2026 2027 $html .= '<div class="form-field" style="flex:1; min-width:0;">'; 2028 $html .= '<label class="field-label-compact"> </label>'; 2029 $html .= '<select id="event-recurrence-type-' . $calId . '" name="recurrenceType" class="input-sleek input-compact" onchange="updateRecurrenceOptions(\'' . $calId . '\')">'; 2030 $html .= '<option value="daily">Day(s)</option>'; 2031 $html .= '<option value="weekly">Week(s)</option>'; 2032 $html .= '<option value="monthly">Month(s)</option>'; 2033 $html .= '<option value="yearly">Year(s)</option>'; 2034 $html .= '</select>'; 2035 $html .= '</div>'; 2036 2037 $html .= '</div>'; // End row 1 2038 2039 // Row 2: Weekly options - day of week checkboxes 2040 $html .= '<div id="weekly-options-' . $calId . '" class="weekly-options" style="display:none; margin-bottom:6px;">'; 2041 $html .= '<label class="field-label-compact" style="display:block; margin-bottom:4px;">On these days:</label>'; 2042 $html .= '<div style="display:flex; flex-wrap:wrap; gap:2px;">'; 2043 $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 2044 foreach ($dayNames as $idx => $day) { 2045 $html .= '<label style="display:inline-flex; align-items:center; padding:2px 6px; background:var(--cell-bg, #1a1a1a); border:1px solid var(--border-color, #333); border-radius:3px; cursor:pointer; font-size:10px;">'; 2046 $html .= '<input type="checkbox" name="weekDays[]" value="' . $idx . '" style="margin-right:3px; width:12px; height:12px;">'; 2047 $html .= '<span>' . $day . '</span>'; 2048 $html .= '</label>'; 2049 } 2050 $html .= '</div>'; 2051 $html .= '</div>'; // End weekly options 2052 2053 // Row 3: Monthly options - day of month OR ordinal weekday 2054 $html .= '<div id="monthly-options-' . $calId . '" class="monthly-options" style="display:none; margin-bottom:6px;">'; 2055 $html .= '<label class="field-label-compact" style="display:block; margin-bottom:4px;">Repeat on:</label>'; 2056 2057 // Radio: Day of month vs Ordinal weekday 2058 $html .= '<div style="margin-bottom:6px;">'; 2059 $html .= '<label style="display:inline-flex; align-items:center; margin-right:12px; cursor:pointer; font-size:11px;">'; 2060 $html .= '<input type="radio" name="monthlyType" value="dayOfMonth" checked onchange="updateMonthlyType(\'' . $calId . '\')" style="margin-right:4px;">'; 2061 $html .= 'Day of month'; 2062 $html .= '</label>'; 2063 $html .= '<label style="display:inline-flex; align-items:center; cursor:pointer; font-size:11px;">'; 2064 $html .= '<input type="radio" name="monthlyType" value="ordinalWeekday" onchange="updateMonthlyType(\'' . $calId . '\')" style="margin-right:4px;">'; 2065 $html .= 'Weekday pattern'; 2066 $html .= '</label>'; 2067 $html .= '</div>'; 2068 2069 // Day of month input (shown by default) 2070 $html .= '<div id="monthly-day-' . $calId . '" style="display:flex; align-items:center; gap:6px;">'; 2071 $html .= '<span style="font-size:11px;">Day</span>'; 2072 $html .= '<input type="number" id="event-month-day-' . $calId . '" name="monthDay" class="input-sleek input-compact" value="1" min="1" max="31" style="width:50px;">'; 2073 $html .= '<span style="font-size:10px; color:var(--text-dim, #666);">of each month</span>'; 2074 $html .= '</div>'; 2075 2076 // Ordinal weekday (hidden by default) 2077 $html .= '<div id="monthly-ordinal-' . $calId . '" style="display:none;">'; 2078 $html .= '<div style="display:flex; align-items:center; gap:4px; flex-wrap:wrap;">'; 2079 $html .= '<select id="event-ordinal-' . $calId . '" name="ordinalWeek" class="input-sleek input-compact" style="width:auto;">'; 2080 $html .= '<option value="1">First</option>'; 2081 $html .= '<option value="2">Second</option>'; 2082 $html .= '<option value="3">Third</option>'; 2083 $html .= '<option value="4">Fourth</option>'; 2084 $html .= '<option value="5">Fifth</option>'; 2085 $html .= '<option value="-1">Last</option>'; 2086 $html .= '</select>'; 2087 $html .= '<select id="event-ordinal-day-' . $calId . '" name="ordinalDay" class="input-sleek input-compact" style="width:auto;">'; 2088 $html .= '<option value="0">Sunday</option>'; 2089 $html .= '<option value="1">Monday</option>'; 2090 $html .= '<option value="2">Tuesday</option>'; 2091 $html .= '<option value="3">Wednesday</option>'; 2092 $html .= '<option value="4">Thursday</option>'; 2093 $html .= '<option value="5">Friday</option>'; 2094 $html .= '<option value="6">Saturday</option>'; 2095 $html .= '</select>'; 2096 $html .= '<span style="font-size:10px; color:var(--text-dim, #666);">of each month</span>'; 2097 $html .= '</div>'; 2098 $html .= '</div>'; 2099 2100 $html .= '</div>'; // End monthly options 2101 2102 // Row 4: End date 2103 $html .= '<div class="form-row-group">'; 2104 $html .= '<div class="form-field">'; 2105 $html .= '<label class="field-label-compact">Repeat Until (optional)</label>'; 2106 $html .= '<input type="date" id="event-recurrence-end-' . $calId . '" name="recurrenceEnd" class="input-sleek input-date input-compact" placeholder="Optional">'; 2107 $html .= '<div style="font-size:9px; color:var(--text-dim, #666); margin-top:2px;">Leave empty for 1 year of events</div>'; 2108 $html .= '</div>'; 2109 $html .= '</div>'; // End row 4 2110 2111 $html .= '</div>'; // End recurring options 2112 2113 // 5. TIME (Start & End) - COLOR (inline) 2114 $html .= '<div class="form-row-group">'; 2115 2116 $html .= '<div class="form-field form-field-half">'; 2117 $html .= '<label class="field-label-compact"> Start Time</label>'; 2118 $html .= '<div class="time-picker-wrapper">'; 2119 // Custom time picker button instead of native select 2120 $html .= '<input type="hidden" id="event-time-' . $calId . '" name="time" value="">'; 2121 $html .= '<button type="button" class="custom-time-picker input-sleek input-compact" id="time-picker-btn-' . $calId . '" data-target="event-time-' . $calId . '" data-type="start">'; 2122 $html .= '<span class="time-display">All day</span>'; 2123 $html .= '<span class="time-arrow">▼</span>'; 2124 $html .= '</button>'; 2125 $html .= '<div class="time-dropdown" id="time-dropdown-' . $calId . '"></div>'; 2126 $html .= '</div>'; 2127 $html .= '</div>'; 2128 2129 $html .= '<div class="form-field form-field-half">'; 2130 $html .= '<label class="field-label-compact"> End Time</label>'; 2131 $html .= '<div class="time-picker-wrapper">'; 2132 // Custom end time picker 2133 $html .= '<input type="hidden" id="event-end-time-' . $calId . '" name="endTime" value="">'; 2134 $html .= '<button type="button" class="custom-time-picker input-sleek input-compact" id="end-time-picker-btn-' . $calId . '" data-target="event-end-time-' . $calId . '" data-type="end" disabled>'; 2135 $html .= '<span class="time-display">Same as start</span>'; 2136 $html .= '<span class="time-arrow">▼</span>'; 2137 $html .= '</button>'; 2138 $html .= '<div class="time-dropdown" id="end-time-dropdown-' . $calId . '"></div>'; 2139 $html .= '</div>'; 2140 $html .= '</div>'; 2141 2142 $html .= '</div>'; // End row 2143 2144 // Color field (new row) 2145 $html .= '<div class="form-row-group">'; 2146 2147 $html .= '<div class="form-field form-field-full">'; 2148 $html .= '<label class="field-label-compact"> Color</label>'; 2149 $html .= '<div class="color-picker-wrapper">'; 2150 $html .= '<select id="event-color-' . $calId . '" name="color" class="input-sleek input-compact color-select" onchange="updateCustomColorPicker(\'' . $calId . '\')">'; 2151 $html .= '<option value="#3498db" style="background:#3498db;color:white"> Blue</option>'; 2152 $html .= '<option value="#2ecc71" style="background:#2ecc71;color:white"> Green</option>'; 2153 $html .= '<option value="#e74c3c" style="background:#e74c3c;color:white"> Red</option>'; 2154 $html .= '<option value="#f39c12" style="background:#f39c12;color:white"> Orange</option>'; 2155 $html .= '<option value="#9b59b6" style="background:#9b59b6;color:white"> Purple</option>'; 2156 $html .= '<option value="#e91e63" style="background:#e91e63;color:white"> Pink</option>'; 2157 $html .= '<option value="#1abc9c" style="background:#1abc9c;color:white"> Teal</option>'; 2158 $html .= '<option value="custom"> Custom...</option>'; 2159 $html .= '</select>'; 2160 $html .= '<input type="color" id="event-color-custom-' . $calId . '" class="color-picker-input color-picker-compact" value="#3498db" onchange="updateColorFromPicker(\'' . $calId . '\')">'; 2161 $html .= '</div>'; 2162 $html .= '</div>'; 2163 2164 $html .= '</div>'; // End row 2165 2166 // Task checkbox 2167 $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">'; 2168 $html .= '<label class="checkbox-label checkbox-label-compact">'; 2169 $html .= '<input type="checkbox" id="event-is-task-' . $calId . '" name="isTask" class="task-toggle">'; 2170 $html .= '<span> This is a task (can be checked off)</span>'; 2171 $html .= '</label>'; 2172 $html .= '</div>'; 2173 2174 // Action buttons 2175 $html .= '<div class="dialog-actions-sleek">'; 2176 $html .= '<button type="button" class="btn-sleek btn-cancel-sleek" onclick="closeEventDialog(\'' . $calId . '\')">Cancel</button>'; 2177 $html .= '<button type="submit" class="btn-sleek btn-save-sleek"> Save</button>'; 2178 $html .= '</div>'; 2179 2180 $html .= '</form>'; 2181 $html .= '</div>'; 2182 $html .= '</div>'; 2183 2184 return $html; 2185 } 2186 2187 private function renderMonthPicker($calId, $year, $month, $namespace, $theme = 'matrix', $themeStyles = null) { 2188 // Fallback to default theme if not provided 2189 if ($themeStyles === null) { 2190 $themeStyles = $this->getSidebarThemeStyles($theme); 2191 } 2192 2193 $themeClass = 'calendar-theme-' . $theme; 2194 2195 $html = '<div class="month-picker-overlay ' . $themeClass . '" id="month-picker-overlay-' . $calId . '" style="display:none;" onclick="closeMonthPicker(\'' . $calId . '\')">'; 2196 $html .= '<div class="month-picker-dialog" onclick="event.stopPropagation();">'; 2197 $html .= '<h4>Jump to Month</h4>'; 2198 2199 $html .= '<div class="month-picker-selects">'; 2200 $html .= '<select id="month-picker-month-' . $calId . '" class="month-picker-select">'; 2201 $monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; 2202 for ($m = 1; $m <= 12; $m++) { 2203 $selected = ($m == $month) ? ' selected' : ''; 2204 $html .= '<option value="' . $m . '"' . $selected . '>' . $monthNames[$m - 1] . '</option>'; 2205 } 2206 $html .= '</select>'; 2207 2208 $html .= '<select id="month-picker-year-' . $calId . '" class="month-picker-select">'; 2209 $currentYear = (int)date('Y'); 2210 for ($y = $currentYear - 5; $y <= $currentYear + 5; $y++) { 2211 $selected = ($y == $year) ? ' selected' : ''; 2212 $html .= '<option value="' . $y . '"' . $selected . '>' . $y . '</option>'; 2213 } 2214 $html .= '</select>'; 2215 $html .= '</div>'; 2216 2217 $html .= '<div class="month-picker-actions">'; 2218 $html .= '<button class="btn-sleek btn-cancel-sleek" onclick="closeMonthPicker(\'' . $calId . '\')">Cancel</button>'; 2219 $html .= '<button class="btn-sleek btn-save-sleek" onclick="jumpToSelectedMonth(\'' . $calId . '\', \'' . $namespace . '\')">Go</button>'; 2220 $html .= '</div>'; 2221 2222 $html .= '</div>'; 2223 $html .= '</div>'; 2224 2225 return $html; 2226 } 2227 2228 private function renderDescription($description, $themeStyles = null) { 2229 if (empty($description)) { 2230 return ''; 2231 } 2232 2233 // Get theme for link colors if not provided 2234 if ($themeStyles === null) { 2235 $theme = $this->getSidebarTheme(); 2236 $themeStyles = $this->getSidebarThemeStyles($theme); 2237 } 2238 2239 $linkColor = ''; 2240 $linkStyle = ' class="cal-link"'; 2241 2242 // Token-based parsing to avoid escaping issues 2243 $rendered = $description; 2244 $tokens = array(); 2245 $tokenIndex = 0; 2246 2247 // Convert DokuWiki image syntax {{image.jpg}} to tokens 2248 $pattern = '/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/'; 2249 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 2250 foreach ($matches as $match) { 2251 $imagePath = trim($match[1]); 2252 $alt = isset($match[2]) ? trim($match[2]) : ''; 2253 2254 // Handle external URLs 2255 if (preg_match('/^https?:\/\//', $imagePath)) { 2256 $imageHtml = '<img src="' . htmlspecialchars($imagePath) . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />'; 2257 } else { 2258 // Handle internal DokuWiki images 2259 $imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath); 2260 $imageHtml = '<img src="' . $imageUrl . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />'; 2261 } 2262 2263 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 2264 $tokens[$tokenIndex] = $imageHtml; 2265 $tokenIndex++; 2266 $rendered = str_replace($match[0], $token, $rendered); 2267 } 2268 2269 // Convert DokuWiki link syntax [[link|text]] to tokens 2270 $pattern = '/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/'; 2271 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 2272 foreach ($matches as $match) { 2273 $link = trim($match[1]); 2274 $text = isset($match[2]) ? trim($match[2]) : $link; 2275 2276 // Handle external URLs 2277 if (preg_match('/^https?:\/\//', $link)) { 2278 $linkHtml = '<a href="' . htmlspecialchars($link) . '" target="_blank" rel="noopener noreferrer"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>'; 2279 } else { 2280 // Handle internal DokuWiki links with section anchors 2281 $parts = explode('#', $link, 2); 2282 $pagePart = $parts[0]; 2283 $sectionPart = isset($parts[1]) ? '#' . $parts[1] : ''; 2284 2285 $wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($pagePart) . $sectionPart; 2286 $linkHtml = '<a href="' . $wikiUrl . '"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>'; 2287 } 2288 2289 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 2290 $tokens[$tokenIndex] = $linkHtml; 2291 $tokenIndex++; 2292 $rendered = str_replace($match[0], $token, $rendered); 2293 } 2294 2295 // Convert markdown-style links [text](url) to tokens 2296 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/'; 2297 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 2298 foreach ($matches as $match) { 2299 $text = trim($match[1]); 2300 $url = trim($match[2]); 2301 2302 if (preg_match('/^https?:\/\//', $url)) { 2303 $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>'; 2304 } else { 2305 $linkHtml = '<a href="' . htmlspecialchars($url) . '"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>'; 2306 } 2307 2308 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 2309 $tokens[$tokenIndex] = $linkHtml; 2310 $tokenIndex++; 2311 $rendered = str_replace($match[0], $token, $rendered); 2312 } 2313 2314 // Convert plain URLs to tokens 2315 $pattern = '/(https?:\/\/[^\s<]+)/'; 2316 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 2317 foreach ($matches as $match) { 2318 $url = $match[1]; 2319 $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer"' . $linkStyle . '>' . htmlspecialchars($url) . '</a>'; 2320 2321 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 2322 $tokens[$tokenIndex] = $linkHtml; 2323 $tokenIndex++; 2324 $rendered = str_replace($match[0], $token, $rendered); 2325 } 2326 2327 // NOW escape HTML (tokens are protected) 2328 $rendered = htmlspecialchars($rendered); 2329 2330 // Convert newlines to <br> 2331 $rendered = nl2br($rendered); 2332 2333 // DokuWiki text formatting 2334 // Bold: **text** or __text__ 2335 $boldStyle = ''; 2336 $rendered = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $rendered); 2337 $rendered = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $rendered); 2338 2339 // Italic: //text// 2340 $rendered = preg_replace('/\/\/(.+?)\/\//', '<em>$1</em>', $rendered); 2341 2342 // Strikethrough: <del>text</del> 2343 $rendered = preg_replace('/<del>(.+?)<\/del>/', '<del>$1</del>', $rendered); 2344 2345 // Monospace: ''text'' 2346 $rendered = preg_replace('/''(.+?)''/', '<code>$1</code>', $rendered); 2347 2348 // Subscript: <sub>text</sub> 2349 $rendered = preg_replace('/<sub>(.+?)<\/sub>/', '<sub>$1</sub>', $rendered); 2350 2351 // Superscript: <sup>text</sup> 2352 $rendered = preg_replace('/<sup>(.+?)<\/sup>/', '<sup>$1</sup>', $rendered); 2353 2354 // Restore tokens 2355 foreach ($tokens as $i => $html) { 2356 $rendered = str_replace("\x00TOKEN" . $i . "\x00", $html, $rendered); 2357 } 2358 2359 return $rendered; 2360 } 2361 2362 private function loadEvents($namespace, $year, $month) { 2363 $dataDir = $this->metaDir(); 2364 if ($namespace) { 2365 $dataDir .= str_replace(':', '/', $namespace) . '/'; 2366 } 2367 $dataDir .= 'calendar/'; 2368 2369 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 2370 2371 if (file_exists($eventFile)) { 2372 $json = file_get_contents($eventFile); 2373 return json_decode($json, true); 2374 } 2375 2376 return array(); 2377 } 2378 2379 private function loadEventsMultiNamespace($namespaces, $year, $month, $excludeList = []) { 2380 // Check for wildcard pattern (namespace:*) 2381 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 2382 $baseNamespace = $matches[1]; 2383 return $this->loadEventsWildcard($baseNamespace, $year, $month, $excludeList); 2384 } 2385 2386 // Check for root wildcard (just *) 2387 if ($namespaces === '*') { 2388 return $this->loadEventsWildcard('', $year, $month, $excludeList); 2389 } 2390 2391 // Parse namespace list (semicolon separated) 2392 // e.g., "team:projects;personal;work:tasks" = three namespaces 2393 $namespaceList = array_map('trim', explode(';', $namespaces)); 2394 2395 // Load events from all namespaces 2396 $allEvents = array(); 2397 foreach ($namespaceList as $ns) { 2398 $ns = trim($ns); 2399 if (empty($ns)) continue; 2400 2401 // Skip excluded namespaces 2402 if ($this->isNamespaceExcluded($ns, $excludeList)) continue; 2403 2404 // ACL check: skip namespaces user cannot read 2405 if (!$this->checkNamespaceRead($ns)) continue; 2406 2407 $events = $this->loadEvents($ns, $year, $month); 2408 2409 // Add namespace tag to each event 2410 foreach ($events as $dateKey => $dayEvents) { 2411 if (!isset($allEvents[$dateKey])) { 2412 $allEvents[$dateKey] = array(); 2413 } 2414 foreach ($dayEvents as $event) { 2415 $event['_namespace'] = $ns; 2416 $allEvents[$dateKey][] = $event; 2417 } 2418 } 2419 } 2420 2421 return $allEvents; 2422 } 2423 2424 private function loadEventsWildcard($baseNamespace, $year, $month, $excludeList = []) { 2425 $metaDir = $this->metaDir(); 2426 $dataDir = $metaDir; 2427 if ($baseNamespace) { 2428 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 2429 } 2430 2431 $allEvents = array(); 2432 2433 // Load events from the base namespace itself 2434 if (empty($baseNamespace)) { 2435 $events = $this->loadEvents('', $year, $month); 2436 foreach ($events as $dateKey => $dayEvents) { 2437 if (!isset($allEvents[$dateKey])) { 2438 $allEvents[$dateKey] = array(); 2439 } 2440 foreach ($dayEvents as $event) { 2441 $event['_namespace'] = ''; 2442 $allEvents[$dateKey][] = $event; 2443 } 2444 } 2445 } else { 2446 if (!$this->isNamespaceExcluded($baseNamespace, $excludeList) && $this->checkNamespaceRead($baseNamespace)) { 2447 $events = $this->loadEvents($baseNamespace, $year, $month); 2448 foreach ($events as $dateKey => $dayEvents) { 2449 if (!isset($allEvents[$dateKey])) { 2450 $allEvents[$dateKey] = array(); 2451 } 2452 foreach ($dayEvents as $event) { 2453 $event['_namespace'] = $baseNamespace; 2454 $allEvents[$dateKey][] = $event; 2455 } 2456 } 2457 } 2458 } 2459 2460 // Find all calendar directories efficiently using iterative glob 2461 // This avoids recursing into every directory in data/meta (thousands on large wikis) 2462 $this->findCalendarNamespaces($dataDir, $metaDir, $year, $month, $allEvents, $excludeList); 2463 2464 return $allEvents; 2465 } 2466 2467 /** 2468 * Find namespaces with calendar data using iterative glob 2469 * Searches for 'calendar/' directories at increasing depth without 2470 * scanning every directory in data/meta 2471 */ 2472 private function findCalendarNamespaces($baseDir, $metaDir, $year, $month, &$allEvents, $excludeList = []) { 2473 if (!is_dir($baseDir)) return; 2474 2475 // Use glob at increasing depths to find 'calendar' directories 2476 // This is vastly more efficient than recursive scandir on large wikis 2477 $maxDepth = 10; 2478 $metaDirLen = strlen($metaDir); 2479 2480 for ($depth = 1; $depth <= $maxDepth; $depth++) { 2481 $pattern = $baseDir . str_repeat('*/', $depth) . 'calendar'; 2482 $calDirs = glob($pattern, GLOB_ONLYDIR); 2483 2484 if (empty($calDirs)) { 2485 // No calendar dirs at this depth or deeper - stop early 2486 // (only if we also found none at previous depths) 2487 if ($depth > 3) break; 2488 continue; 2489 } 2490 2491 foreach ($calDirs as $calDir) { 2492 // Derive namespace from the parent directory of 'calendar/' 2493 $nsDir = dirname($calDir); 2494 $relPath = substr($nsDir, $metaDirLen); 2495 $namespace = str_replace('/', ':', trim($relPath, '/')); 2496 2497 // Skip the root namespace (already handled above) 2498 if (empty($namespace)) continue; 2499 2500 // Skip excluded namespaces 2501 if ($this->isNamespaceExcluded($namespace, $excludeList)) continue; 2502 2503 // ACL check 2504 if (!$this->checkNamespaceRead($namespace)) continue; 2505 2506 $events = $this->loadEvents($namespace, $year, $month); 2507 foreach ($events as $dateKey => $dayEvents) { 2508 if (!isset($allEvents[$dateKey])) { 2509 $allEvents[$dateKey] = array(); 2510 } 2511 foreach ($dayEvents as $event) { 2512 $event['_namespace'] = $namespace; 2513 $allEvents[$dateKey][] = $event; 2514 } 2515 } 2516 } 2517 } 2518 } 2519 2520 private function getAllNamespaces() { 2521 $dataDir = $this->metaDir(); 2522 $namespaces = []; 2523 2524 // Scan for namespaces that have calendar data 2525 $this->scanForCalendarNamespaces($dataDir, '', $namespaces); 2526 2527 // Sort alphabetically 2528 sort($namespaces); 2529 2530 return $namespaces; 2531 } 2532 2533 private function scanForCalendarNamespaces($dir, $baseNamespace, &$namespaces) { 2534 if (!is_dir($dir)) return; 2535 2536 $items = scandir($dir); 2537 foreach ($items as $item) { 2538 if ($item === '.' || $item === '..') continue; 2539 2540 $path = $dir . $item; 2541 if (is_dir($path)) { 2542 // Check if this directory has a calendar subdirectory with data 2543 $calendarDir = $path . '/calendar/'; 2544 if (is_dir($calendarDir)) { 2545 // Check if there are any JSON files in the calendar directory 2546 $jsonFiles = glob($calendarDir . '*.json'); 2547 if (!empty($jsonFiles)) { 2548 // This namespace has calendar data 2549 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 2550 $namespaces[] = $namespace; 2551 } 2552 } 2553 2554 // Recurse into subdirectories 2555 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 2556 $this->scanForCalendarNamespaces($path . '/', $namespace, $namespaces); 2557 } 2558 } 2559 } 2560 2561 /** 2562 * Render new sidebar widget - Week at a glance itinerary (200px wide) 2563 */ 2564 private function renderSidebarWidget($events, $namespace, $calId, $themeOverride = null) { 2565 if (empty($events)) { 2566 return '<div style="width:200px; padding:12px; text-align:center; color:#999; font-size:11px;">No events this week</div>'; 2567 } 2568 2569 // Get important namespaces from config 2570 $configFile = $this->syncConfigPath(); 2571 $importantNsList = ['important']; // default 2572 if (file_exists($configFile)) { 2573 $config = include $configFile; 2574 if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) { 2575 $importantNsList = array_map('trim', explode(',', $config['important_namespaces'])); 2576 } 2577 } 2578 2579 // Calculate date ranges 2580 $todayStr = date('Y-m-d'); 2581 $tomorrowStr = date('Y-m-d', strtotime('+1 day')); 2582 2583 // Get week start preference and calculate week range 2584 $weekStartDay = $this->getWeekStartDay(); 2585 2586 if ($weekStartDay === 'monday') { 2587 // Monday start 2588 $weekStart = date('Y-m-d', strtotime('monday this week')); 2589 $weekEnd = date('Y-m-d', strtotime('sunday this week')); 2590 } else { 2591 // Sunday start (default - US/Canada standard) 2592 $today = date('w'); // 0 (Sun) to 6 (Sat) 2593 if ($today == 0) { 2594 // Today is Sunday 2595 $weekStart = date('Y-m-d'); 2596 } else { 2597 // Monday-Saturday: go back to last Sunday 2598 $weekStart = date('Y-m-d', strtotime('-' . $today . ' days')); 2599 } 2600 $weekEnd = date('Y-m-d', strtotime($weekStart . ' +6 days')); 2601 } 2602 2603 // Group events by category 2604 $todayEvents = []; 2605 $tomorrowEvents = []; 2606 $importantEvents = []; 2607 $weekEvents = []; // For week grid 2608 2609 // Process all events 2610 foreach ($events as $dateKey => $dayEvents) { 2611 // Detect conflicts for events on this day 2612 $eventsWithConflicts = $this->detectTimeConflicts($dayEvents); 2613 2614 foreach ($eventsWithConflicts as $event) { 2615 // Always categorize Today and Tomorrow regardless of week boundaries 2616 if ($dateKey === $todayStr) { 2617 $todayEvents[] = array_merge($event, ['date' => $dateKey]); 2618 } 2619 if ($dateKey === $tomorrowStr) { 2620 $tomorrowEvents[] = array_merge($event, ['date' => $dateKey]); 2621 } 2622 2623 // Process week grid events (only for current week) 2624 if ($dateKey >= $weekStart && $dateKey <= $weekEnd) { 2625 // Initialize week grid day if not exists 2626 if (!isset($weekEvents[$dateKey])) { 2627 $weekEvents[$dateKey] = []; 2628 } 2629 2630 // Pre-render DokuWiki syntax to HTML for JavaScript display 2631 $eventWithHtml = $event; 2632 if (isset($event['title'])) { 2633 $eventWithHtml['title_html'] = $this->renderDokuWikiToHtml($event['title']); 2634 } 2635 if (isset($event['description'])) { 2636 $eventWithHtml['description_html'] = $this->renderDokuWikiToHtml($event['description']); 2637 } 2638 $weekEvents[$dateKey][] = $eventWithHtml; 2639 } 2640 2641 // Check if this is an important namespace 2642 $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; 2643 $isImportant = false; 2644 foreach ($importantNsList as $impNs) { 2645 if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) { 2646 $isImportant = true; 2647 break; 2648 } 2649 } 2650 2651 // Important events: show from today through next 2 weeks 2652 if ($isImportant && $dateKey >= $todayStr) { 2653 $importantEvents[] = array_merge($event, ['date' => $dateKey]); 2654 } 2655 } 2656 } 2657 2658 // Sort Important Events by date (earliest first) 2659 usort($importantEvents, function($a, $b) { 2660 $dateA = isset($a['date']) ? $a['date'] : ''; 2661 $dateB = isset($b['date']) ? $b['date'] : ''; 2662 2663 // Compare dates 2664 if ($dateA === $dateB) { 2665 // Same date - sort by time 2666 $timeA = isset($a['time']) ? $a['time'] : ''; 2667 $timeB = isset($b['time']) ? $b['time'] : ''; 2668 2669 if (empty($timeA) && !empty($timeB)) return 1; // All-day events last 2670 if (!empty($timeA) && empty($timeB)) return -1; 2671 if (empty($timeA) && empty($timeB)) return 0; 2672 2673 // Both have times 2674 $aMinutes = $this->timeToMinutes($timeA); 2675 $bMinutes = $this->timeToMinutes($timeB); 2676 return $aMinutes - $bMinutes; 2677 } 2678 2679 return strcmp($dateA, $dateB); 2680 }); 2681 2682 // Get theme - prefer override from syntax parameter, fall back to admin default 2683 $theme = !empty($themeOverride) ? $themeOverride : $this->getSidebarTheme(); 2684 $themeStyles = $this->getSidebarThemeStyles($theme); 2685 $themeClass = 'sidebar-' . $theme; 2686 2687 // Start building HTML - Dynamic width with default font (overflow:visible for tooltips) 2688 $html = '<div class="sidebar-widget ' . $themeClass . '" id="sidebar-widget-' . $calId . '" style="width:100%; max-width:100%; box-sizing:border-box; font-family:system-ui, sans-serif; background:' . $themeStyles['bg'] . '; border:2px solid ' . $themeStyles['border'] . '; border-radius:4px; overflow:visible; box-shadow:0 0 10px ' . $themeStyles['shadow'] . '; position:relative;">'; 2689 2690 // Inject CSS variables so the event dialog (shared component) picks up the theme 2691 $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg']; 2692 $html .= '<style> 2693 #sidebar-widget-' . $calId . ' { 2694 --background-site: ' . $themeStyles['bg'] . '; 2695 --background-alt: ' . $themeStyles['cell_bg'] . '; 2696 --background-header: ' . $themeStyles['header_bg'] . '; 2697 --text-primary: ' . $themeStyles['text_primary'] . '; 2698 --text-dim: ' . $themeStyles['text_dim'] . '; 2699 --text-bright: ' . $themeStyles['text_bright'] . '; 2700 --border-color: ' . $themeStyles['grid_border'] . '; 2701 --border-main: ' . $themeStyles['border'] . '; 2702 --cell-bg: ' . $themeStyles['cell_bg'] . '; 2703 --cell-today-bg: ' . $themeStyles['cell_today_bg'] . '; 2704 --shadow-color: ' . $themeStyles['shadow'] . '; 2705 --header-border: ' . $themeStyles['header_border'] . '; 2706 --header-shadow: ' . $themeStyles['header_shadow'] . '; 2707 --grid-bg: ' . $themeStyles['grid_bg'] . '; 2708 --btn-text: ' . $btnTextColor . '; 2709 --pastdue-color: ' . $themeStyles['pastdue_color'] . '; 2710 --pastdue-bg: ' . $themeStyles['pastdue_bg'] . '; 2711 --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . '; 2712 --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . '; 2713 --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . '; 2714 --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . '; 2715 --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . '; 2716 } 2717 </style>'; 2718 2719 // Add sparkle effect for pink theme 2720 if ($theme === 'pink') { 2721 $html .= '<style> 2722 @keyframes sparkle-' . $calId . ' { 2723 0% { 2724 opacity: 0; 2725 transform: translate(0, 0) scale(0) rotate(0deg); 2726 } 2727 50% { 2728 opacity: 1; 2729 transform: translate(var(--tx), var(--ty)) scale(1) rotate(180deg); 2730 } 2731 100% { 2732 opacity: 0; 2733 transform: translate(calc(var(--tx) * 2), calc(var(--ty) * 2)) scale(0) rotate(360deg); 2734 } 2735 } 2736 2737 @keyframes pulse-glow-' . $calId . ' { 2738 0%, 100% { box-shadow: 0 0 10px rgba(255, 20, 147, 0.4); } 2739 50% { box-shadow: 0 0 25px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4); } 2740 } 2741 2742 @keyframes shimmer-' . $calId . ' { 2743 0% { background-position: -200% center; } 2744 100% { background-position: 200% center; } 2745 } 2746 2747 .sidebar-pink { 2748 animation: pulse-glow-' . $calId . ' 3s ease-in-out infinite; 2749 } 2750 2751 .sidebar-pink:hover { 2752 box-shadow: 0 0 30px rgba(255, 20, 147, 0.9), 0 0 50px rgba(255, 20, 147, 0.5) !important; 2753 } 2754 2755 .sparkle-' . $calId . ' { 2756 position: absolute; 2757 pointer-events: none; 2758 font-size: 20px; 2759 z-index: 1000; 2760 animation: sparkle-' . $calId . ' 1s ease-out forwards; 2761 filter: drop-shadow(0 0 3px rgba(255, 20, 147, 0.8)); 2762 } 2763 </style>'; 2764 2765 $html .= '<script> 2766 (function() { 2767 const container = document.getElementById("sidebar-widget-' . $calId . '"); 2768 const sparkles = ["✨", "", "", "⭐", "", "", "", "", "", ""]; 2769 2770 function createSparkle(x, y) { 2771 const sparkle = document.createElement("div"); 2772 sparkle.className = "sparkle-' . $calId . '"; 2773 sparkle.textContent = sparkles[Math.floor(Math.random() * sparkles.length)]; 2774 sparkle.style.left = x + "px"; 2775 sparkle.style.top = y + "px"; 2776 2777 // Random direction 2778 const angle = Math.random() * Math.PI * 2; 2779 const distance = 30 + Math.random() * 40; 2780 sparkle.style.setProperty("--tx", Math.cos(angle) * distance + "px"); 2781 sparkle.style.setProperty("--ty", Math.sin(angle) * distance + "px"); 2782 2783 container.appendChild(sparkle); 2784 2785 setTimeout(() => sparkle.remove(), 1000); 2786 } 2787 2788 // Click sparkles 2789 container.addEventListener("click", function(e) { 2790 const rect = container.getBoundingClientRect(); 2791 const x = e.clientX - rect.left; 2792 const y = e.clientY - rect.top; 2793 2794 // Create LOTS of sparkles for maximum bling! 2795 for (let i = 0; i < 8; i++) { 2796 setTimeout(() => { 2797 const offsetX = x + (Math.random() - 0.5) * 30; 2798 const offsetY = y + (Math.random() - 0.5) * 30; 2799 createSparkle(offsetX, offsetY); 2800 }, i * 40); 2801 } 2802 }); 2803 2804 // Random auto-sparkles for extra glamour 2805 setInterval(() => { 2806 const x = Math.random() * container.offsetWidth; 2807 const y = Math.random() * container.offsetHeight; 2808 createSparkle(x, y); 2809 }, 3000); 2810 })(); 2811 </script>'; 2812 } 2813 2814 // Sanitize calId for use in JavaScript variable names (remove dashes) 2815 $jsCalId = str_replace('-', '_', $calId); 2816 2817 // CRITICAL: Add ALL JavaScript FIRST before any HTML that uses it 2818 $html .= '<script> 2819(function() { 2820 // Update clock every second 2821 function updateClock() { 2822 const now = new Date(); 2823 let hours = now.getHours(); 2824 const minutes = String(now.getMinutes()).padStart(2, "0"); 2825 const seconds = String(now.getSeconds()).padStart(2, "0"); 2826 const ampm = hours >= 12 ? "PM" : "AM"; 2827 hours = hours % 12 || 12; 2828 const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm; 2829 const clockEl = document.getElementById("clock-' . $calId . '"); 2830 if (clockEl) clockEl.textContent = timeStr; 2831 } 2832 setInterval(updateClock, 1000); 2833 2834 // Weather - uses default location, click weather to get local 2835 var userLocationGranted = false; 2836 var userLat = 38.5816; // Sacramento default 2837 var userLon = -121.4944; 2838 2839 function fetchWeatherData(lat, lon) { 2840 fetch("https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon + "¤t_weather=true&temperature_unit=fahrenheit") 2841 .then(response => response.json()) 2842 .then(data => { 2843 if (data.current_weather) { 2844 const temp = Math.round(data.current_weather.temperature); 2845 const weatherCode = data.current_weather.weathercode; 2846 const icon = getWeatherIcon(weatherCode); 2847 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 2848 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 2849 if (iconEl) iconEl.textContent = icon; 2850 if (tempEl) tempEl.innerHTML = temp + "°"; 2851 } 2852 }) 2853 .catch(error => console.log("Weather fetch error:", error)); 2854 } 2855 2856 function updateWeather() { 2857 fetchWeatherData(userLat, userLon); 2858 } 2859 2860 // Click weather icon to request local weather (user gesture required) 2861 function requestLocalWeather() { 2862 if (userLocationGranted) return; 2863 if ("geolocation" in navigator) { 2864 navigator.geolocation.getCurrentPosition(function(position) { 2865 userLat = position.coords.latitude; 2866 userLon = position.coords.longitude; 2867 userLocationGranted = true; 2868 fetchWeatherData(userLat, userLon); 2869 }, function(error) { 2870 console.log("Geolocation denied, using default location"); 2871 }); 2872 } 2873 } 2874 2875 setTimeout(function() { 2876 var weatherEl = document.querySelector("#weather-icon-' . $calId . '"); 2877 if (weatherEl) { 2878 weatherEl.style.cursor = "pointer"; 2879 weatherEl.title = "Click for local weather"; 2880 weatherEl.addEventListener("click", requestLocalWeather); 2881 } 2882 }, 100); 2883 2884 function getWeatherIcon(code) { 2885 const icons = { 2886 0: "☀️", 1: "️", 2: "⛅", 3: "☁️", 2887 45: "️", 48: "️", 51: "️", 53: "️", 55: "️", 2888 61: "️", 63: "️", 65: "⛈️", 71: "️", 73: "️", 2889 75: "❄️", 77: "️", 80: "️", 81: "️", 82: "⛈️", 2890 85: "️", 86: "❄️", 95: "⛈️", 96: "⛈️", 99: "⛈️" 2891 }; 2892 return icons[code] || "️"; 2893 } 2894 2895 // Update weather immediately and every 10 minutes 2896 updateWeather(); 2897 setInterval(updateWeather, 600000); 2898})(); 2899</script>'; 2900 2901 // NOW add the header HTML (after JavaScript is defined) 2902 $todayDate = new DateTime(); 2903 $displayDate = $todayDate->format('D, M j, Y'); 2904 $currentTime = $todayDate->format('g:i:s A'); 2905 2906 $html .= '<div class="eventlist-today-header" style="background:' . $themeStyles['header_bg'] . '; border:2px solid ' . $themeStyles['header_border'] . '; box-shadow:' . $themeStyles['header_shadow'] . ';">'; 2907 $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '" style="color:' . $themeStyles['text_bright'] . ';">' . $currentTime . '</span>'; 2908 $html .= '<div class="eventlist-bottom-info">'; 2909 $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">️</span> <span id="weather-temp-' . $calId . '" style="color:' . $themeStyles['text_primary'] . ';">--°</span></span>'; 2910 $html .= '<span class="eventlist-today-date" style="color:' . $themeStyles['text_dim'] . ';">' . $displayDate . '</span>'; 2911 $html .= '</div>'; 2912 $html .= '</div>'; 2913 2914 // Get today's date for default event date 2915 $todayStr = date('Y-m-d'); 2916 2917 // Thin "Add Event" bar between header and week grid - theme-aware colors 2918 $addBtnBg = $themeStyles['cell_today_bg']; 2919 $addBtnHover = $themeStyles['grid_bg']; 2920 $addBtnTextColor = ($theme === 'professional' || $theme === 'wiki') ? 2921 $themeStyles['text_bright'] : $themeStyles['text_bright']; 2922 $addBtnShadow = ($theme === 'professional' || $theme === 'wiki') ? 2923 '0 2px 4px rgba(0,0,0,0.2)' : '0 0 8px ' . $themeStyles['shadow']; 2924 $addBtnHoverShadow = ($theme === 'professional' || $theme === 'wiki') ? 2925 '0 3px 6px rgba(0,0,0,0.3)' : '0 0 12px ' . $themeStyles['shadow']; 2926 2927 $html .= '<div style="background:' . $addBtnBg . '; padding:0; margin:0; height:12px; line-height:10px; text-align:center; cursor:pointer; border-top:1px solid rgba(0, 0, 0, 0.1); border-bottom:1px solid rgba(0, 0, 0, 0.1); box-shadow:' . $addBtnShadow . '; transition:all 0.2s;" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\', \'' . $todayStr . '\');" onmouseover="this.style.background=\'' . $addBtnHover . '\'; this.style.boxShadow=\'' . $addBtnHoverShadow . '\';" onmouseout="this.style.background=\'' . $addBtnBg . '\'; this.style.boxShadow=\'' . $addBtnShadow . '\';">'; 2928 $addBtnTextShadow = ($theme === 'pink') ? '0 0 3px ' . $addBtnTextColor : 'none'; 2929 $html .= '<span style="color:' . $addBtnTextColor . '; font-size:8px; font-weight:700; letter-spacing:0.4px; font-family:system-ui, sans-serif; text-shadow:' . $addBtnTextShadow . '; position:relative; top:-1px;">+ ADD EVENT</span>'; 2930 $html .= '</div>'; 2931 2932 // Week grid (7 cells) 2933 $html .= $this->renderWeekGrid($weekEvents, $weekStart, $themeStyles, $theme); 2934 2935 // Section colors - derived from theme palette 2936 // Today: brightest accent, Tomorrow: primary accent, Important: dim/secondary accent 2937 if ($theme === 'matrix') { 2938 $todayColor = '#00ff00'; // Bright green 2939 $tomorrowColor = '#00cc07'; // Standard green 2940 $importantColor = '#00aa00'; // Dim green 2941 } else if ($theme === 'purple') { 2942 $todayColor = '#d4a5ff'; // Bright purple 2943 $tomorrowColor = '#9b59b6'; // Standard purple 2944 $importantColor = '#8e7ab8'; // Dim purple 2945 } else if ($theme === 'pink') { 2946 $todayColor = '#ff1493'; // Hot pink 2947 $tomorrowColor = '#ff69b4'; // Medium pink 2948 $importantColor = '#ff85c1'; // Light pink 2949 } else if ($theme === 'professional') { 2950 $todayColor = '#4a90e2'; // Blue accent 2951 $tomorrowColor = '#5ba3e6'; // Lighter blue 2952 $importantColor = '#7fb8ec'; // Lightest blue 2953 } else { 2954 // Wiki - section header backgrounds from template colors 2955 $todayColor = $themeStyles['text_bright']; // __link__ 2956 $tomorrowColor = $themeStyles['header_bg']; // __background_alt__ 2957 $importantColor = $themeStyles['header_border'];// __border__ 2958 } 2959 2960 // Check if there are any itinerary items 2961 $hasItinerary = !empty($todayEvents) || !empty($tomorrowEvents) || !empty($importantEvents); 2962 2963 // Itinerary bar (collapsible toggle) - styled like +Add bar 2964 $itineraryBg = $themeStyles['cell_today_bg']; 2965 $itineraryHover = $themeStyles['grid_bg']; 2966 $itineraryTextColor = ($theme === 'professional' || $theme === 'wiki') ? 2967 $themeStyles['text_bright'] : $themeStyles['text_bright']; 2968 $itineraryShadow = ($theme === 'professional' || $theme === 'wiki') ? 2969 '0 2px 4px rgba(0,0,0,0.2)' : '0 0 8px ' . $themeStyles['shadow']; 2970 $itineraryHoverShadow = ($theme === 'professional' || $theme === 'wiki') ? 2971 '0 3px 6px rgba(0,0,0,0.3)' : '0 0 12px ' . $themeStyles['shadow']; 2972 $itineraryTextShadow = ($theme === 'pink') ? '0 0 3px ' . $itineraryTextColor : 'none'; 2973 2974 // Sanitize calId for JavaScript 2975 $jsCalId = str_replace('-', '_', $calId); 2976 2977 // Get itinerary default state from settings 2978 $itineraryDefaultCollapsed = $this->getItineraryCollapsed(); 2979 $arrowDefaultStyle = $itineraryDefaultCollapsed ? 'transform:rotate(-90deg);' : ''; 2980 $contentDefaultStyle = $itineraryDefaultCollapsed ? 'max-height:0px; opacity:0;' : ''; 2981 2982 $html .= '<div id="itinerary-bar-' . $calId . '" style="background:' . $itineraryBg . '; padding:0; margin:0; height:12px; line-height:10px; text-align:center; cursor:pointer; border-top:1px solid rgba(0, 0, 0, 0.1); border-bottom:1px solid rgba(0, 0, 0, 0.1); box-shadow:' . $itineraryShadow . '; transition:all 0.2s; display:flex; align-items:center; justify-content:center; gap:4px;" onclick="toggleItinerary_' . $jsCalId . '();" onmouseover="this.style.background=\'' . $itineraryHover . '\'; this.style.boxShadow=\'' . $itineraryHoverShadow . '\';" onmouseout="this.style.background=\'' . $itineraryBg . '\'; this.style.boxShadow=\'' . $itineraryShadow . '\';">'; 2983 $html .= '<span id="itinerary-arrow-' . $calId . '" style="color:' . $itineraryTextColor . '; font-size:6px; font-weight:700; font-family:system-ui, sans-serif; text-shadow:' . $itineraryTextShadow . '; position:relative; top:-1px; transition:transform 0.2s; ' . $arrowDefaultStyle . '">▼</span>'; 2984 $html .= '<span style="color:' . $itineraryTextColor . '; font-size:8px; font-weight:700; letter-spacing:0.4px; font-family:system-ui, sans-serif; text-shadow:' . $itineraryTextShadow . '; position:relative; top:-1px;">ITINERARY</span>'; 2985 $html .= '</div>'; 2986 2987 // Itinerary content container (collapsible) 2988 $html .= '<div id="itinerary-content-' . $calId . '" style="transition:max-height 0.3s ease-out, opacity 0.2s ease-out; overflow:hidden; ' . $contentDefaultStyle . '">'; 2989 2990 // Today section 2991 if (!empty($todayEvents)) { 2992 $html .= $this->renderSidebarSection('Today', $todayEvents, $todayColor, $calId, $themeStyles, $theme, $importantNsList); 2993 } 2994 2995 // Tomorrow section 2996 if (!empty($tomorrowEvents)) { 2997 $html .= $this->renderSidebarSection('Tomorrow', $tomorrowEvents, $tomorrowColor, $calId, $themeStyles, $theme, $importantNsList); 2998 } 2999 3000 // Important events section 3001 if (!empty($importantEvents)) { 3002 $html .= $this->renderSidebarSection('Important Events', $importantEvents, $importantColor, $calId, $themeStyles, $theme, $importantNsList); 3003 } 3004 3005 // Empty state if no itinerary items 3006 if (!$hasItinerary) { 3007 $html .= '<div style="padding:8px; text-align:center; color:' . $themeStyles['text_dim'] . '; font-size:10px; font-family:system-ui, sans-serif;">No upcoming events</div>'; 3008 } 3009 3010 $html .= '</div>'; // Close itinerary-content 3011 3012 // Get itinerary default state from settings 3013 $itineraryDefaultCollapsed = $this->getItineraryCollapsed(); 3014 $itineraryExpandedDefault = $itineraryDefaultCollapsed ? 'false' : 'true'; 3015 $itineraryArrowDefault = $itineraryDefaultCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; 3016 $itineraryContentDefault = $itineraryDefaultCollapsed ? 'max-height:0px; opacity:0;' : 'max-height:none;'; 3017 3018 // JavaScript for toggling itinerary 3019 $html .= '<script> 3020 (function() { 3021 let itineraryExpanded_' . $jsCalId . ' = ' . $itineraryExpandedDefault . '; 3022 3023 window.toggleItinerary_' . $jsCalId . ' = function() { 3024 const content = document.getElementById("itinerary-content-' . $calId . '"); 3025 const arrow = document.getElementById("itinerary-arrow-' . $calId . '"); 3026 3027 if (itineraryExpanded_' . $jsCalId . ') { 3028 // Collapse 3029 content.style.maxHeight = "0px"; 3030 content.style.opacity = "0"; 3031 arrow.style.transform = "rotate(-90deg)"; 3032 itineraryExpanded_' . $jsCalId . ' = false; 3033 } else { 3034 // Expand 3035 content.style.maxHeight = content.scrollHeight + "px"; 3036 content.style.opacity = "1"; 3037 arrow.style.transform = "rotate(0deg)"; 3038 itineraryExpanded_' . $jsCalId . ' = true; 3039 3040 // After transition, set to auto for dynamic content 3041 setTimeout(function() { 3042 if (itineraryExpanded_' . $jsCalId . ') { 3043 content.style.maxHeight = "none"; 3044 } 3045 }, 300); 3046 } 3047 }; 3048 3049 // Initialize based on default state 3050 const content = document.getElementById("itinerary-content-' . $calId . '"); 3051 const arrow = document.getElementById("itinerary-arrow-' . $calId . '"); 3052 if (content && arrow) { 3053 if (' . $itineraryExpandedDefault . ') { 3054 content.style.maxHeight = "none"; 3055 arrow.style.transform = "rotate(0deg)"; 3056 } else { 3057 content.style.maxHeight = "0px"; 3058 content.style.opacity = "0"; 3059 arrow.style.transform = "rotate(-90deg)"; 3060 } 3061 } 3062 })(); 3063 </script>'; 3064 3065 $html .= '</div>'; 3066 3067 // Add event dialog for sidebar widget 3068 $html .= $this->renderEventDialog($calId, $namespace, $theme); 3069 3070 // Add JavaScript for positioning data-tooltip elements 3071 $html .= '<script> 3072 // Position data-tooltip elements to prevent cutoff (up and to the LEFT) 3073 document.addEventListener("DOMContentLoaded", function() { 3074 const tooltipElements = document.querySelectorAll("[data-tooltip]"); 3075 const isPinkTheme = document.querySelector(".sidebar-pink") !== null; 3076 3077 tooltipElements.forEach(function(element) { 3078 element.addEventListener("mouseenter", function() { 3079 const rect = element.getBoundingClientRect(); 3080 const style = window.getComputedStyle(element, ":before"); 3081 3082 // Position above the element, aligned to LEFT (not right) 3083 element.style.setProperty("--tooltip-left", (rect.left - 150) + "px"); 3084 element.style.setProperty("--tooltip-top", (rect.top - 30) + "px"); 3085 3086 // Pink theme: position heart to the right of tooltip 3087 if (isPinkTheme) { 3088 element.style.setProperty("--heart-left", (rect.left - 150 + 210) + "px"); 3089 element.style.setProperty("--heart-top", (rect.top - 30) + "px"); 3090 } 3091 }); 3092 }); 3093 }); 3094 3095 // Apply custom properties to position tooltips 3096 const style = document.createElement("style"); 3097 style.textContent = ` 3098 [data-tooltip]:hover:before { 3099 left: var(--tooltip-left, 0) !important; 3100 top: var(--tooltip-top, 0) !important; 3101 } 3102 .sidebar-pink [data-tooltip]:hover:after { 3103 left: var(--heart-left, 0) !important; 3104 top: var(--heart-top, 0) !important; 3105 } 3106 `; 3107 document.head.appendChild(style); 3108 </script>'; 3109 3110 return $html; 3111 } 3112 3113 /** 3114 * Render compact week grid (7 cells with event bars) - Theme-aware 3115 */ 3116 private function renderWeekGrid($weekEvents, $weekStart, $themeStyles, $theme) { 3117 // Generate unique ID for this calendar instance - sanitize for JavaScript 3118 $calId = 'cal_' . substr(md5($weekStart . microtime()), 0, 8); 3119 $jsCalId = str_replace('-', '_', $calId); // Sanitize for JS variable names 3120 3121 $html = '<div style="display:grid; grid-template-columns:repeat(7, 1fr); gap:1px; background:' . $themeStyles['grid_bg'] . '; border-bottom:2px solid ' . $themeStyles['grid_border'] . ';">'; 3122 3123 // Day names depend on week start setting 3124 $weekStartDay = $this->getWeekStartDay(); 3125 if ($weekStartDay === 'monday') { 3126 $dayNames = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Monday to Sunday 3127 } else { 3128 $dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; // Sunday to Saturday 3129 } 3130 $today = date('Y-m-d'); 3131 3132 for ($i = 0; $i < 7; $i++) { 3133 $date = date('Y-m-d', strtotime($weekStart . ' +' . $i . ' days')); 3134 $dayNum = date('j', strtotime($date)); 3135 $isToday = $date === $today; 3136 3137 $events = isset($weekEvents[$date]) ? $weekEvents[$date] : []; 3138 $eventCount = count($events); 3139 3140 $bgColor = $isToday ? $themeStyles['cell_today_bg'] : $themeStyles['cell_bg']; 3141 $textColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary']; 3142 $fontWeight = $isToday ? '700' : '500'; 3143 3144 // Theme-aware text shadow 3145 if ($theme === 'pink') { 3146 $glowColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary']; 3147 $textShadow = $isToday ? 'text-shadow:0 0 3px ' . $glowColor . ';' : 'text-shadow:0 0 2px ' . $glowColor . ';'; 3148 } else if ($theme === 'matrix') { 3149 $glowColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary']; 3150 $textShadow = $isToday ? 'text-shadow:0 0 2px ' . $glowColor . ';' : 'text-shadow:0 0 1px ' . $glowColor . ';'; 3151 } else if ($theme === 'purple') { 3152 $glowColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary']; 3153 $textShadow = $isToday ? 'text-shadow:0 0 2px ' . $glowColor . ';' : 'text-shadow:0 0 1px ' . $glowColor . ';'; 3154 } else { 3155 $textShadow = ''; // No glow for professional/wiki 3156 } 3157 3158 // Border color based on theme 3159 $borderColor = $themeStyles['grid_border']; 3160 3161 $hasEvents = $eventCount > 0; 3162 $clickableStyle = $hasEvents ? 'cursor:pointer;' : ''; 3163 $clickHandler = $hasEvents ? ' onclick="showDayEvents_' . $jsCalId . '(\'' . $date . '\')"' : ''; 3164 3165 $html .= '<div style="background:' . $bgColor . '; padding:4px 2px; text-align:center; min-height:45px; position:relative; border:1px solid ' . $borderColor . ' !important; ' . $clickableStyle . '" ' . $clickHandler . '>'; 3166 3167 // Day letter - theme color 3168 $dayLetterColor = $theme === 'professional' ? '#7f8c8d' : $themeStyles['text_primary']; 3169 $html .= '<div style="font-size:9px; color:' . $dayLetterColor . '; font-weight:500; font-family:system-ui, sans-serif;">' . $dayNames[$i] . '</div>'; 3170 3171 // Day number 3172 $html .= '<div style="font-size:12px; color:' . $textColor . '; font-weight:' . $fontWeight . '; margin:2px 0; font-family:system-ui, sans-serif; ' . $textShadow . '">' . $dayNum . '</div>'; 3173 3174 // Event bars (max 4 visible) with theme-aware glow 3175 if ($eventCount > 0) { 3176 $showCount = min($eventCount, 4); 3177 for ($j = 0; $j < $showCount; $j++) { 3178 $event = $events[$j]; 3179 $color = isset($event['color']) ? $event['color'] : $themeStyles['text_primary']; 3180 $barShadow = $theme === 'professional' ? '0 1px 2px rgba(0,0,0,0.2)' : '0 0 3px ' . htmlspecialchars($color); 3181 $html .= '<div style="height:2px; background:' . htmlspecialchars($color) . '; margin:1px 0; border-radius:1px; box-shadow:' . $barShadow . ';"></div>'; 3182 } 3183 3184 // Show "+N more" if more than 4 - theme color 3185 if ($eventCount > 4) { 3186 $moreTextColor = $theme === 'professional' ? '#7f8c8d' : $themeStyles['text_primary']; 3187 $html .= '<div style="font-size:7px; color:' . $moreTextColor . '; margin-top:1px; font-family:system-ui, sans-serif;">+' . ($eventCount - 4) . '</div>'; 3188 } 3189 } 3190 3191 $html .= '</div>'; 3192 } 3193 3194 $html .= '</div>'; 3195 3196 // Add container for selected day events display (with unique ID) - theme-aware 3197 $panelBorderColor = $themeStyles['border']; 3198 $panelHeaderBg = $themeStyles['border']; 3199 $panelShadow = ($theme === 'professional' || $theme === 'wiki') ? 3200 '0 1px 3px rgba(0, 0, 0, 0.1)' : 3201 '0 0 5px ' . $themeStyles['shadow']; 3202 $panelContentBg = ($theme === 'professional') ? 'rgba(255, 255, 255, 0.95)' : 3203 ($theme === 'wiki' ? $themeStyles['cell_bg'] : 'rgba(36, 36, 36, 0.5)'); 3204 $panelHeaderShadow = ($theme === 'professional' || $theme === 'wiki') ? '0 2px 4px rgba(0, 0, 0, 0.15)' : '0 0 8px ' . $panelHeaderBg; 3205 3206 // Header text color - dark bg text for dark themes, white for light theme accent headers 3207 $panelHeaderColor = ($theme === 'matrix' || $theme === 'purple' || $theme === 'pink') ? $themeStyles['bg'] : 3208 (($theme === 'wiki') ? $themeStyles['text_primary'] : '#fff'); 3209 3210 $html .= '<div id="selected-day-events-' . $calId . '" style="display:none; margin:8px 4px; border-left:3px solid ' . $panelBorderColor . ($theme === 'wiki' ? '' : ' !important') . '; box-shadow:' . $panelShadow . ';">'; 3211 if ($theme === 'wiki') { 3212 $html .= '<div style="background:' . $panelHeaderBg . '; color:' . $panelHeaderColor . '; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $panelHeaderShadow . '; display:flex; justify-content:space-between; align-items:center;">'; 3213 $html .= '<span id="selected-day-title-' . $calId . '"></span>'; 3214 $html .= '<span onclick="document.getElementById(\'selected-day-events-' . $calId . '\').style.display=\'none\';" style="cursor:pointer; font-size:12px; padding:0 4px; font-weight:700; color:' . $panelHeaderColor . ';">✕</span>'; 3215 } else { 3216 $html .= '<div style="background:' . $panelHeaderBg . ' !important; color:' . $panelHeaderColor . ' !important; -webkit-text-fill-color:' . $panelHeaderColor . ' !important; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $panelHeaderShadow . '; display:flex; justify-content:space-between; align-items:center;">'; 3217 $html .= '<span id="selected-day-title-' . $calId . '"></span>'; 3218 $html .= '<span onclick="document.getElementById(\'selected-day-events-' . $calId . '\').style.display=\'none\';" style="cursor:pointer; font-size:12px; padding:0 4px; font-weight:700; color:' . $panelHeaderColor . ' !important; -webkit-text-fill-color:' . $panelHeaderColor . ' !important;">✕</span>'; 3219 } 3220 $html .= '</div>'; 3221 $html .= '<div id="selected-day-content-' . $calId . '" style="padding:4px 0; background:' . $panelContentBg . ';"></div>'; 3222 $html .= '</div>'; 3223 3224 // Add JavaScript for day selection with event data 3225 $html .= '<script>'; 3226 // Sanitize calId for JavaScript variable names 3227 $jsCalId = str_replace('-', '_', $calId); 3228 $html .= 'window.weekEventsData_' . $jsCalId . ' = ' . json_encode($weekEvents) . ';'; 3229 3230 // Pass theme colors to JavaScript 3231 $jsThemeColors = json_encode([ 3232 'text_primary' => $themeStyles['text_primary'], 3233 'text_bright' => $themeStyles['text_bright'], 3234 'text_dim' => $themeStyles['text_dim'], 3235 'text_shadow' => ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $themeStyles['text_primary'] : 3236 ((in_array($theme, ['matrix', 'purple'])) ? 'text-shadow:0 0 1px ' . $themeStyles['text_primary'] : ''), 3237 'event_bg' => $theme === 'professional' ? 'rgba(255, 255, 255, 0.5)' : 3238 ($theme === 'wiki' ? $themeStyles['cell_bg'] : 'rgba(36, 36, 36, 0.3)'), 3239 'border_color' => $theme === 'professional' ? 'rgba(0, 0, 0, 0.1)' : 3240 ($theme === 'purple' ? 'rgba(155, 89, 182, 0.2)' : 3241 ($theme === 'pink' ? 'rgba(255, 20, 147, 0.3)' : 3242 ($theme === 'wiki' ? $themeStyles['grid_border'] : 'rgba(0, 204, 7, 0.2)'))), 3243 'bar_shadow' => $theme === 'professional' ? '0 1px 2px rgba(0,0,0,0.2)' : 3244 ($theme === 'wiki' ? '0 1px 2px rgba(0,0,0,0.15)' : '0 0 3px') 3245 ]); 3246 $html .= 'window.themeColors_' . $jsCalId . ' = ' . $jsThemeColors . ';'; 3247 $html .= ' 3248 window.showDayEvents_' . $jsCalId . ' = function(dateKey) { 3249 const eventsData = window.weekEventsData_' . $jsCalId . '; 3250 const container = document.getElementById("selected-day-events-' . $calId . '"); 3251 const title = document.getElementById("selected-day-title-' . $calId . '"); 3252 const content = document.getElementById("selected-day-content-' . $calId . '"); 3253 3254 if (!eventsData[dateKey] || eventsData[dateKey].length === 0) return; 3255 3256 // Format date for display 3257 const dateObj = new Date(dateKey + "T00:00:00"); 3258 const dayName = dateObj.toLocaleDateString("en-US", { weekday: "long" }); 3259 const monthDay = dateObj.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 3260 title.textContent = dayName + ", " + monthDay; 3261 3262 // Clear content 3263 content.innerHTML = ""; 3264 3265 // Sort events by time (all-day events first, then timed events chronologically) 3266 const sortedEvents = [...eventsData[dateKey]].sort((a, b) => { 3267 // All-day events (no time) go to the beginning 3268 if (!a.time && !b.time) return 0; 3269 if (!a.time) return -1; // a is all-day, comes first 3270 if (!b.time) return 1; // b is all-day, comes first 3271 3272 // Compare times (format: "HH:MM") 3273 const timeA = a.time.split(":").map(Number); 3274 const timeB = b.time.split(":").map(Number); 3275 const minutesA = timeA[0] * 60 + timeA[1]; 3276 const minutesB = timeB[0] * 60 + timeB[1]; 3277 3278 return minutesA - minutesB; 3279 }); 3280 3281 // Build events HTML with single color bar (event color only) - theme-aware 3282 const themeColors = window.themeColors_' . $jsCalId . '; 3283 sortedEvents.forEach(event => { 3284 const eventColor = event.color || themeColors.text_primary; 3285 3286 const eventDiv = document.createElement("div"); 3287 eventDiv.style.cssText = "padding:4px 6px; border-bottom:1px solid " + themeColors.border_color + "; font-size:10px; display:flex; align-items:stretch; gap:6px; background:" + themeColors.event_bg + "; min-height:20px;"; 3288 3289 let eventHTML = ""; 3290 3291 // Event assigned color bar (single bar on left) - theme-aware shadow 3292 const barShadow = themeColors.bar_shadow + (themeColors.bar_shadow.includes("rgba") ? "" : " " + eventColor); 3293 eventHTML += "<div style=\\"width:3px; align-self:stretch; background:" + eventColor + "; border-radius:1px; flex-shrink:0; box-shadow:" + barShadow + ";\\"></div>"; 3294 3295 // Content wrapper 3296 eventHTML += "<div style=\\"flex:1; min-width:0; display:flex; justify-content:space-between; align-items:start; gap:4px;\\">"; 3297 3298 // Left side: event details 3299 eventHTML += "<div style=\\"flex:1; min-width:0;\\">"; 3300 eventHTML += "<div style=\\"font-weight:600; color:" + themeColors.text_primary + "; word-wrap:break-word; font-family:system-ui, sans-serif; " + themeColors.text_shadow + ";\\">"; 3301 3302 // Time 3303 if (event.time) { 3304 const timeParts = event.time.split(":"); 3305 let hours = parseInt(timeParts[0]); 3306 const minutes = timeParts[1]; 3307 const ampm = hours >= 12 ? "PM" : "AM"; 3308 hours = hours % 12 || 12; 3309 eventHTML += "<span style=\\"color:" + themeColors.text_bright + "; font-weight:500; font-size:9px;\\">" + hours + ":" + minutes + " " + ampm + "</span> "; 3310 } 3311 3312 // Title - use HTML version if available 3313 const titleHTML = event.title_html || event.title || "Untitled"; 3314 eventHTML += titleHTML; 3315 eventHTML += "</div>"; 3316 3317 // Description if present - use HTML version - theme-aware color 3318 if (event.description_html || event.description) { 3319 const descHTML = event.description_html || event.description; 3320 eventHTML += "<div style=\\"font-size:9px; color:" + themeColors.text_dim + "; margin-top:2px;\\">" + descHTML + "</div>"; 3321 } 3322 3323 eventHTML += "</div>"; // Close event details 3324 3325 // Right side: conflict badge with tooltip 3326 if (event.conflict) { 3327 let conflictList = []; 3328 if (event.conflictingWith && event.conflictingWith.length > 0) { 3329 event.conflictingWith.forEach(conf => { 3330 const confTime = conf.time + (conf.end_time ? " - " + conf.end_time : ""); 3331 conflictList.push(conf.title + " (" + confTime + ")"); 3332 }); 3333 } 3334 const conflictData = btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))); 3335 eventHTML += "<span class=\\"event-conflict-badge\\" style=\\"font-size:10px;\\" data-conflicts=\\"" + conflictData + "\\" onmouseenter=\\"showConflictTooltip(this)\\" onmouseleave=\\"hideConflictTooltip()\\">⚠️ " + (event.conflictingWith ? event.conflictingWith.length : 1) + "</span>"; 3336 } 3337 3338 eventHTML += "</div>"; // Close content wrapper 3339 3340 eventDiv.innerHTML = eventHTML; 3341 content.appendChild(eventDiv); 3342 }); 3343 3344 container.style.display = "block"; 3345 }; 3346 '; 3347 $html .= '</script>'; 3348 3349 return $html; 3350 } 3351 3352 /** 3353 * Render a sidebar section (Today/Tomorrow/Important) - Matrix themed with colored borders 3354 */ 3355 private function renderSidebarSection($title, $events, $accentColor, $calId, $themeStyles, $theme, $importantNsList = ['important']) { 3356 // Keep the original accent colors for borders 3357 $borderColor = $accentColor; 3358 3359 // Show date for Important Events section 3360 $showDate = ($title === 'Important Events'); 3361 3362 // Sort events differently based on section 3363 if ($title === 'Important Events') { 3364 // Important Events: sort by date first, then by time 3365 usort($events, function($a, $b) { 3366 $aDate = isset($a['date']) ? $a['date'] : ''; 3367 $bDate = isset($b['date']) ? $b['date'] : ''; 3368 3369 // Different dates - sort by date 3370 if ($aDate !== $bDate) { 3371 return strcmp($aDate, $bDate); 3372 } 3373 3374 // Same date - sort by time 3375 $aTime = isset($a['time']) && !empty($a['time']) ? $a['time'] : ''; 3376 $bTime = isset($b['time']) && !empty($b['time']) ? $b['time'] : ''; 3377 3378 // All-day events last within same date 3379 if (empty($aTime) && !empty($bTime)) return 1; 3380 if (!empty($aTime) && empty($bTime)) return -1; 3381 if (empty($aTime) && empty($bTime)) return 0; 3382 3383 // Both have times 3384 $aMinutes = $this->timeToMinutes($aTime); 3385 $bMinutes = $this->timeToMinutes($bTime); 3386 return $aMinutes - $bMinutes; 3387 }); 3388 } else { 3389 // Today/Tomorrow: sort by time only (all same date) 3390 usort($events, function($a, $b) { 3391 $aTime = isset($a['time']) && !empty($a['time']) ? $a['time'] : ''; 3392 $bTime = isset($b['time']) && !empty($b['time']) ? $b['time'] : ''; 3393 3394 // All-day events (no time) come first 3395 if (empty($aTime) && !empty($bTime)) return -1; 3396 if (!empty($aTime) && empty($bTime)) return 1; 3397 if (empty($aTime) && empty($bTime)) return 0; 3398 3399 // Both have times - convert to minutes for proper chronological sort 3400 $aMinutes = $this->timeToMinutes($aTime); 3401 $bMinutes = $this->timeToMinutes($bTime); 3402 3403 return $aMinutes - $bMinutes; 3404 }); 3405 } 3406 3407 // Theme-aware section shadow 3408 $sectionShadow = ($theme === 'professional' || $theme === 'wiki') ? 3409 '0 1px 3px rgba(0, 0, 0, 0.1)' : 3410 '0 0 5px ' . $themeStyles['shadow']; 3411 3412 if ($theme === 'wiki') { 3413 // Wiki theme: use a background div for the left bar instead of border-left 3414 // Dark Reader maps border colors differently from background colors, causing mismatch 3415 $html = '<div style="display:flex; margin:8px 4px; box-shadow:' . $sectionShadow . '; background:' . $themeStyles['bg'] . ';">'; 3416 $html .= '<div style="width:3px; flex-shrink:0; background:' . $borderColor . ';"></div>'; 3417 $html .= '<div style="flex:1; min-width:0;">'; 3418 } else { 3419 $html = '<div style="border-left:3px solid ' . $borderColor . ' !important; margin:8px 4px; box-shadow:' . $sectionShadow . ';">'; 3420 } 3421 3422 // Section header with accent color background - theme-aware 3423 $headerShadow = ($theme === 'professional' || $theme === 'wiki') ? '0 2px 4px rgba(0, 0, 0, 0.15)' : '0 0 8px ' . $accentColor; 3424 $headerTextColor = ($theme === 'matrix' || $theme === 'purple' || $theme === 'pink') ? $themeStyles['bg'] : 3425 (($theme === 'wiki') ? $themeStyles['text_primary'] : '#fff'); 3426 if ($theme === 'wiki') { 3427 // Wiki theme: no !important — let Dark Reader adjust these 3428 $html .= '<div style="background:' . $accentColor . '; color:' . $headerTextColor . '; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $headerShadow . ';">'; 3429 } else { 3430 // Dark themes + professional: lock colors against Dark Reader 3431 $html .= '<div style="background:' . $accentColor . ' !important; color:' . $headerTextColor . ' !important; -webkit-text-fill-color:' . $headerTextColor . ' !important; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $headerShadow . ';">'; 3432 } 3433 $html .= htmlspecialchars($title); 3434 $html .= '</div>'; 3435 3436 // Events - no background (transparent) 3437 $html .= '<div style="padding:4px 0;">'; 3438 3439 foreach ($events as $event) { 3440 $html .= $this->renderSidebarEvent($event, $calId, $showDate, $accentColor, $themeStyles, $theme, $importantNsList); 3441 } 3442 3443 $html .= '</div>'; 3444 $html .= '</div>'; 3445 if ($theme === 'wiki') { 3446 $html .= '</div>'; // Close flex wrapper 3447 } 3448 3449 return $html; 3450 } 3451 3452 /** 3453 * Render individual event in sidebar - Theme-aware 3454 */ 3455 private function renderSidebarEvent($event, $calId, $showDate = false, $sectionColor = '#00cc07', $themeStyles = null, $theme = 'matrix', $importantNsList = ['important']) { 3456 $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled'; 3457 $time = isset($event['time']) ? $event['time'] : ''; 3458 $endTime = isset($event['endTime']) ? $event['endTime'] : ''; 3459 $eventColor = isset($event['color']) ? htmlspecialchars($event['color']) : ($themeStyles ? $themeStyles['text_primary'] : '#00cc07'); 3460 $date = isset($event['date']) ? $event['date'] : ''; 3461 $isTask = isset($event['isTask']) && $event['isTask']; 3462 $completed = isset($event['completed']) && $event['completed']; 3463 3464 // Check if this is an important namespace event 3465 $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; 3466 $isImportantNs = false; 3467 foreach ($importantNsList as $impNs) { 3468 if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) { 3469 $isImportantNs = true; 3470 break; 3471 } 3472 } 3473 3474 // Theme-aware colors 3475 $titleColor = $themeStyles ? $themeStyles['text_primary'] : '#00cc07'; 3476 $timeColor = $themeStyles ? $themeStyles['text_bright'] : '#00dd00'; 3477 $textShadow = ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $titleColor . ';' : 3478 ((in_array($theme, ['matrix', 'purple'])) ? 'text-shadow:0 0 1px ' . $titleColor . ';' : ''); 3479 3480 // Check for conflicts (using 'conflict' field set by detectTimeConflicts) 3481 $hasConflict = isset($event['conflict']) && $event['conflict']; 3482 $conflictingWith = isset($event['conflictingWith']) ? $event['conflictingWith'] : []; 3483 3484 // Build conflict list for tooltip 3485 $conflictList = []; 3486 if ($hasConflict && !empty($conflictingWith)) { 3487 foreach ($conflictingWith as $conf) { 3488 $confTime = $this->formatTimeDisplay($conf['time'], isset($conf['end_time']) ? $conf['end_time'] : ''); 3489 $conflictList[] = $conf['title'] . ' (' . $confTime . ')'; 3490 } 3491 } 3492 3493 // No background on individual events (transparent) - unless important namespace 3494 // Use theme grid_border with slight opacity for subtle divider 3495 $borderColor = $themeStyles['grid_border']; 3496 3497 // Important namespace highlighting - subtle themed background 3498 $importantBg = ''; 3499 $importantBorder = ''; 3500 if ($isImportantNs) { 3501 // Theme-specific important highlighting 3502 switch ($theme) { 3503 case 'matrix': 3504 $importantBg = 'background:rgba(0,204,7,0.08);'; 3505 $importantBorder = 'border-right:2px solid rgba(0,204,7,0.4);'; 3506 break; 3507 case 'purple': 3508 $importantBg = 'background:rgba(156,39,176,0.08);'; 3509 $importantBorder = 'border-right:2px solid rgba(156,39,176,0.4);'; 3510 break; 3511 case 'pink': 3512 $importantBg = 'background:rgba(255,105,180,0.1);'; 3513 $importantBorder = 'border-right:2px solid rgba(255,105,180,0.5);'; 3514 break; 3515 case 'professional': 3516 $importantBg = 'background:rgba(33,150,243,0.08);'; 3517 $importantBorder = 'border-right:2px solid rgba(33,150,243,0.4);'; 3518 break; 3519 case 'wiki': 3520 $importantBg = 'background:rgba(0,102,204,0.06);'; 3521 $importantBorder = 'border-right:2px solid rgba(0,102,204,0.3);'; 3522 break; 3523 default: 3524 $importantBg = 'background:rgba(0,204,7,0.08);'; 3525 $importantBorder = 'border-right:2px solid rgba(0,204,7,0.4);'; 3526 } 3527 } 3528 3529 $html = '<div style="padding:4px 6px; border-bottom:1px solid ' . $borderColor . ' !important; font-size:10px; display:flex; align-items:stretch; gap:6px; min-height:20px; ' . $importantBg . $importantBorder . '">'; 3530 3531 // Event's assigned color bar (single bar on the left) 3532 $barShadow = ($theme === 'professional') ? '0 1px 2px rgba(0,0,0,0.2)' : '0 0 3px ' . $eventColor; 3533 $html .= '<div style="width:3px; align-self:stretch; background:' . $eventColor . '; border-radius:1px; flex-shrink:0; box-shadow:' . $barShadow . ';"></div>'; 3534 3535 // Content 3536 $html .= '<div style="flex:1; min-width:0;">'; 3537 3538 // Time + title 3539 $html .= '<div style="font-weight:600; color:' . $titleColor . '; word-wrap:break-word; font-family:system-ui, sans-serif; ' . $textShadow . '">'; 3540 3541 if ($time) { 3542 $displayTime = $this->formatTimeDisplay($time, $endTime); 3543 $html .= '<span style="color:' . $timeColor . '; font-weight:500; font-size:9px;">' . htmlspecialchars($displayTime) . '</span> '; 3544 } 3545 3546 // Task checkbox 3547 if ($isTask) { 3548 $checkIcon = $completed ? '☑' : '☐'; 3549 $checkColor = $themeStyles ? $themeStyles['text_bright'] : '#00ff00'; 3550 $html .= '<span style="font-size:11px; color:' . $checkColor . ';">' . $checkIcon . '</span> '; 3551 } 3552 3553 // Important indicator icon for important namespace events 3554 if ($isImportantNs) { 3555 $html .= '<span style="font-size:9px;" title="Important">⭐</span> '; 3556 } 3557 3558 $html .= $title; // Already HTML-escaped on line 2625 3559 3560 // Conflict badge using same system as main calendar 3561 if ($hasConflict && !empty($conflictList)) { 3562 $conflictJson = base64_encode(json_encode($conflictList)); 3563 $html .= ' <span class="event-conflict-badge" style="font-size:10px;" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . count($conflictList) . '</span>'; 3564 } 3565 3566 $html .= '</div>'; 3567 3568 // Date display BELOW event name for Important events 3569 if ($showDate && $date) { 3570 $dateObj = new DateTime($date); 3571 $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Feb 10" 3572 $dateColor = $themeStyles ? $themeStyles['text_dim'] : '#00aa00'; 3573 $dateShadow = ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $dateColor . ';' : 3574 ((in_array($theme, ['matrix', 'purple'])) ? 'text-shadow:0 0 1px ' . $dateColor . ';' : ''); 3575 $html .= '<div style="font-size:8px; color:' . $dateColor . '; font-weight:500; margin-top:2px; ' . $dateShadow . '">' . htmlspecialchars($displayDate) . '</div>'; 3576 } 3577 3578 $html .= '</div>'; 3579 $html .= '</div>'; 3580 3581 return $html; 3582 } 3583 3584 /** 3585 * Format time display (12-hour format with optional end time) 3586 */ 3587 private function formatTimeDisplay($startTime, $endTime = '') { 3588 // Convert start time 3589 list($hour, $minute) = explode(':', $startTime); 3590 $hour = (int)$hour; 3591 $ampm = $hour >= 12 ? 'PM' : 'AM'; 3592 $displayHour = $hour % 12; 3593 if ($displayHour === 0) $displayHour = 12; 3594 3595 $display = $displayHour . ':' . $minute . ' ' . $ampm; 3596 3597 // Add end time if provided 3598 if ($endTime && $endTime !== '') { 3599 list($endHour, $endMinute) = explode(':', $endTime); 3600 $endHour = (int)$endHour; 3601 $endAmpm = $endHour >= 12 ? 'PM' : 'AM'; 3602 $endDisplayHour = $endHour % 12; 3603 if ($endDisplayHour === 0) $endDisplayHour = 12; 3604 3605 $display .= '-' . $endDisplayHour . ':' . $endMinute . ' ' . $endAmpm; 3606 } 3607 3608 return $display; 3609 } 3610 3611 /** 3612 * Detect time conflicts among events on the same day 3613 * Returns events array with 'conflict' flag and 'conflictingWith' array 3614 */ 3615 private function detectTimeConflicts($dayEvents) { 3616 if (empty($dayEvents)) { 3617 return $dayEvents; 3618 } 3619 3620 // If only 1 event, no conflicts possible but still add the flag 3621 if (count($dayEvents) === 1) { 3622 return [array_merge($dayEvents[0], ['conflict' => false, 'conflictingWith' => []])]; 3623 } 3624 3625 $eventsWithFlags = []; 3626 3627 foreach ($dayEvents as $i => $event) { 3628 $hasConflict = false; 3629 $conflictingWith = []; 3630 3631 // Skip all-day events (no time) 3632 if (empty($event['time'])) { 3633 $eventsWithFlags[] = array_merge($event, ['conflict' => false, 'conflictingWith' => []]); 3634 continue; 3635 } 3636 3637 // Get this event's time range 3638 $startTime = $event['time']; 3639 // Check both 'end_time' (snake_case) and 'endTime' (camelCase) for compatibility 3640 $endTime = ''; 3641 if (isset($event['end_time']) && $event['end_time'] !== '') { 3642 $endTime = $event['end_time']; 3643 } elseif (isset($event['endTime']) && $event['endTime'] !== '') { 3644 $endTime = $event['endTime']; 3645 } else { 3646 // If no end time, use start time (zero duration) - matches main calendar logic 3647 $endTime = $startTime; 3648 } 3649 3650 // Check against all other events 3651 foreach ($dayEvents as $j => $otherEvent) { 3652 if ($i === $j) continue; // Skip self 3653 if (empty($otherEvent['time'])) continue; // Skip all-day events 3654 3655 $otherStart = $otherEvent['time']; 3656 // Check both field name formats 3657 $otherEnd = ''; 3658 if (isset($otherEvent['end_time']) && $otherEvent['end_time'] !== '') { 3659 $otherEnd = $otherEvent['end_time']; 3660 } elseif (isset($otherEvent['endTime']) && $otherEvent['endTime'] !== '') { 3661 $otherEnd = $otherEvent['endTime']; 3662 } else { 3663 $otherEnd = $otherStart; 3664 } 3665 3666 // Check for overlap: convert to minutes and compare 3667 $start1Min = $this->timeToMinutes($startTime); 3668 $end1Min = $this->timeToMinutes($endTime); 3669 $start2Min = $this->timeToMinutes($otherStart); 3670 $end2Min = $this->timeToMinutes($otherEnd); 3671 3672 // Overlap if: start1 < end2 AND start2 < end1 3673 // Note: Using < (not <=) so events that just touch at boundaries don't conflict 3674 // e.g., 1:00-2:00 and 2:00-3:00 are NOT in conflict 3675 if ($start1Min < $end2Min && $start2Min < $end1Min) { 3676 $hasConflict = true; 3677 $conflictingWith[] = [ 3678 'title' => isset($otherEvent['title']) ? $otherEvent['title'] : 'Untitled', 3679 'time' => $otherStart, 3680 'end_time' => $otherEnd 3681 ]; 3682 } 3683 } 3684 3685 $eventsWithFlags[] = array_merge($event, [ 3686 'conflict' => $hasConflict, 3687 'conflictingWith' => $conflictingWith 3688 ]); 3689 } 3690 3691 return $eventsWithFlags; 3692 } 3693 3694 /** 3695 * Add hours to a time string 3696 */ 3697 private function addHoursToTime($time, $hours) { 3698 $totalMinutes = $this->timeToMinutes($time) + ($hours * 60); 3699 $h = floor($totalMinutes / 60) % 24; 3700 $m = $totalMinutes % 60; 3701 return sprintf('%02d:%02d', $h, $m); 3702 } 3703 3704 /** 3705 * Render DokuWiki syntax to HTML 3706 * Converts **bold**, //italic//, [[links]], etc. to HTML 3707 */ 3708 private function renderDokuWikiToHtml($text) { 3709 if (empty($text)) return ''; 3710 3711 // Use DokuWiki's parser to render the text 3712 $instructions = p_get_instructions($text); 3713 3714 // Render instructions to XHTML 3715 $xhtml = p_render('xhtml', $instructions, $info); 3716 3717 // Remove surrounding <p> tags if present (we're rendering inline) 3718 $xhtml = preg_replace('/^<p>(.*)<\/p>$/s', '$1', trim($xhtml)); 3719 3720 return $xhtml; 3721 } 3722 3723 // Keep old scanForNamespaces for backward compatibility (not used anymore) 3724 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 3725 if (!is_dir($dir)) return; 3726 3727 $items = scandir($dir); 3728 foreach ($items as $item) { 3729 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 3730 3731 $path = $dir . $item; 3732 if (is_dir($path)) { 3733 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 3734 $namespaces[] = $namespace; 3735 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 3736 } 3737 } 3738 } 3739 3740 /** 3741 * Get current sidebar theme 3742 */ 3743 private function getSidebarTheme() { 3744 $configFile = $this->metaDir() . 'calendar_theme.txt'; 3745 if (file_exists($configFile)) { 3746 $theme = trim(file_get_contents($configFile)); 3747 if (in_array($theme, ['matrix', 'purple', 'professional', 'pink', 'wiki'])) { 3748 return $theme; 3749 } 3750 } 3751 return 'matrix'; // Default 3752 } 3753 3754 /** 3755 * Get colors from DokuWiki template's style.ini file 3756 */ 3757 private function getWikiTemplateColors() { 3758 global $conf; 3759 3760 // Get current template name 3761 $template = $conf['template']; 3762 3763 // Try multiple possible locations for style.ini (farm-safe) 3764 $possiblePaths = [ 3765 DOKU_INC . 'lib/tpl/' . $template . '/style.ini', 3766 ]; 3767 // Add farm-specific conf override path if available 3768 if (!empty($conf['savedir'])) { 3769 array_unshift($possiblePaths, $conf['savedir'] . '/tpl/' . $template . '/style.ini'); 3770 } 3771 array_unshift($possiblePaths, DOKU_INC . 'conf/tpl/' . $template . '/style.ini'); 3772 3773 $styleIni = null; 3774 foreach ($possiblePaths as $path) { 3775 if (file_exists($path)) { 3776 $styleIni = parse_ini_file($path, true); 3777 break; 3778 } 3779 } 3780 3781 if (!$styleIni) { 3782 return null; // Fall back to CSS variables 3783 } 3784 3785 // Extract color replacements 3786 $replacements = isset($styleIni['replacements']) ? $styleIni['replacements'] : []; 3787 3788 // Map style.ini colors to our theme structure 3789 $bgSite = isset($replacements['__background_site__']) ? $replacements['__background_site__'] : '#f5f5f5'; 3790 $background = isset($replacements['__background__']) ? $replacements['__background__'] : '#fff'; 3791 $bgAlt = isset($replacements['__background_alt__']) ? $replacements['__background_alt__'] : '#e8e8e8'; 3792 $bgNeu = isset($replacements['__background_neu__']) ? $replacements['__background_neu__'] : '#eee'; 3793 $text = isset($replacements['__text__']) ? $replacements['__text__'] : '#333'; 3794 $textAlt = isset($replacements['__text_alt__']) ? $replacements['__text_alt__'] : '#999'; 3795 $textNeu = isset($replacements['__text_neu__']) ? $replacements['__text_neu__'] : '#666'; 3796 $border = isset($replacements['__border__']) ? $replacements['__border__'] : '#ccc'; 3797 $link = isset($replacements['__link__']) ? $replacements['__link__'] : '#2b73b7'; 3798 $existing = isset($replacements['__existing__']) ? $replacements['__existing__'] : $link; 3799 3800 // Build theme colors from template colors 3801 // ============================================ 3802 // DokuWiki style.ini → Calendar CSS Variable Mapping 3803 // ============================================ 3804 // style.ini key → CSS variable → Used for 3805 // __background_site__ → --background-site → Container, panel backgrounds 3806 // __background__ → --cell-bg → Cell/input backgrounds (typically white) 3807 // __background_alt__ → --background-alt → Hover states, header backgrounds 3808 // → --background-header 3809 // __background_neu__ → --cell-today-bg → Today cell highlight 3810 // __text__ → --text-primary → Primary text, labels, titles 3811 // __text_neu__ → --text-dim → Secondary text, dates, descriptions 3812 // __text_alt__ → (not mapped) → Available for future use 3813 // __border__ → --border-color → Grid lines, input borders 3814 // → --border-main → Accent color: buttons, badges, active elements, section headers 3815 // → --header-border 3816 // __link__ → --text-bright → Links, accent text 3817 // __existing__ → (fallback to __link__)→ Available for future use 3818 // 3819 // To customize: edit your template's conf/style.ini [replacements] 3820 return [ 3821 'bg' => $bgSite, 3822 'border' => $border, // Accent color from template border 3823 'shadow' => 'rgba(0, 0, 0, 0.1)', 3824 'header_bg' => $bgAlt, // Headers use alt background 3825 'header_border' => $border, 3826 'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)', 3827 'text_primary' => $text, 3828 'text_bright' => $link, 3829 'text_dim' => $textNeu, 3830 'grid_bg' => $bgSite, 3831 'grid_border' => $border, 3832 'cell_bg' => $background, // Cells use __background__ (white/light) 3833 'cell_today_bg' => $bgNeu, 3834 'bar_glow' => '0 1px 2px', 3835 'pastdue_color' => '#e74c3c', 3836 'pastdue_bg' => '#ffe6e6', 3837 'pastdue_bg_strong' => '#ffd9d9', 3838 'pastdue_bg_light' => '#fff2f2', 3839 'tomorrow_bg' => '#fff9e6', 3840 'tomorrow_bg_strong' => '#fff4cc', 3841 'tomorrow_bg_light' => '#fffbf0', 3842 ]; 3843 } 3844 3845 /** 3846 * Get theme-specific color styles 3847 */ 3848 private function getSidebarThemeStyles($theme) { 3849 // For wiki theme, try to read colors from template's style.ini 3850 if ($theme === 'wiki') { 3851 $wikiColors = $this->getWikiTemplateColors(); 3852 if (!empty($wikiColors)) { 3853 return $wikiColors; 3854 } 3855 // Fall through to default wiki colors if reading fails 3856 } 3857 3858 $themes = [ 3859 'matrix' => [ 3860 'bg' => '#242424', 3861 'border' => '#00cc07', 3862 'shadow' => 'rgba(0, 204, 7, 0.3)', 3863 'header_bg' => 'linear-gradient(180deg, #2a2a2a 0%, #242424 100%)', 3864 'header_border' => '#00cc07', 3865 'header_shadow' => '0 2px 8px rgba(0, 204, 7, 0.3)', 3866 'text_primary' => '#00cc07', 3867 'text_bright' => '#00ff00', 3868 'text_dim' => '#00aa00', 3869 'grid_bg' => '#1a3d1a', 3870 'grid_border' => '#00cc07', 3871 'cell_bg' => '#242424', 3872 'cell_today_bg' => '#2a4d2a', 3873 'bar_glow' => '0 0 3px', 3874 'pastdue_color' => '#e74c3c', 3875 'pastdue_bg' => '#3d1a1a', 3876 'pastdue_bg_strong' => '#4d2020', 3877 'pastdue_bg_light' => '#2d1515', 3878 'tomorrow_bg' => '#3d3d1a', 3879 'tomorrow_bg_strong' => '#4d4d20', 3880 'tomorrow_bg_light' => '#2d2d15', 3881 ], 3882 'purple' => [ 3883 'bg' => '#2a2030', 3884 'border' => '#9b59b6', 3885 'shadow' => 'rgba(155, 89, 182, 0.3)', 3886 'header_bg' => 'linear-gradient(180deg, #2f2438 0%, #2a2030 100%)', 3887 'header_border' => '#9b59b6', 3888 'header_shadow' => '0 2px 8px rgba(155, 89, 182, 0.3)', 3889 'text_primary' => '#b19cd9', 3890 'text_bright' => '#d4a5ff', 3891 'text_dim' => '#8e7ab8', 3892 'grid_bg' => '#3d2b4d', 3893 'grid_border' => '#9b59b6', 3894 'cell_bg' => '#2a2030', 3895 'cell_today_bg' => '#3d2b4d', 3896 'bar_glow' => '0 0 3px', 3897 'pastdue_color' => '#e74c3c', 3898 'pastdue_bg' => '#3d1a2a', 3899 'pastdue_bg_strong' => '#4d2035', 3900 'pastdue_bg_light' => '#2d1520', 3901 'tomorrow_bg' => '#3d3520', 3902 'tomorrow_bg_strong' => '#4d4028', 3903 'tomorrow_bg_light' => '#2d2a18', 3904 ], 3905 'professional' => [ 3906 'bg' => '#f5f7fa', 3907 'border' => '#4a90e2', 3908 'shadow' => 'rgba(74, 144, 226, 0.2)', 3909 'header_bg' => 'linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%)', 3910 'header_border' => '#4a90e2', 3911 'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)', 3912 'text_primary' => '#2c3e50', 3913 'text_bright' => '#4a90e2', 3914 'text_dim' => '#7f8c8d', 3915 'grid_bg' => '#e8ecf1', 3916 'grid_border' => '#d0d7de', 3917 'cell_bg' => '#ffffff', 3918 'cell_today_bg' => '#dce8f7', 3919 'bar_glow' => '0 1px 2px', 3920 'pastdue_color' => '#e74c3c', 3921 'pastdue_bg' => '#ffe6e6', 3922 'pastdue_bg_strong' => '#ffd9d9', 3923 'pastdue_bg_light' => '#fff2f2', 3924 'tomorrow_bg' => '#fff9e6', 3925 'tomorrow_bg_strong' => '#fff4cc', 3926 'tomorrow_bg_light' => '#fffbf0', 3927 ], 3928 'pink' => [ 3929 'bg' => '#1a0d14', 3930 'border' => '#ff1493', 3931 'shadow' => 'rgba(255, 20, 147, 0.4)', 3932 'header_bg' => 'linear-gradient(180deg, #2d1a24 0%, #1a0d14 100%)', 3933 'header_border' => '#ff1493', 3934 'header_shadow' => '0 0 12px rgba(255, 20, 147, 0.6)', 3935 'text_primary' => '#ff69b4', 3936 'text_bright' => '#ff1493', 3937 'text_dim' => '#ff85c1', 3938 'grid_bg' => '#2d1a24', 3939 'grid_border' => '#ff1493', 3940 'cell_bg' => '#1a0d14', 3941 'cell_today_bg' => '#3d2030', 3942 'bar_glow' => '0 0 5px', 3943 'pastdue_color' => '#e74c3c', 3944 'pastdue_bg' => '#3d1520', 3945 'pastdue_bg_strong' => '#4d1a28', 3946 'pastdue_bg_light' => '#2d1018', 3947 'tomorrow_bg' => '#3d3020', 3948 'tomorrow_bg_strong' => '#4d3a28', 3949 'tomorrow_bg_light' => '#2d2518', 3950 ], 3951 'wiki' => [ 3952 'bg' => '#f5f5f5', 3953 'border' => '#ccc', // Template __border__ color 3954 'shadow' => 'rgba(0, 0, 0, 0.1)', 3955 'header_bg' => '#e8e8e8', 3956 'header_border' => '#ccc', 3957 'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)', 3958 'text_primary' => '#333', 3959 'text_bright' => '#2b73b7', // Template __link__ color 3960 'text_dim' => '#666', 3961 'grid_bg' => '#f5f5f5', 3962 'grid_border' => '#ccc', 3963 'cell_bg' => '#fff', 3964 'cell_today_bg' => '#eee', 3965 'bar_glow' => '0 1px 2px', 3966 'pastdue_color' => '#e74c3c', 3967 'pastdue_bg' => '#ffe6e6', 3968 'pastdue_bg_strong' => '#ffd9d9', 3969 'pastdue_bg_light' => '#fff2f2', 3970 'tomorrow_bg' => '#fff9e6', 3971 'tomorrow_bg_strong' => '#fff4cc', 3972 'tomorrow_bg_light' => '#fffbf0', 3973 ], 3974 ]; 3975 3976 return isset($themes[$theme]) ? $themes[$theme] : $themes['matrix']; 3977 } 3978 3979 /** 3980 * Get week start day preference 3981 */ 3982 private function getWeekStartDay() { 3983 $configFile = $this->metaDir() . 'calendar_week_start.txt'; 3984 if (file_exists($configFile)) { 3985 $start = trim(file_get_contents($configFile)); 3986 if (in_array($start, ['monday', 'sunday'])) { 3987 return $start; 3988 } 3989 } 3990 return 'sunday'; // Default to Sunday (US/Canada standard) 3991 } 3992 3993 /** 3994 * Get itinerary collapsed default state 3995 */ 3996 private function getItineraryCollapsed() { 3997 $configFile = $this->metaDir() . 'calendar_itinerary_collapsed.txt'; 3998 if (file_exists($configFile)) { 3999 return trim(file_get_contents($configFile)) === 'yes'; 4000 } 4001 return false; // Default to expanded 4002 } 4003 4004 /** 4005 * Get default search scope (month or all) 4006 */ 4007 private function getSearchDefault() { 4008 $configFile = $this->metaDir() . 'calendar_search_default.txt'; 4009 if (file_exists($configFile)) { 4010 $value = trim(file_get_contents($configFile)); 4011 if (in_array($value, ['month', 'all'])) { 4012 return $value; 4013 } 4014 } 4015 return 'month'; // Default to month search 4016 } 4017 4018 /** 4019 * Parse exclude parameter into an array of namespace strings 4020 * Supports semicolon-separated list: "journal;drafts;personal:private" 4021 */ 4022 private function parseExcludeList($exclude) { 4023 if (empty($exclude)) return []; 4024 return array_filter(array_map('trim', explode(';', $exclude)), function($v) { 4025 return $v !== ''; 4026 }); 4027 } 4028 4029 /** 4030 * Check if a namespace should be excluded 4031 * Matches exact names and prefixes (e.g., exclude "journal" also excludes "journal:sub") 4032 */ 4033 private function isNamespaceExcluded($namespace, $excludeList) { 4034 if (empty($excludeList) || $namespace === '') return false; 4035 foreach ($excludeList as $excluded) { 4036 // Exact match 4037 if ($namespace === $excluded) return true; 4038 // Prefix match: "journal" excludes "journal:sub:deep" 4039 if (strpos($namespace, $excluded . ':') === 0) return true; 4040 } 4041 return false; 4042 } 4043}