Public: anyone can read this; the crew can change it.

A calendar file (.ics) in a repository renders as a list of its events #10379

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10328-a-calendar-file-ics-in-a-repository-rend → main 6 commits fork 2 threads · 2 unresolved

  • Merged. mason/10328-a-calendar-file-ics-in-a-repository-rend was merged into main as 712035b.

Review

Sign in to review this change.
8 files changed +335 −3
docs/features/ics.md added +10
@@ -0,0 +1,10 @@
1 # Calendar files (.ics) as a list of events
2
3 Open a `.ics` file in a repository, such as a calendar export, and read its events as a table: start, end, summary, location, in file order.
4
5 - **Web:** the blob page draws the table on the server (`src/web/render/ics.ts`, `parseIcs`). `?view=source`, Raw and Download work as for any file.
6 - **Terminal:** the workspace opens a `.ics` as the same table (`src/tui/screens/workspace.ts`, sharing `parseIcs` and `EVENT_CAP`); `s` flips to the source. The status line reads `N events`.
7 - Folded lines are unfolded and `\,` `\;` `\n` escapes are undone. UTC times show as `UTC`, `TZID` times keep their zone name, all-day events show dates and the last day (the file's end is exclusive).
8 - The first 500 events (`EVENT_CAP`) are shown; the rest are counted.
9 - A file with no `BEGIN:VCALENDAR` is not a calendar: it shows as source, with a one-line reason on the web.
10 - All text is escaped on output; the parser is pure and never throws.
src/tui/screens/workspace.ts +26 −3
@@ -53,6 +53,7 @@ import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
5353 import type { RepoFacts, RepoSource } from "../../view/source.ts";
5454 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5555 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
56 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
5657 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
5758 import { layoutTable, type Grid } from "../ui/grid.ts";
5859 import type { Ctx, Screen } from "../app/app.ts";
@@ -314,6 +315,25 @@ function subtitleTable(source: string): { table: Table; unread: number } {
314315 };
315316 }
316317
318 /** `.ics`: a calendar, drawn as a table of its events. */
319 function isIcsPath(path: string): boolean {
320 return /\.ics$/i.test(path);
321 }
322
323 /** The events of a calendar as a table; null when the file is not a calendar. Shares the web's parser. */
324 function icsTable(source: string): Table | null {
325 const cal = parseIcs(source);
326 if ("error" in cal) return null;
327 const shown = cal.events.slice(0, EVENT_CAP);
328 return {
329 header: ["Start", "End", "Summary", "Location"],
330 rows: shown.map((e) => [e.start, e.end, e.summary, e.location]),
331 more: cal.events.length - shown.length,
332 delimiter: ",",
333 columns: 4,
334 };
335 }
336
317337 /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */
318338 function isMarkdownPath(path: string): boolean {
319339 const name = path.slice(path.lastIndexOf("/") + 1);
@@ -468,6 +488,9 @@ class Tab {
468488 this.table = subs.table;
469489 this.unread = subs.unread;
470490 this.grid = layoutTable(this.table);
491 } else if (isIcsPath(this.path)) {
492 this.table = icsTable(new TextDecoder().decode(source));
493 if (this.table !== null) this.grid = layoutTable(this.table);
471494 } else if (isTablePath(this.path)) {
472495 // The decoded source, not `plain`: `expandTabs` has eaten every tab there.
473496 const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined;
@@ -2521,7 +2544,7 @@ export function workspaceScreen(
25212544 ? ""
25222545 : `${t.path} · ${
25232546 t.tabular && t.table !== null
2524 ? tableStatus(t.table, t.unread, isSubtitlePath(t.path))
2547 ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path))
25252548 : `${plural(
25262549 t.reading ? t.doc.length : t.plain.length,
25272550 "line",
@@ -2855,9 +2878,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
28552878 }
28562879
28572880 /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */
2858 function tableStatus(table: Table, unread = 0, subs = false): string {
2881 function tableStatus(table: Table, unread = 0, subs = false, events = false): string {
28592882 const rows = table.rows.length + table.more;
2860 return `${plural(rows, subs ? "cue" : "row")}${
2883 return `${plural(rows, subs ? "cue" : events ? "event" : "row")}${
28612884 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
28622885 }${
28632886 table.more > 0 ? `, first ${table.rows.length} shown` : ""
src/web/render/ics.ts added +181
@@ -0,0 +1,181 @@
1 /**
2 * A calendar file (iCalendar `.ics`, RFC 5545) as a list of its events: start,
3 * end (or "all day"), summary and location, in file order. Lines are unfolded
4 * (§3.1), VEVENT blocks read, DTSTART/DTEND taken with or without TZID, and
5 * TEXT escapes (`\,` `\;` `\\` `\n`) undone. Every value is escaped on output.
6 * A file that is not a calendar, or whose blocks do not close, falls back to
7 * its source.
8 */
9
10 import { escapeHtml, html, raw } from "../html.ts";
11 import { fileSize } from "../ui.ts";
12 import type { Rendered, RenderInput } from "./renderers.ts";
13
14 /** Events drawn, whatever the file holds. */
15 export const EVENT_CAP = 500;
16
17 export interface IcsEvent {
18 /** `2026-03-01 09:00`, `2026-03-01`, with a zone note when the file gave one. */
19 readonly start: string;
20 /** Empty when the file gave no end. For an all-day event, the last day. */
21 readonly end: string;
22 readonly allDay: boolean;
23 readonly summary: string;
24 readonly location: string;
25 }
26
27 /** Parsed events, or why the file is not a calendar. */
28 export type Calendar = { readonly events: IcsEvent[] } | { readonly error: string };
29
30 /** RFC 5545 §3.1: a line that starts with a space or tab continues the last. */
31 export function unfold(source: string): string[] {
32 const out: string[] = [];
33 for (const line of source.replace(/^/, "").split(/\r\n|\n|\r/)) {
34 if ((line.startsWith(" ") || line.startsWith("\t")) && out.length > 0) out[out.length - 1] += line.slice(1);
35 else out.push(line);
36 }
37 return out;
38 }
39
40 /** §3.3.11: undo `\\ \; \, \n \N`; an unknown escape keeps its character. */
41 export const unescapeText = (v: string): string =>
42 v.replace(/\\([\s\S])/g, (_, c: string) => (c === "n" || c === "N" ? "\n" : c));
43
44 interface Prop {
45 readonly name: string;
46 readonly params: ReadonlyMap<string, string>;
47 readonly value: string;
48 }
49
50 /** `NAME;PARAM=v;PARAM="a:b":value`, split at the first colon outside quotes. */
51 function property(line: string): Prop | undefined {
52 let quoted = false;
53 let colon = -1;
54 for (let i = 0; i < line.length; i++) {
55 const c = line[i];
56 if (c === '"') quoted = !quoted;
57 else if (c === ":" && !quoted) {
58 colon = i;
59 break;
60 }
61 }
62 if (colon <= 0) return undefined;
63 const [name, ...rest] = line.slice(0, colon).split(";");
64 const params = new Map<string, string>();
65 for (const p of rest) {
66 const eq = p.indexOf("=");
67 if (eq > 0) params.set(p.slice(0, eq).toUpperCase(), p.slice(eq + 1).replace(/^"|"$/g, ""));
68 }
69 return { name: name!.toUpperCase(), params, value: line.slice(colon + 1) };
70 }
71
72 interface When {
73 readonly text: string;
74 readonly dateOnly: boolean;
75 /** Days since the epoch, for an all-day end that is exclusive. */
76 readonly day: number;
77 }
78
79 const DATE_TIME = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/;
80
81 function when(p: Prop): When | undefined {
82 const m = DATE_TIME.exec(p.value.trim());
83 if (m === null) return undefined;
84 const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
85 if (mo < 1 || mo > 12 || d < 1 || d > 31) return undefined;
86 const day = Math.floor(Date.UTC(y, mo - 1, d) / 86_400_000);
87 const date = `${m[1]}-${m[2]}-${m[3]}`;
88 if (m[4] === undefined || p.params.get("VALUE") === "DATE") return { text: date, dateOnly: true, day };
89 if (Number(m[4]) > 23 || Number(m[5]) > 59) return undefined;
90 const zone = m[7] === "Z" ? " UTC" : p.params.has("TZID") ? ` ${p.params.get("TZID")}` : "";
91 return { text: `${date} ${m[4]}:${m[5]}${zone}`, dateOnly: false, day };
92 }
93
94 const isoDay = (day: number): string => new Date(day * 86_400_000).toISOString().slice(0, 10);
95
96 /** Parse a calendar's text. Pure; never throws. */
97 export function parseIcs(source: string): Calendar {
98 const lines = unfold(source);
99 if (!lines.some((l) => l.trim().toUpperCase() === "BEGIN:VCALENDAR"))
100 return { error: "not a calendar: no BEGIN:VCALENDAR" };
101 const events: IcsEvent[] = [];
102 let props: Prop[] | undefined;
103 // Components inside an event (VALARM) have their own SUMMARY and the like.
104 let depth = 0;
105 for (const line of lines) {
106 const t = line.trim().toUpperCase();
107 if (t === "BEGIN:VEVENT") {
108 if (props !== undefined) return { error: "an event begins inside another" };
109 props = [];
110 depth = 0;
111 continue;
112 }
113 if (props === undefined) continue;
114 if (t.startsWith("BEGIN:")) {
115 depth++;
116 continue;
117 }
118 if (t === "END:VEVENT" && depth === 0) {
119 const ev = toEvent(props);
120 if (ev === undefined) return { error: "an event has no readable DTSTART" };
121 events.push(ev);
122 props = undefined;
123 continue;
124 }
125 if (t.startsWith("END:")) {
126 depth--;
127 continue;
128 }
129 if (depth === 0) {
130 const p = property(line);
131 if (p !== undefined) props.push(p);
132 }
133 }
134 if (props !== undefined) return { error: "an event is never closed (no END:VEVENT)" };
135 return { events };
136 }
137
138 function toEvent(props: readonly Prop[]): IcsEvent | undefined {
139 const get = (n: string) => props.find((p) => p.name === n);
140 const startProp = get("DTSTART");
141 const start = startProp === undefined ? undefined : when(startProp);
142 if (start === undefined) return undefined;
143 const endProp = get("DTEND");
144 const end = endProp === undefined ? undefined : when(endProp);
145 let endText = end?.text ?? "";
146 if (start.dateOnly) {
147 // DTEND of a date is exclusive: show the last day, and nothing for one day.
148 const last = end?.dateOnly ? end.day - 1 : undefined;
149 endText = last !== undefined && last > start.day ? isoDay(last) : "";
150 }
151 return {
152 start: start.text,
153 end: endText,
154 allDay: start.dateOnly,
155 summary: unescapeText(get("SUMMARY")?.value ?? ""),
156 location: unescapeText(get("LOCATION")?.value ?? ""),
157 };
158 }
159
160 export function ics(input: RenderInput): Rendered {
161 const cal = parseIcs(new TextDecoder().decode(input.body));
162 if ("error" in cal) return { content: html``, lines: 0, fallback: `This file is not drawn as a calendar: ${cal.error}.` };
163 if (cal.events.length === 0) return { content: html``, lines: 0, fallback: "This calendar holds no events." };
164 let rows = "";
165 for (const e of cal.events.slice(0, EVENT_CAP)) {
166 const end = escapeHtml(e.end);
167 const start = e.allDay ? `${escapeHtml(e.start)} <span class="muted">all day</span>` : escapeHtml(e.start);
168 rows += `<tr><td>${start}</td><td>${end}</td><td>${escapeHtml(e.summary).replace(/\n/g, "<br>")}</td><td>${escapeHtml(e.location).replace(/\n/g, "<br>")}</td></tr>`;
169 }
170 const more =
171 cal.events.length > EVENT_CAP
172 ? `<p class="muted small">Showing ${EVENT_CAP.toLocaleString()} of ${cal.events.length.toLocaleString()} events, ${(cal.events.length - EVENT_CAP).toLocaleString()} more. The rest is in the raw file.</p>`
173 : "";
174 const name = input.path.slice(input.path.lastIndexOf("/") + 1);
175 const content = html`<table class="table" data-dense aria-label="${name} events">
176 <thead><tr><th scope="col">Start</th><th scope="col">End</th><th scope="col">Summary</th><th scope="col">Location</th></tr></thead>
177 <tbody>${raw(rows)}</tbody>
178 </table>${raw(more)}`;
179 const n = cal.events.length;
180 return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "event" : "events"} · ${fileSize(input.size)}` };
181 }
src/web/render/kinds.ts +3
@@ -28,6 +28,7 @@ export type FileKind =
2828 | "patch"
2929 | "log"
3030 | "subtitles"
31 | "calendar"
3132 | "text"
3233 | "binary";
3334
@@ -55,6 +56,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
5556 patch: 1 * MB,
5657 log: 1 * MB,
5758 subtitles: 5 * MB,
59 calendar: 5 * MB,
5860 text: 1 * MB,
5961 binary: 0,
6062 };
@@ -160,6 +162,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
160162 ansi: "log",
161163 srt: "subtitles",
162164 vtt: "subtitles",
165 ics: "calendar",
163166 };
164167
165168 /**
src/web/render/renderers.ts +2
@@ -12,6 +12,7 @@ import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
1313 import { csv } from "./csv.ts";
1414 import { font } from "./font.ts";
15 import { ics } from "./ics.ts";
1516 import { image } from "./image.ts";
1617 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1718 import { log, hasEscape } from "./log.ts";
@@ -66,6 +67,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
6667 patch,
6768 log,
6869 subtitles,
70 calendar: ics,
6971 archive,
7072 font,
7173 };
test/render-ics.test.ts added +79
@@ -0,0 +1,79 @@
1 /** Calendar files as a list of events: folding, all-day, TZID, escapes, hostile text, a bad file. */
2 import { describe, expect, test } from "bun:test";
3 import { EVENT_CAP, ics, parseIcs } from "../src/web/render/ics.ts";
4 import { fileKind } from "../src/web/render/kinds.ts";
5 import { RENDERERS } from "../src/web/render/renderers.ts";
6
7 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
8 const draw = (text: string) => ics({ path: "a/cal.ics", size: text.length, body: enc(text), media: "/m" });
9 const wrap = (...events: string[]) =>
10 `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${events.map((e) => `BEGIN:VEVENT\r\n${e}\r\nEND:VEVENT\r\n`).join("")}END:VCALENDAR\r\n`;
11
12 describe("calendar files", () => {
13 test("the registry names .ics and a renderer draws it", () => {
14 expect(fileKind("x/a.ICS", enc("BEGIN:VCALENDAR\n"))).toBe("calendar");
15 expect(RENDERERS.calendar).toBe(ics);
16 });
17
18 test("folded lines, escapes, UTC and TZID times, in file order", () => {
19 const cal = parseIcs(
20 wrap(
21 "DTSTART:20260301T090000Z\r\nDTEND:20260301T100000Z\r\nSUMMARY:Plan\\, build\\nand a very long\r\n title folded\r\nLOCATION:Room 1\\; East",
22 "DTSTART;TZID=Europe/Paris:20260302T140000\r\nDTEND;TZID=Europe/Paris:20260302T150000\r\nSUMMARY:Second",
23 ),
24 );
25 expect(cal).toEqual({
26 events: [
27 { start: "2026-03-01 09:00 UTC", end: "2026-03-01 10:00 UTC", allDay: false, summary: "Plan, build\nand a very long title folded", location: "Room 1; East" },
28 { start: "2026-03-02 14:00 Europe/Paris", end: "2026-03-02 15:00 Europe/Paris", allDay: false, summary: "Second", location: "" },

A single backslash before the semicolon inside a normal JS string becomes a bare semicolon, so no escape is tested. Double the backslash.

29 ],
30 });
31 });
32
33 test("all-day events: the exclusive end becomes the last day", () => {
34 const cal = parseIcs(
35 wrap(
36 "DTSTART;VALUE=DATE:20260301\r\nDTEND;VALUE=DATE:20260302\r\nSUMMARY:One day",
37 "DTSTART;VALUE=DATE:20260228\r\nDTEND;VALUE=DATE:20260303\r\nSUMMARY:Span",
38 ),
39 );
40 if ("error" in cal) throw new Error(cal.error);
41 expect(cal.events.map((e) => [e.allDay, e.start, e.end])).toEqual([
42 [true, "2026-03-01", ""],
43 [true, "2026-02-28", "2026-03-02"],
44 ]);
45 const out = String(draw(wrap("DTSTART;VALUE=DATE:20260301\r\nSUMMARY:X")).content);
46 expect(out).toContain("all day");
47 });
48
49 test("a nested VALARM does not overwrite the event's summary", () => {
50 const cal = parseIcs(wrap("DTSTART:20260301T090000\r\nSUMMARY:Real\r\nBEGIN:VALARM\r\nSUMMARY:Alarm\r\nEND:VALARM"));
51 if ("error" in cal) throw new Error(cal.error);
52 expect(cal.events[0]!.summary).toBe("Real");
53 });
54
55 test("a <script> summary is escaped, never emitted", () => {
56 const r = draw(wrap("DTSTART:20260301T090000Z\r\nSUMMARY:<script>alert(1)</script>\r\nLOCATION:\"><img src=x onerror=1>"));
57 const out = String(r.content);
58 expect(out).not.toContain("<script");
59 expect(out).not.toContain("<img");
60 expect(out).toContain("&lt;script&gt;");
61 expect(r.summary).toContain("1 event");
62 });
63
64 test("malformed files fall back to text", () => {
65 expect(draw("just some words").fallback).toBeDefined();
66 expect(draw("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260301\r\n").fallback).toContain("never closed");
67 expect(draw(wrap("SUMMARY:no start")).fallback).toContain("DTSTART");
68 expect(draw(wrap("DTSTART:20261399T000000Z\r\nSUMMARY:bad")).fallback).toBeDefined();
69 expect(draw("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n").fallback).toContain("no events");
70 });
71
72 test("capped at 500 events, saying how many more", () => {
73 const many = Array.from({ length: EVENT_CAP + 7 }, (_, i) => `DTSTART:20260301T090000Z\r\nSUMMARY:e${i}`);
74 const out = String(draw(wrap(...many)).content);
75 expect(out).toContain("e499<");
76 expect(out).not.toContain("e500<");
77 expect(out).toContain("7 more");
78 });
79 });
test/render-kinds.test.ts +1
@@ -68,6 +68,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
6868 ansi: [enc("\x1b[31mok\n"), "log", "application/octet-stream"],
6969 srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"],
7070 vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"],
71 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
7172 };
7273
7374 describe("the registry of file kinds", () => {
test/tui-workspace.test.ts +33
@@ -383,6 +383,39 @@ describe("the terminal workspace", () => {
383383 }
384384 });
385385
386 test("a .ics opens as a table of its events sharing the web parser, and s flips to the source", async () => {
387 const made = await createRepo(forge, alice, {
388 name: "calendars",
389 visibility: "public",
390 description: "events",
391 });
392 if (!made.ok) throw new Error("repo failed");
393 await commitToBranch(forge, alice, made.value, {
394 message: "seed",
395 files: [
396 {
397 path: "team.ics",
398 content:
399 "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260301T090000Z\r\nSUMMARY:Planning\r\nLOCATION:Room 1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
400 },
401 ],
402 });
403 const h = boot();
404 try {
405 await h.settle();
406 await open(h, "calendars");
407 await openFile(h, "team.ics");
408 expect(h.text()).toContain("Summary");
409 expect(h.text()).toContain("2026-03-01 09:00 UTC");
410 expect(h.text()).toContain("Planning");
411 expect(h.text()).toContain("1 event");
412 await h.press("s");
413 expect(h.text()).toContain("SUMMARY:Planning");
414 } finally {
415 h.stop();
416 }
417 });
418
386419 test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => {
387420 const made = await createRepo(forge, alice, {
388421 name: "sheets",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs