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 = '
'; // 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
'; $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 .= '
'; $html .= ''; $html .= '
'; $html .= '
'; $html .= $this->renderEventListContent($events, $calId, $namespace); $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); $html .= '
'; // End container return $html; } private function renderEventListContent($events, $calId, $namespace) { if (empty($events)) { return '

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 .= '
'; $html .= '
'; $html .= '
'; $html .= '' . $title . ''; $html .= '
'; // For past events, hide meta and description (collapsed) if (!$isPast) { $html .= '
'; $html .= '' . $displayDate . $multiDay; if ($displayTime) { $html .= ' • ' . $displayTime; } // Add TODAY badge for today's events if ($isToday) { $html .= ' TODAY'; } // Add namespace badge (for multi-namespace or stored namespace) $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; if (!$eventNamespace && isset($event['_namespace'])) { $eventNamespace = $event['_namespace']; // Fallback to _namespace for backward compatibility } if ($eventNamespace) { $html .= ' ' . htmlspecialchars($eventNamespace) . ''; } $html .= ''; $html .= '
'; if ($description) { $html .= '
' . $renderedDescription . '
'; } } $html .= '
'; // event-info // Use stored namespace from event, fallback to passed namespace $buttonNamespace = isset($event['namespace']) ? $event['namespace'] : $namespace; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; // Checkbox for tasks - ON THE FAR RIGHT if ($isTask) { $checked = $completed ? 'checked' : ''; $html .= ''; } $html .= '
'; // Add to HTML output } } return $html; } 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 } // 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++; } $html = '
'; // Header with navigation $html .= '
'; $html .= ''; $html .= '
'; $html .= '

' . $monthName . ' Events

'; if ($namespace) { // Show multiple namespace badges if multi-namespace if ($isMultiNamespace) { // Handle wildcard if (strpos($namespace, '*') !== false) { $html .= '' . htmlspecialchars($namespace) . ' '; } else { // Semicolon-separated list $namespaceList = array_map('trim', explode(';', $namespace)); foreach ($namespaceList as $ns) { $ns = trim($ns); if (empty($ns)) continue; $namespaceUrl = DOKU_BASE . 'doku.php?id=' . str_replace(':', ':', $ns); $html .= '' . htmlspecialchars($ns) . ' '; } } } else { $namespaceUrl = DOKU_BASE . 'doku.php?id=' . str_replace(':', ':', $namespace); $html .= '' . htmlspecialchars($namespace) . ''; } } $html .= '
'; $html .= ''; $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); $html .= '
'; return $html; } private function renderStandaloneEventList($data) { $namespace = $data['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; // Handle "range" parameter - day, week, or month if ($range === 'day') { $startDate = date('Y-m-d'); $endDate = date('Y-m-d'); $headerText = 'Today'; } elseif ($range === 'week') { $startDate = date('Y-m-d'); // Today $endDateTime = new DateTime($startDate); $endDateTime->modify('+7 days'); $endDate = $endDateTime->format('Y-m-d'); $headerText = 'This Week'; } elseif ($range === 'month') { $startDate = date('Y-m-01'); // First of current month $endDate = date('Y-m-t'); // Last of current month $dt = new DateTime($startDate); $headerText = $dt->format('F Y'); } elseif ($sidebar) { // Handle "sidebar" parameter - shows today through one month from today $startDate = date('Y-m-d'); // Today $endDateTime = new DateTime($startDate); $endDateTime->modify('+1 month'); $endDate = $endDateTime->format('Y-m-d'); // One month from today $headerText = 'Upcoming'; } 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]; } } // Simple 2-line display widget $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 $today = 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 // Enable highlighting for sidebar mode AND range modes (day, week, month) $enableHighlighting = $sidebar || !empty($range); $isToday = $enableHighlighting && ($dateKey === $today); $isTomorrow = $enableHighlighting && ($dateKey === $tomorrow); foreach ($dayEvents as $event) { // Skip completed tasks when in sidebar mode or day/week range $skipCompleted = $sidebar || ($range === 'day') || ($range === 'week'); if ($skipCompleted && !empty($event['isTask']) && !empty($event['completed'])) { continue; } // Line 1: Header (Title, Time, Date, Namespace) $todayClass = $isToday ? ' eventlist-simple-today' : ''; $tomorrowClass = $isTomorrow ? ' eventlist-simple-tomorrow' : ''; $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 . ''; // TODAY badge (show for today's events in sidebar) if ($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) { $html = ''; return $html; } private function renderMonthPicker($calId, $year, $month, $namespace) { $html = ''; return $html; } private function renderDescription($description) { if (empty($description)) { return ''; } // 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__ $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); } } } }