wren/lost-property Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Merge pull request #2 from wren/1-build-lost-property
Build lost-property
10 files
changed +559
−119
README.md +13 −1
| @@ -1,6 +1,18 @@ | ||
| 1 | 1 | # lost-property |
| 2 | 2 | |
| 3 | A museum of lost things. Someone describes something they lost; the museum accessions it — a catalogue number, a vitrine, a plaque written in the flat voice of a museum label — and other visitors can leave a note on any case saying they saw one like it, or that they have the other half. | |
| 3 | A museum of lost things. Someone describes something they lost, at `/`; the | |
| 4 | museum accessions it — a catalogue number (`LP 2026.0003`), a vitrine, a | |
| 5 | plaque written in the flat voice of a museum label — and gives it a case at | |
| 6 | `/items/:id`. Other visitors can leave a note on any case: that they saw one | |
| 7 | like it, or that they have the other half. | |
| 8 | ||
| 9 | A catalogue number and a condition line are derived from the item's id; a | |
| 10 | vitrine is chosen by matching a handful of keywords in the description | |
| 11 | against a fixed list of themes (glasses go to *Spectacles and Lenses*, keys | |
| 12 | to *Keys and Fastenings*, anything unmatched to *Miscellanea*). That's the | |
| 13 | whole curator: no image recognition, no AI, no human judgement — a small | |
| 14 | deterministic table in `src/museum.ts`, so the same item always reads the | |
| 15 | same way and it's easy to see exactly what it will say before you submit it. | |
| 4 | 16 | |
| 5 | 17 | This is an app: the forge runs it. Production follows the default branch; |
| 6 | 18 | every open pull gets a preview at its own address with its own copy of the |
src/db.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { Database } from "bun:sqlite"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | ||
| 7 | // Seed a data directory with the note table an earlier version of this app | |
| 8 | // left behind: no item_id, so it does not fit the current schema. | |
| 9 | const dir = mkdtempSync(join(tmpdir(), "app-")); | |
| 10 | process.env.FORGE_APP_DATA = dir; | |
| 11 | const seed = new Database(join(dir, "app.db")); | |
| 12 | seed.exec( | |
| 13 | "CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 14 | ); | |
| 15 | seed.exec("INSERT INTO note (body) VALUES ('an old note from before items existed')"); | |
| 16 | seed.close(); | |
| 17 | ||
| 18 | const { db, createItem, addNote, listNotes, listItems } = await import("./db.ts"); | |
| 19 | ||
| 20 | test("moves an old, incompatible note table aside instead of crashing", () => { | |
| 21 | const row = db.query<{ body: string }, []>("SELECT body FROM note_legacy").get(); | |
| 22 | expect(row?.body).toBe("an old note from before items existed"); | |
| 23 | }); | |
| 24 | ||
| 25 | test("the collection works normally after migrating", () => { | |
| 26 | const id = createItem("a test item"); | |
| 27 | addNote(id, "sighting", "hello"); | |
| 28 | expect(listNotes(id)).toHaveLength(1); | |
| 29 | expect(listItems()[0]?.note_count).toBe(1); | |
| 30 | }); |
src/db.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * The collection: items that were lost and the notes visitors leave on | |
| 3 | * them. One SQLite file under FORGE_APP_DATA; production and each preview | |
| 4 | * get their own, so a preview starts from production's data and never | |
| 5 | * writes back to it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { mkdirSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | ||
| 12 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 13 | mkdirSync(dataDir, { recursive: true }); | |
| 14 | export const db = new Database(join(dataDir, "app.db")); | |
| 15 | db.exec("PRAGMA journal_mode = WAL"); | |
| 16 | ||
| 17 | // An earlier version of this app kept a flat note table with no item to | |
| 18 | // belong to. `CREATE TABLE IF NOT EXISTS` below would leave that shape in | |
| 19 | // place forever, so a table missing item_id is moved aside once, keeping | |
| 20 | // its rows rather than losing them. | |
| 21 | const existingNoteColumns = db.query<{ name: string }, []>("PRAGMA table_info(note)").all(); | |
| 22 | if (existingNoteColumns.length > 0 && !existingNoteColumns.some((c) => c.name === "item_id")) { | |
| 23 | db.exec("ALTER TABLE note RENAME TO note_legacy"); | |
| 24 | } | |
| 25 | ||
| 26 | db.exec( | |
| 27 | `CREATE TABLE IF NOT EXISTS item ( | |
| 28 | id INTEGER PRIMARY KEY, | |
| 29 | description TEXT NOT NULL, | |
| 30 | created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 31 | )`, | |
| 32 | ); | |
| 33 | db.exec( | |
| 34 | `CREATE TABLE IF NOT EXISTS note ( | |
| 35 | id INTEGER PRIMARY KEY, | |
| 36 | item_id INTEGER NOT NULL REFERENCES item(id), | |
| 37 | kind TEXT NOT NULL CHECK (kind IN ('sighting', 'match')), | |
| 38 | body TEXT NOT NULL, | |
| 39 | created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 40 | )`, | |
| 41 | ); | |
| 42 | ||
| 43 | export type Item = { id: number; description: string; created_at: string }; | |
| 44 | export type ItemWithCount = Item & { note_count: number }; | |
| 45 | export type NoteKind = "sighting" | "match"; | |
| 46 | export type Note = { id: number; item_id: number; kind: NoteKind; body: string; created_at: string }; | |
| 47 | ||
| 48 | export function createItem(description: string): number { | |
| 49 | const row = db | |
| 50 | .query<{ id: number }, [string]>("INSERT INTO item (description) VALUES (?1) RETURNING id") | |
| 51 | .get(description); | |
| 52 | return row!.id; | |
| 53 | } | |
| 54 | ||
| 55 | export function getItem(id: number): Item | undefined { | |
| 56 | return db.query<Item, [number]>("SELECT id, description, created_at FROM item WHERE id = ?1").get(id) ?? undefined; | |
| 57 | } | |
| 58 | ||
| 59 | export function listItems(): ItemWithCount[] { | |
| 60 | return db | |
| 61 | .query< | |
| 62 | ItemWithCount, | |
| 63 | [] | |
| 64 | >(`SELECT item.id, item.description, item.created_at, | |
| 65 | (SELECT count(*) FROM note WHERE note.item_id = item.id) AS note_count | |
| 66 | FROM item ORDER BY item.id DESC LIMIT 100`) | |
| 67 | .all(); | |
| 68 | } | |
| 69 | ||
| 70 | export function listNotes(itemId: number): Note[] { | |
| 71 | return db | |
| 72 | .query<Note, [number]>("SELECT id, item_id, kind, body, created_at FROM note WHERE item_id = ?1 ORDER BY id ASC") | |
| 73 | .all(itemId); | |
| 74 | } | |
| 75 | ||
| 76 | export function addNote(itemId: number, kind: NoteKind, body: string): void { | |
| 77 | db.query("INSERT INTO note (item_id, kind, body) VALUES (?1, ?2, ?3)").run(itemId, kind, body); | |
| 78 | } |
src/html.ts added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | /** Escaping shared by every page: the one place user text touches HTML. */ | |
| 2 | export const escape = (s: string): string => | |
| 3 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); |
src/museum.test.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts"; | |
| 3 | ||
| 4 | test("sorts a description into its vitrine by keyword", () => { | |
| 5 | expect(VITRINES[vitrineOf("a single brown leather glove")]).toBe("Outerwear and Umbrellas"); | |
| 6 | expect(VITRINES[vitrineOf("a set of house keys on a red fob")]).toBe("Keys and Fastenings"); | |
| 7 | expect(VITRINES[vitrineOf("reading glasses, tortoiseshell frame")]).toBe("Spectacles and Lenses"); | |
| 8 | expect(VITRINES[vitrineOf("an iphone charger, white")]).toBe("Small Electronics"); | |
| 9 | expect(VITRINES[vitrineOf("a silver ring, engraved")]).toBe("Jewellery and Adornment"); | |
| 10 | expect(VITRINES[vitrineOf("a paperback book, dog-eared")]).toBe("Papers and Correspondence"); | |
| 11 | expect(VITRINES[vitrineOf("a stuffed teddy bear")]).toBe("Children's Effects"); | |
| 12 | }); | |
| 13 | ||
| 14 | test("falls back to Miscellanea when nothing matches", () => { | |
| 15 | expect(VITRINES[vitrineOf("a smooth grey stone")]).toBe("Miscellanea"); | |
| 16 | }); | |
| 17 | ||
| 18 | test("gives the same item the same condition every time", () => { | |
| 19 | expect(conditionOf(7)).toBe(conditionOf(7)); | |
| 20 | }); | |
| 21 | ||
| 22 | test("builds a catalogue number from the year and id", () => { | |
| 23 | expect(catalogueNumber(3, "2026-09-14 10:00:00")).toBe("LP 2026.0003"); | |
| 24 | }); |
src/museum.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * The curatorial rules: pure functions, no I/O, no randomness. Everything a | |
| 3 | * plaque says about an object — its catalogue number, its vitrine, its | |
| 4 | * condition — is derived only from the object's id and the description its | |
| 5 | * loser gave, so the same item always reads the same way. | |
| 6 | */ | |
| 7 | ||
| 8 | export const VITRINES: readonly string[] = [ | |
| 9 | "Outerwear and Umbrellas", | |
| 10 | "Keys and Fastenings", | |
| 11 | "Spectacles and Lenses", | |
| 12 | "Small Electronics", | |
| 13 | "Jewellery and Adornment", | |
| 14 | "Papers and Correspondence", | |
| 15 | "Children's Effects", | |
| 16 | "Miscellanea", | |
| 17 | ]; | |
| 18 | ||
| 19 | // Checked in order; the first match wins. Anything that matches nothing | |
| 20 | // goes to Miscellanea, the last vitrine. | |
| 21 | const KEYWORDS: readonly [RegExp, number][] = [ | |
| 22 | [/\b(umbrellas?|coats?|jackets?|gloves?|scarves?|scarfs?|hats?|mittens?)\b/i, 0], | |
| 23 | [/\b(keys?|padlocks?|keyrings?|locks?)\b/i, 1], | |
| 24 | [/\b(glasses|spectacles|sunglasses|lenses?)\b/i, 2], | |
| 25 | [/\b(phones?|chargers?|laptops?|tablets?|earbuds?|headphones?|cameras?|cables?|airpods?)\b/i, 3], | |
| 26 | [/\b(rings?|necklaces?|bracelets?|earrings?|watches?|brooches?)\b/i, 4], | |
| 27 | [/\b(books?|letters?|notebooks?|diaries|diary|papers?|envelopes?|postcards?)\b/i, 5], | |
| 28 | [/\b(toys?|teddy|teddies|dolls?|dummies|dummy|pacifiers?)\b/i, 6], | |
| 29 | ]; | |
| 30 | ||
| 31 | export function vitrineOf(description: string): number { | |
| 32 | for (const [pattern, index] of KEYWORDS) if (pattern.test(description)) return index; | |
| 33 | return VITRINES.length - 1; | |
| 34 | } | |
| 35 | ||
| 36 | const CONDITIONS: readonly string[] = [ | |
| 37 | "Wear consistent with ordinary use.", | |
| 38 | "Good condition; minor surface wear.", | |
| 39 | "Handled; edges softened with use.", | |
| 40 | "Condition at time of loss not recorded.", | |
| 41 | "As described by the finder; not further examined.", | |
| 42 | "Stable condition; no active deterioration noted.", | |
| 43 | ]; | |
| 44 | ||
| 45 | export function conditionOf(id: number): string { | |
| 46 | return CONDITIONS[id % CONDITIONS.length]!; | |
| 47 | } | |
| 48 | ||
| 49 | // A museum accession number: LP, the year it entered the collection, and | |
| 50 | // its sequence within the collection — not within that year, since this | |
| 51 | // museum is smaller than that distinction is worth. | |
| 52 | export function catalogueNumber(id: number, createdAt: string): string { | |
| 53 | const year = createdAt.slice(0, 4); | |
| 54 | return `LP ${year}.${String(id).padStart(4, "0")}`; | |
| 55 | } |
src/pages.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** The two pages: the entrance (catalogue and intake form) and a case. */ | |
| 2 | ||
| 3 | import { escape } from "./html.ts"; | |
| 4 | import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts"; | |
| 5 | import type { Item, ItemWithCount, Note } from "./db.ts"; | |
| 6 | ||
| 7 | function layout(env: string, commit: string, body: string): string { | |
| 8 | return `<!doctype html> | |
| 9 | <meta charset="utf-8"> | |
| 10 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 11 | <title>lost-property</title> | |
| 12 | <style> | |
| 13 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 14 | body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; } | |
| 15 | .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem; | |
| 16 | background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; } | |
| 17 | textarea, input[type=text] { width: 100%; box-sizing: border-box; padding: .6rem .8rem; font: inherit; | |
| 18 | border: 1px solid #8884; border-radius: .5rem; } | |
| 19 | button { margin-top: .6rem; padding: .7rem 1.2rem; font: inherit; border: 0; border-radius: .5rem; | |
| 20 | background: #111; color: white; min-height: 44px; } | |
| 21 | a { color: inherit; } | |
| 22 | ul.catalogue { padding: 0; } | |
| 23 | li.case { padding: .8rem 0; border-top: 1px solid #8883; list-style: none; } | |
| 24 | .tag { display: inline-block; font-size: .75rem; opacity: .7; border: 1px solid #8886; border-radius: 999px; | |
| 25 | padding: .05rem .5rem; margin: .3rem .4rem 0 0; } | |
| 26 | .plaque { border: 1px solid #8886; border-radius: .5rem; padding: 1rem 1.2rem; margin: 1.5rem 0; } | |
| 27 | .plaque .catalogue-number { font-size: .8rem; opacity: .7; letter-spacing: .05em; text-transform: uppercase; } | |
| 28 | .plaque h2 { margin: .2rem 0 .8rem; } | |
| 29 | .plaque dl { display: grid; grid-template-columns: auto 1fr; gap: .2rem 1rem; font-size: .9rem; opacity: .85; margin: 0; } | |
| 30 | .plaque dt { font-weight: 600; } | |
| 31 | .plaque dd { margin: 0; } | |
| 32 | .note { padding: .6rem 0; border-top: 1px solid #8883; } | |
| 33 | .note .kind { font-weight: 600; font-size: .85rem; } | |
| 34 | small { opacity: .6; } | |
| 35 | fieldset { border: 0; padding: 0; margin: 1rem 0 .8rem; display: flex; gap: 1rem; flex-wrap: wrap; } | |
| 36 | label.option { display: flex; align-items: center; gap: .4rem; min-height: 44px; } | |
| 37 | </style> | |
| 38 | ${body} | |
| 39 | <p><small>lost-property <span class="env">${escape(env)}</span> ${commit ? "@ " + escape(commit) : ""}</small></p> | |
| 40 | `; | |
| 41 | } | |
| 42 | ||
| 43 | export function renderHome(items: ItemWithCount[], env: string, commit: string): string { | |
| 44 | const rows = items | |
| 45 | .map((item) => { | |
| 46 | const vitrine = vitrineOf(item.description); | |
| 47 | const short = item.description.length > 80 ? item.description.slice(0, 80) + "…" : item.description; | |
| 48 | return `<li class="case"> | |
| 49 | <a href="/items/${item.id}">${escape(short)}</a><br> | |
| 50 | <span class="tag">${escape(catalogueNumber(item.id, item.created_at))}</span><span class="tag">${escape(VITRINES[vitrine]!)}</span> | |
| 51 | <br><small>${item.note_count} note${item.note_count === 1 ? "" : "s"} · accessioned ${escape(item.created_at)}</small> | |
| 52 | </li>`; | |
| 53 | }) | |
| 54 | .join(""); | |
| 55 | return layout( | |
| 56 | env, | |
| 57 | commit, | |
| 58 | `<h1>lost-property</h1> | |
| 59 | <p>A museum of lost things. Describe what you lost; the museum accessions it — a catalogue number, a vitrine, a plaque written in the flat voice of a museum label. Other visitors can leave a note on any case: that they saw one like it, or that they have the other half.</p> | |
| 60 | <form method="post" action="/items"> | |
| 61 | <label for="description">Describe what you lost</label> | |
| 62 | <textarea id="description" name="description" rows="3" maxlength="500" required | |
| 63 | placeholder="A single brown leather glove, left hand, found beneath a train seat."></textarea> | |
| 64 | <button>Accession it</button> | |
| 65 | </form> | |
| 66 | <ul class="catalogue">${rows || `<li class="case"><small>Nothing accessioned yet.</small></li>`}</ul>`, | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | export function renderCase(item: Item, notes: Note[], env: string, commit: string): string { | |
| 71 | const vitrineIndex = vitrineOf(item.description); | |
| 72 | const noteRows = notes | |
| 73 | .map( | |
| 74 | (n) => `<div class="note"> | |
| 75 | <span class="kind">${n.kind === "match" ? "Has the other half" : "Saw one like it"}</span> | |
| 76 | <p>${escape(n.body)}</p> | |
| 77 | <small>${escape(n.created_at)}</small> | |
| 78 | </div>`, | |
| 79 | ) | |
| 80 | .join(""); | |
| 81 | return layout( | |
| 82 | env, | |
| 83 | commit, | |
| 84 | `<p><a href="/">← lost-property</a></p> | |
| 85 | <article class="plaque"> | |
| 86 | <p class="catalogue-number">${escape(catalogueNumber(item.id, item.created_at))} · Vitrine ${vitrineIndex + 1}</p> | |
| 87 | <h2>${escape(VITRINES[vitrineIndex]!)}</h2> | |
| 88 | <p>${escape(item.description)}</p> | |
| 89 | <dl> | |
| 90 | <dt>Accessioned</dt><dd>${escape(item.created_at)}</dd> | |
| 91 | <dt>Condition</dt><dd>${escape(conditionOf(item.id))}</dd> | |
| 92 | </dl> | |
| 93 | </article> | |
| 94 | <h3>Notes</h3> | |
| 95 | ${noteRows || `<p><small>No notes yet.</small></p>`} | |
| 96 | <form method="post" action="/items/${item.id}/notes"> | |
| 97 | <fieldset> | |
| 98 | <label class="option"><input type="radio" name="kind" value="sighting" checked required> I saw one like it</label> | |
| 99 | <label class="option"><input type="radio" name="kind" value="match" required> I have the other half</label> | |
| 100 | </fieldset> | |
| 101 | <input type="text" name="body" maxlength="500" required placeholder="Where, when, or how to get in touch"> | |
| 102 | <button>Leave a note</button> | |
| 103 | </form>`, | |
| 104 | ); | |
| 105 | } |
src/server.test.ts +94 −12
| @@ -8,18 +8,86 @@ process.env.PORT = "0"; | ||
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | 9 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | 10 | |
| 11 | test("serves the page", async () => { | |
| 11 | async function accession(description: string): Promise<string> { | |
| 12 | const form = new FormData(); | |
| 13 | form.set("description", description); | |
| 14 | const posted = await fetch(base + "/items", { method: "POST", body: form, redirect: "manual" }); | |
| 15 | expect(posted.status).toBe(303); | |
| 16 | return posted.headers.get("location")!; | |
| 17 | } | |
| 18 | ||
| 19 | test("serves the entrance", async () => { | |
| 12 | 20 | const response = await fetch(base + "/"); |
| 13 | 21 | expect(response.status).toBe(200); |
| 14 | expect(await response.text()).toContain("<h1>"); | |
| 22 | expect(await response.text()).toContain("<h1>lost-property</h1>"); | |
| 15 | 23 | }); |
| 16 | 24 | |
| 17 | test("keeps a note", async () => { | |
| 25 | test("accessions a lost item with a catalogue number and a vitrine", async () => { | |
| 26 | const location = await accession("a single brown leather glove, left hand"); | |
| 27 | expect(location).toMatch(/^\/items\/\d+$/); | |
| 28 | const page = await (await fetch(base + location)).text(); | |
| 29 | expect(page).toContain("a single brown leather glove"); | |
| 30 | expect(page).toMatch(/LP \d{4}\.\d{4}/); | |
| 31 | expect(page).toContain("Outerwear and Umbrellas"); | |
| 32 | }); | |
| 33 | ||
| 34 | test("lists an accessioned item on the entrance", async () => { | |
| 35 | await accession("a set of house keys on a red fob"); | |
| 36 | const page = await (await fetch(base + "/")).text(); | |
| 37 | expect(page).toContain("a set of house keys on a red fob"); | |
| 38 | }); | |
| 39 | ||
| 40 | test("an empty description is not accessioned", async () => { | |
| 18 | 41 | const form = new FormData(); |
| 19 | form.set("body", "hello from a test"); | |
| 20 | const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 42 | form.set("description", " "); | |
| 43 | const posted = await fetch(base + "/items", { method: "POST", body: form, redirect: "manual" }); | |
| 21 | 44 | expect(posted.status).toBe(303); |
| 22 | expect(await (await fetch(base + "/")).text()).toContain("hello from a test"); | |
| 45 | expect(posted.headers.get("location")).toBe("/"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a case that does not exist answers 404", async () => { | |
| 49 | const response = await fetch(base + "/items/999999"); | |
| 50 | expect(response.status).toBe(404); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a visitor leaves a note saying they saw one like it", async () => { | |
| 54 | const location = await accession("reading glasses, tortoiseshell frame"); | |
| 55 | const form = new FormData(); | |
| 56 | form.set("kind", "sighting"); | |
| 57 | form.set("body", "Saw one just like this at the market on Tuesday."); | |
| 58 | const posted = await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 59 | expect(posted.status).toBe(303); | |
| 60 | const page = await (await fetch(base + location)).text(); | |
| 61 | expect(page).toContain("Saw one like it"); | |
| 62 | expect(page).toContain("Saw one just like this at the market on Tuesday."); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a visitor leaves a note saying they have the other half", async () => { | |
| 66 | const location = await accession("a single earring, gold hoop"); | |
| 67 | const form = new FormData(); | |
| 68 | form.set("kind", "match"); | |
| 69 | form.set("body", "I have the matching earring, email me."); | |
| 70 | await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 71 | const page = await (await fetch(base + location)).text(); | |
| 72 | expect(page).toContain("Has the other half"); | |
| 73 | expect(page).toContain("I have the matching earring, email me."); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a note needs a valid kind", async () => { | |
| 77 | const location = await accession("a paperback book, dog-eared"); | |
| 78 | const form = new FormData(); | |
| 79 | form.set("kind", "not-a-real-kind"); | |
| 80 | form.set("body", "hello"); | |
| 81 | const posted = await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 82 | expect(posted.status).toBe(400); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a note cannot be left on a case that does not exist", async () => { | |
| 86 | const form = new FormData(); | |
| 87 | form.set("kind", "sighting"); | |
| 88 | form.set("body", "hello"); | |
| 89 | const posted = await fetch(base + "/items/999999/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 90 | expect(posted.status).toBe(404); | |
| 23 | 91 | }); |
| 24 | 92 | |
| 25 | 93 | test("stands on the security floor", async () => { |
| @@ -30,10 +98,10 @@ test("stands on the security floor", async () => { | ||
| 30 | 98 | |
| 31 | 99 | test("a bug answers with the floor, not the runtime's page", async () => { |
| 32 | 100 | // A percent-escape that is not one makes the search params throw. |
| 33 | const response = await fetch(base + "/notes", { | |
| 101 | const response = await fetch(base + "/items", { | |
| 34 | 102 | method: "POST", |
| 35 | 103 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 36 | body: "body=%E0%A4%A", | |
| 104 | body: "description=%E0%A4%A", | |
| 37 | 105 | }); |
| 38 | 106 | expect(response.status).toBe(200); |
| 39 | 107 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| @@ -42,10 +110,10 @@ test("a bug answers with the floor, not the runtime's page", async () => { | ||
| 42 | 110 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 43 | 111 | // The cap counts bytes as they arrive and never reads Content-Length, so |
| 44 | 112 | // a chunked body with no length is refused the same way this one is. |
| 45 | const response = await fetch(base + "/notes", { | |
| 113 | const response = await fetch(base + "/items", { | |
| 46 | 114 | method: "POST", |
| 47 | 115 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 48 | body: "body=" + "x".repeat(1024 * 1024), | |
| 116 | body: "description=" + "x".repeat(1024 * 1024), | |
| 49 | 117 | }); |
| 50 | 118 | expect(response.status).toBe(413); |
| 51 | 119 | }); |
| @@ -56,10 +124,17 @@ test("HEAD sees the app up", async () => { | ||
| 56 | 124 | expect(await response.text()).toBe(""); |
| 57 | 125 | }); |
| 58 | 126 | |
| 127 | test("HEAD on a case answers like GET without a body", async () => { | |
| 128 | const location = await accession("a stuffed teddy bear, missing an eye"); | |
| 129 | const response = await server.fetch(new Request(base + location, { method: "HEAD" })); | |
| 130 | expect(response.status).toBe(200); | |
| 131 | expect(await response.text()).toBe(""); | |
| 132 | }); | |
| 133 | ||
| 59 | 134 | test("refuses a cross-site post", async () => { |
| 60 | 135 | const form = new FormData(); |
| 61 | form.set("body", "from another site"); | |
| 62 | const posted = await fetch(base + "/notes", { | |
| 136 | form.set("description", "from another site"); | |
| 137 | const posted = await fetch(base + "/items", { | |
| 63 | 138 | method: "POST", |
| 64 | 139 | body: form, |
| 65 | 140 | redirect: "manual", |
| @@ -68,3 +143,10 @@ test("refuses a cross-site post", async () => { | ||
| 68 | 143 | expect(posted.status).toBe(403); |
| 69 | 144 | expect(await (await fetch(base + "/")).text()).not.toContain("from another site"); |
| 70 | 145 | }); |
| 146 | ||
| 147 | test("escapes a description that carries markup", async () => { | |
| 148 | const location = await accession("<script>alert(1)</script>"); | |
| 149 | const page = await (await fetch(base + location)).text(); | |
| 150 | expect(page).not.toContain("<script>alert(1)</script>"); | |
| 151 | expect(page).toContain("<script>"); | |
| 152 | }); |
src/server.ts +45 −66
| @@ -1,29 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | * lost-property — a small app with real data. | |
| 2 | * lost-property — a museum of lost things, as a small HTTP server. | |
| 3 | 3 | * |
| 4 | * Notes live in a SQLite file under FORGE_APP_DATA. Production and each | |
| 5 | * preview get their own directory, so a preview starts with production's | |
| 6 | * notes and never writes back to them. | |
| 4 | * Data lives in SQLite under FORGE_APP_DATA (src/db.ts). The catalogue | |
| 5 | * number, vitrine and condition on a plaque are derived deterministically | |
| 6 | * from an item's id and description (src/museum.ts) — no AI, no lookup, | |
| 7 | * just pattern matching, so the same object always reads the same way. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { mkdirSync } from "node:fs"; | |
| 11 | 10 | import { join } from "node:path"; |
| 12 | ||
| 13 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 14 | mkdirSync(dataDir, { recursive: true }); | |
| 15 | const db = new Database(join(dataDir, "app.db")); | |
| 16 | db.exec("PRAGMA journal_mode = WAL"); | |
| 17 | db.exec( | |
| 18 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 19 | ); | |
| 11 | import { addNote, createItem, getItem, listItems, listNotes } from "./db.ts"; | |
| 12 | import { renderCase, renderHome } from "./pages.ts"; | |
| 20 | 13 | |
| 21 | 14 | const env = process.env.FORGE_APP_ENV ?? "local"; |
| 22 | 15 | const pull = process.env.FORGE_APP_PULL; |
| 23 | 16 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); |
| 24 | ||
| 25 | const escape = (s: string): string => | |
| 26 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 17 | const envLabel = env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 27 | 18 | |
| 28 | 19 | // The security floor every response stands on. Scripts and styles come from |
| 29 | 20 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| @@ -44,6 +35,9 @@ const SECURITY = { | ||
| 44 | 35 | }; |
| 45 | 36 | const html = (body: string, status = 200): Response => |
| 46 | 37 | new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } }); |
| 38 | const text = (body: string, status: number): Response => new Response(body, { status, headers: SECURITY }); | |
| 39 | const asGet = (full: Response, method: string): Response => | |
| 40 | method === "HEAD" ? new Response(null, { status: full.status, headers: full.headers }) : full; | |
| 47 | 41 | |
| 48 | 42 | // A browser on another site can make a person's browser POST here; it cannot |
| 49 | 43 | // make that request look same-origin. Browsers say where a request came from |
| @@ -104,65 +98,50 @@ async function readForm(request: Request): Promise<FormData | undefined> { | ||
| 104 | 98 | }).formData(); |
| 105 | 99 | } |
| 106 | 100 | |
| 107 | function page(): string { | |
| 108 | const notes = db | |
| 109 | .query<{ id: number; body: string; created_at: string }, []>( | |
| 110 | "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100", | |
| 111 | ) | |
| 112 | .all(); | |
| 113 | const where = | |
| 114 | env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 115 | return `<!doctype html> | |
| 116 | <meta charset="utf-8"> | |
| 117 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 118 | <title>lost-property</title> | |
| 119 | <style> | |
| 120 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 121 | body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; } | |
| 122 | .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem; | |
| 123 | background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; } | |
| 124 | form { display: flex; gap: .5rem; margin: 1.5rem 0; } | |
| 125 | input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; } | |
| 126 | button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; } | |
| 127 | li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; } | |
| 128 | small { opacity: .6; } | |
| 129 | </style> | |
| 130 | <h1>lost-property</h1> | |
| 131 | <p>A museum of lost things. Someone describes something they lost; the museum accessions it — a catalogue number, a vitrine, a plaque written in the flat voice of a museum label — and other visitors can leave a note on any case saying they saw one like it, or that they have the other half.</p> | |
| 132 | <p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p> | |
| 133 | <form method="post" action="/notes"> | |
| 134 | <input name="body" placeholder="Write a note" required maxlength="500" autofocus> | |
| 135 | <button>Add</button> | |
| 136 | </form> | |
| 137 | <ul>${notes | |
| 138 | .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`) | |
| 139 | .join("")}</ul> | |
| 140 | <p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p> | |
| 141 | `; | |
| 142 | } | |
| 101 | const ITEM_PATH = /^\/items\/(\d+)$/; | |
| 102 | const NOTES_PATH = /^\/items\/(\d+)\/notes$/; | |
| 143 | 103 | |
| 144 | 104 | export const server = Bun.serve({ |
| 145 | 105 | port: Number(process.env.PORT ?? 8080), |
| 146 | 106 | hostname: process.env.HOST ?? "127.0.0.1", |
| 147 | 107 | async fetch(request) { |
| 148 | 108 | const url = new URL(request.url); |
| 149 | if (request.method === "POST" && !sameOrigin(request)) | |
| 150 | return new Response("forbidden", { status: 403 }); | |
| 151 | if (request.method === "POST" && url.pathname === "/notes") { | |
| 109 | const method = request.method; | |
| 110 | if (method === "POST" && !sameOrigin(request)) return text("forbidden", 403); | |
| 111 | ||
| 112 | if (method === "POST" && url.pathname === "/items") { | |
| 152 | 113 | const form = await readForm(request); |
| 153 | if (form === undefined) | |
| 154 | return new Response("too large", { status: 413, headers: SECURITY }); | |
| 114 | if (form === undefined) return text("too large", 413); | |
| 115 | const description = String(form.get("description") ?? "").trim().slice(0, 500); | |
| 116 | if (!description) return Response.redirect("/", 303); | |
| 117 | const id = createItem(description); | |
| 118 | return Response.redirect(`/items/${id}`, 303); | |
| 119 | } | |
| 120 | ||
| 121 | const notesMatch = url.pathname.match(NOTES_PATH); | |
| 122 | if (method === "POST" && notesMatch) { | |
| 123 | const item = getItem(Number(notesMatch[1])); | |
| 124 | if (item === undefined) return text("not found", 404); | |
| 125 | const form = await readForm(request); | |
| 126 | if (form === undefined) return text("too large", 413); | |
| 127 | const kind = String(form.get("kind") ?? ""); | |
| 155 | 128 | const body = String(form.get("body") ?? "").trim().slice(0, 500); |
| 156 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body); | |
| 157 | return Response.redirect("/", 303); | |
| 129 | if ((kind !== "sighting" && kind !== "match") || !body) return text("bad request", 400); | |
| 130 | addNote(item.id, kind, body); | |
| 131 | return Response.redirect(`/items/${item.id}`, 303); | |
| 158 | 132 | } |
| 133 | ||
| 159 | 134 | // HEAD is GET without the body: a health check sees the app up. |
| 160 | if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { | |
| 161 | const full = html(page()); | |
| 162 | return request.method === "HEAD" | |
| 163 | ? new Response(null, { status: full.status, headers: full.headers }) | |
| 164 | : full; | |
| 135 | if (url.pathname === "/" && (method === "GET" || method === "HEAD")) | |
| 136 | return asGet(html(renderHome(listItems(), envLabel, commit)), method); | |
| 137 | ||
| 138 | const itemMatch = url.pathname.match(ITEM_PATH); | |
| 139 | if (itemMatch && (method === "GET" || method === "HEAD")) { | |
| 140 | const item = getItem(Number(itemMatch[1])); | |
| 141 | if (item === undefined) return text("not found", 404); | |
| 142 | return asGet(html(renderCase(item, listNotes(item.id), envLabel, commit)), method); | |
| 165 | 143 | } |
| 144 | ||
| 166 | 145 | // Files under ./static are served as they are, cached for a year: name |
| 167 | 146 | // them by content (app.3f2a9c.js) and a change is a new name, never a |
| 168 | 147 | // stale cache. The path is confined to the directory. |
| @@ -173,7 +152,7 @@ export const server = Bun.serve({ | ||
| 173 | 152 | headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }, |
| 174 | 153 | }); |
| 175 | 154 | } |
| 176 | return new Response("not found", { status: 404, headers: SECURITY }); | |
| 155 | return text("not found", 404); | |
| 177 | 156 | }, |
| 178 | 157 | // A bug answers with the floor and nothing else: never the runtime's own |
| 179 | 158 | // error page, which carries source and paths, and never without the |
src/tty.ts +112 −40
| @@ -1,73 +1,145 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * lost-property, in a terminal. The forge runs this on a pty when someone |
| 3 | 3 | * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the |
| 4 | * web page uses. j/k move, a adds a note, d deletes the one under the | |
| 5 | * cursor, q leaves. Plain ANSI, no dependencies. | |
| 4 | * web page uses. | |
| 5 | * | |
| 6 | * j/k move, enter opens a case, a accessions something new, s/m leave a | |
| 7 | * note on an open case (saw one / have the other half), b or esc goes | |
| 8 | * back, q leaves. Plain ANSI, no dependencies. | |
| 6 | 9 | */ |
| 7 | 10 | |
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { mkdirSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { addNote, getItem, listItems, listNotes, createItem, type NoteKind } from "./db.ts"; | |
| 12 | import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts"; | |
| 11 | 13 | |
| 12 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 13 | mkdirSync(dataDir, { recursive: true }); | |
| 14 | const db = new Database(join(dataDir, "app.db")); | |
| 15 | db.exec("PRAGMA journal_mode = WAL"); | |
| 16 | db.exec( | |
| 17 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 18 | ); | |
| 19 | const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local"); | |
| 20 | ||
| 21 | type Note = { id: number; body: string; created_at: string }; | |
| 22 | const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all(); | |
| 14 | const where = | |
| 15 | process.env.FORGE_APP_ENV === "preview" | |
| 16 | ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` | |
| 17 | : (process.env.FORGE_APP_ENV ?? "local"); | |
| 23 | 18 | |
| 19 | let screen: "list" | "case" = "list"; | |
| 24 | 20 | let cursor = 0; |
| 25 | let typing: string | undefined; | |
| 26 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 21 | let caseId: number | undefined; | |
| 22 | let typing: { for: "description" } | { for: "note"; kind: NoteKind } | undefined; | |
| 23 | let input = ""; | |
| 24 | ||
| 25 | const out = (s: string): void => { | |
| 26 | process.stdout.write(s); | |
| 27 | }; | |
| 27 | 28 | const esc = "\x1b["; |
| 28 | 29 | |
| 29 | function draw(): void { | |
| 30 | const rows = notes(); | |
| 30 | function drawList(): void { | |
| 31 | const items = listItems(); | |
| 31 | 32 | const lines: string[] = []; |
| 32 | 33 | lines.push(`${esc}1mlost-property${esc}0m ${esc}2m${where}${esc}0m`); |
| 33 | 34 | lines.push(""); |
| 34 | if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`); | |
| 35 | rows.forEach((n, i) => { | |
| 35 | if (items.length === 0) lines.push(`${esc}2mNothing accessioned yet. Press a to describe something you lost.${esc}0m`); | |
| 36 | items.forEach((item, i) => { | |
| 36 | 37 | const mark = i === cursor ? "▸" : " "; |
| 37 | const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body; | |
| 38 | lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`); | |
| 38 | const short = item.description.length > 60 ? item.description.slice(0, 60) + "…" : item.description; | |
| 39 | const label = i === cursor ? `${esc}1m${short}${esc}0m` : short; | |
| 40 | lines.push(`${mark} ${label} ${esc}2m${catalogueNumber(item.id, item.created_at)} · ${item.note_count} note${item.note_count === 1 ? "" : "s"}${esc}0m`); | |
| 39 | 41 | }); |
| 40 | 42 | lines.push(""); |
| 41 | lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`); | |
| 43 | lines.push( | |
| 44 | typing?.for === "description" | |
| 45 | ? `Describe what you lost: ${input}${esc}7m ${esc}0m` | |
| 46 | : `${esc}2mj/k move · enter open · a accession · q quit${esc}0m`, | |
| 47 | ); | |
| 42 | 48 | out(`${esc}2J${esc}H` + lines.join("\r\n")); |
| 43 | 49 | } |
| 44 | 50 | |
| 51 | function drawCase(): void { | |
| 52 | const item = caseId === undefined ? undefined : getItem(caseId); | |
| 53 | if (item === undefined) { | |
| 54 | screen = "list"; | |
| 55 | drawList(); | |
| 56 | return; | |
| 57 | } | |
| 58 | const vitrineIndex = vitrineOf(item.description); | |
| 59 | const notes = listNotes(item.id); | |
| 60 | const lines: string[] = []; | |
| 61 | lines.push(`${esc}1m${catalogueNumber(item.id, item.created_at)}${esc}0m ${esc}2m${where}${esc}0m`); | |
| 62 | lines.push(`${esc}1m${VITRINES[vitrineIndex]}${esc}0m — Vitrine ${vitrineIndex + 1}`); | |
| 63 | lines.push(""); | |
| 64 | lines.push(item.description); | |
| 65 | lines.push(""); | |
| 66 | lines.push(`${esc}2mAccessioned ${item.created_at}${esc}0m`); | |
| 67 | lines.push(`${esc}2mCondition: ${conditionOf(item.id)}${esc}0m`); | |
| 68 | lines.push(""); | |
| 69 | lines.push(`${esc}1mNotes${esc}0m`); | |
| 70 | if (notes.length === 0) lines.push(`${esc}2mNo notes yet.${esc}0m`); | |
| 71 | for (const n of notes) { | |
| 72 | lines.push(`${n.kind === "match" ? "Has the other half" : "Saw one like it"}: ${n.body}`); | |
| 73 | lines.push(` ${esc}2m${n.created_at}${esc}0m`); | |
| 74 | } | |
| 75 | lines.push(""); | |
| 76 | lines.push( | |
| 77 | typing?.for === "note" | |
| 78 | ? `${typing.kind === "match" ? "Have the other half" : "Saw one like it"}: ${input}${esc}7m ${esc}0m` | |
| 79 | : `${esc}2ms saw one like it · m have the other half · b back · q quit${esc}0m`, | |
| 80 | ); | |
| 81 | out(`${esc}2J${esc}H` + lines.join("\r\n")); | |
| 82 | } | |
| 83 | ||
| 84 | function draw(): void { | |
| 85 | if (screen === "list") drawList(); | |
| 86 | else drawCase(); | |
| 87 | } | |
| 88 | ||
| 45 | 89 | process.stdin.setRawMode?.(true); |
| 46 | 90 | process.stdin.resume(); |
| 47 | 91 | process.stdin.setEncoding("utf8"); |
| 48 | 92 | draw(); |
| 93 | ||
| 49 | 94 | process.stdin.on("data", (key: string) => { |
| 50 | 95 | if (typing !== undefined) { |
| 51 | 96 | if (key === "\r") { |
| 52 | const body = typing.trim(); | |
| 53 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500)); | |
| 97 | const value = input.trim().slice(0, 500); | |
| 98 | if (typing.for === "description") { | |
| 99 | if (value) { | |
| 100 | caseId = createItem(value); | |
| 101 | screen = "case"; | |
| 102 | } | |
| 103 | } else if (value && caseId !== undefined) { | |
| 104 | addNote(caseId, typing.kind, value); | |
| 105 | } | |
| 54 | 106 | typing = undefined; |
| 55 | cursor = 0; | |
| 56 | } else if (key === "\x1b") typing = undefined; | |
| 57 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 58 | else if (key >= " ") typing += key; | |
| 107 | input = ""; | |
| 108 | } else if (key === "\x1b") { | |
| 109 | typing = undefined; | |
| 110 | input = ""; | |
| 111 | } else if (key === "\x7f") input = input.slice(0, -1); | |
| 112 | else if (key >= " ") input += key; | |
| 59 | 113 | draw(); |
| 60 | 114 | return; |
| 61 | 115 | } |
| 62 | if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } | |
| 63 | const count = notes().length; | |
| 64 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); | |
| 65 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 66 | if (key === "a") typing = ""; | |
| 67 | if (key === "d") { | |
| 68 | const n = notes()[cursor]; | |
| 69 | if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id); | |
| 70 | cursor = Math.min(cursor, Math.max(0, notes().length - 1)); | |
| 116 | if (screen === "list") { | |
| 117 | if (key === "q" || key === "\x03") { | |
| 118 | out(`${esc}2J${esc}H`); | |
| 119 | process.exit(0); | |
| 120 | } | |
| 121 | const count = listItems().length; | |
| 122 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); | |
| 123 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 124 | if (key === "a") typing = { for: "description" }; | |
| 125 | if (key === "\r") { | |
| 126 | const item = listItems()[cursor]; | |
| 127 | if (item) { | |
| 128 | caseId = item.id; | |
| 129 | screen = "case"; | |
| 130 | } | |
| 131 | } | |
| 132 | } else { | |
| 133 | if (key === "q" || key === "\x03") { | |
| 134 | out(`${esc}2J${esc}H`); | |
| 135 | process.exit(0); | |
| 136 | } | |
| 137 | if (key === "b" || key === "\x1b") { | |
| 138 | screen = "list"; | |
| 139 | caseId = undefined; | |
| 140 | } | |
| 141 | if (key === "s") typing = { for: "note", kind: "sighting" }; | |
| 142 | if (key === "m") typing = { for: "note", kind: "match" }; | |
| 71 | 143 | } |
| 72 | 144 | draw(); |
| 73 | 145 | }); |