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