xref: /plugin/calendar/script.js (revision bdb3bb1a2d6a40681ba422747683dfeeba39924e)
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(/&lt;del&gt;(.+?)&lt;\/del&gt;/g, '<del>$1</del>');
705
706    // Monospace: ''text''
707    rendered = rendered.replace(/&#39;&#39;(.+?)&#39;&#39;/g, '<code>$1</code>');
708
709    // Subscript: <sub>text</sub>
710    rendered = rendered.replace(/&lt;sub&gt;(.+?)&lt;\/sub&gt;/g, '<sub>$1</sub>');
711
712    // Superscript: <sup>text</sup>
713    rendered = rendered.replace(/&lt;sup&gt;(.+?)&lt;\/sup&gt;/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