1/** 2 * DokuWiki Compact Calendar Plugin JavaScript 3 */ 4 5// Navigate to different month 6function navCalendar(calId, year, month, namespace) { 7 const params = new URLSearchParams({ 8 call: 'plugin_calendar', 9 action: 'load_month', 10 year: year, 11 month: month, 12 namespace: namespace, 13 _: new Date().getTime() // Cache buster 14 }); 15 16 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 17 method: 'POST', 18 headers: { 19 'Content-Type': 'application/x-www-form-urlencoded', 20 'Cache-Control': 'no-cache, no-store, must-revalidate', 21 'Pragma': 'no-cache' 22 }, 23 body: params.toString() 24 }) 25 .then(r => r.json()) 26 .then(data => { 27 console.log('Month navigation data:', data); 28 if (data.success) { 29 console.log('Rebuilding calendar for', year, month, 'with', Object.keys(data.events || {}).length, 'date entries'); 30 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 31 } else { 32 console.error('Failed to load month:', data.error); 33 } 34 }) 35 .catch(err => { 36 console.error('Error loading month:', err); 37 }); 38} 39 40// Jump to current month 41function jumpToToday(calId, namespace) { 42 const today = new Date(); 43 const year = today.getFullYear(); 44 const month = today.getMonth() + 1; // JavaScript months are 0-indexed 45 navCalendar(calId, year, month, namespace); 46} 47 48// Jump to today for event panel 49function jumpTodayPanel(calId, namespace) { 50 const today = new Date(); 51 const year = today.getFullYear(); 52 const month = today.getMonth() + 1; 53 navEventPanel(calId, year, month, namespace); 54} 55 56// Open month picker dialog 57function openMonthPicker(calId, currentYear, currentMonth, namespace) { 58 console.log('openMonthPicker called:', calId, currentYear, currentMonth, namespace); 59 60 const overlay = document.getElementById('month-picker-overlay-' + calId); 61 console.log('Overlay element:', overlay); 62 63 const monthSelect = document.getElementById('month-picker-month-' + calId); 64 console.log('Month select:', monthSelect); 65 66 const yearSelect = document.getElementById('month-picker-year-' + calId); 67 console.log('Year select:', yearSelect); 68 69 if (!overlay) { 70 console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId); 71 return; 72 } 73 74 if (!monthSelect || !yearSelect) { 75 console.error('Select elements not found!'); 76 return; 77 } 78 79 // Set current values 80 monthSelect.value = currentMonth; 81 yearSelect.value = currentYear; 82 83 // Show overlay 84 overlay.style.display = 'flex'; 85 console.log('Overlay display set to flex'); 86} 87 88// Open month picker dialog for event panel 89function openMonthPickerPanel(calId, currentYear, currentMonth, namespace) { 90 openMonthPicker(calId, currentYear, currentMonth, namespace); 91} 92 93// Close month picker dialog 94function closeMonthPicker(calId) { 95 const overlay = document.getElementById('month-picker-overlay-' + calId); 96 overlay.style.display = 'none'; 97} 98 99// Jump to selected month 100function jumpToSelectedMonth(calId, namespace) { 101 const monthSelect = document.getElementById('month-picker-month-' + calId); 102 const yearSelect = document.getElementById('month-picker-year-' + calId); 103 104 const month = parseInt(monthSelect.value); 105 const year = parseInt(yearSelect.value); 106 107 closeMonthPicker(calId); 108 109 // Check if this is a calendar or event panel 110 const container = document.getElementById(calId); 111 if (container && container.classList.contains('event-panel-standalone')) { 112 navEventPanel(calId, year, month, namespace); 113 } else { 114 navCalendar(calId, year, month, namespace); 115 } 116} 117 118// Rebuild calendar grid after navigation 119function rebuildCalendar(calId, year, month, events, namespace) { 120 const container = document.getElementById(calId); 121 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 122 'July', 'August', 'September', 'October', 'November', 'December']; 123 124 // Update embedded events data 125 let eventsDataEl = document.getElementById('events-data-' + calId); 126 if (eventsDataEl) { 127 eventsDataEl.textContent = JSON.stringify(events); 128 } else { 129 eventsDataEl = document.createElement('script'); 130 eventsDataEl.type = 'application/json'; 131 eventsDataEl.id = 'events-data-' + calId; 132 eventsDataEl.textContent = JSON.stringify(events); 133 container.appendChild(eventsDataEl); 134 } 135 136 // Update header 137 const header = container.querySelector('.calendar-compact-header h3'); 138 header.textContent = monthNames[month - 1] + ' ' + year; 139 140 // Update nav buttons 141 let prevMonth = month - 1; 142 let prevYear = year; 143 if (prevMonth < 1) { 144 prevMonth = 12; 145 prevYear--; 146 } 147 148 let nextMonth = month + 1; 149 let nextYear = year; 150 if (nextMonth > 12) { 151 nextMonth = 1; 152 nextYear++; 153 } 154 155 const navBtns = container.querySelectorAll('.cal-nav-btn'); 156 navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 157 navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 158 159 // Rebuild calendar grid 160 const tbody = container.querySelector('.calendar-compact-grid tbody'); 161 const firstDay = new Date(year, month - 1, 1); 162 const daysInMonth = new Date(year, month, 0).getDate(); 163 const dayOfWeek = firstDay.getDay(); 164 165 // Calculate month boundaries 166 const monthStart = new Date(year, month - 1, 1); 167 const monthEnd = new Date(year, month - 1, daysInMonth); 168 169 // Build a map of all events with their date ranges 170 const eventRanges = {}; 171 for (const [dateKey, dayEvents] of Object.entries(events)) { 172 // Only process events that could possibly overlap with this month/year 173 const dateYear = parseInt(dateKey.split('-')[0]); 174 const dateMonth = parseInt(dateKey.split('-')[1]); 175 176 // Skip events from completely different years (unless they're very long multi-day events) 177 if (Math.abs(dateYear - year) > 1) { 178 continue; 179 } 180 181 for (const evt of dayEvents) { 182 const startDate = dateKey; 183 const endDate = evt.endDate || dateKey; 184 185 // Check if event overlaps with current month 186 const eventStart = new Date(startDate + 'T00:00:00'); 187 const eventEnd = new Date(endDate + 'T00:00:00'); 188 189 // Skip if event doesn't overlap with current month 190 if (eventEnd < monthStart || eventStart > monthEnd) { 191 continue; 192 } 193 194 // Create entry for each day the event spans 195 const start = new Date(startDate + 'T00:00:00'); 196 const end = new Date(endDate + 'T00:00:00'); 197 const current = new Date(start); 198 199 while (current <= end) { 200 const currentKey = current.toISOString().split('T')[0]; 201 202 // Check if this date is in current month 203 const currentDate = new Date(currentKey + 'T00:00:00'); 204 if (currentDate.getFullYear() === year && currentDate.getMonth() === month - 1) { 205 if (!eventRanges[currentKey]) { 206 eventRanges[currentKey] = []; 207 } 208 209 // Add event with span information 210 const eventCopy = {...evt}; 211 eventCopy._span_start = startDate; 212 eventCopy._span_end = endDate; 213 eventCopy._is_first_day = (currentKey === startDate); 214 eventCopy._is_last_day = (currentKey === endDate); 215 eventCopy._original_date = dateKey; 216 217 // Check if event continues from previous month or to next month 218 eventCopy._continues_from_prev = (eventStart < monthStart); 219 eventCopy._continues_to_next = (eventEnd > monthEnd); 220 221 eventRanges[currentKey].push(eventCopy); 222 } 223 224 current.setDate(current.getDate() + 1); 225 } 226 } 227 } 228 229 let html = ''; 230 let currentDay = 1; 231 const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7); 232 233 for (let row = 0; row < rowCount; row++) { 234 html += '<tr>'; 235 for (let col = 0; col < 7; col++) { 236 if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) { 237 html += '<td class="cal-empty"></td>'; 238 } else { 239 const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`; 240 241 // Get today's date in local timezone 242 const todayObj = new Date(); 243 const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`; 244 245 const isToday = dateKey === today; 246 const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0; 247 248 let classes = 'cal-day'; 249 if (isToday) classes += ' cal-today'; 250 if (hasEvents) classes += ' cal-has-events'; 251 252 html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`; 253 html += `<span class="day-num">${currentDay}</span>`; 254 255 if (hasEvents) { 256 // Sort events by time (no time first, then by time) 257 const sortedEvents = [...eventRanges[dateKey]].sort((a, b) => { 258 const timeA = a.time || ''; 259 const timeB = b.time || ''; 260 261 // Events without time go first 262 if (!timeA && timeB) return -1; 263 if (timeA && !timeB) return 1; 264 if (!timeA && !timeB) return 0; 265 266 // Sort by time 267 return timeA.localeCompare(timeB); 268 }); 269 270 // Show colored stacked bars for each event 271 html += '<div class="event-indicators">'; 272 for (const evt of sortedEvents) { 273 const eventId = evt.id || ''; 274 const eventColor = evt.color || '#3498db'; 275 const eventTime = evt.time || ''; 276 const eventTitle = evt.title || 'Event'; 277 const originalDate = evt._original_date || dateKey; 278 const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true; 279 const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true; 280 281 let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed'; 282 283 // Add classes for multi-day spanning 284 if (!isFirstDay) barClass += ' event-bar-continues'; 285 if (!isLastDay) barClass += ' event-bar-continuing'; 286 287 html += `<span class="event-bar ${barClass}" `; 288 html += `style="background: ${eventColor};" `; 289 html += `title="${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `; 290 html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');"></span>`; 291 } 292 html += '</div>'; 293 } 294 295 html += '</td>'; 296 currentDay++; 297 } 298 } 299 html += '</tr>'; 300 } 301 302 tbody.innerHTML = html; 303 304 // Rebuild event list - show events that overlap with current month 305 const currentMonthEvents = {}; 306 307 for (const [dateKey, dayEvents] of Object.entries(events)) { 308 for (const event of dayEvents) { 309 const startDate = dateKey; 310 const endDate = event.endDate || dateKey; 311 312 // Check if event overlaps with current month 313 // Event starts before month ends AND ends after month starts 314 const eventStartObj = new Date(startDate); 315 const eventEndObj = new Date(endDate); 316 const monthFirstDay = new Date(year, month - 1, 1); 317 const monthLastDay = new Date(year, month - 1, daysInMonth); 318 319 // Include if event overlaps this month at all 320 if (eventEndObj >= monthFirstDay && eventStartObj <= monthLastDay) { 321 if (!currentMonthEvents[dateKey]) { 322 currentMonthEvents[dateKey] = []; 323 } 324 currentMonthEvents[dateKey].push(event); 325 } 326 } 327 } 328 329 const eventList = container.querySelector('.event-list-compact'); 330 eventList.innerHTML = renderEventListFromData(currentMonthEvents, calId, namespace); 331 332 // Update title 333 const title = container.querySelector('#eventlist-title-' + calId); 334 title.textContent = 'Events'; 335} 336 337// Render event list from data 338function renderEventListFromData(events, calId, namespace, year, month) { 339 if (!events || Object.keys(events).length === 0) { 340 return '<p class="no-events-msg">No events this month</p>'; 341 } 342 343 let html = ''; 344 const sortedDates = Object.keys(events).sort(); 345 346 // Filter events to only current month if year/month provided 347 const monthStart = year && month ? new Date(year, month - 1, 1) : null; 348 const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null; 349 350 for (const dateKey of sortedDates) { 351 // Skip events not in current month if filtering 352 if (monthStart && monthEnd) { 353 const eventDate = new Date(dateKey + 'T00:00:00'); 354 if (eventDate < monthStart || eventDate > monthEnd) { 355 continue; 356 } 357 } 358 359 const dayEvents = events[dateKey]; 360 for (const event of dayEvents) { 361 html += renderEventItem(event, dateKey, calId, namespace); 362 } 363 } 364 365 if (!html) { 366 return '<p class="no-events-msg">No events this month</p>'; 367 } 368 369 return html; 370} 371 372// Show day popup with events when clicking a date 373function showDayPopup(calId, date, namespace) { 374 // Get events for this calendar 375 const eventsDataEl = document.getElementById('events-data-' + calId); 376 let events = {}; 377 378 if (eventsDataEl) { 379 try { 380 events = JSON.parse(eventsDataEl.textContent); 381 } catch (e) { 382 console.error('Failed to parse events data:', e); 383 } 384 } 385 386 const dayEvents = events[date] || []; 387 const dateObj = new Date(date + 'T00:00:00'); 388 const displayDate = dateObj.toLocaleDateString('en-US', { 389 weekday: 'long', 390 month: 'long', 391 day: 'numeric', 392 year: 'numeric' 393 }); 394 395 // Create popup 396 let popup = document.getElementById('day-popup-' + calId); 397 if (!popup) { 398 popup = document.createElement('div'); 399 popup.id = 'day-popup-' + calId; 400 popup.className = 'day-popup'; 401 document.body.appendChild(popup); 402 } 403 404 let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>'; 405 html += '<div class="day-popup-content">'; 406 html += '<div class="day-popup-header">'; 407 html += '<h4>' + displayDate + '</h4>'; 408 html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>'; 409 html += '</div>'; 410 411 html += '<div class="day-popup-body">'; 412 413 if (dayEvents.length === 0) { 414 html += '<p class="no-events-msg">No events on this day</p>'; 415 } else { 416 html += '<div class="popup-events-list">'; 417 dayEvents.forEach(event => { 418 const color = event.color || '#3498db'; 419 420 // Convert to 12-hour format 421 let displayTime = ''; 422 if (event.time) { 423 const timeParts = event.time.split(':'); 424 if (timeParts.length === 2) { 425 let hour = parseInt(timeParts[0]); 426 const minute = timeParts[1]; 427 const ampm = hour >= 12 ? 'PM' : 'AM'; 428 hour = hour % 12 || 12; 429 displayTime = hour + ':' + minute + ' ' + ampm; 430 } else { 431 displayTime = event.time; 432 } 433 } 434 435 html += '<div class="popup-event-item">'; 436 html += '<div class="event-color-bar" style="background: ' + color + ';"></div>'; 437 html += '<div class="popup-event-content">'; 438 html += '<div class="popup-event-title">' + escapeHtml(event.title) + '</div>'; 439 if (displayTime) { 440 html += '<div class="popup-event-time"> ' + displayTime + '</div>'; 441 } 442 if (event.description) { 443 html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>'; 444 } 445 html += '<div class="popup-event-actions">'; 446 html += '<button class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + namespace + '\'); closeDayPopup(\'' + calId + '\')">Edit</button>'; 447 html += '<button class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + namespace + '\'); closeDayPopup(\'' + calId + '\')">Delete</button>'; 448 html += '</div>'; 449 html += '</div></div>'; 450 }); 451 html += '</div>'; 452 } 453 454 html += '</div>'; 455 456 html += '<div class="day-popup-footer">'; 457 html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>'; 458 html += '</div>'; 459 460 html += '</div>'; 461 462 popup.innerHTML = html; 463 popup.style.display = 'flex'; 464} 465 466// Close day popup 467function closeDayPopup(calId) { 468 const popup = document.getElementById('day-popup-' + calId); 469 if (popup) { 470 popup.style.display = 'none'; 471 } 472} 473 474// Show events for a specific day (for event list panel) 475function showDayEvents(calId, date, namespace) { 476 const params = new URLSearchParams({ 477 call: 'plugin_calendar', 478 action: 'load_month', 479 year: date.split('-')[0], 480 month: parseInt(date.split('-')[1]), 481 namespace: namespace 482 }); 483 484 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 485 method: 'POST', 486 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 487 body: params.toString() 488 }) 489 .then(r => r.json()) 490 .then(data => { 491 if (data.success) { 492 const eventList = document.getElementById('eventlist-' + calId); 493 const events = data.events; 494 const title = document.getElementById('eventlist-title-' + calId); 495 496 const dateObj = new Date(date + 'T00:00:00'); 497 const displayDate = dateObj.toLocaleDateString('en-US', { 498 weekday: 'short', 499 month: 'short', 500 day: 'numeric' 501 }); 502 503 title.textContent = 'Events - ' + displayDate; 504 505 // Filter events for this day 506 const dayEvents = events[date] || []; 507 508 if (dayEvents.length === 0) { 509 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>'; 510 } else { 511 let html = ''; 512 dayEvents.forEach(event => { 513 html += renderEventItem(event, date, calId, namespace); 514 }); 515 eventList.innerHTML = html; 516 } 517 } 518 }) 519 .catch(err => console.error('Error:', err)); 520} 521 522// Render a single event item 523function renderEventItem(event, date, calId, namespace) { 524 // Format date display with day of week 525 const dateObj = new Date(date + 'T00:00:00'); 526 const displayDate = dateObj.toLocaleDateString('en-US', { 527 weekday: 'short', 528 month: 'short', 529 day: 'numeric' 530 }); 531 532 // Convert to 12-hour format 533 let displayTime = ''; 534 if (event.time) { 535 const timeParts = event.time.split(':'); 536 if (timeParts.length === 2) { 537 let hour = parseInt(timeParts[0]); 538 const minute = timeParts[1]; 539 const ampm = hour >= 12 ? 'PM' : 'AM'; 540 hour = hour % 12 || 12; 541 displayTime = hour + ':' + minute + ' ' + ampm; 542 } else { 543 displayTime = event.time; 544 } 545 } 546 547 // Multi-day indicator 548 let multiDay = ''; 549 if (event.endDate && event.endDate !== date) { 550 const endObj = new Date(event.endDate + 'T00:00:00'); 551 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 552 weekday: 'short', 553 month: 'short', 554 day: 'numeric' 555 }); 556 } 557 558 const completedClass = event.completed ? ' event-completed' : ''; 559 const color = event.color || '#3498db'; 560 const isTask = event.isTask || false; 561 const completed = event.completed || false; 562 563 let html = '<div class="event-compact-item' + completedClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ';">'; 564 565 html += '<div class="event-info">'; 566 html += '<div class="event-title-row">'; 567 html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>'; 568 html += '</div>'; 569 570 html += '<div class="event-meta-compact">'; 571 html += '<span class="event-date-time">' + displayDate + multiDay; 572 if (displayTime) { 573 html += ' • ' + displayTime; 574 } 575 html += '</span>'; 576 html += '</div>'; 577 578 if (event.description) { 579 html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>'; 580 } 581 582 html += '</div>'; // event-info 583 584 html += '<div class="event-actions-compact">'; 585 html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + namespace + '\')">️</button>'; 586 html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + namespace + '\')">✏️</button>'; 587 html += '</div>'; 588 589 // Checkbox for tasks - ON THE FAR RIGHT 590 if (isTask) { 591 const checked = completed ? 'checked' : ''; 592 html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + namespace + '\', this.checked)">'; 593 } 594 595 html += '</div>'; 596 597 return html; 598} 599 600// Render description with rich content support 601function renderDescription(description) { 602 if (!description) return ''; 603 604 // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping) 605 // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00 606 607 let rendered = description; 608 const tokens = []; 609 let tokenIndex = 0; 610 611 // Convert DokuWiki image syntax {{image.jpg}} to tokens 612 rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) { 613 imagePath = imagePath.trim(); 614 alt = alt ? alt.trim() : ''; 615 616 let imageHtml; 617 // Handle external URLs 618 if (imagePath.match(/^https?:\/\//)) { 619 imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 620 } else { 621 // Handle internal DokuWiki images 622 const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath); 623 imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 624 } 625 626 const token = '\x00TOKEN' + tokenIndex + '\x00'; 627 tokens[tokenIndex] = imageHtml; 628 tokenIndex++; 629 return token; 630 }); 631 632 // Convert DokuWiki link syntax [[link|text]] to tokens 633 rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) { 634 link = link.trim(); 635 text = text ? text.trim() : link; 636 637 let linkHtml; 638 // Handle external URLs 639 if (link.match(/^https?:\/\//)) { 640 linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 641 } else { 642 // Handle internal DokuWiki links with section anchors 643 const hashIndex = link.indexOf('#'); 644 let pagePart = link; 645 let sectionPart = ''; 646 647 if (hashIndex !== -1) { 648 pagePart = link.substring(0, hashIndex); 649 sectionPart = link.substring(hashIndex); // Includes the # 650 } 651 652 const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart; 653 linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>'; 654 } 655 656 const token = '\x00TOKEN' + tokenIndex + '\x00'; 657 tokens[tokenIndex] = linkHtml; 658 tokenIndex++; 659 return token; 660 }); 661 662 // Convert markdown-style links [text](url) to tokens 663 rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { 664 text = text.trim(); 665 url = url.trim(); 666 667 let linkHtml; 668 if (url.match(/^https?:\/\//)) { 669 linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 670 } else { 671 linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>'; 672 } 673 674 const token = '\x00TOKEN' + tokenIndex + '\x00'; 675 tokens[tokenIndex] = linkHtml; 676 tokenIndex++; 677 return token; 678 }); 679 680 // Convert plain URLs to tokens 681 rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) { 682 const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>'; 683 const token = '\x00TOKEN' + tokenIndex + '\x00'; 684 tokens[tokenIndex] = linkHtml; 685 tokenIndex++; 686 return token; 687 }); 688 689 // NOW escape the remaining text (tokens are protected with null bytes) 690 rendered = escapeHtml(rendered); 691 692 // Convert newlines to <br> 693 rendered = rendered.replace(/\n/g, '<br>'); 694 695 // DokuWiki text formatting (on escaped text) 696 // Bold: **text** or __text__ 697 rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 698 rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>'); 699 700 // Italic: //text// 701 rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>'); 702 703 // Strikethrough: <del>text</del> 704 rendered = rendered.replace(/<del>(.+?)<\/del>/g, '<del>$1</del>'); 705 706 // Monospace: ''text'' 707 rendered = rendered.replace(/''(.+?)''/g, '<code>$1</code>'); 708 709 // Subscript: <sub>text</sub> 710 rendered = rendered.replace(/<sub>(.+?)<\/sub>/g, '<sub>$1</sub>'); 711 712 // Superscript: <sup>text</sup> 713 rendered = rendered.replace(/<sup>(.+?)<\/sup>/g, '<sup>$1</sup>'); 714 715 // Restore tokens (replace with actual HTML) 716 for (let i = 0; i < tokens.length; i++) { 717 const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g'); 718 rendered = rendered.replace(tokenPattern, tokens[i]); 719 } 720 721 return rendered; 722} 723 724// Open add event dialog 725function openAddEvent(calId, namespace, date) { 726 const dialog = document.getElementById('dialog-' + calId); 727 const form = document.getElementById('eventform-' + calId); 728 const title = document.getElementById('dialog-title-' + calId); 729 const dateField = document.getElementById('event-date-' + calId); 730 731 if (!dateField) { 732 console.error('Date field not found! ID: event-date-' + calId); 733 return; 734 } 735 736 // Reset form 737 form.reset(); 738 document.getElementById('event-id-' + calId).value = ''; 739 740 // Set date - use local date, not UTC 741 let defaultDate = date; 742 if (!defaultDate) { 743 const today = new Date(); 744 const year = today.getFullYear(); 745 const month = String(today.getMonth() + 1).padStart(2, '0'); 746 const day = String(today.getDate()).padStart(2, '0'); 747 defaultDate = `${year}-${month}-${day}`; 748 } 749 dateField.value = defaultDate; 750 dateField.removeAttribute('data-original-date'); 751 752 // Set default color 753 document.getElementById('event-color-' + calId).value = '#3498db'; 754 755 // Set title 756 title.textContent = 'Add Event'; 757 758 // Show dialog 759 dialog.style.display = 'flex'; 760 761 // Focus title field 762 setTimeout(() => { 763 const titleField = document.getElementById('event-title-' + calId); 764 if (titleField) titleField.focus(); 765 }, 100); 766} 767 768// Edit event 769function editEvent(calId, eventId, date, namespace) { 770 const params = new URLSearchParams({ 771 call: 'plugin_calendar', 772 action: 'get_event', 773 namespace: namespace, 774 date: date, 775 eventId: eventId 776 }); 777 778 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 779 method: 'POST', 780 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 781 body: params.toString() 782 }) 783 .then(r => r.json()) 784 .then(data => { 785 if (data.success && data.event) { 786 const event = data.event; 787 const dialog = document.getElementById('dialog-' + calId); 788 const title = document.getElementById('dialog-title-' + calId); 789 const dateField = document.getElementById('event-date-' + calId); 790 791 if (!dateField) { 792 console.error('Date field not found when editing!'); 793 return; 794 } 795 796 // Populate form 797 document.getElementById('event-id-' + calId).value = event.id; 798 dateField.value = date; 799 dateField.setAttribute('data-original-date', date); 800 document.getElementById('event-end-date-' + calId).value = event.endDate || ''; 801 document.getElementById('event-title-' + calId).value = event.title; 802 document.getElementById('event-time-' + calId).value = event.time || ''; 803 document.getElementById('event-color-' + calId).value = event.color || '#3498db'; 804 document.getElementById('event-desc-' + calId).value = event.description || ''; 805 document.getElementById('event-is-task-' + calId).checked = event.isTask || false; 806 807 title.textContent = 'Edit Event'; 808 dialog.style.display = 'flex'; 809 } 810 }) 811 .catch(err => console.error('Error editing event:', err)); 812} 813 814// Delete event 815function deleteEvent(calId, eventId, date, namespace) { 816 if (!confirm('Delete this event?')) return; 817 818 const params = new URLSearchParams({ 819 call: 'plugin_calendar', 820 action: 'delete_event', 821 namespace: namespace, 822 date: date, 823 eventId: eventId 824 }); 825 826 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 827 method: 'POST', 828 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 829 body: params.toString() 830 }) 831 .then(r => r.json()) 832 .then(data => { 833 if (data.success) { 834 // Extract year and month from date 835 const [year, month] = date.split('-').map(Number); 836 837 // Reload calendar data via AJAX 838 reloadCalendarData(calId, year, month, namespace); 839 } 840 }) 841 .catch(err => console.error('Error:', err)); 842} 843 844// Save event (add or edit) 845function saveEventCompact(calId, namespace) { 846 const eventId = document.getElementById('event-id-' + calId).value; 847 const dateInput = document.getElementById('event-date-' + calId); 848 const date = dateInput.value; 849 const oldDate = dateInput.getAttribute('data-original-date') || date; 850 const endDate = document.getElementById('event-end-date-' + calId).value; 851 const title = document.getElementById('event-title-' + calId).value; 852 const time = document.getElementById('event-time-' + calId).value; 853 const color = document.getElementById('event-color-' + calId).value; 854 const description = document.getElementById('event-desc-' + calId).value; 855 const isTask = document.getElementById('event-is-task-' + calId).checked; 856 const completed = false; // New tasks are not completed 857 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 858 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 859 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 860 861 if (!title) { 862 alert('Please enter a title'); 863 return; 864 } 865 866 if (!date) { 867 alert('Please select a date'); 868 return; 869 } 870 871 const params = new URLSearchParams({ 872 call: 'plugin_calendar', 873 action: 'save_event', 874 namespace: namespace, 875 eventId: eventId, 876 date: date, 877 oldDate: oldDate, 878 endDate: endDate, 879 title: title, 880 time: time, 881 color: color, 882 description: description, 883 isTask: isTask ? '1' : '0', 884 completed: completed ? '1' : '0', 885 isRecurring: isRecurring ? '1' : '0', 886 recurrenceType: recurrenceType, 887 recurrenceEnd: recurrenceEnd 888 }); 889 890 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 891 method: 'POST', 892 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 893 body: params.toString() 894 }) 895 .then(r => r.json()) 896 .then(data => { 897 if (data.success) { 898 closeEventDialog(calId); 899 900 // For recurring events, do a full page reload to show all occurrences 901 if (isRecurring) { 902 location.reload(); 903 return; 904 } 905 906 // Extract year and month from the NEW date (in case date was changed) 907 const [year, month] = date.split('-').map(Number); 908 909 // Reload calendar data via AJAX to the month of the event 910 reloadCalendarData(calId, year, month, namespace); 911 } else { 912 alert('Error: ' + (data.error || 'Unknown error')); 913 } 914 }) 915 .catch(err => { 916 console.error('Error:', err); 917 alert('Error saving event'); 918 }); 919} 920 921// Reload calendar data without page refresh 922function reloadCalendarData(calId, year, month, namespace) { 923 const params = new URLSearchParams({ 924 call: 'plugin_calendar', 925 action: 'load_month', 926 year: year, 927 month: month, 928 namespace: namespace, 929 _: new Date().getTime() // Cache buster 930 }); 931 932 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 933 method: 'POST', 934 headers: { 935 'Content-Type': 'application/x-www-form-urlencoded', 936 'Cache-Control': 'no-cache, no-store, must-revalidate', 937 'Pragma': 'no-cache' 938 }, 939 body: params.toString() 940 }) 941 .then(r => r.json()) 942 .then(data => { 943 if (data.success) { 944 const container = document.getElementById(calId); 945 946 // Check if this is a full calendar or just event panel 947 if (container.classList.contains('calendar-compact-container')) { 948 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 949 } else if (container.classList.contains('event-panel-standalone')) { 950 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 951 } 952 } 953 }) 954 .catch(err => console.error('Error:', err)); 955} 956 957// Close event dialog 958function closeEventDialog(calId) { 959 const dialog = document.getElementById('dialog-' + calId); 960 dialog.style.display = 'none'; 961} 962 963// Escape HTML 964function escapeHtml(text) { 965 const div = document.createElement('div'); 966 div.textContent = text; 967 return div.innerHTML; 968} 969 970// Highlight event when clicking on bar in calendar 971function highlightEvent(calId, eventId, date) { 972 // Find the event item in the event list 973 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 974 if (!eventList) return; 975 976 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 977 if (!eventItem) return; 978 979 // Remove previous highlights 980 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 981 previousHighlights.forEach(el => el.classList.remove('event-highlighted')); 982 983 // Add highlight 984 eventItem.classList.add('event-highlighted'); 985 986 // Scroll to event 987 eventItem.scrollIntoView({ 988 behavior: 'smooth', 989 block: 'nearest', 990 inline: 'nearest' 991 }); 992 993 // Remove highlight after 3 seconds 994 setTimeout(() => { 995 eventItem.classList.remove('event-highlighted'); 996 }, 3000); 997} 998 999// Toggle recurring event options 1000function toggleRecurringOptions(calId) { 1001 const checkbox = document.getElementById('event-recurring-' + calId); 1002 const options = document.getElementById('recurring-options-' + calId); 1003 1004 if (checkbox && options) { 1005 options.style.display = checkbox.checked ? 'block' : 'none'; 1006 } 1007} 1008 1009// Close dialog on escape key 1010document.addEventListener('keydown', function(e) { 1011 if (e.key === 'Escape') { 1012 const dialogs = document.querySelectorAll('.event-dialog-compact'); 1013 dialogs.forEach(dialog => { 1014 if (dialog.style.display === 'flex') { 1015 dialog.style.display = 'none'; 1016 } 1017 }); 1018 } 1019}); 1020 1021// Event panel navigation 1022function navEventPanel(calId, year, month, namespace) { 1023 const params = new URLSearchParams({ 1024 call: 'plugin_calendar', 1025 action: 'load_month', 1026 year: year, 1027 month: month, 1028 namespace: namespace 1029 }); 1030 1031 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1032 method: 'POST', 1033 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1034 body: params.toString() 1035 }) 1036 .then(r => r.json()) 1037 .then(data => { 1038 if (data.success) { 1039 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1040 } 1041 }) 1042 .catch(err => console.error('Error:', err)); 1043} 1044 1045// Rebuild event panel only 1046function rebuildEventPanel(calId, year, month, events, namespace) { 1047 const container = document.getElementById(calId); 1048 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 1049 'July', 'August', 'September', 'October', 'November', 'December']; 1050 1051 // Update header - preserve the onclick and classes 1052 const headerContent = container.querySelector('.panel-header-content'); 1053 const header = container.querySelector('.panel-standalone-header h3'); 1054 if (header) { 1055 header.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 1056 header.className = 'calendar-month-picker'; 1057 header.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1058 header.setAttribute('title', 'Click to jump to month'); 1059 } 1060 1061 // Update namespace badge if needed (preserve existing one) 1062 // The namespace badge should already exist and doesn't need updating 1063 1064 // Update nav buttons 1065 let prevMonth = month - 1; 1066 let prevYear = year; 1067 if (prevMonth < 1) { 1068 prevMonth = 12; 1069 prevYear--; 1070 } 1071 1072 let nextMonth = month + 1; 1073 let nextYear = year; 1074 if (nextMonth > 12) { 1075 nextMonth = 1; 1076 nextYear++; 1077 } 1078 1079 const navBtns = container.querySelectorAll('.cal-nav-btn'); 1080 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1081 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1082 1083 // Update Today button 1084 const todayBtn = container.querySelector('.cal-today-btn'); 1085 if (todayBtn) { 1086 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 1087 } 1088 1089 // Rebuild event list 1090 const eventList = container.querySelector('.event-list-compact'); 1091 if (eventList) { 1092 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 1093 } 1094} 1095 1096// Open add event for panel 1097function openAddEventPanel(calId, namespace) { 1098 const today = new Date(); 1099 const year = today.getFullYear(); 1100 const month = String(today.getMonth() + 1).padStart(2, '0'); 1101 const day = String(today.getDate()).padStart(2, '0'); 1102 const localDate = `${year}-${month}-${day}`; 1103 openAddEvent(calId, namespace, localDate); 1104} 1105 1106// Toggle task completion 1107function toggleTaskComplete(calId, eventId, date, namespace, completed) { 1108 const params = new URLSearchParams({ 1109 call: 'plugin_calendar', 1110 action: 'toggle_task', 1111 namespace: namespace, 1112 date: date, 1113 eventId: eventId, 1114 completed: completed ? '1' : '0' 1115 }); 1116 1117 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1118 method: 'POST', 1119 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1120 body: params.toString() 1121 }) 1122 .then(r => r.json()) 1123 .then(data => { 1124 if (data.success) { 1125 const [year, month] = date.split('-').map(Number); 1126 reloadCalendarData(calId, year, month, namespace); 1127 } 1128 }) 1129 .catch(err => console.error('Error toggling task:', err)); 1130} 1131 1132// Make dialog draggable 1133function makeDialogDraggable(calId) { 1134 const dialog = document.getElementById('dialog-content-' + calId); 1135 const handle = document.getElementById('drag-handle-' + calId); 1136 1137 if (!dialog || !handle) return; 1138 1139 let isDragging = false; 1140 let currentX; 1141 let currentY; 1142 let initialX; 1143 let initialY; 1144 let xOffset = 0; 1145 let yOffset = 0; 1146 1147 handle.addEventListener('mousedown', dragStart); 1148 document.addEventListener('mousemove', drag); 1149 document.addEventListener('mouseup', dragEnd); 1150 1151 function dragStart(e) { 1152 initialX = e.clientX - xOffset; 1153 initialY = e.clientY - yOffset; 1154 isDragging = true; 1155 } 1156 1157 function drag(e) { 1158 if (isDragging) { 1159 e.preventDefault(); 1160 currentX = e.clientX - initialX; 1161 currentY = e.clientY - initialY; 1162 xOffset = currentX; 1163 yOffset = currentY; 1164 setTranslate(currentX, currentY, dialog); 1165 } 1166 } 1167 1168 function dragEnd(e) { 1169 initialX = currentX; 1170 initialY = currentY; 1171 isDragging = false; 1172 } 1173 1174 function setTranslate(xPos, yPos, el) { 1175 el.style.transform = `translate(${xPos}px, ${yPos}px)`; 1176 } 1177} 1178 1179// Initialize dialog draggability when opened 1180const originalOpenAddEvent = openAddEvent; 1181openAddEvent = function(calId, namespace, date) { 1182 originalOpenAddEvent(calId, namespace, date); 1183 setTimeout(() => makeDialogDraggable(calId), 100); 1184}; 1185 1186const originalEditEvent = editEvent; 1187editEvent = function(calId, eventId, date, namespace) { 1188 originalEditEvent(calId, eventId, date, namespace); 1189 setTimeout(() => makeDialogDraggable(calId), 100); 1190}; 1191