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