import "./mithril.min.js"; function fa_solid_icon(settings, name) { if (settings.fontawesome_svg_path) { // modify upstream svg to start with `` return m("svg.cte-icon", m("use", {href: `${settings.fontawesome_svg_path}/${name}-solid.svg#root`})); } else { return m(`i.fa-solid.fa-${name}.cte-icon`) } } const is_mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); var AppointmentImage = { view: function(vnode) { const {item, settings} = vnode.attrs; const image_url = item.base.image?.fileUrl ?? (is_mobile ? default_image_mobile : null) ?? settings.default_image; return m(".cte-image", image_url ? m( "img", { src: image_url, alt: item.base.caption, }, ) : null, ) } } // Options for date and time formatting const fmtoptions_date = { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Berlin' }; const fmtoptions_date_short = { day: 'numeric', month: 'numeric', year: 'numeric', timeZone: 'Europe/Berlin' }; const fmtoptions_date_utc = { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }; const fmtoptions_time = { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' }; const fmt_date = new Intl.DateTimeFormat('de-DE', fmtoptions_date); const fmt_date_short = new Intl.DateTimeFormat('de-DE', fmtoptions_date_short); const fmt_date_utc = new Intl.DateTimeFormat('de-DE', fmtoptions_date_utc); const fmt_time = new Intl.DateTimeFormat('de-DE', fmtoptions_time); function display_date_utc(date, content) { const repr = date.toISOString().split("T")[0]; return m("time", {datetime: repr}, content); } function display_date_local(date, content) { const [d, m, y] = fmtoptions_date_short.format(date).split("."); const repr = [y, m, d].join("-"); return m("time", {datetime: repr}, content); } function display_datetime(date, content) { const repr = date.toISOString(); return m("time", {datetime: repr}, content); } function display_event_date(item) { const start = new Date(item.calculated.startDate); const end = new Date(item.calculated.endDate); const start_date = fmt_date.format(start); const end_date = fmt_date.format(end); const start_time = fmt_time.format(start); const end_time = fmt_time.format(end); if (start === end) { return display_datetime(start, `${start_date}, ${start_time}`); } if (start_time === "00:00" && end_time === "00:00") { // localtime "all-day" event const end_day = new Date(end.valueOf() - 86400); const end_day_date = fmt_date.format(end_day); if (start_date === end_day_date) { return display_date_local(start, start_date); } else { return m("span.timerange", display_date_local(start, start_date), " - ", display_date_local(end_day, end_day_date)); } } if (start.getUTCHours() === 0 && start.getUTCMinutes() === 0 && end.getUTCHours() === 0 && end.getUTCMinutes() === 0) { // UTC "all-day" event const end_day = new Date(end.valueOf() - 86400); const utc_start_date = fmt_date_utc.format(start); const utc_end_day_date = fmt_date_utc.format(end_day); if (utc_start_date === utc_end_day_date) { return display_date_utc(start, utc_start_date); } else { return m("span.timerange", display_date_utc(start, utc_start_date), " - ", display_date_utc(end_day, utc_end_day_date)); } } if (start_date === end_date) { return m("span.timerange", display_datetime(start, `${start_date}, ${start_time}`), " - ", display_datetime(end, end_time)); } else { return m( "span.timerange", display_datetime(start, `${start_date}, ${start_time}`), " - ", display_datetime(end, `${end_date}, ${end_time}`) ); } } function escapeICSField(text) { return text.replace(/\\/g, "\\\\") .replace(/[,;]/g, "\\$&") .replace(/\n/g, "\\n"); } function createICSLink(item, settings) { const start = new Date(item.calculated.startDate).toISOString().replace(/-|:|\.\d+/g, ''); const end = new Date(item.calculated.endDate).toISOString().replace(/-|:|\.\d+/g, ''); const summary = item.base.caption; const description = item.base.information || ""; const location = item.base.address?.meetingAt || ""; // Escape special characters for ICS fields const escapedSummary = escapeICSField(summary /* .replace(/['"]/g, "") */); const escapedDescription = escapeICSField(description /* .replace(/['"]/g, "") */); const escapedLocation = escapeICSField(location); const icsContent = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Evang Kirchengemeinde Malmsheim//Gottesdienste//EN BEGIN:VEVENT UID:${crypto.randomUUID()}@malmsheim-evangelisch.de DTSTAMP:${new Date().toISOString().replace(/-|:|\.\d+/g, '')} DTSTART:${start} DTEND:${end} SUMMARY:${escapedSummary} DESCRIPTION:${escapedDescription} LOCATION:${escapedLocation} END:VEVENT END:VCALENDAR `; const blob = new Blob([icsContent], { type: 'text/calendar' }); const url = URL.createObjectURL(blob); return m( "a", { href: url, download: "event.ics", }, [ fa_solid_icon(settings, "calendar-plus"), is_mobile ? "" : " Termin herunterladen", ] ) } var AppointmentHeader = { view: function(vnode) { const {item, settings} = vnode.attrs; return m(".cte-info-header", [ m("span.cte-date", display_event_date(item)), m("span.cte-download", createICSLink(item, settings)), ]); } } var AppointmentDescription = { view: function(vnode) { const {item, settings} = vnode.attrs; const note_parts = []; if (item.base.note) { note_parts.push(item.base.note); } if (item.base.address?.meetingAt) { note_parts.push(item.base.address.meetingAt); } return m(".cte-description", [ m("h1", item.base.caption), m("h2", note_parts.join(" | ")), ]); } } /* find `http://`, `https://` and `www.` links * - requires whitespace, parantheses `(, `)` or line start/end before and after it * - find non-links as first capture, links as second capture (might be empty) */ const REGEX_FIND_LINKS = /(?<=^|[\s()])(?:https?:\/\/|www\.)[^\s()*]*(?=[()\s]|$)/; const REGEX_FIND_LINKS_CONT = /(?<=[\s()])(?:https?:\/\/|www\.)[^\s()*]*(?=[()\s]|$)/; function* format_links(part) { let regex = REGEX_FIND_LINKS; while (true) { const match = part.match(regex); regex = REGEX_FIND_LINKS_CONT; const normal = part.substring(0, match?.index ?? part.length); yield normal; if (!match) { return; } part = part.substring(match.index + match[0].length); const url = match[0]; yield m("a", {href: url, target: "_blank"}, url); } } /* find `*...*` highlighted text: * - requires whitespace or line start/end before and after it * - requires non-space on the inside as first and last character * - `*` not allowed inside * - find non-highlighted text as first capture, highlighted as second capture (might be empty) */ const REGEX_FIND_HIGHLIGHTED = /(?<=^|\s)\*[^*\s](?:[^*]*[^*\s])?\*(?=\s|$)/; const REGEX_FIND_HIGHLIGHTED_CONT = /(?<=\s)\*[^*\s](?:[^*]*[^*\s])?\*(?=\s|$)/; function* format_highlights_and_links(line) { let regex = REGEX_FIND_HIGHLIGHTED; while (true) { const match = line.match(regex); regex = REGEX_FIND_HIGHLIGHTED_CONT; const normal = line.substring(0, match?.index ?? line.length); for (const part of format_links(normal)) { yield part; } if (!match) { return; } line = line.substring(match.index + match[0].length); yield m("strong", ["*", ...format_links(match[0].slice(1, -1)), "*"]) } } function format_information(item) { const information = item.base.information || ""; const paragraphs = []; let cur_lines = []; for (const line of information.split(/\r?\n/)) { if (!line.trim()) { // split paragraphs if (cur_lines.length) { paragraphs.push(cur_lines); cur_lines = []; } continue; } /* find `*...*` highlighted text */ const line_parts = []; if (cur_lines.length) { line_parts.push(m("br")); } line_parts.push(...format_highlights_and_links(line)); cur_lines.push(m("[", line_parts)); } if (cur_lines.length) { paragraphs.push(cur_lines); } return m("[", paragraphs.map((para) => m("p", para))); } var AppointmentLocation = { view: function(vnode) { const {item, settings} = vnode.attrs; const location_parts = []; if (item.base.address?.meetingAt) { location_parts.push(item.base.address.meetingAt); } if (item.base.address?.street) { location_parts.push(item.base.address.street); } const location = location_parts.join(", "); if (!location) { return m("["); } return m("p", [ fa_solid_icon(settings, "location-dot"), " ", location, ]); } } var AppointmentLink = { view: function(vnode) { const {item, settings} = vnode.attrs; if (!item.base.link) { return m("["); } let url = item.base.link; if (!url.startsWith("http")) { url = "https://" + url; } return m("p", [ fa_solid_icon(settings, "link"), " ", m("a", {href: url, target: "_blank"}, item.base.link), ]) } } function AppointmentDetails() { let collapsed = true; function toggle() { collapsed = !collapsed; } return { view: function(vnode) { const {item, settings} = vnode.attrs; const show_details = item.base.information /* || item.base.address?.meetingAt || item.base.address?.street */ || item.base.link; if (!show_details) { return m("["); } return m( ".cte-details-collapser", { class: collapsed ? "collapsed" : "", }, [ m(".cte-details", [ m(".cte-information-group", [ m("h3.cte-information-label", "Beschreibung"), m(".cte-information", [ format_information(item), m(AppointmentLocation,{item, settings}), m(AppointmentLink, {item, settings}), ]), ]), ]), m( "button.toggle-collapse", {onclick: toggle}, collapsed ? "Details anzeigen" : "Details verbergen", " ", fa_solid_icon(settings, collapsed ? "chevron-down" : "chevron-up"), ), ], ); } } }; var Appointment = { view: function(vnode) { const {item, settings} = vnode.attrs; return m(".cte-event", [ m(".cte-summary", [ m(AppointmentImage, {item, settings}), m(".cte-info", [ m(AppointmentHeader, {item, settings}), m(AppointmentDescription, {item, settings}), ]), ]), m(AppointmentDetails, {item, settings}), ]) }, }; async function load_events(dom_node, defaults) { if (dom_node.dataset.cteActive) { return; } dom_node.dataset.cteActive = true; const settings = { instance: dom_node.dataset.cteInstance ?? defaults.instance, days: dom_node.dataset.cteDays ?? defaults.days ?? 31, calendar_regex: dom_node.dataset.cteCalendarRegex ?? defaults.calendar_regex, event_regex: dom_node.dataset.cteEventRegex ?? defaults.event_regex, limit: dom_node.dataset.cteLimit ?? defaults.limit ?? 5, default_image: dom_node.dataset.cteDefaultImage ?? defaults.default_image, default_image_mobile: dom_node.dataset.cteDefaultImageMobile ?? defaults.default_image_mobile, fontawesome_svg_path: dom_node.dataset.cteFontawesomeSvgPath ?? defaults.fontawesome_svg_path, }; function format_date(date) { return date.toISOString().split('T')[0]; } const req_config = { credentials: "omit", mode: "cors", } const r_cal_regex = new RegExp(settings.calendar_regex, "i"); const r_event_regex = new RegExp(settings.event_regex, "i"); const all_calendars = (await (await fetch(settings.instance + "/api/calendars", req_config)).json()).data; const calendars = all_calendars.filter((calendar) => r_cal_regex.exec(calendar.name)); const calendar_ids = calendars.map((calendar) => calendar.id); const filters = calendar_ids.map((cal_id) => `calendar_ids[]=${cal_id}`); const from = new Date(); filters.push(`from=${format_date(from)}`); const to = new Date(from.valueOf() + settings.days * 86400 * 1000); filters.push(`to=${format_date(to)}`); const query = filters.join("&"); const all_appointments = (await (await fetch(settings.instance + `/api/calendars/appointments?${query}`, req_config)).json()).data; const appointments = all_appointments.filter((appointment) => r_event_regex.exec(appointment.base.title)); if (appointments.length > settings.limit) { appointments.length = settings.limit; } const Appointments = { view: function(vnode) { return m("[", appointments.map((item) => m(Appointment, {item, settings}))); } }; m.mount(dom_node, Appointments); } async function load_all_events(defaults) { await Promise.all(Array.from(document.querySelectorAll(".cte-events")).map((node) => load_events(node, defaults))); } export { load_all_events };