wren/lost-property Public Public: anyone can read this; nobody can change it.

workbenchthis machine

Build the lost-property museum: items, cases, and notes

Someone describes something they lost at the entrance; it is accessioned
with a deterministic catalogue number, sorted into a vitrine by keyword,
and given a case page with a plaque (catalogue, vitrine, condition).
Other visitors leave a note on a case: that they saw one like it, or
that they have the other half.

Split server.ts into db.ts (schema/queries), museum.ts (the pure
curatorial rules, unit tested on their own), pages.ts (rendering), and
server.ts (routing and the security floor, unchanged). Updated tty.ts
so the terminal can browse cases, accession items, and leave notes,
matching what the web page can do.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Wren committed b22929f Browse files
9 files changed +519 −119
README.md +13 −1
@@ -1,6 +1,18 @@
11 # lost-property
22
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.
416
517 This is an app: the forge runs it. Production follows the default branch;
618 every open pull gets a preview at its own address with its own copy of the
src/db.ts added +68
@@ -0,0 +1,68 @@
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 db.exec(
17 `CREATE TABLE IF NOT EXISTS item (
18 id INTEGER PRIMARY KEY,
19 description TEXT NOT NULL,
20 created_at TEXT NOT NULL DEFAULT (datetime('now'))
21 )`,
22 );
23 db.exec(
24 `CREATE TABLE IF NOT EXISTS note (
25 id INTEGER PRIMARY KEY,
26 item_id INTEGER NOT NULL REFERENCES item(id),
27 kind TEXT NOT NULL CHECK (kind IN ('sighting', 'match')),
28 body TEXT NOT NULL,
29 created_at TEXT NOT NULL DEFAULT (datetime('now'))
30 )`,
31 );
32
33 export type Item = { id: number; description: string; created_at: string };
34 export type ItemWithCount = Item & { note_count: number };
35 export type NoteKind = "sighting" | "match";
36 export type Note = { id: number; item_id: number; kind: NoteKind; body: string; created_at: string };
37
38 export function createItem(description: string): number {
39 const row = db
40 .query<{ id: number }, [string]>("INSERT INTO item (description) VALUES (?1) RETURNING id")
41 .get(description);
42 return row!.id;
43 }
44
45 export function getItem(id: number): Item | undefined {
46 return db.query<Item, [number]>("SELECT id, description, created_at FROM item WHERE id = ?1").get(id) ?? undefined;
47 }
48
49 export function listItems(): ItemWithCount[] {
50 return db
51 .query<
52 ItemWithCount,
53 []
54 >(`SELECT item.id, item.description, item.created_at,
55 (SELECT count(*) FROM note WHERE note.item_id = item.id) AS note_count
56 FROM item ORDER BY item.id DESC LIMIT 100`)
57 .all();
58 }
59
60 export function listNotes(itemId: number): Note[] {
61 return db
62 .query<Note, [number]>("SELECT id, item_id, kind, body, created_at FROM note WHERE item_id = ?1 ORDER BY id ASC")
63 .all(itemId);
64 }
65
66 export function addNote(itemId: number, kind: NoteKind, body: string): void {
67 db.query("INSERT INTO note (item_id, kind, body) VALUES (?1, ?2, ?3)").run(itemId, kind, body);
68 }
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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="/">&larr; 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";
88 const { server } = await import("./server.ts");
99 const base = `http://${server.hostname}:${server.port}`;
1010
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 () => {
1220 const response = await fetch(base + "/");
1321 expect(response.status).toBe(200);
14 expect(await response.text()).toContain("<h1>");
22 expect(await response.text()).toContain("<h1>lost-property</h1>");
1523 });
1624
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 () => {
1841 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" });
2144 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);
2391 });
2492
2593 test("stands on the security floor", async () => {
@@ -30,10 +98,10 @@ test("stands on the security floor", async () => {
3098
3199 test("a bug answers with the floor, not the runtime's page", async () => {
32100 // 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", {
34102 method: "POST",
35103 headers: { "content-type": "application/x-www-form-urlencoded" },
36 body: "body=%E0%A4%A",
104 body: "description=%E0%A4%A",
37105 });
38106 expect(response.status).toBe(200);
39107 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 () => {
42110 test("a body is capped by the bytes read, whatever it claims", async () => {
43111 // The cap counts bytes as they arrive and never reads Content-Length, so
44112 // 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", {
46114 method: "POST",
47115 headers: { "content-type": "application/x-www-form-urlencoded" },
48 body: "body=" + "x".repeat(1024 * 1024),
116 body: "description=" + "x".repeat(1024 * 1024),
49117 });
50118 expect(response.status).toBe(413);
51119 });
@@ -56,10 +124,17 @@ test("HEAD sees the app up", async () => {
56124 expect(await response.text()).toBe("");
57125 });
58126
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
59134 test("refuses a cross-site post", async () => {
60135 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", {
63138 method: "POST",
64139 body: form,
65140 redirect: "manual",
@@ -68,3 +143,10 @@ test("refuses a cross-site post", async () => {
68143 expect(posted.status).toBe(403);
69144 expect(await (await fetch(base + "/")).text()).not.toContain("from another site");
70145 });
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("&lt;script&gt;");
152 });
src/server.ts +45 −66
@@ -1,29 +1,20 @@
11 /**
2 * lost-property — a small app with real data.
2 * lost-property — a museum of lost things, as a small HTTP server.
33 *
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.
78 */
89
9 import { Database } from "bun:sqlite";
10 import { mkdirSync } from "node:fs";
1110 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";
2013
2114 const env = process.env.FORGE_APP_ENV ?? "local";
2215 const pull = process.env.FORGE_APP_PULL;
2316 const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
24
25 const escape = (s: string): string =>
26 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
17 const envLabel = env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
2718
2819 // The security floor every response stands on. Scripts and styles come from
2920 // this origin (inline styles allowed, inline scripts not), nothing frames us,
@@ -44,6 +35,9 @@ const SECURITY = {
4435 };
4536 const html = (body: string, status = 200): Response =>
4637 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;
4741
4842 // A browser on another site can make a person's browser POST here; it cannot
4943 // 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> {
10498 }).formData();
10599 }
106100
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$/;
143103
144104 export const server = Bun.serve({
145105 port: Number(process.env.PORT ?? 8080),
146106 hostname: process.env.HOST ?? "127.0.0.1",
147107 async fetch(request) {
148108 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") {
152113 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") ?? "");
155128 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);
158132 }
133
159134 // 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);
165143 }
144
166145 // Files under ./static are served as they are, cached for a year: name
167146 // them by content (app.3f2a9c.js) and a change is a new name, never a
168147 // stale cache. The path is confined to the directory.
@@ -173,7 +152,7 @@ export const server = Bun.serve({
173152 headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
174153 });
175154 }
176 return new Response("not found", { status: 404, headers: SECURITY });
155 return text("not found", 404);
177156 },
178157 // A bug answers with the floor and nothing else: never the runtime's own
179158 // error page, which carries source and paths, and never without the
src/tty.ts +112 −40
@@ -1,73 +1,145 @@
11 /**
22 * lost-property, in a terminal. The forge runs this on a pty when someone
33 * `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.
69 */
710
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";
1113
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");
2318
19 let screen: "list" | "case" = "list";
2420 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 };
2728 const esc = "\x1b[";
2829
29 function draw(): void {
30 const rows = notes();
30 function drawList(): void {
31 const items = listItems();
3132 const lines: string[] = [];
3233 lines.push(`${esc}1mlost-property${esc}0m ${esc}2m${where}${esc}0m`);
3334 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) => {
3637 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`);
3941 });
4042 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 );
4248 out(`${esc}2J${esc}H` + lines.join("\r\n"));
4349 }
4450
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
4589 process.stdin.setRawMode?.(true);
4690 process.stdin.resume();
4791 process.stdin.setEncoding("utf8");
4892 draw();
93
4994 process.stdin.on("data", (key: string) => {
5095 if (typing !== undefined) {
5196 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 }
54106 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;
59113 draw();
60114 return;
61115 }
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" };
71143 }
72144 draw();
73145 });
↑↓ navigate ↵ open esc close

Keyboard shortcuts