Public: anyone can read this; the crew can change it.
Download a listing's launch kit: every asset in one file, marked approved or draft, to hand to an assistant #49
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/38-download-a-listing-s-launch-kit-every-as → main 1 commit fork
13 files
changed +295
−10
README.md +10
| @@ -57,6 +57,11 @@ to make a listing of your own. | ||
| 57 | 57 | 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with |
| 58 | 58 | the photo, price, facts, description and your name. Print it or save it |
| 59 | 59 | as a PDF. |
| 60 | 10. **Download the launch kit.** Press **Download launch kit** at the top of | |
| 61 | the listing: one text file named from the address, with the price and | |
| 62 | facts, then every asset under its name, each marked "Approved by … on …" | |
| 63 | or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand | |
| 64 | it to an assistant or a coordinator. | |
| 60 | 65 | |
| 61 | 66 | `src/walkthrough.test.ts` walks these steps on every `bun test`, so this |
| 62 | 67 | list cannot drift from the app. |
| @@ -130,6 +135,11 @@ list cannot drift from the app. | ||
| 130 | 135 | JavaScript the page still renders. "Copy subject" copies the subject line |
| 131 | 136 | apart, for the mail tool's subject box; the message copied is the card only. |
| 132 | 137 | In the terminal, `p` on an open listing previews the same message as text. Photos are this app's own, never remote. |
| 138 | **Download launch kit** (`GET /listings/:id/kit.txt`, built in `src/kit.ts`) sends | |
| 139 | the whole listing as one attachment named like `412-alder-lane-portland-or-97214-launch-kit.txt`. | |
| 140 | Whoever may read the listing may download it; no permission changed. The `launch_kit` | |
| 141 | MCP tool returns the same text, and `k` on an open listing in the terminal shows it | |
| 142 | (the arrow keys move between assets there). | |
| 133 | 143 | 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo, |
| 134 | 144 | price, a strip of the facts, the description, your photo, name, |
| 135 | 145 | brokerage and license number, and the Equal Housing Opportunity line. |
contract/openapi.json +38
| @@ -6,6 +6,44 @@ | ||
| 6 | 6 | "description": "One address in, a listing launched: the MLS description, three social posts, a flyer and an email blast, each checked for Fair Housing language and dressed in the agent's own brand." |
| 7 | 7 | }, |
| 8 | 8 | "paths": { |
| 9 | "/listings/{id}/kit.txt": { | |
| 10 | "get": { | |
| 11 | "summary": "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.", | |
| 12 | "operationId": "getListingsIdKitTxt", | |
| 13 | "parameters": [ | |
| 14 | { | |
| 15 | "name": "id", | |
| 16 | "in": "path", | |
| 17 | "required": true, | |
| 18 | "schema": { | |
| 19 | "type": "integer" | |
| 20 | } | |
| 21 | } | |
| 22 | ], | |
| 23 | "responses": { | |
| 24 | "200": { | |
| 25 | "description": "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.", | |
| 26 | "content": { | |
| 27 | "text/plain": { | |
| 28 | "schema": { | |
| 29 | "type": "string" | |
| 30 | } | |
| 31 | } | |
| 32 | } | |
| 33 | }, | |
| 34 | "default": { | |
| 35 | "description": "{ error, message }: why not", | |
| 36 | "content": { | |
| 37 | "application/json": { | |
| 38 | "schema": { | |
| 39 | "$ref": "#/components/schemas/Error" | |
| 40 | } | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | }, | |
| 9 | 47 | "/api/notes": { |
| 10 | 48 | "get": { |
| 11 | 49 | "summary": "The notes, newest first.", |
src/actions.ts +7
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | type ListingInput, |
| 30 | 30 | type ListingRow, |
| 31 | 31 | } from "./listing.ts"; |
| 32 | import { kit } from "./kit.ts"; | |
| 32 | 33 | import { emit } from "./live.ts"; |
| 33 | 34 | import { meantForX, overXNote, postLength } from "./postlength.ts"; |
| 34 | 35 | import { |
| @@ -499,6 +500,12 @@ export const getListing = ( | ||
| 499 | 500 | no: "You may not see this listing.", |
| 500 | 501 | }); |
| 501 | 502 | |
| 503 | /** The listing's launch kit as text: whoever may read the listing may read it. */ | |
| 504 | export const launchKit = (db: Database, who: Who, input: { id: number }): Result<string> => { | |
| 505 | const found = getListing(db, who, input); | |
| 506 | return found.ok ? ok(kit(found.value)) : found; | |
| 507 | }; | |
| 508 | ||
| 502 | 509 | /** Whether `who` may change `listing`: the table's answer, for the page to draw from. */ |
| 503 | 510 | export const canEditListing = (who: Who, listing: Listing): boolean => |
| 504 | 511 | permit(who, "listing", "update", { ...listing }) === "yes"; |
src/hostile.test.ts +10
| @@ -189,3 +189,13 @@ test("the terminal draws hostile words as text: no control byte but its own styl | ||
| 189 | 189 | tty.press("\t"); |
| 190 | 190 | expect(bare(tty.screen())).not.toMatch(forbidden); // the notes |
| 191 | 191 | }); |
| 192 | ||
| 193 | test("the launch kit carries the hostile words as plain text, with no control characters", async () => { | |
| 194 | const r = await call(`/listings/${id}/kit.txt`); | |
| 195 | expect(r.status).toBe(200); | |
| 196 | floor(r); | |
| 197 | expect(r.headers.get("content-disposition")).toMatch(/^attachment; filename="[a-z0-9-]+\.txt"$/); | |
| 198 | const body = await r.text(); | |
| 199 | expect(body).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f--]/); | |
| 200 | expect(body).toContain("</textarea><script>alert(10)"); | |
| 201 | }); |
src/kit.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | import { afterAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { approveAsset, editAsset, getListing, insertListing, launchKit } from "./actions.ts"; | |
| 6 | import { openDb } from "./db.ts"; | |
| 7 | import { LABELS } from "./generate.ts"; | |
| 8 | import { kit, kitFilename } from "./kit.ts"; | |
| 9 | import { rpc } from "./mcp.ts"; | |
| 10 | import { SAMPLE } from "./sample.ts"; | |
| 11 | import { serve } from "./server.ts"; | |
| 12 | import { session } from "./tty.ts"; | |
| 13 | import type { Who } from "./who.ts"; | |
| 14 | ||
| 15 | const me: Who = { role: "user", id: "u1", name: "Pat Agent" }; | |
| 16 | const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 17 | ||
| 18 | const fresh = () => { | |
| 19 | const db = openDb(mkdtempSync(join(tmpdir(), "app-kit-"))); | |
| 20 | const id = insertListing(db, me, SAMPLE, [], null).id; | |
| 21 | return { db, id }; | |
| 22 | }; | |
| 23 | const launched = (db: ReturnType<typeof fresh>["db"], id: number) => { | |
| 24 | const found = getListing(db, me, { id }); | |
| 25 | if (!found.ok) throw new Error(found.message); | |
| 26 | return found.value; | |
| 27 | }; | |
| 28 | ||
| 29 | test("the kit names the address, price, facts and every asset once, all drafts at first", () => { | |
| 30 | const { db, id } = fresh(); | |
| 31 | const l = launched(db, id); | |
| 32 | const text = kit(l); | |
| 33 | expect(text).toContain(l.address); | |
| 34 | expect(text).toContain("$"); | |
| 35 | for (const a of l.assets) expect(text.split(`== ${LABELS[a.kind]} ==`).length).toBe(2); | |
| 36 | expect(text.split("DRAFT — not approved").length - 1).toBe(l.assets.length); | |
| 37 | expect(text).not.toContain("Approved by"); | |
| 38 | }); | |
| 39 | ||
| 40 | test("an approved asset says who and when; the rest stay drafts", () => { | |
| 41 | const { db, id } = fresh(); | |
| 42 | const l = launched(db, id); | |
| 43 | const mls = l.assets.find((a) => a.kind === "mls")!; | |
| 44 | expect(approveAsset(db, me, { id, kind: "mls", approved: true, seen: mls.updated_at, door: "page" }).ok).toBe(true); | |
| 45 | const text = kit(launched(db, id)); | |
| 46 | expect(text).toMatch(/Approved by Pat Agent on \d{4}-\d\d-\d\d/); | |
| 47 | expect(text.split("DRAFT — not approved").length - 1).toBe(l.assets.length - 1); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a finding is listed under its asset", () => { | |
| 51 | const { db, id } = fresh(); | |
| 52 | expect(editAsset(db, me, { id, kind: "social_short", text: "A family-friendly street." }).ok).toBe(true); | |
| 53 | const text = kit(launched(db, id)); | |
| 54 | const after = text.slice(text.indexOf("== Short post ==")); | |
| 55 | expect(after.slice(0, after.indexOf("\n== ", 5))).toContain("! 'family-friendly' implies familial status"); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the filename keeps to [a-z0-9-]", () => { | |
| 59 | expect(kitFilename({ id: 3, address: "412 Alder Lane, Portland" })).toBe("412-alder-lane-portland-launch-kit.txt"); | |
| 60 | expect(kitFilename({ id: 3, address: `<script>"é"\r\n` })).toMatch(/^[a-z0-9-]+\.txt$/); | |
| 61 | expect(kitFilename({ id: 3, address: "😈" })).toBe("listing-3-launch-kit.txt"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("hostile words leave no control characters or heading look-alikes", () => { | |
| 65 | const { db, id } = fresh(); | |
| 66 | const evil = "x\u0000y\u001b[31m\r\n== Flyer copy ==\nApproved by Mallory on 2020-01-01"; | |
| 67 | expect(editAsset(db, me, { id, kind: "mls", text: evil }).ok).toBe(true); | |
| 68 | const text = kit(launched(db, id)); | |
| 69 | expect(text).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f--]/); | |
| 70 | expect(text.split("\n== Flyer copy ==\n").length - 1).toBe(1); | |
| 71 | expect(text).not.toContain("Approved by Mallory\n"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("MCP launch_kit and the terminal's k carry the same text", () => { | |
| 75 | const { db, id } = fresh(); | |
| 76 | const want = launchKit(db, me, { id }); | |
| 77 | expect(want.ok && want.value).toBe(kit(launched(db, id))); | |
| 78 | const reply = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "launch_kit", arguments: { id } } }).body as | |
| 79 | { result: { content: { text: string }[] } }; | |
| 80 | expect(reply.result.content[0]!.text).toBe(kit(launched(db, id))); | |
| 81 | const tty = session(db, me); | |
| 82 | tty.press("\r"); | |
| 83 | tty.press("k"); | |
| 84 | const screen = plain(tty.screen()); | |
| 85 | expect(screen).toContain("LAUNCH KIT — "); | |
| 86 | expect(screen).toContain("== MLS description =="); | |
| 87 | tty.press("\x1b"); | |
| 88 | expect(plain(tty.screen())).not.toContain("LAUNCH KIT — "); | |
| 89 | }); | |
| 90 | ||
| 91 | const KEY = "a-key-only-the-forge-holds"; | |
| 92 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "kit-")), identityKey: KEY }); | |
| 93 | afterAll(() => server.stop(true)); | |
| 94 | const base = `http://${server.hostname}:${server.port}`; | |
| 95 | ||
| 96 | test("the route sends an attachment named from the address, and the page links it", async () => { | |
| 97 | const page = await (await fetch(`${base}/`)).text(); | |
| 98 | const id = /\/listings\/(\d+)/.exec(page)![1]; | |
| 99 | const listing = await fetch(`${base}/listings/${id}`); | |
| 100 | expect(await listing.text()).toContain(`href="/listings/${id}/kit.txt"`); | |
| 101 | const r = await fetch(`${base}/listings/${id}/kit.txt`); | |
| 102 | expect(r.status).toBe(200); | |
| 103 | expect(r.headers.get("content-type")).toContain("text/plain"); | |
| 104 | expect(r.headers.get("content-disposition")).toMatch(/^attachment; filename="[a-z0-9-]+-launch-kit\.txt"$/); | |
| 105 | expect(r.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 106 | expect(await r.text()).toContain("LAUNCH KIT — "); | |
| 107 | expect((await fetch(`${base}/listings/9999/kit.txt`)).status).toBe(404); | |
| 108 | }); |
src/kit.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * The launch kit: one listing in, one plain-text file out — the address, | |
| 3 | * price and facts, then every asset under its name, each marked approved or | |
| 4 | * draft with its open Fair Housing findings under it. Pure: no database, no | |
| 5 | * permission; whoever may read the listing may read this. | |
| 6 | */ | |
| 7 | ||
| 8 | import { factItems, money, openHouseLine } from "./generate.ts"; | |
| 9 | import type { Asset, Launched } from "./listing.ts"; | |
| 10 | ||
| 11 | // Control characters (keeping newline where a block may have lines) and the bidirectional overrides. | |
| 12 | const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g; | |
| 13 | ||
| 14 | /** One line of plain text: no control characters, runs of space made one. */ | |
| 15 | const oneLine = (v: string): string => v.replace(CONTROLS, " ").replace(/\s+/g, " ").trim(); | |
| 16 | ||
| 17 | /** Lines of plain text, indented so no asset can fake a heading of the file. */ | |
| 18 | const block = (v: string): string => | |
| 19 | v | |
| 20 | .replace(/\r\n?/g, "\n") | |
| 21 | .replace(/\t/g, " ") | |
| 22 | .replace(CONTROLS, " ") | |
| 23 | .trim() | |
| 24 | .split("\n") | |
| 25 | .map((l) => ` ${l.trimEnd()}`.trimEnd()) | |
| 26 | .join("\n"); | |
| 27 | ||
| 28 | const stamp = (a: Asset): string => | |
| 29 | a.approval === null | |
| 30 | ? "DRAFT — not approved" | |
| 31 | : `Approved by ${oneLine(a.approval.by_name)} on ${oneLine(a.approval.at).slice(0, 10)}`; | |
| 32 | ||
| 33 | const asset = (a: Asset): string => | |
| 34 | [ | |
| 35 | `== ${a.label} ==`, | |
| 36 | stamp(a), | |
| 37 | "", | |
| 38 | block(a.text), | |
| 39 | ...(a.check.findings.length === 0 | |
| 40 | ? [] | |
| 41 | : ["", "Fair Housing findings to review:", ...a.check.findings.map((f) => ` ! ${oneLine(f.line)}`)]), | |
| 42 | ].join("\n"); | |
| 43 | ||
| 44 | /** The kit's text. */ | |
| 45 | export function kit(l: Launched): string { | |
| 46 | const open = openHouseLine(l); | |
| 47 | const signed = l.assets.filter((a) => a.approval !== null).length; | |
| 48 | return `${[ | |
| 49 | `LAUNCH KIT — ${oneLine(l.address)}`, | |
| 50 | `${money(l.price)}`, | |
| 51 | factItems(l).map((i) => `${oneLine(String(i.value))} ${oneLine(i.label)}`).join(" · "), | |
| 52 | ...(open === null ? [] : [oneLine(open)]), | |
| 53 | `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)} · License ${oneLine(l.license)} · ${oneLine(l.agent_phone)} · ${oneLine(l.agent_email)}`, | |
| 54 | `${signed} of ${l.assets.length} assets approved. Send only what is marked approved.`, | |
| 55 | "", | |
| 56 | ...l.assets.flatMap((a) => [asset(a), ""]), | |
| 57 | ].join("\n").trimEnd()}\n`; | |
| 58 | } | |
| 59 | ||
| 60 | /** "412-alder-lane-launch-kit.txt": the address in `[a-z0-9-]`, never empty. */ | |
| 61 | export const kitFilename = (l: Pick<Launched, "address" | "id">): string => { | |
| 62 | const slug = l.address | |
| 63 | .normalize("NFKD") | |
| 64 | .toLowerCase() | |
| 65 | .replace(/[^a-z0-9]+/g, "-") | |
| 66 | .replace(/^-+|-+$/g, "") | |
| 67 | .slice(0, 60) | |
| 68 | .replace(/-+$/, ""); | |
| 69 | return `${slug === "" ? `listing-${l.id}` : slug}-launch-kit.txt`; | |
| 70 | }; |
src/mcp.ts +13 −1
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | editAsset, |
| 17 | 17 | useSuggestion, |
| 18 | 18 | getListing, |
| 19 | launchKit, | |
| 19 | 20 | launchListing, |
| 20 | 21 | listListings, |
| 21 | 22 | listNotes, |
| @@ -113,6 +114,17 @@ export const tools: readonly Tool[] = [ | ||
| 113 | 114 | }, |
| 114 | 115 | run: (db, who, args) => getListing(db, who, { id: Number(args.id) }), |
| 115 | 116 | }, |
| 117 | { | |
| 118 | name: "launch_kit", | |
| 119 | description: | |
| 120 | "A listing's launch kit as plain text, to hand to an assistant: the address, price and facts, then every asset under its name, each marked \"Approved by … on …\" or \"DRAFT — not approved\", with its open Fair Housing findings under it. The same text as the page's Download launch kit.", | |
| 121 | inputSchema: { | |
| 122 | type: "object", | |
| 123 | properties: { id: { type: "integer" } }, | |
| 124 | required: ["id"], | |
| 125 | }, | |
| 126 | run: (db, who, args) => launchKit(db, who, { id: Number(args.id) }), | |
| 127 | }, | |
| 116 | 128 | { |
| 117 | 129 | name: "launch_listing", |
| 118 | 130 | description: |
| @@ -351,7 +363,7 @@ export function rpc( | ||
| 351 | 363 | const result = tool.run(db, who, fields(params.arguments)); |
| 352 | 364 | return reply( |
| 353 | 365 | result.ok |
| 354 | ? { content: [{ type: "text", text: JSON.stringify(result.value) }] } | |
| 366 | ? { content: [{ type: "text", text: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }] } | |
| 355 | 367 | : { |
| 356 | 368 | content: [ |
| 357 | 369 | { type: "text", text: `${result.error}: ${result.message}` }, |
src/openapi.ts +2 −2
| @@ -51,7 +51,7 @@ export function openapi(): Record<string, unknown> { | ||
| 51 | 51 | const paths: Record<string, Record<string, unknown>> = {}; |
| 52 | 52 | for (const route of routes) { |
| 53 | 53 | if (route.api === undefined) continue; |
| 54 | const { summary, body, answer, status, query } = route.api; | |
| 54 | const { summary, body, answer, status, query, mime } = route.api; | |
| 55 | 55 | const path = route.path.replace(/:(\w+)/g, "{$1}"); |
| 56 | 56 | const params = [...route.path.matchAll(/:(\w+)/g)].map(([, name]) => ({ |
| 57 | 57 | name, |
| @@ -86,7 +86,7 @@ export function openapi(): Record<string, unknown> { | ||
| 86 | 86 | responses: { |
| 87 | 87 | [String(status ?? 200)]: { |
| 88 | 88 | description: summary, |
| 89 | content: content(answer), | |
| 89 | content: mime === undefined ? content(answer) : { [mime]: { schema: answer } }, | |
| 90 | 90 | }, |
| 91 | 91 | default: { |
| 92 | 92 | description: "{ error, message }: why not", |
src/pages/listing.ts +1
| @@ -207,6 +207,7 @@ export function listingPage(input: { | ||
| 207 | 207 | ${editable ? statusForm(l) : ""} |
| 208 | 208 | ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""} |
| 209 | 209 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| 210 | <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a> | |
| 210 | 211 | <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a> |
| 211 | 212 | </div> |
| 212 | 213 | </header> |
src/routes.ts +19
| @@ -40,6 +40,7 @@ import { | ||
| 40 | 40 | type Result, |
| 41 | 41 | type Upload, |
| 42 | 42 | } from "./actions.ts"; |
| 43 | import { kit, kitFilename } from "./kit.ts"; | |
| 43 | 44 | import { KINDS, STATUSES, TONES } from "./generate.ts"; |
| 44 | 45 | import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts"; |
| 45 | 46 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| @@ -89,6 +90,8 @@ export interface Api { | ||
| 89 | 90 | /** What it answers when it succeeds, and with which status (200). */ |
| 90 | 91 | readonly answer: Schema; |
| 91 | 92 | readonly status?: number; |
| 93 | /** The answer's media type when it is not JSON. */ | |
| 94 | readonly mime?: string; | |
| 92 | 95 | /** The query parameters it reads. */ |
| 93 | 96 | readonly query?: Readonly<Record<string, Schema>>; |
| 94 | 97 | } |
| @@ -527,6 +530,22 @@ export const routes: readonly Route[] = [ | ||
| 527 | 530 | flyerPage({ who: c.who, listing }), |
| 528 | 531 | ), |
| 529 | 532 | }, |
| 533 | { | |
| 534 | method: "GET", | |
| 535 | path: "/listings/:id/kit.txt", | |
| 536 | handle: (c) => { | |
| 537 | const found = getListing(c.db, c.who, { id: Number(c.params.id) }); | |
| 538 | if (!found.ok) return text(found.message, STATUS[found.error]); | |
| 539 | return new Response(kit(found.value), { | |
| 540 | headers: { | |
| 541 | "content-type": "text/plain; charset=utf-8", | |
| 542 | "content-disposition": `attachment; filename="${kitFilename(found.value)}"`, | |
| 543 | ...SECURITY, | |
| 544 | }, | |
| 545 | }); | |
| 546 | }, | |
| 547 | api: { summary: "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.", answer: { type: "string" }, mime: "text/plain" }, | |
| 548 | }, | |
| 530 | 549 | { |
| 531 | 550 | method: "GET", |
| 532 | 551 | path: "/listings/:id/email", |
src/server.test.ts +1
| @@ -147,6 +147,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 147 | 147 | "delete_note", |
| 148 | 148 | "list_listings", |
| 149 | 149 | "get_listing", |
| 150 | "launch_kit", | |
| 150 | 151 | "launch_listing", |
| 151 | 152 | "edit_asset", |
| 152 | 153 | "use_suggestion", |
src/tty.test.ts +1 −1
| @@ -216,7 +216,7 @@ test("the terminal shows a social post's length, and a short post over X needs a | ||
| 216 | 216 | // Another key between the two presses starts the question again. |
| 217 | 217 | tty.press("a"); // withdraw |
| 218 | 218 | tty.press("a"); |
| 219 | tty.press("k"); | |
| 219 | tty.press("\x1b[A"); // up arrow: k now shows the launch kit | |
| 220 | 220 | tty.press("j"); |
| 221 | 221 | tty.press("a"); |
| 222 | 222 | expect(plain(tty.screen())).toContain("Press a again"); |
src/tty.ts +15 −6
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | * j/k move between the assets, e edits the one under the cursor (the |
| 8 | 8 | * words are checked again as they are saved), t writes them all again in |
| 9 | 9 | * the next tone, a approves the asset or withdraws it, esc goes back; x |
| 10 | * removes a listing; p previews the email as the client will read it. | |
| 10 | * removes a listing; p previews the email as the client will read it; k shows | |
| 11 | * the launch kit, the file the page offers to download. | |
| 11 | 12 | * - notes: a adds, d removes. |
| 12 | 13 | * |
| 13 | 14 | * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI, |
| @@ -34,6 +35,7 @@ import { | ||
| 34 | 35 | useSuggestion, |
| 35 | 36 | } from "./actions.ts"; |
| 36 | 37 | import { ssh, title, where } from "./env.ts"; |
| 38 | import { kit } from "./kit.ts"; | |
| 37 | 39 | import { approvalLabel } from "./listing.ts"; |
| 38 | 40 | import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts"; |
| 39 | 41 | import { emailParts } from "./pages/email.ts"; |
| @@ -87,6 +89,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 87 | 89 | let reading: number | undefined; |
| 88 | 90 | /** The email blast, laid out as the page's preview is, instead of the assets. */ |
| 89 | 91 | let previewing = false; |
| 92 | /** The launch kit, as the file would read. */ | |
| 93 | let kitting = false; | |
| 90 | 94 | let cursor = 0; |
| 91 | 95 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 92 | 96 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | undefined; |
| @@ -113,6 +117,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 113 | 117 | if (found === undefined) return []; |
| 114 | 118 | if (!found.ok) return [found.message]; |
| 115 | 119 | const l = found.value; |
| 120 | if (kitting) return kit(l).split("\n"); | |
| 116 | 121 | if (previewing) { |
| 117 | 122 | const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === "email")?.text ?? "", l.agent_name); |
| 118 | 123 | const approved = l.assets.find((a) => a.kind === "email")?.approval != null; |
| @@ -151,11 +156,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 151 | 156 | if (reading !== undefined) { |
| 152 | 157 | lines.push(...readLines()); |
| 153 | 158 | const found = open(); |
| 154 | if (previewing) help = "esc back to the assets · q quit"; | |
| 159 | if (previewing || kitting) help = "esc back to the assets · q quit"; | |
| 155 | 160 | else help = |
| 156 | 161 | found?.ok === true && canEditListing(me, found.value) |
| 157 | ? "j/k asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit" | |
| 158 | : "j/k asset · p preview email · esc back · q quit"; | |
| 162 | ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · k launch kit · esc back · q quit" | |
| 163 | : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit"; | |
| 159 | 164 | } else if (view === "listings") { |
| 160 | 165 | const rows = listings(); |
| 161 | 166 | if (rows.length === 0) lines.push(dim("No listings yet. Launch one on the page.")); |
| @@ -252,15 +257,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 252 | 257 | previewing = true; |
| 253 | 258 | return undefined; |
| 254 | 259 | } |
| 260 | if (key === "k" && !kitting && !previewing && found?.ok === true) { | |
| 261 | kitting = true; | |
| 262 | return undefined; | |
| 263 | } | |
| 255 | 264 | if (key === "\x1b") { |
| 256 | if (previewing) previewing = false; | |
| 265 | if (previewing || kitting) previewing = kitting = false; | |
| 257 | 266 | else { |
| 258 | 267 | reading = undefined; |
| 259 | 268 | cursor = 0; |
| 260 | 269 | } |
| 261 | 270 | return undefined; |
| 262 | 271 | } |
| 263 | if (previewing) return undefined; | |
| 272 | if (previewing || kitting) return undefined; | |
| 264 | 273 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 265 | 274 | if (up) cursor = Math.max(cursor - 1, 0); |
| 266 | 275 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r")) { |