churchtools-next-events/js/ct-events.js
2024-12-18 15:23:37 +01:00

444 lines
13 KiB
JavaScript

import "./mithril.min.js";
function fa_solid_icon(settings, name) {
if (settings.fontawesome_svg_path) {
// modify upstream svg to start with `<svg id="root" ...>`
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 format_ICS_text(text) {
return text.replace(/[\\,;]/g, "\\$&") // escape backslash and separators
.replace(/\n/g, "\\n") // escape newline
.replace(/[\x00-\x1f]+/g, " ") // replace control characters with whitespace
;
}
function format_ICS_date(date) {
if (date) {
date = new Date(date);
} else {
date = new Date(); // default to "now"
}
// remove "-" and ":" separators (but keep "T"), and drop microseconds
return date.toISOString().replace(/-|:|\.\d+/g, "");
}
function createICSLink(item, settings) {
const start = format_ICS_date(item.calculated.startDate);
const end = format_ICS_date(item.calculated.endDate);
const summary = item.base.caption;
const description = item.base.information || "";
const location = item.base.address?.meetingAt || "";
// Escape special characters for ICS fields
const escapedSummary = format_ICS_text(summary /* .replace(/['"]/g, "") */);
const escapedDescription = format_ICS_text(description /* .replace(/['"]/g, "") */);
const escapedLocation = format_ICS_text(location);
const icsContent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//churchtools-next-events//EN
BEGIN:VEVENT
UID:appointment-${item.base.id}@${window.location.hostname}
CREATED:${format_ICS_date(item.base.meta?.createdDate)}
LAST-MODIFIED:${format_ICS_date(item.base.meta?.modifiedDate)}
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);
let appointments;
if (calendar_ids.length) {
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;
appointments = all_appointments.filter((appointment) => r_event_regex.exec(appointment.base.title));
if (appointments.length > settings.limit) {
appointments.length = settings.limit;
}
} else {
appointments = [];
}
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 };