Lexer->addSpecialPattern('\{\{calendar(?:[^\}]*)\}\}', $mode, 'plugin_calendar'); $this->Lexer->addSpecialPattern('\{\{eventlist(?:[^\}]*)\}\}', $mode, 'plugin_calendar'); $this->Lexer->addSpecialPattern('\{\{eventpanel(?:[^\}]*)\}\}', $mode, 'plugin_calendar'); } public function handle($match, $state, $pos, Doku_Handler $handler) { $isEventList = (strpos($match, '{{eventlist') === 0); $isEventPanel = (strpos($match, '{{eventpanel') === 0); if ($isEventList) { $match = substr($match, 12, -2); } elseif ($isEventPanel) { $match = substr($match, 13, -2); } else { $match = substr($match, 10, -2); } $params = array( 'type' => $isEventPanel ? 'eventpanel' : ($isEventList ? 'eventlist' : 'calendar'), 'year' => date('Y'), 'month' => date('n'), 'namespace' => '', 'daterange' => '', 'date' => '', 'range' => '' ); if (trim($match)) { $pairs = preg_split('/\s+/', trim($match)); foreach ($pairs as $pair) { if (strpos($pair, '=') !== false) { list($key, $value) = explode('=', $pair, 2); $params[trim($key)] = trim($value); } else { // Handle standalone flags like "today" $params[trim($pair)] = true; } } } return $params; } public function render($mode, Doku_Renderer $renderer, $data) { if ($mode !== 'xhtml') return false; if ($data['type'] === 'eventlist') { $html = $this->renderStandaloneEventList($data); } elseif ($data['type'] === 'eventpanel') { $html = $this->renderEventPanelOnly($data); } else { $html = $this->renderCompactCalendar($data); } $renderer->doc .= $html; return true; } private function renderCompactCalendar($data) { $year = (int)$data['year']; $month = (int)$data['month']; $namespace = $data['namespace']; // Get theme $theme = $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); $themeClass = 'calendar-theme-' . $theme; // Determine button text color: professional uses white, others use bg color $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg']; // Check if multiple namespaces or wildcard specified $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); if ($isMultiNamespace) { $events = $this->loadEventsMultiNamespace($namespace, $year, $month); } else { $events = $this->loadEvents($namespace, $year, $month); } $calId = 'cal_' . md5(serialize($data) . microtime()); $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year)); $prevMonth = $month - 1; $prevYear = $year; if ($prevMonth < 1) { $prevMonth = 12; $prevYear--; } $nextMonth = $month + 1; $nextYear = $year; if ($nextMonth > 12) { $nextMonth = 1; $nextYear++; } // Container - all styling via CSS variables $html = '
'; // Inject CSS variables for this calendar instance - all theming flows from here $html .= ''; // Load calendar JavaScript manually (not through DokuWiki concatenation) $html .= ''; // Initialize DOKU_BASE for JavaScript $html .= ''; // Embed events data as JSON for JavaScript access $html .= ''; // Left side: Calendar $html .= '
'; // Header with navigation $html .= '
'; $html .= ''; $html .= '

' . $monthName . '

'; $html .= ''; $html .= ''; $html .= '
'; // Calendar grid $html .= ''; $html .= ''; $html .= ''; $html .= ''; $firstDay = mktime(0, 0, 0, $month, 1, $year); $daysInMonth = date('t', $firstDay); $dayOfWeek = date('w', $firstDay); // Build a map of all events with their date ranges for the calendar grid $eventRanges = array(); foreach ($events as $dateKey => $dayEvents) { foreach ($dayEvents as $evt) { $eventId = isset($evt['id']) ? $evt['id'] : ''; $startDate = $dateKey; $endDate = isset($evt['endDate']) && $evt['endDate'] ? $evt['endDate'] : $dateKey; // Only process events that touch this month $eventStart = new DateTime($startDate); $eventEnd = new DateTime($endDate); $monthStart = new DateTime(sprintf('%04d-%02d-01', $year, $month)); $monthEnd = new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth)); // Skip if event doesn't overlap with current month if ($eventEnd < $monthStart || $eventStart > $monthEnd) { continue; } // Create entry for each day the event spans $current = clone $eventStart; while ($current <= $eventEnd) { $currentKey = $current->format('Y-m-d'); // Check if this date is in current month $currentDate = DateTime::createFromFormat('Y-m-d', $currentKey); if ($currentDate && $currentDate->format('Y-m') === sprintf('%04d-%02d', $year, $month)) { if (!isset($eventRanges[$currentKey])) { $eventRanges[$currentKey] = array(); } // Add event with span information $evt['_span_start'] = $startDate; $evt['_span_end'] = $endDate; $evt['_is_first_day'] = ($currentKey === $startDate); $evt['_is_last_day'] = ($currentKey === $endDate); $evt['_original_date'] = $dateKey; // Keep track of original date // Check if event continues from previous month or to next month $evt['_continues_from_prev'] = ($eventStart < $monthStart); $evt['_continues_to_next'] = ($eventEnd > $monthEnd); $eventRanges[$currentKey][] = $evt; } $current->modify('+1 day'); } } } $currentDay = 1; $rowCount = ceil(($daysInMonth + $dayOfWeek) / 7); for ($row = 0; $row < $rowCount; $row++) { $html .= ''; for ($col = 0; $col < 7; $col++) { if (($row === 0 && $col < $dayOfWeek) || $currentDay > $daysInMonth) { $html .= ''; } else { $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $currentDay); $isToday = ($dateKey === date('Y-m-d')); $hasEvents = isset($eventRanges[$dateKey]) && !empty($eventRanges[$dateKey]); $classes = 'cal-day'; if ($isToday) $classes .= ' cal-today'; if ($hasEvents) $classes .= ' cal-has-events'; $html .= ''; $currentDay++; } } $html .= ''; } $html .= '
SMTWTFS
'; $dayNumClass = $isToday ? 'day-num day-num-today' : 'day-num'; $html .= '' . $currentDay . ''; if ($hasEvents) { // Sort events by time (no time first, then by time) $sortedEvents = $eventRanges[$dateKey]; usort($sortedEvents, function($a, $b) { $timeA = isset($a['time']) ? $a['time'] : ''; $timeB = isset($b['time']) ? $b['time'] : ''; // Events without time go first if (empty($timeA) && !empty($timeB)) return -1; if (!empty($timeA) && empty($timeB)) return 1; if (empty($timeA) && empty($timeB)) return 0; // Sort by time return strcmp($timeA, $timeB); }); // Show colored stacked bars for each event $html .= '
'; foreach ($sortedEvents as $evt) { $eventId = isset($evt['id']) ? $evt['id'] : ''; $eventColor = isset($evt['color']) ? htmlspecialchars($evt['color']) : '#3498db'; $eventTime = isset($evt['time']) ? $evt['time'] : ''; $eventTitle = isset($evt['title']) ? htmlspecialchars($evt['title']) : 'Event'; $originalDate = isset($evt['_original_date']) ? $evt['_original_date'] : $dateKey; $isFirstDay = isset($evt['_is_first_day']) ? $evt['_is_first_day'] : true; $isLastDay = isset($evt['_is_last_day']) ? $evt['_is_last_day'] : true; $barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed'; // Add classes for multi-day spanning if (!$isFirstDay) $barClass .= ' event-bar-continues'; if (!$isLastDay) $barClass .= ' event-bar-continuing'; $html .= ''; $html .= ''; } $html .= '
'; } $html .= '
'; $html .= '
'; // End calendar-left // Right side: Event list $html .= '
'; $html .= '
'; $html .= '
'; $html .= '

Events

'; if ($namespace) { $html .= '' . htmlspecialchars($namespace) . ''; } $html .= '
'; // Search bar in header $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= ''; $html .= '
'; $html .= '
'; $html .= $this->renderEventListContent($events, $calId, $namespace, $themeStyles); $html .= '
'; $html .= '
'; // End calendar-right // Event dialog $html .= $this->renderEventDialog($calId, $namespace); // Month/Year picker dialog (at container level for proper overlay) $html .= $this->renderMonthPicker($calId, $year, $month, $namespace, $theme, $themeStyles); $html .= '
'; // End container return $html; } private function renderEventListContent($events, $calId, $namespace, $themeStyles = null) { if (empty($events)) { return '

No events this month

'; } // Default theme styles if not provided if ($themeStyles === null) { $theme = $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); } // Check for time conflicts $events = $this->checkTimeConflicts($events); // Sort by date ascending (chronological order - oldest first) ksort($events); // Sort events within each day by time foreach ($events as $dateKey => &$dayEvents) { usort($dayEvents, function($a, $b) { $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null; $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null; // All-day events (no time) go to the TOP if ($timeA === null && $timeB !== null) return -1; // A before B if ($timeA !== null && $timeB === null) return 1; // A after B if ($timeA === null && $timeB === null) return 0; // Both all-day, equal // Both have times, sort chronologically return strcmp($timeA, $timeB); }); } unset($dayEvents); // Break reference // Get today's date for comparison $today = date('Y-m-d'); $firstFutureEventId = null; // Helper function to check if event is past (with 15-minute grace period for timed events) $isEventPast = function($dateKey, $time) use ($today) { // If event is on a past date, it's definitely past if ($dateKey < $today) { return true; } // If event is on a future date, it's definitely not past if ($dateKey > $today) { return false; } // Event is today - check time with grace period if ($time && $time !== '') { try { $currentDateTime = new DateTime(); $eventDateTime = new DateTime($dateKey . ' ' . $time); // Add 15-minute grace period $eventDateTime->modify('+15 minutes'); // Event is past if current time > event time + 15 minutes return $currentDateTime > $eventDateTime; } catch (Exception $e) { // If time parsing fails, fall back to date-only comparison return false; } } // No time specified for today's event, treat as future return false; }; // Build HTML for each event - separate past/completed from future $pastHtml = ''; $futureHtml = ''; $pastCount = 0; foreach ($events as $dateKey => $dayEvents) { foreach ($dayEvents as $event) { // Track first future/today event for auto-scroll if (!$firstFutureEventId && $dateKey >= $today) { $firstFutureEventId = isset($event['id']) ? $event['id'] : ''; } $eventId = isset($event['id']) ? $event['id'] : ''; $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled'; $timeRaw = isset($event['time']) ? $event['time'] : ''; $time = htmlspecialchars($timeRaw); $endTime = isset($event['endTime']) ? htmlspecialchars($event['endTime']) : ''; $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db'; $description = isset($event['description']) ? $event['description'] : ''; $isTask = isset($event['isTask']) ? $event['isTask'] : false; $completed = isset($event['completed']) ? $event['completed'] : false; $endDate = isset($event['endDate']) ? $event['endDate'] : ''; // Use helper function to determine if event is past (with grace period) $isPast = $isEventPast($dateKey, $timeRaw); $isToday = $dateKey === $today; // Check if event should be in past section // EXCEPTION: Uncompleted tasks (isTask && !completed) should stay visible even if past $isPastOrCompleted = ($isPast && (!$isTask || $completed)) || $completed; if ($isPastOrCompleted) { $pastCount++; } // Determine if task is past due (past date, is task, not completed) $isPastDue = $isPast && $isTask && !$completed; // Process description for wiki syntax, HTML, images, and links $renderedDescription = $this->renderDescription($description, $themeStyles); // Convert to 12-hour format and handle time ranges $displayTime = ''; if ($time) { $timeObj = DateTime::createFromFormat('H:i', $time); if ($timeObj) { $displayTime = $timeObj->format('g:i A'); // Add end time if present and different from start time if ($endTime && $endTime !== $time) { $endTimeObj = DateTime::createFromFormat('H:i', $endTime); if ($endTimeObj) { $displayTime .= ' - ' . $endTimeObj->format('g:i A'); } } } else { $displayTime = $time; } } // Format date display with day of week // Use originalStartDate if this is a multi-month event continuation $displayDateKey = isset($event['originalStartDate']) ? $event['originalStartDate'] : $dateKey; $dateObj = new DateTime($displayDateKey); $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Jan 24" // Multi-day indicator $multiDay = ''; if ($endDate && $endDate !== $displayDateKey) { $endObj = new DateTime($endDate); $multiDay = ' → ' . $endObj->format('D, M j'); } $completedClass = $completed ? ' event-completed' : ''; // Don't grey out past due tasks - they need attention! $pastClass = ($isPast && !$isPastDue) ? ' event-past' : ''; $pastDueClass = $isPastDue ? ' event-pastdue' : ''; $firstFutureAttr = ($firstFutureEventId === $eventId) ? ' data-first-future="true"' : ''; // For all themes: use CSS variables, only keep border-left-color as inline $pastClickHandler = ($isPast && !$isPastDue) ? ' onclick="togglePastEventExpand(this)"' : ''; $eventHtml = '
'; $eventHtml .= '
'; $eventHtml .= '
'; $eventHtml .= '' . $title . ''; $eventHtml .= '
'; // For past events, hide meta and description (collapsed) // EXCEPTION: Past due tasks should show their details if (!$isPast || $isPastDue) { $eventHtml .= '
'; $eventHtml .= '' . $displayDate . $multiDay; if ($displayTime) { $eventHtml .= ' • ' . $displayTime; } // Add TODAY badge for today's events OR PAST DUE for uncompleted past tasks if ($isPastDue) { $eventHtml .= ' PAST DUE'; } elseif ($isToday) { $eventHtml .= ' TODAY'; } // Add namespace badge - ALWAYS show if event has a namespace $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; if (!$eventNamespace && isset($event['_namespace'])) { $eventNamespace = $event['_namespace']; // Fallback to _namespace for backward compatibility } // Show badge if namespace exists and is not empty if ($eventNamespace && $eventNamespace !== '') { $eventHtml .= ' ' . htmlspecialchars($eventNamespace) . ''; } // Add conflict warning if event has time conflicts if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) { $conflictList = []; foreach ($event['conflictsWith'] as $conflict) { $conflictText = $conflict['title']; if (!empty($conflict['time'])) { // Format time range $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']); $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time']; if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) { $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']); $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime']; $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')'; } else { $conflictText .= ' (' . $startTimeFormatted . ')'; } } $conflictList[] = $conflictText; } $conflictCount = count($event['conflictsWith']); $conflictJson = base64_encode(json_encode($conflictList)); $eventHtml .= ' ⚠️ ' . $conflictCount . ''; } $eventHtml .= ''; $eventHtml .= '
'; if ($description) { $eventHtml .= '
' . $renderedDescription . '
'; } } else { // Past events: render with display:none for click-to-expand $eventHtml .= ''; if ($description) { $eventHtml .= ''; } } $eventHtml .= '
'; // event-info // Use stored namespace from event, fallback to passed namespace $buttonNamespace = isset($event['namespace']) ? $event['namespace'] : $namespace; $eventHtml .= '
'; $eventHtml .= ''; $eventHtml .= ''; $eventHtml .= '
'; // Checkbox for tasks - ON THE FAR RIGHT if ($isTask) { $checked = $completed ? 'checked' : ''; $eventHtml .= ''; } $eventHtml .= '
'; // Add to appropriate section if ($isPastOrCompleted) { $pastHtml .= $eventHtml; } else { $futureHtml .= $eventHtml; } } } // Build final HTML with collapsible past events section $html = ''; // Add collapsible past events section if any exist if ($pastCount > 0) { $html .= '
'; $html .= '
'; $html .= ' '; $html .= 'Past Events (' . $pastCount . ')'; $html .= '
'; $html .= ''; $html .= '
'; } // Add future events $html .= $futureHtml; return $html; } /** * Check for time conflicts between events */ private function checkTimeConflicts($events) { // Group events by date $eventsByDate = []; foreach ($events as $date => $dateEvents) { if (!is_array($dateEvents)) continue; foreach ($dateEvents as $evt) { if (empty($evt['time'])) continue; // Skip all-day events if (!isset($eventsByDate[$date])) { $eventsByDate[$date] = []; } $eventsByDate[$date][] = $evt; } } // Check for overlaps on each date foreach ($eventsByDate as $date => $dateEvents) { for ($i = 0; $i < count($dateEvents); $i++) { for ($j = $i + 1; $j < count($dateEvents); $j++) { if ($this->eventsOverlap($dateEvents[$i], $dateEvents[$j])) { // Mark both events as conflicting $dateEvents[$i]['hasConflict'] = true; $dateEvents[$j]['hasConflict'] = true; // Store conflict info if (!isset($dateEvents[$i]['conflictsWith'])) { $dateEvents[$i]['conflictsWith'] = []; } if (!isset($dateEvents[$j]['conflictsWith'])) { $dateEvents[$j]['conflictsWith'] = []; } $dateEvents[$i]['conflictsWith'][] = [ 'id' => $dateEvents[$j]['id'], 'title' => $dateEvents[$j]['title'], 'time' => $dateEvents[$j]['time'], 'endTime' => isset($dateEvents[$j]['endTime']) ? $dateEvents[$j]['endTime'] : '' ]; $dateEvents[$j]['conflictsWith'][] = [ 'id' => $dateEvents[$i]['id'], 'title' => $dateEvents[$i]['title'], 'time' => $dateEvents[$i]['time'], 'endTime' => isset($dateEvents[$i]['endTime']) ? $dateEvents[$i]['endTime'] : '' ]; } } } // Update the events array with conflict information foreach ($events[$date] as &$evt) { foreach ($dateEvents as $checkedEvt) { if ($evt['id'] === $checkedEvt['id']) { if (isset($checkedEvt['hasConflict'])) { $evt['hasConflict'] = $checkedEvt['hasConflict']; } if (isset($checkedEvt['conflictsWith'])) { $evt['conflictsWith'] = $checkedEvt['conflictsWith']; } break; } } } } return $events; } /** * Check if two events overlap in time */ private function eventsOverlap($evt1, $evt2) { if (empty($evt1['time']) || empty($evt2['time'])) { return false; // All-day events don't conflict } $start1 = $evt1['time']; $end1 = isset($evt1['endTime']) && !empty($evt1['endTime']) ? $evt1['endTime'] : $evt1['time']; $start2 = $evt2['time']; $end2 = isset($evt2['endTime']) && !empty($evt2['endTime']) ? $evt2['endTime'] : $evt2['time']; // Convert to minutes for easier comparison $start1Mins = $this->timeToMinutes($start1); $end1Mins = $this->timeToMinutes($end1); $start2Mins = $this->timeToMinutes($start2); $end2Mins = $this->timeToMinutes($end2); // Check for overlap: start1 < end2 AND start2 < end1 return $start1Mins < $end2Mins && $start2Mins < $end1Mins; } /** * Convert HH:MM time to minutes since midnight */ private function timeToMinutes($timeStr) { $parts = explode(':', $timeStr); if (count($parts) !== 2) return 0; return (int)$parts[0] * 60 + (int)$parts[1]; } private function renderEventPanelOnly($data) { $year = (int)$data['year']; $month = (int)$data['month']; $namespace = $data['namespace']; $height = isset($data['height']) ? $data['height'] : '400px'; // Validate height format (must be px, em, rem, vh, or %) if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) { $height = '400px'; // Default fallback } // Get theme $theme = $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); // Check if multiple namespaces or wildcard specified $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); if ($isMultiNamespace) { $events = $this->loadEventsMultiNamespace($namespace, $year, $month); } else { $events = $this->loadEvents($namespace, $year, $month); } $calId = 'panel_' . md5(serialize($data) . microtime()); $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year)); $prevMonth = $month - 1; $prevYear = $year; if ($prevMonth < 1) { $prevMonth = 12; $prevYear--; } $nextMonth = $month + 1; $nextYear = $year; if ($nextMonth > 12) { $nextMonth = 1; $nextYear++; } // Determine button text color based on theme $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg']; $html = '
'; // Inject CSS variables for this panel instance - same as main calendar $html .= ''; // Load calendar JavaScript manually (not through DokuWiki concatenation) $html .= ''; // Initialize DOKU_BASE for JavaScript $html .= ''; // Compact two-row header designed for ~500px width $html .= '
'; // Row 1: Navigation and title $html .= '
'; $html .= ''; // Compact month name (e.g. "Feb 2026" instead of "February 2026 Events") $shortMonthName = date('M Y', mktime(0, 0, 0, $month, 1, $year)); $html .= '

' . $shortMonthName . '

'; $html .= ''; // Namespace badge (if applicable) if ($namespace) { if ($isMultiNamespace) { if (strpos($namespace, '*') !== false) { $html .= '' . htmlspecialchars($namespace) . ''; } else { $namespaceList = array_map('trim', explode(';', $namespace)); $nsCount = count($namespaceList); $html .= '' . $nsCount . ' NS'; } } else { $isFiltering = ($namespace !== '*' && strpos($namespace, '*') === false && strpos($namespace, ';') === false); if ($isFiltering) { $html .= '' . htmlspecialchars($namespace) . ' ✕'; } else { $html .= '' . htmlspecialchars($namespace) . ''; } } } $html .= ''; $html .= '
'; // Row 2: Search and add button $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= '
'; $html .= $this->renderEventListContent($events, $calId, $namespace); $html .= '
'; $html .= $this->renderEventDialog($calId, $namespace); // Month/Year picker for event panel $html .= $this->renderMonthPicker($calId, $year, $month, $namespace, $theme, $themeStyles); $html .= '
'; return $html; } private function renderStandaloneEventList($data) { $namespace = $data['namespace']; // If no namespace specified, show all namespaces if (empty($namespace)) { $namespace = '*'; } $daterange = $data['daterange']; $date = $data['date']; $range = isset($data['range']) ? strtolower($data['range']) : ''; $today = isset($data['today']) ? true : false; $sidebar = isset($data['sidebar']) ? true : false; $showchecked = isset($data['showchecked']) ? true : false; // New parameter $noheader = isset($data['noheader']) ? true : false; // New parameter to hide header // Handle "range" parameter - day, week, or month if ($range === 'day') { $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks $endDate = date('Y-m-d'); $headerText = 'Today'; } elseif ($range === 'week') { $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks $endDateTime = new DateTime(); $endDateTime->modify('+7 days'); $endDate = $endDateTime->format('Y-m-d'); $headerText = 'This Week'; } elseif ($range === 'month') { $startDate = date('Y-m-01', strtotime('-1 month')); // Include previous month for past due tasks $endDate = date('Y-m-t'); // Last of current month $dt = new DateTime(); $headerText = $dt->format('F Y'); } elseif ($sidebar) { // NEW: Sidebar widget - load current week's events $weekStartDay = $this->getWeekStartDay(); // Get saved preference if ($weekStartDay === 'monday') { // Monday start $weekStart = date('Y-m-d', strtotime('monday this week')); $weekEnd = date('Y-m-d', strtotime('sunday this week')); } else { // Sunday start (default - US/Canada standard) $today = date('w'); // 0 (Sun) to 6 (Sat) if ($today == 0) { // Today is Sunday $weekStart = date('Y-m-d'); } else { // Monday-Saturday: go back to last Sunday $weekStart = date('Y-m-d', strtotime('-' . $today . ' days')); } $weekEnd = date('Y-m-d', strtotime($weekStart . ' +6 days')); } // Load events for the entire week PLUS tomorrow (if tomorrow is outside week) // PLUS next 2 weeks for Important events $start = new DateTime($weekStart); $end = new DateTime($weekEnd); // Check if we need to extend to include tomorrow $tomorrowDate = date('Y-m-d', strtotime('+1 day')); if ($tomorrowDate > $weekEnd) { // Tomorrow is outside the week, extend end date to include it $end = new DateTime($tomorrowDate); } // Extend 2 weeks into the future for Important events $twoWeeksOut = date('Y-m-d', strtotime($weekEnd . ' +14 days')); $end = new DateTime($twoWeeksOut); $end->modify('+1 day'); // DatePeriod excludes end date $interval = new DateInterval('P1D'); $period = new DatePeriod($start, $interval, $end); $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); $allEvents = []; $loadedMonths = []; foreach ($period as $dt) { $year = (int)$dt->format('Y'); $month = (int)$dt->format('n'); $dateKey = $dt->format('Y-m-d'); $monthKey = $year . '-' . $month . '-' . $namespace; if (!isset($loadedMonths[$monthKey])) { if ($isMultiNamespace) { $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month); } else { $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month); } } $monthEvents = $loadedMonths[$monthKey]; if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) { $allEvents[$dateKey] = $monthEvents[$dateKey]; } } // Apply time conflict detection $allEvents = $this->checkTimeConflicts($allEvents); $calId = 'sidebar-' . substr(md5($namespace . $weekStart), 0, 8); // Render sidebar widget and return immediately return $this->renderSidebarWidget($allEvents, $namespace, $calId); } elseif ($today) { $startDate = date('Y-m-d'); $endDate = date('Y-m-d'); $headerText = 'Today'; } elseif ($daterange) { list($startDate, $endDate) = explode(':', $daterange); $start = new DateTime($startDate); $end = new DateTime($endDate); $headerText = $start->format('M j') . ' - ' . $end->format('M j, Y'); } elseif ($date) { $startDate = $date; $endDate = $date; $dt = new DateTime($date); $headerText = $dt->format('l, F j, Y'); } else { $startDate = date('Y-m-01'); $endDate = date('Y-m-t'); $dt = new DateTime($startDate); $headerText = $dt->format('F Y'); } // Load all events in date range $allEvents = array(); $start = new DateTime($startDate); $end = new DateTime($endDate); $end->modify('+1 day'); $interval = new DateInterval('P1D'); $period = new DatePeriod($start, $interval, $end); // Check if multiple namespaces or wildcard specified $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); static $loadedMonths = array(); foreach ($period as $dt) { $year = (int)$dt->format('Y'); $month = (int)$dt->format('n'); $dateKey = $dt->format('Y-m-d'); $monthKey = $year . '-' . $month . '-' . $namespace; if (!isset($loadedMonths[$monthKey])) { if ($isMultiNamespace) { $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month); } else { $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month); } } $monthEvents = $loadedMonths[$monthKey]; if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) { $allEvents[$dateKey] = $monthEvents[$dateKey]; } } // Sort events by date (already sorted by dateKey), then by time within each day foreach ($allEvents as $dateKey => &$dayEvents) { usort($dayEvents, function($a, $b) { $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null; $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null; // All-day events (no time) go to the TOP if ($timeA === null && $timeB !== null) return -1; // A before B if ($timeA !== null && $timeB === null) return 1; // A after B if ($timeA === null && $timeB === null) return 0; // Both all-day, equal // Both have times, sort chronologically return strcmp($timeA, $timeB); }); } unset($dayEvents); // Break reference // Simple 2-line display widget $calId = 'eventlist_' . uniqid(); $html = '
'; // Load calendar JavaScript manually (not through DokuWiki concatenation) $html .= ''; // Initialize DOKU_BASE for JavaScript $html .= ''; // Add compact header with date and clock for "today" mode (unless noheader is set) if ($today && !empty($allEvents) && !$noheader) { $todayDate = new DateTime(); $displayDate = $todayDate->format('D, M j, Y'); // "Fri, Jan 30, 2026" $currentTime = $todayDate->format('g:i:s A'); // "2:45:30 PM" $html .= '
'; $html .= '' . $currentTime . ''; $html .= '
'; $html .= '🌤️ --°'; $html .= '' . $displayDate . ''; $html .= '
'; // Three CPU/Memory bars (all update live) $html .= '
'; // 5-minute load average (green, updates every 2 seconds) $html .= '
'; $html .= '
'; $html .= ''; $html .= '
'; // Real-time CPU (purple, updates with 5-sec average) $html .= '
'; $html .= '
'; $html .= ''; $html .= '
'; // Real-time Memory (orange, updates) $html .= '
'; $html .= '
'; $html .= ''; $html .= '
'; $html .= '
'; $html .= '
'; // Add JavaScript to update clock and weather $html .= ''; } if (empty($allEvents)) { $html .= '
'; $html .= '
' . htmlspecialchars($headerText); if ($namespace) { $html .= ' ' . htmlspecialchars($namespace) . ''; } $html .= '
'; $html .= '
No events
'; $html .= '
'; } else { // Calculate today and tomorrow's dates for highlighting $todayStr = date('Y-m-d'); $tomorrow = date('Y-m-d', strtotime('+1 day')); foreach ($allEvents as $dateKey => $dayEvents) { $dateObj = new DateTime($dateKey); $displayDate = $dateObj->format('D, M j'); // Check if this date is today or tomorrow or past // Enable highlighting for sidebar mode AND range modes (day, week, month) $enableHighlighting = $sidebar || !empty($range); $isToday = $enableHighlighting && ($dateKey === $todayStr); $isTomorrow = $enableHighlighting && ($dateKey === $tomorrow); $isPast = $dateKey < $todayStr; foreach ($dayEvents as $event) { // Check if this is a task and if it's completed $isTask = !empty($event['isTask']); $completed = !empty($event['completed']); // ALWAYS skip completed tasks UNLESS showchecked is explicitly set if (!$showchecked && $isTask && $completed) { continue; } // Skip past events that are NOT tasks (only show past due tasks from the past) if ($isPast && !$isTask) { continue; } // Determine if task is past due (past date, is task, not completed) $isPastDue = $isPast && $isTask && !$completed; // Line 1: Header (Title, Time, Date, Namespace) $todayClass = $isToday ? ' eventlist-simple-today' : ''; $tomorrowClass = $isTomorrow ? ' eventlist-simple-tomorrow' : ''; $pastDueClass = $isPastDue ? ' eventlist-simple-pastdue' : ''; $html .= '
'; $html .= '
'; // Title $html .= '' . htmlspecialchars($event['title']) . ''; // Time (12-hour format) if (!empty($event['time'])) { $timeParts = explode(':', $event['time']); if (count($timeParts) === 2) { $hour = (int)$timeParts[0]; $minute = $timeParts[1]; $ampm = $hour >= 12 ? 'PM' : 'AM'; $hour = $hour % 12 ?: 12; $displayTime = $hour . ':' . $minute . ' ' . $ampm; $html .= ' ' . $displayTime . ''; } } // Date $html .= ' ' . $displayDate . ''; // Badge: PAST DUE, TODAY, or nothing if ($isPastDue) { $html .= ' PAST DUE'; } elseif ($isToday) { $html .= ' TODAY'; } // Namespace badge (show individual event's namespace) $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; if (!$eventNamespace && isset($event['_namespace'])) { $eventNamespace = $event['_namespace']; // Fallback to _namespace for multi-namespace loading } if ($eventNamespace) { $html .= ' ' . htmlspecialchars($eventNamespace) . ''; } $html .= '
'; // header // Line 2: Body (Description only) - only show if description exists if (!empty($event['description'])) { $html .= '
' . $this->renderDescription($event['description']) . '
'; } $html .= '
'; // item } } } $html .= '
'; // eventlist-simple return $html; } private function renderEventDialog($calId, $namespace) { // Get theme for dialog $theme = $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); $html = ''; return $html; } private function renderMonthPicker($calId, $year, $month, $namespace, $theme = 'matrix', $themeStyles = null) { // Fallback to default theme if not provided if ($themeStyles === null) { $themeStyles = $this->getSidebarThemeStyles($theme); } $themeClass = 'calendar-theme-' . $theme; $html = ''; return $html; } private function renderDescription($description, $themeStyles = null) { if (empty($description)) { return ''; } // Get theme for link colors if not provided if ($themeStyles === null) { $theme = $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); } $linkColor = ''; $linkStyle = ' class="cal-link"'; // Token-based parsing to avoid escaping issues $rendered = $description; $tokens = array(); $tokenIndex = 0; // Convert DokuWiki image syntax {{image.jpg}} to tokens $pattern = '/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/'; preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $imagePath = trim($match[1]); $alt = isset($match[2]) ? trim($match[2]) : ''; // Handle external URLs if (preg_match('/^https?:\/\//', $imagePath)) { $imageHtml = '' . htmlspecialchars($alt) . ''; } else { // Handle internal DokuWiki images $imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath); $imageHtml = '' . htmlspecialchars($alt) . ''; } $token = "\x00TOKEN" . $tokenIndex . "\x00"; $tokens[$tokenIndex] = $imageHtml; $tokenIndex++; $rendered = str_replace($match[0], $token, $rendered); } // Convert DokuWiki link syntax [[link|text]] to tokens $pattern = '/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/'; preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $link = trim($match[1]); $text = isset($match[2]) ? trim($match[2]) : $link; // Handle external URLs if (preg_match('/^https?:\/\//', $link)) { $linkHtml = '' . htmlspecialchars($text) . ''; } else { // Handle internal DokuWiki links with section anchors $parts = explode('#', $link, 2); $pagePart = $parts[0]; $sectionPart = isset($parts[1]) ? '#' . $parts[1] : ''; $wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($pagePart) . $sectionPart; $linkHtml = '' . htmlspecialchars($text) . ''; } $token = "\x00TOKEN" . $tokenIndex . "\x00"; $tokens[$tokenIndex] = $linkHtml; $tokenIndex++; $rendered = str_replace($match[0], $token, $rendered); } // Convert markdown-style links [text](url) to tokens $pattern = '/\[([^\]]+)\]\(([^)]+)\)/'; preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $text = trim($match[1]); $url = trim($match[2]); if (preg_match('/^https?:\/\//', $url)) { $linkHtml = '' . htmlspecialchars($text) . ''; } else { $linkHtml = '' . htmlspecialchars($text) . ''; } $token = "\x00TOKEN" . $tokenIndex . "\x00"; $tokens[$tokenIndex] = $linkHtml; $tokenIndex++; $rendered = str_replace($match[0], $token, $rendered); } // Convert plain URLs to tokens $pattern = '/(https?:\/\/[^\s<]+)/'; preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $url = $match[1]; $linkHtml = '' . htmlspecialchars($url) . ''; $token = "\x00TOKEN" . $tokenIndex . "\x00"; $tokens[$tokenIndex] = $linkHtml; $tokenIndex++; $rendered = str_replace($match[0], $token, $rendered); } // NOW escape HTML (tokens are protected) $rendered = htmlspecialchars($rendered); // Convert newlines to
$rendered = nl2br($rendered); // DokuWiki text formatting // Bold: **text** or __text__ $boldStyle = ''; $rendered = preg_replace('/\*\*(.+?)\*\*/', '$1', $rendered); $rendered = preg_replace('/__(.+?)__/', '$1', $rendered); // Italic: //text// $rendered = preg_replace('/\/\/(.+?)\/\//', '$1', $rendered); // Strikethrough: text $rendered = preg_replace('/<del>(.+?)<\/del>/', '$1', $rendered); // Monospace: ''text'' $rendered = preg_replace('/''(.+?)''/', '$1', $rendered); // Subscript: text $rendered = preg_replace('/<sub>(.+?)<\/sub>/', '$1', $rendered); // Superscript: text $rendered = preg_replace('/<sup>(.+?)<\/sup>/', '$1', $rendered); // Restore tokens foreach ($tokens as $i => $html) { $rendered = str_replace("\x00TOKEN" . $i . "\x00", $html, $rendered); } return $rendered; } private function loadEvents($namespace, $year, $month) { $dataDir = DOKU_INC . 'data/meta/'; if ($namespace) { $dataDir .= str_replace(':', '/', $namespace) . '/'; } $dataDir .= 'calendar/'; $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); if (file_exists($eventFile)) { $json = file_get_contents($eventFile); return json_decode($json, true); } return array(); } private function loadEventsMultiNamespace($namespaces, $year, $month) { // Check for wildcard pattern (namespace:*) if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { $baseNamespace = $matches[1]; return $this->loadEventsWildcard($baseNamespace, $year, $month); } // Check for root wildcard (just *) if ($namespaces === '*') { return $this->loadEventsWildcard('', $year, $month); } // Parse namespace list (semicolon separated) // e.g., "team:projects;personal;work:tasks" = three namespaces $namespaceList = array_map('trim', explode(';', $namespaces)); // Load events from all namespaces $allEvents = array(); foreach ($namespaceList as $ns) { $ns = trim($ns); if (empty($ns)) continue; $events = $this->loadEvents($ns, $year, $month); // Add namespace tag to each event foreach ($events as $dateKey => $dayEvents) { if (!isset($allEvents[$dateKey])) { $allEvents[$dateKey] = array(); } foreach ($dayEvents as $event) { $event['_namespace'] = $ns; $allEvents[$dateKey][] = $event; } } } return $allEvents; } private function loadEventsWildcard($baseNamespace, $year, $month) { // Find all subdirectories under the base namespace $dataDir = DOKU_INC . 'data/meta/'; if ($baseNamespace) { $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; } $allEvents = array(); // First, load events from the base namespace itself if (empty($baseNamespace)) { // Root wildcard - load from root calendar $events = $this->loadEvents('', $year, $month); foreach ($events as $dateKey => $dayEvents) { if (!isset($allEvents[$dateKey])) { $allEvents[$dateKey] = array(); } foreach ($dayEvents as $event) { $event['_namespace'] = ''; $allEvents[$dateKey][] = $event; } } } else { $events = $this->loadEvents($baseNamespace, $year, $month); foreach ($events as $dateKey => $dayEvents) { if (!isset($allEvents[$dateKey])) { $allEvents[$dateKey] = array(); } foreach ($dayEvents as $event) { $event['_namespace'] = $baseNamespace; $allEvents[$dateKey][] = $event; } } } // Recursively find all subdirectories $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); return $allEvents; } private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . $item; if (is_dir($path) && $item !== 'calendar') { // This is a namespace directory $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; // Load events from this namespace $events = $this->loadEvents($namespace, $year, $month); foreach ($events as $dateKey => $dayEvents) { if (!isset($allEvents[$dateKey])) { $allEvents[$dateKey] = array(); } foreach ($dayEvents as $event) { $event['_namespace'] = $namespace; $allEvents[$dateKey][] = $event; } } // Recurse into subdirectories $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); } } } private function getAllNamespaces() { $dataDir = DOKU_INC . 'data/meta/'; $namespaces = []; // Scan for namespaces that have calendar data $this->scanForCalendarNamespaces($dataDir, '', $namespaces); // Sort alphabetically sort($namespaces); return $namespaces; } private function scanForCalendarNamespaces($dir, $baseNamespace, &$namespaces) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . $item; if (is_dir($path)) { // Check if this directory has a calendar subdirectory with data $calendarDir = $path . '/calendar/'; if (is_dir($calendarDir)) { // Check if there are any JSON files in the calendar directory $jsonFiles = glob($calendarDir . '*.json'); if (!empty($jsonFiles)) { // This namespace has calendar data $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; $namespaces[] = $namespace; } } // Recurse into subdirectories $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; $this->scanForCalendarNamespaces($path . '/', $namespace, $namespaces); } } } /** * Render new sidebar widget - Week at a glance itinerary (200px wide) */ private function renderSidebarWidget($events, $namespace, $calId) { if (empty($events)) { return '
No events this week
'; } // Get important namespaces from config $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; $importantNsList = ['important']; // default if (file_exists($configFile)) { $config = include $configFile; if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) { $importantNsList = array_map('trim', explode(',', $config['important_namespaces'])); } } // Calculate date ranges $todayStr = date('Y-m-d'); $tomorrowStr = date('Y-m-d', strtotime('+1 day')); // Get week start preference and calculate week range $weekStartDay = $this->getWeekStartDay(); if ($weekStartDay === 'monday') { // Monday start $weekStart = date('Y-m-d', strtotime('monday this week')); $weekEnd = date('Y-m-d', strtotime('sunday this week')); } else { // Sunday start (default - US/Canada standard) $today = date('w'); // 0 (Sun) to 6 (Sat) if ($today == 0) { // Today is Sunday $weekStart = date('Y-m-d'); } else { // Monday-Saturday: go back to last Sunday $weekStart = date('Y-m-d', strtotime('-' . $today . ' days')); } $weekEnd = date('Y-m-d', strtotime($weekStart . ' +6 days')); } // Group events by category $todayEvents = []; $tomorrowEvents = []; $importantEvents = []; $weekEvents = []; // For week grid // Process all events foreach ($events as $dateKey => $dayEvents) { // Detect conflicts for events on this day $eventsWithConflicts = $this->detectTimeConflicts($dayEvents); foreach ($eventsWithConflicts as $event) { // Always categorize Today and Tomorrow regardless of week boundaries if ($dateKey === $todayStr) { $todayEvents[] = array_merge($event, ['date' => $dateKey]); } if ($dateKey === $tomorrowStr) { $tomorrowEvents[] = array_merge($event, ['date' => $dateKey]); } // Process week grid events (only for current week) if ($dateKey >= $weekStart && $dateKey <= $weekEnd) { // Initialize week grid day if not exists if (!isset($weekEvents[$dateKey])) { $weekEvents[$dateKey] = []; } // Pre-render DokuWiki syntax to HTML for JavaScript display $eventWithHtml = $event; if (isset($event['title'])) { $eventWithHtml['title_html'] = $this->renderDokuWikiToHtml($event['title']); } if (isset($event['description'])) { $eventWithHtml['description_html'] = $this->renderDokuWikiToHtml($event['description']); } $weekEvents[$dateKey][] = $eventWithHtml; } // Check if this is an important namespace $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; $isImportant = false; foreach ($importantNsList as $impNs) { if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) { $isImportant = true; break; } } // Important events: show from today through next 2 weeks if ($isImportant && $dateKey >= $todayStr) { $importantEvents[] = array_merge($event, ['date' => $dateKey]); } } } // Sort Important Events by date (earliest first) usort($importantEvents, function($a, $b) { $dateA = isset($a['date']) ? $a['date'] : ''; $dateB = isset($b['date']) ? $b['date'] : ''; // Compare dates if ($dateA === $dateB) { // Same date - sort by time $timeA = isset($a['time']) ? $a['time'] : ''; $timeB = isset($b['time']) ? $b['time'] : ''; if (empty($timeA) && !empty($timeB)) return 1; // All-day events last if (!empty($timeA) && empty($timeB)) return -1; if (empty($timeA) && empty($timeB)) return 0; // Both have times $aMinutes = $this->timeToMinutes($timeA); $bMinutes = $this->timeToMinutes($timeB); return $aMinutes - $bMinutes; } return strcmp($dateA, $dateB); }); // Get theme and apply appropriate CSS $theme = $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); $themeClass = 'sidebar-' . $theme; // Start building HTML - Dynamic width with default font (overflow:visible for tooltips) $html = ''; // Add event dialog for sidebar widget $html .= $this->renderEventDialog($calId, $namespace); // Add JavaScript for positioning data-tooltip elements $html .= ''; return $html; } /** * Render compact week grid (7 cells with event bars) - Theme-aware */ private function renderWeekGrid($weekEvents, $weekStart, $themeStyles, $theme) { // Generate unique ID for this calendar instance - sanitize for JavaScript $calId = 'cal_' . substr(md5($weekStart . microtime()), 0, 8); $jsCalId = str_replace('-', '_', $calId); // Sanitize for JS variable names $html = '
'; // Day names depend on week start setting $weekStartDay = $this->getWeekStartDay(); if ($weekStartDay === 'monday') { $dayNames = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Monday to Sunday } else { $dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; // Sunday to Saturday } $today = date('Y-m-d'); for ($i = 0; $i < 7; $i++) { $date = date('Y-m-d', strtotime($weekStart . ' +' . $i . ' days')); $dayNum = date('j', strtotime($date)); $isToday = $date === $today; $events = isset($weekEvents[$date]) ? $weekEvents[$date] : []; $eventCount = count($events); $bgColor = $isToday ? $themeStyles['cell_today_bg'] : $themeStyles['cell_bg']; $textColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary']; $fontWeight = $isToday ? '700' : '500'; // Theme-aware text shadow if ($theme === 'pink') { $glowColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary']; $textShadow = $isToday ? 'text-shadow:0 0 6px ' . $glowColor . ';' : 'text-shadow:0 0 4px ' . $glowColor . ';'; } else { $textShadow = ''; // No glow for other themes } // Border color based on theme $borderColor = $themeStyles['grid_border']; $hasEvents = $eventCount > 0; $clickableStyle = $hasEvents ? 'cursor:pointer;' : ''; $clickHandler = $hasEvents ? ' onclick="showDayEvents_' . $jsCalId . '(\'' . $date . '\')"' : ''; $html .= '
'; // Day letter - theme color $dayLetterColor = $theme === 'professional' ? '#7f8c8d' : $themeStyles['text_primary']; $html .= '
' . $dayNames[$i] . '
'; // Day number $html .= '
' . $dayNum . '
'; // Event bars (max 4 visible) with theme-aware glow if ($eventCount > 0) { $showCount = min($eventCount, 4); for ($j = 0; $j < $showCount; $j++) { $event = $events[$j]; $color = isset($event['color']) ? $event['color'] : $themeStyles['text_primary']; $barShadow = $theme === 'professional' ? '0 1px 2px rgba(0,0,0,0.2)' : '0 0 3px ' . htmlspecialchars($color); $html .= '
'; } // Show "+N more" if more than 4 - theme color if ($eventCount > 4) { $moreTextColor = $theme === 'professional' ? '#7f8c8d' : $themeStyles['text_primary']; $html .= '
+' . ($eventCount - 4) . '
'; } } $html .= '
'; } $html .= '
'; // Add container for selected day events display (with unique ID) - theme-aware $panelBorderColor = $theme === 'matrix' ? '#00cc07' : ($theme === 'purple' ? '#9b59b6' : ($theme === 'pink' ? '#ff1493' : ($theme === 'wiki' ? $themeStyles['border'] : '#3498db'))); $panelHeaderBg = $theme === 'matrix' ? '#00cc07' : ($theme === 'purple' ? '#9b59b6' : ($theme === 'pink' ? '#ff1493' : ($theme === 'wiki' ? $themeStyles['border'] : '#3498db'))); $panelShadow = $theme === 'matrix' ? '0 0 5px rgba(0, 204, 7, 0.2)' : ($theme === 'purple' ? '0 0 5px rgba(155, 89, 182, 0.2)' : ($theme === 'pink' ? '0 0 8px rgba(255, 20, 147, 0.4)' : '0 1px 3px rgba(0, 0, 0, 0.1)')); $panelContentBg = $theme === 'professional' ? 'rgba(255, 255, 255, 0.95)' : ($theme === 'wiki' ? $themeStyles['cell_bg'] : 'rgba(36, 36, 36, 0.5)'); $panelHeaderShadow = ($theme === 'professional' || $theme === 'wiki') ? '0 2px 4px rgba(0, 0, 0, 0.15)' : '0 0 8px ' . $panelHeaderBg; // Header text color - white for colored headers, dark for light headers $panelHeaderColor = ($theme === 'wiki' || $theme === 'professional') ? '#fff' : '#000'; $html .= ''; // Add JavaScript for day selection with event data $html .= ''; return $html; } /** * Render a sidebar section (Today/Tomorrow/Important) - Matrix themed with colored borders */ private function renderSidebarSection($title, $events, $accentColor, $calId, $themeStyles, $theme) { // Keep the original accent colors for borders $borderColor = $accentColor; // Show date for Important Events section $showDate = ($title === 'Important Events'); // Sort events differently based on section if ($title === 'Important Events') { // Important Events: sort by date first, then by time usort($events, function($a, $b) { $aDate = isset($a['date']) ? $a['date'] : ''; $bDate = isset($b['date']) ? $b['date'] : ''; // Different dates - sort by date if ($aDate !== $bDate) { return strcmp($aDate, $bDate); } // Same date - sort by time $aTime = isset($a['time']) && !empty($a['time']) ? $a['time'] : ''; $bTime = isset($b['time']) && !empty($b['time']) ? $b['time'] : ''; // All-day events last within same date if (empty($aTime) && !empty($bTime)) return 1; if (!empty($aTime) && empty($bTime)) return -1; if (empty($aTime) && empty($bTime)) return 0; // Both have times $aMinutes = $this->timeToMinutes($aTime); $bMinutes = $this->timeToMinutes($bTime); return $aMinutes - $bMinutes; }); } else { // Today/Tomorrow: sort by time only (all same date) usort($events, function($a, $b) { $aTime = isset($a['time']) && !empty($a['time']) ? $a['time'] : ''; $bTime = isset($b['time']) && !empty($b['time']) ? $b['time'] : ''; // All-day events (no time) come first if (empty($aTime) && !empty($bTime)) return -1; if (!empty($aTime) && empty($bTime)) return 1; if (empty($aTime) && empty($bTime)) return 0; // Both have times - convert to minutes for proper chronological sort $aMinutes = $this->timeToMinutes($aTime); $bMinutes = $this->timeToMinutes($bTime); return $aMinutes - $bMinutes; }); } // Theme-aware section shadow $sectionShadow = $theme === 'matrix' ? '0 0 5px rgba(0, 204, 7, 0.2)' : ($theme === 'purple' ? '0 0 5px rgba(155, 89, 182, 0.2)' : ($theme === 'pink' ? '0 0 8px rgba(255, 20, 147, 0.4)' : '0 1px 3px rgba(0, 0, 0, 0.1)')); $html = '
'; // Section header with accent color background - theme-aware shadow $headerShadow = ($theme === 'professional' || $theme === 'wiki') ? '0 2px 4px rgba(0, 0, 0, 0.15)' : '0 0 8px ' . $accentColor; $headerTextColor = ($theme === 'wiki') ? '#fff' : '#000'; $html .= '
'; $html .= htmlspecialchars($title); $html .= '
'; // Events - no background (transparent) $html .= '
'; foreach ($events as $event) { $html .= $this->renderSidebarEvent($event, $calId, $showDate, $accentColor, $themeStyles, $theme); } $html .= '
'; $html .= '
'; return $html; } /** * Render individual event in sidebar - Theme-aware */ private function renderSidebarEvent($event, $calId, $showDate = false, $sectionColor = '#00cc07', $themeStyles = null, $theme = 'matrix') { $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled'; $time = isset($event['time']) ? $event['time'] : ''; $endTime = isset($event['endTime']) ? $event['endTime'] : ''; $eventColor = isset($event['color']) ? htmlspecialchars($event['color']) : ($themeStyles ? $themeStyles['text_primary'] : '#00cc07'); $date = isset($event['date']) ? $event['date'] : ''; $isTask = isset($event['isTask']) && $event['isTask']; $completed = isset($event['completed']) && $event['completed']; // Theme-aware colors $titleColor = $themeStyles ? $themeStyles['text_primary'] : '#00cc07'; $timeColor = $themeStyles ? $themeStyles['text_bright'] : '#00dd00'; $textShadow = ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $titleColor . ';' : ''; // Check for conflicts (using 'conflict' field set by detectTimeConflicts) $hasConflict = isset($event['conflict']) && $event['conflict']; $conflictingWith = isset($event['conflictingWith']) ? $event['conflictingWith'] : []; // Build conflict list for tooltip $conflictList = []; if ($hasConflict && !empty($conflictingWith)) { foreach ($conflictingWith as $conf) { $confTime = $this->formatTimeDisplay($conf['time'], isset($conf['end_time']) ? $conf['end_time'] : ''); $conflictList[] = $conf['title'] . ' (' . $confTime . ')'; } } // No background on individual events (transparent) // Use theme grid_border with slight opacity for subtle divider $borderColor = $themeStyles['grid_border']; $html = '
'; // Event's assigned color bar (single bar on the left) $barShadow = ($theme === 'professional') ? '0 1px 2px rgba(0,0,0,0.2)' : '0 0 3px ' . $eventColor; $html .= '
'; // Content $html .= '
'; // Time + title $html .= '
'; if ($time) { $displayTime = $this->formatTimeDisplay($time, $endTime); $html .= '' . htmlspecialchars($displayTime) . ' '; } // Task checkbox if ($isTask) { $checkIcon = $completed ? '☑' : '☐'; $checkColor = $themeStyles ? $themeStyles['text_bright'] : '#00ff00'; $html .= '' . $checkIcon . ' '; } $html .= $title; // Already HTML-escaped on line 2625 // Conflict badge using same system as main calendar if ($hasConflict && !empty($conflictList)) { $conflictJson = base64_encode(json_encode($conflictList)); $html .= ' ⚠️ ' . count($conflictList) . ''; } $html .= '
'; // Date display BELOW event name for Important events if ($showDate && $date) { $dateObj = new DateTime($date); $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Feb 10" $dateColor = $themeStyles ? $themeStyles['text_dim'] : '#00aa00'; $dateShadow = ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $dateColor . ';' : ''; $html .= '
' . htmlspecialchars($displayDate) . '
'; } $html .= '
'; $html .= '
'; return $html; } /** * Format time display (12-hour format with optional end time) */ private function formatTimeDisplay($startTime, $endTime = '') { // Convert start time list($hour, $minute) = explode(':', $startTime); $hour = (int)$hour; $ampm = $hour >= 12 ? 'PM' : 'AM'; $displayHour = $hour % 12; if ($displayHour === 0) $displayHour = 12; $display = $displayHour . ':' . $minute . ' ' . $ampm; // Add end time if provided if ($endTime && $endTime !== '') { list($endHour, $endMinute) = explode(':', $endTime); $endHour = (int)$endHour; $endAmpm = $endHour >= 12 ? 'PM' : 'AM'; $endDisplayHour = $endHour % 12; if ($endDisplayHour === 0) $endDisplayHour = 12; $display .= '-' . $endDisplayHour . ':' . $endMinute . ' ' . $endAmpm; } return $display; } /** * Detect time conflicts among events on the same day * Returns events array with 'conflict' flag and 'conflictingWith' array */ private function detectTimeConflicts($dayEvents) { if (empty($dayEvents)) { return $dayEvents; } // If only 1 event, no conflicts possible but still add the flag if (count($dayEvents) === 1) { return [array_merge($dayEvents[0], ['conflict' => false, 'conflictingWith' => []])]; } $eventsWithFlags = []; foreach ($dayEvents as $i => $event) { $hasConflict = false; $conflictingWith = []; // Skip all-day events (no time) if (empty($event['time'])) { $eventsWithFlags[] = array_merge($event, ['conflict' => false, 'conflictingWith' => []]); continue; } // Get this event's time range $startTime = $event['time']; // Check both 'end_time' (snake_case) and 'endTime' (camelCase) for compatibility $endTime = ''; if (isset($event['end_time']) && $event['end_time'] !== '') { $endTime = $event['end_time']; } elseif (isset($event['endTime']) && $event['endTime'] !== '') { $endTime = $event['endTime']; } else { // If no end time, use start time (zero duration) - matches main calendar logic $endTime = $startTime; } // Check against all other events foreach ($dayEvents as $j => $otherEvent) { if ($i === $j) continue; // Skip self if (empty($otherEvent['time'])) continue; // Skip all-day events $otherStart = $otherEvent['time']; // Check both field name formats $otherEnd = ''; if (isset($otherEvent['end_time']) && $otherEvent['end_time'] !== '') { $otherEnd = $otherEvent['end_time']; } elseif (isset($otherEvent['endTime']) && $otherEvent['endTime'] !== '') { $otherEnd = $otherEvent['endTime']; } else { $otherEnd = $otherStart; } // Check for overlap: convert to minutes and compare $start1Min = $this->timeToMinutes($startTime); $end1Min = $this->timeToMinutes($endTime); $start2Min = $this->timeToMinutes($otherStart); $end2Min = $this->timeToMinutes($otherEnd); // Overlap if: start1 < end2 AND start2 < end1 // Note: Using < (not <=) so events that just touch at boundaries don't conflict // e.g., 1:00-2:00 and 2:00-3:00 are NOT in conflict if ($start1Min < $end2Min && $start2Min < $end1Min) { $hasConflict = true; $conflictingWith[] = [ 'title' => isset($otherEvent['title']) ? $otherEvent['title'] : 'Untitled', 'time' => $otherStart, 'end_time' => $otherEnd ]; } } $eventsWithFlags[] = array_merge($event, [ 'conflict' => $hasConflict, 'conflictingWith' => $conflictingWith ]); } return $eventsWithFlags; } /** * Add hours to a time string */ private function addHoursToTime($time, $hours) { $totalMinutes = $this->timeToMinutes($time) + ($hours * 60); $h = floor($totalMinutes / 60) % 24; $m = $totalMinutes % 60; return sprintf('%02d:%02d', $h, $m); } /** * Render DokuWiki syntax to HTML * Converts **bold**, //italic//, [[links]], etc. to HTML */ private function renderDokuWikiToHtml($text) { if (empty($text)) return ''; // Use DokuWiki's parser to render the text $instructions = p_get_instructions($text); // Render instructions to XHTML $xhtml = p_render('xhtml', $instructions, $info); // Remove surrounding

tags if present (we're rendering inline) $xhtml = preg_replace('/^

(.*)<\/p>$/s', '$1', trim($xhtml)); return $xhtml; } // Keep old scanForNamespaces for backward compatibility (not used anymore) private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..' || $item === 'calendar') continue; $path = $dir . $item; if (is_dir($path)) { $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; $namespaces[] = $namespace; $this->scanForNamespaces($path . '/', $namespace, $namespaces); } } } /** * Get current sidebar theme */ private function getSidebarTheme() { $configFile = DOKU_INC . 'data/meta/calendar_theme.txt'; if (file_exists($configFile)) { $theme = trim(file_get_contents($configFile)); if (in_array($theme, ['matrix', 'purple', 'professional', 'pink', 'wiki'])) { return $theme; } } return 'matrix'; // Default } /** * Get colors from DokuWiki template's style.ini file */ private function getWikiTemplateColors() { global $conf; // Get current template name $template = $conf['template']; // Try multiple possible locations for style.ini $possiblePaths = [ DOKU_INC . 'conf/tpl/' . $template . '/style.ini', DOKU_INC . 'lib/tpl/' . $template . '/style.ini', ]; $styleIni = null; foreach ($possiblePaths as $path) { if (file_exists($path)) { $styleIni = parse_ini_file($path, true); break; } } if (!$styleIni) { return null; // Fall back to CSS variables } // Extract color replacements $replacements = isset($styleIni['replacements']) ? $styleIni['replacements'] : []; // Map style.ini colors to our theme structure $bgSite = isset($replacements['__background_site__']) ? $replacements['__background_site__'] : '#f5f5f5'; $background = isset($replacements['__background__']) ? $replacements['__background__'] : '#fff'; $bgAlt = isset($replacements['__background_alt__']) ? $replacements['__background_alt__'] : '#e8e8e8'; $bgNeu = isset($replacements['__background_neu__']) ? $replacements['__background_neu__'] : '#eee'; $text = isset($replacements['__text__']) ? $replacements['__text__'] : '#333'; $textAlt = isset($replacements['__text_alt__']) ? $replacements['__text_alt__'] : '#999'; $textNeu = isset($replacements['__text_neu__']) ? $replacements['__text_neu__'] : '#666'; $border = isset($replacements['__border__']) ? $replacements['__border__'] : '#ccc'; $link = isset($replacements['__link__']) ? $replacements['__link__'] : '#2b73b7'; $existing = isset($replacements['__existing__']) ? $replacements['__existing__'] : $link; // Build theme colors from template colors // ============================================ // DokuWiki style.ini → Calendar CSS Variable Mapping // ============================================ // style.ini key → CSS variable → Used for // __background_site__ → --background-site → Container, panel backgrounds // __background__ → --cell-bg → Cell/input backgrounds (typically white) // __background_alt__ → --background-alt → Hover states, header backgrounds // → --background-header // __background_neu__ → --cell-today-bg → Today cell highlight // __text__ → --text-primary → Primary text, labels, titles // __text_neu__ → --text-dim → Secondary text, dates, descriptions // __text_alt__ → (not mapped) → Available for future use // __border__ → --border-color → Grid lines, input borders // → --header-border // __link__ → --border-main → Accent color: buttons, badges, active elements // → --text-bright → Links, accent text // __existing__ → (fallback to __link__)→ Available for future use // // To customize: edit your template's conf/style.ini [replacements] return [ 'bg' => $bgSite, 'border' => $link, // Accent color from template links 'shadow' => 'rgba(0, 0, 0, 0.1)', 'header_bg' => $bgAlt, // Headers use alt background 'header_border' => $border, 'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)', 'text_primary' => $text, 'text_bright' => $link, 'text_dim' => $textNeu, 'grid_bg' => $bgSite, 'grid_border' => $border, 'cell_bg' => $background, // Cells use __background__ (white/light) 'cell_today_bg' => $bgNeu, 'bar_glow' => '0 1px 2px', ]; } /** * Get theme-specific color styles */ private function getSidebarThemeStyles($theme) { // For wiki theme, try to read colors from template's style.ini if ($theme === 'wiki') { $wikiColors = $this->getWikiTemplateColors(); if (!empty($wikiColors)) { return $wikiColors; } // Fall through to default wiki colors if reading fails } $themes = [ 'matrix' => [ 'bg' => '#242424', 'border' => '#00cc07', 'shadow' => 'rgba(0, 204, 7, 0.3)', 'header_bg' => 'linear-gradient(180deg, #2a2a2a 0%, #242424 100%)', 'header_border' => '#00cc07', 'header_shadow' => '0 2px 8px rgba(0, 204, 7, 0.3)', 'text_primary' => '#00cc07', 'text_bright' => '#00ff00', 'text_dim' => '#00aa00', 'grid_bg' => '#1a3d1a', 'grid_border' => '#00cc07', 'cell_bg' => '#242424', 'cell_today_bg' => '#2a4d2a', 'bar_glow' => '0 0 3px', ], 'purple' => [ 'bg' => '#2a2030', 'border' => '#9b59b6', 'shadow' => 'rgba(155, 89, 182, 0.3)', 'header_bg' => 'linear-gradient(180deg, #2f2438 0%, #2a2030 100%)', 'header_border' => '#9b59b6', 'header_shadow' => '0 2px 8px rgba(155, 89, 182, 0.3)', 'text_primary' => '#b19cd9', 'text_bright' => '#d4a5ff', 'text_dim' => '#8e7ab8', 'grid_bg' => '#3d2b4d', 'grid_border' => '#9b59b6', 'cell_bg' => '#2a2030', 'cell_today_bg' => '#3d2b4d', 'bar_glow' => '0 0 3px', ], 'professional' => [ 'bg' => '#f5f7fa', 'border' => '#4a90e2', 'shadow' => 'rgba(74, 144, 226, 0.2)', 'header_bg' => 'linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%)', 'header_border' => '#4a90e2', 'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)', 'text_primary' => '#2c3e50', 'text_bright' => '#4a90e2', 'text_dim' => '#7f8c8d', 'grid_bg' => '#e8ecf1', 'grid_border' => '#d0d7de', 'cell_bg' => '#ffffff', 'cell_today_bg' => '#dce8f7', 'bar_glow' => '0 1px 2px', ], 'pink' => [ 'bg' => '#1a0d14', 'border' => '#ff1493', 'shadow' => 'rgba(255, 20, 147, 0.4)', 'header_bg' => 'linear-gradient(180deg, #2d1a24 0%, #1a0d14 100%)', 'header_border' => '#ff1493', 'header_shadow' => '0 0 12px rgba(255, 20, 147, 0.6)', 'text_primary' => '#ff69b4', 'text_bright' => '#ff1493', 'text_dim' => '#ff85c1', 'grid_bg' => '#2d1a24', 'grid_border' => '#ff1493', 'cell_bg' => '#1a0d14', 'cell_today_bg' => '#3d2030', 'bar_glow' => '0 0 5px', ], 'wiki' => [ 'bg' => '#f5f5f5', 'border' => '#2b73b7', // Use link blue as accent (matches template) 'shadow' => 'rgba(0, 0, 0, 0.1)', 'header_bg' => '#e8e8e8', 'header_border' => '#ccc', 'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)', 'text_primary' => '#333', 'text_bright' => '#2b73b7', 'text_dim' => '#666', 'grid_bg' => '#f5f5f5', 'grid_border' => '#ccc', 'cell_bg' => '#fff', 'cell_today_bg' => '#eee', 'bar_glow' => '0 1px 2px', ], ]; return isset($themes[$theme]) ? $themes[$theme] : $themes['matrix']; } /** * Get week start day preference */ private function getWeekStartDay() { $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt'; if (file_exists($configFile)) { $start = trim(file_get_contents($configFile)); if (in_array($start, ['monday', 'sunday'])) { return $start; } } return 'sunday'; // Default to Sunday (US/Canada standard) } }