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