|
1 | 1 | import type { HTConference } from "@/types/db"; |
2 | 2 | import type { ProcessedEvent } from "@/types/ht"; |
3 | 3 |
|
4 | | -export type ICalOpts = { |
5 | | - productId?: string; // PRODID (default -//HackerTracker//Schedule//EN) |
6 | | - method?: |
7 | | - | "PUBLISH" |
8 | | - | "REQUEST" |
9 | | - | "CANCEL" |
10 | | - | "ADD" |
11 | | - | "REPLY" |
12 | | - | "COUNTER" |
13 | | - | "DECLINECOUNTER"; |
14 | | - now?: Date; // override DTSTAMP |
15 | | - alarmMinutesBefore?: number; // optional single display alarm (>=0) |
16 | | - // Optional URL builder for deep links to content pages |
17 | | - urlForEvent?: (ev: ProcessedEvent, conf: HTConference) => string | undefined; |
18 | | - // Domain used in UID: <eventId>@<domain> (default `${conf.code}.hackertracker`) |
19 | | - uidDomain?: string; |
20 | | - // Optional calendar name/description |
21 | | - name?: string; |
22 | | - description?: string; |
23 | | -}; |
24 | | - |
25 | | -export function generateICalFromProcessed( |
26 | | - events: ProcessedEvent | ProcessedEvent[], |
27 | | - conference: HTConference, |
28 | | - opts: ICalOpts = {} |
29 | | -): string { |
30 | | - const list = Array.isArray(events) ? events : [events]; |
31 | | - |
32 | | - const productId = opts.productId ?? "-//HackerTracker//Schedule//EN"; |
33 | | - const method = opts.method ?? "PUBLISH"; |
34 | | - |
35 | | - const body: string[] = []; |
36 | | - body.push("BEGIN:VCALENDAR"); |
37 | | - body.push("VERSION:2.0"); |
38 | | - body.push(fold("PRODID", productId)); |
39 | | - body.push("CALSCALE:GREGORIAN"); |
40 | | - body.push(fold("METHOD", method)); |
41 | | - if (opts.name) body.push(val("X-WR-CALNAME", escapeText(opts.name))); |
42 | | - if (opts.description) |
43 | | - body.push(val("X-WR-CALDESC", escapeText(opts.description))); |
44 | | - |
45 | | - for (const ev of list) { |
46 | | - body.push(buildEventFromProcessed(ev, conference, opts)); |
47 | | - } |
48 | | - |
49 | | - body.push("END:VCALENDAR"); |
50 | | - return body.join(CRLF) + CRLF; |
51 | | -} |
52 | | - |
53 | | -// Optional helper to trigger browser download |
54 | | -export function downloadIcs(filename: string, ics: string) { |
55 | | - const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" }); |
56 | | - const href = URL.createObjectURL(blob); |
57 | | - const a = document.createElement("a"); |
58 | | - a.href = href; |
59 | | - a.download = filename.endsWith(".ics") ? filename : `${filename}.ics`; |
60 | | - document.body.appendChild(a); |
61 | | - a.click(); |
62 | | - a.remove(); |
63 | | - URL.revokeObjectURL(href); |
64 | | -} |
65 | | - |
66 | | -// ------------ Implementation ------------------------------------------------- |
| 4 | +const BASEURL = "https://hackertracker.app"; |
| 5 | +const PRODID = "-//hackertracker//web Calendar 1.0//EN"; |
| 6 | +const MAX_LINE_LEN = 75; |
67 | 7 |
|
68 | | -const CRLF = "\r\n"; |
69 | | - |
70 | | -function toDate(d: string | number | Date | null | undefined): Date | null { |
71 | | - if (d == null) return null; |
72 | | - return d instanceof Date ? d : new Date(d); |
73 | | -} |
74 | | - |
75 | | -function msFromSeconds(seconds: number | null): number | null { |
76 | | - return seconds == null ? null : seconds * 1000; |
77 | | -} |
78 | | - |
79 | | -function ensureStart(ev: ProcessedEvent): Date { |
80 | | - // Prefer explicit begin, else beginTimestampSeconds |
81 | | - const date = |
82 | | - toDate(ev.begin) ?? |
83 | | - (msFromSeconds(ev.beginTimestampSeconds) |
84 | | - ? new Date(msFromSeconds(ev.beginTimestampSeconds)!) |
85 | | - : null); |
86 | | - if (!date) throw new Error(`Event ${ev.id} has no valid start time`); |
87 | | - return date; |
88 | | -} |
89 | | - |
90 | | -function ensureEnd(ev: ProcessedEvent, start: Date): Date { |
91 | | - // Prefer explicit end, else endTimestampSeconds, else +60m default |
92 | | - const fromEndField = toDate(ev.end); |
93 | | - if (fromEndField) return fromEndField; |
94 | | - const fromSeconds = msFromSeconds(ev.endTimestampSeconds); |
95 | | - if (fromSeconds) return new Date(fromSeconds); |
96 | | - return new Date(start.getTime() + 60 * 60 * 1000); |
97 | | -} |
| 8 | +/** Escape special chars per RFC 5545 */ |
| 9 | +const escapeICalText = (text = "") => |
| 10 | + text |
| 11 | + .replace(/\\/g, "\\\\") |
| 12 | + .replace(/;/g, "\\;") |
| 13 | + .replace(/,/g, "\\,") |
| 14 | + .replace(/\r?\n/g, "\\n"); |
98 | 15 |
|
99 | | -function fmtUtc(dt: Date): string { |
100 | | - // YYYYMMDDTHHMMSSZ |
| 16 | +/** Format a Date to iCal “YYYYMMDDTHHMMSSZ” in UTC */ |
| 17 | +export const formatICalDate = (d: Date): string => { |
101 | 18 | const pad = (n: number) => String(n).padStart(2, "0"); |
102 | 19 | return ( |
103 | | - dt.getUTCFullYear().toString() + |
104 | | - pad(dt.getUTCMonth() + 1) + |
105 | | - pad(dt.getUTCDate()) + |
| 20 | + d.getUTCFullYear().toString() + |
| 21 | + pad(d.getUTCMonth() + 1) + |
| 22 | + pad(d.getUTCDate()) + |
106 | 23 | "T" + |
107 | | - pad(dt.getUTCHours()) + |
108 | | - pad(dt.getUTCMinutes()) + |
109 | | - pad(dt.getUTCSeconds()) + |
| 24 | + pad(d.getUTCHours()) + |
| 25 | + pad(d.getUTCMinutes()) + |
| 26 | + pad(d.getUTCSeconds()) + |
110 | 27 | "Z" |
111 | 28 | ); |
112 | | -} |
113 | | - |
114 | | -function escapeText(v: string): string { |
115 | | - // RFC5545 §3.3.11: escape \ ; , and newline |
116 | | - return v |
117 | | - .replace(/\\/g, "\\\\") |
118 | | - .replace(/;/g, "\\;") |
119 | | - .replace(/,/g, "\\,") |
120 | | - .replace(/\r?\n/g, "\\n"); |
121 | | -} |
| 29 | +}; |
122 | 30 |
|
123 | | -// Fold to 75 characters (octet-approx) with continuation lines |
124 | | -function foldLine(line: string): string { |
125 | | - const limit = 75; |
126 | | - if (line.length <= limit) return line; |
127 | | - const out: string[] = []; |
128 | | - for (let i = 0; i < line.length; i += limit) { |
129 | | - const chunk = line.slice(i, i + limit); |
130 | | - out.push(i === 0 ? chunk : " " + chunk); |
| 31 | +/** Fold long lines with a space prefix on continuations */ |
| 32 | +const foldLine = (line: string) => { |
| 33 | + if (line.length <= MAX_LINE_LEN) return line; |
| 34 | + const pieces: string[] = []; |
| 35 | + for (let pos = 0; pos < line.length; pos += MAX_LINE_LEN) { |
| 36 | + const chunk = line.slice(pos, pos + MAX_LINE_LEN); |
| 37 | + pieces.push(pos === 0 ? chunk : " " + chunk); |
131 | 38 | } |
132 | | - return out.join(CRLF); |
133 | | -} |
134 | | - |
135 | | -function fold(name: string, value: string): string { |
136 | | - return foldLine(`${name}:${value}`); |
137 | | -} |
138 | | - |
139 | | -function val(name: string, raw?: string): string { |
140 | | - return raw && raw.length ? fold(name, raw) : ""; |
141 | | -} |
142 | | - |
143 | | -function buildEventFromProcessed( |
144 | | - ev: ProcessedEvent, |
145 | | - conf: HTConference, |
146 | | - opts: ICalOpts |
147 | | -): string { |
148 | | - const start = ensureStart(ev); |
149 | | - const end = ensureEnd(ev, start); |
150 | | - |
151 | | - const dtStamp = fmtUtc(opts.now ?? new Date()); |
152 | | - const dtStart = fmtUtc(start); |
153 | | - const dtEnd = fmtUtc(end); |
154 | | - |
155 | | - const uidDomain = opts.uidDomain ?? `${conf.code}.hackertracker`; |
156 | | - const uid = `${ev.id}@${uidDomain}`; |
157 | | - |
158 | | - const title = ev.title || `Event ${ev.id}`; |
159 | | - const location = ev.location ?? ""; |
160 | | - const speakers = ev.speakers ? `Speakers: ${ev.speakers}` : ""; |
161 | | - const tags = ev.tags?.length |
162 | | - ? `Tags: ${ev.tags.map((t) => t.label).join(", ")}` |
163 | | - : ""; |
164 | | - const zone = ev.timeZone || conf.timezone || ""; |
165 | | - const url = opts.urlForEvent ? opts.urlForEvent(ev, conf) : undefined; |
166 | | - |
167 | | - const descParts = [ |
168 | | - conf.name ? `Conference: ${conf.name} (${conf.code})` : undefined, |
169 | | - speakers || undefined, |
170 | | - tags || undefined, |
171 | | - zone ? `Timezone: ${zone}` : undefined, |
172 | | - url ? `Link: ${url}` : undefined, |
173 | | - ].filter(Boolean) as string[]; |
| 39 | + return pieces.join("\r\n"); |
| 40 | +}; |
174 | 41 |
|
175 | | - const lines: string[] = []; |
176 | | - lines.push("BEGIN:VEVENT"); |
177 | | - lines.push(fold("UID", uid)); |
178 | | - lines.push(fold("DTSTAMP", dtStamp)); |
179 | | - lines.push(fold("DTSTART", dtStart)); |
180 | | - lines.push(fold("DTEND", dtEnd)); |
181 | | - lines.push(val("SUMMARY", escapeText(title))); |
182 | | - if (location) lines.push(val("LOCATION", escapeText(location))); |
183 | | - if (descParts.length) |
184 | | - lines.push(val("DESCRIPTION", escapeText(descParts.join("\n")))); |
185 | | - if (url) lines.push(fold("URL", escapeText(url))); |
| 42 | +/** Build a plain-text description including speakers */ |
| 43 | +const buildDescription = (event: ProcessedEvent) => { |
| 44 | + const speakers = event.speakers ?? ""; |
| 45 | + return [event.description, speakers].filter(Boolean).join("\\n"); |
| 46 | +}; |
186 | 47 |
|
187 | | - if ( |
188 | | - typeof opts.alarmMinutesBefore === "number" && |
189 | | - Number.isFinite(opts.alarmMinutesBefore) |
190 | | - ) { |
191 | | - const mins = Math.max(0, Math.floor(opts.alarmMinutesBefore)); |
192 | | - lines.push("BEGIN:VALARM"); |
193 | | - lines.push(fold("TRIGGER", `-PT${mins}M`)); |
194 | | - lines.push("ACTION:DISPLAY"); |
195 | | - lines.push(val("DESCRIPTION", escapeText(title))); |
196 | | - lines.push("END:VALARM"); |
197 | | - } |
| 48 | +/** Generate a full iCal string for an event */ |
| 49 | +export const generateICal = ( |
| 50 | + event: ProcessedEvent, |
| 51 | + conference: HTConference |
| 52 | +): string => { |
| 53 | + const now = new Date(); |
| 54 | + const dtstamp = formatICalDate(now); |
| 55 | + const dtstart = formatICalDate(new Date(event.begin)); |
| 56 | + const dtend = formatICalDate(new Date(event.end ?? event.begin)); |
| 57 | + const uid = `${conference.code}-${event.id}@hackertracker.app`; |
| 58 | + |
| 59 | + const lines = [ |
| 60 | + "BEGIN:VCALENDAR", |
| 61 | + "METHOD:PUBLISH", |
| 62 | + "VERSION:2.0", |
| 63 | + `PRODID:${PRODID}`, |
| 64 | + "BEGIN:VEVENT", |
| 65 | + `UID:${uid}`, |
| 66 | + `SEQUENCE:0`, |
| 67 | + `DTSTAMP:${dtstamp}`, |
| 68 | + `DTSTART:${dtstart}`, |
| 69 | + `DTEND:${dtend}`, |
| 70 | + "STATUS:CONFIRMED", |
| 71 | + "CATEGORIES:CONFERENCE", |
| 72 | + `SUMMARY:${escapeICalText(event.title)}`, |
| 73 | + `URL:${BASEURL}/event?conf=${conference.code}&event=${event.id}`, |
| 74 | + `LOCATION:${escapeICalText(event.location ?? "")}`, |
| 75 | + `DESCRIPTION:${escapeICalText(buildDescription(event))}`, |
| 76 | + "END:VEVENT", |
| 77 | + "END:VCALENDAR", |
| 78 | + ]; |
| 79 | + |
| 80 | + // fold and join |
| 81 | + return lines.map(foldLine).join("\r\n"); |
| 82 | +}; |
198 | 83 |
|
199 | | - lines.push("END:VEVENT"); |
200 | | - return lines.join(CRLF); |
201 | | -} |
| 84 | +export default generateICal; |
0 commit comments