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' => '', 'static' => false, 'title' => '', 'noprint' => false, 'theme' => '', 'locked' => false // Will be set true if month/year specified ); // Track if user explicitly set month or year $userSetMonth = false; $userSetYear = false; if (trim($match)) { // Parse parameters, handling quoted strings properly // Match: key="value with spaces" OR key=value OR standalone_flag preg_match_all('/(\w+)=["\']([^"\']+)["\']|(\w+)=(\S+)|(\w+)/', trim($match), $matches, PREG_SET_ORDER); foreach ($matches as $m) { if (!empty($m[1]) && isset($m[2])) { // key="quoted value" $key = $m[1]; $value = $m[2]; $params[$key] = $value; if ($key === 'month') $userSetMonth = true; if ($key === 'year') $userSetYear = true; } elseif (!empty($m[3]) && isset($m[4])) { // key=unquoted_value $key = $m[3]; $value = $m[4]; $params[$key] = $value; if ($key === 'month') $userSetMonth = true; if ($key === 'year') $userSetYear = true; } elseif (!empty($m[5])) { // standalone flag $params[$m[5]] = true; } } } // If user explicitly set month or year, lock navigation if ($userSetMonth || $userSetYear) { $params['locked'] = true; } return $params; } public function render($mode, Doku_Renderer $renderer, $data) { if ($mode !== 'xhtml') return false; // Disable caching - theme can change via admin without page edit $renderer->nocache(); if ($data['type'] === 'eventlist') { $html = $this->renderStandaloneEventList($data); } elseif ($data['type'] === 'eventpanel') { $html = $this->renderEventPanelOnly($data); } elseif ($data['static']) { $html = $this->renderStaticCalendar($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 - prefer inline theme= parameter, fall back to admin default $theme = !empty($data['theme']) ? $data['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++; } // Get important namespaces from config for highlighting $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'])); } } // Container - all styling via CSS variables $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 .= ' | ';
$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;
// Check if this event is from an important namespace
$evtNs = isset($evt['namespace']) ? $evt['namespace'] : '';
if (!$evtNs && isset($evt['_namespace'])) {
$evtNs = $evt['_namespace'];
}
$isImportantEvent = false;
foreach ($importantNsList as $impNs) {
if ($evtNs === $impNs || strpos($evtNs, $impNs . ':') === 0) {
$isImportantEvent = true;
break;
}
}
$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';
if ($isImportantEvent) {
$barClass .= ' event-bar-important';
if ($isFirstDay) {
$barClass .= ' event-bar-has-star';
}
}
$titlePrefix = $isImportantEvent ? 'ā ' : '';
$html .= '';
}
$html .= ' ';
}
$html .= ' | ';
$currentDay++;
}
}
$html .= '
| ' . $day . ' | '; } $html .= '|
|---|---|
| '; } else { $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $dayCount); $dayEvents = isset($events[$dateKey]) ? $events[$dateKey] : []; $isToday = ($dateKey === date('Y-m-d')); $isWeekend = ($col === 0 || $col === 6); $cellClass = 'static-day'; if ($isToday) $cellClass .= ' static-day-today'; if ($isWeekend) $cellClass .= ' static-day-weekend'; if (!empty($dayEvents)) $cellClass .= ' static-day-has-events'; $html .= ' | ';
$html .= ' ' . $dayCount . ' ';
if (!empty($dayEvents)) {
$html .= '';
foreach ($dayEvents as $event) {
$color = isset($event['color']) ? $event['color'] : '#3498db';
$title = hsc($event['title']);
$time = isset($event['time']) && $event['time'] ? $event['time'] : '';
$desc = isset($event['description']) ? $event['description'] : '';
$eventNs = isset($event['namespace']) ? $event['namespace'] : $namespace;
// Check if important
$isImportant = false;
foreach ($importantNsList as $impNs) {
if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) {
$isImportant = true;
break;
}
}
// Build tooltip - plain text with basic formatting indicators
$tooltipText = $event['title'];
if ($time) {
$tooltipText .= "\nš " . $this->formatTime12Hour($time);
if (isset($event['endTime']) && $event['endTime']) {
$tooltipText .= ' - ' . $this->formatTime12Hour($event['endTime']);
}
}
if ($desc) {
// Convert formatting to plain text equivalents
$plainDesc = $desc;
$plainDesc = preg_replace('/\*\*(.+?)\*\*/', '*$1*', $plainDesc);
$plainDesc = preg_replace('/__(.+?)__/', '*$1*', $plainDesc);
$plainDesc = preg_replace('/\/\/(.+?)\/\//', '_$1_', $plainDesc);
$plainDesc = preg_replace('/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/', '$2 ($1)', $plainDesc);
$plainDesc = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '$1 ($2)', $plainDesc);
$tooltipText .= "\n\n" . $plainDesc;
}
$eventClass = 'static-event';
if ($isImportant) $eventClass .= ' static-event-important';
$html .= ' ';
}
$html .= '';
if ($isImportant) {
$html .= 'ā';
}
if ($time) {
$html .= '' . $this->formatTime12Hour($time) . ' ';
}
$html .= '' . $title . '';
$html .= ' ';
}
$html .= ' | ';
$dayCount++;
}
}
$html .= '
' . $this->getLang('calendar_label') . ': ' . hsc($namespace) . '
'; } // Collect all events sorted by date $allEvents = []; foreach ($events as $dateKey => $dayEvents) { foreach ($dayEvents as $event) { $event['_date'] = $dateKey; $allEvents[] = $event; } } // Sort by date, then time usort($allEvents, function($a, $b) { $dateCompare = strcmp($a['_date'], $b['_date']); if ($dateCompare !== 0) return $dateCompare; $timeA = isset($a['time']) ? $a['time'] : '99:99'; $timeB = isset($b['time']) ? $b['time'] : '99:99'; return strcmp($timeA, $timeB); }); if (empty($allEvents)) { $html .= '' . $this->getLang('no_events_scheduled') . '
'; } else { $html .= '| Date | Time | Event | Details | |
|---|---|---|---|---|
| ' . $dateDisplay . ' | '; $lastDate = $dateKey; } else { $html .= ''; } // Time $time = isset($event['time']) && $event['time'] ? $this->formatTime12Hour($event['time']) : $this->getLang('all_day'); if (isset($event['endTime']) && $event['endTime'] && isset($event['time']) && $event['time']) { $time .= ' - ' . $this->formatTime12Hour($event['endTime']); } $html .= ' | ' . $time . ' | '; // Title with star for important $html .= ''; if ($isImportant) { $html .= 'ā '; } $html .= hsc($event['title']); $html .= ' | '; // Description - with formatting $desc = isset($event['description']) ? $this->renderDescription($event['description']) : ''; $html .= '' . $desc . ' | '; $html .= '
No events this month
'; } // Default theme styles if not provided if ($themeStyles === null) { $theme = $this->getSidebarTheme(); $themeStyles = $this->getSidebarThemeStyles($theme); } else { $theme = $this->getSidebarTheme(); } // 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'])); } } // 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"' : ''; // Check if this is an important namespace event $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; if (!$eventNamespace && isset($event['_namespace'])) { $eventNamespace = $event['_namespace']; } $isImportantNs = false; foreach ($importantNsList as $impNs) { if ($eventNamespace === $impNs || strpos($eventNamespace, $impNs . ':') === 0) { $isImportantNs = true; break; } } $importantClass = $isImportantNs ? ' event-important' : ''; // For all themes: use CSS variables, only keep border-left-color as inline $pastClickHandler = ($isPast && !$isPastDue) ? ' onclick="togglePastEventExpand(this)"' : ''; $eventHtml = '$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, $themeOverride = null) {
if (empty($events)) {
return '