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/** 37 * Get DokuWiki security token from multiple possible sources 38 * DokuWiki stores this in different places depending on version/config 39 */ 40function getSecurityToken() { 41 // Try JSINFO.sectok (standard location) 42 if (typeof JSINFO !== 'undefined' && JSINFO.sectok) { 43 return JSINFO.sectok; 44 } 45 // Try window.JSINFO 46 if (typeof window.JSINFO !== 'undefined' && window.JSINFO.sectok) { 47 return window.JSINFO.sectok; 48 } 49 // Try finding it in a hidden form field (some templates/plugins add this) 50 var sectokInput = document.querySelector('input[name="sectok"]'); 51 if (sectokInput && sectokInput.value) { 52 return sectokInput.value; 53 } 54 // Try meta tag (some DokuWiki setups) 55 var sectokMeta = document.querySelector('meta[name="sectok"]'); 56 if (sectokMeta && sectokMeta.content) { 57 return sectokMeta.content; 58 } 59 // Return empty string if not found 60 console.warn('Calendar plugin: Security token not found'); 61 return ''; 62} 63 64// Helper: propagate CSS variables from a calendar container to a target element 65// This is needed for dialogs/popups that use position:fixed (they inherit CSS vars 66// from DOM parents per spec, but some DokuWiki templates break this inheritance) 67function propagateThemeVars(calId, targetEl) { 68 if (!targetEl) return; 69 // Find the calendar container (could be cal_, panel_, sidebar-widget-, etc.) 70 const container = document.getElementById(calId) 71 || document.getElementById('sidebar-widget-' + calId) 72 || document.querySelector('[id$="' + calId + '"]'); 73 if (!container) return; 74 const cs = getComputedStyle(container); 75 const vars = [ 76 '--background-site', '--background-alt', '--background-header', 77 '--text-primary', '--text-bright', '--text-dim', 78 '--border-color', '--border-main', 79 '--cell-bg', '--cell-today-bg', '--grid-bg', 80 '--shadow-color', '--header-border', '--header-shadow', 81 '--btn-text' 82 ]; 83 vars.forEach(v => { 84 const val = cs.getPropertyValue(v).trim(); 85 if (val) targetEl.style.setProperty(v, val); 86 }); 87} 88 89// Filter calendar by namespace 90window.filterCalendarByNamespace = function(calId, namespace) { 91 // Get current year and month from calendar 92 const container = document.getElementById(calId); 93 if (!container) { 94 console.error('Calendar container not found:', calId); 95 return; 96 } 97 98 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 99 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 100 101 // Reload calendar with the filtered namespace 102 navCalendar(calId, year, month, namespace); 103}; 104 105// Navigate to different month 106window.navCalendar = function(calId, year, month, namespace) { 107 108 const params = new URLSearchParams({ 109 call: 'plugin_calendar', 110 action: 'load_month', 111 year: year, 112 month: month, 113 namespace: namespace, 114 _: new Date().getTime() // Cache buster 115 }); 116 117 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 118 method: 'POST', 119 headers: { 120 'Content-Type': 'application/x-www-form-urlencoded', 121 'Cache-Control': 'no-cache, no-store, must-revalidate', 122 'Pragma': 'no-cache' 123 }, 124 body: params.toString() 125 }) 126 .then(r => r.json()) 127 .then(data => { 128 if (data.success) { 129 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 130 } else { 131 console.error('Failed to load month:', data.error); 132 } 133 }) 134 .catch(err => { 135 console.error('Error loading month:', err); 136 }); 137}; 138 139// Jump to current month 140window.jumpToToday = function(calId, namespace) { 141 const today = new Date(); 142 const year = today.getFullYear(); 143 const month = today.getMonth() + 1; // JavaScript months are 0-indexed 144 navCalendar(calId, year, month, namespace); 145}; 146 147// Jump to today for event panel 148window.jumpTodayPanel = function(calId, namespace) { 149 const today = new Date(); 150 const year = today.getFullYear(); 151 const month = today.getMonth() + 1; 152 navEventPanel(calId, year, month, namespace); 153}; 154 155// Open month picker dialog 156window.openMonthPicker = function(calId, currentYear, currentMonth, namespace) { 157 158 const overlay = document.getElementById('month-picker-overlay-' + calId); 159 160 const monthSelect = document.getElementById('month-picker-month-' + calId); 161 162 const yearSelect = document.getElementById('month-picker-year-' + calId); 163 164 if (!overlay) { 165 console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId); 166 return; 167 } 168 169 if (!monthSelect || !yearSelect) { 170 console.error('Select elements not found!'); 171 return; 172 } 173 174 // Set current values 175 monthSelect.value = currentMonth; 176 yearSelect.value = currentYear; 177 178 // Show overlay 179 overlay.style.display = 'flex'; 180}; 181 182// Open month picker dialog for event panel 183window.openMonthPickerPanel = function(calId, currentYear, currentMonth, namespace) { 184 openMonthPicker(calId, currentYear, currentMonth, namespace); 185}; 186 187// Close month picker dialog 188window.closeMonthPicker = function(calId) { 189 const overlay = document.getElementById('month-picker-overlay-' + calId); 190 overlay.style.display = 'none'; 191}; 192 193// Jump to selected month 194window.jumpToSelectedMonth = function(calId, namespace) { 195 const monthSelect = document.getElementById('month-picker-month-' + calId); 196 const yearSelect = document.getElementById('month-picker-year-' + calId); 197 198 const month = parseInt(monthSelect.value); 199 const year = parseInt(yearSelect.value); 200 201 closeMonthPicker(calId); 202 203 // Check if this is a calendar or event panel 204 const container = document.getElementById(calId); 205 if (container && container.classList.contains('event-panel-standalone')) { 206 navEventPanel(calId, year, month, namespace); 207 } else { 208 navCalendar(calId, year, month, namespace); 209 } 210}; 211 212// Rebuild calendar grid after navigation 213window.rebuildCalendar = function(calId, year, month, events, namespace) { 214 215 const container = document.getElementById(calId); 216 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 217 'July', 'August', 'September', 'October', 'November', 'December']; 218 219 // Get theme data from container 220 const theme = container.dataset.theme || 'matrix'; 221 let themeStyles = {}; 222 try { 223 themeStyles = JSON.parse(container.dataset.themeStyles || '{}'); 224 } catch (e) { 225 console.error('Failed to parse theme styles:', e); 226 themeStyles = {}; 227 } 228 229 // Preserve original namespace if not yet set 230 if (!container.dataset.originalNamespace) { 231 container.setAttribute('data-original-namespace', namespace || ''); 232 } 233 234 // Update container data attributes for current month/year 235 container.setAttribute('data-year', year); 236 container.setAttribute('data-month', month); 237 238 // Update embedded events data 239 let eventsDataEl = document.getElementById('events-data-' + calId); 240 if (eventsDataEl) { 241 eventsDataEl.textContent = JSON.stringify(events); 242 } else { 243 eventsDataEl = document.createElement('script'); 244 eventsDataEl.type = 'application/json'; 245 eventsDataEl.id = 'events-data-' + calId; 246 eventsDataEl.textContent = JSON.stringify(events); 247 container.appendChild(eventsDataEl); 248 } 249 250 // Update header 251 const header = container.querySelector('.calendar-compact-header h3'); 252 header.textContent = monthNames[month - 1] + ' ' + year; 253 254 // Update or create namespace filter indicator 255 let filterIndicator = container.querySelector('.calendar-namespace-filter'); 256 const shouldShowFilter = namespace && namespace !== '' && namespace !== '*' && 257 namespace.indexOf('*') === -1 && namespace.indexOf(';') === -1; 258 259 if (shouldShowFilter) { 260 // Show/update filter indicator 261 if (!filterIndicator) { 262 // Create filter indicator if it doesn't exist 263 const headerDiv = container.querySelector('.calendar-compact-header'); 264 if (headerDiv) { 265 filterIndicator = document.createElement('div'); 266 filterIndicator.className = 'calendar-namespace-filter'; 267 filterIndicator.id = 'namespace-filter-' + calId; 268 headerDiv.parentNode.insertBefore(filterIndicator, headerDiv.nextSibling); 269 } 270 } 271 272 if (filterIndicator) { 273 filterIndicator.innerHTML = 274 '<span class="namespace-filter-label">Filtering:</span>' + 275 '<span class="namespace-filter-name">' + escapeHtml(namespace) + '</span>' + 276 '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' + calId + '\')" title="Clear filter and show all namespaces">✕</button>'; 277 filterIndicator.style.display = 'flex'; 278 } 279 } else { 280 // Hide filter indicator 281 if (filterIndicator) { 282 filterIndicator.style.display = 'none'; 283 } 284 } 285 286 // Update container's namespace attribute 287 container.setAttribute('data-namespace', namespace || ''); 288 289 // Update nav buttons 290 let prevMonth = month - 1; 291 let prevYear = year; 292 if (prevMonth < 1) { 293 prevMonth = 12; 294 prevYear--; 295 } 296 297 let nextMonth = month + 1; 298 let nextYear = year; 299 if (nextMonth > 12) { 300 nextMonth = 1; 301 nextYear++; 302 } 303 304 const navBtns = container.querySelectorAll('.cal-nav-btn'); 305 navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 306 navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 307 308 // Rebuild calendar grid 309 const tbody = container.querySelector('.calendar-compact-grid tbody'); 310 const firstDay = new Date(year, month - 1, 1); 311 const daysInMonth = new Date(year, month, 0).getDate(); 312 const dayOfWeek = firstDay.getDay(); 313 314 // Calculate month boundaries 315 const monthStart = new Date(year, month - 1, 1); 316 const monthEnd = new Date(year, month - 1, daysInMonth); 317 318 // Build a map of all events with their date ranges 319 const eventRanges = {}; 320 for (const [dateKey, dayEvents] of Object.entries(events)) { 321 // Defensive check: ensure dayEvents is an array 322 if (!Array.isArray(dayEvents)) { 323 console.error('dayEvents is not an array for dateKey:', dateKey, 'value:', dayEvents); 324 continue; 325 } 326 327 // Only process events that could possibly overlap with this month/year 328 const dateYear = parseInt(dateKey.split('-')[0]); 329 330 // Skip events from completely different years (unless they're very long multi-day events) 331 if (Math.abs(dateYear - year) > 1) { 332 continue; 333 } 334 335 for (const evt of dayEvents) { 336 const startDate = dateKey; 337 const endDate = evt.endDate || dateKey; 338 339 // Check if event overlaps with current month 340 const eventStart = new Date(startDate + 'T00:00:00'); 341 const eventEnd = new Date(endDate + 'T00:00:00'); 342 343 // Skip if event doesn't overlap with current month 344 if (eventEnd < monthStart || eventStart > monthEnd) { 345 continue; 346 } 347 348 // Create entry for each day the event spans 349 const start = new Date(startDate + 'T00:00:00'); 350 const end = new Date(endDate + 'T00:00:00'); 351 const current = new Date(start); 352 353 while (current <= end) { 354 const currentKey = current.toISOString().split('T')[0]; 355 356 // Check if this date is in current month 357 const currentDate = new Date(currentKey + 'T00:00:00'); 358 if (currentDate.getFullYear() === year && currentDate.getMonth() === month - 1) { 359 if (!eventRanges[currentKey]) { 360 eventRanges[currentKey] = []; 361 } 362 363 // Add event with span information 364 const eventCopy = {...evt}; 365 eventCopy._span_start = startDate; 366 eventCopy._span_end = endDate; 367 eventCopy._is_first_day = (currentKey === startDate); 368 eventCopy._is_last_day = (currentKey === endDate); 369 eventCopy._original_date = dateKey; 370 371 // Check if event continues from previous month or to next month 372 eventCopy._continues_from_prev = (eventStart < monthStart); 373 eventCopy._continues_to_next = (eventEnd > monthEnd); 374 375 eventRanges[currentKey].push(eventCopy); 376 } 377 378 current.setDate(current.getDate() + 1); 379 } 380 } 381 } 382 383 let html = ''; 384 let currentDay = 1; 385 const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7); 386 387 for (let row = 0; row < rowCount; row++) { 388 html += '<tr>'; 389 for (let col = 0; col < 7; col++) { 390 if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) { 391 html += `<td class="cal-empty"></td>`; 392 } else { 393 const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`; 394 395 // Get today's date in local timezone 396 const todayObj = new Date(); 397 const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`; 398 399 const isToday = dateKey === today; 400 const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0; 401 402 let classes = 'cal-day'; 403 if (isToday) classes += ' cal-today'; 404 if (hasEvents) classes += ' cal-has-events'; 405 406 const dayNumClass = isToday ? 'day-num day-num-today' : 'day-num'; 407 408 html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`; 409 html += `<span class="${dayNumClass}">${currentDay}</span>`; 410 411 if (hasEvents) { 412 // Sort events by time (no time first, then by time) 413 const sortedEvents = [...eventRanges[dateKey]].sort((a, b) => { 414 const timeA = a.time || ''; 415 const timeB = b.time || ''; 416 if (!timeA && timeB) return -1; 417 if (timeA && !timeB) return 1; 418 if (!timeA && !timeB) return 0; 419 return timeA.localeCompare(timeB); 420 }); 421 422 // Get important namespaces 423 let importantNamespaces = ['important']; 424 if (container.dataset.importantNamespaces) { 425 try { 426 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 427 } catch (e) {} 428 } 429 430 // Show colored stacked bars for each event 431 html += '<div class="event-indicators">'; 432 for (const evt of sortedEvents) { 433 const eventId = evt.id || ''; 434 const eventColor = evt.color || '#3498db'; 435 const eventTitle = evt.title || 'Event'; 436 const eventTime = evt.time || ''; 437 const originalDate = evt._original_date || dateKey; 438 const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true; 439 const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true; 440 441 // Check if important namespace 442 let evtNs = evt.namespace || evt._namespace || ''; 443 let isImportant = false; 444 for (const impNs of importantNamespaces) { 445 if (evtNs === impNs || evtNs.startsWith(impNs + ':')) { 446 isImportant = true; 447 break; 448 } 449 } 450 451 let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed'; 452 if (!isFirstDay) barClass += ' event-bar-continues'; 453 if (!isLastDay) barClass += ' event-bar-continuing'; 454 if (isImportant) { 455 barClass += ' event-bar-important'; 456 if (isFirstDay) { 457 barClass += ' event-bar-has-star'; 458 } 459 } 460 461 html += `<span class="event-bar ${barClass}" `; 462 html += `style="background: ${eventColor};" `; 463 html += `title="${isImportant ? '⭐ ' : ''}${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `; 464 html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');">`; 465 html += '</span>'; 466 } 467 html += '</div>'; 468 } 469 470 html += '</td>'; 471 currentDay++; 472 } 473 } 474 html += '</tr>'; 475 } 476 477 tbody.innerHTML = html; 478 479 // Update Today button with current namespace 480 const todayBtn = container.querySelector('.cal-today-btn'); 481 if (todayBtn) { 482 todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`); 483 } 484 485 // Update month picker with current namespace 486 const monthPicker = container.querySelector('.calendar-month-picker'); 487 if (monthPicker) { 488 monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`); 489 } 490 491 // Rebuild event list - server already filtered to current month 492 const eventList = container.querySelector('.event-list-compact'); 493 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 494 495 // Auto-scroll to first future event (past events will be above viewport) 496 setTimeout(() => { 497 const firstFuture = eventList.querySelector('[data-first-future="true"]'); 498 if (firstFuture) { 499 firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' }); 500 } 501 }, 100); 502 503 // Update title 504 const title = container.querySelector('#eventlist-title-' + calId); 505 title.textContent = 'Events'; 506}; 507 508// Render event list from data 509window.renderEventListFromData = function(events, calId, namespace, year, month) { 510 if (!events || Object.keys(events).length === 0) { 511 return '<p class="no-events-msg">No events this month</p>'; 512 } 513 514 // Get theme data from container 515 const container = document.getElementById(calId); 516 let themeStyles = {}; 517 if (container && container.dataset.themeStyles) { 518 try { 519 themeStyles = JSON.parse(container.dataset.themeStyles); 520 } catch (e) { 521 console.error('Failed to parse theme styles in renderEventListFromData:', e); 522 } 523 } 524 525 // Check for time conflicts 526 events = checkTimeConflicts(events, null); 527 528 let pastHtml = ''; 529 let futureHtml = ''; 530 let pastCount = 0; 531 532 const sortedDates = Object.keys(events).sort(); 533 const today = new Date(); 534 today.setHours(0, 0, 0, 0); 535 const todayStr = today.toISOString().split('T')[0]; 536 537 // Helper function to check if event is past (with 15-minute grace period) 538 const isEventPast = function(dateKey, time) { 539 // If event is on a past date, it's definitely past 540 if (dateKey < todayStr) { 541 return true; 542 } 543 544 // If event is on a future date, it's definitely not past 545 if (dateKey > todayStr) { 546 return false; 547 } 548 549 // Event is today - check time with grace period 550 if (time && time.trim() !== '') { 551 try { 552 const now = new Date(); 553 const eventDateTime = new Date(dateKey + 'T' + time); 554 555 // Add 15-minute grace period 556 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 557 558 // Event is past if current time > event time + 15 minutes 559 return now > gracePeriodEnd; 560 } catch (e) { 561 // If time parsing fails, treat as future 562 return false; 563 } 564 } 565 566 // No time specified for today's event, treat as future 567 return false; 568 }; 569 570 // Filter events to only current month if year/month provided 571 const monthStart = year && month ? new Date(year, month - 1, 1) : null; 572 const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null; 573 574 for (const dateKey of sortedDates) { 575 // Skip events not in current month if filtering 576 if (monthStart && monthEnd) { 577 const eventDate = new Date(dateKey + 'T00:00:00'); 578 579 if (eventDate < monthStart || eventDate > monthEnd) { 580 continue; 581 } 582 } 583 584 // Sort events within this day by time (all-day events at top) 585 const dayEvents = events[dateKey]; 586 dayEvents.sort((a, b) => { 587 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 588 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 589 590 // All-day events (no time) go to the TOP 591 if (timeA === null && timeB !== null) return -1; // A before B 592 if (timeA !== null && timeB === null) return 1; // A after B 593 if (timeA === null && timeB === null) return 0; // Both all-day, equal 594 595 // Both have times, sort chronologically 596 return timeA.localeCompare(timeB); 597 }); 598 599 for (const event of dayEvents) { 600 const isTask = event.isTask || false; 601 const completed = event.completed || false; 602 603 // Use helper function to determine if event is past (with grace period) 604 const isPast = isEventPast(dateKey, event.time); 605 const isPastDue = isPast && isTask && !completed; 606 607 // Determine if this goes in past section 608 const isPastOrCompleted = (isPast && (!isTask || completed)) || completed; 609 610 const eventHtml = renderEventItem(event, dateKey, calId, namespace); 611 612 if (isPastOrCompleted) { 613 pastCount++; 614 pastHtml += eventHtml; 615 } else { 616 futureHtml += eventHtml; 617 } 618 } 619 } 620 621 let html = ''; 622 623 // Add collapsible past events section if any exist 624 if (pastCount > 0) { 625 html += '<div class="past-events-section">'; 626 html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">'; 627 html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> '; 628 html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>'; 629 html += '</div>'; 630 html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">'; 631 html += pastHtml; 632 html += '</div>'; 633 html += '</div>'; 634 } else { 635 } 636 637 // Add future events 638 html += futureHtml; 639 640 641 if (!html) { 642 return '<p class="no-events-msg">No events this month</p>'; 643 } 644 645 return html; 646}; 647 648// Show day popup with events when clicking a date 649window.showDayPopup = function(calId, date, namespace) { 650 // Get events for this calendar 651 const eventsDataEl = document.getElementById('events-data-' + calId); 652 let events = {}; 653 654 if (eventsDataEl) { 655 try { 656 events = JSON.parse(eventsDataEl.textContent); 657 } catch (e) { 658 console.error('Failed to parse events data:', e); 659 } 660 } 661 662 const dayEvents = events[date] || []; 663 664 // Check for conflicts on this day 665 const dayEventsObj = {[date]: dayEvents}; 666 const checkedEvents = checkTimeConflicts(dayEventsObj, null); 667 const dayEventsWithConflicts = checkedEvents[date] || dayEvents; 668 669 // Sort events: all-day at top, then chronological by time 670 dayEventsWithConflicts.sort((a, b) => { 671 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 672 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 673 674 // All-day events (no time) go to the TOP 675 if (timeA === null && timeB !== null) return -1; // A before B 676 if (timeA !== null && timeB === null) return 1; // A after B 677 if (timeA === null && timeB === null) return 0; // Both all-day, equal 678 679 // Both have times, sort chronologically 680 return timeA.localeCompare(timeB); 681 }); 682 683 const dateObj = new Date(date + 'T00:00:00'); 684 const displayDate = dateObj.toLocaleDateString('en-US', { 685 weekday: 'long', 686 month: 'long', 687 day: 'numeric', 688 year: 'numeric' 689 }); 690 691 // Create popup 692 let popup = document.getElementById('day-popup-' + calId); 693 if (!popup) { 694 popup = document.createElement('div'); 695 popup.id = 'day-popup-' + calId; 696 popup.className = 'day-popup'; 697 document.body.appendChild(popup); 698 } 699 700 // Get theme styles and important namespaces 701 const container = document.getElementById(calId); 702 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 703 const theme = container ? container.dataset.theme : 'matrix'; 704 705 // Get important namespaces 706 let importantNamespaces = ['important']; 707 if (container && container.dataset.importantNamespaces) { 708 try { 709 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 710 } catch (e) { 711 importantNamespaces = ['important']; 712 } 713 } 714 715 let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>'; 716 html += '<div class="day-popup-content">'; 717 html += '<div class="day-popup-header">'; 718 html += '<h4>' + displayDate + '</h4>'; 719 html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>'; 720 html += '</div>'; 721 722 html += '<div class="day-popup-body">'; 723 724 if (dayEventsWithConflicts.length === 0) { 725 html += '<p class="no-events-msg">No events on this day</p>'; 726 } else { 727 html += '<div class="popup-events-list">'; 728 dayEventsWithConflicts.forEach(event => { 729 const color = event.color || '#3498db'; 730 731 // Use individual event namespace if available (for multi-namespace support) 732 const eventNamespace = event._namespace !== undefined ? event._namespace : namespace; 733 734 // Check if this is an important namespace event 735 let isImportant = false; 736 if (eventNamespace) { 737 for (const impNs of importantNamespaces) { 738 if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) { 739 isImportant = true; 740 break; 741 } 742 } 743 } 744 745 // Check if this is a continuation (event started before this date) 746 const originalStartDate = event.originalStartDate || event._dateKey || date; 747 const isContinuation = originalStartDate < date; 748 749 // Convert to 12-hour format and handle time ranges 750 let displayTime = ''; 751 if (event.time) { 752 displayTime = formatTimeRange(event.time, event.endTime); 753 } 754 755 // Multi-day indicator 756 let multiDay = ''; 757 if (event.endDate && event.endDate !== date) { 758 const endObj = new Date(event.endDate + 'T00:00:00'); 759 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 760 month: 'short', 761 day: 'numeric' 762 }); 763 } 764 765 // Continuation message 766 if (isContinuation) { 767 const startObj = new Date(originalStartDate + 'T00:00:00'); 768 const startDisplay = startObj.toLocaleDateString('en-US', { 769 weekday: 'short', 770 month: 'short', 771 day: 'numeric' 772 }); 773 html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>'; 774 } 775 776 const importantClass = isImportant ? ' popup-event-important' : ''; 777 html += '<div class="popup-event-item' + importantClass + '">'; 778 html += '<div class="event-color-bar" style="background: ' + color + ';"></div>'; 779 html += '<div class="popup-event-content">'; 780 781 // Single line with title, time, date range, namespace, and actions 782 html += '<div class="popup-event-main-row">'; 783 html += '<div class="popup-event-info-inline">'; 784 785 // Add star for important events 786 if (isImportant) { 787 html += '<span class="popup-event-star">⭐</span>'; 788 } 789 790 html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>'; 791 if (displayTime) { 792 html += '<span class="popup-event-time"> ' + displayTime + '</span>'; 793 } 794 if (multiDay) { 795 html += '<span class="popup-event-multiday">' + multiDay + '</span>'; 796 } 797 if (eventNamespace) { 798 html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>'; 799 } 800 801 // Add conflict warning badge if event has conflicts 802 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 803 // Build conflict list for tooltip 804 let conflictList = []; 805 event.conflictsWith.forEach(conflict => { 806 let conflictText = conflict.title; 807 if (conflict.time) { 808 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 809 } 810 conflictList.push(conflictText); 811 }); 812 813 html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 814 } 815 816 html += '</div>'; 817 html += '<div class="popup-event-actions">'; 818 html += '<button type="button" class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>'; 819 html += '<button type="button" class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">️</button>'; 820 html += '</div>'; 821 html += '</div>'; 822 823 // Description on separate line if present 824 if (event.description) { 825 html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>'; 826 } 827 828 html += '</div></div>'; 829 }); 830 html += '</div>'; 831 } 832 833 html += '</div>'; 834 835 html += '<div class="day-popup-footer">'; 836 html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>'; 837 html += '</div>'; 838 839 html += '</div>'; 840 841 popup.innerHTML = html; 842 popup.style.display = 'flex'; 843 844 // Propagate CSS vars from calendar container to popup (popup is outside container in DOM) 845 if (container) { 846 propagateThemeVars(calId, popup.querySelector('.day-popup-content')); 847 } 848 849 // Make popup draggable by header 850 const popupContent = popup.querySelector('.day-popup-content'); 851 const popupHeader = popup.querySelector('.day-popup-header'); 852 853 if (popupContent && popupHeader) { 854 // Reset position to center 855 popupContent.style.position = 'relative'; 856 popupContent.style.left = '0'; 857 popupContent.style.top = '0'; 858 859 // Store drag state on the element itself 860 popupHeader._isDragging = false; 861 862 popupHeader.onmousedown = function(e) { 863 // Ignore if clicking the close button 864 if (e.target.classList.contains('popup-close')) return; 865 866 popupHeader._isDragging = true; 867 popupHeader._dragStartX = e.clientX; 868 popupHeader._dragStartY = e.clientY; 869 870 const rect = popupContent.getBoundingClientRect(); 871 const parentRect = popup.getBoundingClientRect(); 872 popupHeader._initialLeft = rect.left - parentRect.left - (parentRect.width / 2 - rect.width / 2); 873 popupHeader._initialTop = rect.top - parentRect.top - (parentRect.height / 2 - rect.height / 2); 874 875 popupContent.style.transition = 'none'; 876 e.preventDefault(); 877 }; 878 879 popup.onmousemove = function(e) { 880 if (!popupHeader._isDragging) return; 881 882 const deltaX = e.clientX - popupHeader._dragStartX; 883 const deltaY = e.clientY - popupHeader._dragStartY; 884 885 popupContent.style.left = (popupHeader._initialLeft + deltaX) + 'px'; 886 popupContent.style.top = (popupHeader._initialTop + deltaY) + 'px'; 887 }; 888 889 popup.onmouseup = function() { 890 if (popupHeader._isDragging) { 891 popupHeader._isDragging = false; 892 popupContent.style.transition = ''; 893 } 894 }; 895 896 popup.onmouseleave = function() { 897 if (popupHeader._isDragging) { 898 popupHeader._isDragging = false; 899 popupContent.style.transition = ''; 900 } 901 }; 902 } 903}; 904 905// Close day popup 906window.closeDayPopup = function(calId) { 907 const popup = document.getElementById('day-popup-' + calId); 908 if (popup) { 909 popup.style.display = 'none'; 910 } 911}; 912 913// Show events for a specific day (for event list panel) 914window.showDayEvents = function(calId, date, namespace) { 915 const params = new URLSearchParams({ 916 call: 'plugin_calendar', 917 action: 'load_month', 918 year: date.split('-')[0], 919 month: parseInt(date.split('-')[1]), 920 namespace: namespace, 921 _: new Date().getTime() // Cache buster 922 }); 923 924 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 925 method: 'POST', 926 headers: { 927 'Content-Type': 'application/x-www-form-urlencoded', 928 'Cache-Control': 'no-cache, no-store, must-revalidate', 929 'Pragma': 'no-cache' 930 }, 931 body: params.toString() 932 }) 933 .then(r => r.json()) 934 .then(data => { 935 if (data.success) { 936 const eventList = document.getElementById('eventlist-' + calId); 937 const events = data.events; 938 const title = document.getElementById('eventlist-title-' + calId); 939 940 const dateObj = new Date(date + 'T00:00:00'); 941 const displayDate = dateObj.toLocaleDateString('en-US', { 942 weekday: 'short', 943 month: 'short', 944 day: 'numeric' 945 }); 946 947 title.textContent = 'Events - ' + displayDate; 948 949 // Filter events for this day 950 const dayEvents = events[date] || []; 951 952 if (dayEvents.length === 0) { 953 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>'; 954 } else { 955 let html = ''; 956 dayEvents.forEach(event => { 957 html += renderEventItem(event, date, calId, namespace); 958 }); 959 eventList.innerHTML = html; 960 } 961 } 962 }) 963 .catch(err => console.error('Error:', err)); 964}; 965 966// Render a single event item 967window.renderEventItem = function(event, date, calId, namespace) { 968 // Get theme data from container 969 const container = document.getElementById(calId); 970 let themeStyles = {}; 971 let importantNamespaces = ['important']; // default 972 if (container && container.dataset.themeStyles) { 973 try { 974 themeStyles = JSON.parse(container.dataset.themeStyles); 975 } catch (e) { 976 console.error('Failed to parse theme styles:', e); 977 } 978 } 979 // Get important namespaces from container data attribute 980 if (container && container.dataset.importantNamespaces) { 981 try { 982 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 983 } catch (e) { 984 importantNamespaces = ['important']; 985 } 986 } 987 988 // Check if this event is in the past or today (with 15-minute grace period) 989 const today = new Date(); 990 today.setHours(0, 0, 0, 0); 991 const todayStr = today.toISOString().split('T')[0]; 992 const eventDate = new Date(date + 'T00:00:00'); 993 994 // Helper to determine if event is past with grace period 995 let isPast; 996 if (date < todayStr) { 997 isPast = true; // Past date 998 } else if (date > todayStr) { 999 isPast = false; // Future date 1000 } else { 1001 // Today - check time with grace period 1002 if (event.time && event.time.trim() !== '') { 1003 try { 1004 const now = new Date(); 1005 const eventDateTime = new Date(date + 'T' + event.time); 1006 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 1007 isPast = now > gracePeriodEnd; 1008 } catch (e) { 1009 isPast = false; 1010 } 1011 } else { 1012 isPast = false; // No time, treat as future 1013 } 1014 } 1015 1016 const isToday = eventDate.getTime() === today.getTime(); 1017 1018 // Check if this is an important namespace event 1019 let eventNamespace = event.namespace || ''; 1020 if (!eventNamespace && event._namespace !== undefined) { 1021 eventNamespace = event._namespace; 1022 } 1023 let isImportantNs = false; 1024 if (eventNamespace) { 1025 for (const impNs of importantNamespaces) { 1026 if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) { 1027 isImportantNs = true; 1028 break; 1029 } 1030 } 1031 } 1032 1033 // Format date display with day of week 1034 const displayDateKey = event.originalStartDate || date; 1035 const dateObj = new Date(displayDateKey + 'T00:00:00'); 1036 const displayDate = dateObj.toLocaleDateString('en-US', { 1037 weekday: 'short', 1038 month: 'short', 1039 day: 'numeric' 1040 }); 1041 1042 // Convert to 12-hour format and handle time ranges 1043 let displayTime = ''; 1044 if (event.time) { 1045 displayTime = formatTimeRange(event.time, event.endTime); 1046 } 1047 1048 // Multi-day indicator 1049 let multiDay = ''; 1050 if (event.endDate && event.endDate !== displayDateKey) { 1051 const endObj = new Date(event.endDate + 'T00:00:00'); 1052 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 1053 weekday: 'short', 1054 month: 'short', 1055 day: 'numeric' 1056 }); 1057 } 1058 1059 const completedClass = event.completed ? ' event-completed' : ''; 1060 const isTask = event.isTask || false; 1061 const completed = event.completed || false; 1062 const isPastDue = isPast && isTask && !completed; 1063 const pastClass = (isPast && !isPastDue) ? ' event-past' : ''; 1064 const pastDueClass = isPastDue ? ' event-pastdue' : ''; 1065 const importantClass = isImportantNs ? ' event-important' : ''; 1066 const color = event.color || '#3498db'; 1067 1068 // Only inline style needed: border-left-color for event color indicator 1069 let html = '<div class="event-compact-item' + completedClass + pastClass + pastDueClass + importantClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ' !important;" onclick="' + (isPast && !isPastDue ? 'togglePastEventExpand(this)' : '') + '">'; 1070 1071 html += '<div class="event-info">'; 1072 html += '<div class="event-title-row">'; 1073 // Add star for important namespace events 1074 if (isImportantNs) { 1075 html += '<span class="event-important-star" title="Important">⭐</span> '; 1076 } 1077 html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>'; 1078 html += '</div>'; 1079 1080 // Show meta and description for non-past events AND past due tasks 1081 if (!isPast || isPastDue) { 1082 html += '<div class="event-meta-compact">'; 1083 html += '<span class="event-date-time">' + displayDate + multiDay; 1084 if (displayTime) { 1085 html += ' • ' + displayTime; 1086 } 1087 // Add PAST DUE or TODAY badge 1088 if (isPastDue) { 1089 html += ' <span class="event-pastdue-badge" style="background:var(--pastdue-color, #e74c3c) !important; color:white !important; -webkit-text-fill-color:white !important;">PAST DUE</span>'; 1090 } else if (isToday) { 1091 html += ' <span class="event-today-badge" style="background:var(--border-main, #9b59b6) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;">TODAY</span>'; 1092 } 1093 // Add namespace badge 1094 if (eventNamespace) { 1095 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 1096 } 1097 // Add conflict warning if event has time conflicts 1098 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1099 let conflictList = []; 1100 event.conflictsWith.forEach(conflict => { 1101 let conflictText = conflict.title; 1102 if (conflict.time) { 1103 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1104 } 1105 conflictList.push(conflictText); 1106 }); 1107 1108 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1109 } 1110 html += '</span>'; 1111 html += '</div>'; 1112 1113 if (event.description) { 1114 html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>'; 1115 } 1116 } else { 1117 // For past events (not past due), store data in hidden divs for expand/collapse 1118 html += '<div class="event-meta-compact" style="display: none;">'; 1119 html += '<span class="event-date-time">' + displayDate + multiDay; 1120 if (displayTime) { 1121 html += ' • ' + displayTime; 1122 } 1123 // Add namespace badge for past events too 1124 let eventNamespace = event.namespace || ''; 1125 if (!eventNamespace && event._namespace !== undefined) { 1126 eventNamespace = event._namespace; 1127 } 1128 if (eventNamespace) { 1129 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 1130 } 1131 // Add conflict warning for past events too 1132 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1133 let conflictList = []; 1134 event.conflictsWith.forEach(conflict => { 1135 let conflictText = conflict.title; 1136 if (conflict.time) { 1137 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1138 } 1139 conflictList.push(conflictText); 1140 }); 1141 1142 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1143 } 1144 html += '</span>'; 1145 html += '</div>'; 1146 1147 if (event.description) { 1148 html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>'; 1149 } 1150 } 1151 1152 html += '</div>'; // event-info 1153 1154 // Use stored namespace from event, fallback to _namespace, then passed namespace 1155 let buttonNamespace = event.namespace || ''; 1156 if (!buttonNamespace && event._namespace !== undefined) { 1157 buttonNamespace = event._namespace; 1158 } 1159 if (!buttonNamespace) { 1160 buttonNamespace = namespace; 1161 } 1162 1163 html += '<div class="event-actions-compact">'; 1164 html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">️</button>'; 1165 html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>'; 1166 html += '</div>'; 1167 1168 // Checkbox for tasks - ON THE FAR RIGHT 1169 if (isTask) { 1170 const checked = completed ? 'checked' : ''; 1171 html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">'; 1172 } 1173 1174 html += '</div>'; 1175 1176 return html; 1177}; 1178 1179// Render description with rich content support 1180window.renderDescription = function(description) { 1181 if (!description) return ''; 1182 1183 // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping) 1184 // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00 1185 1186 let rendered = description; 1187 const tokens = []; 1188 let tokenIndex = 0; 1189 1190 // Convert DokuWiki image syntax {{image.jpg}} to tokens 1191 rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) { 1192 imagePath = imagePath.trim(); 1193 alt = alt ? alt.trim() : ''; 1194 1195 let imageHtml; 1196 // Handle external URLs 1197 if (imagePath.match(/^https?:\/\//)) { 1198 imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1199 } else { 1200 // Handle internal DokuWiki images 1201 const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath); 1202 imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1203 } 1204 1205 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1206 tokens[tokenIndex] = imageHtml; 1207 tokenIndex++; 1208 return token; 1209 }); 1210 1211 // Convert DokuWiki link syntax [[link|text]] to tokens 1212 rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) { 1213 link = link.trim(); 1214 text = text ? text.trim() : link; 1215 1216 let linkHtml; 1217 // Handle external URLs 1218 if (link.match(/^https?:\/\//)) { 1219 linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1220 } else { 1221 // Handle internal DokuWiki links with section anchors 1222 const hashIndex = link.indexOf('#'); 1223 let pagePart = link; 1224 let sectionPart = ''; 1225 1226 if (hashIndex !== -1) { 1227 pagePart = link.substring(0, hashIndex); 1228 sectionPart = link.substring(hashIndex); // Includes the # 1229 } 1230 1231 const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart; 1232 linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>'; 1233 } 1234 1235 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1236 tokens[tokenIndex] = linkHtml; 1237 tokenIndex++; 1238 return token; 1239 }); 1240 1241 // Convert markdown-style links [text](url) to tokens 1242 rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { 1243 text = text.trim(); 1244 url = url.trim(); 1245 1246 let linkHtml; 1247 if (url.match(/^https?:\/\//)) { 1248 linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1249 } else { 1250 linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>'; 1251 } 1252 1253 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1254 tokens[tokenIndex] = linkHtml; 1255 tokenIndex++; 1256 return token; 1257 }); 1258 1259 // Convert plain URLs to tokens 1260 rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) { 1261 const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>'; 1262 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1263 tokens[tokenIndex] = linkHtml; 1264 tokenIndex++; 1265 return token; 1266 }); 1267 1268 // NOW escape the remaining text (tokens are protected with null bytes) 1269 rendered = escapeHtml(rendered); 1270 1271 // Convert newlines to <br> 1272 rendered = rendered.replace(/\n/g, '<br>'); 1273 1274 // DokuWiki text formatting (on escaped text) 1275 // Bold: **text** or __text__ 1276 rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 1277 rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>'); 1278 1279 // Italic: //text// 1280 rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>'); 1281 1282 // Strikethrough: <del>text</del> 1283 rendered = rendered.replace(/<del>(.+?)<\/del>/g, '<del>$1</del>'); 1284 1285 // Monospace: ''text'' 1286 rendered = rendered.replace(/''(.+?)''/g, '<code>$1</code>'); 1287 1288 // Subscript: <sub>text</sub> 1289 rendered = rendered.replace(/<sub>(.+?)<\/sub>/g, '<sub>$1</sub>'); 1290 1291 // Superscript: <sup>text</sup> 1292 rendered = rendered.replace(/<sup>(.+?)<\/sup>/g, '<sup>$1</sup>'); 1293 1294 // Restore tokens (replace with actual HTML) 1295 for (let i = 0; i < tokens.length; i++) { 1296 const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g'); 1297 rendered = rendered.replace(tokenPattern, tokens[i]); 1298 } 1299 1300 return rendered; 1301} 1302 1303// Open add event dialog 1304window.openAddEvent = function(calId, namespace, date) { 1305 const dialog = document.getElementById('dialog-' + calId); 1306 const form = document.getElementById('eventform-' + calId); 1307 const title = document.getElementById('dialog-title-' + calId); 1308 const dateField = document.getElementById('event-date-' + calId); 1309 1310 if (!dateField) { 1311 console.error('Date field not found! ID: event-date-' + calId); 1312 return; 1313 } 1314 1315 // Check if there's a filtered namespace active (only for regular calendars) 1316 const calendar = document.getElementById(calId); 1317 const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null; 1318 1319 // Use filtered namespace if available, otherwise use the passed namespace 1320 const effectiveNamespace = filteredNamespace || namespace; 1321 1322 1323 // Reset form 1324 form.reset(); 1325 document.getElementById('event-id-' + calId).value = ''; 1326 1327 // Store the effective namespace in a hidden field or data attribute 1328 form.dataset.effectiveNamespace = effectiveNamespace; 1329 1330 // Set namespace dropdown to effective namespace 1331 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1332 if (namespaceSelect) { 1333 if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) { 1334 // Set to specific namespace if not wildcard or multi-namespace 1335 namespaceSelect.value = effectiveNamespace; 1336 } else { 1337 // Default to empty (default namespace) for wildcard/multi views 1338 namespaceSelect.value = ''; 1339 } 1340 } 1341 1342 // Clear event namespace from previous edits 1343 delete form.dataset.eventNamespace; 1344 1345 // Set date - use local date, not UTC 1346 let defaultDate = date; 1347 if (!defaultDate) { 1348 // Get the currently displayed month from the calendar container 1349 const container = document.getElementById(calId); 1350 const displayedYear = parseInt(container.getAttribute('data-year')); 1351 const displayedMonth = parseInt(container.getAttribute('data-month')); 1352 1353 1354 if (displayedYear && displayedMonth) { 1355 // Use first day of the displayed month 1356 const year = displayedYear; 1357 const month = String(displayedMonth).padStart(2, '0'); 1358 defaultDate = `${year}-${month}-01`; 1359 } else { 1360 // Fallback to today if attributes not found 1361 const today = new Date(); 1362 const year = today.getFullYear(); 1363 const month = String(today.getMonth() + 1).padStart(2, '0'); 1364 const day = String(today.getDate()).padStart(2, '0'); 1365 defaultDate = `${year}-${month}-${day}`; 1366 } 1367 } 1368 dateField.value = defaultDate; 1369 dateField.removeAttribute('data-original-date'); 1370 1371 // Also set the end date field to the same default (user can change it) 1372 const endDateField = document.getElementById('event-end-date-' + calId); 1373 if (endDateField) { 1374 endDateField.value = ''; // Empty by default (single-day event) 1375 // Set min attribute to help the date picker open on the right month 1376 endDateField.setAttribute('min', defaultDate); 1377 } 1378 1379 // Set default color 1380 document.getElementById('event-color-' + calId).value = '#3498db'; 1381 1382 // Initialize end time dropdown (disabled by default since no start time set) 1383 const endTimeField = document.getElementById('event-end-time-' + calId); 1384 if (endTimeField) { 1385 endTimeField.disabled = true; 1386 endTimeField.value = ''; 1387 } 1388 1389 // Initialize namespace search 1390 initNamespaceSearch(calId); 1391 1392 // Set title 1393 title.textContent = 'Add Event'; 1394 1395 // Show dialog 1396 dialog.style.display = 'flex'; 1397 1398 // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates) 1399 propagateThemeVars(calId, dialog); 1400 1401 // Make dialog draggable 1402 setTimeout(() => makeDialogDraggable(calId), 50); 1403 1404 // Focus title field 1405 setTimeout(() => { 1406 const titleField = document.getElementById('event-title-' + calId); 1407 if (titleField) titleField.focus(); 1408 }, 100); 1409}; 1410 1411// Edit event 1412window.editEvent = function(calId, eventId, date, namespace) { 1413 const params = new URLSearchParams({ 1414 call: 'plugin_calendar', 1415 action: 'get_event', 1416 namespace: namespace, 1417 date: date, 1418 eventId: eventId 1419 }); 1420 1421 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1422 method: 'POST', 1423 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1424 body: params.toString() 1425 }) 1426 .then(r => r.json()) 1427 .then(data => { 1428 if (data.success && data.event) { 1429 const event = data.event; 1430 const dialog = document.getElementById('dialog-' + calId); 1431 const title = document.getElementById('dialog-title-' + calId); 1432 const dateField = document.getElementById('event-date-' + calId); 1433 const form = document.getElementById('eventform-' + calId); 1434 1435 if (!dateField) { 1436 console.error('Date field not found when editing!'); 1437 return; 1438 } 1439 1440 // Store the event's actual namespace for saving (important for namespace=* views) 1441 if (event.namespace !== undefined) { 1442 form.dataset.eventNamespace = event.namespace; 1443 } 1444 1445 // Populate form 1446 document.getElementById('event-id-' + calId).value = event.id; 1447 dateField.value = date; 1448 dateField.setAttribute('data-original-date', date); 1449 1450 const endDateField = document.getElementById('event-end-date-' + calId); 1451 endDateField.value = event.endDate || ''; 1452 // Set min attribute to help date picker open on the start date's month 1453 endDateField.setAttribute('min', date); 1454 1455 document.getElementById('event-title-' + calId).value = event.title; 1456 document.getElementById('event-time-' + calId).value = event.time || ''; 1457 document.getElementById('event-end-time-' + calId).value = event.endTime || ''; 1458 document.getElementById('event-color-' + calId).value = event.color || '#3498db'; 1459 document.getElementById('event-desc-' + calId).value = event.description || ''; 1460 document.getElementById('event-is-task-' + calId).checked = event.isTask || false; 1461 1462 // Update end time options based on start time 1463 if (event.time) { 1464 updateEndTimeOptions(calId); 1465 } 1466 1467 // Initialize namespace search 1468 initNamespaceSearch(calId); 1469 1470 // Set namespace fields if available 1471 const namespaceHidden = document.getElementById('event-namespace-' + calId); 1472 const namespaceSearch = document.getElementById('event-namespace-search-' + calId); 1473 if (namespaceHidden && event.namespace !== undefined) { 1474 // Set the hidden input (this is what gets submitted) 1475 namespaceHidden.value = event.namespace || ''; 1476 // Set the search input to display the namespace 1477 if (namespaceSearch) { 1478 namespaceSearch.value = event.namespace || '(default)'; 1479 } 1480 } else { 1481 // No namespace on event, set to default 1482 if (namespaceHidden) { 1483 namespaceHidden.value = ''; 1484 } 1485 if (namespaceSearch) { 1486 namespaceSearch.value = '(default)'; 1487 } 1488 } 1489 1490 title.textContent = 'Edit Event'; 1491 dialog.style.display = 'flex'; 1492 1493 // Propagate CSS vars to dialog 1494 propagateThemeVars(calId, dialog); 1495 1496 // Make dialog draggable 1497 setTimeout(() => makeDialogDraggable(calId), 50); 1498 } 1499 }) 1500 .catch(err => console.error('Error editing event:', err)); 1501}; 1502 1503// Delete event 1504window.deleteEvent = function(calId, eventId, date, namespace) { 1505 if (!confirm('Delete this event?')) return; 1506 1507 const params = new URLSearchParams({ 1508 call: 'plugin_calendar', 1509 action: 'delete_event', 1510 namespace: namespace, 1511 date: date, 1512 eventId: eventId, 1513 sectok: getSecurityToken() 1514 }); 1515 1516 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1517 method: 'POST', 1518 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1519 body: params.toString() 1520 }) 1521 .then(r => r.json()) 1522 .then(data => { 1523 if (data.success) { 1524 // Extract year and month from date 1525 const [year, month] = date.split('-').map(Number); 1526 1527 // Get the calendar's ORIGINAL namespace setting (not the deleted event's namespace) 1528 // This preserves wildcard/multi-namespace views 1529 const container = document.getElementById(calId); 1530 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1531 1532 // Reload calendar data via AJAX with the calendar's original namespace 1533 reloadCalendarData(calId, year, month, calendarNamespace); 1534 } 1535 }) 1536 .catch(err => console.error('Error:', err)); 1537}; 1538 1539// Save event (add or edit) 1540window.saveEventCompact = function(calId, namespace) { 1541 const form = document.getElementById('eventform-' + calId); 1542 1543 // Get namespace from dropdown - this is what the user selected 1544 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1545 const selectedNamespace = namespaceSelect ? namespaceSelect.value : ''; 1546 1547 // ALWAYS use what the user selected in the dropdown 1548 // This allows changing namespace when editing 1549 const finalNamespace = selectedNamespace; 1550 1551 const eventId = document.getElementById('event-id-' + calId).value; 1552 1553 // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event) 1554 const originalNamespace = form.dataset.eventNamespace; 1555 1556 1557 const dateInput = document.getElementById('event-date-' + calId); 1558 const date = dateInput.value; 1559 const oldDate = dateInput.getAttribute('data-original-date') || date; 1560 const endDate = document.getElementById('event-end-date-' + calId).value; 1561 const title = document.getElementById('event-title-' + calId).value; 1562 const time = document.getElementById('event-time-' + calId).value; 1563 const endTime = document.getElementById('event-end-time-' + calId).value; 1564 const colorSelect = document.getElementById('event-color-' + calId); 1565 let color = colorSelect.value; 1566 1567 // Handle custom color 1568 if (color === 'custom') { 1569 color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value; 1570 } 1571 1572 const description = document.getElementById('event-desc-' + calId).value; 1573 const isTask = document.getElementById('event-is-task-' + calId).checked; 1574 const completed = false; // New tasks are not completed 1575 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 1576 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 1577 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 1578 1579 // New recurrence options 1580 const recurrenceIntervalInput = document.getElementById('event-recurrence-interval-' + calId); 1581 const recurrenceInterval = recurrenceIntervalInput ? parseInt(recurrenceIntervalInput.value) || 1 : 1; 1582 1583 // Weekly: collect selected days 1584 let weekDays = []; 1585 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1586 if (weeklyOptions && recurrenceType === 'weekly') { 1587 const checkboxes = weeklyOptions.querySelectorAll('input[name="weekDays[]"]:checked'); 1588 weekDays = Array.from(checkboxes).map(cb => cb.value); 1589 } 1590 1591 // Monthly: collect day-of-month or ordinal weekday 1592 let monthDay = ''; 1593 let monthlyType = 'dayOfMonth'; 1594 let ordinalWeek = ''; 1595 let ordinalDay = ''; 1596 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1597 if (monthlyOptions && recurrenceType === 'monthly') { 1598 const monthlyTypeRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 1599 monthlyType = monthlyTypeRadio ? monthlyTypeRadio.value : 'dayOfMonth'; 1600 1601 if (monthlyType === 'dayOfMonth') { 1602 const monthDayInput = document.getElementById('event-month-day-' + calId); 1603 monthDay = monthDayInput ? monthDayInput.value : ''; 1604 } else { 1605 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 1606 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 1607 ordinalWeek = ordinalSelect ? ordinalSelect.value : '1'; 1608 ordinalDay = ordinalDaySelect ? ordinalDaySelect.value : '0'; 1609 } 1610 } 1611 1612 if (!title) { 1613 alert('Please enter a title'); 1614 return; 1615 } 1616 1617 if (!date) { 1618 alert('Please select a date'); 1619 return; 1620 } 1621 1622 const params = new URLSearchParams({ 1623 call: 'plugin_calendar', 1624 action: 'save_event', 1625 namespace: finalNamespace, 1626 eventId: eventId, 1627 date: date, 1628 oldDate: oldDate, 1629 endDate: endDate, 1630 title: title, 1631 time: time, 1632 endTime: endTime, 1633 color: color, 1634 description: description, 1635 isTask: isTask ? '1' : '0', 1636 completed: completed ? '1' : '0', 1637 isRecurring: isRecurring ? '1' : '0', 1638 recurrenceType: recurrenceType, 1639 recurrenceInterval: recurrenceInterval, 1640 recurrenceEnd: recurrenceEnd, 1641 weekDays: weekDays.join(','), 1642 monthlyType: monthlyType, 1643 monthDay: monthDay, 1644 ordinalWeek: ordinalWeek, 1645 ordinalDay: ordinalDay, 1646 sectok: getSecurityToken() 1647 }); 1648 1649 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1650 method: 'POST', 1651 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1652 body: params.toString() 1653 }) 1654 .then(r => r.json()) 1655 .then(data => { 1656 if (data.success) { 1657 closeEventDialog(calId); 1658 1659 // For recurring events, do a full page reload to show all occurrences 1660 if (isRecurring) { 1661 location.reload(); 1662 return; 1663 } 1664 1665 // Extract year and month from the NEW date (in case date was changed) 1666 const [year, month] = date.split('-').map(Number); 1667 1668 // Get the calendar's ORIGINAL namespace setting from the container 1669 // This preserves wildcard/multi-namespace views after editing 1670 const container = document.getElementById(calId); 1671 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1672 1673 // Reload calendar data via AJAX to the month of the event 1674 reloadCalendarData(calId, year, month, calendarNamespace); 1675 } else { 1676 alert('Error: ' + (data.error || 'Unknown error')); 1677 } 1678 }) 1679 .catch(err => { 1680 console.error('Error:', err); 1681 alert('Error saving event'); 1682 }); 1683}; 1684 1685// Reload calendar data without page refresh 1686window.reloadCalendarData = function(calId, year, month, namespace) { 1687 const params = new URLSearchParams({ 1688 call: 'plugin_calendar', 1689 action: 'load_month', 1690 year: year, 1691 month: month, 1692 namespace: namespace, 1693 _: new Date().getTime() // Cache buster 1694 }); 1695 1696 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1697 method: 'POST', 1698 headers: { 1699 'Content-Type': 'application/x-www-form-urlencoded', 1700 'Cache-Control': 'no-cache, no-store, must-revalidate', 1701 'Pragma': 'no-cache' 1702 }, 1703 body: params.toString() 1704 }) 1705 .then(r => r.json()) 1706 .then(data => { 1707 if (data.success) { 1708 const container = document.getElementById(calId); 1709 1710 // Check if this is a full calendar or just event panel 1711 if (container.classList.contains('calendar-compact-container')) { 1712 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 1713 } else if (container.classList.contains('event-panel-standalone')) { 1714 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1715 } 1716 } 1717 }) 1718 .catch(err => console.error('Error:', err)); 1719}; 1720 1721// Close event dialog 1722window.closeEventDialog = function(calId) { 1723 const dialog = document.getElementById('dialog-' + calId); 1724 dialog.style.display = 'none'; 1725}; 1726 1727// Escape HTML 1728window.escapeHtml = function(text) { 1729 const div = document.createElement('div'); 1730 div.textContent = text; 1731 return div.innerHTML; 1732}; 1733 1734// Highlight event when clicking on bar in calendar 1735window.highlightEvent = function(calId, eventId, date) { 1736 1737 // Find the event item in the event list 1738 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 1739 if (!eventList) { 1740 return; 1741 } 1742 1743 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 1744 if (!eventItem) { 1745 return; 1746 } 1747 1748 1749 // Get theme 1750 const container = document.getElementById(calId); 1751 const theme = container ? container.dataset.theme : 'matrix'; 1752 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 1753 1754 1755 // Theme-specific highlight colors 1756 let highlightBg, highlightShadow; 1757 if (theme === 'matrix') { 1758 highlightBg = '#1a3d1a'; // Darker green 1759 highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)'; 1760 } else if (theme === 'purple') { 1761 highlightBg = '#3d2b4d'; // Darker purple 1762 highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)'; 1763 } else if (theme === 'professional') { 1764 highlightBg = '#e3f2fd'; // Light blue 1765 highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)'; 1766 } else if (theme === 'pink') { 1767 highlightBg = '#3d2030'; // Darker pink 1768 highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)'; 1769 } else if (theme === 'wiki') { 1770 highlightBg = themeStyles.header_bg || '#e8e8e8'; // __background_alt__ 1771 highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)'; 1772 } 1773 1774 1775 // Store original styles 1776 const originalBg = eventItem.style.background; 1777 const originalShadow = eventItem.style.boxShadow; 1778 1779 // Remove previous highlights (restore their original styles) 1780 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 1781 previousHighlights.forEach(el => { 1782 el.classList.remove('event-highlighted'); 1783 }); 1784 1785 // Add highlight class and apply theme-aware glow 1786 eventItem.classList.add('event-highlighted'); 1787 1788 // Set CSS properties directly 1789 eventItem.style.setProperty('background', highlightBg, 'important'); 1790 eventItem.style.setProperty('box-shadow', highlightShadow, 'important'); 1791 eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important'); 1792 1793 1794 // Scroll to event 1795 eventItem.scrollIntoView({ 1796 behavior: 'smooth', 1797 block: 'nearest', 1798 inline: 'nearest' 1799 }); 1800 1801 // Remove highlight after 3 seconds and restore original styles 1802 setTimeout(() => { 1803 eventItem.classList.remove('event-highlighted'); 1804 eventItem.style.setProperty('background', originalBg); 1805 eventItem.style.setProperty('box-shadow', originalShadow); 1806 eventItem.style.setProperty('transition', ''); 1807 }, 3000); 1808}; 1809 1810// Toggle recurring event options 1811window.toggleRecurringOptions = function(calId) { 1812 const checkbox = document.getElementById('event-recurring-' + calId); 1813 const options = document.getElementById('recurring-options-' + calId); 1814 1815 if (checkbox && options) { 1816 options.style.display = checkbox.checked ? 'block' : 'none'; 1817 if (checkbox.checked) { 1818 // Initialize the sub-options based on current selection 1819 updateRecurrenceOptions(calId); 1820 } 1821 } 1822}; 1823 1824// Update visible recurrence options based on type (daily/weekly/monthly/yearly) 1825window.updateRecurrenceOptions = function(calId) { 1826 const typeSelect = document.getElementById('event-recurrence-type-' + calId); 1827 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1828 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1829 1830 if (!typeSelect) return; 1831 1832 const recurrenceType = typeSelect.value; 1833 1834 // Hide all conditional options first 1835 if (weeklyOptions) weeklyOptions.style.display = 'none'; 1836 if (monthlyOptions) monthlyOptions.style.display = 'none'; 1837 1838 // Show relevant options 1839 if (recurrenceType === 'weekly' && weeklyOptions) { 1840 weeklyOptions.style.display = 'block'; 1841 // Auto-select today's day of week if nothing selected 1842 const checkboxes = weeklyOptions.querySelectorAll('input[type="checkbox"]'); 1843 const anyChecked = Array.from(checkboxes).some(cb => cb.checked); 1844 if (!anyChecked) { 1845 const today = new Date().getDay(); 1846 const todayCheckbox = weeklyOptions.querySelector('input[value="' + today + '"]'); 1847 if (todayCheckbox) todayCheckbox.checked = true; 1848 } 1849 } else if (recurrenceType === 'monthly' && monthlyOptions) { 1850 monthlyOptions.style.display = 'block'; 1851 // Set default day to current day of month 1852 const monthDayInput = document.getElementById('event-month-day-' + calId); 1853 if (monthDayInput && !monthDayInput.dataset.userSet) { 1854 monthDayInput.value = new Date().getDate(); 1855 } 1856 } 1857}; 1858 1859// Toggle between day-of-month and ordinal weekday for monthly recurrence 1860window.updateMonthlyType = function(calId) { 1861 const dayOfMonthDiv = document.getElementById('monthly-day-' + calId); 1862 const ordinalDiv = document.getElementById('monthly-ordinal-' + calId); 1863 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1864 1865 if (!monthlyOptions) return; 1866 1867 const selectedRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 1868 if (!selectedRadio) return; 1869 1870 if (selectedRadio.value === 'dayOfMonth') { 1871 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'flex'; 1872 if (ordinalDiv) ordinalDiv.style.display = 'none'; 1873 } else { 1874 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'none'; 1875 if (ordinalDiv) ordinalDiv.style.display = 'block'; 1876 1877 // Set defaults based on current date 1878 const now = new Date(); 1879 const dayOfWeek = now.getDay(); 1880 const weekOfMonth = Math.ceil(now.getDate() / 7); 1881 1882 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 1883 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 1884 1885 if (ordinalSelect && !ordinalSelect.dataset.userSet) { 1886 ordinalSelect.value = weekOfMonth; 1887 } 1888 if (ordinalDaySelect && !ordinalDaySelect.dataset.userSet) { 1889 ordinalDaySelect.value = dayOfWeek; 1890 } 1891 } 1892}; 1893 1894// ============================================================ 1895// Document-level event delegation (guarded - only attach once) 1896// These use event delegation so they work for AJAX-rebuilt content. 1897// ============================================================ 1898if (!window._calendarDelegationInit) { 1899 window._calendarDelegationInit = true; 1900 1901 // ESC closes dialogs, popups, tooltips 1902 document.addEventListener('keydown', function(e) { 1903 if (e.key === 'Escape') { 1904 document.querySelectorAll('.event-dialog-compact').forEach(function(d) { 1905 if (d.style.display === 'flex') d.style.display = 'none'; 1906 }); 1907 document.querySelectorAll('.day-popup').forEach(function(p) { 1908 p.style.display = 'none'; 1909 }); 1910 hideConflictTooltip(); 1911 } 1912 }); 1913 1914 // Conflict tooltip delegation (capture phase for mouseenter/leave) 1915 document.addEventListener('mouseenter', function(e) { 1916 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1917 showConflictTooltip(e.target); 1918 } 1919 }, true); 1920 1921 document.addEventListener('mouseleave', function(e) { 1922 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1923 hideConflictTooltip(); 1924 } 1925 }, true); 1926} // end delegation guard 1927 1928// Event panel navigation 1929window.navEventPanel = function(calId, year, month, namespace) { 1930 const params = new URLSearchParams({ 1931 call: 'plugin_calendar', 1932 action: 'load_month', 1933 year: year, 1934 month: month, 1935 namespace: namespace, 1936 _: new Date().getTime() // Cache buster 1937 }); 1938 1939 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1940 method: 'POST', 1941 headers: { 1942 'Content-Type': 'application/x-www-form-urlencoded', 1943 'Cache-Control': 'no-cache, no-store, must-revalidate', 1944 'Pragma': 'no-cache' 1945 }, 1946 body: params.toString() 1947 }) 1948 .then(r => r.json()) 1949 .then(data => { 1950 if (data.success) { 1951 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1952 } 1953 }) 1954 .catch(err => console.error('Error:', err)); 1955}; 1956 1957// Rebuild event panel only 1958window.rebuildEventPanel = function(calId, year, month, events, namespace) { 1959 const container = document.getElementById(calId); 1960 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1961 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 1962 1963 // Update month title in new compact header 1964 const monthTitle = container.querySelector('.panel-month-title'); 1965 if (monthTitle) { 1966 monthTitle.textContent = monthNames[month - 1] + ' ' + year; 1967 monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1968 monthTitle.setAttribute('title', 'Click to jump to month'); 1969 } 1970 1971 // Fallback: Update old header format if exists 1972 const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker'); 1973 if (oldHeader && !monthTitle) { 1974 oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 1975 oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1976 } 1977 1978 // Update nav buttons 1979 let prevMonth = month - 1; 1980 let prevYear = year; 1981 if (prevMonth < 1) { 1982 prevMonth = 12; 1983 prevYear--; 1984 } 1985 1986 let nextMonth = month + 1; 1987 let nextYear = year; 1988 if (nextMonth > 12) { 1989 nextMonth = 1; 1990 nextYear++; 1991 } 1992 1993 // Update new compact nav buttons 1994 const navBtns = container.querySelectorAll('.panel-nav-btn'); 1995 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1996 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1997 1998 // Fallback for old nav buttons 1999 const oldNavBtns = container.querySelectorAll('.cal-nav-btn'); 2000 if (oldNavBtns.length > 0 && navBtns.length === 0) { 2001 if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 2002 if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 2003 } 2004 2005 // Update Today button (works for both old and new) 2006 const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact'); 2007 if (todayBtn) { 2008 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 2009 } 2010 2011 // Rebuild event list 2012 const eventList = container.querySelector('.event-list-compact'); 2013 if (eventList) { 2014 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 2015 } 2016}; 2017 2018// Open add event for panel 2019window.openAddEventPanel = function(calId, namespace) { 2020 const today = new Date(); 2021 const year = today.getFullYear(); 2022 const month = String(today.getMonth() + 1).padStart(2, '0'); 2023 const day = String(today.getDate()).padStart(2, '0'); 2024 const localDate = `${year}-${month}-${day}`; 2025 openAddEvent(calId, namespace, localDate); 2026}; 2027 2028// Toggle task completion 2029window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) { 2030 const params = new URLSearchParams({ 2031 call: 'plugin_calendar', 2032 action: 'toggle_task', 2033 namespace: namespace, 2034 date: date, 2035 eventId: eventId, 2036 completed: completed ? '1' : '0', 2037 sectok: getSecurityToken() 2038 }); 2039 2040 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 2041 method: 'POST', 2042 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 2043 body: params.toString() 2044 }) 2045 .then(r => r.json()) 2046 .then(data => { 2047 if (data.success) { 2048 const [year, month] = date.split('-').map(Number); 2049 2050 // Get the calendar's ORIGINAL namespace setting from the container 2051 const container = document.getElementById(calId); 2052 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 2053 2054 reloadCalendarData(calId, year, month, calendarNamespace); 2055 } 2056 }) 2057 .catch(err => console.error('Error toggling task:', err)); 2058}; 2059 2060// Make dialog draggable 2061window.makeDialogDraggable = function(calId) { 2062 const dialog = document.getElementById('dialog-content-' + calId); 2063 const handle = document.getElementById('drag-handle-' + calId); 2064 2065 if (!dialog || !handle) return; 2066 2067 // Remove any existing drag setup to prevent duplicate listeners 2068 if (handle._dragCleanup) { 2069 handle._dragCleanup(); 2070 } 2071 2072 // Reset position when dialog opens 2073 dialog.style.transform = ''; 2074 2075 let isDragging = false; 2076 let currentX = 0; 2077 let currentY = 0; 2078 let initialX; 2079 let initialY; 2080 let xOffset = 0; 2081 let yOffset = 0; 2082 2083 function dragStart(e) { 2084 // Only start drag if clicking on the handle itself, not buttons inside it 2085 if (e.target.tagName === 'BUTTON') return; 2086 2087 initialX = e.clientX - xOffset; 2088 initialY = e.clientY - yOffset; 2089 isDragging = true; 2090 handle.style.cursor = 'grabbing'; 2091 } 2092 2093 function drag(e) { 2094 if (isDragging) { 2095 e.preventDefault(); 2096 currentX = e.clientX - initialX; 2097 currentY = e.clientY - initialY; 2098 xOffset = currentX; 2099 yOffset = currentY; 2100 dialog.style.transform = `translate(${currentX}px, ${currentY}px)`; 2101 } 2102 } 2103 2104 function dragEnd(e) { 2105 if (isDragging) { 2106 initialX = currentX; 2107 initialY = currentY; 2108 isDragging = false; 2109 handle.style.cursor = 'move'; 2110 } 2111 } 2112 2113 // Add listeners 2114 handle.addEventListener('mousedown', dragStart); 2115 document.addEventListener('mousemove', drag); 2116 document.addEventListener('mouseup', dragEnd); 2117 2118 // Store cleanup function to remove listeners later 2119 handle._dragCleanup = function() { 2120 handle.removeEventListener('mousedown', dragStart); 2121 document.removeEventListener('mousemove', drag); 2122 document.removeEventListener('mouseup', dragEnd); 2123 }; 2124}; 2125 2126// Toggle expand/collapse for past events 2127window.togglePastEventExpand = function(element) { 2128 // Stop propagation to prevent any parent click handlers 2129 event.stopPropagation(); 2130 2131 const meta = element.querySelector(".event-meta-compact"); 2132 const desc = element.querySelector(".event-desc-compact"); 2133 2134 // Toggle visibility 2135 if (meta.style.display === "none") { 2136 // Expand 2137 meta.style.display = "block"; 2138 if (desc) desc.style.display = "block"; 2139 element.classList.add("event-past-expanded"); 2140 } else { 2141 // Collapse 2142 meta.style.display = "none"; 2143 if (desc) desc.style.display = "none"; 2144 element.classList.remove("event-past-expanded"); 2145 } 2146}; 2147 2148// Filter calendar by namespace when clicking namespace badge (guarded) 2149if (!window._calendarClickDelegationInit) { 2150 window._calendarClickDelegationInit = true; 2151 document.addEventListener('click', function(e) { 2152 if (e.target.classList.contains('event-namespace-badge')) { 2153 const namespace = e.target.textContent; 2154 const calendar = e.target.closest('.calendar-compact-container'); 2155 2156 if (!calendar) return; 2157 2158 const calId = calendar.id; 2159 2160 // Use AJAX reload to filter both calendar grid and event list 2161 filterCalendarByNamespace(calId, namespace); 2162 } 2163 }); 2164} // end click delegation guard 2165 2166// Update the displayed filtered namespace in event list header 2167// Legacy badge removed - namespace filtering still works but badge no longer shown 2168window.updateFilteredNamespaceDisplay = function(calId, namespace) { 2169 const calendar = document.getElementById(calId); 2170 if (!calendar) return; 2171 2172 const headerContent = calendar.querySelector('.event-list-header-content'); 2173 if (!headerContent) return; 2174 2175 // Remove any existing filter badge (cleanup) 2176 let filterBadge = headerContent.querySelector('.namespace-filter-badge'); 2177 if (filterBadge) { 2178 filterBadge.remove(); 2179 } 2180}; 2181 2182// Clear namespace filter 2183window.clearNamespaceFilter = function(calId) { 2184 2185 const container = document.getElementById(calId); 2186 if (!container) { 2187 console.error('Calendar container not found:', calId); 2188 return; 2189 } 2190 2191 // Immediately hide/remove the filter badge 2192 const filterBadge = container.querySelector('.calendar-namespace-filter'); 2193 if (filterBadge) { 2194 filterBadge.style.display = 'none'; 2195 filterBadge.remove(); 2196 } 2197 2198 // Get current year and month 2199 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2200 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2201 2202 // Get original namespace (what the calendar was initialized with) 2203 const originalNamespace = container.dataset.originalNamespace || ''; 2204 2205 // Also check for sidebar widget 2206 const sidebarContainer = document.getElementById('sidebar-widget-' + calId); 2207 if (sidebarContainer) { 2208 // For sidebar widget, just reload the page without namespace filter 2209 // Remove the namespace from the URL and reload 2210 const url = new URL(window.location.href); 2211 url.searchParams.delete('namespace'); 2212 window.location.href = url.toString(); 2213 return; 2214 } 2215 2216 // For regular calendar, reload calendar with original namespace 2217 navCalendar(calId, year, month, originalNamespace); 2218}; 2219 2220window.clearNamespaceFilterPanel = function(calId) { 2221 2222 const container = document.getElementById(calId); 2223 if (!container) { 2224 console.error('Event panel container not found:', calId); 2225 return; 2226 } 2227 2228 // Get current year and month from URL params or container 2229 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2230 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2231 2232 // Get original namespace (what the panel was initialized with) 2233 const originalNamespace = container.dataset.originalNamespace || ''; 2234 2235 2236 // Reload event panel with original namespace 2237 navEventPanel(calId, year, month, originalNamespace); 2238}; 2239 2240// Color picker functions 2241window.updateCustomColorPicker = function(calId) { 2242 const select = document.getElementById('event-color-' + calId); 2243 const picker = document.getElementById('event-color-custom-' + calId); 2244 2245 if (select.value === 'custom') { 2246 // Show color picker 2247 picker.style.display = 'inline-block'; 2248 picker.click(); // Open color picker 2249 } else { 2250 // Hide color picker and sync value 2251 picker.style.display = 'none'; 2252 picker.value = select.value; 2253 } 2254}; 2255 2256function updateColorFromPicker(calId) { 2257 const select = document.getElementById('event-color-' + calId); 2258 const picker = document.getElementById('event-color-custom-' + calId); 2259 2260 // Set select to custom and update its underlying value 2261 select.value = 'custom'; 2262 // Store the actual color value in a data attribute 2263 select.dataset.customColor = picker.value; 2264} 2265 2266// Toggle past events visibility 2267window.togglePastEvents = function(calId) { 2268 const content = document.getElementById('past-events-' + calId); 2269 const arrow = document.getElementById('past-arrow-' + calId); 2270 2271 if (!content || !arrow) { 2272 console.error('Past events elements not found for:', calId); 2273 return; 2274 } 2275 2276 // Check computed style instead of inline style 2277 const isHidden = window.getComputedStyle(content).display === 'none'; 2278 2279 if (isHidden) { 2280 content.style.display = 'block'; 2281 arrow.textContent = '▼'; 2282 } else { 2283 content.style.display = 'none'; 2284 arrow.textContent = '▶'; 2285 } 2286}; 2287 2288// Fuzzy match scoring function 2289window.fuzzyMatch = function(pattern, str) { 2290 pattern = pattern.toLowerCase(); 2291 str = str.toLowerCase(); 2292 2293 let patternIdx = 0; 2294 let score = 0; 2295 let consecutiveMatches = 0; 2296 2297 for (let i = 0; i < str.length; i++) { 2298 if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) { 2299 score += 1 + consecutiveMatches; 2300 consecutiveMatches++; 2301 patternIdx++; 2302 } else { 2303 consecutiveMatches = 0; 2304 } 2305 } 2306 2307 // Return null if not all characters matched 2308 if (patternIdx !== pattern.length) { 2309 return null; 2310 } 2311 2312 // Bonus for exact match 2313 if (str === pattern) { 2314 score += 100; 2315 } 2316 2317 // Bonus for starts with 2318 if (str.startsWith(pattern)) { 2319 score += 50; 2320 } 2321 2322 return score; 2323}; 2324 2325// Initialize namespace search for a calendar 2326window.initNamespaceSearch = function(calId) { 2327 const searchInput = document.getElementById('event-namespace-search-' + calId); 2328 const hiddenInput = document.getElementById('event-namespace-' + calId); 2329 const dropdown = document.getElementById('event-namespace-dropdown-' + calId); 2330 const dataElement = document.getElementById('namespaces-data-' + calId); 2331 2332 if (!searchInput || !hiddenInput || !dropdown || !dataElement) { 2333 return; // Elements not found 2334 } 2335 2336 let namespaces = []; 2337 try { 2338 namespaces = JSON.parse(dataElement.textContent); 2339 } catch (e) { 2340 console.error('Failed to parse namespaces data:', e); 2341 return; 2342 } 2343 2344 let selectedIndex = -1; 2345 2346 // Filter and show dropdown 2347 function filterNamespaces(query) { 2348 if (!query || query.trim() === '') { 2349 // Show all namespaces when empty 2350 hiddenInput.value = ''; 2351 const results = namespaces.slice(0, 20); // Limit to 20 2352 showDropdown(results); 2353 return; 2354 } 2355 2356 // Fuzzy match and score 2357 const matches = []; 2358 for (let i = 0; i < namespaces.length; i++) { 2359 const score = fuzzyMatch(query, namespaces[i]); 2360 if (score !== null) { 2361 matches.push({ namespace: namespaces[i], score: score }); 2362 } 2363 } 2364 2365 // Sort by score (descending) 2366 matches.sort((a, b) => b.score - a.score); 2367 2368 // Take top 20 results 2369 const results = matches.slice(0, 20).map(m => m.namespace); 2370 showDropdown(results); 2371 } 2372 2373 function showDropdown(results) { 2374 dropdown.innerHTML = ''; 2375 selectedIndex = -1; 2376 2377 if (results.length === 0) { 2378 dropdown.style.display = 'none'; 2379 return; 2380 } 2381 2382 // Add (default) option 2383 const defaultOption = document.createElement('div'); 2384 defaultOption.className = 'namespace-option'; 2385 defaultOption.textContent = '(default)'; 2386 defaultOption.dataset.value = ''; 2387 dropdown.appendChild(defaultOption); 2388 2389 results.forEach(ns => { 2390 const option = document.createElement('div'); 2391 option.className = 'namespace-option'; 2392 option.textContent = ns; 2393 option.dataset.value = ns; 2394 dropdown.appendChild(option); 2395 }); 2396 2397 dropdown.style.display = 'block'; 2398 } 2399 2400 function hideDropdown() { 2401 dropdown.style.display = 'none'; 2402 selectedIndex = -1; 2403 } 2404 2405 function selectOption(namespace) { 2406 hiddenInput.value = namespace; 2407 searchInput.value = namespace || '(default)'; 2408 hideDropdown(); 2409 } 2410 2411 // Event listeners 2412 searchInput.addEventListener('input', function(e) { 2413 filterNamespaces(e.target.value); 2414 }); 2415 2416 searchInput.addEventListener('focus', function(e) { 2417 filterNamespaces(e.target.value); 2418 }); 2419 2420 searchInput.addEventListener('blur', function(e) { 2421 // Delay to allow click on dropdown 2422 setTimeout(hideDropdown, 200); 2423 }); 2424 2425 searchInput.addEventListener('keydown', function(e) { 2426 const options = dropdown.querySelectorAll('.namespace-option'); 2427 2428 if (e.key === 'ArrowDown') { 2429 e.preventDefault(); 2430 selectedIndex = Math.min(selectedIndex + 1, options.length - 1); 2431 updateSelection(options); 2432 } else if (e.key === 'ArrowUp') { 2433 e.preventDefault(); 2434 selectedIndex = Math.max(selectedIndex - 1, -1); 2435 updateSelection(options); 2436 } else if (e.key === 'Enter') { 2437 e.preventDefault(); 2438 if (selectedIndex >= 0 && options[selectedIndex]) { 2439 selectOption(options[selectedIndex].dataset.value); 2440 } 2441 } else if (e.key === 'Escape') { 2442 hideDropdown(); 2443 } 2444 }); 2445 2446 function updateSelection(options) { 2447 options.forEach((opt, idx) => { 2448 if (idx === selectedIndex) { 2449 opt.classList.add('selected'); 2450 opt.scrollIntoView({ block: 'nearest' }); 2451 } else { 2452 opt.classList.remove('selected'); 2453 } 2454 }); 2455 } 2456 2457 // Click on dropdown option 2458 dropdown.addEventListener('mousedown', function(e) { 2459 if (e.target.classList.contains('namespace-option')) { 2460 selectOption(e.target.dataset.value); 2461 } 2462 }); 2463}; 2464 2465// Update end time options based on start time selection 2466window.updateEndTimeOptions = function(calId) { 2467 const startTimeSelect = document.getElementById('event-time-' + calId); 2468 const endTimeSelect = document.getElementById('event-end-time-' + calId); 2469 const startDateField = document.getElementById('event-date-' + calId); 2470 const endDateField = document.getElementById('event-end-date-' + calId); 2471 2472 if (!startTimeSelect || !endTimeSelect) return; 2473 2474 const startTime = startTimeSelect.value; 2475 const startDate = startDateField ? startDateField.value : ''; 2476 const endDate = endDateField ? endDateField.value : ''; 2477 2478 // Check if end date is different from start date (multi-day event) 2479 const isMultiDay = endDate && endDate !== startDate; 2480 2481 // If start time is empty (all day), disable end time and reset 2482 if (!startTime) { 2483 endTimeSelect.disabled = true; 2484 endTimeSelect.value = ''; 2485 // Show all options again 2486 Array.from(endTimeSelect.options).forEach(opt => { 2487 opt.disabled = false; 2488 opt.style.display = ''; 2489 }); 2490 return; 2491 } 2492 2493 // Enable end time select 2494 endTimeSelect.disabled = false; 2495 2496 // If multi-day event, allow all end times (event can end at any time on the end date) 2497 if (isMultiDay) { 2498 Array.from(endTimeSelect.options).forEach(opt => { 2499 opt.disabled = false; 2500 opt.style.display = ''; 2501 }); 2502 return; 2503 } 2504 2505 // Same-day event: Convert start time to minutes and filter options 2506 const [startHour, startMinute] = startTime.split(':').map(Number); 2507 const startMinutes = startHour * 60 + startMinute; 2508 2509 // Get current end time value 2510 const currentEndTime = endTimeSelect.value; 2511 let currentEndMinutes = 0; 2512 if (currentEndTime) { 2513 const [h, m] = currentEndTime.split(':').map(Number); 2514 currentEndMinutes = h * 60 + m; 2515 } 2516 2517 // Disable/hide options before or equal to start time 2518 let firstValidOption = null; 2519 Array.from(endTimeSelect.options).forEach(opt => { 2520 if (opt.value === '') { 2521 // Keep "Same as start" option enabled 2522 opt.disabled = false; 2523 opt.style.display = ''; 2524 return; 2525 } 2526 2527 const [h, m] = opt.value.split(':').map(Number); 2528 const optMinutes = h * 60 + m; 2529 2530 if (optMinutes <= startMinutes) { 2531 // Disable and hide times at or before start 2532 opt.disabled = true; 2533 opt.style.display = 'none'; 2534 } else { 2535 // Enable and show times after start 2536 opt.disabled = false; 2537 opt.style.display = ''; 2538 if (!firstValidOption) { 2539 firstValidOption = opt.value; 2540 } 2541 } 2542 }); 2543 2544 // If current end time is now invalid, set a new one 2545 if (currentEndTime && currentEndMinutes <= startMinutes) { 2546 // Try to set to 1 hour after start 2547 let endHour = startHour + 1; 2548 let endMinute = startMinute; 2549 2550 if (endHour >= 24) { 2551 endHour = 23; 2552 endMinute = 45; 2553 } 2554 2555 const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0'); 2556 2557 // Check if suggested time exists and is valid 2558 const suggestedOpt = Array.from(endTimeSelect.options).find(opt => opt.value === suggestedEndTime && !opt.disabled); 2559 2560 if (suggestedOpt) { 2561 endTimeSelect.value = suggestedEndTime; 2562 } else if (firstValidOption) { 2563 endTimeSelect.value = firstValidOption; 2564 } else { 2565 endTimeSelect.value = ''; 2566 } 2567 } 2568}; 2569 2570// Check for time conflicts between events on the same date 2571window.checkTimeConflicts = function(events, currentEventId) { 2572 const conflicts = []; 2573 2574 // Group events by date 2575 const eventsByDate = {}; 2576 for (const [date, dateEvents] of Object.entries(events)) { 2577 if (!Array.isArray(dateEvents)) continue; 2578 2579 dateEvents.forEach(evt => { 2580 if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event 2581 2582 if (!eventsByDate[date]) eventsByDate[date] = []; 2583 eventsByDate[date].push(evt); 2584 }); 2585 } 2586 2587 // Check for overlaps on each date 2588 for (const [date, dateEvents] of Object.entries(eventsByDate)) { 2589 for (let i = 0; i < dateEvents.length; i++) { 2590 for (let j = i + 1; j < dateEvents.length; j++) { 2591 const evt1 = dateEvents[i]; 2592 const evt2 = dateEvents[j]; 2593 2594 if (eventsOverlap(evt1, evt2)) { 2595 // Mark both events as conflicting 2596 if (!evt1.hasConflict) evt1.hasConflict = true; 2597 if (!evt2.hasConflict) evt2.hasConflict = true; 2598 2599 // Store conflict info 2600 if (!evt1.conflictsWith) evt1.conflictsWith = []; 2601 if (!evt2.conflictsWith) evt2.conflictsWith = []; 2602 2603 evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime}); 2604 evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime}); 2605 } 2606 } 2607 } 2608 } 2609 2610 return events; 2611}; 2612 2613// Check if two events overlap in time 2614function eventsOverlap(evt1, evt2) { 2615 if (!evt1.time || !evt2.time) return false; // All-day events don't conflict 2616 2617 const start1 = evt1.time; 2618 const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start 2619 2620 const start2 = evt2.time; 2621 const end2 = evt2.endTime || evt2.time; 2622 2623 // Convert to minutes for easier comparison 2624 const start1Mins = timeToMinutes(start1); 2625 const end1Mins = timeToMinutes(end1); 2626 const start2Mins = timeToMinutes(start2); 2627 const end2Mins = timeToMinutes(end2); 2628 2629 // Check for overlap 2630 // Events overlap if: start1 < end2 AND start2 < end1 2631 return start1Mins < end2Mins && start2Mins < end1Mins; 2632} 2633 2634// Convert HH:MM time to minutes since midnight 2635function timeToMinutes(timeStr) { 2636 const [hours, minutes] = timeStr.split(':').map(Number); 2637 return hours * 60 + minutes; 2638} 2639 2640// Format time range for display 2641window.formatTimeRange = function(startTime, endTime) { 2642 if (!startTime) return ''; 2643 2644 const formatTime = (timeStr) => { 2645 const [hour24, minute] = timeStr.split(':').map(Number); 2646 const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); 2647 const ampm = hour24 < 12 ? 'AM' : 'PM'; 2648 return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2649 }; 2650 2651 if (!endTime || endTime === startTime) { 2652 return formatTime(startTime); 2653 } 2654 2655 return formatTime(startTime) + ' - ' + formatTime(endTime); 2656}; 2657 2658// Track last known mouse position for tooltip positioning fallback 2659var _lastMouseX = 0, _lastMouseY = 0; 2660document.addEventListener('mousemove', function(e) { 2661 _lastMouseX = e.clientX; 2662 _lastMouseY = e.clientY; 2663}); 2664 2665// Show custom conflict tooltip 2666window.showConflictTooltip = function(badgeElement) { 2667 // Remove any existing tooltip 2668 hideConflictTooltip(); 2669 2670 // Get conflict data (base64-encoded JSON to avoid attribute quote issues) 2671 const conflictsRaw = badgeElement.getAttribute('data-conflicts'); 2672 if (!conflictsRaw) return; 2673 2674 let conflicts; 2675 try { 2676 conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw)))); 2677 } catch (e) { 2678 // Fallback: try parsing as plain JSON (for PHP-rendered badges) 2679 try { 2680 conflicts = JSON.parse(conflictsRaw); 2681 } catch (e2) { 2682 console.error('Failed to parse conflicts:', e2); 2683 return; 2684 } 2685 } 2686 2687 // Get theme from the calendar container via CSS variables 2688 // Try closest ancestor first, then fall back to any calendar on the page 2689 let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone'); 2690 if (!containerEl) { 2691 // Badge might be inside a day popup (appended to body) - find any calendar container 2692 containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]'); 2693 } 2694 const cs = containerEl ? getComputedStyle(containerEl) : null; 2695 2696 const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424'; 2697 const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07'; 2698 const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07'; 2699 const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00'; 2700 const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)'; 2701 2702 // Create tooltip 2703 const tooltip = document.createElement('div'); 2704 tooltip.id = 'conflict-tooltip'; 2705 tooltip.className = 'conflict-tooltip'; 2706 2707 // Apply theme styles 2708 tooltip.style.background = bg; 2709 tooltip.style.borderColor = border; 2710 tooltip.style.color = textPrimary; 2711 tooltip.style.boxShadow = '0 4px 12px ' + shadow; 2712 2713 // Build content with themed colors 2714 let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>'; 2715 html += '<div class="conflict-tooltip-body">'; 2716 conflicts.forEach(conflict => { 2717 html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>'; 2718 }); 2719 html += '</div>'; 2720 2721 tooltip.innerHTML = html; 2722 document.body.appendChild(tooltip); 2723 2724 // Position tooltip 2725 const rect = badgeElement.getBoundingClientRect(); 2726 const tooltipRect = tooltip.getBoundingClientRect(); 2727 2728 // Position above the badge, centered 2729 let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); 2730 let top = rect.top - tooltipRect.height - 8; 2731 2732 // Keep tooltip within viewport 2733 if (left < 10) left = 10; 2734 if (left + tooltipRect.width > window.innerWidth - 10) { 2735 left = window.innerWidth - tooltipRect.width - 10; 2736 } 2737 if (top < 10) { 2738 // If not enough room above, show below 2739 top = rect.bottom + 8; 2740 } 2741 2742 tooltip.style.left = left + 'px'; 2743 tooltip.style.top = top + 'px'; 2744 tooltip.style.opacity = '1'; 2745}; 2746 2747// Hide conflict tooltip 2748window.hideConflictTooltip = function() { 2749 const tooltip = document.getElementById('conflict-tooltip'); 2750 if (tooltip) { 2751 tooltip.remove(); 2752 } 2753}; 2754 2755// Fuzzy search helper for event filtering - normalizes text for matching 2756function eventSearchNormalize(text) { 2757 if (typeof text !== 'string') { 2758 console.log('[eventSearchNormalize] WARNING: text is not a string:', typeof text, text); 2759 return ''; 2760 } 2761 return text 2762 .toLowerCase() 2763 .trim() 2764 // Remove common punctuation that might differ 2765 .replace(/[''\u2018\u2019]/g, '') // Remove apostrophes/quotes 2766 .replace(/["""\u201C\u201D]/g, '') // Remove smart quotes 2767 .replace(/[-–—]/g, ' ') // Dashes to spaces 2768 .replace(/[.,!?;:]/g, '') // Remove punctuation 2769 .replace(/\s+/g, ' ') // Normalize whitespace 2770 .trim(); 2771} 2772 2773// Check if search term matches text for event filtering 2774function eventSearchMatch(text, searchTerm) { 2775 const normalizedText = eventSearchNormalize(text); 2776 const normalizedSearch = eventSearchNormalize(searchTerm); 2777 2778 // Direct match after normalization 2779 if (normalizedText.includes(normalizedSearch)) { 2780 return true; 2781 } 2782 2783 // Split search into words and check if all words are present 2784 const searchWords = normalizedSearch.split(' ').filter(w => w.length > 0); 2785 if (searchWords.length > 1) { 2786 return searchWords.every(word => normalizedText.includes(word)); 2787 } 2788 2789 return false; 2790} 2791 2792// Filter events by search term 2793window.filterEvents = function(calId, searchTerm) { 2794 const eventList = document.getElementById('eventlist-' + calId); 2795 const searchClear = document.getElementById('search-clear-' + calId); 2796 const searchMode = document.getElementById('search-mode-' + calId); 2797 2798 if (!eventList) return; 2799 2800 // Check if we're in "all dates" mode 2801 const isAllDatesMode = searchMode && searchMode.classList.contains('all-dates'); 2802 2803 // Show/hide clear button 2804 if (searchClear) { 2805 searchClear.style.display = searchTerm ? 'block' : 'none'; 2806 } 2807 2808 searchTerm = searchTerm.trim(); 2809 2810 // If all-dates mode and we have a search term, do AJAX search 2811 if (isAllDatesMode && searchTerm.length >= 2) { 2812 searchAllDates(calId, searchTerm); 2813 return; 2814 } 2815 2816 // If all-dates mode but search cleared, restore normal view 2817 if (isAllDatesMode && !searchTerm) { 2818 // Remove search results container if exists 2819 const resultsContainer = eventList.querySelector('.all-dates-results'); 2820 if (resultsContainer) { 2821 resultsContainer.remove(); 2822 } 2823 // Show normal event items 2824 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2825 item.style.display = ''; 2826 }); 2827 // Show past events toggle if it exists 2828 const pastToggle = eventList.querySelector('.past-events-toggle'); 2829 if (pastToggle) pastToggle.style.display = ''; 2830 } 2831 2832 // Get all event items 2833 const eventItems = eventList.querySelectorAll('.event-compact-item'); 2834 let visibleCount = 0; 2835 let hiddenPastCount = 0; 2836 2837 eventItems.forEach(item => { 2838 const title = item.querySelector('.event-title-compact'); 2839 const description = item.querySelector('.event-desc-compact'); 2840 const dateTime = item.querySelector('.event-date-time'); 2841 2842 // Build searchable text 2843 let searchableText = ''; 2844 if (title) searchableText += title.textContent + ' '; 2845 if (description) searchableText += description.textContent + ' '; 2846 if (dateTime) searchableText += dateTime.textContent + ' '; 2847 2848 // Check if matches search using fuzzy matching 2849 const matches = !searchTerm || eventSearchMatch(searchableText, searchTerm); 2850 2851 if (matches) { 2852 item.style.display = ''; 2853 visibleCount++; 2854 } else { 2855 item.style.display = 'none'; 2856 // Check if this is a past event 2857 if (item.classList.contains('event-past') || item.classList.contains('event-completed')) { 2858 hiddenPastCount++; 2859 } 2860 } 2861 }); 2862 2863 // Update past events toggle if it exists 2864 const pastToggle = eventList.querySelector('.past-events-toggle'); 2865 const pastLabel = eventList.querySelector('.past-events-label'); 2866 const pastContent = document.getElementById('past-events-' + calId); 2867 2868 if (pastToggle && pastLabel && pastContent) { 2869 const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])'); 2870 const totalPastVisible = visiblePastEvents.length; 2871 2872 if (totalPastVisible > 0) { 2873 pastLabel.textContent = `Past Events (${totalPastVisible})`; 2874 pastToggle.style.display = ''; 2875 } else { 2876 pastToggle.style.display = 'none'; 2877 } 2878 } 2879 2880 // Show "no results" message if nothing visible (only for month mode, not all-dates mode) 2881 let noResultsMsg = eventList.querySelector('.no-search-results'); 2882 if (visibleCount === 0 && searchTerm && !isAllDatesMode) { 2883 if (!noResultsMsg) { 2884 noResultsMsg = document.createElement('p'); 2885 noResultsMsg.className = 'no-search-results no-events-msg'; 2886 noResultsMsg.textContent = 'No events match your search'; 2887 eventList.appendChild(noResultsMsg); 2888 } 2889 noResultsMsg.style.display = 'block'; 2890 } else if (noResultsMsg) { 2891 noResultsMsg.style.display = 'none'; 2892 } 2893}; 2894 2895// Toggle search mode between "this month" and "all dates" 2896window.toggleSearchMode = function(calId, namespace) { 2897 const searchMode = document.getElementById('search-mode-' + calId); 2898 const searchInput = document.getElementById('event-search-' + calId); 2899 2900 if (!searchMode) return; 2901 2902 const isAllDates = searchMode.classList.toggle('all-dates'); 2903 2904 // Update button icon and title 2905 if (isAllDates) { 2906 searchMode.innerHTML = ''; 2907 searchMode.title = 'Searching all dates'; 2908 if (searchInput) { 2909 searchInput.placeholder = 'Search all dates...'; 2910 } 2911 } else { 2912 searchMode.innerHTML = ''; 2913 searchMode.title = 'Search this month only'; 2914 if (searchInput) { 2915 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 2916 } 2917 } 2918 2919 // Re-run search with current term 2920 if (searchInput && searchInput.value) { 2921 filterEvents(calId, searchInput.value); 2922 } else { 2923 // Clear any all-dates results 2924 const eventList = document.getElementById('eventlist-' + calId); 2925 if (eventList) { 2926 const resultsContainer = eventList.querySelector('.all-dates-results'); 2927 if (resultsContainer) { 2928 resultsContainer.remove(); 2929 } 2930 // Show normal event items 2931 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2932 item.style.display = ''; 2933 }); 2934 const pastToggle = eventList.querySelector('.past-events-toggle'); 2935 if (pastToggle) pastToggle.style.display = ''; 2936 } 2937 } 2938}; 2939 2940// Search all dates via AJAX 2941window.searchAllDates = function(calId, searchTerm) { 2942 const eventList = document.getElementById('eventlist-' + calId); 2943 if (!eventList) return; 2944 2945 // Get namespace from container 2946 const container = document.getElementById(calId); 2947 const namespace = container ? (container.dataset.namespace || '') : ''; 2948 2949 // Hide normal event items 2950 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2951 item.style.display = 'none'; 2952 }); 2953 const pastToggle = eventList.querySelector('.past-events-toggle'); 2954 if (pastToggle) pastToggle.style.display = 'none'; 2955 2956 // Remove old results container 2957 let resultsContainer = eventList.querySelector('.all-dates-results'); 2958 if (resultsContainer) { 2959 resultsContainer.remove(); 2960 } 2961 2962 // Create new results container 2963 resultsContainer = document.createElement('div'); 2964 resultsContainer.className = 'all-dates-results'; 2965 resultsContainer.innerHTML = '<p class="search-loading" style="text-align:center; padding:20px; color:var(--text-dim);"> Searching all dates...</p>'; 2966 eventList.appendChild(resultsContainer); 2967 2968 // Make AJAX request 2969 const params = new URLSearchParams({ 2970 call: 'plugin_calendar', 2971 action: 'search_all', 2972 search: searchTerm, 2973 namespace: namespace, 2974 _: new Date().getTime() 2975 }); 2976 2977 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 2978 method: 'POST', 2979 headers: { 2980 'Content-Type': 'application/x-www-form-urlencoded' 2981 }, 2982 body: params.toString() 2983 }) 2984 .then(r => r.json()) 2985 .then(data => { 2986 if (data.success && data.results) { 2987 if (data.results.length === 0) { 2988 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim); font-style:italic;">No events found matching "' + escapeHtml(searchTerm) + '"</p>'; 2989 } else { 2990 let html = '<div class="all-dates-header" style="padding:4px 8px; background:var(--cell-today-bg, #e8f5e9); font-size:10px; font-weight:600; color:var(--text-bright, #00cc07); border-bottom:1px solid var(--border-color);">Found ' + data.results.length + ' event(s) across all dates</div>'; 2991 2992 data.results.forEach(event => { 2993 const dateObj = new Date(event.date + 'T00:00:00'); 2994 const dateDisplay = dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); 2995 const color = event.color || 'var(--text-bright, #00cc07)'; 2996 2997 html += '<div class="event-compact-item search-result-item" style="display:flex; border-bottom:1px solid var(--border-color, #e0e0e0); padding:6px 8px; gap:6px; cursor:pointer;" onclick="jumpToDate(\'' + calId + '\', \'' + event.date + '\', \'' + namespace + '\')">'; 2998 html += '<div style="width:3px; background:' + color + '; border-radius:1px; flex-shrink:0;"></div>'; 2999 html += '<div style="flex:1; min-width:0;">'; 3000 html += '<div class="event-title-compact" style="font-weight:600; color:var(--text-primary); font-size:11px;">' + escapeHtml(event.title) + '</div>'; 3001 html += '<div class="event-date-time" style="font-size:10px; color:var(--text-dim);">' + dateDisplay; 3002 if (event.time) { 3003 html += ' • ' + formatTimeRange(event.time, event.endTime); 3004 } 3005 html += '</div>'; 3006 if (event.namespace) { 3007 html += '<span style="font-size:9px; background:var(--text-bright); color:var(--background-site); padding:1px 4px; border-radius:2px; margin-top:2px; display:inline-block;">' + escapeHtml(event.namespace) + '</span>'; 3008 } 3009 html += '</div></div>'; 3010 }); 3011 3012 resultsContainer.innerHTML = html; 3013 } 3014 } else { 3015 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 3016 } 3017 }) 3018 .catch(err => { 3019 console.error('Search error:', err); 3020 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 3021 }); 3022}; 3023 3024// Jump to a specific date (used by search results) 3025window.jumpToDate = function(calId, date, namespace) { 3026 const parts = date.split('-'); 3027 const year = parseInt(parts[0]); 3028 const month = parseInt(parts[1]); 3029 3030 // Get container to check current month 3031 const container = document.getElementById(calId); 3032 const currentYear = container ? parseInt(container.dataset.year) : year; 3033 const currentMonth = container ? parseInt(container.dataset.month) : month; 3034 3035 // Get search elements 3036 const searchInput = document.getElementById('event-search-' + calId); 3037 const searchMode = document.getElementById('search-mode-' + calId); 3038 const searchClear = document.getElementById('search-clear-' + calId); 3039 const eventList = document.getElementById('eventlist-' + calId); 3040 3041 // Remove the all-dates results container 3042 if (eventList) { 3043 const resultsContainer = eventList.querySelector('.all-dates-results'); 3044 if (resultsContainer) { 3045 resultsContainer.remove(); 3046 } 3047 // Show normal event items again 3048 eventList.querySelectorAll('.event-compact-item').forEach(item => { 3049 item.style.display = ''; 3050 }); 3051 const pastToggle = eventList.querySelector('.past-events-toggle'); 3052 if (pastToggle) pastToggle.style.display = ''; 3053 3054 // Hide any no-results message 3055 const noResults = eventList.querySelector('.no-search-results'); 3056 if (noResults) noResults.style.display = 'none'; 3057 } 3058 3059 // Clear search input 3060 if (searchInput) { 3061 searchInput.value = ''; 3062 } 3063 3064 // Hide clear button 3065 if (searchClear) { 3066 searchClear.style.display = 'none'; 3067 } 3068 3069 // Switch back to month mode 3070 if (searchMode && searchMode.classList.contains('all-dates')) { 3071 searchMode.classList.remove('all-dates'); 3072 searchMode.innerHTML = ''; 3073 searchMode.title = 'Search this month only'; 3074 if (searchInput) { 3075 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 3076 } 3077 } 3078 3079 // Check if we need to navigate to a different month 3080 if (year !== currentYear || month !== currentMonth) { 3081 // Navigate to the target month, then show popup 3082 navCalendar(calId, year, month, namespace); 3083 3084 // After navigation completes, show the day popup 3085 setTimeout(() => { 3086 showDayPopup(calId, date, namespace); 3087 }, 400); 3088 } else { 3089 // Same month - just show the popup 3090 showDayPopup(calId, date, namespace); 3091 } 3092}; 3093 3094// Clear event search 3095window.clearEventSearch = function(calId) { 3096 const searchInput = document.getElementById('event-search-' + calId); 3097 if (searchInput) { 3098 searchInput.value = ''; 3099 filterEvents(calId, ''); 3100 searchInput.focus(); 3101 } 3102}; 3103 3104// ============================================ 3105// PINK THEME - GLOWING PARTICLE EFFECTS 3106// ============================================ 3107 3108// Create glowing pink particle effects for pink theme 3109(function() { 3110 let pinkThemeActive = false; 3111 let trailTimer = null; 3112 let pixelTimer = null; 3113 3114 // Check if pink theme is active 3115 function checkPinkTheme() { 3116 const pinkCalendars = document.querySelectorAll('.calendar-theme-pink'); 3117 pinkThemeActive = pinkCalendars.length > 0; 3118 return pinkThemeActive; 3119 } 3120 3121 // Create trail particle 3122 function createTrailParticle(clientX, clientY) { 3123 if (!pinkThemeActive) return; 3124 3125 const trail = document.createElement('div'); 3126 trail.className = 'pink-cursor-trail'; 3127 trail.style.left = clientX + 'px'; 3128 trail.style.top = clientY + 'px'; 3129 trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards'; 3130 3131 document.body.appendChild(trail); 3132 3133 setTimeout(function() { 3134 trail.remove(); 3135 }, 500); 3136 } 3137 3138 // Create pixel sparkles 3139 function createPixelSparkles(clientX, clientY) { 3140 if (!pinkThemeActive || pixelTimer) return; 3141 3142 const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels 3143 3144 for (let i = 0; i < pixelCount; i++) { 3145 const pixel = document.createElement('div'); 3146 pixel.className = 'pink-pixel-sparkle'; 3147 3148 // Random offset from cursor 3149 const offsetX = (Math.random() - 0.5) * 30; 3150 const offsetY = (Math.random() - 0.5) * 30; 3151 3152 pixel.style.left = (clientX + offsetX) + 'px'; 3153 pixel.style.top = (clientY + offsetY) + 'px'; 3154 3155 // Random color - bright neon pinks and whites 3156 const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 3157 const color = colors[Math.floor(Math.random() * colors.length)]; 3158 pixel.style.background = color; 3159 pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff'; 3160 3161 // Random animation 3162 if (Math.random() > 0.5) { 3163 pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards'; 3164 } else { 3165 pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards'; 3166 } 3167 3168 document.body.appendChild(pixel); 3169 3170 setTimeout(function() { 3171 pixel.remove(); 3172 }, 800); 3173 } 3174 3175 pixelTimer = setTimeout(function() { 3176 pixelTimer = null; 3177 }, 40); 3178 } 3179 3180 // Create explosion 3181 function createExplosion(clientX, clientY) { 3182 if (!pinkThemeActive) return; 3183 3184 const particleCount = 25; 3185 const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff']; 3186 3187 // Add hearts to explosion (8-12 hearts) 3188 const heartCount = 8 + Math.floor(Math.random() * 5); 3189 for (let i = 0; i < heartCount; i++) { 3190 const heart = document.createElement('div'); 3191 heart.textContent = ''; 3192 heart.style.position = 'fixed'; 3193 heart.style.left = clientX + 'px'; 3194 heart.style.top = clientY + 'px'; 3195 heart.style.pointerEvents = 'none'; 3196 heart.style.zIndex = '9999999'; 3197 heart.style.fontSize = (12 + Math.random() * 16) + 'px'; 3198 3199 // Random direction 3200 const angle = Math.random() * Math.PI * 2; 3201 const velocity = 60 + Math.random() * 80; 3202 const tx = Math.cos(angle) * velocity; 3203 const ty = Math.sin(angle) * velocity; 3204 3205 heart.style.setProperty('--tx', tx + 'px'); 3206 heart.style.setProperty('--ty', ty + 'px'); 3207 3208 const duration = 0.8 + Math.random() * 0.4; 3209 heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3210 3211 document.body.appendChild(heart); 3212 3213 setTimeout(function() { 3214 heart.remove(); 3215 }, duration * 1000); 3216 } 3217 3218 // Main explosion particles 3219 for (let i = 0; i < particleCount; i++) { 3220 const particle = document.createElement('div'); 3221 particle.className = 'pink-particle'; 3222 3223 const color = colors[Math.floor(Math.random() * colors.length)]; 3224 particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)'; 3225 particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color; 3226 3227 particle.style.left = clientX + 'px'; 3228 particle.style.top = clientY + 'px'; 3229 3230 const angle = (Math.PI * 2 * i) / particleCount; 3231 const velocity = 50 + Math.random() * 100; 3232 const tx = Math.cos(angle) * velocity; 3233 const ty = Math.sin(angle) * velocity; 3234 3235 particle.style.setProperty('--tx', tx + 'px'); 3236 particle.style.setProperty('--ty', ty + 'px'); 3237 3238 const size = 4 + Math.random() * 6; 3239 particle.style.width = size + 'px'; 3240 particle.style.height = size + 'px'; 3241 3242 const duration = 0.6 + Math.random() * 0.4; 3243 particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3244 3245 document.body.appendChild(particle); 3246 3247 setTimeout(function() { 3248 particle.remove(); 3249 }, duration * 1000); 3250 } 3251 3252 // Pixel sparkles 3253 const pixelSparkleCount = 40; 3254 3255 for (let i = 0; i < pixelSparkleCount; i++) { 3256 const pixel = document.createElement('div'); 3257 pixel.className = 'pink-pixel-sparkle'; 3258 3259 const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 3260 const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)]; 3261 pixel.style.background = pixelColor; 3262 pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff'; 3263 3264 const angle = Math.random() * Math.PI * 2; 3265 const distance = 30 + Math.random() * 80; 3266 const offsetX = Math.cos(angle) * distance; 3267 const offsetY = Math.sin(angle) * distance; 3268 3269 pixel.style.left = clientX + 'px'; 3270 pixel.style.top = clientY + 'px'; 3271 pixel.style.setProperty('--tx', offsetX + 'px'); 3272 pixel.style.setProperty('--ty', offsetY + 'px'); 3273 3274 const pixelSize = 1 + Math.random() * 2; 3275 pixel.style.width = pixelSize + 'px'; 3276 pixel.style.height = pixelSize + 'px'; 3277 3278 const duration = 0.4 + Math.random() * 0.4; 3279 if (Math.random() > 0.5) { 3280 pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards'; 3281 } else { 3282 pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3283 } 3284 3285 document.body.appendChild(pixel); 3286 3287 setTimeout(function() { 3288 pixel.remove(); 3289 }, duration * 1000); 3290 } 3291 3292 // Flash 3293 const flash = document.createElement('div'); 3294 flash.style.position = 'fixed'; 3295 flash.style.left = clientX + 'px'; 3296 flash.style.top = clientY + 'px'; 3297 flash.style.width = '40px'; 3298 flash.style.height = '40px'; 3299 flash.style.borderRadius = '50%'; 3300 flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)'; 3301 flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4'; 3302 flash.style.pointerEvents = 'none'; 3303 flash.style.zIndex = '9999999'; // Above everything including dialogs 3304 flash.style.transform = 'translate(-50%, -50%)'; 3305 flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards'; 3306 3307 document.body.appendChild(flash); 3308 3309 setTimeout(function() { 3310 flash.remove(); 3311 }, 300); 3312 } 3313 3314 function initPinkParticles() { 3315 if (!checkPinkTheme()) return; 3316 3317 // Use capture phase to catch events before stopPropagation 3318 document.addEventListener('mousemove', function(e) { 3319 if (!pinkThemeActive) return; 3320 3321 createTrailParticle(e.clientX, e.clientY); 3322 createPixelSparkles(e.clientX, e.clientY); 3323 }, true); // Capture phase! 3324 3325 // Throttle main trail 3326 document.addEventListener('mousemove', function(e) { 3327 if (!pinkThemeActive || trailTimer) return; 3328 3329 trailTimer = setTimeout(function() { 3330 trailTimer = null; 3331 }, 30); 3332 }, true); // Capture phase! 3333 3334 // Click explosion - use capture phase 3335 document.addEventListener('click', function(e) { 3336 if (!pinkThemeActive) return; 3337 3338 createExplosion(e.clientX, e.clientY); 3339 }, true); // Capture phase! 3340 } 3341 3342 // Initialize on load 3343 if (document.readyState === 'loading') { 3344 document.addEventListener('DOMContentLoaded', initPinkParticles); 3345 } else { 3346 initPinkParticles(); 3347 } 3348 3349 // Re-check theme if calendar is dynamically added 3350 // Must wait for document.body to exist 3351 function setupMutationObserver() { 3352 if (typeof MutationObserver !== 'undefined' && document.body) { 3353 const observer = new MutationObserver(function(mutations) { 3354 mutations.forEach(function(mutation) { 3355 if (mutation.addedNodes.length > 0) { 3356 mutation.addedNodes.forEach(function(node) { 3357 if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) { 3358 checkPinkTheme(); 3359 initPinkParticles(); 3360 } 3361 }); 3362 } 3363 }); 3364 }); 3365 3366 observer.observe(document.body, { 3367 childList: true, 3368 subtree: true 3369 }); 3370 } 3371 } 3372 3373 // Setup observer when DOM is ready 3374 if (document.readyState === 'loading') { 3375 document.addEventListener('DOMContentLoaded', setupMutationObserver); 3376 } else { 3377 setupMutationObserver(); 3378 } 3379})(); 3380 3381// Mobile touch event delegation for edit/delete buttons 3382// This ensures buttons work on mobile where onclick may not fire reliably 3383(function() { 3384 function handleButtonTouch(e) { 3385 const btn = e.target.closest('.event-edit-btn, .event-delete-btn, .event-action-btn'); 3386 if (!btn) return; 3387 3388 // Prevent double-firing with onclick 3389 e.preventDefault(); 3390 3391 // Small delay to show visual feedback 3392 setTimeout(function() { 3393 btn.click(); 3394 }, 10); 3395 } 3396 3397 // Use touchend for more reliable mobile handling 3398 document.addEventListener('touchend', handleButtonTouch, { passive: false }); 3399})(); 3400 3401// Static calendar navigation 3402window.navStaticCalendar = function(calId, direction) { 3403 const container = document.getElementById(calId); 3404 if (!container) return; 3405 3406 let year = parseInt(container.dataset.year); 3407 let month = parseInt(container.dataset.month); 3408 const namespace = container.dataset.namespace || ''; 3409 3410 // Calculate new month 3411 month += direction; 3412 if (month < 1) { 3413 month = 12; 3414 year--; 3415 } else if (month > 12) { 3416 month = 1; 3417 year++; 3418 } 3419 3420 // Fetch new calendar content via AJAX 3421 const params = new URLSearchParams({ 3422 call: 'plugin_calendar', 3423 action: 'get_static_calendar', 3424 year: year, 3425 month: month, 3426 namespace: namespace 3427 }); 3428 3429 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 3430 method: 'POST', 3431 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 3432 body: params.toString() 3433 }) 3434 .then(r => r.json()) 3435 .then(data => { 3436 if (data.success && data.html) { 3437 // Replace the container content 3438 container.outerHTML = data.html; 3439 } 3440 }) 3441 .catch(err => console.error('Static calendar navigation error:', err)); 3442}; 3443 3444// Print static calendar - opens print dialog with only calendar content 3445window.printStaticCalendar = function(calId) { 3446 const container = document.getElementById(calId); 3447 if (!container) return; 3448 3449 // Get the print view content 3450 const printView = container.querySelector('.static-print-view'); 3451 if (!printView) return; 3452 3453 // Create a new window for printing 3454 const printWindow = window.open('', '_blank', 'width=800,height=600'); 3455 3456 // Build print document with inline margins for maximum compatibility 3457 const printContent = ` 3458<!DOCTYPE html> 3459<html> 3460<head> 3461 <title>Calendar - ${container.dataset.year}-${String(container.dataset.month).padStart(2, '0')}</title> 3462 <style> 3463 * { margin: 0; padding: 0; box-sizing: border-box; } 3464 body { font-family: Arial, sans-serif; color: #333; background: white; } 3465 table { border-collapse: collapse; font-size: 12px; } 3466 th { background: #2c3e50; color: white; padding: 8px; text-align: left; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 3467 td { padding: 6px 8px; border-bottom: 1px solid #ccc; vertical-align: top; } 3468 tr:nth-child(even) { background: #f0f0f0; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 3469 .static-itinerary-important { background: #fffde7 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 3470 .static-itinerary-date { font-weight: bold; white-space: nowrap; } 3471 .static-itinerary-time { white-space: nowrap; color: #555; } 3472 .static-itinerary-title { font-weight: 500; } 3473 .static-itinerary-desc { color: #555; font-size: 11px; } 3474 thead { display: table-header-group; } 3475 tr { page-break-inside: avoid; } 3476 h2 { font-size: 16px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 2px solid #333; } 3477 p { font-size: 12px; color: #666; margin-bottom: 15px; } 3478 </style> 3479</head> 3480<body style="margin: 0; padding: 0;"> 3481 <div style="padding: 50px 60px; margin: 0 auto; max-width: 800px;"> 3482 ${printView.innerHTML} 3483 </div> 3484 <script> 3485 window.onload = function() { 3486 setTimeout(function() { 3487 window.print(); 3488 }, 300); 3489 window.onafterprint = function() { 3490 window.close(); 3491 }; 3492 }; 3493 </script> 3494</body> 3495</html>`; 3496 3497 printWindow.document.write(printContent); 3498 printWindow.document.close(); 3499}; 3500 3501// End of calendar plugin JavaScript 3502