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('/<del>(.+?)<\/del>/', '<del>$1</del>', $rendered); 960 961 // Monospace: ''text'' 962 $rendered = preg_replace('/''(.+?)''/', '<code>$1</code>', $rendered); 963 964 // Subscript: <sub>text</sub> 965 $rendered = preg_replace('/<sub>(.+?)<\/sub>/', '<sub>$1</sub>', $rendered); 966 967 // Superscript: <sup>text</sup> 968 $rendered = preg_replace('/<sup>(.+?)<\/sup>/', '<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