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

A contacts file (.vcf) in a repository renders as a list of its contacts, nothing run #10639

Merged opened this

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

Merged

scout/forge:scout/10371-a-contacts-file-vcf-in-a-repository-rend → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/10371-a-contacts-file-vcf-in-a-repository-rend was merged into main as 09e5851.

Review

Sign in to review this change.
9 files changed +273 −3
docs/features/vcf-view.md added +8
@@ -0,0 +1,8 @@
1 # Contacts files (.vcf) as a list of people
2
3 Open a `.vcf` or `.vcard` file in a repository and read it as a list of its contacts, in file order, from a card's name and organisation to its email, phone, web and note.
outdated

ics.md splits Web and Terminal bullets. Add the Terminal bullet once the screen exists. One paragraph is what the issue asked for; keep it short and drop the internal constant names.

4
5 - **Web:** the blob page draws one row per person, with the labelled facts folded under the name so it fits a phone (`src/web/render/vcf.ts`). Values are escaped and urls are text, never links. Files over 5 MB, or with no complete card, show as source; past 500 contacts a "Showing first N" line says how many were left out. `?view=source`, Raw and Download work as for any file.
6 - **Terminal:** the workspace opens the same file as a table of name, organisation, email and phone, sharing the parser; `s` flips to the source. The status line reads `N contacts`.
7
8 Not done: vCard 2.1 quoted-printable and photos.
src/tui/screens/workspace.ts +26 −3
@@ -58,6 +58,7 @@ import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
5858 import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts";
5959 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6060 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
61 import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts";
6162 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
6263 import { layoutTable, type Grid } from "../ui/grid.ts";
6364 import type { Ctx, Screen } from "../app/app.ts";
@@ -340,6 +341,25 @@ function icsTable(source: string): Table | null {
340341 };
341342 }
342343
344 /** `.vcf` and `.vcard`: contacts, drawn as a table of people. */
345 function isVcfPath(path: string): boolean {
346 return /\.(vcf|vcard)$/i.test(path);
347 }
348
349 /** The cards of a contacts file as a table; null when the file is not one. Shares the web's parser. */
350 function vcfTable(source: string): Table | null {
351 const parsed = parseVcf(source);
352 if ("error" in parsed || parsed.total === 0) return null;
353 const shown = parsed.cards.slice(0, CARD_CAP);
354 return {
355 header: ["Name", "Organisation", "Email", "Phone"],
356 rows: shown.map((c) => [c.name, c.org, c.emails.join(", "), c.phones.join(", ")]),
357 more: parsed.total - shown.length,
358 delimiter: ",",
359 columns: 4,
360 };
361 }
362
343363 /** `.gpx`: a GPS track, drawn as a table of its facts (track, distance, climb, points). */
344364 function isGpxPath(path: string): boolean {
345365 return /\.gpx$/i.test(path);
@@ -525,6 +545,9 @@ class Tab {
525545 } else if (isIcsPath(this.path)) {
526546 this.table = icsTable(new TextDecoder().decode(source));
527547 if (this.table !== null) this.grid = layoutTable(this.table);
548 } else if (isVcfPath(this.path)) {
549 this.table = vcfTable(new TextDecoder().decode(source));
550 if (this.table !== null) this.grid = layoutTable(this.table);
528551 } else if (isGpxPath(this.path)) {
529552 this.table = gpxTable(new TextDecoder().decode(source), this.path);
530553 if (this.table !== null) this.grid = layoutTable(this.table);
@@ -2613,7 +2636,7 @@ export function workspaceScreen(
26132636 ? ""
26142637 : `${t.path} · ${
26152638 t.tabular && t.table !== null
2616 ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isGeoJsonPath(t.path))
2639 ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path))
26172640 : `${plural(
26182641 t.reading ? t.doc.length : t.plain.length,
26192642 "line",
@@ -2947,9 +2970,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
29472970 }
29482971
29492972 /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */
2950 function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, features = false): string {
2973 function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false): string {
29512974 const rows = table.rows.length + table.more;
2952 return `${plural(rows, subs ? "cue" : events ? "event" : facts ? "fact" : features ? "feature" : "row")}${
2975 return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : "row")}${
29532976 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
29542977 }${
29552978 table.more > 0 ? `, first ${table.rows.length} shown` : ""
src/web/render/kinds.ts +4
@@ -32,6 +32,7 @@ export type FileKind =
3232 | "gpx"
3333 | "geojson"
3434 | "calendar"
35 | "contacts"
3536 | "text"
3637 | "binary";
3738
@@ -63,6 +64,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
6364 gpx: 20 * MB,
6465 geojson: 5 * MB,
6566 calendar: 5 * MB,
67 contacts: 5 * MB,
6668 text: 1 * MB,
6769 binary: 0,
6870 };
@@ -173,6 +175,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
173175 gpx: "gpx",
174176 geojson: "geojson",
175177 ics: "calendar",
178 vcf: "contacts",
179 vcard: "contacts",
176180 };
177181
178182 /**
src/web/render/renderers.ts +2
@@ -15,6 +15,7 @@ import { geojson } from "./geojson.ts";
1515 import { gpx } from "./gpx.ts";
1616 import { font } from "./font.ts";
1717 import { ics } from "./ics.ts";
18 import { vcf } from "./vcf.ts";
1819 import { image } from "./image.ts";
1920 import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
2021 import { log, hasEscape } from "./log.ts";
@@ -78,6 +79,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
7879 gpx,
7980 geojson,
8081 calendar: ics,
82 contacts: vcf,
8183 archive,
8284 font,
8385 };
src/web/render/vcf-parse.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * A contacts file (vCard `.vcf`, RFC 6350 and 2426/2.1 in the common subset)
3 * as plain data: one entry per card with its name, organisation, emails,
4 * phones, urls and note. Lines are unfolded, TEXT escapes undone, multiple
5 * cards read in file order, and a card with only FN is kept. Pure; never
6 * throws. Nothing here is markup: the caller escapes.
7 */
8
9 import { unescapeText, unfold } from "./ics.ts";
10
11 /** Cards kept, whatever the file holds. */
12 export const CARD_CAP = 500;
13 /** Values kept per list field on one card. */
14 export const VALUE_CAP = 20;
15 /** Longest single value kept, in characters. */
16 export const VALUE_LENGTH = 2000;
17
18 export interface Card {
19 readonly name: string;
20 readonly org: string;
21 readonly emails: string[];
22 readonly phones: string[];
23 readonly urls: string[];
24 readonly note: string;
25 }
26
27 export type Contacts = { readonly cards: Card[]; readonly total: number } | { readonly error: string };
28
29 const clip = (v: string): string => (v.length > VALUE_LENGTH ? v.slice(0, VALUE_LENGTH) : v);
30
31 /** Split at the first colon; the name is what precedes the first `;` or `.` group. */
32 function split(line: string): { name: string; params: string; value: string } | undefined {
33 const colon = line.indexOf(":");
34 if (colon <= 0) return undefined;
35 const head = line.slice(0, colon);
36 const semi = head.indexOf(";");
37 let name = semi < 0 ? head : head.slice(0, semi);
38 name = name.slice(name.lastIndexOf(".") + 1).trim().toUpperCase();
39 return { name, params: semi < 0 ? "" : head.slice(semi + 1).toUpperCase(), value: line.slice(colon + 1) };
40 }
41
42 /** The `N` property as a display name: given, family, in reading order. */
43 const fromN = (v: string): string => {
44 const [family = "", given = "", additional = "", prefix = "", suffix = ""] = v.split(";").map((s) => unescapeText(s).trim());
45 return [prefix, given, additional, family, suffix].filter(Boolean).join(" ");
46 };
47
48 export function parseVcf(source: string): Contacts {
49 const lines = unfold(source);
50 if (!lines.some((l) => l.trim().toUpperCase() === "BEGIN:VCARD")) return { error: "not a contacts file: no BEGIN:VCARD" };
51 const cards: Card[] = [];
52 let total = 0;
53 let cur: { fn: string; n: string; org: string; emails: string[]; phones: string[]; urls: string[]; note: string } | undefined;
54 for (const line of lines) {
55 const t = line.trim().toUpperCase();
56 if (t === "BEGIN:VCARD") {
57 cur = { fn: "", n: "", org: "", emails: [], phones: [], urls: [], note: "" };
58 continue;
59 }
60 if (cur === undefined) continue;
61 if (t === "END:VCARD") {
62 total++;
63 if (cards.length < CARD_CAP)
64 cards.push({
65 name: cur.fn || cur.n || cur.emails[0] || "(no name)",
66 org: cur.org,
67 emails: cur.emails,
68 phones: cur.phones,
69 urls: cur.urls,
70 note: cur.note,
71 });
72 cur = undefined;
73 continue;
74 }
75 // Past the cap a card is only counted; its fields are not kept.
76 if (cards.length >= CARD_CAP) continue;
77 const p = split(line);
78 if (p === undefined) continue;
79 const add = (list: string[], v: string) => {
80 const x = clip(unescapeText(v).trim());
81 if (x !== "" && list.length < VALUE_CAP) list.push(x);
82 };
83 if (p.name === "FN") cur.fn = clip(unescapeText(p.value).trim());
84 else if (p.name === "N") cur.n = clip(fromN(p.value));
85 else if (p.name === "ORG") cur.org = clip(p.value.split(";").map((s) => unescapeText(s).trim()).filter(Boolean).join(", "));
86 else if (p.name === "EMAIL") add(cur.emails, p.value);
87 else if (p.name === "TEL") add(cur.phones, p.value.replace(/^tel:/i, ""));
88 else if (p.name === "URL") add(cur.urls, p.value);
89 else if (p.name === "NOTE") cur.note = clip(unescapeText(p.value).trim());
90 }
91 // A card that never closes is dropped, not guessed at.
92 return { cards, total };
93 }
src/web/render/vcf.ts added +38
@@ -0,0 +1,38 @@
1 /**
2 * A contacts file (`.vcf`, `.vcard`) as a readable list of people, one row
3 * each: the name and organisation, with labelled email, phone, web and note
4 * folded beneath. Every value is escaped by the template; a url or note is
5 * shown as text, never as a link, so a `javascript:` value is inert.
6 */
7
8 import { html, type Html } from "../html.ts";
9 import { fileSize } from "../ui.ts";
10 import type { Rendered, RenderInput } from "./renderers.ts";
11 import { CARD_CAP, parseVcf, type Card } from "./vcf-parse.ts";
12
13 const fact = (label: string, values: readonly string[]): Html | null =>
outdated

Both doors: nothing in src/tui draws contacts. Add the terminal view in workspace.ts sharing parseVcf and CARD_CAP, as .ics does, with a test.

14 values.length === 0 ? null : html`<div><dt>${label}</dt><dd>${values.join(", ")}</dd></div>`;
15
16 function row(c: Card): Html {
outdated

Principles 3 and 7: five columns with <br>-stacked cells will overflow a 360px phone. Stack email and phone under the name as a row, or verify the table scrolls in its own container. Also the 'Other' column mixes urls and notes unlabelled; split or label.

17 const facts = [fact("Email", c.emails), fact("Phone", c.phones), fact("Web", c.urls), fact("Note", c.note === "" ? [] : [c.note])].filter(
18 (f): f is Html => f !== null,
19 );
20 return html`<div class="status-row">
21 <dt>${c.name}${c.org === "" ? "" : html` <span class="muted">${c.org}</span>`}</dt>
22 <dd>${facts.length === 0 ? "" : html`<dl class="row-facts">${facts}</dl>`}</dd>
23 </div>`;
24 }
25
outdated

Hand-rolled <p class="muted small"> through raw(); use the design system's named note component and build it with html`` so escaping is by construction. Principle 8: 'The rest is in the raw file' is fine, keep it short.

26 export function vcf(input: RenderInput): Rendered {
27 const parsed = parseVcf(new TextDecoder().decode(input.body));
28 if ("error" in parsed) return { content: html``, lines: 0, fallback: `This file is not drawn as contacts: ${parsed.error}.` };
29 if (parsed.total === 0) return { content: html``, lines: 0, fallback: "This contacts file holds no complete cards." };
30 const more =
31 parsed.total > CARD_CAP
32 ? html`<p class="muted small">Showing first ${CARD_CAP.toLocaleString()} of ${parsed.total.toLocaleString()} contacts.</p>`
33 : html``;
34 const name = input.path.slice(input.path.lastIndexOf("/") + 1);
35 const content = html`<dl class="status-rows" aria-label="${name} contacts">${parsed.cards.map(row)}</dl>${more}`;
36 const n = parsed.total;
37 return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "contact" : "contacts"} · ${fileSize(input.size)}` };
38 }
test/render-kinds.test.ts +2
@@ -71,6 +71,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
7171 srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"],
7272 vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"],
7373 gpx: [enc("<gpx/>"), "gpx", "application/octet-stream"],
74 vcf: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"],
75 vcard: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"],
7476 geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"],
7577 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
7678 };
test/render-vcf.test.ts added +67
@@ -0,0 +1,67 @@
1 /** Contacts files as a list of people: folding, many cards, FN only, hostile text, caps, a bad file. */
2 import { describe, expect, test } from "bun:test";
3 import { fileKind, RENDER_CAP } from "../src/web/render/kinds.ts";
4 import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts";
5 import { vcf } from "../src/web/render/vcf.ts";
6 import { CARD_CAP, parseVcf } from "../src/web/render/vcf-parse.ts";
7
8 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
9 const draw = (text: string) => vcf({ path: "a/people.vcf", size: text.length, body: enc(text), media: "/m", download: "/d" });
10 const card = (...l: string[]) => `BEGIN:VCARD\r\nVERSION:3.0\r\n${l.join("\r\n")}\r\nEND:VCARD\r\n`;
11 const html = (r: ReturnType<typeof vcf>): string => String(r.content);
12
13 describe("contacts files", () => {
14 test("the registry names .vcf and .vcard and a renderer draws them", () => {
15 expect(fileKind("x/a.VCF", enc("BEGIN:VCARD\n"))).toBe("contacts");
16 expect(fileKind("a.vcard", enc("BEGIN:VCARD\n"))).toBe("contacts");
17 expect(RENDERERS.contacts).toBe(vcf);
18 });
19
20 test("folding, many cards, FN only, escapes", () => {
21 const r = parseVcf(
22 card("FN:Ada Lovelace", "N:Lovelace;Ada;;;", "ORG:Analytical\\, Ltd;Dept", "EMAIL;TYPE=work:[email protected]", "TEL;TYPE=cell:+1 555 0100", "NOTE:long\r\n folded\\nline2") +
23 card("FN:Only Name") +
24 card("N:Hopper;Grace;;Rear Admiral;"),
25 );
26 if ("error" in r) throw new Error(r.error);
27 expect(r.total).toBe(3);
28 expect(r.cards[0]).toEqual({ name: "Ada Lovelace", org: "Analytical, Ltd, Dept", emails: ["[email protected]"], phones: ["+1 555 0100"], urls: [], note: "long folded\nline2" });
29 expect(r.cards[1]!.name).toBe("Only Name");
30 expect(r.cards[2]!.name).toBe("Rear Admiral Grace Hopper");
31 });
32
33 test("hostile fields render as text, never markup or links", () => {
34 const out = html(draw(card("FN:<img src=x onerror=alert(1)>", "URL:javascript:alert(1)", "NOTE:<script>alert(2)</script>", "EMAIL:a<b>@x")));
35 expect(out).not.toContain("<script");
36 expect(out).not.toContain("<img");
37 expect(out).not.toContain("<b>");
38 expect(out).not.toContain("<a ");
39 expect(out).toContain("&lt;script&gt;");
40 expect(out).toContain("javascript:alert(1)");
41 });
42
43 test("past the cap, a named 'showing first' line and a full count", () => {
44 const big = Array.from({ length: CARD_CAP + 3 }, (_, i) => card(`FN:P${i}`)).join("");
45 const r = draw(big);
46 expect(html(r)).toContain(`Showing first ${CARD_CAP} of ${CARD_CAP + 3} contacts`);
47 expect(html(r)).not.toContain(`P${CARD_CAP}<`);
48 expect(r.summary).toContain(`${CARD_CAP + 3} contacts`);
49 });
50
51 test("urls and notes are labelled apart, under the name, in rows not a wide table", () => {
52 const out = html(draw(card("FN:Ada", "EMAIL:[email protected]", "TEL:123", "URL:http://x.org", "NOTE:hi")));
53 for (const label of ["Email", "Phone", "Web", "Note"]) expect(out).toContain(`<dt>${label}</dt>`);
54 expect(out).not.toContain("<table");
55 });
56
57 test("past the size cap the renderer is not used and the source shows", () => {
58 const blob = (size: number) => ({ kind: "text", body: enc("BEGIN:VCARD\nFN:A\nEND:VCARD\n"), size, oid: "0".repeat(40) }) as never;
59 expect(rendererFor("a.vcf", blob(RENDER_CAP.contacts))?.render).toBe(vcf);
60 expect(rendererFor("a.vcf", blob(RENDER_CAP.contacts + 1))).toBeUndefined();
61 });
62
63 test("a file that is not contacts falls back to source", () => {
64 expect(draw("hello").fallback).toContain("BEGIN:VCARD");
65 expect(draw("BEGIN:VCARD\nFN:Unclosed\n").fallback).toContain("no complete cards");
66 });
67 });
test/tui-workspace.test.ts +33
@@ -416,6 +416,39 @@ describe("the terminal workspace", () => {
416416 }
417417 });
418418
419 test("a .vcf opens as a table of its contacts sharing the web parser, and s flips to the source", async () => {
420 const made = await createRepo(forge, alice, {
421 name: "people",
422 visibility: "public",
423 description: "contacts",
424 });
425 if (!made.ok) throw new Error("repo failed");
426 await commitToBranch(forge, alice, made.value, {
427 message: "seed",
428 files: [
429 {
430 path: "team.vcf",
431 content:
432 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nORG:Analytical\r\nEMAIL:[email protected]\r\nEND:VCARD\r\n",
433 },
434 ],
435 });
436 const h = boot();
437 try {
438 await h.settle();
439 await open(h, "people");
440 await openFile(h, "team.vcf");
441 expect(h.text()).toContain("Organisation");
442 expect(h.text()).toContain("Ada Lovelace");
443 expect(h.text()).toContain("[email protected]");
444 expect(h.text()).toContain("1 contact");
445 await h.press("s");
446 expect(h.text()).toContain("FN:Ada Lovelace");
447 } finally {
448 h.stop();
449 }
450 });
451
419452 test("a .gpx opens as its facts, sharing the web parser, and s flips to the source", async () => {
420453 const made = await createRepo(forge, alice, {
421454 name: "tracks",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs