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' => ''
);
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);
}
}
}
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'];
$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 .= '';
// Calendar grid
$html .= '
';
$html .= '';
$html .= 'S M T W T F S ';
$html .= ' ';
$firstDay = mktime(0, 0, 0, $month, 1, $year);
$daysInMonth = date('t', $firstDay);
$dayOfWeek = date('w', $firstDay);
$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($events[$dateKey]) && !empty($events[$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 = $events[$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';
$barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed';
$html .= '';
$html .= ' ';
}
$html .= '
';
}
$html .= ' ';
$currentDay++;
}
}
$html .= ' ';
}
$html .= '
';
$html .= '
'; // End calendar-left
// Right side: Event list
$html .= '
';
$html .= '';
$html .= '
';
$html .= $this->renderEventListContent($events, $calId, $namespace);
$html .= '
';
$html .= '
'; // End calendar-right
// Event dialog
$html .= $this->renderEventDialog($calId, $namespace);
$html .= '
'; // End container
return $html;
}
private function renderEventListContent($events, $calId, $namespace) {
if (empty($events)) {
return 'No events this month
';
}
$html = '';
ksort($events);
foreach ($events as $dateKey => $dayEvents) {
foreach ($dayEvents as $event) {
$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
$dateObj = new DateTime($dateKey);
$displayDate = $dateObj->format('M j');
// Multi-day indicator
$multiDay = '';
if ($endDate && $endDate !== $dateKey) {
$endObj = new DateTime($endDate);
$multiDay = ' → ' . $endObj->format('M j');
}
$completedClass = $completed ? ' event-completed' : '';
$html .= '';
$html .= '
';
$html .= '
';
$html .= '' . $title . ' ';
$html .= '
';
$html .= '
';
$html .= '' . $displayDate . $multiDay;
if ($displayTime) {
$html .= ' • ' . $displayTime;
}
$html .= ' ';
$html .= '
';
if ($description) {
$html .= '
' . $renderedDescription . '
';
}
$html .= '
'; // event-info
$html .= '
';
$html .= '🗑️ ';
$html .= '✏️ ';
$html .= '
';
// Checkbox for tasks - ON THE FAR RIGHT
if ($isTask) {
$checked = $completed ? 'checked' : '';
$html .= '
';
}
$html .= '
';
}
}
return $html;
}
private function renderEventPanelOnly($data) {
$year = (int)$data['year'];
$month = (int)$data['month'];
$namespace = $data['namespace'];
$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 .= '
' . $monthName . ' Events ';
$html .= '› ';
$html .= '';
$html .= '
';
$html .= '+ Add Event ';
$html .= '
';
$html .= '
';
$html .= $this->renderEventListContent($events, $calId, $namespace);
$html .= '
';
$html .= $this->renderEventDialog($calId, $namespace);
$html .= '
';
return $html;
}
private function renderStandaloneEventList($data) {
$namespace = $data['namespace'];
$daterange = $data['daterange'];
$date = $data['date'];
if ($daterange) {
list($startDate, $endDate) = explode(':', $daterange);
} elseif ($date) {
$startDate = $date;
$endDate = $date;
} else {
$startDate = date('Y-m-01');
$endDate = date('Y-m-t');
}
$allEvents = array();
$start = new DateTime($startDate);
$end = new DateTime($endDate);
$end->modify('+1 day');
$interval = new DateInterval('P1D');
$period = new DatePeriod($start, $interval, $end);
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;
if (!isset($loadedMonths[$monthKey])) {
$loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month);
}
$monthEvents = $loadedMonths[$monthKey];
if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) {
$allEvents[$dateKey] = $monthEvents[$dateKey];
}
}
$html = '';
$html .= '
Events: ' . date('M j', strtotime($startDate)) . ' - ' . date('M j, Y', strtotime($endDate)) . ' ';
if (empty($allEvents)) {
$html .= '
No events in this date range
';
} else {
foreach ($allEvents as $dateKey => $dayEvents) {
$displayDate = date('l, F j, Y', strtotime($dateKey));
$html .= '
';
$html .= '
' . $displayDate . ' ';
foreach ($dayEvents as $event) {
$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']) ? htmlspecialchars($event['description']) : '';
$html .= '
';
$html .= '
';
$html .= '
';
if ($time) {
$html .= '
' . $time . ' ';
}
$html .= '
' . $title . ' ';
if ($description) {
$html .= '
' . nl2br($description) . '
';
}
$html .= '
';
}
$html .= '
';
}
}
$html .= '
';
return $html;
}
private function renderEventDialog($calId, $namespace) {
$html = '';
$html .= '
';
// Draggable dialog
$html .= '
';
// Header with drag handle and close button
$html .= '
';
$html .= '
Add Event ';
$html .= '× ';
$html .= '';
// Form content
$html .= '
';
$html .= '
';
$html .= '
';
return $html;
}
private function renderDescription($description) {
if (empty($description)) {
return '';
}
// Convert newlines to for basic formatting
$rendered = nl2br($description);
// Convert DokuWiki image syntax {{image.jpg}} to HTML
$rendered = preg_replace_callback(
'/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/',
function($matches) {
$imagePath = trim($matches[1]);
$alt = isset($matches[2]) ? trim($matches[2]) : '';
// Handle external URLs (http:// or https://)
if (preg_match('/^https?:\/\//', $imagePath)) {
return ' ';
}
// Handle internal DokuWiki images
$imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath);
return ' ';
},
$rendered
);
// Convert DokuWiki link syntax [[link|text]] to HTML
$rendered = preg_replace_callback(
'/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/',
function($matches) {
$link = trim($matches[1]);
$text = isset($matches[2]) ? trim($matches[2]) : $link;
// Handle external URLs
if (preg_match('/^https?:\/\//', $link)) {
return '' . htmlspecialchars($text) . ' ';
}
// Handle internal DokuWiki links
$wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($link);
return '' . htmlspecialchars($text) . ' ';
},
$rendered
);
// Convert markdown-style links [text](url) to HTML
$rendered = preg_replace_callback(
'/\[([^\]]+)\]\(([^)]+)\)/',
function($matches) {
$text = trim($matches[1]);
$url = trim($matches[2]);
if (preg_match('/^https?:\/\//', $url)) {
return '' . htmlspecialchars($text) . ' ';
}
return '' . htmlspecialchars($text) . ' ';
},
$rendered
);
// Convert plain URLs to clickable links
$rendered = preg_replace_callback(
'/(https?:\/\/[^\s<]+)/',
function($matches) {
$url = $matches[1];
return '' . htmlspecialchars($url) . ' ';
},
$rendered
);
// Allow basic HTML tags (bold, italic, strong, em, u, code)
// Already in the description, just pass through
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();
}
}