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