/** * Modul: Kalender (Calendar) * Zweck: Monats-/Wochen-/Tages-/Agenda-Ansicht mit vollem Termin-CRUD * Abhängigkeiten: /api.js, /router.js (window.oikos) */ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatTime } from '/i18n.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const VIEWS = ['month', 'week', 'day', 'agenda']; const VIEW_LABELS = () => ({ month: t('calendar.viewMonth'), week: t('calendar.viewWeek'), day: t('calendar.viewDay'), agenda: t('calendar.viewAgenda'), }); const DAY_NAMES_SHORT = () => [ t('calendar.dayShortSunday'), t('calendar.dayShortMonday'), t('calendar.dayShortTuesday'), t('calendar.dayShortWednesday'), t('calendar.dayShortThursday'), t('calendar.dayShortFriday'), t('calendar.dayShortSaturday'), ]; const DAY_NAMES_LONG = () => [ t('calendar.dayLongSunday'), t('calendar.dayLongMonday'), t('calendar.dayLongTuesday'), t('calendar.dayLongWednesday'), t('calendar.dayLongThursday'), t('calendar.dayLongFriday'), t('calendar.dayLongSaturday'), ]; const MONTH_NAMES = () => [ t('calendar.monthJanuary'), t('calendar.monthFebruary'), t('calendar.monthMarch'), t('calendar.monthApril'), t('calendar.monthMay'), t('calendar.monthJune'), t('calendar.monthJuly'), t('calendar.monthAugust'), t('calendar.monthSeptember'), t('calendar.monthOctober'), t('calendar.monthNovember'), t('calendar.monthDecember'), ]; const EVENT_COLORS = [ '#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF6B35', '#5AC8FA', '#FFCC00', '#8E8E93', '#30B0C7', ]; const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht // -------------------------------------------------------- // State // -------------------------------------------------------- let state = { view: 'month', today: '', cursor: null, // aktuell angezeigte Referenz-Datum (YYYY-MM-DD) events: [], users: [], rangeFrom: '', rangeTo: '', }; let _container = null; // -------------------------------------------------------- // Datumshelfer // -------------------------------------------------------- function pad(n) { return String(n).padStart(2, '0'); } function isoDate(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function addMonths(dateStr, n) { const d = new Date(dateStr + 'T00:00:00'); d.setMonth(d.getMonth() + n); return isoDate(d); } function addDays(dateStr, n) { const d = new Date(dateStr + 'T00:00:00'); d.setDate(d.getDate() + n); return isoDate(d); } function getMondayOf(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const day = d.getDay(); const diff = (day === 0 ? -6 : 1 - day); d.setDate(d.getDate() + diff); return isoDate(d); } function formatDate(dateStr, { long = false, weekday = false } = {}) { const d = new Date(dateStr + 'T00:00:00'); const day = d.getDate(); const mon = MONTH_NAMES()[d.getMonth()]; if (weekday) { const wd = long ? DAY_NAMES_LONG()[d.getDay()] : DAY_NAMES_SHORT()[d.getDay()]; return `${wd}, ${day}. ${mon}`; } return `${day}. ${mon} ${d.getFullYear()}`; } function formatDateTime(datetimeStr) { if (!datetimeStr) return ''; const date = datetimeStr.slice(0, 10); const hasTime = datetimeStr.length > 10 && datetimeStr.slice(11, 16).trim() !== ''; const time = hasTime ? formatTime(datetimeStr) : ''; return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date); } function getMonthRange(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const year = d.getFullYear(); const month = d.getMonth(); const from = `${year}-${pad(month + 1)}-01`; // Extra Tage für Kalenderraster (6 Wochen × 7 = 42 Tage) const to = addDays(from, 41); return { from, to }; } function getWeekRange(dateStr) { const monday = getMondayOf(dateStr); return { from: monday, to: addDays(monday, 6) }; } function getAgendaRange(dateStr) { return { from: dateStr, to: addDays(dateStr, 30) }; } function eventsOnDay(dateStr) { return state.events.filter((e) => { const start = e.start_datetime.slice(0, 10); const end = e.end_datetime ? e.end_datetime.slice(0, 10) : start; return start <= dateStr && end >= dateStr; }); } // -------------------------------------------------------- // API // -------------------------------------------------------- async function loadRange(from, to) { try { const res = await api.get(`/calendar?from=${from}&to=${to}`); state.events = res.data; } catch (err) { console.error('[Calendar] loadRange Fehler:', err); state.events = []; window.oikos?.showToast(t('calendar.loadError'), 'danger'); } state.rangeFrom = from; state.rangeTo = to; } async function loadUsers() { try { const res = await api.get('/auth/users'); state.users = res.data; } catch { state.users = []; } } // -------------------------------------------------------- // Entry Point // -------------------------------------------------------- export async function render(container, { user }) { _container = container; state.today = isoDate(new Date()); state.cursor = state.today; state.view = 'month'; container.innerHTML = `
`; const { from, to } = getMonthRange(state.cursor); await Promise.all([loadRange(from, to), loadUsers()]); renderToolbar(); renderView(); container.querySelector('#fab-new-event')?.addEventListener('click', () => openEventModal({ mode: 'create' })); } // -------------------------------------------------------- // Toolbar // -------------------------------------------------------- function renderToolbar() { const bar = _container.querySelector('#cal-toolbar'); if (!bar) return; bar.innerHTML = `

${t('calendar.title')}

${VIEWS.map((v) => ` `).join('')}
`; if (window.lucide) lucide.createIcons(); updateLabel(); bar.querySelector('#cal-prev').addEventListener('click', () => navigate(-1)); bar.querySelector('#cal-next').addEventListener('click', () => navigate(1)); bar.querySelector('#cal-today').addEventListener('click', goToday); bar.querySelector('#cal-add').addEventListener('click', () => openEventModal({ mode: 'create' })); bar.querySelectorAll('[data-view]').forEach((btn) => { btn.addEventListener('click', async () => { if (btn.dataset.view === state.view) return; state.view = btn.dataset.view; bar.querySelectorAll('[data-view]').forEach((b) => b.classList.toggle('cal-toolbar__view-btn--active', b.dataset.view === state.view) ); await reloadForView(); renderView(); }); }); } function updateLabel() { const lbl = _container.querySelector('#cal-label'); if (!lbl) return; const d = new Date(state.cursor + 'T00:00:00'); const year = d.getFullYear(); const mon = MONTH_NAMES()[d.getMonth()]; if (state.view === 'month') lbl.textContent = `${mon} ${year}`; if (state.view === 'week') lbl.textContent = t('calendar.weekNumberLabel', { week: getWeekNumber(state.cursor), month: mon, year }); if (state.view === 'day') lbl.textContent = formatDate(state.cursor, { weekday: true, long: true }); if (state.view === 'agenda') lbl.textContent = t('calendar.agendaFrom', { date: formatDate(state.cursor) }); } function getWeekNumber(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const jan = new Date(d.getFullYear(), 0, 1); return Math.ceil(((d - jan) / 86400000 + jan.getDay() + 1) / 7); } async function navigate(dir) { if (state.view === 'month') { state.cursor = addMonths(state.cursor, dir); } else if (state.view === 'week') { state.cursor = addDays(state.cursor, dir * 7); } else if (state.view === 'day') { state.cursor = addDays(state.cursor, dir); } else if (state.view === 'agenda') { state.cursor = addDays(state.cursor, dir * 30); } await reloadForView(); updateLabel(); renderView(); } async function goToday() { state.cursor = state.today; await reloadForView(); updateLabel(); renderView(); } async function reloadForView() { let from, to; if (state.view === 'month') ({ from, to } = getMonthRange(state.cursor)); if (state.view === 'week') ({ from, to } = getWeekRange(state.cursor)); if (state.view === 'day') { from = state.cursor; to = state.cursor; } if (state.view === 'agenda') ({ from, to } = getAgendaRange(state.cursor)); if (from !== state.rangeFrom || to !== state.rangeTo) { await loadRange(from, to); } } // -------------------------------------------------------- // Ansicht-Dispatcher // -------------------------------------------------------- function renderView() { const body = _container.querySelector('#cal-body'); if (!body) return; body.innerHTML = ''; if (state.view === 'month') renderMonthView(body); if (state.view === 'week') renderWeekView(body); if (state.view === 'day') renderDayView(body); if (state.view === 'agenda') renderAgendaView(body); } // -------------------------------------------------------- // Monatsansicht // -------------------------------------------------------- function renderMonthView(container) { const d = new Date(state.cursor + 'T00:00:00'); const year = d.getFullYear(); const month = d.getMonth(); // Erster Tag des Monats const firstDay = new Date(year, month, 1); // Montag-basiert: 0=Mo … 6=So let startOffset = firstDay.getDay() - 1; if (startOffset < 0) startOffset = 6; // 42 Tage anzeigen (6 Wochen) const startDate = new Date(firstDay); startDate.setDate(startDate.getDate() - startOffset); const days = Array.from({ length: 42 }, (_, i) => { const dt = new Date(startDate); dt.setDate(startDate.getDate() + i); return { date: isoDate(dt), inMonth: dt.getMonth() === month }; }); container.innerHTML = `
${[t('calendar.dayShortMonday'),t('calendar.dayShortTuesday'),t('calendar.dayShortWednesday'),t('calendar.dayShortThursday'),t('calendar.dayShortFriday'),t('calendar.dayShortSaturday'),t('calendar.dayShortSunday')].map((n) => `
${n}
`).join('')}
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')}
`; container.querySelector('#month-grid').addEventListener('click', (e) => { const evEl = e.target.closest('.month-day__event'); if (evEl) { e.stopPropagation(); const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); return; } const dayEl = e.target.closest('.month-day'); if (dayEl) { openEventModal({ mode: 'create', date: dayEl.dataset.date }); } }); } function renderMonthDay(date, inMonth) { const evs = eventsOnDay(date); const isToday = date === state.today; const classes = [ 'month-day', !inMonth ? 'month-day--outside' : '', isToday ? 'month-day--today' : '', ].filter(Boolean).join(' '); const MAX_SHOW = 3; const shown = evs.slice(0, MAX_SHOW); const extra = evs.length - MAX_SHOW; const evHtml = shown.map((ev) => `
${escHtml(ev.title)}
`).join(''); return `
${new Date(date + 'T00:00:00').getDate()}
${evHtml} ${extra > 0 ? `
${t('calendar.moreEvents', { count: extra })}
` : ''}
`; } // -------------------------------------------------------- // Wochenansicht // -------------------------------------------------------- function renderWeekView(container) { const monday = getMondayOf(state.cursor); const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i)); const alldayEvs = days.map((d) => eventsOnDay(d).filter((e) => e.all_day || !e.start_datetime.includes('T')) ); const timedEvs = days.map((d) => eventsOnDay(d).filter((e) => !e.all_day && e.start_datetime.includes('T')) ); container.innerHTML = `
${days.map((d) => { const dt = new Date(d + 'T00:00:00'); return `
${DAY_NAMES_SHORT()[dt.getDay()]}
${dt.getDate()}
`; }).join('')}
${t('calendar.allDayShort')}
${days.map((d, i) => `
${alldayEvs[i].map((ev) => `
${escHtml(ev.title)}
`).join('')}
`).join('')}
${Array.from({ length: 24 }, (_, h) => `
${h === 0 ? '' : `${pad(h)}:00`}
`).join('')}
${days.map((d, i) => `
${Array.from({ length: 24 }, (_, h) => `
`).join('')} ${timedEvs[i].map((ev) => renderWeekEvent(ev)).join('')} ${d === state.today ? `
` : ''}
`).join('')}
`; // Event-Delegation container.querySelector('#week-cols').addEventListener('click', (e) => { const evEl = e.target.closest('.week-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); return; } const col = e.target.closest('[data-date]'); if (col) openEventModal({ mode: 'create', date: col.dataset.date }); }); container.querySelector('.allday-row').addEventListener('click', (e) => { const evEl = e.target.closest('.allday-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); } }); // Scrollen zu aktueller Zeit const scroll = container.querySelector('#week-scroll'); if (scroll) { const h = new Date().getHours(); scroll.scrollTop = Math.max(0, h * HOUR_HEIGHT - 80); } } function renderWeekEvent(ev) { const start = timeToMinutes(ev.start_datetime.slice(11, 16)); const end = ev.end_datetime ? timeToMinutes(ev.end_datetime.slice(11, 16)) : start + 60; const duration = Math.max(end - start, 30); const top = (start / 60) * HOUR_HEIGHT; const height = (duration / 60) * HOUR_HEIGHT - 2; return `
${escHtml(ev.title)}
${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}
`; } function timeToMinutes(timeStr) { if (!timeStr) return 0; const [h, m] = timeStr.split(':').map(Number); return h * 60 + (m || 0); } function nowTop() { const now = new Date(); const minutes = now.getHours() * 60 + now.getMinutes(); return (minutes / 60) * HOUR_HEIGHT; } // -------------------------------------------------------- // Tagesansicht // -------------------------------------------------------- function renderDayView(container) { const dt = new Date(state.cursor + 'T00:00:00'); const dayEvs = eventsOnDay(state.cursor); const allday = dayEvs.filter((e) => e.all_day || !e.start_datetime.includes('T')); const timed = dayEvs.filter((e) => !e.all_day && e.start_datetime.includes('T')); container.innerHTML = `
${formatDate(state.cursor, { weekday: true, long: true })}
${allday.length ? `
${t('calendar.allDayShort')}
${allday.map((ev) => `
${escHtml(ev.title)}
`).join('')}
` : ''}
${Array.from({ length: 24 }, (_, h) => `
${h === 0 ? '' : `${pad(h)}:00`}
`).join('')}
${Array.from({ length: 24 }, (_, h) => `
`).join('')} ${timed.map((ev) => renderWeekEvent(ev)).join('')} ${state.cursor === state.today ? `
` : ''}
`; container.querySelector('#day-col').addEventListener('click', (e) => { const evEl = e.target.closest('.week-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); return; } openEventModal({ mode: 'create', date: state.cursor }); }); const scroll = container.querySelector('#day-scroll'); if (scroll) { const h = new Date().getHours(); scroll.scrollTop = Math.max(0, h * HOUR_HEIGHT - 80); } } // -------------------------------------------------------- // Agenda-Ansicht // -------------------------------------------------------- function renderAgendaView(container) { const { from, to } = getAgendaRange(state.cursor); const days = Array.from({ length: 31 }, (_, i) => addDays(from, i)); const groups = days .map((d) => ({ date: d, events: eventsOnDay(d) })) .filter((g) => g.events.length > 0); container.innerHTML = `
${groups.length === 0 ? `
${t('calendar.noEvents')}
` : groups.map(({ date, events }) => `
${formatDate(date)} ${DAY_NAMES_LONG()[new Date(date + 'T00:00:00').getDay()]}
${events.map((ev) => renderAgendaEvent(ev)).join('')}
`).join('') }
`; stagger(container.querySelectorAll('.agenda-event')); container.querySelector('#agenda-view').addEventListener('click', (e) => { const evEl = e.target.closest('.agenda-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); } }); } function renderAgendaEvent(ev) { const timeStr = ev.all_day ? t('calendar.allDay') : formatTime(ev.start_datetime) + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd()); const initials = ev.assigned_name ? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) : ''; return `
${escHtml(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' ' : ''}
${timeStr} ${ev.location ? `📍 ${escHtml(ev.location)}` : ''} ${ev.assigned_name ? ` ${initials} ${escHtml(ev.assigned_name)} ` : ''}
`; } // -------------------------------------------------------- // Event-Popup (Detail-Ansicht bei Klick auf Termin) // -------------------------------------------------------- function showEventPopup(ev, anchor) { document.querySelector('#event-popup')?.remove(); const popup = document.createElement('div'); popup.id = 'event-popup'; popup.className = 'event-popup'; const timeStr = ev.all_day ? t('calendar.allDay') : formatDateTime(ev.start_datetime) + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : ''); popup.innerHTML = `
${escHtml(ev.title)}
${timeStr}
${ev.location ? `
📍 ${escHtml(ev.location)}
` : ''} ${ev.description ? `
${escHtml(ev.description)}
` : ''} ${ev.assigned_name ? `
👤 ${escHtml(ev.assigned_name)}
` : ''}
`; document.body.appendChild(popup); if (window.lucide) lucide.createIcons(); // Positionierung const rect = anchor.getBoundingClientRect(); const top = Math.min(rect.bottom + 8, window.innerHeight - 280); const left = Math.min(rect.left, window.innerWidth - 340); popup.style.top = `${Math.max(8, top)}px`; popup.style.left = `${Math.max(8, left)}px`; popup.querySelector('#popup-edit').addEventListener('click', () => { popup.remove(); openEventModal({ mode: 'edit', event: ev }); }); popup.querySelector('#popup-delete').addEventListener('click', async () => { if (!confirm(t('calendar.deleteConfirm', { title: ev.title }))) return; popup.remove(); await deleteEvent(ev.id); }); // Schließen bei Klick außerhalb setTimeout(() => { document.addEventListener('click', function closePopup(e) { if (!popup.isConnected || !popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', closePopup); } }); }, 0); } // -------------------------------------------------------- // Event-Modal (Erstellen / Bearbeiten) // -------------------------------------------------------- function openEventModal({ mode, event = null, date = null }) { const isEdit = mode === 'edit'; const content = buildEventModalContent({ mode, event, date }); openSharedModal({ title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'), content, size: 'md', onSave(panel) { // RRULE-Events binden bindRRuleEvents(panel, 'event'); const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0]; // Farb-Auswahl panel.querySelectorAll('.color-swatch').forEach((sw) => { sw.addEventListener('click', () => { panel.querySelectorAll('.color-swatch').forEach((s) => s.classList.remove('color-swatch--active')); sw.classList.add('color-swatch--active'); }); }); panel.querySelectorAll('.color-swatch').forEach((sw) => { if (sw.dataset.color === selectedColor) sw.classList.add('color-swatch--active'); }); // Ganztägig-Toggle const alldayCheck = panel.querySelector('#modal-allday'); const timeFields = panel.querySelector('#time-fields'); const alldayFields = panel.querySelector('#allday-fields'); alldayCheck.addEventListener('change', () => { if (alldayCheck.checked) { timeFields.style.display = 'none'; alldayFields.style.display = ''; } else { timeFields.style.display = ''; alldayFields.style.display = 'none'; } }); if (isEdit && event?.all_day) { timeFields.style.display = 'none'; alldayFields.style.display = ''; } panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { if (!confirm(t('calendar.deleteConfirm', { title: event.title }))) return; closeModal(); await deleteEvent(event.id); }); panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id)); }, }); } function buildEventModalContent({ mode, event, date }) { const isEdit = mode === 'edit'; const today = date || state.today; const startDate = isEdit ? event.start_datetime.slice(0, 10) : today; const startTime = isEdit && event.start_datetime.length > 10 ? event.start_datetime.slice(11, 16) : '09:00'; const endDate = isEdit && event.end_datetime ? event.end_datetime.slice(0, 10) : startDate; const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10 ? event.end_datetime.slice(11, 16) : '10:00'; const userOpts = [ ``, ...state.users.map((u) => `` ), ].join(''); return `
${EVENT_COLORS.map((c) => ` `).join('')}
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)} `; } async function saveEvent(overlay, mode, eventId) { const saveBtn = overlay.querySelector('#modal-save'); const title = overlay.querySelector('#modal-title').value.trim(); if (!title) { window.oikos?.showToast(t('calendar.titleRequired'), 'error'); return; } const allday = overlay.querySelector('#modal-allday').checked; const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0]; const location = overlay.querySelector('#modal-location').value.trim() || null; const assigned_to = overlay.querySelector('#modal-assigned').value || null; const description = overlay.querySelector('#modal-description').value.trim() || null; let start_datetime, end_datetime; if (allday) { start_datetime = overlay.querySelector('#modal-allday-start')?.value || overlay.querySelector('#modal-start-date').value; end_datetime = overlay.querySelector('#modal-allday-end')?.value || overlay.querySelector('#modal-end-date').value; end_datetime = end_datetime || null; } else { const sd = overlay.querySelector('#modal-start-date').value; const st = overlay.querySelector('#modal-start-time').value; const ed = overlay.querySelector('#modal-end-date').value; const et = overlay.querySelector('#modal-end-time').value; start_datetime = st ? `${sd}T${st}` : sd; end_datetime = et ? `${ed}T${et}` : (ed || null); } saveBtn.disabled = true; saveBtn.textContent = '…'; try { const rrule = getRRuleValues(overlay, 'event'); const body = { title, description, start_datetime, end_datetime, all_day: allday ? 1 : 0, location, color, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null, recurrence_rule: rrule.recurrence_rule, }; if (mode === 'create') { const res = await api.post('/calendar', body); state.events.push(res.data); } else { const res = await api.put(`/calendar/${eventId}`, body); const idx = state.events.findIndex((e) => e.id === eventId); if (idx !== -1) state.events[idx] = res.data; } closeModal(); renderView(); window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('calendar.saveError'), 'error'); saveBtn.disabled = false; saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create'); } } async function deleteEvent(id) { try { await api.delete(`/calendar/${id}`); state.events = state.events.filter((e) => e.id !== id); renderView(); window.oikos?.showToast(t('calendar.deletedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'error'); } } // -------------------------------------------------------- // Hilfsfunktion // -------------------------------------------------------- function escHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }