xref: /plugin/calendar/syntax.php (revision e3a9f44ce79ec1754946340aa2b4e60f3e5583ec)
1<?php
2/**
3 * DokuWiki Plugin calendar (Syntax Component)
4 * Compact design with integrated event list
5 *
6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
7 * @author  DokuWiki Community
8 */
9
10if (!defined('DOKU_INC')) die();
11
12class syntax_plugin_calendar extends DokuWiki_Syntax_Plugin {
13
14    public function getType() {
15        return 'substition';
16    }
17
18    public function getPType() {
19        return 'block';
20    }
21
22    public function getSort() {
23        return 155;
24    }
25
26    public function connectTo($mode) {
27        $this->Lexer->addSpecialPattern('\{\{calendar(?:[^\}]*)\}\}', $mode, 'plugin_calendar');
28        $this->Lexer->addSpecialPattern('\{\{eventlist(?:[^\}]*)\}\}', $mode, 'plugin_calendar');
29        $this->Lexer->addSpecialPattern('\{\{eventpanel(?:[^\}]*)\}\}', $mode, 'plugin_calendar');
30    }
31
32    public function handle($match, $state, $pos, Doku_Handler $handler) {
33        $isEventList = (strpos($match, '{{eventlist') === 0);
34        $isEventPanel = (strpos($match, '{{eventpanel') === 0);
35
36        if ($isEventList) {
37            $match = substr($match, 12, -2);
38        } elseif ($isEventPanel) {
39            $match = substr($match, 13, -2);
40        } else {
41            $match = substr($match, 10, -2);
42        }
43
44        $params = array(
45            'type' => $isEventPanel ? 'eventpanel' : ($isEventList ? 'eventlist' : 'calendar'),
46            'year' => date('Y'),
47            'month' => date('n'),
48            'namespace' => '',
49            'daterange' => '',
50            'date' => '',
51            'range' => ''
52        );
53
54        if (trim($match)) {
55            $pairs = preg_split('/\s+/', trim($match));
56            foreach ($pairs as $pair) {
57                if (strpos($pair, '=') !== false) {
58                    list($key, $value) = explode('=', $pair, 2);
59                    $params[trim($key)] = trim($value);
60                } else {
61                    // Handle standalone flags like "today"
62                    $params[trim($pair)] = true;
63                }
64            }
65        }
66
67        return $params;
68    }
69
70    public function render($mode, Doku_Renderer $renderer, $data) {
71        if ($mode !== 'xhtml') return false;
72
73        if ($data['type'] === 'eventlist') {
74            $html = $this->renderStandaloneEventList($data);
75        } elseif ($data['type'] === 'eventpanel') {
76            $html = $this->renderEventPanelOnly($data);
77        } else {
78            $html = $this->renderCompactCalendar($data);
79        }
80
81        $renderer->doc .= $html;
82        return true;
83    }
84
85    private function renderCompactCalendar($data) {
86        $year = (int)$data['year'];
87        $month = (int)$data['month'];
88        $namespace = $data['namespace'];
89
90        // Check if multiple namespaces or wildcard specified
91        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
92
93        if ($isMultiNamespace) {
94            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
95        } else {
96            $events = $this->loadEvents($namespace, $year, $month);
97        }
98        $calId = 'cal_' . md5(serialize($data) . microtime());
99
100        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
101
102        $prevMonth = $month - 1;
103        $prevYear = $year;
104        if ($prevMonth < 1) {
105            $prevMonth = 12;
106            $prevYear--;
107        }
108
109        $nextMonth = $month + 1;
110        $nextYear = $year;
111        if ($nextMonth > 12) {
112            $nextMonth = 1;
113            $nextYear++;
114        }
115
116        $html = '<div class="calendar-compact-container" id="' . $calId . '" data-namespace="' . htmlspecialchars($namespace) . '" data-year="' . $year . '" data-month="' . $month . '">';
117
118        // Embed events data as JSON for JavaScript access
119        $html .= '<script type="application/json" id="events-data-' . $calId . '">' . json_encode($events) . '</script>';
120
121        // Left side: Calendar
122        $html .= '<div class="calendar-compact-left">';
123
124        // Header with navigation
125        $html .= '<div class="calendar-compact-header">';
126        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
127        $html .= '<h3 class="calendar-month-picker" onclick="openMonthPicker(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . '</h3>';
128        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
129        $html .= '<button class="cal-today-btn" onclick="jumpToToday(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
130        $html .= '</div>';
131
132        // Calendar grid
133        $html .= '<table class="calendar-compact-grid">';
134        $html .= '<thead><tr>';
135        $html .= '<th>S</th><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th>';
136        $html .= '</tr></thead><tbody>';
137
138        $firstDay = mktime(0, 0, 0, $month, 1, $year);
139        $daysInMonth = date('t', $firstDay);
140        $dayOfWeek = date('w', $firstDay);
141
142        // Build a map of all events with their date ranges for the calendar grid
143        $eventRanges = array();
144        foreach ($events as $dateKey => $dayEvents) {
145            foreach ($dayEvents as $evt) {
146                $eventId = isset($evt['id']) ? $evt['id'] : '';
147                $startDate = $dateKey;
148                $endDate = isset($evt['endDate']) && $evt['endDate'] ? $evt['endDate'] : $dateKey;
149
150                // Only process events that touch this month
151                $eventStart = new DateTime($startDate);
152                $eventEnd = new DateTime($endDate);
153                $monthStart = new DateTime(sprintf('%04d-%02d-01', $year, $month));
154                $monthEnd = new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth));
155
156                // Skip if event doesn't overlap with current month
157                if ($eventEnd < $monthStart || $eventStart > $monthEnd) {
158                    continue;
159                }
160
161                // Create entry for each day the event spans
162                $current = clone $eventStart;
163                while ($current <= $eventEnd) {
164                    $currentKey = $current->format('Y-m-d');
165
166                    // Check if this date is in current month
167                    $currentDate = DateTime::createFromFormat('Y-m-d', $currentKey);
168                    if ($currentDate && $currentDate->format('Y-m') === sprintf('%04d-%02d', $year, $month)) {
169                        if (!isset($eventRanges[$currentKey])) {
170                            $eventRanges[$currentKey] = array();
171                        }
172
173                        // Add event with span information
174                        $evt['_span_start'] = $startDate;
175                        $evt['_span_end'] = $endDate;
176                        $evt['_is_first_day'] = ($currentKey === $startDate);
177                        $evt['_is_last_day'] = ($currentKey === $endDate);
178                        $evt['_original_date'] = $dateKey; // Keep track of original date
179
180                        // Check if event continues from previous month or to next month
181                        $evt['_continues_from_prev'] = ($eventStart < $monthStart);
182                        $evt['_continues_to_next'] = ($eventEnd > $monthEnd);
183
184                        $eventRanges[$currentKey][] = $evt;
185                    }
186
187                    $current->modify('+1 day');
188                }
189            }
190        }
191
192        $currentDay = 1;
193        $rowCount = ceil(($daysInMonth + $dayOfWeek) / 7);
194
195        for ($row = 0; $row < $rowCount; $row++) {
196            $html .= '<tr>';
197            for ($col = 0; $col < 7; $col++) {
198                if (($row === 0 && $col < $dayOfWeek) || $currentDay > $daysInMonth) {
199                    $html .= '<td class="cal-empty"></td>';
200                } else {
201                    $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $currentDay);
202                    $isToday = ($dateKey === date('Y-m-d'));
203                    $hasEvents = isset($eventRanges[$dateKey]) && !empty($eventRanges[$dateKey]);
204
205                    $classes = 'cal-day';
206                    if ($isToday) $classes .= ' cal-today';
207                    if ($hasEvents) $classes .= ' cal-has-events';
208
209                    $html .= '<td class="' . $classes . '" data-date="' . $dateKey . '" onclick="showDayPopup(\'' . $calId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">';
210                    $html .= '<span class="day-num">' . $currentDay . '</span>';
211
212                    if ($hasEvents) {
213                        // Sort events by time (no time first, then by time)
214                        $sortedEvents = $eventRanges[$dateKey];
215                        usort($sortedEvents, function($a, $b) {
216                            $timeA = isset($a['time']) ? $a['time'] : '';
217                            $timeB = isset($b['time']) ? $b['time'] : '';
218
219                            // Events without time go first
220                            if (empty($timeA) && !empty($timeB)) return -1;
221                            if (!empty($timeA) && empty($timeB)) return 1;
222                            if (empty($timeA) && empty($timeB)) return 0;
223
224                            // Sort by time
225                            return strcmp($timeA, $timeB);
226                        });
227
228                        // Show colored stacked bars for each event
229                        $html .= '<div class="event-indicators">';
230                        foreach ($sortedEvents as $evt) {
231                            $eventId = isset($evt['id']) ? $evt['id'] : '';
232                            $eventColor = isset($evt['color']) ? htmlspecialchars($evt['color']) : '#3498db';
233                            $eventTime = isset($evt['time']) ? $evt['time'] : '';
234                            $eventTitle = isset($evt['title']) ? htmlspecialchars($evt['title']) : 'Event';
235                            $originalDate = isset($evt['_original_date']) ? $evt['_original_date'] : $dateKey;
236                            $isFirstDay = isset($evt['_is_first_day']) ? $evt['_is_first_day'] : true;
237                            $isLastDay = isset($evt['_is_last_day']) ? $evt['_is_last_day'] : true;
238
239                            $barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed';
240
241                            // Add classes for multi-day spanning
242                            if (!$isFirstDay) $barClass .= ' event-bar-continues';
243                            if (!$isLastDay) $barClass .= ' event-bar-continuing';
244
245                            $html .= '<span class="event-bar ' . $barClass . '" ';
246                            $html .= 'style="background: ' . $eventColor . ';" ';
247                            $html .= 'title="' . $eventTitle . ($eventTime ? ' @ ' . $eventTime : '') . '" ';
248                            $html .= 'onclick="event.stopPropagation(); highlightEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $originalDate . '\');">';
249                            $html .= '</span>';
250                        }
251                        $html .= '</div>';
252                    }
253
254                    $html .= '</td>';
255                    $currentDay++;
256                }
257            }
258            $html .= '</tr>';
259        }
260
261        $html .= '</tbody></table>';
262        $html .= '</div>'; // End calendar-left
263
264        // Right side: Event list
265        $html .= '<div class="calendar-compact-right">';
266        $html .= '<div class="event-list-header">';
267        $html .= '<div class="event-list-header-content">';
268        $html .= '<h4 id="eventlist-title-' . $calId . '">Events</h4>';
269        if ($namespace) {
270            $html .= '<span class="namespace-badge">' . htmlspecialchars($namespace) . '</span>';
271        }
272        $html .= '</div>';
273        $html .= '<button class="add-event-compact" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>';
274        $html .= '</div>';
275
276        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '">';
277        $html .= $this->renderEventListContent($events, $calId, $namespace);
278        $html .= '</div>';
279
280        $html .= '</div>'; // End calendar-right
281
282        // Event dialog
283        $html .= $this->renderEventDialog($calId, $namespace);
284
285        // Month/Year picker dialog (at container level for proper overlay)
286        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace);
287
288        $html .= '</div>'; // End container
289
290        return $html;
291    }
292
293    private function renderEventListContent($events, $calId, $namespace) {
294        if (empty($events)) {
295            return '<p class="no-events-msg">No events this month</p>';
296        }
297
298        // Sort by date ascending (chronological order - oldest first)
299        ksort($events);
300
301        // Sort events within each day by time
302        foreach ($events as $dateKey => &$dayEvents) {
303            usort($dayEvents, function($a, $b) {
304                $timeA = isset($a['time']) ? $a['time'] : '00:00';
305                $timeB = isset($b['time']) ? $b['time'] : '00:00';
306                return strcmp($timeA, $timeB);
307            });
308        }
309        unset($dayEvents); // Break reference
310
311        // Get today's date for comparison
312        $today = date('Y-m-d');
313        $firstFutureEventId = null;
314
315        // Build HTML for each event
316        $html = '';
317
318        foreach ($events as $dateKey => $dayEvents) {
319            $isPast = $dateKey < $today;
320            $isToday = $dateKey === $today;
321
322            foreach ($dayEvents as $event) {
323                // Track first future/today event for auto-scroll
324                if (!$firstFutureEventId && $dateKey >= $today) {
325                    $firstFutureEventId = isset($event['id']) ? $event['id'] : '';
326                }
327                $eventId = isset($event['id']) ? $event['id'] : '';
328                $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled';
329                $time = isset($event['time']) ? htmlspecialchars($event['time']) : '';
330                $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db';
331                $description = isset($event['description']) ? $event['description'] : '';
332                $isTask = isset($event['isTask']) ? $event['isTask'] : false;
333                $completed = isset($event['completed']) ? $event['completed'] : false;
334                $endDate = isset($event['endDate']) ? $event['endDate'] : '';
335
336                // Process description for wiki syntax, HTML, images, and links
337                $renderedDescription = $this->renderDescription($description);
338
339                // Convert to 12-hour format
340                $displayTime = '';
341                if ($time) {
342                    $timeObj = DateTime::createFromFormat('H:i', $time);
343                    if ($timeObj) {
344                        $displayTime = $timeObj->format('g:i A');
345                    } else {
346                        $displayTime = $time;
347                    }
348                }
349
350                // Format date display with day of week
351                // Use originalStartDate if this is a multi-month event continuation
352                $displayDateKey = isset($event['originalStartDate']) ? $event['originalStartDate'] : $dateKey;
353                $dateObj = new DateTime($displayDateKey);
354                $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Jan 24"
355
356                // Multi-day indicator
357                $multiDay = '';
358                if ($endDate && $endDate !== $displayDateKey) {
359                    $endObj = new DateTime($endDate);
360                    $multiDay = ' → ' . $endObj->format('D, M j');
361                }
362
363                $completedClass = $completed ? ' event-completed' : '';
364                $pastClass = $isPast ? ' event-past' : '';
365                $firstFutureAttr = ($firstFutureEventId === $eventId) ? ' data-first-future="true"' : '';
366
367                $html .= '<div class="event-compact-item' . $completedClass . $pastClass . '" data-event-id="' . $eventId . '" data-date="' . $dateKey . '" style="border-left-color: ' . $color . ';"' . $firstFutureAttr . '>';
368
369                $html .= '<div class="event-info">';
370                $html .= '<div class="event-title-row">';
371                $html .= '<span class="event-title-compact">' . $title . '</span>';
372                $html .= '</div>';
373
374                // For past events, hide meta and description (collapsed)
375                if (!$isPast) {
376                    $html .= '<div class="event-meta-compact">';
377                    $html .= '<span class="event-date-time">' . $displayDate . $multiDay;
378                    if ($displayTime) {
379                        $html .= ' • ' . $displayTime;
380                    }
381                    // Add TODAY badge for today's events
382                    if ($isToday) {
383                        $html .= ' <span class="event-today-badge">TODAY</span>';
384                    }
385                    // Add namespace badge (for multi-namespace or stored namespace)
386                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
387                    if (!$eventNamespace && isset($event['_namespace'])) {
388                        $eventNamespace = $event['_namespace']; // Fallback to _namespace for backward compatibility
389                    }
390                    if ($eventNamespace) {
391                        $html .= ' <span class="event-namespace-badge">' . htmlspecialchars($eventNamespace) . '</span>';
392                    }
393                    $html .= '</span>';
394                    $html .= '</div>';
395
396                    if ($description) {
397                        $html .= '<div class="event-desc-compact">' . $renderedDescription . '</div>';
398                    }
399                }
400
401                $html .= '</div>'; // event-info
402
403                // Use stored namespace from event, fallback to passed namespace
404                $buttonNamespace = isset($event['namespace']) ? $event['namespace'] : $namespace;
405
406                $html .= '<div class="event-actions-compact">';
407                $html .= '<button class="event-action-btn" onclick="deleteEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">��️</button>';
408                $html .= '<button class="event-action-btn" onclick="editEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">✏️</button>';
409                $html .= '</div>';
410
411                // Checkbox for tasks - ON THE FAR RIGHT
412                if ($isTask) {
413                    $checked = $completed ? 'checked' : '';
414                    $html .= '<input type="checkbox" class="task-checkbox" ' . $checked . ' onclick="toggleTaskComplete(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\', this.checked)">';
415                }
416
417                $html .= '</div>';
418
419                // Add to HTML output
420            }
421        }
422
423        return $html;
424    }
425
426    private function renderEventPanelOnly($data) {
427        $year = (int)$data['year'];
428        $month = (int)$data['month'];
429        $namespace = $data['namespace'];
430        $height = isset($data['height']) ? $data['height'] : '400px';
431
432        // Validate height format (must be px, em, rem, vh, or %)
433        if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) {
434            $height = '400px'; // Default fallback
435        }
436
437        // Check if multiple namespaces or wildcard specified
438        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
439
440        if ($isMultiNamespace) {
441            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
442        } else {
443            $events = $this->loadEvents($namespace, $year, $month);
444        }
445        $calId = 'panel_' . md5(serialize($data) . microtime());
446
447        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
448
449        $prevMonth = $month - 1;
450        $prevYear = $year;
451        if ($prevMonth < 1) {
452            $prevMonth = 12;
453            $prevYear--;
454        }
455
456        $nextMonth = $month + 1;
457        $nextYear = $year;
458        if ($nextMonth > 12) {
459            $nextMonth = 1;
460            $nextYear++;
461        }
462
463        $html = '<div class="event-panel-standalone" id="' . $calId . '" data-height="' . htmlspecialchars($height) . '" data-namespace="' . htmlspecialchars($namespace) . '">';
464
465        // Header with navigation
466        $html .= '<div class="panel-standalone-header">';
467        $html .= '<button class="cal-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
468        $html .= '<div class="panel-header-content">';
469        $html .= '<h3 class="calendar-month-picker" onclick="openMonthPickerPanel(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . ' Events</h3>';
470        if ($namespace) {
471            // Show multiple namespace badges if multi-namespace
472            if ($isMultiNamespace) {
473                // Handle wildcard
474                if (strpos($namespace, '*') !== false) {
475                    $html .= '<span class="namespace-badge">' . htmlspecialchars($namespace) . '</span> ';
476                } else {
477                    // Semicolon-separated list
478                    $namespaceList = array_map('trim', explode(';', $namespace));
479                    foreach ($namespaceList as $ns) {
480                        $ns = trim($ns);
481                        if (empty($ns)) continue;
482                        $namespaceUrl = DOKU_BASE . 'doku.php?id=' . str_replace(':', ':', $ns);
483                        $html .= '<a href="' . $namespaceUrl . '" class="namespace-badge" title="Go to namespace page">' . htmlspecialchars($ns) . '</a> ';
484                    }
485                }
486            } else {
487                $namespaceUrl = DOKU_BASE . 'doku.php?id=' . str_replace(':', ':', $namespace);
488                $html .= '<a href="' . $namespaceUrl . '" class="namespace-badge" title="Go to namespace page">' . htmlspecialchars($namespace) . '</a>';
489            }
490        }
491        $html .= '</div>';
492        $html .= '<button class="cal-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
493        $html .= '<button class="cal-today-btn" onclick="jumpTodayPanel(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
494        $html .= '</div>';
495
496        $html .= '<div class="panel-standalone-actions">';
497        $html .= '<button class="add-event-compact" onclick="openAddEventPanel(\'' . $calId . '\', \'' . $namespace . '\')">+ Add Event</button>';
498        $html .= '</div>';
499
500        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '" style="max-height: ' . htmlspecialchars($height) . ';">';
501        $html .= $this->renderEventListContent($events, $calId, $namespace);
502        $html .= '</div>';
503
504        $html .= $this->renderEventDialog($calId, $namespace);
505
506        // Month/Year picker for event panel
507        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace);
508
509        $html .= '</div>';
510
511        return $html;
512    }
513
514    private function renderStandaloneEventList($data) {
515        $namespace = $data['namespace'];
516        $daterange = $data['daterange'];
517        $date = $data['date'];
518        $range = isset($data['range']) ? strtolower($data['range']) : '';
519        $today = isset($data['today']) ? true : false;
520        $sidebar = isset($data['sidebar']) ? true : false;
521
522        // Handle "range" parameter - day, week, or month
523        if ($range === 'day') {
524            $startDate = date('Y-m-d');
525            $endDate = date('Y-m-d');
526            $headerText = 'Today';
527        } elseif ($range === 'week') {
528            $startDate = date('Y-m-d'); // Today
529            $endDateTime = new DateTime($startDate);
530            $endDateTime->modify('+7 days');
531            $endDate = $endDateTime->format('Y-m-d');
532            $headerText = 'This Week';
533        } elseif ($range === 'month') {
534            $startDate = date('Y-m-01'); // First of current month
535            $endDate = date('Y-m-t'); // Last of current month
536            $dt = new DateTime($startDate);
537            $headerText = $dt->format('F Y');
538        } elseif ($sidebar) {
539            // Handle "sidebar" parameter - shows today through one month from today
540            $startDate = date('Y-m-d'); // Today
541            $endDateTime = new DateTime($startDate);
542            $endDateTime->modify('+1 month');
543            $endDate = $endDateTime->format('Y-m-d'); // One month from today
544            $headerText = 'Upcoming';
545        } elseif ($today) {
546            $startDate = date('Y-m-d');
547            $endDate = date('Y-m-d');
548            $headerText = 'Today';
549        } elseif ($daterange) {
550            list($startDate, $endDate) = explode(':', $daterange);
551            $start = new DateTime($startDate);
552            $end = new DateTime($endDate);
553            $headerText = $start->format('M j') . ' - ' . $end->format('M j, Y');
554        } elseif ($date) {
555            $startDate = $date;
556            $endDate = $date;
557            $dt = new DateTime($date);
558            $headerText = $dt->format('l, F j, Y');
559        } else {
560            $startDate = date('Y-m-01');
561            $endDate = date('Y-m-t');
562            $dt = new DateTime($startDate);
563            $headerText = $dt->format('F Y');
564        }
565
566        // Load all events in date range
567        $allEvents = array();
568        $start = new DateTime($startDate);
569        $end = new DateTime($endDate);
570        $end->modify('+1 day');
571
572        $interval = new DateInterval('P1D');
573        $period = new DatePeriod($start, $interval, $end);
574
575        // Check if multiple namespaces or wildcard specified
576        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
577
578        static $loadedMonths = array();
579
580        foreach ($period as $dt) {
581            $year = (int)$dt->format('Y');
582            $month = (int)$dt->format('n');
583            $dateKey = $dt->format('Y-m-d');
584
585            $monthKey = $year . '-' . $month . '-' . $namespace;
586
587            if (!isset($loadedMonths[$monthKey])) {
588                if ($isMultiNamespace) {
589                    $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month);
590                } else {
591                    $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month);
592                }
593            }
594
595            $monthEvents = $loadedMonths[$monthKey];
596
597            if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) {
598                $allEvents[$dateKey] = $monthEvents[$dateKey];
599            }
600        }
601
602        // Simple 2-line display widget
603        $html = '<div class="eventlist-simple">';
604
605        if (empty($allEvents)) {
606            $html .= '<div class="eventlist-simple-empty">';
607            $html .= '<div class="eventlist-simple-header">' . htmlspecialchars($headerText);
608            if ($namespace) {
609                $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($namespace) . '</span>';
610            }
611            $html .= '</div>';
612            $html .= '<div class="eventlist-simple-body">No events</div>';
613            $html .= '</div>';
614        } else {
615            // Calculate today and tomorrow's dates for highlighting
616            $today = date('Y-m-d');
617            $tomorrow = date('Y-m-d', strtotime('+1 day'));
618
619            foreach ($allEvents as $dateKey => $dayEvents) {
620                $dateObj = new DateTime($dateKey);
621                $displayDate = $dateObj->format('D, M j');
622
623                // Check if this date is today or tomorrow
624                // Enable highlighting for sidebar mode AND range modes (day, week, month)
625                $enableHighlighting = $sidebar || !empty($range);
626                $isToday = $enableHighlighting && ($dateKey === $today);
627                $isTomorrow = $enableHighlighting && ($dateKey === $tomorrow);
628
629                foreach ($dayEvents as $event) {
630                    // Skip completed tasks when in sidebar mode or day/week range
631                    $skipCompleted = $sidebar || ($range === 'day') || ($range === 'week');
632                    if ($skipCompleted && !empty($event['isTask']) && !empty($event['completed'])) {
633                        continue;
634                    }
635
636                    // Line 1: Header (Title, Time, Date, Namespace)
637                    $todayClass = $isToday ? ' eventlist-simple-today' : '';
638                    $tomorrowClass = $isTomorrow ? ' eventlist-simple-tomorrow' : '';
639                    $html .= '<div class="eventlist-simple-item' . $todayClass . $tomorrowClass . '">';
640                    $html .= '<div class="eventlist-simple-header">';
641
642                    // Title
643                    $html .= '<span class="eventlist-simple-title">' . htmlspecialchars($event['title']) . '</span>';
644
645                    // Time (12-hour format)
646                    if (!empty($event['time'])) {
647                        $timeParts = explode(':', $event['time']);
648                        if (count($timeParts) === 2) {
649                            $hour = (int)$timeParts[0];
650                            $minute = $timeParts[1];
651                            $ampm = $hour >= 12 ? 'PM' : 'AM';
652                            $hour = $hour % 12 ?: 12;
653                            $displayTime = $hour . ':' . $minute . ' ' . $ampm;
654                            $html .= ' <span class="eventlist-simple-time">' . $displayTime . '</span>';
655                        }
656                    }
657
658                    // Date
659                    $html .= ' <span class="eventlist-simple-date">' . $displayDate . '</span>';
660
661                    // TODAY badge (show for today's events in sidebar)
662                    if ($isToday) {
663                        $html .= ' <span class="eventlist-simple-today-badge">TODAY</span>';
664                    }
665
666                    // Namespace badge (show individual event's namespace)
667                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
668                    if (!$eventNamespace && isset($event['_namespace'])) {
669                        $eventNamespace = $event['_namespace']; // Fallback to _namespace for multi-namespace loading
670                    }
671                    if ($eventNamespace) {
672                        $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($eventNamespace) . '</span>';
673                    }
674
675                    $html .= '</div>'; // header
676
677                    // Line 2: Body (Description only) - only show if description exists
678                    if (!empty($event['description'])) {
679                        $html .= '<div class="eventlist-simple-body">' . $this->renderDescription($event['description']) . '</div>';
680                    }
681
682                    $html .= '</div>'; // item
683                }
684            }
685        }
686
687        $html .= '</div>'; // eventlist-simple
688
689        return $html;
690    }
691
692    private function renderEventDialog($calId, $namespace) {
693        $html = '<div class="event-dialog-compact" id="dialog-' . $calId . '" style="display:none;">';
694        $html .= '<div class="dialog-overlay" onclick="closeEventDialog(\'' . $calId . '\')"></div>';
695
696        // Draggable dialog
697        $html .= '<div class="dialog-content-sleek" id="dialog-content-' . $calId . '">';
698
699        // Header with drag handle and close button
700        $html .= '<div class="dialog-header-sleek dialog-drag-handle" id="drag-handle-' . $calId . '">';
701        $html .= '<h3 id="dialog-title-' . $calId . '">Add Event</h3>';
702        $html .= '<button type="button" class="dialog-close-btn" onclick="closeEventDialog(\'' . $calId . '\')">×</button>';
703        $html .= '</div>';
704
705        // Form content
706        $html .= '<form id="eventform-' . $calId . '" onsubmit="saveEventCompact(\'' . $calId . '\', \'' . $namespace . '\'); return false;" class="sleek-form">';
707
708        // Hidden ID field
709        $html .= '<input type="hidden" id="event-id-' . $calId . '" name="eventId" value="">';
710
711        // Task checkbox
712        $html .= '<div class="form-field form-field-checkbox">';
713        $html .= '<label class="checkbox-label">';
714        $html .= '<input type="checkbox" id="event-is-task-' . $calId . '" name="isTask" class="task-toggle">';
715        $html .= '<span>�� This is a task (can be checked off)</span>';
716        $html .= '</label>';
717        $html .= '</div>';
718
719        // Date and Time in a row
720        $html .= '<div class="form-row-group">';
721
722        // Start Date field
723        $html .= '<div class="form-field form-field-date">';
724        $html .= '<label class="field-label">�� Start Date</label>';
725        $html .= '<input type="date" id="event-date-' . $calId . '" name="date" required class="input-sleek input-date">';
726        $html .= '</div>';
727
728        // End Date field (for multi-day events)
729        $html .= '<div class="form-field form-field-date">';
730        $html .= '<label class="field-label">�� End Date</label>';
731        $html .= '<input type="date" id="event-end-date-' . $calId . '" name="endDate" class="input-sleek input-date">';
732        $html .= '</div>';
733
734        $html .= '</div>';
735
736        // Recurring event section
737        $html .= '<div class="form-field form-field-checkbox">';
738        $html .= '<label class="checkbox-label">';
739        $html .= '<input type="checkbox" id="event-recurring-' . $calId . '" name="isRecurring" class="recurring-toggle" onchange="toggleRecurringOptions(\'' . $calId . '\')">';
740        $html .= '<span>�� Repeating Event</span>';
741        $html .= '</label>';
742        $html .= '</div>';
743
744        // Recurring options (hidden by default)
745        $html .= '<div id="recurring-options-' . $calId . '" class="recurring-options" style="display:none;">';
746
747        // Recurrence pattern
748        $html .= '<div class="form-field">';
749        $html .= '<label class="field-label">Repeat Every</label>';
750        $html .= '<select id="event-recurrence-type-' . $calId . '" name="recurrenceType" class="input-sleek">';
751        $html .= '<option value="daily">Daily</option>';
752        $html .= '<option value="weekly">Weekly</option>';
753        $html .= '<option value="monthly">Monthly</option>';
754        $html .= '<option value="yearly">Yearly</option>';
755        $html .= '</select>';
756        $html .= '</div>';
757
758        // Recurrence end date
759        $html .= '<div class="form-field">';
760        $html .= '<label class="field-label">�� Repeat Until (optional)</label>';
761        $html .= '<input type="date" id="event-recurrence-end-' . $calId . '" name="recurrenceEnd" class="input-sleek input-date">';
762        $html .= '</div>';
763
764        $html .= '</div>';
765
766        // Time field - dropdown with 15-minute intervals
767        $html .= '<div class="form-field">';
768        $html .= '<label class="field-label">�� Time (optional)</label>';
769        $html .= '<select id="event-time-' . $calId . '" name="time" class="input-sleek">';
770        $html .= '<option value="">No specific time</option>';
771
772        // Generate time options in 15-minute intervals
773        for ($hour = 0; $hour < 24; $hour++) {
774            for ($minute = 0; $minute < 60; $minute += 15) {
775                $timeValue = sprintf('%02d:%02d', $hour, $minute);
776                $displayHour = $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour);
777                $ampm = $hour < 12 ? 'AM' : 'PM';
778                $displayTime = sprintf('%d:%02d %s', $displayHour, $minute, $ampm);
779                $html .= '<option value="' . $timeValue . '">' . $displayTime . '</option>';
780            }
781        }
782
783        $html .= '</select>';
784        $html .= '</div>';
785
786        // Title field
787        $html .= '<div class="form-field">';
788        $html .= '<label class="field-label">�� Title</label>';
789        $html .= '<input type="text" id="event-title-' . $calId . '" name="title" required class="input-sleek" placeholder="Event or task title...">';
790        $html .= '</div>';
791
792        // Description field
793        $html .= '<div class="form-field">';
794        $html .= '<label class="field-label">�� Description</label>';
795        $html .= '<textarea id="event-desc-' . $calId . '" name="description" rows="3" class="input-sleek textarea-sleek" placeholder="Add details (optional)..."></textarea>';
796        $html .= '</div>';
797
798        // Color picker
799        $html .= '<div class="form-field">';
800        $html .= '<label class="field-label">�� Color</label>';
801        $html .= '<div class="color-picker-container">';
802        $html .= '<input type="color" id="event-color-' . $calId . '" name="color" value="#3498db" class="input-color-sleek">';
803        $html .= '<span class="color-label">Choose event color</span>';
804        $html .= '</div>';
805        $html .= '</div>';
806
807        // Action buttons
808        $html .= '<div class="dialog-actions-sleek">';
809        $html .= '<button type="button" class="btn-sleek btn-cancel-sleek" onclick="closeEventDialog(\'' . $calId . '\')">Cancel</button>';
810        $html .= '<button type="submit" class="btn-sleek btn-save-sleek">�� Save</button>';
811        $html .= '</div>';
812
813        $html .= '</form>';
814        $html .= '</div>';
815        $html .= '</div>';
816
817        return $html;
818    }
819
820    private function renderMonthPicker($calId, $year, $month, $namespace) {
821        $html = '<div class="month-picker-overlay" id="month-picker-overlay-' . $calId . '" style="display:none;" onclick="closeMonthPicker(\'' . $calId . '\')">';
822        $html .= '<div class="month-picker-dialog" onclick="event.stopPropagation();">';
823        $html .= '<h4>Jump to Month</h4>';
824
825        $html .= '<div class="month-picker-selects">';
826        $html .= '<select id="month-picker-month-' . $calId . '" class="month-picker-select">';
827        $monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
828        for ($m = 1; $m <= 12; $m++) {
829            $selected = ($m == $month) ? ' selected' : '';
830            $html .= '<option value="' . $m . '"' . $selected . '>' . $monthNames[$m - 1] . '</option>';
831        }
832        $html .= '</select>';
833
834        $html .= '<select id="month-picker-year-' . $calId . '" class="month-picker-select">';
835        $currentYear = (int)date('Y');
836        for ($y = $currentYear - 5; $y <= $currentYear + 5; $y++) {
837            $selected = ($y == $year) ? ' selected' : '';
838            $html .= '<option value="' . $y . '"' . $selected . '>' . $y . '</option>';
839        }
840        $html .= '</select>';
841        $html .= '</div>';
842
843        $html .= '<div class="month-picker-actions">';
844        $html .= '<button class="btn-sleek btn-cancel-sleek" onclick="closeMonthPicker(\'' . $calId . '\')">Cancel</button>';
845        $html .= '<button class="btn-sleek btn-save-sleek" onclick="jumpToSelectedMonth(\'' . $calId . '\', \'' . $namespace . '\')">Go</button>';
846        $html .= '</div>';
847
848        $html .= '</div>';
849        $html .= '</div>';
850
851        return $html;
852    }
853
854    private function renderDescription($description) {
855        if (empty($description)) {
856            return '';
857        }
858
859        // Token-based parsing to avoid escaping issues
860        $rendered = $description;
861        $tokens = array();
862        $tokenIndex = 0;
863
864        // Convert DokuWiki image syntax {{image.jpg}} to tokens
865        $pattern = '/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/';
866        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
867        foreach ($matches as $match) {
868            $imagePath = trim($match[1]);
869            $alt = isset($match[2]) ? trim($match[2]) : '';
870
871            // Handle external URLs
872            if (preg_match('/^https?:\/\//', $imagePath)) {
873                $imageHtml = '<img src="' . htmlspecialchars($imagePath) . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
874            } else {
875                // Handle internal DokuWiki images
876                $imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath);
877                $imageHtml = '<img src="' . $imageUrl . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
878            }
879
880            $token = "\x00TOKEN" . $tokenIndex . "\x00";
881            $tokens[$tokenIndex] = $imageHtml;
882            $tokenIndex++;
883            $rendered = str_replace($match[0], $token, $rendered);
884        }
885
886        // Convert DokuWiki link syntax [[link|text]] to tokens
887        $pattern = '/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/';
888        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
889        foreach ($matches as $match) {
890            $link = trim($match[1]);
891            $text = isset($match[2]) ? trim($match[2]) : $link;
892
893            // Handle external URLs
894            if (preg_match('/^https?:\/\//', $link)) {
895                $linkHtml = '<a href="' . htmlspecialchars($link) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>';
896            } else {
897                // Handle internal DokuWiki links with section anchors
898                $parts = explode('#', $link, 2);
899                $pagePart = $parts[0];
900                $sectionPart = isset($parts[1]) ? '#' . $parts[1] : '';
901
902                $wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($pagePart) . $sectionPart;
903                $linkHtml = '<a href="' . $wikiUrl . '">' . htmlspecialchars($text) . '</a>';
904            }
905
906            $token = "\x00TOKEN" . $tokenIndex . "\x00";
907            $tokens[$tokenIndex] = $linkHtml;
908            $tokenIndex++;
909            $rendered = str_replace($match[0], $token, $rendered);
910        }
911
912        // Convert markdown-style links [text](url) to tokens
913        $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
914        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
915        foreach ($matches as $match) {
916            $text = trim($match[1]);
917            $url = trim($match[2]);
918
919            if (preg_match('/^https?:\/\//', $url)) {
920                $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>';
921            } else {
922                $linkHtml = '<a href="' . htmlspecialchars($url) . '">' . htmlspecialchars($text) . '</a>';
923            }
924
925            $token = "\x00TOKEN" . $tokenIndex . "\x00";
926            $tokens[$tokenIndex] = $linkHtml;
927            $tokenIndex++;
928            $rendered = str_replace($match[0], $token, $rendered);
929        }
930
931        // Convert plain URLs to tokens
932        $pattern = '/(https?:\/\/[^\s<]+)/';
933        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
934        foreach ($matches as $match) {
935            $url = $match[1];
936            $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($url) . '</a>';
937
938            $token = "\x00TOKEN" . $tokenIndex . "\x00";
939            $tokens[$tokenIndex] = $linkHtml;
940            $tokenIndex++;
941            $rendered = str_replace($match[0], $token, $rendered);
942        }
943
944        // NOW escape HTML (tokens are protected)
945        $rendered = htmlspecialchars($rendered);
946
947        // Convert newlines to <br>
948        $rendered = nl2br($rendered);
949
950        // DokuWiki text formatting
951        // Bold: **text** or __text__
952        $rendered = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $rendered);
953        $rendered = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $rendered);
954
955        // Italic: //text//
956        $rendered = preg_replace('/\/\/(.+?)\/\//', '<em>$1</em>', $rendered);
957
958        // Strikethrough: <del>text</del>
959        $rendered = preg_replace('/&lt;del&gt;(.+?)&lt;\/del&gt;/', '<del>$1</del>', $rendered);
960
961        // Monospace: ''text''
962        $rendered = preg_replace('/&#039;&#039;(.+?)&#039;&#039;/', '<code>$1</code>', $rendered);
963
964        // Subscript: <sub>text</sub>
965        $rendered = preg_replace('/&lt;sub&gt;(.+?)&lt;\/sub&gt;/', '<sub>$1</sub>', $rendered);
966
967        // Superscript: <sup>text</sup>
968        $rendered = preg_replace('/&lt;sup&gt;(.+?)&lt;\/sup&gt;/', '<sup>$1</sup>', $rendered);
969
970        // Restore tokens
971        foreach ($tokens as $i => $html) {
972            $rendered = str_replace("\x00TOKEN" . $i . "\x00", $html, $rendered);
973        }
974
975        return $rendered;
976    }
977
978    private function loadEvents($namespace, $year, $month) {
979        $dataDir = DOKU_INC . 'data/meta/';
980        if ($namespace) {
981            $dataDir .= str_replace(':', '/', $namespace) . '/';
982        }
983        $dataDir .= 'calendar/';
984
985        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
986
987        if (file_exists($eventFile)) {
988            $json = file_get_contents($eventFile);
989            return json_decode($json, true);
990        }
991
992        return array();
993    }
994
995    private function loadEventsMultiNamespace($namespaces, $year, $month) {
996        // Check for wildcard pattern (namespace:*)
997        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
998            $baseNamespace = $matches[1];
999            return $this->loadEventsWildcard($baseNamespace, $year, $month);
1000        }
1001
1002        // Check for root wildcard (just *)
1003        if ($namespaces === '*') {
1004            return $this->loadEventsWildcard('', $year, $month);
1005        }
1006
1007        // Parse namespace list (semicolon separated)
1008        // e.g., "team:projects;personal;work:tasks" = three namespaces
1009        $namespaceList = array_map('trim', explode(';', $namespaces));
1010
1011        // Load events from all namespaces
1012        $allEvents = array();
1013        foreach ($namespaceList as $ns) {
1014            $ns = trim($ns);
1015            if (empty($ns)) continue;
1016
1017            $events = $this->loadEvents($ns, $year, $month);
1018
1019            // Add namespace tag to each event
1020            foreach ($events as $dateKey => $dayEvents) {
1021                if (!isset($allEvents[$dateKey])) {
1022                    $allEvents[$dateKey] = array();
1023                }
1024                foreach ($dayEvents as $event) {
1025                    $event['_namespace'] = $ns;
1026                    $allEvents[$dateKey][] = $event;
1027                }
1028            }
1029        }
1030
1031        return $allEvents;
1032    }
1033
1034    private function loadEventsWildcard($baseNamespace, $year, $month) {
1035        // Find all subdirectories under the base namespace
1036        $dataDir = DOKU_INC . 'data/meta/';
1037        if ($baseNamespace) {
1038            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
1039        }
1040
1041        $allEvents = array();
1042
1043        // First, load events from the base namespace itself
1044        if (empty($baseNamespace)) {
1045            // Root wildcard - load from root calendar
1046            $events = $this->loadEvents('', $year, $month);
1047            foreach ($events as $dateKey => $dayEvents) {
1048                if (!isset($allEvents[$dateKey])) {
1049                    $allEvents[$dateKey] = array();
1050                }
1051                foreach ($dayEvents as $event) {
1052                    $event['_namespace'] = '';
1053                    $allEvents[$dateKey][] = $event;
1054                }
1055            }
1056        } else {
1057            $events = $this->loadEvents($baseNamespace, $year, $month);
1058            foreach ($events as $dateKey => $dayEvents) {
1059                if (!isset($allEvents[$dateKey])) {
1060                    $allEvents[$dateKey] = array();
1061                }
1062                foreach ($dayEvents as $event) {
1063                    $event['_namespace'] = $baseNamespace;
1064                    $allEvents[$dateKey][] = $event;
1065                }
1066            }
1067        }
1068
1069        // Recursively find all subdirectories
1070        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
1071
1072        return $allEvents;
1073    }
1074
1075    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
1076        if (!is_dir($dir)) return;
1077
1078        $items = scandir($dir);
1079        foreach ($items as $item) {
1080            if ($item === '.' || $item === '..') continue;
1081
1082            $path = $dir . $item;
1083            if (is_dir($path) && $item !== 'calendar') {
1084                // This is a namespace directory
1085                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1086
1087                // Load events from this namespace
1088                $events = $this->loadEvents($namespace, $year, $month);
1089                foreach ($events as $dateKey => $dayEvents) {
1090                    if (!isset($allEvents[$dateKey])) {
1091                        $allEvents[$dateKey] = array();
1092                    }
1093                    foreach ($dayEvents as $event) {
1094                        $event['_namespace'] = $namespace;
1095                        $allEvents[$dateKey][] = $event;
1096                    }
1097                }
1098
1099                // Recurse into subdirectories
1100                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
1101            }
1102        }
1103    }
1104}
1105