Files
windwiderstand/src/lib/calendar-ics.ts
T
Peter Meier 6fb2592eae
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m3s
feat(calendar): add feed subscription, bulk export, share & Google Cal
- /api/kalender.ics SSR route: ICS feed of all calendar items (webcal-subscribable, Cache 1h)
- buildICSFeed() / downloadICSFeed() for multi-event ICS export
- googleCalUrl() deep-link pre-fills Google Calendar create form
- Web Share API per event (navigator.share on mobile, clipboard fallback on desktop)
  — "Kopiert\!" toast per event for 2s after clipboard write
- Actions bar (subscribe + bulk export) only rendered after onMount
  — Apple platforms: webcal:// link opens Calendar.app natively
  — All others: direct ICS download via <a download>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:50:56 +02:00

195 lines
6.6 KiB
TypeScript

/**
* Helpers for "send calendar entry to my own calendar" flows.
*
* - `buildICS()` produces a single-event RFC-5545 string suitable for
* download. Apple Calendar, Outlook and Google all import it.
* - `googleCalUrl()` returns a deep link to Google Calendar's event-
* create form pre-filled with the event details.
* - `mapsUrl()` returns a Google Maps search URL for an address string.
*
* All client-only — no Node APIs touched. Safe in SSR contexts (the
* functions just return strings; the actual download is triggered by
* the caller via `URL.createObjectURL`).
*/
export type CalendarItemICS = {
title: string;
start: Date;
/** Optional end date. If omitted, default duration is 1 hour. */
end?: Date | null;
description?: string;
location?: string;
url?: string;
/** Stable identifier; falls back to start-timestamp + title hash. */
uid?: string;
};
/** Folds long lines per RFC-5545 §3.1 (75 octets max). Most clients
* tolerate longer lines but Outlook is strict — better safe. */
function foldLine(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
const chunk = line.slice(i, i + (i === 0 ? 75 : 74));
out.push((i === 0 ? "" : " ") + chunk);
i += i === 0 ? 75 : 74;
}
return out.join("\r\n");
}
/** Escape per RFC-5545 §3.3.11 — backslash, comma, semicolon, newline. */
function escapeText(s: string): string {
return s
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;");
}
/** UTC timestamp formatted as `YYYYMMDDTHHMMSSZ`. */
function fmtICSDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
d.getUTCFullYear().toString() +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()) +
"Z"
);
}
function deterministicUid(title: string, start: Date): string {
let hash = 0;
for (let i = 0; i < title.length; i++) {
hash = (hash * 31 + title.charCodeAt(i)) | 0;
}
return `${start.getTime()}-${Math.abs(hash).toString(36)}@windwiderstand.de`;
}
export function buildICS(item: CalendarItemICS): string {
const start = item.start;
const end =
item.end ??
new Date(start.getTime() + 60 * 60 * 1000); // 1h default
const uid = item.uid ?? deterministicUid(item.title, start);
const now = new Date();
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//windwiderstand.de//calendar//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${fmtICSDate(now)}`,
`DTSTART:${fmtICSDate(start)}`,
`DTEND:${fmtICSDate(end)}`,
foldLine(`SUMMARY:${escapeText(item.title)}`),
];
if (item.description) {
lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
}
if (item.location) {
lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
}
if (item.url) {
lines.push(foldLine(`URL:${item.url}`));
}
lines.push("END:VEVENT", "END:VCALENDAR");
return lines.join("\r\n");
}
/** Trigger a browser download of an ICS file. Caller should pass a
* filesystem-friendly filename; we still strip path separators
* defensively. No-op on the server (`window` undefined). */
export function downloadICS(item: CalendarItemICS, filename: string): void {
if (typeof window === "undefined") return;
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "event.ics";
const ics = buildICS(item);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Builds a VCALENDAR with multiple VEVENTs — for feed/bulk export. */
export function buildICSFeed(items: CalendarItemICS[], calName = "Windwiderstand Termine"): string {
const now = new Date();
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//windwiderstand.de//calendar//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
foldLine(`X-WR-CALNAME:${escapeText(calName)}`),
"X-WR-TIMEZONE:Europe/Berlin",
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
"X-PUBLISHED-TTL:PT1H",
];
for (const item of items) {
const start = item.start;
const end = item.end ?? new Date(start.getTime() + 60 * 60 * 1000);
const uid = item.uid ?? deterministicUid(item.title, start);
lines.push(
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${fmtICSDate(now)}`,
`DTSTART:${fmtICSDate(start)}`,
`DTEND:${fmtICSDate(end)}`,
foldLine(`SUMMARY:${escapeText(item.title)}`),
);
if (item.description) lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
if (item.location) lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
if (item.url) lines.push(foldLine(`URL:${item.url}`));
lines.push("END:VEVENT");
}
lines.push("END:VCALENDAR");
return lines.join("\r\n");
}
/** Triggers a browser download of a multi-event ICS file. */
export function downloadICSFeed(items: CalendarItemICS[], filename: string): void {
if (typeof window === "undefined") return;
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "termine.ics";
const ics = buildICSFeed(items);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Deep-link to Google Calendar's pre-filled create form. */
export function googleCalUrl(item: CalendarItemICS): string {
const fmt = (d: Date) =>
d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
const end = item.end ?? new Date(item.start.getTime() + 60 * 60 * 1000);
const params = new URLSearchParams({
action: "TEMPLATE",
text: item.title,
dates: `${fmt(item.start)}/${fmt(end)}`,
});
if (item.description) params.set("details", item.description);
if (item.location) params.set("location", item.location);
return `https://calendar.google.com/calendar/render?${params}`;
}
/** OpenStreetMap search URL for an address or place name. */
export function mapsUrl(location: string): string {
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(location)}`;
}