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 // Check if multiple namespaces or wildcard specified 91 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 92 93 if ($isMultiNamespace) { 94 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 95 } else { 96 $events = $this->loadEvents($namespace, $year, $month); 97 } 98 $calId = 'cal_' . md5(serialize($data) . microtime()); 99 100 $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year)); 101 102 $prevMonth = $month - 1; 103 $prevYear = $year; 104 if ($prevMonth < 1) { 105 $prevMonth = 12; 106 $prevYear--; 107 } 108 109 $nextMonth = $month + 1; 110 $nextYear = $year; 111 if ($nextMonth > 12) { 112 $nextMonth = 1; 113 $nextYear++; 114 } 115 116 $html = '<div class="calendar-compact-container" id="' . $calId . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '" data-year="' . $year . '" data-month="' . $month . '">'; 117 118 // Load calendar JavaScript manually (not through DokuWiki concatenation) 119 $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>'; 120 121 // Initialize DOKU_BASE for JavaScript 122 $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>'; 123 124 // Embed events data as JSON for JavaScript access 125 $html .= '<script type="application/json" id="events-data-' . $calId . '">' . json_encode($events) . '</script>'; 126 127 // Left side: Calendar 128 $html .= '<div class="calendar-compact-left">'; 129 130 // Header with navigation 131 $html .= '<div class="calendar-compact-header">'; 132 $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>'; 133 $html .= '<h3 class="calendar-month-picker" onclick="openMonthPicker(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . '</h3>'; 134 $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>'; 135 $html .= '<button class="cal-today-btn" onclick="jumpToToday(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>'; 136 $html .= '</div>'; 137 138 // Namespace filter indicator - only show if actively filtering a specific namespace 139 if ($namespace && $namespace !== '*' && strpos($namespace, '*') === false && strpos($namespace, ';') === false) { 140 $html .= '<div class="calendar-namespace-filter" id="namespace-filter-' . $calId . '">'; 141 $html .= '<span class="namespace-filter-label">Filtering:</span>'; 142 $html .= '<span class="namespace-filter-name">' . htmlspecialchars($namespace) . '</span>'; 143 $html .= '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' . $calId . '\')" title="Clear filter and show all namespaces">✕</button>'; 144 $html .= '</div>'; 145 } 146 147 // Calendar grid 148 $html .= '<table class="calendar-compact-grid">'; 149 $html .= '<thead><tr>'; 150 $html .= '<th>S</th><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th>'; 151 $html .= '</tr></thead><tbody>'; 152 153 $firstDay = mktime(0, 0, 0, $month, 1, $year); 154 $daysInMonth = date('t', $firstDay); 155 $dayOfWeek = date('w', $firstDay); 156 157 // Build a map of all events with their date ranges for the calendar grid 158 $eventRanges = array(); 159 foreach ($events as $dateKey => $dayEvents) { 160 foreach ($dayEvents as $evt) { 161 $eventId = isset($evt['id']) ? $evt['id'] : ''; 162 $startDate = $dateKey; 163 $endDate = isset($evt['endDate']) && $evt['endDate'] ? $evt['endDate'] : $dateKey; 164 165 // Only process events that touch this month 166 $eventStart = new DateTime($startDate); 167 $eventEnd = new DateTime($endDate); 168 $monthStart = new DateTime(sprintf('%04d-%02d-01', $year, $month)); 169 $monthEnd = new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth)); 170 171 // Skip if event doesn't overlap with current month 172 if ($eventEnd < $monthStart || $eventStart > $monthEnd) { 173 continue; 174 } 175 176 // Create entry for each day the event spans 177 $current = clone $eventStart; 178 while ($current <= $eventEnd) { 179 $currentKey = $current->format('Y-m-d'); 180 181 // Check if this date is in current month 182 $currentDate = DateTime::createFromFormat('Y-m-d', $currentKey); 183 if ($currentDate && $currentDate->format('Y-m') === sprintf('%04d-%02d', $year, $month)) { 184 if (!isset($eventRanges[$currentKey])) { 185 $eventRanges[$currentKey] = array(); 186 } 187 188 // Add event with span information 189 $evt['_span_start'] = $startDate; 190 $evt['_span_end'] = $endDate; 191 $evt['_is_first_day'] = ($currentKey === $startDate); 192 $evt['_is_last_day'] = ($currentKey === $endDate); 193 $evt['_original_date'] = $dateKey; // Keep track of original date 194 195 // Check if event continues from previous month or to next month 196 $evt['_continues_from_prev'] = ($eventStart < $monthStart); 197 $evt['_continues_to_next'] = ($eventEnd > $monthEnd); 198 199 $eventRanges[$currentKey][] = $evt; 200 } 201 202 $current->modify('+1 day'); 203 } 204 } 205 } 206 207 $currentDay = 1; 208 $rowCount = ceil(($daysInMonth + $dayOfWeek) / 7); 209 210 for ($row = 0; $row < $rowCount; $row++) { 211 $html .= '<tr>'; 212 for ($col = 0; $col < 7; $col++) { 213 if (($row === 0 && $col < $dayOfWeek) || $currentDay > $daysInMonth) { 214 $html .= '<td class="cal-empty"></td>'; 215 } else { 216 $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $currentDay); 217 $isToday = ($dateKey === date('Y-m-d')); 218 $hasEvents = isset($eventRanges[$dateKey]) && !empty($eventRanges[$dateKey]); 219 220 $classes = 'cal-day'; 221 if ($isToday) $classes .= ' cal-today'; 222 if ($hasEvents) $classes .= ' cal-has-events'; 223 224 $html .= '<td class="' . $classes . '" data-date="' . $dateKey . '" onclick="showDayPopup(\'' . $calId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">'; 225 $html .= '<span class="day-num">' . $currentDay . '</span>'; 226 227 if ($hasEvents) { 228 // Sort events by time (no time first, then by time) 229 $sortedEvents = $eventRanges[$dateKey]; 230 usort($sortedEvents, function($a, $b) { 231 $timeA = isset($a['time']) ? $a['time'] : ''; 232 $timeB = isset($b['time']) ? $b['time'] : ''; 233 234 // Events without time go first 235 if (empty($timeA) && !empty($timeB)) return -1; 236 if (!empty($timeA) && empty($timeB)) return 1; 237 if (empty($timeA) && empty($timeB)) return 0; 238 239 // Sort by time 240 return strcmp($timeA, $timeB); 241 }); 242 243 // Show colored stacked bars for each event 244 $html .= '<div class="event-indicators">'; 245 foreach ($sortedEvents as $evt) { 246 $eventId = isset($evt['id']) ? $evt['id'] : ''; 247 $eventColor = isset($evt['color']) ? htmlspecialchars($evt['color']) : '#3498db'; 248 $eventTime = isset($evt['time']) ? $evt['time'] : ''; 249 $eventTitle = isset($evt['title']) ? htmlspecialchars($evt['title']) : 'Event'; 250 $originalDate = isset($evt['_original_date']) ? $evt['_original_date'] : $dateKey; 251 $isFirstDay = isset($evt['_is_first_day']) ? $evt['_is_first_day'] : true; 252 $isLastDay = isset($evt['_is_last_day']) ? $evt['_is_last_day'] : true; 253 254 $barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed'; 255 256 // Add classes for multi-day spanning 257 if (!$isFirstDay) $barClass .= ' event-bar-continues'; 258 if (!$isLastDay) $barClass .= ' event-bar-continuing'; 259 260 $html .= '<span class="event-bar ' . $barClass . '" '; 261 $html .= 'style="background: ' . $eventColor . ';" '; 262 $html .= 'title="' . $eventTitle . ($eventTime ? ' @ ' . $eventTime : '') . '" '; 263 $html .= 'onclick="event.stopPropagation(); highlightEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $originalDate . '\');">'; 264 $html .= '</span>'; 265 } 266 $html .= '</div>'; 267 } 268 269 $html .= '</td>'; 270 $currentDay++; 271 } 272 } 273 $html .= '</tr>'; 274 } 275 276 $html .= '</tbody></table>'; 277 $html .= '</div>'; // End calendar-left 278 279 // Right side: Event list 280 $html .= '<div class="calendar-compact-right">'; 281 $html .= '<div class="event-list-header">'; 282 $html .= '<div class="event-list-header-content">'; 283 $html .= '<h4 id="eventlist-title-' . $calId . '">Events</h4>'; 284 if ($namespace) { 285 $html .= '<span class="namespace-badge">' . htmlspecialchars($namespace) . '</span>'; 286 } 287 $html .= '</div>'; 288 289 // Search bar in header 290 $html .= '<div class="event-search-container-inline">'; 291 $html .= '<input type="text" class="event-search-input-inline" id="event-search-' . $calId . '" placeholder=" Search..." oninput="filterEvents(\'' . $calId . '\', this.value)">'; 292 $html .= '<button class="event-search-clear-inline" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>'; 293 $html .= '</div>'; 294 295 $html .= '<button class="add-event-compact" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>'; 296 $html .= '</div>'; 297 298 $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '">'; 299 $html .= $this->renderEventListContent($events, $calId, $namespace); 300 $html .= '</div>'; 301 302 $html .= '</div>'; // End calendar-right 303 304 // Event dialog 305 $html .= $this->renderEventDialog($calId, $namespace); 306 307 // Month/Year picker dialog (at container level for proper overlay) 308 $html .= $this->renderMonthPicker($calId, $year, $month, $namespace); 309 310 $html .= '</div>'; // End container 311 312 return $html; 313 } 314 315 private function renderEventListContent($events, $calId, $namespace) { 316 if (empty($events)) { 317 return '<p class="no-events-msg">No events this month</p>'; 318 } 319 320 // Check for time conflicts 321 $events = $this->checkTimeConflicts($events); 322 323 // Sort by date ascending (chronological order - oldest first) 324 ksort($events); 325 326 // Sort events within each day by time 327 foreach ($events as $dateKey => &$dayEvents) { 328 usort($dayEvents, function($a, $b) { 329 $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null; 330 $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null; 331 332 // All-day events (no time) go to the TOP 333 if ($timeA === null && $timeB !== null) return -1; // A before B 334 if ($timeA !== null && $timeB === null) return 1; // A after B 335 if ($timeA === null && $timeB === null) return 0; // Both all-day, equal 336 337 // Both have times, sort chronologically 338 return strcmp($timeA, $timeB); 339 }); 340 } 341 unset($dayEvents); // Break reference 342 343 // Get today's date for comparison 344 $today = date('Y-m-d'); 345 $firstFutureEventId = null; 346 347 // Helper function to check if event is past (with 15-minute grace period for timed events) 348 $isEventPast = function($dateKey, $time) use ($today) { 349 // If event is on a past date, it's definitely past 350 if ($dateKey < $today) { 351 return true; 352 } 353 354 // If event is on a future date, it's definitely not past 355 if ($dateKey > $today) { 356 return false; 357 } 358 359 // Event is today - check time with grace period 360 if ($time && $time !== '') { 361 try { 362 $currentDateTime = new DateTime(); 363 $eventDateTime = new DateTime($dateKey . ' ' . $time); 364 365 // Add 15-minute grace period 366 $eventDateTime->modify('+15 minutes'); 367 368 // Event is past if current time > event time + 15 minutes 369 return $currentDateTime > $eventDateTime; 370 } catch (Exception $e) { 371 // If time parsing fails, fall back to date-only comparison 372 return false; 373 } 374 } 375 376 // No time specified for today's event, treat as future 377 return false; 378 }; 379 380 // Build HTML for each event - separate past/completed from future 381 $pastHtml = ''; 382 $futureHtml = ''; 383 $pastCount = 0; 384 385 foreach ($events as $dateKey => $dayEvents) { 386 387 foreach ($dayEvents as $event) { 388 // Track first future/today event for auto-scroll 389 if (!$firstFutureEventId && $dateKey >= $today) { 390 $firstFutureEventId = isset($event['id']) ? $event['id'] : ''; 391 } 392 $eventId = isset($event['id']) ? $event['id'] : ''; 393 $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled'; 394 $timeRaw = isset($event['time']) ? $event['time'] : ''; 395 $time = htmlspecialchars($timeRaw); 396 $endTime = isset($event['endTime']) ? htmlspecialchars($event['endTime']) : ''; 397 $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db'; 398 $description = isset($event['description']) ? $event['description'] : ''; 399 $isTask = isset($event['isTask']) ? $event['isTask'] : false; 400 $completed = isset($event['completed']) ? $event['completed'] : false; 401 $endDate = isset($event['endDate']) ? $event['endDate'] : ''; 402 403 // Use helper function to determine if event is past (with grace period) 404 $isPast = $isEventPast($dateKey, $timeRaw); 405 $isToday = $dateKey === $today; 406 407 // Check if event should be in past section 408 // EXCEPTION: Uncompleted tasks (isTask && !completed) should stay visible even if past 409 $isPastOrCompleted = ($isPast && (!$isTask || $completed)) || $completed; 410 if ($isPastOrCompleted) { 411 $pastCount++; 412 } 413 414 // Determine if task is past due (past date, is task, not completed) 415 $isPastDue = $isPast && $isTask && !$completed; 416 417 // Process description for wiki syntax, HTML, images, and links 418 $renderedDescription = $this->renderDescription($description); 419 420 // Convert to 12-hour format and handle time ranges 421 $displayTime = ''; 422 if ($time) { 423 $timeObj = DateTime::createFromFormat('H:i', $time); 424 if ($timeObj) { 425 $displayTime = $timeObj->format('g:i A'); 426 427 // Add end time if present and different from start time 428 if ($endTime && $endTime !== $time) { 429 $endTimeObj = DateTime::createFromFormat('H:i', $endTime); 430 if ($endTimeObj) { 431 $displayTime .= ' - ' . $endTimeObj->format('g:i A'); 432 } 433 } 434 } else { 435 $displayTime = $time; 436 } 437 } 438 439 // Format date display with day of week 440 // Use originalStartDate if this is a multi-month event continuation 441 $displayDateKey = isset($event['originalStartDate']) ? $event['originalStartDate'] : $dateKey; 442 $dateObj = new DateTime($displayDateKey); 443 $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Jan 24" 444 445 // Multi-day indicator 446 $multiDay = ''; 447 if ($endDate && $endDate !== $displayDateKey) { 448 $endObj = new DateTime($endDate); 449 $multiDay = ' → ' . $endObj->format('D, M j'); 450 } 451 452 $completedClass = $completed ? ' event-completed' : ''; 453 // Don't grey out past due tasks - they need attention! 454 $pastClass = ($isPast && !$isPastDue) ? ' event-past' : ''; 455 $pastDueClass = $isPastDue ? ' event-pastdue' : ''; 456 $firstFutureAttr = ($firstFutureEventId === $eventId) ? ' data-first-future="true"' : ''; 457 458 $eventHtml = '<div class="event-compact-item' . $completedClass . $pastClass . $pastDueClass . '" data-event-id="' . $eventId . '" data-date="' . $dateKey . '" style="border-left-color: ' . $color . ';"' . $firstFutureAttr . '>'; 459 460 $eventHtml .= '<div class="event-info">'; 461 $eventHtml .= '<div class="event-title-row">'; 462 $eventHtml .= '<span class="event-title-compact">' . $title . '</span>'; 463 $eventHtml .= '</div>'; 464 465 // For past events, hide meta and description (collapsed) 466 // EXCEPTION: Past due tasks should show their details 467 if (!$isPast || $isPastDue) { 468 $eventHtml .= '<div class="event-meta-compact">'; 469 $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay; 470 if ($displayTime) { 471 $eventHtml .= ' • ' . $displayTime; 472 } 473 // Add TODAY badge for today's events OR PAST DUE for uncompleted past tasks 474 if ($isPastDue) { 475 $eventHtml .= ' <span class="event-pastdue-badge">PAST DUE</span>'; 476 } elseif ($isToday) { 477 $eventHtml .= ' <span class="event-today-badge">TODAY</span>'; 478 } 479 // Add namespace badge - ALWAYS show if event has a namespace 480 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 481 if (!$eventNamespace && isset($event['_namespace'])) { 482 $eventNamespace = $event['_namespace']; // Fallback to _namespace for backward compatibility 483 } 484 // Show badge if namespace exists and is not empty 485 if ($eventNamespace && $eventNamespace !== '') { 486 $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>'; 487 } 488 489 // Add conflict warning if event has time conflicts 490 if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) { 491 $conflictList = []; 492 foreach ($event['conflictsWith'] as $conflict) { 493 $conflictText = htmlspecialchars($conflict['title']); 494 if (!empty($conflict['time'])) { 495 // Format time range 496 $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']); 497 $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time']; 498 499 if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) { 500 $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']); 501 $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime']; 502 $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')'; 503 } else { 504 $conflictText .= ' (' . $startTimeFormatted . ')'; 505 } 506 } 507 $conflictList[] = $conflictText; 508 } 509 $conflictCount = count($event['conflictsWith']); 510 $conflictJson = htmlspecialchars(json_encode($conflictList), ENT_QUOTES, 'UTF-8'); 511 $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>'; 512 } 513 514 $eventHtml .= '</span>'; 515 $eventHtml .= '</div>'; 516 517 if ($description) { 518 $eventHtml .= '<div class="event-desc-compact">' . $renderedDescription . '</div>'; 519 } 520 } else { 521 // Past events: render with display:none for click-to-expand 522 $eventHtml .= '<div class="event-meta-compact" style="display:none;">'; 523 $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay; 524 if ($displayTime) { 525 $eventHtml .= ' • ' . $displayTime; 526 } 527 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 528 if (!$eventNamespace && isset($event['_namespace'])) { 529 $eventNamespace = $event['_namespace']; 530 } 531 if ($eventNamespace && $eventNamespace !== '') { 532 $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>'; 533 } 534 535 // Add conflict warning if event has time conflicts 536 if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) { 537 $conflictList = []; 538 foreach ($event['conflictsWith'] as $conflict) { 539 $conflictText = htmlspecialchars($conflict['title']); 540 if (!empty($conflict['time'])) { 541 $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']); 542 $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time']; 543 544 if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) { 545 $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']); 546 $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime']; 547 $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')'; 548 } else { 549 $conflictText .= ' (' . $startTimeFormatted . ')'; 550 } 551 } 552 $conflictList[] = $conflictText; 553 } 554 $conflictCount = count($event['conflictsWith']); 555 $conflictJson = htmlspecialchars(json_encode($conflictList), ENT_QUOTES, 'UTF-8'); 556 $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>'; 557 } 558 559 $eventHtml .= '</span>'; 560 $eventHtml .= '</div>'; 561 562 if ($description) { 563 $eventHtml .= '<div class="event-desc-compact" style="display:none;">' . $renderedDescription . '</div>'; 564 } 565 } 566 567 $eventHtml .= '</div>'; // event-info 568 569 // Use stored namespace from event, fallback to passed namespace 570 $buttonNamespace = isset($event['namespace']) ? $event['namespace'] : $namespace; 571 572 $eventHtml .= '<div class="event-actions-compact">'; 573 $eventHtml .= '<button class="event-action-btn" onclick="deleteEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">️</button>'; 574 $eventHtml .= '<button class="event-action-btn" onclick="editEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">✏️</button>'; 575 $eventHtml .= '</div>'; 576 577 // Checkbox for tasks - ON THE FAR RIGHT 578 if ($isTask) { 579 $checked = $completed ? 'checked' : ''; 580 $eventHtml .= '<input type="checkbox" class="task-checkbox" ' . $checked . ' onclick="toggleTaskComplete(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\', this.checked)">'; 581 } 582 583 $eventHtml .= '</div>'; 584 585 // Add to appropriate section 586 if ($isPastOrCompleted) { 587 $pastHtml .= $eventHtml; 588 } else { 589 $futureHtml .= $eventHtml; 590 } 591 } 592 } 593 594 // Build final HTML with collapsible past events section 595 $html = ''; 596 597 // Add collapsible past events section if any exist 598 if ($pastCount > 0) { 599 $html .= '<div class="past-events-section">'; 600 $html .= '<div class="past-events-toggle" onclick="togglePastEvents(\'' . $calId . '\')">'; 601 $html .= '<span class="past-events-arrow" id="past-arrow-' . $calId . '">▶</span> '; 602 $html .= '<span class="past-events-label">Past Events (' . $pastCount . ')</span>'; 603 $html .= '</div>'; 604 $html .= '<div class="past-events-content" id="past-events-' . $calId . '" style="display:none;">'; 605 $html .= $pastHtml; 606 $html .= '</div>'; 607 $html .= '</div>'; 608 } 609 610 // Add future events 611 $html .= $futureHtml; 612 613 return $html; 614 } 615 616 /** 617 * Check for time conflicts between events 618 */ 619 private function checkTimeConflicts($events) { 620 // Group events by date 621 $eventsByDate = []; 622 foreach ($events as $date => $dateEvents) { 623 if (!is_array($dateEvents)) continue; 624 625 foreach ($dateEvents as $evt) { 626 if (empty($evt['time'])) continue; // Skip all-day events 627 628 if (!isset($eventsByDate[$date])) { 629 $eventsByDate[$date] = []; 630 } 631 $eventsByDate[$date][] = $evt; 632 } 633 } 634 635 // Check for overlaps on each date 636 foreach ($eventsByDate as $date => $dateEvents) { 637 for ($i = 0; $i < count($dateEvents); $i++) { 638 for ($j = $i + 1; $j < count($dateEvents); $j++) { 639 if ($this->eventsOverlap($dateEvents[$i], $dateEvents[$j])) { 640 // Mark both events as conflicting 641 $dateEvents[$i]['hasConflict'] = true; 642 $dateEvents[$j]['hasConflict'] = true; 643 644 // Store conflict info 645 if (!isset($dateEvents[$i]['conflictsWith'])) { 646 $dateEvents[$i]['conflictsWith'] = []; 647 } 648 if (!isset($dateEvents[$j]['conflictsWith'])) { 649 $dateEvents[$j]['conflictsWith'] = []; 650 } 651 652 $dateEvents[$i]['conflictsWith'][] = [ 653 'id' => $dateEvents[$j]['id'], 654 'title' => $dateEvents[$j]['title'], 655 'time' => $dateEvents[$j]['time'], 656 'endTime' => isset($dateEvents[$j]['endTime']) ? $dateEvents[$j]['endTime'] : '' 657 ]; 658 659 $dateEvents[$j]['conflictsWith'][] = [ 660 'id' => $dateEvents[$i]['id'], 661 'title' => $dateEvents[$i]['title'], 662 'time' => $dateEvents[$i]['time'], 663 'endTime' => isset($dateEvents[$i]['endTime']) ? $dateEvents[$i]['endTime'] : '' 664 ]; 665 } 666 } 667 } 668 669 // Update the events array with conflict information 670 foreach ($events[$date] as &$evt) { 671 foreach ($dateEvents as $checkedEvt) { 672 if ($evt['id'] === $checkedEvt['id']) { 673 if (isset($checkedEvt['hasConflict'])) { 674 $evt['hasConflict'] = $checkedEvt['hasConflict']; 675 } 676 if (isset($checkedEvt['conflictsWith'])) { 677 $evt['conflictsWith'] = $checkedEvt['conflictsWith']; 678 } 679 break; 680 } 681 } 682 } 683 } 684 685 return $events; 686 } 687 688 /** 689 * Check if two events overlap in time 690 */ 691 private function eventsOverlap($evt1, $evt2) { 692 if (empty($evt1['time']) || empty($evt2['time'])) { 693 return false; // All-day events don't conflict 694 } 695 696 $start1 = $evt1['time']; 697 $end1 = isset($evt1['endTime']) && !empty($evt1['endTime']) ? $evt1['endTime'] : $evt1['time']; 698 699 $start2 = $evt2['time']; 700 $end2 = isset($evt2['endTime']) && !empty($evt2['endTime']) ? $evt2['endTime'] : $evt2['time']; 701 702 // Convert to minutes for easier comparison 703 $start1Mins = $this->timeToMinutes($start1); 704 $end1Mins = $this->timeToMinutes($end1); 705 $start2Mins = $this->timeToMinutes($start2); 706 $end2Mins = $this->timeToMinutes($end2); 707 708 // Check for overlap: start1 < end2 AND start2 < end1 709 return $start1Mins < $end2Mins && $start2Mins < $end1Mins; 710 } 711 712 /** 713 * Convert HH:MM time to minutes since midnight 714 */ 715 private function timeToMinutes($timeStr) { 716 $parts = explode(':', $timeStr); 717 if (count($parts) !== 2) return 0; 718 719 return (int)$parts[0] * 60 + (int)$parts[1]; 720 } 721 722 private function renderEventPanelOnly($data) { 723 $year = (int)$data['year']; 724 $month = (int)$data['month']; 725 $namespace = $data['namespace']; 726 $height = isset($data['height']) ? $data['height'] : '400px'; 727 728 // Validate height format (must be px, em, rem, vh, or %) 729 if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) { 730 $height = '400px'; // Default fallback 731 } 732 733 // Check if multiple namespaces or wildcard specified 734 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 735 736 if ($isMultiNamespace) { 737 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 738 } else { 739 $events = $this->loadEvents($namespace, $year, $month); 740 } 741 $calId = 'panel_' . md5(serialize($data) . microtime()); 742 743 $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year)); 744 745 $prevMonth = $month - 1; 746 $prevYear = $year; 747 if ($prevMonth < 1) { 748 $prevMonth = 12; 749 $prevYear--; 750 } 751 752 $nextMonth = $month + 1; 753 $nextYear = $year; 754 if ($nextMonth > 12) { 755 $nextMonth = 1; 756 $nextYear++; 757 } 758 759 $html = '<div class="event-panel-standalone" id="' . $calId . '" data-height="' . htmlspecialchars($height) . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '">'; 760 761 // Load calendar JavaScript manually (not through DokuWiki concatenation) 762 $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>'; 763 764 // Initialize DOKU_BASE for JavaScript 765 $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>'; 766 767 // Compact two-row header designed for ~500px width 768 $html .= '<div class="panel-header-compact">'; 769 770 // Row 1: Navigation and title 771 $html .= '<div class="panel-header-row-1">'; 772 $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>'; 773 774 // Compact month name (e.g. "Feb 2026" instead of "February 2026 Events") 775 $shortMonthName = date('M Y', mktime(0, 0, 0, $month, 1, $year)); 776 $html .= '<h3 class="panel-month-title" onclick="openMonthPickerPanel(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $shortMonthName . '</h3>'; 777 778 $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>'; 779 780 // Namespace badge (if applicable) 781 if ($namespace) { 782 if ($isMultiNamespace) { 783 if (strpos($namespace, '*') !== false) { 784 $html .= '<span class="panel-ns-badge" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>'; 785 } else { 786 $namespaceList = array_map('trim', explode(';', $namespace)); 787 $nsCount = count($namespaceList); 788 $html .= '<span class="panel-ns-badge" title="' . htmlspecialchars(implode(', ', $namespaceList)) . '">' . $nsCount . ' NS</span>'; 789 } 790 } else { 791 $isFiltering = ($namespace !== '*' && strpos($namespace, '*') === false && strpos($namespace, ';') === false); 792 if ($isFiltering) { 793 $html .= '<span class="panel-ns-badge filter-on" title="Filtering by ' . htmlspecialchars($namespace) . ' - click to clear" onclick="clearNamespaceFilterPanel(\'' . $calId . '\')">' . htmlspecialchars($namespace) . ' ✕</span>'; 794 } else { 795 $html .= '<span class="panel-ns-badge" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>'; 796 } 797 } 798 } 799 800 $html .= '<button class="panel-today-btn" onclick="jumpTodayPanel(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>'; 801 $html .= '</div>'; 802 803 // Row 2: Search and add button 804 $html .= '<div class="panel-header-row-2">'; 805 $html .= '<div class="panel-search-box">'; 806 $html .= '<input type="text" class="panel-search-input" id="event-search-' . $calId . '" placeholder="Search events..." oninput="filterEvents(\'' . $calId . '\', this.value)">'; 807 $html .= '<button class="panel-search-clear" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>'; 808 $html .= '</div>'; 809 $html .= '<button class="panel-add-btn" onclick="openAddEventPanel(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>'; 810 $html .= '</div>'; 811 812 $html .= '</div>'; 813 814 $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '" style="max-height: ' . htmlspecialchars($height) . ';">'; 815 $html .= $this->renderEventListContent($events, $calId, $namespace); 816 $html .= '</div>'; 817 818 $html .= $this->renderEventDialog($calId, $namespace); 819 820 // Month/Year picker for event panel 821 $html .= $this->renderMonthPicker($calId, $year, $month, $namespace); 822 823 $html .= '</div>'; 824 825 return $html; 826 } 827 828 private function renderStandaloneEventList($data) { 829 $namespace = $data['namespace']; 830 // If no namespace specified, show all namespaces 831 if (empty($namespace)) { 832 $namespace = '*'; 833 } 834 $daterange = $data['daterange']; 835 $date = $data['date']; 836 $range = isset($data['range']) ? strtolower($data['range']) : ''; 837 $today = isset($data['today']) ? true : false; 838 $sidebar = isset($data['sidebar']) ? true : false; 839 $showchecked = isset($data['showchecked']) ? true : false; // New parameter 840 $noheader = isset($data['noheader']) ? true : false; // New parameter to hide header 841 842 // Handle "range" parameter - day, week, or month 843 if ($range === 'day') { 844 $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks 845 $endDate = date('Y-m-d'); 846 $headerText = 'Today'; 847 } elseif ($range === 'week') { 848 $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks 849 $endDateTime = new DateTime(); 850 $endDateTime->modify('+7 days'); 851 $endDate = $endDateTime->format('Y-m-d'); 852 $headerText = 'This Week'; 853 } elseif ($range === 'month') { 854 $startDate = date('Y-m-01', strtotime('-1 month')); // Include previous month for past due tasks 855 $endDate = date('Y-m-t'); // Last of current month 856 $dt = new DateTime(); 857 $headerText = $dt->format('F Y'); 858 } elseif ($sidebar) { 859 // NEW: Sidebar widget - load current week's events 860 $weekStart = date('Y-m-d', strtotime('monday this week')); 861 $weekEnd = date('Y-m-d', strtotime('sunday this week')); 862 863 // Load events for the entire week 864 $start = new DateTime($weekStart); 865 $end = new DateTime($weekEnd); 866 $end->modify('+1 day'); // DatePeriod excludes end date 867 $interval = new DateInterval('P1D'); 868 $period = new DatePeriod($start, $interval, $end); 869 870 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 871 $allEvents = []; 872 $loadedMonths = []; 873 874 foreach ($period as $dt) { 875 $year = (int)$dt->format('Y'); 876 $month = (int)$dt->format('n'); 877 $dateKey = $dt->format('Y-m-d'); 878 879 $monthKey = $year . '-' . $month . '-' . $namespace; 880 881 if (!isset($loadedMonths[$monthKey])) { 882 if ($isMultiNamespace) { 883 $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month); 884 } else { 885 $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month); 886 } 887 } 888 889 $monthEvents = $loadedMonths[$monthKey]; 890 891 if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) { 892 $allEvents[$dateKey] = $monthEvents[$dateKey]; 893 } 894 } 895 896 // Apply time conflict detection 897 $allEvents = $this->checkTimeConflicts($allEvents); 898 899 $calId = 'sidebar-' . substr(md5($namespace . $weekStart), 0, 8); 900 901 // Render sidebar widget and return immediately 902 return $this->renderSidebarWidget($allEvents, $namespace, $calId); 903 } elseif ($today) { 904 $startDate = date('Y-m-d'); 905 $endDate = date('Y-m-d'); 906 $headerText = 'Today'; 907 } elseif ($daterange) { 908 list($startDate, $endDate) = explode(':', $daterange); 909 $start = new DateTime($startDate); 910 $end = new DateTime($endDate); 911 $headerText = $start->format('M j') . ' - ' . $end->format('M j, Y'); 912 } elseif ($date) { 913 $startDate = $date; 914 $endDate = $date; 915 $dt = new DateTime($date); 916 $headerText = $dt->format('l, F j, Y'); 917 } else { 918 $startDate = date('Y-m-01'); 919 $endDate = date('Y-m-t'); 920 $dt = new DateTime($startDate); 921 $headerText = $dt->format('F Y'); 922 } 923 924 // Load all events in date range 925 $allEvents = array(); 926 $start = new DateTime($startDate); 927 $end = new DateTime($endDate); 928 $end->modify('+1 day'); 929 930 $interval = new DateInterval('P1D'); 931 $period = new DatePeriod($start, $interval, $end); 932 933 // Check if multiple namespaces or wildcard specified 934 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 935 936 static $loadedMonths = array(); 937 938 foreach ($period as $dt) { 939 $year = (int)$dt->format('Y'); 940 $month = (int)$dt->format('n'); 941 $dateKey = $dt->format('Y-m-d'); 942 943 $monthKey = $year . '-' . $month . '-' . $namespace; 944 945 if (!isset($loadedMonths[$monthKey])) { 946 if ($isMultiNamespace) { 947 $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month); 948 } else { 949 $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month); 950 } 951 } 952 953 $monthEvents = $loadedMonths[$monthKey]; 954 955 if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) { 956 $allEvents[$dateKey] = $monthEvents[$dateKey]; 957 } 958 } 959 960 // Sort events by date (already sorted by dateKey), then by time within each day 961 foreach ($allEvents as $dateKey => &$dayEvents) { 962 usort($dayEvents, function($a, $b) { 963 $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null; 964 $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null; 965 966 // All-day events (no time) go to the TOP 967 if ($timeA === null && $timeB !== null) return -1; // A before B 968 if ($timeA !== null && $timeB === null) return 1; // A after B 969 if ($timeA === null && $timeB === null) return 0; // Both all-day, equal 970 971 // Both have times, sort chronologically 972 return strcmp($timeA, $timeB); 973 }); 974 } 975 unset($dayEvents); // Break reference 976 977 // Simple 2-line display widget 978 $calId = 'eventlist_' . uniqid(); 979 $html = '<div class="eventlist-simple" id="' . $calId . '">'; 980 981 // Load calendar JavaScript manually (not through DokuWiki concatenation) 982 $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>'; 983 984 // Initialize DOKU_BASE for JavaScript 985 $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>'; 986 987 // Add compact header with date and clock for "today" mode (unless noheader is set) 988 if ($today && !empty($allEvents) && !$noheader) { 989 $todayDate = new DateTime(); 990 $displayDate = $todayDate->format('D, M j, Y'); // "Fri, Jan 30, 2026" 991 $currentTime = $todayDate->format('g:i:s A'); // "2:45:30 PM" 992 993 $html .= '<div class="eventlist-today-header">'; 994 $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '">' . $currentTime . '</span>'; 995 $html .= '<div class="eventlist-bottom-info">'; 996 $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">️</span> <span id="weather-temp-' . $calId . '">--°</span></span>'; 997 $html .= '<span class="eventlist-today-date">' . $displayDate . '</span>'; 998 $html .= '</div>'; 999 1000 // Three CPU/Memory bars (all update live) 1001 $html .= '<div class="eventlist-stats-container">'; 1002 1003 // 5-minute load average (green, updates every 2 seconds) 1004 $html .= '<div class="eventlist-cpu-bar" onmouseover="showTooltip_' . $calId . '(\'green\')" onmouseout="hideTooltip_' . $calId . '(\'green\')">'; 1005 $html .= '<div class="eventlist-cpu-fill" id="cpu-5min-' . $calId . '" style="width: 0%;"></div>'; 1006 $html .= '<div class="system-tooltip" id="tooltip-green-' . $calId . '" style="display:none;"></div>'; 1007 $html .= '</div>'; 1008 1009 // Real-time CPU (purple, updates with 5-sec average) 1010 $html .= '<div class="eventlist-cpu-bar eventlist-cpu-realtime" onmouseover="showTooltip_' . $calId . '(\'purple\')" onmouseout="hideTooltip_' . $calId . '(\'purple\')">'; 1011 $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-purple" id="cpu-realtime-' . $calId . '" style="width: 0%;"></div>'; 1012 $html .= '<div class="system-tooltip" id="tooltip-purple-' . $calId . '" style="display:none;"></div>'; 1013 $html .= '</div>'; 1014 1015 // Real-time Memory (orange, updates) 1016 $html .= '<div class="eventlist-cpu-bar eventlist-mem-realtime" onmouseover="showTooltip_' . $calId . '(\'orange\')" onmouseout="hideTooltip_' . $calId . '(\'orange\')">'; 1017 $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-orange" id="mem-realtime-' . $calId . '" style="width: 0%;"></div>'; 1018 $html .= '<div class="system-tooltip" id="tooltip-orange-' . $calId . '" style="display:none;"></div>'; 1019 $html .= '</div>'; 1020 1021 $html .= '</div>'; 1022 $html .= '</div>'; 1023 1024 // Add JavaScript to update clock and weather 1025 $html .= '<script> 1026(function() { 1027 // Update clock every second 1028 function updateClock() { 1029 const now = new Date(); 1030 let hours = now.getHours(); 1031 const minutes = String(now.getMinutes()).padStart(2, "0"); 1032 const seconds = String(now.getSeconds()).padStart(2, "0"); 1033 const ampm = hours >= 12 ? "PM" : "AM"; 1034 hours = hours % 12 || 12; 1035 const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm; 1036 const clockEl = document.getElementById("clock-' . $calId . '"); 1037 if (clockEl) clockEl.textContent = timeStr; 1038 } 1039 setInterval(updateClock, 1000); 1040 1041 // Fetch weather (geolocation-based) 1042 function updateWeather() { 1043 if ("geolocation" in navigator) { 1044 navigator.geolocation.getCurrentPosition(function(position) { 1045 const lat = position.coords.latitude; 1046 const lon = position.coords.longitude; 1047 1048 // Use Open-Meteo API (free, no key required) 1049 fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t_weather=true&temperature_unit=fahrenheit`) 1050 .then(response => response.json()) 1051 .then(data => { 1052 if (data.current_weather) { 1053 const temp = Math.round(data.current_weather.temperature); 1054 const weatherCode = data.current_weather.weathercode; 1055 const icon = getWeatherIcon(weatherCode); 1056 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 1057 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 1058 if (iconEl) iconEl.textContent = icon; 1059 if (tempEl) tempEl.innerHTML = temp + "°"; 1060 } 1061 }) 1062 .catch(error => { 1063 console.log("Weather fetch error:", error); 1064 }); 1065 }, function(error) { 1066 // If geolocation fails, use Sacramento as default 1067 fetch("https://api.open-meteo.com/v1/forecast?latitude=38.5816&longitude=-121.4944¤t_weather=true&temperature_unit=fahrenheit") 1068 .then(response => response.json()) 1069 .then(data => { 1070 if (data.current_weather) { 1071 const temp = Math.round(data.current_weather.temperature); 1072 const weatherCode = data.current_weather.weathercode; 1073 const icon = getWeatherIcon(weatherCode); 1074 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 1075 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 1076 if (iconEl) iconEl.textContent = icon; 1077 if (tempEl) tempEl.innerHTML = temp + "°"; 1078 } 1079 }) 1080 .catch(err => console.log("Weather error:", err)); 1081 }); 1082 } else { 1083 // No geolocation, use Sacramento 1084 fetch("https://api.open-meteo.com/v1/forecast?latitude=38.5816&longitude=-121.4944¤t_weather=true&temperature_unit=fahrenheit") 1085 .then(response => response.json()) 1086 .then(data => { 1087 if (data.current_weather) { 1088 const temp = Math.round(data.current_weather.temperature); 1089 const weatherCode = data.current_weather.weathercode; 1090 const icon = getWeatherIcon(weatherCode); 1091 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 1092 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 1093 if (iconEl) iconEl.textContent = icon; 1094 if (tempEl) tempEl.innerHTML = temp + "°"; 1095 } 1096 }) 1097 .catch(err => console.log("Weather error:", err)); 1098 } 1099 } 1100 1101 // WMO Weather interpretation codes 1102 function getWeatherIcon(code) { 1103 const icons = { 1104 0: "☀️", // Clear sky 1105 1: "️", // Mainly clear 1106 2: "⛅", // Partly cloudy 1107 3: "☁️", // Overcast 1108 45: "️", // Fog 1109 48: "️", // Depositing rime fog 1110 51: "️", // Light drizzle 1111 53: "️", // Moderate drizzle 1112 55: "️", // Dense drizzle 1113 61: "️", // Slight rain 1114 63: "️", // Moderate rain 1115 65: "⛈️", // Heavy rain 1116 71: "️", // Slight snow 1117 73: "️", // Moderate snow 1118 75: "❄️", // Heavy snow 1119 77: "️", // Snow grains 1120 80: "️", // Slight rain showers 1121 81: "️", // Moderate rain showers 1122 82: "⛈️", // Violent rain showers 1123 85: "️", // Slight snow showers 1124 86: "❄️", // Heavy snow showers 1125 95: "⛈️", // Thunderstorm 1126 96: "⛈️", // Thunderstorm with slight hail 1127 99: "⛈️" // Thunderstorm with heavy hail 1128 }; 1129 return icons[code] || "️"; 1130 } 1131 1132 // Update weather immediately and every 10 minutes 1133 updateWeather(); 1134 setInterval(updateWeather, 600000); 1135 1136 // CPU load history for 4-second rolling average 1137 const cpuHistory = []; 1138 const CPU_HISTORY_SIZE = 2; // 2 samples × 2 seconds = 4 seconds 1139 1140 // Store latest system stats for tooltips 1141 let latestStats = { 1142 load: {"1min": 0, "5min": 0, "15min": 0}, 1143 uptime: "", 1144 memory_details: {}, 1145 top_processes: [] 1146 }; 1147 1148 // Tooltip functions 1149 window["showTooltip_' . $calId . '"] = function(color) { 1150 const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '"); 1151 if (!tooltip) { 1152 console.log("Tooltip element not found for color:", color); 1153 return; 1154 } 1155 1156 console.log("Showing tooltip for:", color, "latestStats:", latestStats); 1157 1158 let content = ""; 1159 1160 if (color === "green") { 1161 // Green bar: Load averages and uptime 1162 content = "<div class=\"tooltip-title\">CPU Load Average</div>"; 1163 content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>"; 1164 content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>"; 1165 content += "<div>15 min: " + (latestStats.load["15min"] || "N/A") + "</div>"; 1166 if (latestStats.uptime) { 1167 content += "<div style=\"margin-top:3px; padding-top:2px; border-top:1px solid rgba(0,204,7,0.3);\">Uptime: " + latestStats.uptime + "</div>"; 1168 } 1169 tooltip.style.borderColor = "#00cc07"; 1170 tooltip.style.color = "#00cc07"; 1171 } else if (color === "purple") { 1172 // Purple bar: Load averages (short-term) and top processes 1173 content = "<div class=\"tooltip-title\">CPU Load (Short-term)</div>"; 1174 content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>"; 1175 content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>"; 1176 if (latestStats.top_processes && latestStats.top_processes.length > 0) { 1177 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>"; 1178 latestStats.top_processes.slice(0, 5).forEach(proc => { 1179 content += "<div>" + proc.cpu + " " + proc.command + "</div>"; 1180 }); 1181 } 1182 tooltip.style.borderColor = "#9b59b6"; 1183 tooltip.style.color = "#9b59b6"; 1184 } else if (color === "orange") { 1185 // Orange bar: Memory details and top processes 1186 content = "<div class=\"tooltip-title\">Memory Usage</div>"; 1187 if (latestStats.memory_details && latestStats.memory_details.total) { 1188 content += "<div>Total: " + latestStats.memory_details.total + "</div>"; 1189 content += "<div>Used: " + latestStats.memory_details.used + "</div>"; 1190 content += "<div>Available: " + latestStats.memory_details.available + "</div>"; 1191 if (latestStats.memory_details.cached) { 1192 content += "<div>Cached: " + latestStats.memory_details.cached + "</div>"; 1193 } 1194 } else { 1195 content += "<div>Loading...</div>"; 1196 } 1197 if (latestStats.top_processes && latestStats.top_processes.length > 0) { 1198 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>"; 1199 latestStats.top_processes.slice(0, 5).forEach(proc => { 1200 content += "<div>" + proc.cpu + " " + proc.command + "</div>"; 1201 }); 1202 } 1203 tooltip.style.borderColor = "#ff9800"; 1204 tooltip.style.color = "#ff9800"; 1205 } 1206 1207 console.log("Tooltip content:", content); 1208 tooltip.innerHTML = content; 1209 tooltip.style.display = "block"; 1210 1211 // Position tooltip using fixed positioning above the bar 1212 const bar = tooltip.parentElement; 1213 const barRect = bar.getBoundingClientRect(); 1214 const tooltipRect = tooltip.getBoundingClientRect(); 1215 1216 // Center horizontally on the bar 1217 const left = barRect.left + (barRect.width / 2) - (tooltipRect.width / 2); 1218 // Position above the bar with 8px gap 1219 const top = barRect.top - tooltipRect.height - 8; 1220 1221 tooltip.style.left = left + "px"; 1222 tooltip.style.top = top + "px"; 1223 }; 1224 1225 window["hideTooltip_' . $calId . '"] = function(color) { 1226 const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '"); 1227 if (tooltip) { 1228 tooltip.style.display = "none"; 1229 } 1230 }; 1231 1232 // Update CPU and memory bars every 2 seconds 1233 function updateSystemStats() { 1234 // Fetch real system stats from server 1235 fetch("' . DOKU_BASE . 'lib/plugins/calendar/get_system_stats.php") 1236 .then(response => response.json()) 1237 .then(data => { 1238 console.log("System stats received:", data); 1239 1240 // Store data for tooltips 1241 latestStats = { 1242 load: data.load || {"1min": 0, "5min": 0, "15min": 0}, 1243 uptime: data.uptime || "", 1244 memory_details: data.memory_details || {}, 1245 top_processes: data.top_processes || [] 1246 }; 1247 1248 console.log("latestStats updated to:", latestStats); 1249 1250 // Update green bar (5-minute average) - updates live now! 1251 const greenBar = document.getElementById("cpu-5min-' . $calId . '"); 1252 if (greenBar) { 1253 greenBar.style.width = Math.min(100, data.cpu_5min) + "%"; 1254 } 1255 1256 // Add current CPU to history for purple bar 1257 cpuHistory.push(data.cpu); 1258 if (cpuHistory.length > CPU_HISTORY_SIZE) { 1259 cpuHistory.shift(); // Remove oldest 1260 } 1261 1262 // Calculate 5-second average for CPU 1263 const cpuAverage = cpuHistory.reduce((sum, val) => sum + val, 0) / cpuHistory.length; 1264 1265 // Update CPU bar (purple) with 5-second average 1266 const cpuBar = document.getElementById("cpu-realtime-' . $calId . '"); 1267 if (cpuBar) { 1268 cpuBar.style.width = Math.min(100, cpuAverage) + "%"; 1269 } 1270 1271 // Update memory bar (orange) with real data 1272 const memBar = document.getElementById("mem-realtime-' . $calId . '"); 1273 if (memBar) { 1274 memBar.style.width = Math.min(100, data.memory) + "%"; 1275 } 1276 }) 1277 .catch(error => { 1278 console.log("System stats error:", error); 1279 // Fallback to client-side estimates on error 1280 const cpuFallback = Math.random() * 100; 1281 cpuHistory.push(cpuFallback); 1282 if (cpuHistory.length > CPU_HISTORY_SIZE) { 1283 cpuHistory.shift(); 1284 } 1285 const cpuAverage = cpuHistory.reduce((sum, val) => sum + val, 0) / cpuHistory.length; 1286 1287 const greenBar = document.getElementById("cpu-5min-' . $calId . '"); 1288 if (greenBar) greenBar.style.width = Math.min(100, cpuFallback) + "%"; 1289 1290 const cpuBar = document.getElementById("cpu-realtime-' . $calId . '"); 1291 if (cpuBar) cpuBar.style.width = Math.min(100, cpuAverage) + "%"; 1292 1293 let memoryUsage = 0; 1294 if (performance.memory) { 1295 memoryUsage = (performance.memory.usedJSHeapSize / performance.memory.jsHeapSizeLimit) * 100; 1296 } else { 1297 memoryUsage = Math.random() * 100; 1298 } 1299 const memBar = document.getElementById("mem-realtime-' . $calId . '"); 1300 if (memBar) memBar.style.width = Math.min(100, memoryUsage) + "%"; 1301 }); 1302 } 1303 1304 // Update immediately and then every 2 seconds 1305 updateSystemStats(); 1306 setInterval(updateSystemStats, 2000); 1307})(); 1308</script>'; 1309 } 1310 1311 if (empty($allEvents)) { 1312 $html .= '<div class="eventlist-simple-empty">'; 1313 $html .= '<div class="eventlist-simple-header">' . htmlspecialchars($headerText); 1314 if ($namespace) { 1315 $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($namespace) . '</span>'; 1316 } 1317 $html .= '</div>'; 1318 $html .= '<div class="eventlist-simple-body">No events</div>'; 1319 $html .= '</div>'; 1320 } else { 1321 // Calculate today and tomorrow's dates for highlighting 1322 $todayStr = date('Y-m-d'); 1323 $tomorrow = date('Y-m-d', strtotime('+1 day')); 1324 1325 foreach ($allEvents as $dateKey => $dayEvents) { 1326 $dateObj = new DateTime($dateKey); 1327 $displayDate = $dateObj->format('D, M j'); 1328 1329 // Check if this date is today or tomorrow or past 1330 // Enable highlighting for sidebar mode AND range modes (day, week, month) 1331 $enableHighlighting = $sidebar || !empty($range); 1332 $isToday = $enableHighlighting && ($dateKey === $todayStr); 1333 $isTomorrow = $enableHighlighting && ($dateKey === $tomorrow); 1334 $isPast = $dateKey < $todayStr; 1335 1336 foreach ($dayEvents as $event) { 1337 // Check if this is a task and if it's completed 1338 $isTask = !empty($event['isTask']); 1339 $completed = !empty($event['completed']); 1340 1341 // ALWAYS skip completed tasks UNLESS showchecked is explicitly set 1342 if (!$showchecked && $isTask && $completed) { 1343 continue; 1344 } 1345 1346 // Skip past events that are NOT tasks (only show past due tasks from the past) 1347 if ($isPast && !$isTask) { 1348 continue; 1349 } 1350 1351 // Determine if task is past due (past date, is task, not completed) 1352 $isPastDue = $isPast && $isTask && !$completed; 1353 1354 // Line 1: Header (Title, Time, Date, Namespace) 1355 $todayClass = $isToday ? ' eventlist-simple-today' : ''; 1356 $tomorrowClass = $isTomorrow ? ' eventlist-simple-tomorrow' : ''; 1357 $pastDueClass = $isPastDue ? ' eventlist-simple-pastdue' : ''; 1358 $html .= '<div class="eventlist-simple-item' . $todayClass . $tomorrowClass . $pastDueClass . '">'; 1359 $html .= '<div class="eventlist-simple-header">'; 1360 1361 // Title 1362 $html .= '<span class="eventlist-simple-title">' . htmlspecialchars($event['title']) . '</span>'; 1363 1364 // Time (12-hour format) 1365 if (!empty($event['time'])) { 1366 $timeParts = explode(':', $event['time']); 1367 if (count($timeParts) === 2) { 1368 $hour = (int)$timeParts[0]; 1369 $minute = $timeParts[1]; 1370 $ampm = $hour >= 12 ? 'PM' : 'AM'; 1371 $hour = $hour % 12 ?: 12; 1372 $displayTime = $hour . ':' . $minute . ' ' . $ampm; 1373 $html .= ' <span class="eventlist-simple-time">' . $displayTime . '</span>'; 1374 } 1375 } 1376 1377 // Date 1378 $html .= ' <span class="eventlist-simple-date">' . $displayDate . '</span>'; 1379 1380 // Badge: PAST DUE, TODAY, or nothing 1381 if ($isPastDue) { 1382 $html .= ' <span class="eventlist-simple-pastdue-badge">PAST DUE</span>'; 1383 } elseif ($isToday) { 1384 $html .= ' <span class="eventlist-simple-today-badge">TODAY</span>'; 1385 } 1386 1387 // Namespace badge (show individual event's namespace) 1388 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 1389 if (!$eventNamespace && isset($event['_namespace'])) { 1390 $eventNamespace = $event['_namespace']; // Fallback to _namespace for multi-namespace loading 1391 } 1392 if ($eventNamespace) { 1393 $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($eventNamespace) . '</span>'; 1394 } 1395 1396 $html .= '</div>'; // header 1397 1398 // Line 2: Body (Description only) - only show if description exists 1399 if (!empty($event['description'])) { 1400 $html .= '<div class="eventlist-simple-body">' . $this->renderDescription($event['description']) . '</div>'; 1401 } 1402 1403 $html .= '</div>'; // item 1404 } 1405 } 1406 } 1407 1408 $html .= '</div>'; // eventlist-simple 1409 1410 return $html; 1411 } 1412 1413 private function renderEventDialog($calId, $namespace) { 1414 $html = '<div class="event-dialog-compact" id="dialog-' . $calId . '" style="display:none;">'; 1415 $html .= '<div class="dialog-overlay" onclick="closeEventDialog(\'' . $calId . '\')"></div>'; 1416 1417 // Draggable dialog 1418 $html .= '<div class="dialog-content-sleek" id="dialog-content-' . $calId . '">'; 1419 1420 // Header with drag handle and close button 1421 $html .= '<div class="dialog-header-sleek dialog-drag-handle" id="drag-handle-' . $calId . '">'; 1422 $html .= '<h3 id="dialog-title-' . $calId . '">Add Event</h3>'; 1423 $html .= '<button type="button" class="dialog-close-btn" onclick="closeEventDialog(\'' . $calId . '\')">×</button>'; 1424 $html .= '</div>'; 1425 1426 // Form content 1427 $html .= '<form id="eventform-' . $calId . '" onsubmit="saveEventCompact(\'' . $calId . '\', \'' . $namespace . '\'); return false;" class="sleek-form">'; 1428 1429 // Hidden ID field 1430 $html .= '<input type="hidden" id="event-id-' . $calId . '" name="eventId" value="">'; 1431 1432 // 1. TITLE 1433 $html .= '<div class="form-field">'; 1434 $html .= '<label class="field-label"> Title</label>'; 1435 $html .= '<input type="text" id="event-title-' . $calId . '" name="title" required class="input-sleek input-compact" placeholder="Event or task title...">'; 1436 $html .= '</div>'; 1437 1438 // 1.5 NAMESPACE SELECTOR (Searchable with fuzzy matching) 1439 $html .= '<div class="form-field">'; 1440 $html .= '<label class="field-label"> Namespace</label>'; 1441 1442 // Hidden field to store actual selected namespace 1443 $html .= '<input type="hidden" id="event-namespace-' . $calId . '" name="namespace" value="">'; 1444 1445 // Searchable input 1446 $html .= '<div class="namespace-search-wrapper">'; 1447 $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">'; 1448 $html .= '<div class="namespace-dropdown" id="event-namespace-dropdown-' . $calId . '" style="display:none;"></div>'; 1449 $html .= '</div>'; 1450 1451 // Store namespaces as JSON for JavaScript 1452 $allNamespaces = $this->getAllNamespaces(); 1453 $html .= '<script type="application/json" id="namespaces-data-' . $calId . '">' . json_encode($allNamespaces) . '</script>'; 1454 1455 $html .= '</div>'; 1456 1457 // 2. DESCRIPTION 1458 $html .= '<div class="form-field">'; 1459 $html .= '<label class="field-label"> Description</label>'; 1460 $html .= '<textarea id="event-desc-' . $calId . '" name="description" rows="1" class="input-sleek textarea-sleek textarea-compact" placeholder="Optional details..."></textarea>'; 1461 $html .= '</div>'; 1462 1463 // 3. START DATE - END DATE (inline) 1464 $html .= '<div class="form-row-group">'; 1465 1466 $html .= '<div class="form-field form-field-half">'; 1467 $html .= '<label class="field-label-compact"> Start Date</label>'; 1468 $html .= '<input type="date" id="event-date-' . $calId . '" name="date" required class="input-sleek input-date input-compact">'; 1469 $html .= '</div>'; 1470 1471 $html .= '<div class="form-field form-field-half">'; 1472 $html .= '<label class="field-label-compact"> End Date</label>'; 1473 $html .= '<input type="date" id="event-end-date-' . $calId . '" name="endDate" class="input-sleek input-date input-compact" placeholder="Optional">'; 1474 $html .= '</div>'; 1475 1476 $html .= '</div>'; // End row 1477 1478 // 4. IS REPEATING CHECKBOX 1479 $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">'; 1480 $html .= '<label class="checkbox-label checkbox-label-compact">'; 1481 $html .= '<input type="checkbox" id="event-recurring-' . $calId . '" name="isRecurring" class="recurring-toggle" onchange="toggleRecurringOptions(\'' . $calId . '\')">'; 1482 $html .= '<span> Repeating Event</span>'; 1483 $html .= '</label>'; 1484 $html .= '</div>'; 1485 1486 // Recurring options (shown when checkbox is checked) 1487 $html .= '<div id="recurring-options-' . $calId . '" class="recurring-options" style="display:none;">'; 1488 1489 $html .= '<div class="form-row-group">'; 1490 1491 $html .= '<div class="form-field form-field-half">'; 1492 $html .= '<label class="field-label-compact">Repeat Every</label>'; 1493 $html .= '<select id="event-recurrence-type-' . $calId . '" name="recurrenceType" class="input-sleek input-compact">'; 1494 $html .= '<option value="daily">Daily</option>'; 1495 $html .= '<option value="weekly">Weekly</option>'; 1496 $html .= '<option value="monthly">Monthly</option>'; 1497 $html .= '<option value="yearly">Yearly</option>'; 1498 $html .= '</select>'; 1499 $html .= '</div>'; 1500 1501 $html .= '<div class="form-field form-field-half">'; 1502 $html .= '<label class="field-label-compact">Repeat Until</label>'; 1503 $html .= '<input type="date" id="event-recurrence-end-' . $calId . '" name="recurrenceEnd" class="input-sleek input-date input-compact" placeholder="Optional">'; 1504 $html .= '</div>'; 1505 1506 $html .= '</div>'; // End row 1507 $html .= '</div>'; // End recurring options 1508 1509 // 5. TIME (Start & End) - COLOR (inline) 1510 $html .= '<div class="form-row-group">'; 1511 1512 $html .= '<div class="form-field form-field-half">'; 1513 $html .= '<label class="field-label-compact"> Start Time</label>'; 1514 $html .= '<select id="event-time-' . $calId . '" name="time" class="input-sleek input-compact" onchange="updateEndTimeOptions(\'' . $calId . '\')">'; 1515 $html .= '<option value="">All day</option>'; 1516 1517 // Generate time options in 15-minute intervals 1518 for ($hour = 0; $hour < 24; $hour++) { 1519 for ($minute = 0; $minute < 60; $minute += 15) { 1520 $timeValue = sprintf('%02d:%02d', $hour, $minute); 1521 $displayHour = $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour); 1522 $ampm = $hour < 12 ? 'AM' : 'PM'; 1523 $displayTime = sprintf('%d:%02d %s', $displayHour, $minute, $ampm); 1524 $html .= '<option value="' . $timeValue . '">' . $displayTime . '</option>'; 1525 } 1526 } 1527 1528 $html .= '</select>'; 1529 $html .= '</div>'; 1530 1531 $html .= '<div class="form-field form-field-half">'; 1532 $html .= '<label class="field-label-compact"> End Time</label>'; 1533 $html .= '<select id="event-end-time-' . $calId . '" name="endTime" class="input-sleek input-compact">'; 1534 $html .= '<option value="">Same as start</option>'; 1535 1536 // Generate time options in 15-minute intervals 1537 for ($hour = 0; $hour < 24; $hour++) { 1538 for ($minute = 0; $minute < 60; $minute += 15) { 1539 $timeValue = sprintf('%02d:%02d', $hour, $minute); 1540 $displayHour = $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour); 1541 $ampm = $hour < 12 ? 'AM' : 'PM'; 1542 $displayTime = sprintf('%d:%02d %s', $displayHour, $minute, $ampm); 1543 $html .= '<option value="' . $timeValue . '">' . $displayTime . '</option>'; 1544 } 1545 } 1546 1547 $html .= '</select>'; 1548 $html .= '</div>'; 1549 1550 $html .= '</div>'; // End row 1551 1552 // Color field (new row) 1553 $html .= '<div class="form-row-group">'; 1554 1555 $html .= '<div class="form-field form-field-full">'; 1556 $html .= '<label class="field-label-compact"> Color</label>'; 1557 $html .= '<div class="color-picker-wrapper">'; 1558 $html .= '<select id="event-color-' . $calId . '" name="color" class="input-sleek input-compact color-select" onchange="updateCustomColorPicker(\'' . $calId . '\')">'; 1559 $html .= '<option value="#3498db" style="background:#3498db;color:white"> Blue</option>'; 1560 $html .= '<option value="#2ecc71" style="background:#2ecc71;color:white"> Green</option>'; 1561 $html .= '<option value="#e74c3c" style="background:#e74c3c;color:white"> Red</option>'; 1562 $html .= '<option value="#f39c12" style="background:#f39c12;color:white"> Orange</option>'; 1563 $html .= '<option value="#9b59b6" style="background:#9b59b6;color:white"> Purple</option>'; 1564 $html .= '<option value="#e91e63" style="background:#e91e63;color:white"> Pink</option>'; 1565 $html .= '<option value="#1abc9c" style="background:#1abc9c;color:white"> Teal</option>'; 1566 $html .= '<option value="custom"> Custom...</option>'; 1567 $html .= '</select>'; 1568 $html .= '<input type="color" id="event-color-custom-' . $calId . '" class="color-picker-input color-picker-compact" value="#3498db" onchange="updateColorFromPicker(\'' . $calId . '\')">'; 1569 $html .= '</div>'; 1570 $html .= '</div>'; 1571 1572 $html .= '</div>'; // End row 1573 1574 // Task checkbox 1575 $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">'; 1576 $html .= '<label class="checkbox-label checkbox-label-compact">'; 1577 $html .= '<input type="checkbox" id="event-is-task-' . $calId . '" name="isTask" class="task-toggle">'; 1578 $html .= '<span> This is a task (can be checked off)</span>'; 1579 $html .= '</label>'; 1580 $html .= '</div>'; 1581 1582 // Action buttons 1583 $html .= '<div class="dialog-actions-sleek">'; 1584 $html .= '<button type="button" class="btn-sleek btn-cancel-sleek" onclick="closeEventDialog(\'' . $calId . '\')">Cancel</button>'; 1585 $html .= '<button type="submit" class="btn-sleek btn-save-sleek"> Save</button>'; 1586 $html .= '</div>'; 1587 1588 $html .= '</form>'; 1589 $html .= '</div>'; 1590 $html .= '</div>'; 1591 1592 return $html; 1593 } 1594 1595 private function renderMonthPicker($calId, $year, $month, $namespace) { 1596 $html = '<div class="month-picker-overlay" id="month-picker-overlay-' . $calId . '" style="display:none;" onclick="closeMonthPicker(\'' . $calId . '\')">'; 1597 $html .= '<div class="month-picker-dialog" onclick="event.stopPropagation();">'; 1598 $html .= '<h4>Jump to Month</h4>'; 1599 1600 $html .= '<div class="month-picker-selects">'; 1601 $html .= '<select id="month-picker-month-' . $calId . '" class="month-picker-select">'; 1602 $monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; 1603 for ($m = 1; $m <= 12; $m++) { 1604 $selected = ($m == $month) ? ' selected' : ''; 1605 $html .= '<option value="' . $m . '"' . $selected . '>' . $monthNames[$m - 1] . '</option>'; 1606 } 1607 $html .= '</select>'; 1608 1609 $html .= '<select id="month-picker-year-' . $calId . '" class="month-picker-select">'; 1610 $currentYear = (int)date('Y'); 1611 for ($y = $currentYear - 5; $y <= $currentYear + 5; $y++) { 1612 $selected = ($y == $year) ? ' selected' : ''; 1613 $html .= '<option value="' . $y . '"' . $selected . '>' . $y . '</option>'; 1614 } 1615 $html .= '</select>'; 1616 $html .= '</div>'; 1617 1618 $html .= '<div class="month-picker-actions">'; 1619 $html .= '<button class="btn-sleek btn-cancel-sleek" onclick="closeMonthPicker(\'' . $calId . '\')">Cancel</button>'; 1620 $html .= '<button class="btn-sleek btn-save-sleek" onclick="jumpToSelectedMonth(\'' . $calId . '\', \'' . $namespace . '\')">Go</button>'; 1621 $html .= '</div>'; 1622 1623 $html .= '</div>'; 1624 $html .= '</div>'; 1625 1626 return $html; 1627 } 1628 1629 private function renderDescription($description) { 1630 if (empty($description)) { 1631 return ''; 1632 } 1633 1634 // Token-based parsing to avoid escaping issues 1635 $rendered = $description; 1636 $tokens = array(); 1637 $tokenIndex = 0; 1638 1639 // Convert DokuWiki image syntax {{image.jpg}} to tokens 1640 $pattern = '/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/'; 1641 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 1642 foreach ($matches as $match) { 1643 $imagePath = trim($match[1]); 1644 $alt = isset($match[2]) ? trim($match[2]) : ''; 1645 1646 // Handle external URLs 1647 if (preg_match('/^https?:\/\//', $imagePath)) { 1648 $imageHtml = '<img src="' . htmlspecialchars($imagePath) . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />'; 1649 } else { 1650 // Handle internal DokuWiki images 1651 $imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath); 1652 $imageHtml = '<img src="' . $imageUrl . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />'; 1653 } 1654 1655 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 1656 $tokens[$tokenIndex] = $imageHtml; 1657 $tokenIndex++; 1658 $rendered = str_replace($match[0], $token, $rendered); 1659 } 1660 1661 // Convert DokuWiki link syntax [[link|text]] to tokens 1662 $pattern = '/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/'; 1663 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 1664 foreach ($matches as $match) { 1665 $link = trim($match[1]); 1666 $text = isset($match[2]) ? trim($match[2]) : $link; 1667 1668 // Handle external URLs 1669 if (preg_match('/^https?:\/\//', $link)) { 1670 $linkHtml = '<a href="' . htmlspecialchars($link) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>'; 1671 } else { 1672 // Handle internal DokuWiki links with section anchors 1673 $parts = explode('#', $link, 2); 1674 $pagePart = $parts[0]; 1675 $sectionPart = isset($parts[1]) ? '#' . $parts[1] : ''; 1676 1677 $wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($pagePart) . $sectionPart; 1678 $linkHtml = '<a href="' . $wikiUrl . '">' . htmlspecialchars($text) . '</a>'; 1679 } 1680 1681 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 1682 $tokens[$tokenIndex] = $linkHtml; 1683 $tokenIndex++; 1684 $rendered = str_replace($match[0], $token, $rendered); 1685 } 1686 1687 // Convert markdown-style links [text](url) to tokens 1688 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/'; 1689 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 1690 foreach ($matches as $match) { 1691 $text = trim($match[1]); 1692 $url = trim($match[2]); 1693 1694 if (preg_match('/^https?:\/\//', $url)) { 1695 $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>'; 1696 } else { 1697 $linkHtml = '<a href="' . htmlspecialchars($url) . '">' . htmlspecialchars($text) . '</a>'; 1698 } 1699 1700 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 1701 $tokens[$tokenIndex] = $linkHtml; 1702 $tokenIndex++; 1703 $rendered = str_replace($match[0], $token, $rendered); 1704 } 1705 1706 // Convert plain URLs to tokens 1707 $pattern = '/(https?:\/\/[^\s<]+)/'; 1708 preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); 1709 foreach ($matches as $match) { 1710 $url = $match[1]; 1711 $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($url) . '</a>'; 1712 1713 $token = "\x00TOKEN" . $tokenIndex . "\x00"; 1714 $tokens[$tokenIndex] = $linkHtml; 1715 $tokenIndex++; 1716 $rendered = str_replace($match[0], $token, $rendered); 1717 } 1718 1719 // NOW escape HTML (tokens are protected) 1720 $rendered = htmlspecialchars($rendered); 1721 1722 // Convert newlines to <br> 1723 $rendered = nl2br($rendered); 1724 1725 // DokuWiki text formatting 1726 // Bold: **text** or __text__ 1727 $rendered = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $rendered); 1728 $rendered = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $rendered); 1729 1730 // Italic: //text// 1731 $rendered = preg_replace('/\/\/(.+?)\/\//', '<em>$1</em>', $rendered); 1732 1733 // Strikethrough: <del>text</del> 1734 $rendered = preg_replace('/<del>(.+?)<\/del>/', '<del>$1</del>', $rendered); 1735 1736 // Monospace: ''text'' 1737 $rendered = preg_replace('/''(.+?)''/', '<code>$1</code>', $rendered); 1738 1739 // Subscript: <sub>text</sub> 1740 $rendered = preg_replace('/<sub>(.+?)<\/sub>/', '<sub>$1</sub>', $rendered); 1741 1742 // Superscript: <sup>text</sup> 1743 $rendered = preg_replace('/<sup>(.+?)<\/sup>/', '<sup>$1</sup>', $rendered); 1744 1745 // Restore tokens 1746 foreach ($tokens as $i => $html) { 1747 $rendered = str_replace("\x00TOKEN" . $i . "\x00", $html, $rendered); 1748 } 1749 1750 return $rendered; 1751 } 1752 1753 private function loadEvents($namespace, $year, $month) { 1754 $dataDir = DOKU_INC . 'data/meta/'; 1755 if ($namespace) { 1756 $dataDir .= str_replace(':', '/', $namespace) . '/'; 1757 } 1758 $dataDir .= 'calendar/'; 1759 1760 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1761 1762 if (file_exists($eventFile)) { 1763 $json = file_get_contents($eventFile); 1764 return json_decode($json, true); 1765 } 1766 1767 return array(); 1768 } 1769 1770 private function loadEventsMultiNamespace($namespaces, $year, $month) { 1771 // Check for wildcard pattern (namespace:*) 1772 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 1773 $baseNamespace = $matches[1]; 1774 return $this->loadEventsWildcard($baseNamespace, $year, $month); 1775 } 1776 1777 // Check for root wildcard (just *) 1778 if ($namespaces === '*') { 1779 return $this->loadEventsWildcard('', $year, $month); 1780 } 1781 1782 // Parse namespace list (semicolon separated) 1783 // e.g., "team:projects;personal;work:tasks" = three namespaces 1784 $namespaceList = array_map('trim', explode(';', $namespaces)); 1785 1786 // Load events from all namespaces 1787 $allEvents = array(); 1788 foreach ($namespaceList as $ns) { 1789 $ns = trim($ns); 1790 if (empty($ns)) continue; 1791 1792 $events = $this->loadEvents($ns, $year, $month); 1793 1794 // Add namespace tag to each event 1795 foreach ($events as $dateKey => $dayEvents) { 1796 if (!isset($allEvents[$dateKey])) { 1797 $allEvents[$dateKey] = array(); 1798 } 1799 foreach ($dayEvents as $event) { 1800 $event['_namespace'] = $ns; 1801 $allEvents[$dateKey][] = $event; 1802 } 1803 } 1804 } 1805 1806 return $allEvents; 1807 } 1808 1809 private function loadEventsWildcard($baseNamespace, $year, $month) { 1810 // Find all subdirectories under the base namespace 1811 $dataDir = DOKU_INC . 'data/meta/'; 1812 if ($baseNamespace) { 1813 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 1814 } 1815 1816 $allEvents = array(); 1817 1818 // First, load events from the base namespace itself 1819 if (empty($baseNamespace)) { 1820 // Root wildcard - load from root calendar 1821 $events = $this->loadEvents('', $year, $month); 1822 foreach ($events as $dateKey => $dayEvents) { 1823 if (!isset($allEvents[$dateKey])) { 1824 $allEvents[$dateKey] = array(); 1825 } 1826 foreach ($dayEvents as $event) { 1827 $event['_namespace'] = ''; 1828 $allEvents[$dateKey][] = $event; 1829 } 1830 } 1831 } else { 1832 $events = $this->loadEvents($baseNamespace, $year, $month); 1833 foreach ($events as $dateKey => $dayEvents) { 1834 if (!isset($allEvents[$dateKey])) { 1835 $allEvents[$dateKey] = array(); 1836 } 1837 foreach ($dayEvents as $event) { 1838 $event['_namespace'] = $baseNamespace; 1839 $allEvents[$dateKey][] = $event; 1840 } 1841 } 1842 } 1843 1844 // Recursively find all subdirectories 1845 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 1846 1847 return $allEvents; 1848 } 1849 1850 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 1851 if (!is_dir($dir)) return; 1852 1853 $items = scandir($dir); 1854 foreach ($items as $item) { 1855 if ($item === '.' || $item === '..') continue; 1856 1857 $path = $dir . $item; 1858 if (is_dir($path) && $item !== 'calendar') { 1859 // This is a namespace directory 1860 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1861 1862 // Load events from this namespace 1863 $events = $this->loadEvents($namespace, $year, $month); 1864 foreach ($events as $dateKey => $dayEvents) { 1865 if (!isset($allEvents[$dateKey])) { 1866 $allEvents[$dateKey] = array(); 1867 } 1868 foreach ($dayEvents as $event) { 1869 $event['_namespace'] = $namespace; 1870 $allEvents[$dateKey][] = $event; 1871 } 1872 } 1873 1874 // Recurse into subdirectories 1875 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 1876 } 1877 } 1878 } 1879 1880 private function getAllNamespaces() { 1881 $dataDir = DOKU_INC . 'data/meta/'; 1882 $namespaces = []; 1883 1884 // Scan for namespaces that have calendar data 1885 $this->scanForCalendarNamespaces($dataDir, '', $namespaces); 1886 1887 // Sort alphabetically 1888 sort($namespaces); 1889 1890 return $namespaces; 1891 } 1892 1893 private function scanForCalendarNamespaces($dir, $baseNamespace, &$namespaces) { 1894 if (!is_dir($dir)) return; 1895 1896 $items = scandir($dir); 1897 foreach ($items as $item) { 1898 if ($item === '.' || $item === '..') continue; 1899 1900 $path = $dir . $item; 1901 if (is_dir($path)) { 1902 // Check if this directory has a calendar subdirectory with data 1903 $calendarDir = $path . '/calendar/'; 1904 if (is_dir($calendarDir)) { 1905 // Check if there are any JSON files in the calendar directory 1906 $jsonFiles = glob($calendarDir . '*.json'); 1907 if (!empty($jsonFiles)) { 1908 // This namespace has calendar data 1909 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1910 $namespaces[] = $namespace; 1911 } 1912 } 1913 1914 // Recurse into subdirectories 1915 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1916 $this->scanForCalendarNamespaces($path . '/', $namespace, $namespaces); 1917 } 1918 } 1919 } 1920 1921 /** 1922 * Render new sidebar widget - Week at a glance itinerary (200px wide) 1923 */ 1924 private function renderSidebarWidget($events, $namespace, $calId) { 1925 if (empty($events)) { 1926 return '<div style="width:200px; padding:12px; text-align:center; color:#999; font-size:11px;">No events this week</div>'; 1927 } 1928 1929 // Get important namespaces from config 1930 $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; 1931 $importantNsList = ['important']; // default 1932 if (file_exists($configFile)) { 1933 $config = include $configFile; 1934 if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) { 1935 $importantNsList = array_map('trim', explode(',', $config['important_namespaces'])); 1936 } 1937 } 1938 1939 // Calculate date ranges 1940 $todayStr = date('Y-m-d'); 1941 $tomorrowStr = date('Y-m-d', strtotime('+1 day')); 1942 $weekStart = date('Y-m-d', strtotime('monday this week')); 1943 $weekEnd = date('Y-m-d', strtotime('sunday this week')); 1944 1945 // Group events by category 1946 $todayEvents = []; 1947 $tomorrowEvents = []; 1948 $importantEvents = []; 1949 $weekEvents = []; // For week grid 1950 1951 // Process all events 1952 foreach ($events as $dateKey => $dayEvents) { 1953 // Skip events before this week 1954 if ($dateKey < $weekStart) continue; 1955 1956 // Initialize week grid day if in current week 1957 if ($dateKey >= $weekStart && $dateKey <= $weekEnd) { 1958 if (!isset($weekEvents[$dateKey])) { 1959 $weekEvents[$dateKey] = []; 1960 } 1961 } 1962 1963 foreach ($dayEvents as $event) { 1964 // Add to week grid if in week range 1965 if ($dateKey >= $weekStart && $dateKey <= $weekEnd) { 1966 // Pre-render DokuWiki syntax to HTML for JavaScript display 1967 $eventWithHtml = $event; 1968 if (isset($event['title'])) { 1969 $eventWithHtml['title_html'] = $this->renderDokuWikiToHtml($event['title']); 1970 } 1971 if (isset($event['description'])) { 1972 $eventWithHtml['description_html'] = $this->renderDokuWikiToHtml($event['description']); 1973 } 1974 $weekEvents[$dateKey][] = $eventWithHtml; 1975 } 1976 1977 // Categorize for detailed sections 1978 if ($dateKey === $todayStr) { 1979 $todayEvents[] = array_merge($event, ['date' => $dateKey]); 1980 } elseif ($dateKey === $tomorrowStr) { 1981 $tomorrowEvents[] = array_merge($event, ['date' => $dateKey]); 1982 } else { 1983 // Check if this is an important namespace 1984 $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; 1985 $isImportant = false; 1986 foreach ($importantNsList as $impNs) { 1987 if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) { 1988 $isImportant = true; 1989 break; 1990 } 1991 } 1992 1993 // Important events: this week but not today/tomorrow 1994 if ($isImportant && $dateKey >= $weekStart && $dateKey <= $weekEnd) { 1995 $importantEvents[] = array_merge($event, ['date' => $dateKey]); 1996 } 1997 } 1998 } 1999 } 2000 2001 // Start building HTML - Dynamic width with default font 2002 $html = '<div class="sidebar-widget sidebar-matrix" style="width:100%; max-width:100%; box-sizing:border-box; font-family:system-ui, sans-serif; background:#242424; border:2px solid #00cc07; border-radius:4px; overflow:hidden; box-shadow:0 0 10px rgba(0, 204, 7, 0.3);">'; 2003 2004 // Sanitize calId for use in JavaScript variable names (remove dashes) 2005 $jsCalId = str_replace('-', '_', $calId); 2006 2007 // CRITICAL: Add ALL JavaScript FIRST before any HTML that uses it 2008 $html .= '<script> 2009(function() { 2010 // Shared state for system stats and tooltips 2011 const sharedState_' . $jsCalId . ' = { 2012 latestStats: { 2013 load: {"1min": 0, "5min": 0, "15min": 0}, 2014 uptime: "", 2015 memory_details: {}, 2016 top_processes: [] 2017 }, 2018 cpuHistory: [], 2019 CPU_HISTORY_SIZE: 2 2020 }; 2021 2022 // Tooltip functions - MUST be defined before HTML uses them 2023 window["showTooltip_' . $jsCalId . '"] = function(color) { 2024 const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '"); 2025 if (!tooltip) { 2026 console.log("Tooltip element not found for color:", color); 2027 return; 2028 } 2029 2030 const latestStats = sharedState_' . $jsCalId . '.latestStats; 2031 let content = ""; 2032 2033 if (color === "green") { 2034 content = "<div class=\\"tooltip-title\\">CPU Load Average</div>"; 2035 content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>"; 2036 content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>"; 2037 content += "<div>15 min: " + (latestStats.load["15min"] || "N/A") + "</div>"; 2038 if (latestStats.uptime) { 2039 content += "<div style=\\"margin-top:3px; padding-top:2px; border-top:1px solid rgba(0,204,7,0.3);\\">Uptime: " + latestStats.uptime + "</div>"; 2040 } 2041 tooltip.style.borderColor = "#00cc07"; 2042 tooltip.style.color = "#00cc07"; 2043 } else if (color === "purple") { 2044 content = "<div class=\\"tooltip-title\\">CPU Load (Short-term)</div>"; 2045 content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>"; 2046 content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>"; 2047 if (latestStats.top_processes && latestStats.top_processes.length > 0) { 2048 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>"; 2049 latestStats.top_processes.slice(0, 5).forEach(proc => { 2050 content += "<div>" + proc.cpu + " " + proc.command + "</div>"; 2051 }); 2052 } 2053 tooltip.style.borderColor = "#9b59b6"; 2054 tooltip.style.color = "#9b59b6"; 2055 } else if (color === "orange") { 2056 content = "<div class=\\"tooltip-title\\">Memory Usage</div>"; 2057 if (latestStats.memory_details && latestStats.memory_details.total) { 2058 content += "<div>Total: " + latestStats.memory_details.total + "</div>"; 2059 content += "<div>Used: " + latestStats.memory_details.used + "</div>"; 2060 content += "<div>Available: " + latestStats.memory_details.available + "</div>"; 2061 if (latestStats.memory_details.cached) { 2062 content += "<div>Cached: " + latestStats.memory_details.cached + "</div>"; 2063 } 2064 } else { 2065 content += "<div>Loading...</div>"; 2066 } 2067 if (latestStats.top_processes && latestStats.top_processes.length > 0) { 2068 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>"; 2069 latestStats.top_processes.slice(0, 5).forEach(proc => { 2070 content += "<div>" + proc.cpu + " " + proc.command + "</div>"; 2071 }); 2072 } 2073 tooltip.style.borderColor = "#ff9800"; 2074 tooltip.style.color = "#ff9800"; 2075 } 2076 2077 tooltip.innerHTML = content; 2078 tooltip.style.display = "block"; 2079 2080 const bar = tooltip.parentElement; 2081 const barRect = bar.getBoundingClientRect(); 2082 const tooltipRect = tooltip.getBoundingClientRect(); 2083 2084 const left = barRect.left + (barRect.width / 2) - (tooltipRect.width / 2); 2085 const top = barRect.top - tooltipRect.height - 8; 2086 2087 tooltip.style.left = left + "px"; 2088 tooltip.style.top = top + "px"; 2089 }; 2090 2091 window["hideTooltip_' . $jsCalId . '"] = function(color) { 2092 const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '"); 2093 if (tooltip) { 2094 tooltip.style.display = "none"; 2095 } 2096 }; 2097 2098 // Update clock every second 2099 function updateClock() { 2100 const now = new Date(); 2101 let hours = now.getHours(); 2102 const minutes = String(now.getMinutes()).padStart(2, "0"); 2103 const seconds = String(now.getSeconds()).padStart(2, "0"); 2104 const ampm = hours >= 12 ? "PM" : "AM"; 2105 hours = hours % 12 || 12; 2106 const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm; 2107 const clockEl = document.getElementById("clock-' . $calId . '"); 2108 if (clockEl) clockEl.textContent = timeStr; 2109 } 2110 setInterval(updateClock, 1000); 2111 2112 // Weather update function 2113 function updateWeather() { 2114 if ("geolocation" in navigator) { 2115 navigator.geolocation.getCurrentPosition(function(position) { 2116 const lat = position.coords.latitude; 2117 const lon = position.coords.longitude; 2118 2119 fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t_weather=true&temperature_unit=fahrenheit`) 2120 .then(response => response.json()) 2121 .then(data => { 2122 if (data.current_weather) { 2123 const temp = Math.round(data.current_weather.temperature); 2124 const weatherCode = data.current_weather.weathercode; 2125 const icon = getWeatherIcon(weatherCode); 2126 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 2127 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 2128 if (iconEl) iconEl.textContent = icon; 2129 if (tempEl) tempEl.innerHTML = temp + "°"; 2130 } 2131 }) 2132 .catch(error => console.log("Weather fetch error:", error)); 2133 }, function(error) { 2134 // If geolocation fails, use default location (Irvine, CA) 2135 fetch("https://api.open-meteo.com/v1/forecast?latitude=33.6846&longitude=-117.8265¤t_weather=true&temperature_unit=fahrenheit") 2136 .then(response => response.json()) 2137 .then(data => { 2138 if (data.current_weather) { 2139 const temp = Math.round(data.current_weather.temperature); 2140 const weatherCode = data.current_weather.weathercode; 2141 const icon = getWeatherIcon(weatherCode); 2142 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 2143 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 2144 if (iconEl) iconEl.textContent = icon; 2145 if (tempEl) tempEl.innerHTML = temp + "°"; 2146 } 2147 }) 2148 .catch(err => console.log("Weather error:", err)); 2149 }); 2150 } else { 2151 // No geolocation, use default (Irvine, CA) 2152 fetch("https://api.open-meteo.com/v1/forecast?latitude=33.6846&longitude=-117.8265¤t_weather=true&temperature_unit=fahrenheit") 2153 .then(response => response.json()) 2154 .then(data => { 2155 if (data.current_weather) { 2156 const temp = Math.round(data.current_weather.temperature); 2157 const weatherCode = data.current_weather.weathercode; 2158 const icon = getWeatherIcon(weatherCode); 2159 const iconEl = document.getElementById("weather-icon-' . $calId . '"); 2160 const tempEl = document.getElementById("weather-temp-' . $calId . '"); 2161 if (iconEl) iconEl.textContent = icon; 2162 if (tempEl) tempEl.innerHTML = temp + "°"; 2163 } 2164 }) 2165 .catch(err => console.log("Weather error:", err)); 2166 } 2167 } 2168 2169 function getWeatherIcon(code) { 2170 const icons = { 2171 0: "☀️", 1: "️", 2: "⛅", 3: "☁️", 2172 45: "️", 48: "️", 51: "️", 53: "️", 55: "️", 2173 61: "️", 63: "️", 65: "⛈️", 71: "️", 73: "️", 2174 75: "❄️", 77: "️", 80: "️", 81: "️", 82: "⛈️", 2175 85: "️", 86: "❄️", 95: "⛈️", 96: "⛈️", 99: "⛈️" 2176 }; 2177 return icons[code] || "️"; 2178 } 2179 2180 // Update weather immediately and every 10 minutes 2181 updateWeather(); 2182 setInterval(updateWeather, 600000); 2183 2184 // Update system stats and tooltips data 2185 function updateSystemStats() { 2186 fetch("' . DOKU_BASE . 'lib/plugins/calendar/get_system_stats.php") 2187 .then(response => response.json()) 2188 .then(data => { 2189 sharedState_' . $jsCalId . '.latestStats = { 2190 load: data.load || {"1min": 0, "5min": 0, "15min": 0}, 2191 uptime: data.uptime || "", 2192 memory_details: data.memory_details || {}, 2193 top_processes: data.top_processes || [] 2194 }; 2195 2196 const greenBar = document.getElementById("cpu-5min-' . $calId . '"); 2197 if (greenBar) { 2198 greenBar.style.width = Math.min(100, data.cpu_5min) + "%"; 2199 } 2200 2201 sharedState_' . $jsCalId . '.cpuHistory.push(data.cpu); 2202 if (sharedState_' . $jsCalId . '.cpuHistory.length > sharedState_' . $jsCalId . '.CPU_HISTORY_SIZE) { 2203 sharedState_' . $jsCalId . '.cpuHistory.shift(); 2204 } 2205 2206 const cpuAverage = sharedState_' . $jsCalId . '.cpuHistory.reduce((sum, val) => sum + val, 0) / sharedState_' . $jsCalId . '.cpuHistory.length; 2207 2208 const cpuBar = document.getElementById("cpu-realtime-' . $calId . '"); 2209 if (cpuBar) { 2210 cpuBar.style.width = Math.min(100, cpuAverage) + "%"; 2211 } 2212 2213 const memBar = document.getElementById("mem-realtime-' . $calId . '"); 2214 if (memBar) { 2215 memBar.style.width = Math.min(100, data.memory) + "%"; 2216 } 2217 }) 2218 .catch(error => { 2219 console.log("System stats error:", error); 2220 }); 2221 } 2222 2223 updateSystemStats(); 2224 setInterval(updateSystemStats, 2000); 2225})(); 2226</script>'; 2227 2228 // NOW add the header HTML (after JavaScript is defined) 2229 $todayDate = new DateTime(); 2230 $displayDate = $todayDate->format('D, M j, Y'); 2231 $currentTime = $todayDate->format('g:i:s A'); 2232 2233 $html .= '<div class="eventlist-today-header">'; 2234 $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '">' . $currentTime . '</span>'; 2235 $html .= '<div class="eventlist-bottom-info">'; 2236 $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">️</span> <span id="weather-temp-' . $calId . '">--°</span></span>'; 2237 $html .= '<span class="eventlist-today-date">' . $displayDate . '</span>'; 2238 $html .= '</div>'; 2239 2240 // Three CPU/Memory bars (all update live) 2241 $html .= '<div class="eventlist-stats-container">'; 2242 2243 // 5-minute load average (green, updates every 2 seconds) 2244 $html .= '<div class="eventlist-cpu-bar" onmouseover="showTooltip_' . $jsCalId . '(\'green\')" onmouseout="hideTooltip_' . $jsCalId . '(\'green\')">'; 2245 $html .= '<div class="eventlist-cpu-fill" id="cpu-5min-' . $calId . '" style="width: 0%;"></div>'; 2246 $html .= '<div class="system-tooltip" id="tooltip-green-' . $calId . '" style="display:none;"></div>'; 2247 $html .= '</div>'; 2248 2249 // Real-time CPU (purple, updates with 5-sec average) 2250 $html .= '<div class="eventlist-cpu-bar eventlist-cpu-realtime" onmouseover="showTooltip_' . $jsCalId . '(\'purple\')" onmouseout="hideTooltip_' . $jsCalId . '(\'purple\')">'; 2251 $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-purple" id="cpu-realtime-' . $calId . '" style="width: 0%;"></div>'; 2252 $html .= '<div class="system-tooltip" id="tooltip-purple-' . $calId . '" style="display:none;"></div>'; 2253 $html .= '</div>'; 2254 2255 // Real-time Memory (orange, updates) 2256 $html .= '<div class="eventlist-cpu-bar eventlist-mem-realtime" onmouseover="showTooltip_' . $jsCalId . '(\'orange\')" onmouseout="hideTooltip_' . $jsCalId . '(\'orange\')">'; 2257 $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-orange" id="mem-realtime-' . $calId . '" style="width: 0%;"></div>'; 2258 $html .= '<div class="system-tooltip" id="tooltip-orange-' . $calId . '" style="display:none;"></div>'; 2259 $html .= '</div>'; 2260 2261 $html .= '</div>'; 2262 $html .= '</div>'; 2263 2264 // Get today's date for default event date 2265 $todayStr = date('Y-m-d'); 2266 2267 // Thin dark green "Add Event" bar between header and week grid (zero margin, smaller text, text positioned higher) 2268 $html .= '<div style="background:#006400; padding:0; margin:0; height:12px; line-height:10px; text-align:center; cursor:pointer; border-top:1px solid rgba(0, 100, 0, 0.3); border-bottom:1px solid rgba(0, 100, 0, 0.3); box-shadow:0 0 8px rgba(0, 100, 0, 0.4); transition:all 0.2s;" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\', \'' . $todayStr . '\');" onmouseover="this.style.background=\'#004d00\'; this.style.boxShadow=\'0 0 12px rgba(0, 100, 0, 0.6)\';" onmouseout="this.style.background=\'#006400\'; this.style.boxShadow=\'0 0 8px rgba(0, 100, 0, 0.4)\';">'; 2269 $html .= '<span style="color:#00ff00; font-size:8px; font-weight:700; letter-spacing:0.4px; font-family:system-ui, sans-serif; text-shadow:0 0 3px rgba(0, 255, 0, 0.5); position:relative; top:-1px;">+ ADD EVENT</span>'; 2270 $html .= '</div>'; 2271 2272 // Week grid (7 cells) 2273 $html .= $this->renderWeekGrid($weekEvents, $weekStart); 2274 2275 // Today section (orange) 2276 if (!empty($todayEvents)) { 2277 $html .= $this->renderSidebarSection('Today', $todayEvents, '#ff9800', $calId); 2278 } 2279 2280 // Tomorrow section (green) 2281 if (!empty($tomorrowEvents)) { 2282 $html .= $this->renderSidebarSection('Tomorrow', $tomorrowEvents, '#4caf50', $calId); 2283 } 2284 2285 // Important events section (purple) 2286 if (!empty($importantEvents)) { 2287 $html .= $this->renderSidebarSection('Important Events', $importantEvents, '#9b59b6', $calId); 2288 } 2289 2290 $html .= '</div>'; 2291 2292 // Add event dialog for sidebar widget 2293 $html .= $this->renderEventDialog($calId, $namespace); 2294 2295 return $html; 2296 } 2297 2298 /** 2299 * Render compact week grid (7 cells with event bars) - Matrix themed with clickable days 2300 */ 2301 private function renderWeekGrid($weekEvents, $weekStart) { 2302 // Generate unique ID for this calendar instance - sanitize for JavaScript 2303 $calId = 'cal_' . substr(md5($weekStart . microtime()), 0, 8); 2304 $jsCalId = str_replace('-', '_', $calId); // Sanitize for JS variable names 2305 2306 $html = '<div style="display:grid; grid-template-columns:repeat(7, 1fr); gap:1px; background:#1a3d1a; border-bottom:2px solid #00cc07;">'; 2307 2308 $dayNames = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; 2309 $today = date('Y-m-d'); 2310 2311 for ($i = 0; $i < 7; $i++) { 2312 $date = date('Y-m-d', strtotime($weekStart . ' +' . $i . ' days')); 2313 $dayNum = date('j', strtotime($date)); 2314 $isToday = $date === $today; 2315 2316 $events = isset($weekEvents[$date]) ? $weekEvents[$date] : []; 2317 $eventCount = count($events); 2318 2319 $bgColor = $isToday ? '#2a4d2a' : '#242424'; 2320 $textColor = $isToday ? '#00ff00' : '#00cc07'; 2321 $fontWeight = $isToday ? '700' : '500'; 2322 $textShadow = $isToday ? 'text-shadow:0 0 6px rgba(0, 255, 0, 0.6);' : 'text-shadow:0 0 4px rgba(0, 204, 7, 0.4);'; 2323 2324 $hasEvents = $eventCount > 0; 2325 $clickableStyle = $hasEvents ? 'cursor:pointer;' : ''; 2326 $clickHandler = $hasEvents ? ' onclick="showDayEvents_' . $jsCalId . '(\'' . $date . '\')"' : ''; 2327 2328 $html .= '<div style="background:' . $bgColor . '; padding:4px 2px; text-align:center; min-height:45px; position:relative; border:1px solid rgba(0, 204, 7, 0.2); ' . $clickableStyle . '" ' . $clickHandler . '>'; 2329 2330 // Day letter 2331 $html .= '<div style="font-size:9px; color:#00cc07; font-weight:500; font-family:system-ui, sans-serif; ' . $textShadow . '">' . $dayNames[$i] . '</div>'; 2332 2333 // Day number 2334 $html .= '<div style="font-size:12px; color:' . $textColor . '; font-weight:' . $fontWeight . '; margin:2px 0; font-family:system-ui, sans-serif; ' . $textShadow . '">' . $dayNum . '</div>'; 2335 2336 // Event bars (max 3 visible) with glow effect 2337 if ($eventCount > 0) { 2338 $showCount = min($eventCount, 3); 2339 for ($j = 0; $j < $showCount; $j++) { 2340 $event = $events[$j]; 2341 $color = isset($event['color']) ? $event['color'] : '#00cc07'; 2342 $html .= '<div style="height:2px; background:' . htmlspecialchars($color) . '; margin:1px 0; border-radius:1px; box-shadow:0 0 3px ' . htmlspecialchars($color) . ';"></div>'; 2343 } 2344 2345 // Show "+N more" if more than 3 2346 if ($eventCount > 3) { 2347 $html .= '<div style="font-size:7px; color:#00cc07; margin-top:1px; font-family:system-ui, sans-serif;">+' . ($eventCount - 3) . '</div>'; 2348 } 2349 } 2350 2351 $html .= '</div>'; 2352 } 2353 2354 $html .= '</div>'; 2355 2356 // Add container for selected day events display (with unique ID) 2357 $html .= '<div id="selected-day-events-' . $calId . '" style="display:none; margin:8px 4px; border-left:3px solid #3498db; box-shadow:0 0 5px rgba(0, 204, 7, 0.2);">'; 2358 $html .= '<div style="background:#3498db; color:#000; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:0 0 8px #3498db; display:flex; justify-content:space-between; align-items:center;">'; 2359 $html .= '<span id="selected-day-title-' . $calId . '"></span>'; 2360 $html .= '<span onclick="document.getElementById(\'selected-day-events-' . $calId . '\').style.display=\'none\';" style="cursor:pointer; font-size:12px; padding:0 4px; font-weight:700;">✕</span>'; 2361 $html .= '</div>'; 2362 $html .= '<div id="selected-day-content-' . $calId . '" style="padding:4px 0; background:rgba(36, 36, 36, 0.5);"></div>'; 2363 $html .= '</div>'; 2364 2365 // Add JavaScript for day selection with event data 2366 $html .= '<script>'; 2367 // Sanitize calId for JavaScript variable names 2368 $jsCalId = str_replace('-', '_', $calId); 2369 $html .= 'window.weekEventsData_' . $jsCalId . ' = ' . json_encode($weekEvents) . ';'; 2370 $html .= ' 2371 window.showDayEvents_' . $jsCalId . ' = function(dateKey) { 2372 const eventsData = window.weekEventsData_' . $jsCalId . '; 2373 const container = document.getElementById("selected-day-events-' . $calId . '"); 2374 const title = document.getElementById("selected-day-title-' . $calId . '"); 2375 const content = document.getElementById("selected-day-content-' . $calId . '"); 2376 2377 if (!eventsData[dateKey] || eventsData[dateKey].length === 0) return; 2378 2379 // Format date for display 2380 const dateObj = new Date(dateKey + "T00:00:00"); 2381 const dayName = dateObj.toLocaleDateString("en-US", { weekday: "long" }); 2382 const monthDay = dateObj.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 2383 title.textContent = dayName + ", " + monthDay; 2384 2385 // Clear content 2386 content.innerHTML = ""; 2387 2388 // Sort events by time (all-day events first, then timed events chronologically) 2389 const sortedEvents = [...eventsData[dateKey]].sort((a, b) => { 2390 // All-day events (no time) go to the beginning 2391 if (!a.time && !b.time) return 0; 2392 if (!a.time) return -1; // a is all-day, comes first 2393 if (!b.time) return 1; // b is all-day, comes first 2394 2395 // Compare times (format: "HH:MM") 2396 const timeA = a.time.split(":").map(Number); 2397 const timeB = b.time.split(":").map(Number); 2398 const minutesA = timeA[0] * 60 + timeA[1]; 2399 const minutesB = timeB[0] * 60 + timeB[1]; 2400 2401 return minutesA - minutesB; 2402 }); 2403 2404 // Build events HTML with single color bar (event color only) 2405 sortedEvents.forEach(event => { 2406 const eventColor = event.color || "#00cc07"; 2407 2408 const eventDiv = document.createElement("div"); 2409 eventDiv.style.cssText = "padding:4px 6px; border-bottom:1px solid rgba(0, 204, 7, 0.2); font-size:10px; display:flex; align-items:stretch; gap:6px; background:rgba(36, 36, 36, 0.3); min-height:20px;"; 2410 2411 let eventHTML = ""; 2412 2413 // Event assigned color bar (single bar on left) 2414 eventHTML += "<div style=\\"width:3px; align-self:stretch; background:" + eventColor + "; border-radius:1px; flex-shrink:0; box-shadow:0 0 3px " + eventColor + ";\\"></div>"; 2415 2416 // Content wrapper 2417 eventHTML += "<div style=\\"flex:1; min-width:0; display:flex; justify-content:space-between; align-items:start; gap:4px;\\">"; 2418 2419 // Left side: event details 2420 eventHTML += "<div style=\\"flex:1; min-width:0;\\">"; 2421 eventHTML += "<div style=\\"font-weight:600; color:#00cc07; word-wrap:break-word; font-family:system-ui, sans-serif; text-shadow:0 0 3px rgba(0, 204, 7, 0.4);\\">"; 2422 2423 // Time 2424 if (event.time) { 2425 const timeParts = event.time.split(":"); 2426 let hours = parseInt(timeParts[0]); 2427 const minutes = timeParts[1]; 2428 const ampm = hours >= 12 ? "PM" : "AM"; 2429 hours = hours % 12 || 12; 2430 eventHTML += "<span style=\\"color:#00dd00; font-weight:500; font-size:9px;\\">" + hours + ":" + minutes + " " + ampm + "</span> "; 2431 } 2432 2433 // Title - use HTML version if available 2434 const titleHTML = event.title_html || event.title || "Untitled"; 2435 eventHTML += titleHTML; 2436 eventHTML += "</div>"; 2437 2438 // Description if present - use HTML version 2439 if (event.description_html || event.description) { 2440 const descHTML = event.description_html || event.description; 2441 eventHTML += "<div style=\\"font-size:9px; color:#00aa00; margin-top:2px;\\">" + descHTML + "</div>"; 2442 } 2443 2444 eventHTML += "</div>"; // Close event details 2445 2446 // Right side: conflict badge (if present) 2447 if (event.conflict) { 2448 eventHTML += "<div style=\\"flex-shrink:0; color:#ff9800; font-size:10px; margin-top:2px; opacity:0.8;\\" title=\\"Time conflict detected\\">⚠</div>"; 2449 } 2450 2451 eventHTML += "</div>"; // Close content wrapper 2452 2453 eventDiv.innerHTML = eventHTML; 2454 content.appendChild(eventDiv); 2455 }); 2456 2457 container.style.display = "block"; 2458 }; 2459 '; 2460 $html .= '</script>'; 2461 2462 return $html; 2463 } 2464 2465 /** 2466 * Render a sidebar section (Today/Tomorrow/Important) - Matrix themed with colored borders 2467 */ 2468 private function renderSidebarSection($title, $events, $accentColor, $calId) { 2469 // Keep the original accent colors for borders 2470 $borderColor = $accentColor; 2471 2472 // Show date for Important Events section 2473 $showDate = ($title === 'Important Events'); 2474 2475 $html = '<div style="border-left:3px solid ' . $borderColor . '; margin:8px 4px; box-shadow:0 0 5px rgba(0, 204, 7, 0.2);">'; 2476 2477 // Section header with accent color background - smaller, not all caps 2478 $html .= '<div style="background:' . $accentColor . '; color:#000; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:0 0 8px ' . $accentColor . ';">'; 2479 $html .= htmlspecialchars($title); 2480 $html .= '</div>'; 2481 2482 // Events 2483 $html .= '<div style="padding:4px 0; background:rgba(36, 36, 36, 0.5);">'; 2484 2485 foreach ($events as $event) { 2486 $html .= $this->renderSidebarEvent($event, $calId, $showDate, $accentColor); 2487 } 2488 2489 $html .= '</div>'; 2490 $html .= '</div>'; 2491 2492 return $html; 2493 } 2494 2495 /** 2496 * Render individual event in sidebar - Matrix themed with dual color bars 2497 */ 2498 private function renderSidebarEvent($event, $calId, $showDate = false, $sectionColor = '#00cc07') { 2499 $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled'; 2500 $time = isset($event['time']) ? $event['time'] : ''; 2501 $endTime = isset($event['endTime']) ? $event['endTime'] : ''; 2502 $eventColor = isset($event['color']) ? htmlspecialchars($event['color']) : '#00cc07'; 2503 $date = isset($event['date']) ? $event['date'] : ''; 2504 $isTask = isset($event['isTask']) && $event['isTask']; 2505 $completed = isset($event['completed']) && $event['completed']; 2506 2507 // Check for conflicts 2508 $hasConflict = isset($event['conflicts']) && !empty($event['conflicts']); 2509 2510 $html = '<div style="padding:4px 6px; border-bottom:1px solid rgba(0, 204, 7, 0.2); font-size:10px; display:flex; align-items:stretch; gap:6px; background:rgba(36, 36, 36, 0.3); min-height:20px;">'; 2511 2512 // Event's assigned color bar (single bar on the left) 2513 $html .= '<div style="width:3px; align-self:stretch; background:' . $eventColor . '; border-radius:1px; flex-shrink:0; box-shadow:0 0 3px ' . $eventColor . ';"></div>'; 2514 2515 // Content 2516 $html .= '<div style="flex:1; min-width:0;">'; 2517 2518 // Time + title 2519 $html .= '<div style="font-weight:600; color:#00cc07; word-wrap:break-word; font-family:system-ui, sans-serif; text-shadow:0 0 3px rgba(0, 204, 7, 0.4);">'; 2520 2521 if ($time) { 2522 $displayTime = $this->formatTimeDisplay($time, $endTime); 2523 $html .= '<span style="color:#00dd00; font-weight:500; font-size:9px;">' . htmlspecialchars($displayTime) . '</span> '; 2524 } 2525 2526 // Task checkbox 2527 if ($isTask) { 2528 $checkIcon = $completed ? '☑' : '☐'; 2529 $html .= '<span style="font-size:11px; color:#00ff00;">' . $checkIcon . '</span> '; 2530 } 2531 2532 $html .= htmlspecialchars($title); 2533 2534 // Conflict badge 2535 if ($hasConflict) { 2536 $conflictCount = count($event['conflicts']); 2537 $html .= ' <span style="background:#ff0000; color:#000; padding:1px 3px; border-radius:2px; font-size:8px; font-weight:700; box-shadow:0 0 4px #ff0000;">⚠ ' . $conflictCount . '</span>'; 2538 } 2539 2540 $html .= '</div>'; 2541 2542 // Date display BELOW event name for Important events 2543 if ($showDate && $date) { 2544 $dateObj = new DateTime($date); 2545 $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Feb 10" 2546 $html .= '<div style="font-size:8px; color:#00aa00; font-weight:500; margin-top:2px; text-shadow:0 0 2px rgba(0, 170, 0, 0.3);">' . htmlspecialchars($displayDate) . '</div>'; 2547 } 2548 2549 $html .= '</div>'; 2550 $html .= '</div>'; 2551 2552 return $html; 2553 } 2554 2555 /** 2556 * Format time display (12-hour format with optional end time) 2557 */ 2558 private function formatTimeDisplay($startTime, $endTime = '') { 2559 // Convert start time 2560 list($hour, $minute) = explode(':', $startTime); 2561 $hour = (int)$hour; 2562 $ampm = $hour >= 12 ? 'PM' : 'AM'; 2563 $displayHour = $hour % 12; 2564 if ($displayHour === 0) $displayHour = 12; 2565 2566 $display = $displayHour . ':' . $minute . ' ' . $ampm; 2567 2568 // Add end time if provided 2569 if ($endTime && $endTime !== '') { 2570 list($endHour, $endMinute) = explode(':', $endTime); 2571 $endHour = (int)$endHour; 2572 $endAmpm = $endHour >= 12 ? 'PM' : 'AM'; 2573 $endDisplayHour = $endHour % 12; 2574 if ($endDisplayHour === 0) $endDisplayHour = 12; 2575 2576 $display .= '-' . $endDisplayHour . ':' . $endMinute . ' ' . $endAmpm; 2577 } 2578 2579 return $display; 2580 } 2581 2582 /** 2583 * Render DokuWiki syntax to HTML 2584 * Converts **bold**, //italic//, [[links]], etc. to HTML 2585 */ 2586 private function renderDokuWikiToHtml($text) { 2587 if (empty($text)) return ''; 2588 2589 // Use DokuWiki's parser to render the text 2590 $instructions = p_get_instructions($text); 2591 2592 // Render instructions to XHTML 2593 $xhtml = p_render('xhtml', $instructions, $info); 2594 2595 // Remove surrounding <p> tags if present (we're rendering inline) 2596 $xhtml = preg_replace('/^<p>(.*)<\/p>$/s', '$1', trim($xhtml)); 2597 2598 return $xhtml; 2599 } 2600 2601 // Keep old scanForNamespaces for backward compatibility (not used anymore) 2602 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 2603 if (!is_dir($dir)) return; 2604 2605 $items = scandir($dir); 2606 foreach ($items as $item) { 2607 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 2608 2609 $path = $dir . $item; 2610 if (is_dir($path)) { 2611 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 2612 $namespaces[] = $namespace; 2613 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 2614 } 2615 } 2616 } 2617} 2618