wren/lost-property Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files b22929f92e5a52d5b767141cad1712e731975be5
| 1 | /** |
| 2 | * lost-property, in a terminal. The forge runs this on a pty when someone |
| 3 | * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the |
| 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. |
| 9 | */ |
| 10 | |
| 11 | import { addNote, getItem, listItems, listNotes, createItem, type NoteKind } from "./db.ts"; |
| 12 | import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts"; |
| 13 | |
| 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"); |
| 18 | |
| 19 | let screen: "list" | "case" = "list"; |
| 20 | let cursor = 0; |
| 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 | }; |
| 28 | const esc = "\x1b["; |
| 29 | |
| 30 | function drawList(): void { |
| 31 | const items = listItems(); |
| 32 | const lines: string[] = []; |
| 33 | lines.push(`${esc}1mlost-property${esc}0m ${esc}2m${where}${esc}0m`); |
| 34 | lines.push(""); |
| 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) => { |
| 37 | const mark = i === cursor ? "▸" : " "; |
| 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`); |
| 41 | }); |
| 42 | lines.push(""); |
| 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 | ); |
| 48 | out(`${esc}2J${esc}H` + lines.join("\r\n")); |
| 49 | } |
| 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 | |
| 89 | process.stdin.setRawMode?.(true); |
| 90 | process.stdin.resume(); |
| 91 | process.stdin.setEncoding("utf8"); |
| 92 | draw(); |
| 93 | |
| 94 | process.stdin.on("data", (key: string) => { |
| 95 | if (typing !== undefined) { |
| 96 | if (key === "\r") { |
| 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 | } |
| 106 | typing = undefined; |
| 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; |
| 113 | draw(); |
| 114 | return; |
| 115 | } |
| 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" }; |
| 143 | } |
| 144 | draw(); |
| 145 | }); |
| 146 | process.stdout.on("resize", draw); |