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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10371-a-contacts-file-vcf-in-a-repository-rend → main 4 commits fork 4 threads · 4 unresolved
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. | |
| 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"; | ||
| 58 | 58 | import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts"; |
| 59 | 59 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 60 | 60 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 61 | import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts"; | |
| 61 | 62 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| 62 | 63 | import { layoutTable, type Grid } from "../ui/grid.ts"; |
| 63 | 64 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -340,6 +341,25 @@ function icsTable(source: string): Table | null { | ||
| 340 | 341 | }; |
| 341 | 342 | } |
| 342 | 343 | |
| 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 | ||
| 343 | 363 | /** `.gpx`: a GPS track, drawn as a table of its facts (track, distance, climb, points). */ |
| 344 | 364 | function isGpxPath(path: string): boolean { |
| 345 | 365 | return /\.gpx$/i.test(path); |
| @@ -525,6 +545,9 @@ class Tab { | ||
| 525 | 545 | } else if (isIcsPath(this.path)) { |
| 526 | 546 | this.table = icsTable(new TextDecoder().decode(source)); |
| 527 | 547 | 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); | |
| 528 | 551 | } else if (isGpxPath(this.path)) { |
| 529 | 552 | this.table = gpxTable(new TextDecoder().decode(source), this.path); |
| 530 | 553 | if (this.table !== null) this.grid = layoutTable(this.table); |
| @@ -2613,7 +2636,7 @@ export function workspaceScreen( | ||
| 2613 | 2636 | ? "" |
| 2614 | 2637 | : `${t.path} · ${ |
| 2615 | 2638 | 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)) | |
| 2617 | 2640 | : `${plural( |
| 2618 | 2641 | t.reading ? t.doc.length : t.plain.length, |
| 2619 | 2642 | "line", |
| @@ -2947,9 +2970,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 2947 | 2970 | } |
| 2948 | 2971 | |
| 2949 | 2972 | /** `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 { | |
| 2951 | 2974 | 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")}${ | |
| 2953 | 2976 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 2954 | 2977 | }${ |
| 2955 | 2978 | table.more > 0 ? `, first ${table.rows.length} shown` : "" |
src/web/render/kinds.ts +4
| @@ -32,6 +32,7 @@ export type FileKind = | ||
| 32 | 32 | | "gpx" |
| 33 | 33 | | "geojson" |
| 34 | 34 | | "calendar" |
| 35 | | "contacts" | |
| 35 | 36 | | "text" |
| 36 | 37 | | "binary"; |
| 37 | 38 | |
| @@ -63,6 +64,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 63 | 64 | gpx: 20 * MB, |
| 64 | 65 | geojson: 5 * MB, |
| 65 | 66 | calendar: 5 * MB, |
| 67 | contacts: 5 * MB, | |
| 66 | 68 | text: 1 * MB, |
| 67 | 69 | binary: 0, |
| 68 | 70 | }; |
| @@ -173,6 +175,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 173 | 175 | gpx: "gpx", |
| 174 | 176 | geojson: "geojson", |
| 175 | 177 | ics: "calendar", |
| 178 | vcf: "contacts", | |
| 179 | vcard: "contacts", | |
| 176 | 180 | }; |
| 177 | 181 | |
| 178 | 182 | /** |
src/web/render/renderers.ts +2
| @@ -15,6 +15,7 @@ import { geojson } from "./geojson.ts"; | ||
| 15 | 15 | import { gpx } from "./gpx.ts"; |
| 16 | 16 | import { font } from "./font.ts"; |
| 17 | 17 | import { ics } from "./ics.ts"; |
| 18 | import { vcf } from "./vcf.ts"; | |
| 18 | 19 | import { image } from "./image.ts"; |
| 19 | 20 | import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 20 | 21 | import { log, hasEscape } from "./log.ts"; |
| @@ -78,6 +79,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 78 | 79 | gpx, |
| 79 | 80 | geojson, |
| 80 | 81 | calendar: ics, |
| 82 | contacts: vcf, | |
| 81 | 83 | archive, |
| 82 | 84 | font, |
| 83 | 85 | }; |
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 |
||
| 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]> = { | ||
| 71 | 71 | srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"], |
| 72 | 72 | vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"], |
| 73 | 73 | 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"], | |
| 74 | 76 | geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"], |
| 75 | 77 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 76 | 78 | }; |
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("<script>"); | |
| 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", () => { | ||
| 416 | 416 | } |
| 417 | 417 | }); |
| 418 | 418 | |
| 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 | ||
| 419 | 452 | test("a .gpx opens as its facts, sharing the web parser, and s flips to the source", async () => { |
| 420 | 453 | const made = await createRepo(forge, alice, { |
| 421 | 454 | name: "tracks", |
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.