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']; // 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++; } $html = '
| S | M | T | W | T | F | S | '; $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 .= ' | ';
$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 .= ' | ';
$currentDay++;
}
}
$html .= '
No events this month
'; } // 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']) ? $a['time'] : '00:00'; $timeB = isset($b['time']) ? $b['time'] : '00:00'; return strcmp($timeA, $timeB); }); } unset($dayEvents); // Break reference // Get today's date for comparison $today = date('Y-m-d'); $firstFutureEventId = null; // Build HTML for each event $html = ''; foreach ($events as $dateKey => $dayEvents) { $isPast = $dateKey < $today; $isToday = $dateKey === $today; 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'; $time = isset($event['time']) ? htmlspecialchars($event['time']) : ''; $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'] : ''; // Process description for wiki syntax, HTML, images, and links $renderedDescription = $this->renderDescription($description); // Convert to 12-hour format $displayTime = ''; if ($time) { $timeObj = DateTime::createFromFormat('H:i', $time); if ($timeObj) { $displayTime = $timeObj->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' : ''; $pastClass = $isPast ? ' event-past' : ''; $firstFutureAttr = ($firstFutureEventId === $eventId) ? ' data-first-future="true"' : ''; $html .= '$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);
}
}
}
}