/** * 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)}`; }