xref: /plugin/calendar/syntax.php (revision 87ac9bf3391b3f7059f4ccd6abc619e9db5fad8d)
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        );
52
53        if (trim($match)) {
54            $pairs = preg_split('/\s+/', trim($match));
55            foreach ($pairs as $pair) {
56                if (strpos($pair, '=') !== false) {
57                    list($key, $value) = explode('=', $pair, 2);
58                    $params[trim($key)] = trim($value);
59                } else {
60                    // Handle standalone flags like "today"
61                    $params[trim($pair)] = true;
62                }
63            }
64        }
65
66        return $params;
67    }
68
69    public function render($mode, Doku_Renderer $renderer, $data) {
70        if ($mode !== 'xhtml') return false;
71
72        if ($data['type'] === 'eventlist') {
73            $html = $this->renderStandaloneEventList($data);
74        } elseif ($data['type'] === 'eventpanel') {
75            $html = $this->renderEventPanelOnly($data);
76        } else {
77            $html = $this->renderCompactCalendar($data);
78        }
79
80        $renderer->doc .= $html;
81        return true;
82    }
83
84    private function renderCompactCalendar($data) {
85        $year = (int)$data['year'];
86        $month = (int)$data['month'];
87        $namespace = $data['namespace'];
88
89        $events = $this->loadEvents($namespace, $year, $month);
90        $calId = 'cal_' . md5(serialize($data) . microtime());
91
92        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
93
94        $prevMonth = $month - 1;
95        $prevYear = $year;
96        if ($prevMonth < 1) {
97            $prevMonth = 12;
98            $prevYear--;
99        }
100
101        $nextMonth = $month + 1;
102        $nextYear = $year;
103        if ($nextMonth > 12) {
104            $nextMonth = 1;
105            $nextYear++;
106        }
107
108        $html = '<div class="calendar-compact-container" id="' . $calId . '" data-namespace="' . htmlspecialchars($namespace) . '" data-year="' . $year . '" data-month="' . $month . '">';
109
110        // Embed events data as JSON for JavaScript access
111        $html .= '<script type="application/json" id="events-data-' . $calId . '">' . json_encode($events) . '</script>';
112
113        // Left side: Calendar
114        $html .= '<div class="calendar-compact-left">';
115
116        // Header with navigation
117        $html .= '<div class="calendar-compact-header">';
118        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
119        $html .= '<h3 class="calendar-month-picker" onclick="openMonthPicker(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . '</h3>';
120        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
121        $html .= '<button class="cal-today-btn" onclick="jumpToToday(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
122        $html .= '</div>';
123
124        // Calendar grid
125        $html .= '<table class="calendar-compact-grid">';
126        $html .= '<thead><tr>';
127        $html .= '<th>S</th><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th>';
128        $html .= '</tr></thead><tbody>';
129
130        $firstDay = mktime(0, 0, 0, $month, 1, $year);
131        $daysInMonth = date('t', $firstDay);
132        $dayOfWeek = date('w', $firstDay);
133
134        // Load events from previous and next months to catch spanning events
135        $prevMonth = $month - 1;
136        $prevYear = $year;
137        if ($prevMonth < 1) {
138            $prevMonth = 12;
139            $prevYear--;
140        }
141
142        $nextMonth = $month + 1;
143        $nextYear = $year;
144        if ($nextMonth > 12) {
145            $nextMonth = 1;
146            $nextYear++;
147        }
148
149        $prevMonthEvents = $this->loadEvents($namespace, $prevYear, $prevMonth);
150        $nextMonthEvents = $this->loadEvents($namespace, $nextYear, $nextMonth);
151
152        // Combine all events for processing
153        $allEvents = array_merge($events, $prevMonthEvents, $nextMonthEvents);
154
155        // Build a map of all events with their date ranges
156        $eventRanges = array();
157        foreach ($allEvents as $dateKey => $dayEvents) {
158            foreach ($dayEvents as $evt) {
159                $eventId = isset($evt['id']) ? $evt['id'] : '';
160                $startDate = $dateKey;
161                $endDate = isset($evt['endDate']) && $evt['endDate'] ? $evt['endDate'] : $dateKey;
162
163                // Only process events that touch this month
164                $eventStart = new DateTime($startDate);
165                $eventEnd = new DateTime($endDate);
166                $monthStart = new DateTime(sprintf('%04d-%02d-01', $year, $month));
167                $monthEnd = new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth));
168
169                // Skip if event doesn't overlap with current month
170                if ($eventEnd < $monthStart || $eventStart > $monthEnd) {
171                    continue;
172                }
173
174                // Create entry for each day the event spans
175                $current = clone $eventStart;
176                while ($current <= $eventEnd) {
177                    $currentKey = $current->format('Y-m-d');
178
179                    // Check if this date is in current month
180                    $currentDate = DateTime::createFromFormat('Y-m-d', $currentKey);
181                    if ($currentDate && $currentDate->format('Y-m') === sprintf('%04d-%02d', $year, $month)) {
182                        if (!isset($eventRanges[$currentKey])) {
183                            $eventRanges[$currentKey] = array();
184                        }
185
186                        // Add event with span information
187                        $evt['_span_start'] = $startDate;
188                        $evt['_span_end'] = $endDate;
189                        $evt['_is_first_day'] = ($currentKey === $startDate);
190                        $evt['_is_last_day'] = ($currentKey === $endDate);
191                        $evt['_original_date'] = $dateKey; // Keep track of original date
192
193                        // Check if event continues from previous month or to next month
194                        $evt['_continues_from_prev'] = ($eventStart < $monthStart);
195                        $evt['_continues_to_next'] = ($eventEnd > $monthEnd);
196
197                        $eventRanges[$currentKey][] = $evt;
198                    }
199
200                    $current->modify('+1 day');
201                }
202            }
203        }
204
205        $currentDay = 1;
206        $rowCount = ceil(($daysInMonth + $dayOfWeek) / 7);
207
208        for ($row = 0; $row < $rowCount; $row++) {
209            $html .= '<tr>';
210            for ($col = 0; $col < 7; $col++) {
211                if (($row === 0 && $col < $dayOfWeek) || $currentDay > $daysInMonth) {
212                    $html .= '<td class="cal-empty"></td>';
213                } else {
214                    $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $currentDay);
215                    $isToday = ($dateKey === date('Y-m-d'));
216                    $hasEvents = isset($eventRanges[$dateKey]) && !empty($eventRanges[$dateKey]);
217
218                    $classes = 'cal-day';
219                    if ($isToday) $classes .= ' cal-today';
220                    if ($hasEvents) $classes .= ' cal-has-events';
221
222                    $html .= '<td class="' . $classes . '" data-date="' . $dateKey . '" onclick="showDayPopup(\'' . $calId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">';
223                    $html .= '<span class="day-num">' . $currentDay . '</span>';
224
225                    if ($hasEvents) {
226                        // Sort events by time (no time first, then by time)
227                        $sortedEvents = $eventRanges[$dateKey];
228                        usort($sortedEvents, function($a, $b) {
229                            $timeA = isset($a['time']) ? $a['time'] : '';
230                            $timeB = isset($b['time']) ? $b['time'] : '';
231
232                            // Events without time go first
233                            if (empty($timeA) && !empty($timeB)) return -1;
234                            if (!empty($timeA) && empty($timeB)) return 1;
235                            if (empty($timeA) && empty($timeB)) return 0;
236
237                            // Sort by time
238                            return strcmp($timeA, $timeB);
239                        });
240
241                        // Show colored stacked bars for each event
242                        $html .= '<div class="event-indicators">';
243                        foreach ($sortedEvents as $evt) {
244                            $eventId = isset($evt['id']) ? $evt['id'] : '';
245                            $eventColor = isset($evt['color']) ? htmlspecialchars($evt['color']) : '#3498db';
246                            $eventTime = isset($evt['time']) ? $evt['time'] : '';
247                            $eventTitle = isset($evt['title']) ? htmlspecialchars($evt['title']) : 'Event';
248                            $originalDate = isset($evt['_original_date']) ? $evt['_original_date'] : $dateKey;
249                            $isFirstDay = isset($evt['_is_first_day']) ? $evt['_is_first_day'] : true;
250                            $isLastDay = isset($evt['_is_last_day']) ? $evt['_is_last_day'] : true;
251
252                            $barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed';
253
254                            // Add classes for multi-day spanning
255                            if (!$isFirstDay) $barClass .= ' event-bar-continues';
256                            if (!$isLastDay) $barClass .= ' event-bar-continuing';
257
258                            $html .= '<span class="event-bar ' . $barClass . '" ';
259                            $html .= 'style="background: ' . $eventColor . ';" ';
260                            $html .= 'title="' . $eventTitle . ($eventTime ? ' @ ' . $eventTime : '') . '" ';
261                            $html .= 'onclick="event.stopPropagation(); highlightEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $originalDate . '\');">';
262                            $html .= '</span>';
263                        }
264                        $html .= '</div>';
265                    }
266
267                    $html .= '</td>';
268                    $currentDay++;
269                }
270            }
271            $html .= '</tr>';
272        }
273
274        $html .= '</tbody></table>';
275        $html .= '</div>'; // End calendar-left
276
277        // Right side: Event list
278        $html .= '<div class="calendar-compact-right">';
279        $html .= '<div class="event-list-header">';
280        $html .= '<div class="event-list-header-content">';
281        $html .= '<h4 id="eventlist-title-' . $calId . '">Events</h4>';
282        if ($namespace) {
283            $html .= '<span class="namespace-badge">' . htmlspecialchars($namespace) . '</span>';
284        }
285        $html .= '</div>';
286        $html .= '<button class="add-event-compact" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>';
287        $html .= '</div>';
288
289        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '">';
290        $html .= $this->renderEventListContent($events, $calId, $namespace);
291        $html .= '</div>';
292
293        $html .= '</div>'; // End calendar-right
294
295        // Event dialog
296        $html .= $this->renderEventDialog($calId, $namespace);
297
298        // Month/Year picker dialog (at container level for proper overlay)
299        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace);
300
301        $html .= '</div>'; // End container
302
303        return $html;
304    }
305
306    private function renderEventListContent($events, $calId, $namespace) {
307        if (empty($events)) {
308            return '<p class="no-events-msg">No events this month</p>';
309        }
310
311        $html = '';
312        ksort($events);
313
314        foreach ($events as $dateKey => $dayEvents) {
315            foreach ($dayEvents as $event) {
316                $eventId = isset($event['id']) ? $event['id'] : '';
317                $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled';
318                $time = isset($event['time']) ? htmlspecialchars($event['time']) : '';
319                $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db';
320                $description = isset($event['description']) ? $event['description'] : '';
321                $isTask = isset($event['isTask']) ? $event['isTask'] : false;
322                $completed = isset($event['completed']) ? $event['completed'] : false;
323                $endDate = isset($event['endDate']) ? $event['endDate'] : '';
324
325                // Process description for wiki syntax, HTML, images, and links
326                $renderedDescription = $this->renderDescription($description);
327
328                // Convert to 12-hour format
329                $displayTime = '';
330                if ($time) {
331                    $timeObj = DateTime::createFromFormat('H:i', $time);
332                    if ($timeObj) {
333                        $displayTime = $timeObj->format('g:i A');
334                    } else {
335                        $displayTime = $time;
336                    }
337                }
338
339                // Format date display with day of week
340                $dateObj = new DateTime($dateKey);
341                $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Jan 24"
342
343                // Multi-day indicator
344                $multiDay = '';
345                if ($endDate && $endDate !== $dateKey) {
346                    $endObj = new DateTime($endDate);
347                    $multiDay = ' → ' . $endObj->format('D, M j');
348                }
349
350                $completedClass = $completed ? ' event-completed' : '';
351
352                $html .= '<div class="event-compact-item' . $completedClass . '" data-event-id="' . $eventId . '" data-date="' . $dateKey . '" style="border-left-color: ' . $color . ';">';
353
354                $html .= '<div class="event-info">';
355                $html .= '<div class="event-title-row">';
356                $html .= '<span class="event-title-compact">' . $title . '</span>';
357                $html .= '</div>';
358
359                $html .= '<div class="event-meta-compact">';
360                $html .= '<span class="event-date-time">' . $displayDate . $multiDay;
361                if ($displayTime) {
362                    $html .= ' • ' . $displayTime;
363                }
364                $html .= '</span>';
365                $html .= '</div>';
366
367                if ($description) {
368                    $html .= '<div class="event-desc-compact">' . $renderedDescription . '</div>';
369                }
370
371                $html .= '</div>'; // event-info
372
373                $html .= '<div class="event-actions-compact">';
374                $html .= '<button class="event-action-btn" onclick="deleteEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">��️</button>';
375                $html .= '<button class="event-action-btn" onclick="editEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">✏️</button>';
376                $html .= '</div>';
377
378                // Checkbox for tasks - ON THE FAR RIGHT
379                if ($isTask) {
380                    $checked = $completed ? 'checked' : '';
381                    $html .= '<input type="checkbox" class="task-checkbox" ' . $checked . ' onclick="toggleTaskComplete(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $namespace . '\', this.checked)">';
382                }
383
384                $html .= '</div>';
385            }
386        }
387
388        return $html;
389    }
390
391    private function renderEventPanelOnly($data) {
392        $year = (int)$data['year'];
393        $month = (int)$data['month'];
394        $namespace = $data['namespace'];
395        $height = isset($data['height']) ? $data['height'] : '400px';
396
397        // Validate height format (must be px, em, rem, vh, or %)
398        if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) {
399            $height = '400px'; // Default fallback
400        }
401
402        $events = $this->loadEvents($namespace, $year, $month);
403        $calId = 'panel_' . md5(serialize($data) . microtime());
404
405        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
406
407        $prevMonth = $month - 1;
408        $prevYear = $year;
409        if ($prevMonth < 1) {
410            $prevMonth = 12;
411            $prevYear--;
412        }
413
414        $nextMonth = $month + 1;
415        $nextYear = $year;
416        if ($nextMonth > 12) {
417            $nextMonth = 1;
418            $nextYear++;
419        }
420
421        $html = '<div class="event-panel-standalone" id="' . $calId . '" data-height="' . htmlspecialchars($height) . '" data-namespace="' . htmlspecialchars($namespace) . '">';
422
423        // Header with navigation
424        $html .= '<div class="panel-standalone-header">';
425        $html .= '<button class="cal-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
426        $html .= '<div class="panel-header-content">';
427        $html .= '<h3 class="calendar-month-picker" onclick="openMonthPickerPanel(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . ' Events</h3>';
428        if ($namespace) {
429            $namespaceUrl = DOKU_BASE . 'doku.php?id=' . str_replace(':', ':', $namespace);
430            $html .= '<a href="' . $namespaceUrl . '" class="namespace-badge" title="Go to namespace page">' . htmlspecialchars($namespace) . '</a>';
431        }
432        $html .= '</div>';
433        $html .= '<button class="cal-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
434        $html .= '<button class="cal-today-btn" onclick="jumpTodayPanel(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
435        $html .= '</div>';
436
437        $html .= '<div class="panel-standalone-actions">';
438        $html .= '<button class="add-event-compact" onclick="openAddEventPanel(\'' . $calId . '\', \'' . $namespace . '\')">+ Add Event</button>';
439        $html .= '</div>';
440
441        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '" style="max-height: ' . htmlspecialchars($height) . ';">';
442        $html .= $this->renderEventListContent($events, $calId, $namespace);
443        $html .= '</div>';
444
445        $html .= $this->renderEventDialog($calId, $namespace);
446
447        // Month/Year picker for event panel
448        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace);
449
450        $html .= '</div>';
451
452        return $html;
453    }
454
455    private function renderStandaloneEventList($data) {
456        $namespace = $data['namespace'];
457        $daterange = $data['daterange'];
458        $date = $data['date'];
459        $width = isset($data['width']) ? $data['width'] : '300px';
460        $height = isset($data['height']) ? $data['height'] : '400px';
461        $today = isset($data['today']) ? true : false;
462
463        // Validate width/height format
464        if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|vw|%)$/', $width)) {
465            $width = '300px';
466        }
467        if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) {
468            $height = '400px';
469        }
470
471        // Handle "today" parameter
472        if ($today) {
473            $startDate = date('Y-m-d');
474            $endDate = date('Y-m-d');
475        } elseif ($daterange) {
476            list($startDate, $endDate) = explode(':', $daterange);
477        } elseif ($date) {
478            $startDate = $date;
479            $endDate = $date;
480        } else {
481            $startDate = date('Y-m-01');
482            $endDate = date('Y-m-t');
483        }
484
485        $allEvents = array();
486        $start = new DateTime($startDate);
487        $end = new DateTime($endDate);
488        $end->modify('+1 day');
489
490        $interval = new DateInterval('P1D');
491        $period = new DatePeriod($start, $interval, $end);
492
493        static $loadedMonths = array();
494
495        foreach ($period as $dt) {
496            $year = (int)$dt->format('Y');
497            $month = (int)$dt->format('n');
498            $dateKey = $dt->format('Y-m-d');
499
500            $monthKey = $year . '-' . $month;
501
502            if (!isset($loadedMonths[$monthKey])) {
503                $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month);
504            }
505
506            $monthEvents = $loadedMonths[$monthKey];
507
508            if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) {
509                $allEvents[$dateKey] = $monthEvents[$dateKey];
510            }
511        }
512
513        // Compact container with custom size
514        $html = '<div class="eventlist-compact-widget" style="width: ' . htmlspecialchars($width) . '; max-height: ' . htmlspecialchars($height) . ';">';
515
516        // Compact header
517        if ($today) {
518            $html .= '<div class="eventlist-widget-header">';
519            $html .= '<h4>�� Today\'s Events</h4>';
520            $html .= '</div>';
521        } else {
522            $html .= '<div class="eventlist-widget-header">';
523            $html .= '<h4>' . date('M j', strtotime($startDate));
524            if ($startDate !== $endDate) {
525                $html .= ' - ' . date('M j', strtotime($endDate));
526            }
527            $html .= '</h4>';
528            $html .= '</div>';
529        }
530
531        // Scrollable event list
532        $html .= '<div class="eventlist-widget-content">';
533
534        if (empty($allEvents)) {
535            $html .= '<p class="eventlist-widget-empty">No events</p>';
536        } else {
537            foreach ($allEvents as $dateKey => $dayEvents) {
538                // Compact date header (only if not "today" mode or multi-day range)
539                if (!$today && $startDate !== $endDate) {
540                    $dateObj = new DateTime($dateKey);
541                    $html .= '<div class="eventlist-widget-date">' . $dateObj->format('D, M j') . '</div>';
542                }
543
544                foreach ($dayEvents as $event) {
545                    $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled';
546                    $time = isset($event['time']) ? $event['time'] : '';
547                    $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db';
548                    $description = isset($event['description']) ? $event['description'] : '';
549
550                    // Convert time to 12-hour format
551                    $displayTime = '';
552                    if ($time) {
553                        $timeParts = explode(':', $time);
554                        if (count($timeParts) === 2) {
555                            $hour = (int)$timeParts[0];
556                            $minute = $timeParts[1];
557                            $ampm = $hour >= 12 ? 'PM' : 'AM';
558                            $hour = $hour % 12;
559                            if ($hour === 0) $hour = 12;
560                            $displayTime = $hour . ':' . $minute . ' ' . $ampm;
561                        } else {
562                            $displayTime = $time;
563                        }
564                    }
565
566                    // Compact event item
567                    $html .= '<div class="eventlist-widget-item" style="border-left-color: ' . $color . ';">';
568                    $html .= '<div class="eventlist-widget-title">' . $title . '</div>';
569                    if ($displayTime) {
570                        $html .= '<div class="eventlist-widget-time">' . $displayTime . '</div>';
571                    }
572                    if ($description) {
573                        $renderedDesc = $this->renderDescription($description);
574                        $html .= '<div class="eventlist-widget-desc">' . $renderedDesc . '</div>';
575                    }
576                    $html .= '</div>';
577                }
578            }
579        }
580
581        $html .= '</div>'; // End content
582        $html .= '</div>'; // End container
583
584        return $html;
585    }
586
587    private function renderEventDialog($calId, $namespace) {
588        $html = '<div class="event-dialog-compact" id="dialog-' . $calId . '" style="display:none;">';
589        $html .= '<div class="dialog-overlay" onclick="closeEventDialog(\'' . $calId . '\')"></div>';
590
591        // Draggable dialog
592        $html .= '<div class="dialog-content-sleek" id="dialog-content-' . $calId . '">';
593
594        // Header with drag handle and close button
595        $html .= '<div class="dialog-header-sleek dialog-drag-handle" id="drag-handle-' . $calId . '">';
596        $html .= '<h3 id="dialog-title-' . $calId . '">Add Event</h3>';
597        $html .= '<button type="button" class="dialog-close-btn" onclick="closeEventDialog(\'' . $calId . '\')">×</button>';
598        $html .= '</div>';
599
600        // Form content
601        $html .= '<form id="eventform-' . $calId . '" onsubmit="saveEventCompact(\'' . $calId . '\', \'' . $namespace . '\'); return false;" class="sleek-form">';
602
603        // Hidden ID field
604        $html .= '<input type="hidden" id="event-id-' . $calId . '" name="eventId" value="">';
605
606        // Task checkbox
607        $html .= '<div class="form-field form-field-checkbox">';
608        $html .= '<label class="checkbox-label">';
609        $html .= '<input type="checkbox" id="event-is-task-' . $calId . '" name="isTask" class="task-toggle">';
610        $html .= '<span>�� This is a task (can be checked off)</span>';
611        $html .= '</label>';
612        $html .= '</div>';
613
614        // Date and Time in a row
615        $html .= '<div class="form-row-group">';
616
617        // Start Date field
618        $html .= '<div class="form-field form-field-date">';
619        $html .= '<label class="field-label">�� Start Date</label>';
620        $html .= '<input type="date" id="event-date-' . $calId . '" name="date" required class="input-sleek input-date">';
621        $html .= '</div>';
622
623        // End Date field (for multi-day events)
624        $html .= '<div class="form-field form-field-date">';
625        $html .= '<label class="field-label">�� End Date</label>';
626        $html .= '<input type="date" id="event-end-date-' . $calId . '" name="endDate" class="input-sleek input-date">';
627        $html .= '</div>';
628
629        $html .= '</div>';
630
631        // Recurring event section
632        $html .= '<div class="form-field form-field-checkbox">';
633        $html .= '<label class="checkbox-label">';
634        $html .= '<input type="checkbox" id="event-recurring-' . $calId . '" name="isRecurring" class="recurring-toggle" onchange="toggleRecurringOptions(\'' . $calId . '\')">';
635        $html .= '<span>�� Repeating Event</span>';
636        $html .= '</label>';
637        $html .= '</div>';
638
639        // Recurring options (hidden by default)
640        $html .= '<div id="recurring-options-' . $calId . '" class="recurring-options" style="display:none;">';
641
642        // Recurrence pattern
643        $html .= '<div class="form-field">';
644        $html .= '<label class="field-label">Repeat Every</label>';
645        $html .= '<select id="event-recurrence-type-' . $calId . '" name="recurrenceType" class="input-sleek">';
646        $html .= '<option value="daily">Daily</option>';
647        $html .= '<option value="weekly">Weekly</option>';
648        $html .= '<option value="monthly">Monthly</option>';
649        $html .= '<option value="yearly">Yearly</option>';
650        $html .= '</select>';
651        $html .= '</div>';
652
653        // Recurrence end date
654        $html .= '<div class="form-field">';
655        $html .= '<label class="field-label">�� Repeat Until (optional)</label>';
656        $html .= '<input type="date" id="event-recurrence-end-' . $calId . '" name="recurrenceEnd" class="input-sleek input-date">';
657        $html .= '</div>';
658
659        $html .= '</div>';
660
661        // Time field
662        $html .= '<div class="form-field">';
663        $html .= '<label class="field-label">�� Time (optional)</label>';
664        $html .= '<input type="time" id="event-time-' . $calId . '" name="time" class="input-sleek">';
665        $html .= '</div>';
666
667        // Title field
668        $html .= '<div class="form-field">';
669        $html .= '<label class="field-label">�� Title</label>';
670        $html .= '<input type="text" id="event-title-' . $calId . '" name="title" required class="input-sleek" placeholder="Event or task title...">';
671        $html .= '</div>';
672
673        // Description field
674        $html .= '<div class="form-field">';
675        $html .= '<label class="field-label">�� Description</label>';
676        $html .= '<textarea id="event-desc-' . $calId . '" name="description" rows="3" class="input-sleek textarea-sleek" placeholder="Add details (optional)..."></textarea>';
677        $html .= '</div>';
678
679        // Color picker
680        $html .= '<div class="form-field">';
681        $html .= '<label class="field-label">�� Color</label>';
682        $html .= '<div class="color-picker-container">';
683        $html .= '<input type="color" id="event-color-' . $calId . '" name="color" value="#3498db" class="input-color-sleek">';
684        $html .= '<span class="color-label">Choose event color</span>';
685        $html .= '</div>';
686        $html .= '</div>';
687
688        // Action buttons
689        $html .= '<div class="dialog-actions-sleek">';
690        $html .= '<button type="button" class="btn-sleek btn-cancel-sleek" onclick="closeEventDialog(\'' . $calId . '\')">Cancel</button>';
691        $html .= '<button type="submit" class="btn-sleek btn-save-sleek">�� Save</button>';
692        $html .= '</div>';
693
694        $html .= '</form>';
695        $html .= '</div>';
696        $html .= '</div>';
697
698        return $html;
699    }
700
701    private function renderMonthPicker($calId, $year, $month, $namespace) {
702        $html = '<div class="month-picker-overlay" id="month-picker-overlay-' . $calId . '" style="display:none;" onclick="closeMonthPicker(\'' . $calId . '\')">';
703        $html .= '<div class="month-picker-dialog" onclick="event.stopPropagation();">';
704        $html .= '<h4>Jump to Month</h4>';
705
706        $html .= '<div class="month-picker-selects">';
707        $html .= '<select id="month-picker-month-' . $calId . '" class="month-picker-select">';
708        $monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
709        for ($m = 1; $m <= 12; $m++) {
710            $selected = ($m == $month) ? ' selected' : '';
711            $html .= '<option value="' . $m . '"' . $selected . '>' . $monthNames[$m - 1] . '</option>';
712        }
713        $html .= '</select>';
714
715        $html .= '<select id="month-picker-year-' . $calId . '" class="month-picker-select">';
716        $currentYear = (int)date('Y');
717        for ($y = $currentYear - 5; $y <= $currentYear + 5; $y++) {
718            $selected = ($y == $year) ? ' selected' : '';
719            $html .= '<option value="' . $y . '"' . $selected . '>' . $y . '</option>';
720        }
721        $html .= '</select>';
722        $html .= '</div>';
723
724        $html .= '<div class="month-picker-actions">';
725        $html .= '<button class="btn-sleek btn-cancel-sleek" onclick="closeMonthPicker(\'' . $calId . '\')">Cancel</button>';
726        $html .= '<button class="btn-sleek btn-save-sleek" onclick="jumpToSelectedMonth(\'' . $calId . '\', \'' . $namespace . '\')">Go</button>';
727        $html .= '</div>';
728
729        $html .= '</div>';
730        $html .= '</div>';
731
732        return $html;
733    }
734
735    private function renderDescription($description) {
736        if (empty($description)) {
737            return '';
738        }
739
740        // Convert newlines to <br> for basic formatting
741        $rendered = nl2br($description);
742
743        // Convert DokuWiki image syntax {{image.jpg}} to HTML
744        $rendered = preg_replace_callback(
745            '/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/',
746            function($matches) {
747                $imagePath = trim($matches[1]);
748                $alt = isset($matches[2]) ? trim($matches[2]) : '';
749
750                // Handle external URLs (http:// or https://)
751                if (preg_match('/^https?:\/\//', $imagePath)) {
752                    return '<img src="' . htmlspecialchars($imagePath) . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
753                }
754
755                // Handle internal DokuWiki images
756                $imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath);
757                return '<img src="' . $imageUrl . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
758            },
759            $rendered
760        );
761
762        // Convert DokuWiki link syntax [[link|text]] to HTML
763        $rendered = preg_replace_callback(
764            '/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/',
765            function($matches) {
766                $link = trim($matches[1]);
767                $text = isset($matches[2]) ? trim($matches[2]) : $link;
768
769                // Handle external URLs
770                if (preg_match('/^https?:\/\//', $link)) {
771                    return '<a href="' . htmlspecialchars($link) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>';
772                }
773
774                // Handle internal DokuWiki links with section anchors
775                // Split page and section (e.g., "page#section" or "namespace:page#section")
776                $parts = explode('#', $link, 2);
777                $pagePart = $parts[0];
778                $sectionPart = isset($parts[1]) ? '#' . $parts[1] : '';
779
780                // Build URL with properly encoded page and unencoded section anchor
781                $wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($pagePart) . $sectionPart;
782                return '<a href="' . $wikiUrl . '">' . htmlspecialchars($text) . '</a>';
783            },
784            $rendered
785        );
786
787        // Convert markdown-style links [text](url) to HTML
788        $rendered = preg_replace_callback(
789            '/\[([^\]]+)\]\(([^)]+)\)/',
790            function($matches) {
791                $text = trim($matches[1]);
792                $url = trim($matches[2]);
793
794                if (preg_match('/^https?:\/\//', $url)) {
795                    return '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>';
796                }
797
798                return '<a href="' . htmlspecialchars($url) . '">' . htmlspecialchars($text) . '</a>';
799            },
800            $rendered
801        );
802
803        // Convert plain URLs to clickable links
804        $rendered = preg_replace_callback(
805            '/(https?:\/\/[^\s<]+)/',
806            function($matches) {
807                $url = $matches[1];
808                return '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($url) . '</a>';
809            },
810            $rendered
811        );
812
813        // Allow basic HTML tags (bold, italic, strong, em, u, code)
814        // Already in the description, just pass through
815
816        return $rendered;
817    }
818
819    private function loadEvents($namespace, $year, $month) {
820        $dataDir = DOKU_INC . 'data/meta/';
821        if ($namespace) {
822            $dataDir .= str_replace(':', '/', $namespace) . '/';
823        }
824        $dataDir .= 'calendar/';
825
826        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
827
828        if (file_exists($eventFile)) {
829            $json = file_get_contents($eventFile);
830            return json_decode($json, true);
831        }
832
833        return array();
834    }
835}
836