1/** 2 * DokuWiki Compact Calendar Plugin JavaScript 3 * Loaded independently to avoid DokuWiki concatenation issues 4 */ 5 6// Ensure DOKU_BASE is defined - check multiple sources 7if (typeof DOKU_BASE === 'undefined') { 8 // Try to get from global jsinfo object (DokuWiki standard) 9 if (typeof window.jsinfo !== 'undefined' && window.jsinfo.dokubase) { 10 window.DOKU_BASE = window.jsinfo.dokubase; 11 } else { 12 // Fallback: extract from script source path 13 var scripts = document.getElementsByTagName('script'); 14 var pluginScriptPath = null; 15 for (var i = 0; i < scripts.length; i++) { 16 if (scripts[i].src && scripts[i].src.indexOf('calendar/script.js') !== -1) { 17 pluginScriptPath = scripts[i].src; 18 break; 19 } 20 } 21 22 if (pluginScriptPath) { 23 // Extract base path from: .../lib/plugins/calendar/script.js 24 var match = pluginScriptPath.match(/^(.*?)lib\/plugins\//); 25 window.DOKU_BASE = match ? match[1] : '/'; 26 } else { 27 // Last resort: use root 28 window.DOKU_BASE = '/'; 29 } 30 } 31} 32 33// Shorthand for convenience 34var DOKU_BASE = window.DOKU_BASE || '/'; 35 36// Filter calendar by namespace 37window.filterCalendarByNamespace = function(calId, namespace) { 38 // Get current year and month from calendar 39 const container = document.getElementById(calId); 40 if (!container) { 41 console.error('Calendar container not found:', calId); 42 return; 43 } 44 45 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 46 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 47 48 // Reload calendar with the filtered namespace 49 navCalendar(calId, year, month, namespace); 50}; 51 52// Navigate to different month 53window.navCalendar = function(calId, year, month, namespace) { 54 55 const params = new URLSearchParams({ 56 call: 'plugin_calendar', 57 action: 'load_month', 58 year: year, 59 month: month, 60 namespace: namespace, 61 _: new Date().getTime() // Cache buster 62 }); 63 64 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 65 method: 'POST', 66 headers: { 67 'Content-Type': 'application/x-www-form-urlencoded', 68 'Cache-Control': 'no-cache, no-store, must-revalidate', 69 'Pragma': 'no-cache' 70 }, 71 body: params.toString() 72 }) 73 .then(r => r.json()) 74 .then(data => { 75 if (data.success) { 76 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 77 } else { 78 console.error('Failed to load month:', data.error); 79 } 80 }) 81 .catch(err => { 82 console.error('Error loading month:', err); 83 }); 84}; 85 86// Jump to current month 87window.jumpToToday = function(calId, namespace) { 88 const today = new Date(); 89 const year = today.getFullYear(); 90 const month = today.getMonth() + 1; // JavaScript months are 0-indexed 91 navCalendar(calId, year, month, namespace); 92}; 93 94// Jump to today for event panel 95window.jumpTodayPanel = function(calId, namespace) { 96 const today = new Date(); 97 const year = today.getFullYear(); 98 const month = today.getMonth() + 1; 99 navEventPanel(calId, year, month, namespace); 100}; 101 102// Open month picker dialog 103window.openMonthPicker = function(calId, currentYear, currentMonth, namespace) { 104 105 const overlay = document.getElementById('month-picker-overlay-' + calId); 106 107 const monthSelect = document.getElementById('month-picker-month-' + calId); 108 109 const yearSelect = document.getElementById('month-picker-year-' + calId); 110 111 if (!overlay) { 112 console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId); 113 return; 114 } 115 116 if (!monthSelect || !yearSelect) { 117 console.error('Select elements not found!'); 118 return; 119 } 120 121 // Set current values 122 monthSelect.value = currentMonth; 123 yearSelect.value = currentYear; 124 125 // Show overlay 126 overlay.style.display = 'flex'; 127}; 128 129// Open month picker dialog for event panel 130window.openMonthPickerPanel = function(calId, currentYear, currentMonth, namespace) { 131 openMonthPicker(calId, currentYear, currentMonth, namespace); 132}; 133 134// Close month picker dialog 135window.closeMonthPicker = function(calId) { 136 const overlay = document.getElementById('month-picker-overlay-' + calId); 137 overlay.style.display = 'none'; 138}; 139 140// Jump to selected month 141window.jumpToSelectedMonth = function(calId, namespace) { 142 const monthSelect = document.getElementById('month-picker-month-' + calId); 143 const yearSelect = document.getElementById('month-picker-year-' + calId); 144 145 const month = parseInt(monthSelect.value); 146 const year = parseInt(yearSelect.value); 147 148 closeMonthPicker(calId); 149 150 // Check if this is a calendar or event panel 151 const container = document.getElementById(calId); 152 if (container && container.classList.contains('event-panel-standalone')) { 153 navEventPanel(calId, year, month, namespace); 154 } else { 155 navCalendar(calId, year, month, namespace); 156 } 157}; 158 159// Rebuild calendar grid after navigation 160window.rebuildCalendar = function(calId, year, month, events, namespace) { 161 162 const container = document.getElementById(calId); 163 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 164 'July', 'August', 'September', 'October', 'November', 'December']; 165 166 // Preserve original namespace if not yet set 167 if (!container.dataset.originalNamespace) { 168 container.setAttribute('data-original-namespace', namespace || ''); 169 } 170 171 // Update container data attributes for current month/year 172 container.setAttribute('data-year', year); 173 container.setAttribute('data-month', month); 174 175 // Update embedded events data 176 let eventsDataEl = document.getElementById('events-data-' + calId); 177 if (eventsDataEl) { 178 eventsDataEl.textContent = JSON.stringify(events); 179 } else { 180 eventsDataEl = document.createElement('script'); 181 eventsDataEl.type = 'application/json'; 182 eventsDataEl.id = 'events-data-' + calId; 183 eventsDataEl.textContent = JSON.stringify(events); 184 container.appendChild(eventsDataEl); 185 } 186 187 // Update header 188 const header = container.querySelector('.calendar-compact-header h3'); 189 header.textContent = monthNames[month - 1] + ' ' + year; 190 191 // Update or create namespace filter indicator 192 let filterIndicator = container.querySelector('.calendar-namespace-filter'); 193 const shouldShowFilter = namespace && namespace !== '' && namespace !== '*' && 194 namespace.indexOf('*') === -1 && namespace.indexOf(';') === -1; 195 196 if (shouldShowFilter) { 197 // Show/update filter indicator 198 if (!filterIndicator) { 199 // Create filter indicator if it doesn't exist 200 const headerDiv = container.querySelector('.calendar-compact-header'); 201 if (!headerDiv) { 202 console.error('Header div not found!'); 203 } else { 204 filterIndicator = document.createElement('div'); 205 filterIndicator.className = 'calendar-namespace-filter'; 206 filterIndicator.id = 'namespace-filter-' + calId; 207 headerDiv.parentNode.insertBefore(filterIndicator, headerDiv.nextSibling); 208 } 209 } else { 210 } 211 212 if (filterIndicator) { 213 filterIndicator.innerHTML = 214 '<span class="namespace-filter-label">Filtering:</span>' + 215 '<span class="namespace-filter-name">' + escapeHtml(namespace) + '</span>' + 216 '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' + calId + '\')" title="Clear filter and show all namespaces">✕</button>'; 217 filterIndicator.style.display = 'flex'; 218 } 219 } else { 220 // Hide filter indicator 221 if (filterIndicator) { 222 filterIndicator.style.display = 'none'; 223 } 224 } 225 226 // Update container's namespace attribute 227 container.setAttribute('data-namespace', namespace || ''); 228 229 // Update nav buttons 230 let prevMonth = month - 1; 231 let prevYear = year; 232 if (prevMonth < 1) { 233 prevMonth = 12; 234 prevYear--; 235 } 236 237 let nextMonth = month + 1; 238 let nextYear = year; 239 if (nextMonth > 12) { 240 nextMonth = 1; 241 nextYear++; 242 } 243 244 const navBtns = container.querySelectorAll('.cal-nav-btn'); 245 navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 246 navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 247 248 // Rebuild calendar grid 249 const tbody = container.querySelector('.calendar-compact-grid tbody'); 250 const firstDay = new Date(year, month - 1, 1); 251 const daysInMonth = new Date(year, month, 0).getDate(); 252 const dayOfWeek = firstDay.getDay(); 253 254 // Calculate month boundaries 255 const monthStart = new Date(year, month - 1, 1); 256 const monthEnd = new Date(year, month - 1, daysInMonth); 257 258 // Build a map of all events with their date ranges 259 const eventRanges = {}; 260 for (const [dateKey, dayEvents] of Object.entries(events)) { 261 // Defensive check: ensure dayEvents is an array 262 if (!Array.isArray(dayEvents)) { 263 console.error('dayEvents is not an array for dateKey:', dateKey, 'value:', dayEvents); 264 continue; 265 } 266 267 // Only process events that could possibly overlap with this month/year 268 const dateYear = parseInt(dateKey.split('-')[0]); 269 const dateMonth = parseInt(dateKey.split('-')[1]); 270 271 // Skip events from completely different years (unless they're very long multi-day events) 272 if (Math.abs(dateYear - year) > 1) { 273 continue; 274 } 275 276 for (const evt of dayEvents) { 277 const startDate = dateKey; 278 const endDate = evt.endDate || dateKey; 279 280 // Check if event overlaps with current month 281 const eventStart = new Date(startDate + 'T00:00:00'); 282 const eventEnd = new Date(endDate + 'T00:00:00'); 283 284 // Skip if event doesn't overlap with current month 285 if (eventEnd < monthStart || eventStart > monthEnd) { 286 continue; 287 } 288 289 // Create entry for each day the event spans 290 const start = new Date(startDate + 'T00:00:00'); 291 const end = new Date(endDate + 'T00:00:00'); 292 const current = new Date(start); 293 294 while (current <= end) { 295 const currentKey = current.toISOString().split('T')[0]; 296 297 // Check if this date is in current month 298 const currentDate = new Date(currentKey + 'T00:00:00'); 299 if (currentDate.getFullYear() === year && currentDate.getMonth() === month - 1) { 300 if (!eventRanges[currentKey]) { 301 eventRanges[currentKey] = []; 302 } 303 304 // Add event with span information 305 const eventCopy = {...evt}; 306 eventCopy._span_start = startDate; 307 eventCopy._span_end = endDate; 308 eventCopy._is_first_day = (currentKey === startDate); 309 eventCopy._is_last_day = (currentKey === endDate); 310 eventCopy._original_date = dateKey; 311 312 // Check if event continues from previous month or to next month 313 eventCopy._continues_from_prev = (eventStart < monthStart); 314 eventCopy._continues_to_next = (eventEnd > monthEnd); 315 316 eventRanges[currentKey].push(eventCopy); 317 } 318 319 current.setDate(current.getDate() + 1); 320 } 321 } 322 } 323 324 let html = ''; 325 let currentDay = 1; 326 const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7); 327 328 for (let row = 0; row < rowCount; row++) { 329 html += '<tr>'; 330 for (let col = 0; col < 7; col++) { 331 if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) { 332 html += '<td class="cal-empty"></td>'; 333 } else { 334 const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`; 335 336 // Get today's date in local timezone 337 const todayObj = new Date(); 338 const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`; 339 340 const isToday = dateKey === today; 341 const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0; 342 343 let classes = 'cal-day'; 344 if (isToday) classes += ' cal-today'; 345 if (hasEvents) classes += ' cal-has-events'; 346 347 html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`; 348 html += `<span class="day-num">${currentDay}</span>`; 349 350 if (hasEvents) { 351 // Sort events by time (no time first, then by time) 352 const sortedEvents = [...eventRanges[dateKey]].sort((a, b) => { 353 const timeA = a.time || ''; 354 const timeB = b.time || ''; 355 356 // Events without time go first 357 if (!timeA && timeB) return -1; 358 if (timeA && !timeB) return 1; 359 if (!timeA && !timeB) return 0; 360 361 // Sort by time 362 return timeA.localeCompare(timeB); 363 }); 364 365 // Show colored stacked bars for each event 366 html += '<div class="event-indicators">'; 367 for (const evt of sortedEvents) { 368 const eventId = evt.id || ''; 369 const eventColor = evt.color || '#3498db'; 370 const eventTime = evt.time || ''; 371 const eventTitle = evt.title || 'Event'; 372 const originalDate = evt._original_date || dateKey; 373 const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true; 374 const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true; 375 376 let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed'; 377 378 // Add classes for multi-day spanning 379 if (!isFirstDay) barClass += ' event-bar-continues'; 380 if (!isLastDay) barClass += ' event-bar-continuing'; 381 382 html += `<span class="event-bar ${barClass}" `; 383 html += `style="background: ${eventColor};" `; 384 html += `title="${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `; 385 html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');"></span>`; 386 } 387 html += '</div>'; 388 } 389 390 html += '</td>'; 391 currentDay++; 392 } 393 } 394 html += '</tr>'; 395 } 396 397 tbody.innerHTML = html; 398 399 // Update Today button with current namespace 400 const todayBtn = container.querySelector('.cal-today-btn'); 401 if (todayBtn) { 402 todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`); 403 } 404 405 // Update month picker with current namespace 406 const monthPicker = container.querySelector('.calendar-month-picker'); 407 if (monthPicker) { 408 monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`); 409 } 410 411 // Rebuild event list - server already filtered to current month 412 const eventList = container.querySelector('.event-list-compact'); 413 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 414 415 // Auto-scroll to first future event (past events will be above viewport) 416 setTimeout(() => { 417 const firstFuture = eventList.querySelector('[data-first-future="true"]'); 418 if (firstFuture) { 419 firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' }); 420 } 421 }, 100); 422 423 // Update title 424 const title = container.querySelector('#eventlist-title-' + calId); 425 title.textContent = 'Events'; 426}; 427 428// Render event list from data 429window.renderEventListFromData = function(events, calId, namespace, year, month) { 430 if (!events || Object.keys(events).length === 0) { 431 return '<p class="no-events-msg">No events this month</p>'; 432 } 433 434 // Check for time conflicts 435 events = checkTimeConflicts(events, null); 436 437 let pastHtml = ''; 438 let futureHtml = ''; 439 let pastCount = 0; 440 441 const sortedDates = Object.keys(events).sort(); 442 const today = new Date(); 443 today.setHours(0, 0, 0, 0); 444 const todayStr = today.toISOString().split('T')[0]; 445 446 // Helper function to check if event is past (with 15-minute grace period) 447 const isEventPast = function(dateKey, time) { 448 // If event is on a past date, it's definitely past 449 if (dateKey < todayStr) { 450 return true; 451 } 452 453 // If event is on a future date, it's definitely not past 454 if (dateKey > todayStr) { 455 return false; 456 } 457 458 // Event is today - check time with grace period 459 if (time && time.trim() !== '') { 460 try { 461 const now = new Date(); 462 const eventDateTime = new Date(dateKey + 'T' + time); 463 464 // Add 15-minute grace period 465 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 466 467 // Event is past if current time > event time + 15 minutes 468 return now > gracePeriodEnd; 469 } catch (e) { 470 // If time parsing fails, treat as future 471 return false; 472 } 473 } 474 475 // No time specified for today's event, treat as future 476 return false; 477 }; 478 479 // Filter events to only current month if year/month provided 480 const monthStart = year && month ? new Date(year, month - 1, 1) : null; 481 const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null; 482 483 for (const dateKey of sortedDates) { 484 // Skip events not in current month if filtering 485 if (monthStart && monthEnd) { 486 const eventDate = new Date(dateKey + 'T00:00:00'); 487 488 if (eventDate < monthStart || eventDate > monthEnd) { 489 continue; 490 } 491 } 492 493 // Sort events within this day by time (all-day events at top) 494 const dayEvents = events[dateKey]; 495 dayEvents.sort((a, b) => { 496 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 497 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 498 499 // All-day events (no time) go to the TOP 500 if (timeA === null && timeB !== null) return -1; // A before B 501 if (timeA !== null && timeB === null) return 1; // A after B 502 if (timeA === null && timeB === null) return 0; // Both all-day, equal 503 504 // Both have times, sort chronologically 505 return timeA.localeCompare(timeB); 506 }); 507 508 for (const event of dayEvents) { 509 const isTask = event.isTask || false; 510 const completed = event.completed || false; 511 512 // Use helper function to determine if event is past (with grace period) 513 const isPast = isEventPast(dateKey, event.time); 514 const isPastDue = isPast && isTask && !completed; 515 516 // Determine if this goes in past section 517 const isPastOrCompleted = (isPast && (!isTask || completed)) || completed; 518 519 const eventHtml = renderEventItem(event, dateKey, calId, namespace); 520 521 if (isPastOrCompleted) { 522 pastCount++; 523 pastHtml += eventHtml; 524 } else { 525 futureHtml += eventHtml; 526 } 527 } 528 } 529 530 let html = ''; 531 532 // Add collapsible past events section if any exist 533 if (pastCount > 0) { 534 html += '<div class="past-events-section">'; 535 html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">'; 536 html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> '; 537 html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>'; 538 html += '</div>'; 539 html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">'; 540 html += pastHtml; 541 html += '</div>'; 542 html += '</div>'; 543 } else { 544 } 545 546 // Add future events 547 html += futureHtml; 548 549 550 if (!html) { 551 return '<p class="no-events-msg">No events this month</p>'; 552 } 553 554 return html; 555}; 556 557// Show day popup with events when clicking a date 558window.showDayPopup = function(calId, date, namespace) { 559 // Get events for this calendar 560 const eventsDataEl = document.getElementById('events-data-' + calId); 561 let events = {}; 562 563 if (eventsDataEl) { 564 try { 565 events = JSON.parse(eventsDataEl.textContent); 566 } catch (e) { 567 console.error('Failed to parse events data:', e); 568 } 569 } 570 571 const dayEvents = events[date] || []; 572 573 // Check for conflicts on this day 574 const dayEventsObj = {[date]: dayEvents}; 575 const checkedEvents = checkTimeConflicts(dayEventsObj, null); 576 const dayEventsWithConflicts = checkedEvents[date] || dayEvents; 577 578 // Sort events: all-day at top, then chronological by time 579 dayEventsWithConflicts.sort((a, b) => { 580 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 581 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 582 583 // All-day events (no time) go to the TOP 584 if (timeA === null && timeB !== null) return -1; // A before B 585 if (timeA !== null && timeB === null) return 1; // A after B 586 if (timeA === null && timeB === null) return 0; // Both all-day, equal 587 588 // Both have times, sort chronologically 589 return timeA.localeCompare(timeB); 590 }); 591 592 const dateObj = new Date(date + 'T00:00:00'); 593 const displayDate = dateObj.toLocaleDateString('en-US', { 594 weekday: 'long', 595 month: 'long', 596 day: 'numeric', 597 year: 'numeric' 598 }); 599 600 // Create popup 601 let popup = document.getElementById('day-popup-' + calId); 602 if (!popup) { 603 popup = document.createElement('div'); 604 popup.id = 'day-popup-' + calId; 605 popup.className = 'day-popup'; 606 document.body.appendChild(popup); 607 } 608 609 let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>'; 610 html += '<div class="day-popup-content">'; 611 html += '<div class="day-popup-header">'; 612 html += '<h4>' + displayDate + '</h4>'; 613 html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>'; 614 html += '</div>'; 615 616 html += '<div class="day-popup-body">'; 617 618 if (dayEventsWithConflicts.length === 0) { 619 html += '<p class="no-events-msg">No events on this day</p>'; 620 } else { 621 html += '<div class="popup-events-list">'; 622 dayEventsWithConflicts.forEach(event => { 623 const color = event.color || '#3498db'; 624 625 // Use individual event namespace if available (for multi-namespace support) 626 const eventNamespace = event._namespace !== undefined ? event._namespace : namespace; 627 628 // Check if this is a continuation (event started before this date) 629 const originalStartDate = event.originalStartDate || event._dateKey || date; 630 const isContinuation = originalStartDate < date; 631 632 // Convert to 12-hour format and handle time ranges 633 let displayTime = ''; 634 if (event.time) { 635 displayTime = formatTimeRange(event.time, event.endTime); 636 } 637 638 // Multi-day indicator 639 let multiDay = ''; 640 if (event.endDate && event.endDate !== date) { 641 const endObj = new Date(event.endDate + 'T00:00:00'); 642 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 643 month: 'short', 644 day: 'numeric' 645 }); 646 } 647 648 // Continuation message 649 if (isContinuation) { 650 const startObj = new Date(originalStartDate + 'T00:00:00'); 651 const startDisplay = startObj.toLocaleDateString('en-US', { 652 weekday: 'short', 653 month: 'short', 654 day: 'numeric' 655 }); 656 html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>'; 657 } 658 659 html += '<div class="popup-event-item">'; 660 html += '<div class="event-color-bar" style="background: ' + color + ';"></div>'; 661 html += '<div class="popup-event-content">'; 662 663 // Single line with title, time, date range, namespace, and actions 664 html += '<div class="popup-event-main-row">'; 665 html += '<div class="popup-event-info-inline">'; 666 html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>'; 667 if (displayTime) { 668 html += '<span class="popup-event-time"> ' + displayTime + '</span>'; 669 } 670 if (multiDay) { 671 html += '<span class="popup-event-multiday">' + multiDay + '</span>'; 672 } 673 if (eventNamespace) { 674 html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>'; 675 } 676 677 // Add conflict warning badge if event has conflicts 678 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 679 // Build conflict list for tooltip 680 let conflictList = []; 681 event.conflictsWith.forEach(conflict => { 682 let conflictText = conflict.title; 683 if (conflict.time) { 684 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 685 } 686 conflictList.push(conflictText); 687 }); 688 689 html += '<span class="event-conflict-badge" data-conflicts="' + escapeHtml(JSON.stringify(conflictList)) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 690 } 691 692 html += '</div>'; 693 html += '<div class="popup-event-actions">'; 694 html += '<button class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>'; 695 html += '<button class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">️</button>'; 696 html += '</div>'; 697 html += '</div>'; 698 699 // Description on separate line if present 700 if (event.description) { 701 html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>'; 702 } 703 704 html += '</div></div>'; 705 }); 706 html += '</div>'; 707 } 708 709 html += '</div>'; 710 711 html += '<div class="day-popup-footer">'; 712 html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>'; 713 html += '</div>'; 714 715 html += '</div>'; 716 717 popup.innerHTML = html; 718 popup.style.display = 'flex'; 719}; 720 721// Close day popup 722window.closeDayPopup = function(calId) { 723 const popup = document.getElementById('day-popup-' + calId); 724 if (popup) { 725 popup.style.display = 'none'; 726 } 727}; 728 729// Show events for a specific day (for event list panel) 730window.showDayEvents = function(calId, date, namespace) { 731 const params = new URLSearchParams({ 732 call: 'plugin_calendar', 733 action: 'load_month', 734 year: date.split('-')[0], 735 month: parseInt(date.split('-')[1]), 736 namespace: namespace, 737 _: new Date().getTime() // Cache buster 738 }); 739 740 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 741 method: 'POST', 742 headers: { 743 'Content-Type': 'application/x-www-form-urlencoded', 744 'Cache-Control': 'no-cache, no-store, must-revalidate', 745 'Pragma': 'no-cache' 746 }, 747 body: params.toString() 748 }) 749 .then(r => r.json()) 750 .then(data => { 751 if (data.success) { 752 const eventList = document.getElementById('eventlist-' + calId); 753 const events = data.events; 754 const title = document.getElementById('eventlist-title-' + calId); 755 756 const dateObj = new Date(date + 'T00:00:00'); 757 const displayDate = dateObj.toLocaleDateString('en-US', { 758 weekday: 'short', 759 month: 'short', 760 day: 'numeric' 761 }); 762 763 title.textContent = 'Events - ' + displayDate; 764 765 // Filter events for this day 766 const dayEvents = events[date] || []; 767 768 if (dayEvents.length === 0) { 769 eventList.innerHTML = '<p class="no-events-msg">No events on this day<br><button class="add-event-compact" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\')">+ Add Event</button></p>'; 770 } else { 771 let html = ''; 772 dayEvents.forEach(event => { 773 html += renderEventItem(event, date, calId, namespace); 774 }); 775 eventList.innerHTML = html; 776 } 777 } 778 }) 779 .catch(err => console.error('Error:', err)); 780}; 781 782// Render a single event item 783window.renderEventItem = function(event, date, calId, namespace) { 784 // Check if this event is in the past or today (with 15-minute grace period) 785 const today = new Date(); 786 today.setHours(0, 0, 0, 0); 787 const todayStr = today.toISOString().split('T')[0]; 788 const eventDate = new Date(date + 'T00:00:00'); 789 790 // Helper to determine if event is past with grace period 791 let isPast; 792 if (date < todayStr) { 793 isPast = true; // Past date 794 } else if (date > todayStr) { 795 isPast = false; // Future date 796 } else { 797 // Today - check time with grace period 798 if (event.time && event.time.trim() !== '') { 799 try { 800 const now = new Date(); 801 const eventDateTime = new Date(date + 'T' + event.time); 802 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 803 isPast = now > gracePeriodEnd; 804 } catch (e) { 805 isPast = false; 806 } 807 } else { 808 isPast = false; // No time, treat as future 809 } 810 } 811 812 const isToday = eventDate.getTime() === today.getTime(); 813 814 // Format date display with day of week 815 // Use originalStartDate if this is a multi-month event continuation 816 const displayDateKey = event.originalStartDate || date; 817 const dateObj = new Date(displayDateKey + 'T00:00:00'); 818 const displayDate = dateObj.toLocaleDateString('en-US', { 819 weekday: 'short', 820 month: 'short', 821 day: 'numeric' 822 }); 823 824 // Convert to 12-hour format and handle time ranges 825 let displayTime = ''; 826 if (event.time) { 827 displayTime = formatTimeRange(event.time, event.endTime); 828 } 829 830 // Multi-day indicator 831 let multiDay = ''; 832 if (event.endDate && event.endDate !== displayDateKey) { 833 const endObj = new Date(event.endDate + 'T00:00:00'); 834 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 835 weekday: 'short', 836 month: 'short', 837 day: 'numeric' 838 }); 839 } 840 841 const completedClass = event.completed ? ' event-completed' : ''; 842 const isTask = event.isTask || false; 843 const completed = event.completed || false; 844 const isPastDue = isPast && isTask && !completed; 845 const pastClass = (isPast && !isPastDue) ? ' event-past' : ''; 846 const pastDueClass = isPastDue ? ' event-pastdue' : ''; 847 const color = event.color || '#3498db'; 848 849 let html = '<div class="event-compact-item' + completedClass + pastClass + pastDueClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ';" onclick="' + (isPast && !isPastDue ? 'togglePastEventExpand(this)' : '') + '">'; 850 851 html += '<div class="event-info">'; 852 html += '<div class="event-title-row">'; 853 html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>'; 854 html += '</div>'; 855 856 // Show meta and description for non-past events AND past due tasks 857 if (!isPast || isPastDue) { 858 html += '<div class="event-meta-compact">'; 859 html += '<span class="event-date-time">' + displayDate + multiDay; 860 if (displayTime) { 861 html += ' • ' + displayTime; 862 } 863 // Add PAST DUE or TODAY badge 864 if (isPastDue) { 865 html += ' <span class="event-pastdue-badge">PAST DUE</span>'; 866 } else if (isToday) { 867 html += ' <span class="event-today-badge">TODAY</span>'; 868 } 869 // Add namespace badge (stored namespace or _namespace for multi-namespace) 870 let eventNamespace = event.namespace || ''; 871 if (!eventNamespace && event._namespace !== undefined) { 872 eventNamespace = event._namespace; // Fallback to _namespace for multi-namespace loading 873 } 874 if (eventNamespace) { 875 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="cursor:pointer;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 876 } 877 // Add conflict warning if event has time conflicts 878 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 879 // Build conflict list for data attribute 880 let conflictList = []; 881 event.conflictsWith.forEach(conflict => { 882 let conflictText = conflict.title; 883 if (conflict.time) { 884 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 885 } 886 conflictList.push(conflictText); 887 }); 888 889 html += ' <span class="event-conflict-badge" data-conflicts="' + escapeHtml(JSON.stringify(conflictList)) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 890 } 891 html += '</span>'; 892 html += '</div>'; 893 894 if (event.description) { 895 html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>'; 896 } 897 } else { 898 // For past events (not past due), store data in hidden divs for expand/collapse 899 html += '<div class="event-meta-compact" style="display: none;">'; 900 html += '<span class="event-date-time">' + displayDate + multiDay; 901 if (displayTime) { 902 html += ' • ' + displayTime; 903 } 904 // Add namespace badge for past events too 905 let eventNamespace = event.namespace || ''; 906 if (!eventNamespace && event._namespace !== undefined) { 907 eventNamespace = event._namespace; 908 } 909 if (eventNamespace) { 910 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="cursor:pointer;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 911 } 912 html += '</span>'; 913 html += '</div>'; 914 915 if (event.description) { 916 html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>'; 917 } 918 } 919 920 html += '</div>'; // event-info 921 922 // Use stored namespace from event, fallback to _namespace, then passed namespace 923 let buttonNamespace = event.namespace || ''; 924 if (!buttonNamespace && event._namespace !== undefined) { 925 buttonNamespace = event._namespace; 926 } 927 if (!buttonNamespace) { 928 buttonNamespace = namespace; 929 } 930 931 html += '<div class="event-actions-compact">'; 932 html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">️</button>'; 933 html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>'; 934 html += '</div>'; 935 936 // Checkbox for tasks - ON THE FAR RIGHT 937 if (isTask) { 938 const checked = completed ? 'checked' : ''; 939 html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">'; 940 } 941 942 html += '</div>'; 943 944 return html; 945}; 946 947// Render description with rich content support 948window.renderDescription = function(description) { 949 if (!description) return ''; 950 951 // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping) 952 // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00 953 954 let rendered = description; 955 const tokens = []; 956 let tokenIndex = 0; 957 958 // Convert DokuWiki image syntax {{image.jpg}} to tokens 959 rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) { 960 imagePath = imagePath.trim(); 961 alt = alt ? alt.trim() : ''; 962 963 let imageHtml; 964 // Handle external URLs 965 if (imagePath.match(/^https?:\/\//)) { 966 imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 967 } else { 968 // Handle internal DokuWiki images 969 const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath); 970 imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 971 } 972 973 const token = '\x00TOKEN' + tokenIndex + '\x00'; 974 tokens[tokenIndex] = imageHtml; 975 tokenIndex++; 976 return token; 977 }); 978 979 // Convert DokuWiki link syntax [[link|text]] to tokens 980 rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) { 981 link = link.trim(); 982 text = text ? text.trim() : link; 983 984 let linkHtml; 985 // Handle external URLs 986 if (link.match(/^https?:\/\//)) { 987 linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 988 } else { 989 // Handle internal DokuWiki links with section anchors 990 const hashIndex = link.indexOf('#'); 991 let pagePart = link; 992 let sectionPart = ''; 993 994 if (hashIndex !== -1) { 995 pagePart = link.substring(0, hashIndex); 996 sectionPart = link.substring(hashIndex); // Includes the # 997 } 998 999 const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart; 1000 linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>'; 1001 } 1002 1003 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1004 tokens[tokenIndex] = linkHtml; 1005 tokenIndex++; 1006 return token; 1007 }); 1008 1009 // Convert markdown-style links [text](url) to tokens 1010 rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { 1011 text = text.trim(); 1012 url = url.trim(); 1013 1014 let linkHtml; 1015 if (url.match(/^https?:\/\//)) { 1016 linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1017 } else { 1018 linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>'; 1019 } 1020 1021 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1022 tokens[tokenIndex] = linkHtml; 1023 tokenIndex++; 1024 return token; 1025 }); 1026 1027 // Convert plain URLs to tokens 1028 rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) { 1029 const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>'; 1030 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1031 tokens[tokenIndex] = linkHtml; 1032 tokenIndex++; 1033 return token; 1034 }); 1035 1036 // NOW escape the remaining text (tokens are protected with null bytes) 1037 rendered = escapeHtml(rendered); 1038 1039 // Convert newlines to <br> 1040 rendered = rendered.replace(/\n/g, '<br>'); 1041 1042 // DokuWiki text formatting (on escaped text) 1043 // Bold: **text** or __text__ 1044 rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 1045 rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>'); 1046 1047 // Italic: //text// 1048 rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>'); 1049 1050 // Strikethrough: <del>text</del> 1051 rendered = rendered.replace(/<del>(.+?)<\/del>/g, '<del>$1</del>'); 1052 1053 // Monospace: ''text'' 1054 rendered = rendered.replace(/''(.+?)''/g, '<code>$1</code>'); 1055 1056 // Subscript: <sub>text</sub> 1057 rendered = rendered.replace(/<sub>(.+?)<\/sub>/g, '<sub>$1</sub>'); 1058 1059 // Superscript: <sup>text</sup> 1060 rendered = rendered.replace(/<sup>(.+?)<\/sup>/g, '<sup>$1</sup>'); 1061 1062 // Restore tokens (replace with actual HTML) 1063 for (let i = 0; i < tokens.length; i++) { 1064 const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g'); 1065 rendered = rendered.replace(tokenPattern, tokens[i]); 1066 } 1067 1068 return rendered; 1069} 1070 1071// Open add event dialog 1072window.openAddEvent = function(calId, namespace, date) { 1073 const dialog = document.getElementById('dialog-' + calId); 1074 const form = document.getElementById('eventform-' + calId); 1075 const title = document.getElementById('dialog-title-' + calId); 1076 const dateField = document.getElementById('event-date-' + calId); 1077 1078 if (!dateField) { 1079 console.error('Date field not found! ID: event-date-' + calId); 1080 return; 1081 } 1082 1083 // Check if there's a filtered namespace active (only for regular calendars) 1084 const calendar = document.getElementById(calId); 1085 const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null; 1086 1087 // Use filtered namespace if available, otherwise use the passed namespace 1088 const effectiveNamespace = filteredNamespace || namespace; 1089 1090 1091 // Reset form 1092 form.reset(); 1093 document.getElementById('event-id-' + calId).value = ''; 1094 1095 // Store the effective namespace in a hidden field or data attribute 1096 form.dataset.effectiveNamespace = effectiveNamespace; 1097 1098 // Set namespace dropdown to effective namespace 1099 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1100 if (namespaceSelect) { 1101 if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) { 1102 // Set to specific namespace if not wildcard or multi-namespace 1103 namespaceSelect.value = effectiveNamespace; 1104 } else { 1105 // Default to empty (default namespace) for wildcard/multi views 1106 namespaceSelect.value = ''; 1107 } 1108 } 1109 1110 // Clear event namespace from previous edits 1111 delete form.dataset.eventNamespace; 1112 1113 // Set date - use local date, not UTC 1114 let defaultDate = date; 1115 if (!defaultDate) { 1116 // Get the currently displayed month from the calendar container 1117 const container = document.getElementById(calId); 1118 const displayedYear = parseInt(container.getAttribute('data-year')); 1119 const displayedMonth = parseInt(container.getAttribute('data-month')); 1120 1121 1122 if (displayedYear && displayedMonth) { 1123 // Use first day of the displayed month 1124 const year = displayedYear; 1125 const month = String(displayedMonth).padStart(2, '0'); 1126 defaultDate = `${year}-${month}-01`; 1127 } else { 1128 // Fallback to today if attributes not found 1129 const today = new Date(); 1130 const year = today.getFullYear(); 1131 const month = String(today.getMonth() + 1).padStart(2, '0'); 1132 const day = String(today.getDate()).padStart(2, '0'); 1133 defaultDate = `${year}-${month}-${day}`; 1134 } 1135 } 1136 dateField.value = defaultDate; 1137 dateField.removeAttribute('data-original-date'); 1138 1139 // Also set the end date field to the same default (user can change it) 1140 const endDateField = document.getElementById('event-end-date-' + calId); 1141 if (endDateField) { 1142 endDateField.value = ''; // Empty by default (single-day event) 1143 // Set min attribute to help the date picker open on the right month 1144 endDateField.setAttribute('min', defaultDate); 1145 } 1146 1147 // Set default color 1148 document.getElementById('event-color-' + calId).value = '#3498db'; 1149 1150 // Initialize end time dropdown (disabled by default since no start time set) 1151 const endTimeField = document.getElementById('event-end-time-' + calId); 1152 if (endTimeField) { 1153 endTimeField.disabled = true; 1154 endTimeField.value = ''; 1155 } 1156 1157 // Initialize namespace search 1158 initNamespaceSearch(calId); 1159 1160 // Set title 1161 title.textContent = 'Add Event'; 1162 1163 // Show dialog 1164 dialog.style.display = 'flex'; 1165 1166 // Focus title field 1167 setTimeout(() => { 1168 const titleField = document.getElementById('event-title-' + calId); 1169 if (titleField) titleField.focus(); 1170 }, 100); 1171}; 1172 1173// Edit event 1174window.editEvent = function(calId, eventId, date, namespace) { 1175 const params = new URLSearchParams({ 1176 call: 'plugin_calendar', 1177 action: 'get_event', 1178 namespace: namespace, 1179 date: date, 1180 eventId: eventId 1181 }); 1182 1183 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1184 method: 'POST', 1185 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1186 body: params.toString() 1187 }) 1188 .then(r => r.json()) 1189 .then(data => { 1190 if (data.success && data.event) { 1191 const event = data.event; 1192 const dialog = document.getElementById('dialog-' + calId); 1193 const title = document.getElementById('dialog-title-' + calId); 1194 const dateField = document.getElementById('event-date-' + calId); 1195 const form = document.getElementById('eventform-' + calId); 1196 1197 if (!dateField) { 1198 console.error('Date field not found when editing!'); 1199 return; 1200 } 1201 1202 // Store the event's actual namespace for saving (important for namespace=* views) 1203 if (event.namespace !== undefined) { 1204 form.dataset.eventNamespace = event.namespace; 1205 } 1206 1207 // Populate form 1208 document.getElementById('event-id-' + calId).value = event.id; 1209 dateField.value = date; 1210 dateField.setAttribute('data-original-date', date); 1211 1212 const endDateField = document.getElementById('event-end-date-' + calId); 1213 endDateField.value = event.endDate || ''; 1214 // Set min attribute to help date picker open on the start date's month 1215 endDateField.setAttribute('min', date); 1216 1217 document.getElementById('event-title-' + calId).value = event.title; 1218 document.getElementById('event-time-' + calId).value = event.time || ''; 1219 document.getElementById('event-end-time-' + calId).value = event.endTime || ''; 1220 document.getElementById('event-color-' + calId).value = event.color || '#3498db'; 1221 document.getElementById('event-desc-' + calId).value = event.description || ''; 1222 document.getElementById('event-is-task-' + calId).checked = event.isTask || false; 1223 1224 // Update end time options based on start time 1225 if (event.time) { 1226 updateEndTimeOptions(calId); 1227 } 1228 1229 // Initialize namespace search 1230 initNamespaceSearch(calId); 1231 1232 // Set namespace fields if available 1233 const namespaceHidden = document.getElementById('event-namespace-' + calId); 1234 const namespaceSearch = document.getElementById('event-namespace-search-' + calId); 1235 if (namespaceHidden && event.namespace !== undefined) { 1236 namespaceHidden.value = event.namespace; 1237 if (namespaceSearch) { 1238 namespaceSearch.value = event.namespace || '(default)'; 1239 } 1240 } 1241 1242 title.textContent = 'Edit Event'; 1243 dialog.style.display = 'flex'; 1244 } 1245 }) 1246 .catch(err => console.error('Error editing event:', err)); 1247}; 1248 1249// Delete event 1250window.deleteEvent = function(calId, eventId, date, namespace) { 1251 if (!confirm('Delete this event?')) return; 1252 1253 const params = new URLSearchParams({ 1254 call: 'plugin_calendar', 1255 action: 'delete_event', 1256 namespace: namespace, 1257 date: date, 1258 eventId: eventId 1259 }); 1260 1261 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1262 method: 'POST', 1263 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1264 body: params.toString() 1265 }) 1266 .then(r => r.json()) 1267 .then(data => { 1268 if (data.success) { 1269 // Extract year and month from date 1270 const [year, month] = date.split('-').map(Number); 1271 1272 // Reload calendar data via AJAX 1273 reloadCalendarData(calId, year, month, namespace); 1274 } 1275 }) 1276 .catch(err => console.error('Error:', err)); 1277}; 1278 1279// Save event (add or edit) 1280window.saveEventCompact = function(calId, namespace) { 1281 const form = document.getElementById('eventform-' + calId); 1282 1283 // Get namespace from dropdown - this is what the user selected 1284 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1285 const selectedNamespace = namespaceSelect ? namespaceSelect.value : ''; 1286 1287 // ALWAYS use what the user selected in the dropdown 1288 // This allows changing namespace when editing 1289 const finalNamespace = selectedNamespace; 1290 1291 const eventId = document.getElementById('event-id-' + calId).value; 1292 1293 // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event) 1294 const originalNamespace = form.dataset.eventNamespace; 1295 1296 1297 const dateInput = document.getElementById('event-date-' + calId); 1298 const date = dateInput.value; 1299 const oldDate = dateInput.getAttribute('data-original-date') || date; 1300 const endDate = document.getElementById('event-end-date-' + calId).value; 1301 const title = document.getElementById('event-title-' + calId).value; 1302 const time = document.getElementById('event-time-' + calId).value; 1303 const endTime = document.getElementById('event-end-time-' + calId).value; 1304 const colorSelect = document.getElementById('event-color-' + calId); 1305 let color = colorSelect.value; 1306 1307 // Handle custom color 1308 if (color === 'custom') { 1309 color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value; 1310 } 1311 1312 const description = document.getElementById('event-desc-' + calId).value; 1313 const isTask = document.getElementById('event-is-task-' + calId).checked; 1314 const completed = false; // New tasks are not completed 1315 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 1316 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 1317 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 1318 1319 if (!title) { 1320 alert('Please enter a title'); 1321 return; 1322 } 1323 1324 if (!date) { 1325 alert('Please select a date'); 1326 return; 1327 } 1328 1329 const params = new URLSearchParams({ 1330 call: 'plugin_calendar', 1331 action: 'save_event', 1332 namespace: finalNamespace, 1333 eventId: eventId, 1334 date: date, 1335 oldDate: oldDate, 1336 endDate: endDate, 1337 title: title, 1338 time: time, 1339 endTime: endTime, 1340 color: color, 1341 description: description, 1342 isTask: isTask ? '1' : '0', 1343 completed: completed ? '1' : '0', 1344 isRecurring: isRecurring ? '1' : '0', 1345 recurrenceType: recurrenceType, 1346 recurrenceEnd: recurrenceEnd 1347 }); 1348 1349 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1350 method: 'POST', 1351 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1352 body: params.toString() 1353 }) 1354 .then(r => r.json()) 1355 .then(data => { 1356 if (data.success) { 1357 closeEventDialog(calId); 1358 1359 // For recurring events, do a full page reload to show all occurrences 1360 if (isRecurring) { 1361 location.reload(); 1362 return; 1363 } 1364 1365 // Extract year and month from the NEW date (in case date was changed) 1366 const [year, month] = date.split('-').map(Number); 1367 1368 // Reload calendar data via AJAX to the month of the event 1369 reloadCalendarData(calId, year, month, namespace); 1370 } else { 1371 alert('Error: ' + (data.error || 'Unknown error')); 1372 } 1373 }) 1374 .catch(err => { 1375 console.error('Error:', err); 1376 alert('Error saving event'); 1377 }); 1378}; 1379 1380// Reload calendar data without page refresh 1381window.reloadCalendarData = function(calId, year, month, namespace) { 1382 const params = new URLSearchParams({ 1383 call: 'plugin_calendar', 1384 action: 'load_month', 1385 year: year, 1386 month: month, 1387 namespace: namespace, 1388 _: new Date().getTime() // Cache buster 1389 }); 1390 1391 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1392 method: 'POST', 1393 headers: { 1394 'Content-Type': 'application/x-www-form-urlencoded', 1395 'Cache-Control': 'no-cache, no-store, must-revalidate', 1396 'Pragma': 'no-cache' 1397 }, 1398 body: params.toString() 1399 }) 1400 .then(r => r.json()) 1401 .then(data => { 1402 if (data.success) { 1403 const container = document.getElementById(calId); 1404 1405 // Check if this is a full calendar or just event panel 1406 if (container.classList.contains('calendar-compact-container')) { 1407 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 1408 } else if (container.classList.contains('event-panel-standalone')) { 1409 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1410 } 1411 } 1412 }) 1413 .catch(err => console.error('Error:', err)); 1414}; 1415 1416// Close event dialog 1417window.closeEventDialog = function(calId) { 1418 const dialog = document.getElementById('dialog-' + calId); 1419 dialog.style.display = 'none'; 1420}; 1421 1422// Escape HTML 1423window.escapeHtml = function(text) { 1424 const div = document.createElement('div'); 1425 div.textContent = text; 1426 return div.innerHTML; 1427}; 1428 1429// Highlight event when clicking on bar in calendar 1430window.highlightEvent = function(calId, eventId, date) { 1431 // Find the event item in the event list 1432 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 1433 if (!eventList) return; 1434 1435 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 1436 if (!eventItem) return; 1437 1438 // Remove previous highlights 1439 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 1440 previousHighlights.forEach(el => el.classList.remove('event-highlighted')); 1441 1442 // Add highlight 1443 eventItem.classList.add('event-highlighted'); 1444 1445 // Scroll to event 1446 eventItem.scrollIntoView({ 1447 behavior: 'smooth', 1448 block: 'nearest', 1449 inline: 'nearest' 1450 }); 1451 1452 // Remove highlight after 3 seconds 1453 setTimeout(() => { 1454 eventItem.classList.remove('event-highlighted'); 1455 }, 3000); 1456}; 1457 1458// Toggle recurring event options 1459window.toggleRecurringOptions = function(calId) { 1460 const checkbox = document.getElementById('event-recurring-' + calId); 1461 const options = document.getElementById('recurring-options-' + calId); 1462 1463 if (checkbox && options) { 1464 options.style.display = checkbox.checked ? 'block' : 'none'; 1465 } 1466}; 1467 1468// Close dialog on escape key 1469document.addEventListener('keydown', function(e) { 1470 if (e.key === 'Escape') { 1471 const dialogs = document.querySelectorAll('.event-dialog-compact'); 1472 dialogs.forEach(dialog => { 1473 if (dialog.style.display === 'flex') { 1474 dialog.style.display = 'none'; 1475 } 1476 }); 1477 } 1478}); 1479 1480// Event panel navigation 1481window.navEventPanel = function(calId, year, month, namespace) { 1482 const params = new URLSearchParams({ 1483 call: 'plugin_calendar', 1484 action: 'load_month', 1485 year: year, 1486 month: month, 1487 namespace: namespace, 1488 _: new Date().getTime() // Cache buster 1489 }); 1490 1491 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1492 method: 'POST', 1493 headers: { 1494 'Content-Type': 'application/x-www-form-urlencoded', 1495 'Cache-Control': 'no-cache, no-store, must-revalidate', 1496 'Pragma': 'no-cache' 1497 }, 1498 body: params.toString() 1499 }) 1500 .then(r => r.json()) 1501 .then(data => { 1502 if (data.success) { 1503 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1504 } 1505 }) 1506 .catch(err => console.error('Error:', err)); 1507}; 1508 1509// Rebuild event panel only 1510window.rebuildEventPanel = function(calId, year, month, events, namespace) { 1511 const container = document.getElementById(calId); 1512 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1513 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 1514 1515 // Update month title in new compact header 1516 const monthTitle = container.querySelector('.panel-month-title'); 1517 if (monthTitle) { 1518 monthTitle.textContent = monthNames[month - 1] + ' ' + year; 1519 monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1520 monthTitle.setAttribute('title', 'Click to jump to month'); 1521 } 1522 1523 // Fallback: Update old header format if exists 1524 const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker'); 1525 if (oldHeader && !monthTitle) { 1526 oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 1527 oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1528 } 1529 1530 // Update nav buttons 1531 let prevMonth = month - 1; 1532 let prevYear = year; 1533 if (prevMonth < 1) { 1534 prevMonth = 12; 1535 prevYear--; 1536 } 1537 1538 let nextMonth = month + 1; 1539 let nextYear = year; 1540 if (nextMonth > 12) { 1541 nextMonth = 1; 1542 nextYear++; 1543 } 1544 1545 // Update new compact nav buttons 1546 const navBtns = container.querySelectorAll('.panel-nav-btn'); 1547 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1548 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1549 1550 // Fallback for old nav buttons 1551 const oldNavBtns = container.querySelectorAll('.cal-nav-btn'); 1552 if (oldNavBtns.length > 0 && navBtns.length === 0) { 1553 if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1554 if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1555 } 1556 1557 // Update Today button (works for both old and new) 1558 const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact'); 1559 if (todayBtn) { 1560 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 1561 } 1562 1563 // Rebuild event list 1564 const eventList = container.querySelector('.event-list-compact'); 1565 if (eventList) { 1566 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 1567 } 1568}; 1569 1570// Open add event for panel 1571window.openAddEventPanel = function(calId, namespace) { 1572 const today = new Date(); 1573 const year = today.getFullYear(); 1574 const month = String(today.getMonth() + 1).padStart(2, '0'); 1575 const day = String(today.getDate()).padStart(2, '0'); 1576 const localDate = `${year}-${month}-${day}`; 1577 openAddEvent(calId, namespace, localDate); 1578}; 1579 1580// Toggle task completion 1581window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) { 1582 const params = new URLSearchParams({ 1583 call: 'plugin_calendar', 1584 action: 'toggle_task', 1585 namespace: namespace, 1586 date: date, 1587 eventId: eventId, 1588 completed: completed ? '1' : '0' 1589 }); 1590 1591 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1592 method: 'POST', 1593 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1594 body: params.toString() 1595 }) 1596 .then(r => r.json()) 1597 .then(data => { 1598 if (data.success) { 1599 const [year, month] = date.split('-').map(Number); 1600 reloadCalendarData(calId, year, month, namespace); 1601 } 1602 }) 1603 .catch(err => console.error('Error toggling task:', err)); 1604}; 1605 1606// Make dialog draggable 1607window.makeDialogDraggable = function(calId) { 1608 const dialog = document.getElementById('dialog-content-' + calId); 1609 const handle = document.getElementById('drag-handle-' + calId); 1610 1611 if (!dialog || !handle) return; 1612 1613 let isDragging = false; 1614 let currentX; 1615 let currentY; 1616 let initialX; 1617 let initialY; 1618 let xOffset = 0; 1619 let yOffset = 0; 1620 1621 handle.addEventListener('mousedown', dragStart); 1622 document.addEventListener('mousemove', drag); 1623 document.addEventListener('mouseup', dragEnd); 1624 1625 function dragStart(e) { 1626 initialX = e.clientX - xOffset; 1627 initialY = e.clientY - yOffset; 1628 isDragging = true; 1629 } 1630 1631 function drag(e) { 1632 if (isDragging) { 1633 e.preventDefault(); 1634 currentX = e.clientX - initialX; 1635 currentY = e.clientY - initialY; 1636 xOffset = currentX; 1637 yOffset = currentY; 1638 setTranslate(currentX, currentY, dialog); 1639 } 1640 } 1641 1642 function dragEnd(e) { 1643 initialX = currentX; 1644 initialY = currentY; 1645 isDragging = false; 1646 } 1647 1648 function setTranslate(xPos, yPos, el) { 1649 el.style.transform = `translate(${xPos}px, ${yPos}px)`; 1650 } 1651}; 1652 1653// Initialize dialog draggability when opened (avoid duplicate declaration) 1654if (!window.calendarDraggabilityPatched) { 1655 window.calendarDraggabilityPatched = true; 1656 1657 const originalOpenAddEvent = openAddEvent; 1658 openAddEvent = function(calId, namespace, date) { 1659 originalOpenAddEvent(calId, namespace, date); 1660 setTimeout(() => makeDialogDraggable(calId), 100); 1661 }; 1662 1663 const originalEditEvent = editEvent; 1664 editEvent = function(calId, eventId, date, namespace) { 1665 originalEditEvent(calId, eventId, date, namespace); 1666 setTimeout(() => makeDialogDraggable(calId), 100); 1667 }; 1668} 1669 1670// Toggle expand/collapse for past events 1671window.togglePastEventExpand = function(element) { 1672 // Stop propagation to prevent any parent click handlers 1673 event.stopPropagation(); 1674 1675 const meta = element.querySelector(".event-meta-compact"); 1676 const desc = element.querySelector(".event-desc-compact"); 1677 1678 // Toggle visibility 1679 if (meta.style.display === "none") { 1680 // Expand 1681 meta.style.display = "block"; 1682 if (desc) desc.style.display = "block"; 1683 element.classList.add("event-past-expanded"); 1684 } else { 1685 // Collapse 1686 meta.style.display = "none"; 1687 if (desc) desc.style.display = "none"; 1688 element.classList.remove("event-past-expanded"); 1689 } 1690}; 1691 1692// Filter calendar by namespace when clicking namespace badge 1693document.addEventListener('click', function(e) { 1694 if (e.target.classList.contains('event-namespace-badge')) { 1695 const namespace = e.target.textContent; 1696 const eventItem = e.target.closest('.event-compact-item'); 1697 const eventList = e.target.closest('.event-list-compact'); 1698 const calendar = e.target.closest('.calendar-compact-container'); 1699 1700 if (!eventList || !calendar) return; 1701 1702 const calId = calendar.id; 1703 1704 // Check if already filtered 1705 const isFiltered = eventList.classList.contains('namespace-filtered'); 1706 1707 if (isFiltered && eventList.dataset.filterNamespace === namespace) { 1708 // Unfilter - show all 1709 eventList.classList.remove('namespace-filtered'); 1710 delete eventList.dataset.filterNamespace; 1711 delete calendar.dataset.filteredNamespace; 1712 eventList.querySelectorAll('.event-compact-item').forEach(item => { 1713 item.style.display = ''; 1714 }); 1715 1716 // Update header to show "all namespaces" 1717 updateFilteredNamespaceDisplay(calId, null); 1718 } else { 1719 // Filter by this namespace 1720 eventList.classList.add('namespace-filtered'); 1721 eventList.dataset.filterNamespace = namespace; 1722 calendar.dataset.filteredNamespace = namespace; 1723 eventList.querySelectorAll('.event-compact-item').forEach(item => { 1724 const itemBadge = item.querySelector('.event-namespace-badge'); 1725 if (itemBadge && itemBadge.textContent === namespace) { 1726 item.style.display = ''; 1727 } else { 1728 item.style.display = 'none'; 1729 } 1730 }); 1731 1732 // Update header to show filtered namespace 1733 updateFilteredNamespaceDisplay(calId, namespace); 1734 } 1735 } 1736}); 1737 1738// Update the displayed filtered namespace in event list header 1739window.updateFilteredNamespaceDisplay = function(calId, namespace) { 1740 const calendar = document.getElementById(calId); 1741 if (!calendar) return; 1742 1743 const headerContent = calendar.querySelector('.event-list-header-content'); 1744 if (!headerContent) return; 1745 1746 // Remove existing filter badge 1747 let filterBadge = headerContent.querySelector('.namespace-filter-badge'); 1748 if (filterBadge) { 1749 filterBadge.remove(); 1750 } 1751 1752 // Add new filter badge if filtering 1753 if (namespace) { 1754 filterBadge = document.createElement('span'); 1755 filterBadge.className = 'namespace-badge namespace-filter-badge'; 1756 filterBadge.innerHTML = escapeHtml(namespace) + ' <button class="filter-clear-inline" onclick="clearNamespaceFilter(\'' + calId + '\'); event.stopPropagation();">✕</button>'; 1757 headerContent.appendChild(filterBadge); 1758 } 1759}; 1760 1761// Clear namespace filter 1762window.clearNamespaceFilter = function(calId) { 1763 1764 const container = document.getElementById(calId); 1765 if (!container) { 1766 console.error('Calendar container not found:', calId); 1767 return; 1768 } 1769 1770 // Get current year and month 1771 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 1772 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 1773 1774 // Get original namespace (what the calendar was initialized with) 1775 const originalNamespace = container.dataset.originalNamespace || ''; 1776 1777 1778 // Reload calendar with original namespace 1779 navCalendar(calId, year, month, originalNamespace); 1780}; 1781 1782window.clearNamespaceFilterPanel = function(calId) { 1783 1784 const container = document.getElementById(calId); 1785 if (!container) { 1786 console.error('Event panel container not found:', calId); 1787 return; 1788 } 1789 1790 // Get current year and month from URL params or container 1791 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 1792 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 1793 1794 // Get original namespace (what the panel was initialized with) 1795 const originalNamespace = container.dataset.originalNamespace || ''; 1796 1797 1798 // Reload event panel with original namespace 1799 navEventPanel(calId, year, month, originalNamespace); 1800}; 1801 1802// Color picker functions 1803window.updateCustomColorPicker = function(calId) { 1804 const select = document.getElementById('event-color-' + calId); 1805 const picker = document.getElementById('event-color-custom-' + calId); 1806 1807 if (select.value === 'custom') { 1808 // Show color picker 1809 picker.style.display = 'inline-block'; 1810 picker.click(); // Open color picker 1811 } else { 1812 // Hide color picker and sync value 1813 picker.style.display = 'none'; 1814 picker.value = select.value; 1815 } 1816}; 1817 1818function updateColorFromPicker(calId) { 1819 const select = document.getElementById('event-color-' + calId); 1820 const picker = document.getElementById('event-color-custom-' + calId); 1821 1822 // Set select to custom and update its underlying value 1823 select.value = 'custom'; 1824 // Store the actual color value in a data attribute 1825 select.dataset.customColor = picker.value; 1826} 1827 1828// Toggle past events visibility 1829window.togglePastEvents = function(calId) { 1830 const content = document.getElementById('past-events-' + calId); 1831 const arrow = document.getElementById('past-arrow-' + calId); 1832 1833 if (!content || !arrow) { 1834 console.error('Past events elements not found for:', calId); 1835 return; 1836 } 1837 1838 // Check computed style instead of inline style 1839 const isHidden = window.getComputedStyle(content).display === 'none'; 1840 1841 if (isHidden) { 1842 content.style.display = 'block'; 1843 arrow.textContent = '▼'; 1844 } else { 1845 content.style.display = 'none'; 1846 arrow.textContent = '▶'; 1847 } 1848}; 1849 1850// Fuzzy match scoring function 1851window.fuzzyMatch = function(pattern, str) { 1852 pattern = pattern.toLowerCase(); 1853 str = str.toLowerCase(); 1854 1855 let patternIdx = 0; 1856 let score = 0; 1857 let consecutiveMatches = 0; 1858 1859 for (let i = 0; i < str.length; i++) { 1860 if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) { 1861 score += 1 + consecutiveMatches; 1862 consecutiveMatches++; 1863 patternIdx++; 1864 } else { 1865 consecutiveMatches = 0; 1866 } 1867 } 1868 1869 // Return null if not all characters matched 1870 if (patternIdx !== pattern.length) { 1871 return null; 1872 } 1873 1874 // Bonus for exact match 1875 if (str === pattern) { 1876 score += 100; 1877 } 1878 1879 // Bonus for starts with 1880 if (str.startsWith(pattern)) { 1881 score += 50; 1882 } 1883 1884 return score; 1885}; 1886 1887// Initialize namespace search for a calendar 1888window.initNamespaceSearch = function(calId) { 1889 const searchInput = document.getElementById('event-namespace-search-' + calId); 1890 const hiddenInput = document.getElementById('event-namespace-' + calId); 1891 const dropdown = document.getElementById('event-namespace-dropdown-' + calId); 1892 const dataElement = document.getElementById('namespaces-data-' + calId); 1893 1894 if (!searchInput || !hiddenInput || !dropdown || !dataElement) { 1895 return; // Elements not found 1896 } 1897 1898 let namespaces = []; 1899 try { 1900 namespaces = JSON.parse(dataElement.textContent); 1901 } catch (e) { 1902 console.error('Failed to parse namespaces data:', e); 1903 return; 1904 } 1905 1906 let selectedIndex = -1; 1907 1908 // Filter and show dropdown 1909 function filterNamespaces(query) { 1910 if (!query || query.trim() === '') { 1911 // Show all namespaces when empty 1912 hiddenInput.value = ''; 1913 const results = namespaces.slice(0, 20); // Limit to 20 1914 showDropdown(results); 1915 return; 1916 } 1917 1918 // Fuzzy match and score 1919 const matches = []; 1920 for (let i = 0; i < namespaces.length; i++) { 1921 const score = fuzzyMatch(query, namespaces[i]); 1922 if (score !== null) { 1923 matches.push({ namespace: namespaces[i], score: score }); 1924 } 1925 } 1926 1927 // Sort by score (descending) 1928 matches.sort((a, b) => b.score - a.score); 1929 1930 // Take top 20 results 1931 const results = matches.slice(0, 20).map(m => m.namespace); 1932 showDropdown(results); 1933 } 1934 1935 function showDropdown(results) { 1936 dropdown.innerHTML = ''; 1937 selectedIndex = -1; 1938 1939 if (results.length === 0) { 1940 dropdown.style.display = 'none'; 1941 return; 1942 } 1943 1944 // Add (default) option 1945 const defaultOption = document.createElement('div'); 1946 defaultOption.className = 'namespace-option'; 1947 defaultOption.textContent = '(default)'; 1948 defaultOption.dataset.value = ''; 1949 dropdown.appendChild(defaultOption); 1950 1951 results.forEach(ns => { 1952 const option = document.createElement('div'); 1953 option.className = 'namespace-option'; 1954 option.textContent = ns; 1955 option.dataset.value = ns; 1956 dropdown.appendChild(option); 1957 }); 1958 1959 dropdown.style.display = 'block'; 1960 } 1961 1962 function hideDropdown() { 1963 dropdown.style.display = 'none'; 1964 selectedIndex = -1; 1965 } 1966 1967 function selectOption(namespace) { 1968 hiddenInput.value = namespace; 1969 searchInput.value = namespace || '(default)'; 1970 hideDropdown(); 1971 } 1972 1973 // Event listeners 1974 searchInput.addEventListener('input', function(e) { 1975 filterNamespaces(e.target.value); 1976 }); 1977 1978 searchInput.addEventListener('focus', function(e) { 1979 filterNamespaces(e.target.value); 1980 }); 1981 1982 searchInput.addEventListener('blur', function(e) { 1983 // Delay to allow click on dropdown 1984 setTimeout(hideDropdown, 200); 1985 }); 1986 1987 searchInput.addEventListener('keydown', function(e) { 1988 const options = dropdown.querySelectorAll('.namespace-option'); 1989 1990 if (e.key === 'ArrowDown') { 1991 e.preventDefault(); 1992 selectedIndex = Math.min(selectedIndex + 1, options.length - 1); 1993 updateSelection(options); 1994 } else if (e.key === 'ArrowUp') { 1995 e.preventDefault(); 1996 selectedIndex = Math.max(selectedIndex - 1, -1); 1997 updateSelection(options); 1998 } else if (e.key === 'Enter') { 1999 e.preventDefault(); 2000 if (selectedIndex >= 0 && options[selectedIndex]) { 2001 selectOption(options[selectedIndex].dataset.value); 2002 } 2003 } else if (e.key === 'Escape') { 2004 hideDropdown(); 2005 } 2006 }); 2007 2008 function updateSelection(options) { 2009 options.forEach((opt, idx) => { 2010 if (idx === selectedIndex) { 2011 opt.classList.add('selected'); 2012 opt.scrollIntoView({ block: 'nearest' }); 2013 } else { 2014 opt.classList.remove('selected'); 2015 } 2016 }); 2017 } 2018 2019 // Click on dropdown option 2020 dropdown.addEventListener('mousedown', function(e) { 2021 if (e.target.classList.contains('namespace-option')) { 2022 selectOption(e.target.dataset.value); 2023 } 2024 }); 2025}; 2026 2027// Update end time options based on start time selection 2028window.updateEndTimeOptions = function(calId) { 2029 const startTimeSelect = document.getElementById('event-time-' + calId); 2030 const endTimeSelect = document.getElementById('event-end-time-' + calId); 2031 2032 if (!startTimeSelect || !endTimeSelect) return; 2033 2034 const startTime = startTimeSelect.value; 2035 2036 // If start time is empty (all day), disable end time 2037 if (!startTime) { 2038 endTimeSelect.disabled = true; 2039 endTimeSelect.value = ''; 2040 return; 2041 } 2042 2043 // Enable end time select 2044 endTimeSelect.disabled = false; 2045 2046 // Convert start time to minutes 2047 const startMinutes = timeToMinutes(startTime); 2048 2049 // Get current end time value (to preserve if valid) 2050 const currentEndTime = endTimeSelect.value; 2051 const currentEndMinutes = currentEndTime ? timeToMinutes(currentEndTime) : 0; 2052 2053 // Filter options - show only times after start time 2054 const options = endTimeSelect.options; 2055 let firstValidOption = null; 2056 let currentStillValid = false; 2057 2058 for (let i = 0; i < options.length; i++) { 2059 const option = options[i]; 2060 const optionValue = option.value; 2061 2062 if (optionValue === '') { 2063 // Keep "Same as start" option visible 2064 option.style.display = ''; 2065 continue; 2066 } 2067 2068 const optionMinutes = timeToMinutes(optionValue); 2069 2070 if (optionMinutes > startMinutes) { 2071 // Show options after start time 2072 option.style.display = ''; 2073 if (!firstValidOption) { 2074 firstValidOption = optionValue; 2075 } 2076 if (optionValue === currentEndTime) { 2077 currentStillValid = true; 2078 } 2079 } else { 2080 // Hide options before or equal to start time 2081 option.style.display = 'none'; 2082 } 2083 } 2084 2085 // If current end time is now invalid, set a new one 2086 if (!currentStillValid || currentEndMinutes <= startMinutes) { 2087 // Try to set to 1 hour after start 2088 const [startHour, startMinute] = startTime.split(':').map(Number); 2089 let endHour = startHour + 1; 2090 let endMinute = startMinute; 2091 2092 if (endHour >= 24) { 2093 endHour = 23; 2094 endMinute = 45; 2095 } 2096 2097 const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0'); 2098 2099 // Check if suggested time is in the list 2100 const suggestedExists = Array.from(options).some(opt => opt.value === suggestedEndTime); 2101 2102 if (suggestedExists) { 2103 endTimeSelect.value = suggestedEndTime; 2104 } else if (firstValidOption) { 2105 // Use first valid option 2106 endTimeSelect.value = firstValidOption; 2107 } else { 2108 // No valid options (shouldn't happen, but just in case) 2109 endTimeSelect.value = ''; 2110 } 2111 } 2112}; 2113 2114// Check for time conflicts between events on the same date 2115window.checkTimeConflicts = function(events, currentEventId) { 2116 const conflicts = []; 2117 2118 // Group events by date 2119 const eventsByDate = {}; 2120 for (const [date, dateEvents] of Object.entries(events)) { 2121 if (!Array.isArray(dateEvents)) continue; 2122 2123 dateEvents.forEach(evt => { 2124 if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event 2125 2126 if (!eventsByDate[date]) eventsByDate[date] = []; 2127 eventsByDate[date].push(evt); 2128 }); 2129 } 2130 2131 // Check for overlaps on each date 2132 for (const [date, dateEvents] of Object.entries(eventsByDate)) { 2133 for (let i = 0; i < dateEvents.length; i++) { 2134 for (let j = i + 1; j < dateEvents.length; j++) { 2135 const evt1 = dateEvents[i]; 2136 const evt2 = dateEvents[j]; 2137 2138 if (eventsOverlap(evt1, evt2)) { 2139 // Mark both events as conflicting 2140 if (!evt1.hasConflict) evt1.hasConflict = true; 2141 if (!evt2.hasConflict) evt2.hasConflict = true; 2142 2143 // Store conflict info 2144 if (!evt1.conflictsWith) evt1.conflictsWith = []; 2145 if (!evt2.conflictsWith) evt2.conflictsWith = []; 2146 2147 evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime}); 2148 evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime}); 2149 } 2150 } 2151 } 2152 } 2153 2154 return events; 2155}; 2156 2157// Check if two events overlap in time 2158function eventsOverlap(evt1, evt2) { 2159 if (!evt1.time || !evt2.time) return false; // All-day events don't conflict 2160 2161 const start1 = evt1.time; 2162 const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start 2163 2164 const start2 = evt2.time; 2165 const end2 = evt2.endTime || evt2.time; 2166 2167 // Convert to minutes for easier comparison 2168 const start1Mins = timeToMinutes(start1); 2169 const end1Mins = timeToMinutes(end1); 2170 const start2Mins = timeToMinutes(start2); 2171 const end2Mins = timeToMinutes(end2); 2172 2173 // Check for overlap 2174 // Events overlap if: start1 < end2 AND start2 < end1 2175 return start1Mins < end2Mins && start2Mins < end1Mins; 2176} 2177 2178// Convert HH:MM time to minutes since midnight 2179function timeToMinutes(timeStr) { 2180 const [hours, minutes] = timeStr.split(':').map(Number); 2181 return hours * 60 + minutes; 2182} 2183 2184// Format time range for display 2185window.formatTimeRange = function(startTime, endTime) { 2186 if (!startTime) return ''; 2187 2188 const formatTime = (timeStr) => { 2189 const [hour24, minute] = timeStr.split(':').map(Number); 2190 const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); 2191 const ampm = hour24 < 12 ? 'AM' : 'PM'; 2192 return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2193 }; 2194 2195 if (!endTime || endTime === startTime) { 2196 return formatTime(startTime); 2197 } 2198 2199 return formatTime(startTime) + ' - ' + formatTime(endTime); 2200}; 2201 2202// Show custom conflict tooltip 2203window.showConflictTooltip = function(badgeElement) { 2204 // Remove any existing tooltip 2205 hideConflictTooltip(); 2206 2207 // Get conflict data 2208 const conflictsJson = badgeElement.getAttribute('data-conflicts'); 2209 if (!conflictsJson) return; 2210 2211 let conflicts; 2212 try { 2213 conflicts = JSON.parse(conflictsJson); 2214 } catch (e) { 2215 console.error('Failed to parse conflicts:', e); 2216 return; 2217 } 2218 2219 // Create tooltip 2220 const tooltip = document.createElement('div'); 2221 tooltip.id = 'conflict-tooltip'; 2222 tooltip.className = 'conflict-tooltip'; 2223 2224 // Build content 2225 let html = '<div class="conflict-tooltip-header">⚠️ Time Conflicts</div>'; 2226 html += '<div class="conflict-tooltip-body">'; 2227 conflicts.forEach(conflict => { 2228 html += '<div class="conflict-item">• ' + escapeHtml(conflict) + '</div>'; 2229 }); 2230 html += '</div>'; 2231 2232 tooltip.innerHTML = html; 2233 document.body.appendChild(tooltip); 2234 2235 // Position tooltip 2236 const rect = badgeElement.getBoundingClientRect(); 2237 const tooltipRect = tooltip.getBoundingClientRect(); 2238 2239 // Position above the badge, centered 2240 let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); 2241 let top = rect.top - tooltipRect.height - 8; 2242 2243 // Keep tooltip within viewport 2244 if (left < 10) left = 10; 2245 if (left + tooltipRect.width > window.innerWidth - 10) { 2246 left = window.innerWidth - tooltipRect.width - 10; 2247 } 2248 if (top < 10) { 2249 // If not enough room above, show below 2250 top = rect.bottom + 8; 2251 } 2252 2253 tooltip.style.left = left + 'px'; 2254 tooltip.style.top = top + 'px'; 2255 tooltip.style.opacity = '1'; 2256}; 2257 2258// Hide conflict tooltip 2259window.hideConflictTooltip = function() { 2260 const tooltip = document.getElementById('conflict-tooltip'); 2261 if (tooltip) { 2262 tooltip.remove(); 2263 } 2264}; 2265 2266// Filter events by search term 2267window.filterEvents = function(calId, searchTerm) { 2268 const eventList = document.getElementById('eventlist-' + calId); 2269 const searchClear = document.getElementById('search-clear-' + calId); 2270 2271 if (!eventList) return; 2272 2273 // Show/hide clear button 2274 if (searchClear) { 2275 searchClear.style.display = searchTerm ? 'block' : 'none'; 2276 } 2277 2278 searchTerm = searchTerm.toLowerCase().trim(); 2279 2280 // Get all event items 2281 const eventItems = eventList.querySelectorAll('.event-compact-item'); 2282 let visibleCount = 0; 2283 let hiddenPastCount = 0; 2284 2285 eventItems.forEach(item => { 2286 const title = item.querySelector('.event-title-compact'); 2287 const description = item.querySelector('.event-desc-compact'); 2288 const dateTime = item.querySelector('.event-date-time'); 2289 2290 // Build searchable text 2291 let searchableText = ''; 2292 if (title) searchableText += title.textContent.toLowerCase() + ' '; 2293 if (description) searchableText += description.textContent.toLowerCase() + ' '; 2294 if (dateTime) searchableText += dateTime.textContent.toLowerCase() + ' '; 2295 2296 // Check if matches search 2297 const matches = !searchTerm || searchableText.includes(searchTerm); 2298 2299 if (matches) { 2300 item.style.display = ''; 2301 visibleCount++; 2302 } else { 2303 item.style.display = 'none'; 2304 // Check if this is a past event 2305 if (item.classList.contains('event-past') || item.classList.contains('event-completed')) { 2306 hiddenPastCount++; 2307 } 2308 } 2309 }); 2310 2311 // Update past events toggle if it exists 2312 const pastToggle = eventList.querySelector('.past-events-toggle'); 2313 const pastLabel = eventList.querySelector('.past-events-label'); 2314 const pastContent = document.getElementById('past-events-' + calId); 2315 2316 if (pastToggle && pastLabel && pastContent) { 2317 const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])'); 2318 const totalPastVisible = visiblePastEvents.length; 2319 2320 if (totalPastVisible > 0) { 2321 pastLabel.textContent = `Past Events (${totalPastVisible})`; 2322 pastToggle.style.display = ''; 2323 } else { 2324 pastToggle.style.display = 'none'; 2325 } 2326 } 2327 2328 // Show "no results" message if nothing visible 2329 let noResultsMsg = eventList.querySelector('.no-search-results'); 2330 if (visibleCount === 0 && searchTerm) { 2331 if (!noResultsMsg) { 2332 noResultsMsg = document.createElement('p'); 2333 noResultsMsg.className = 'no-search-results no-events-msg'; 2334 noResultsMsg.textContent = 'No events match your search'; 2335 eventList.appendChild(noResultsMsg); 2336 } 2337 noResultsMsg.style.display = 'block'; 2338 } else if (noResultsMsg) { 2339 noResultsMsg.style.display = 'none'; 2340 } 2341}; 2342 2343// Clear event search 2344window.clearEventSearch = function(calId) { 2345 const searchInput = document.getElementById('event-search-' + calId); 2346 if (searchInput) { 2347 searchInput.value = ''; 2348 filterEvents(calId, ''); 2349 searchInput.focus(); 2350 } 2351}; 2352 2353// End of calendar plugin JavaScript 2354