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