Public: anyone can read this; the crew can change it.

A proposer fixes a typo: the title and the paragraph, edited in the open and stamped #60

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/quorum:ledger/53-a-proposer-fixes-a-typo-the-title-and-th → main 1 commit fork

  • Merged. ledger/53-a-proposer-fixes-a-typo-the-title-and-th was merged into main as 2adbe7d.

Review

Sign in to review this change.
14 files changed +881 −37
README.md +24 −7
@@ -24,7 +24,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2424
2525 | file | what it is |
2626 | ---------------------------------- | ------------------------------------------------------------ |
27 | `src/actions.ts` | propose, list, read, withdraw, remark, health — and who may |
27 | `src/actions.ts` | propose, edit, list, read, withdraw, remark, health — who may |
2828 | `src/quorum.ts` | the three rules as plain data: read one, say it, count it |
2929 | `src/plain.ts` | somebody else's text, safe to print on a terminal |
3030 | `src/who.ts` | who is asking, from the forge's signed headers |
@@ -33,7 +33,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
3333 | `src/backup.ts` | a copy of the record, and putting one back |
3434 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
3535 | `src/routes.ts` | every path, in one table |
36 | `src/pages/` | the HTML, rendered on the server: one layout, three pages |
36 | `src/pages/` | the HTML, rendered on the server: one layout, four pages |
3737 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
3838 | `src/server.ts` | boot: open the database, serve |
3939 | `src/tty.ts` | the terminal, over the same actions |
@@ -47,12 +47,13 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
4747
4848 - **anon** — anyone. Reads.
4949 - **user** — signed in to the forge and able to see this app. Makes
50 proposals, as themselves, withdraws their own, says things on anybody's
51 and deletes what they said; on a proposal they opened, they can accept a
52 counter.
50 proposals, as themselves, corrects and withdraws their own, says things on
51 anybody's and deletes what they said; on a proposal they opened, they can
52 accept a counter.
5353 - **admin** — able to write this repository. Withdraws any proposal, deletes
5454 any remark, accepts a counter on any proposal, and takes a copy of the
55 whole record.
55 whole record. Not editing anybody's text: an admin takes things away, and
56 does not put words under somebody else's name.
5657
5758 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5859 `/__forge/signout`; the forge answers both on the app's own host.
@@ -71,6 +72,19 @@ names and count a `named:` rule needs; `/p/<id>` says the rule in words above
7172 the options. A guest sees both pages and no forms. With nothing proposed yet,
7273 `/` says in four sentences what a proposal is rather than that it is empty.
7374
75 Its proposer can correct the words: `/p/<id>/edit` is a form holding the title
76 and the paragraph as they stand now, open to the person who wrote it and only
77 while the proposal is open — an admin withdraws anybody's proposal and edits
78 nobody's. **What may be edited is the title and the paragraph, and nothing
79 else**: the options are what the group is choosing between, so changing them
80 would change what a ballot already cast was cast for, and a counter is how an
81 option is added. **A correction is shown rather than hidden**: `/p/<id>` says
82 "edited" beside the proposer's name with the time it happened, the thread
83 underneath is untouched, and saving the same words again changes nothing and
84 claims nothing. (That mark comes from a column of its own, `edited_at`, and
85 not from comparing `updated_at` with `created_at` — withdrawing a proposal and
86 accepting a counter stamp `updated_at` too, and neither is an edit.)
87
7488 Under the proposal is the argument about it: a composer for whoever is signed
7589 in, and then the thread, oldest first, each remark labelled with its kind. The
7690 thread is the last thing on the page and grows at the bottom, so a remark
@@ -93,6 +107,8 @@ curl 'https://<app>/api/proposals?state=open'
93107 curl https://<app>/api/proposals/1 # one, with its options in order
94108 curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \
95109 -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"],"rule":"named:alice,bob,carol:2"}'
110 curl -X PATCH https://<app>/api/proposals/1 -H 'content-type: application/json' \
111 -d '{"title":"Adopt the four-day week","body":"A trial for two quarters."}'
96112 curl -X POST https://<app>/api/proposals/1/withdraw
97113 curl https://<app>/api/proposals/1/remarks # the thread, oldest first
98114 curl -X POST https://<app>/api/proposals/1/remarks -H 'content-type: application/json' \
@@ -258,7 +274,8 @@ moves, Enter opens the proposal under the cursor — with the thread on it
258274 underneath — `n` proposes (a title, then the paragraph, then options one per
259275 line until a blank line, then the rule — Enter alone for `majority`), `c`
260276 says something (`1`, `2` or `3` for the kind, then the text, then the option
261 a counter wants), `w` withdraws, Esc goes back and `q` quits. A guest is told
277 a counter wants), `e` corrects the title and the paragraph of one you opened
278 (each arriving already typed), `w` withdraws, Esc goes back and `q` quits. A guest is told
262279 to sign in rather than shown a composer. Nothing anyone else wrote reaches
263280 that screen as a control character: it goes through `src/plain.ts` on the way
264281 out, so the only escape sequences a terminal here sees are the app's own.
src/actions.test.ts +175
@@ -7,19 +7,24 @@ import {
77 addMember,
88 addRemark,
99 canDeleteRemark,
10 canEdit,
1011 canWithdraw,
1112 deleteRemark,
13 editProposal,
1214 getProposal,
1315 health,
1416 isMember,
1517 listMembers,
1618 listProposals,
1719 listRemarks,
20 BODY_MAX,
1821 MEMBER_NAME_MAX,
22 OPTIONS_LOCKED,
1923 PERSON_ID_MAX,
2024 propose,
2125 removeMember,
2226 ROSTER_MAX,
27 TITLE_MAX,
2328 withdrawProposal,
2429 type AdminHealth,
2530 } from "./actions.ts";
@@ -46,6 +51,11 @@ const made = (who: Who = alice, input: Record<string, unknown> = {}) => {
4651 if (!r.ok) throw new Error(`${r.error}: ${r.message}`);
4752 return r.value;
4853 };
54 /** The title on the record, for a refusal that must have changed nothing. */
55 const title = (id: number): string | undefined => {
56 const found = getProposal(db, anon, { id });
57 return found.ok ? found.value.title : undefined;
58 };
4959
5060 test("anyone reads; only a signed-in person proposes, as themselves", () => {
5161 expect(listProposals(db, anon)).toEqual({ ok: true, value: [] });
@@ -254,6 +264,171 @@ test("only the author or an admin withdraws, and only once", () => {
254264 if (!nowhere.ok) expect(nowhere.error).toBe("not_found");
255265 });
256266
267 test("the proposer corrects the text; nobody else does, not even an admin", () => {
268 const mine = made(alice, { title: "Where do we met" });
269 expect(canEdit(alice, mine)).toBe(true);
270 expect(canEdit(bob, mine)).toBe(false);
271 // An admin withdraws anybody's proposal and edits nobody's: editing puts
272 // words under someone else's name.
273 expect(canEdit(root, mine)).toBe(false);
274 expect(canEdit(anon, mine)).toBe(false);
275 expect(mine.edited_at).toBeNull();
276
277 const byBob = editProposal(db, bob, {
278 id: mine.id,
279 title: "Where do we meet",
280 body: mine.body,
281 });
282 expect(byBob.ok).toBe(false);
283 if (!byBob.ok) {
284 expect(byBob.error).toBe("forbidden");
285 expect(byBob.message).toBe("Only the proposer edits a proposal.");
286 }
287 const byRoot = editProposal(db, root, {
288 id: mine.id,
289 title: "Where do we meet",
290 body: mine.body,
291 });
292 expect(byRoot.ok).toBe(false);
293 if (!byRoot.ok) expect(byRoot.error).toBe("forbidden");
294 const byAnon = editProposal(db, anon, {
295 id: mine.id,
296 title: "Where do we meet",
297 body: mine.body,
298 });
299 expect(byAnon.ok).toBe(false);
300 if (!byAnon.ok) expect(byAnon.error).toBe("signin_required");
301 // Refused is refused: the text on the record is the text nobody changed.
302 expect(getProposal(db, anon, { id: mine.id }).ok && title(mine.id)).toBe(
303 "Where do we met",
304 );
305
306 const heard: LiveEvent[] = [];
307 const stop = subscribe((event) => heard.push(event));
308 const fixed = editProposal(db, alice, {
309 id: mine.id,
310 title: " Where do we meet ",
311 body: " We need one warm room for the retrospective. ",
312 });
313 expect(fixed.ok).toBe(true);
314 if (!fixed.ok) return stop();
315 expect(fixed.value.title).toBe("Where do we meet");
316 expect(fixed.value.body).toBe("We need one warm room for the retrospective.");
317 expect(fixed.value.edited_at).not.toBeNull();
318 expect(fixed.value.updated_at >= fixed.value.created_at).toBe(true);
319 expect(heard.map((e) => e.type)).toEqual(["proposal.edited"]);
320
321 // Saving the same words again is not an edit: no second event, and the
322 // stamp does not move.
323 const same = editProposal(db, alice, {
324 id: mine.id,
325 title: "Where do we meet",
326 body: "We need one warm room for the retrospective.",
327 });
328 expect(same.ok).toBe(true);
329 if (same.ok) expect(same.value.edited_at).toBe(fixed.value.edited_at);
330 expect(heard.map((e) => e.type)).toEqual(["proposal.edited"]);
331 stop();
332
333 // The options are untouched, and the thread underneath is still there.
334 const after = getProposal(db, anon, { id: mine.id });
335 expect(after.ok && after.value.options.map((o) => o.label)).toEqual([
336 "The kitchen",
337 "The garden",
338 ]);
339 });
340
341 test("an edit that changes nothing on a proposal nobody edited stamps nothing", () => {
342 const mine = made(alice);
343 const same = editProposal(db, alice, {
344 id: mine.id,
345 title: mine.title,
346 body: mine.body,
347 });
348 expect(same.ok).toBe(true);
349 // The mark on the page means somebody really changed the words.
350 if (same.ok) expect(same.value.edited_at).toBeNull();
351 });
352
353 test("a correction is judged by the sentences propose uses, and never the options", () => {
354 const mine = made(alice);
355 const long = editProposal(db, alice, {
356 id: mine.id,
357 title: "x".repeat(TITLE_MAX + 1),
358 body: mine.body,
359 });
360 const longAtBirth = propose(db, alice, {
361 ...good,
362 title: "x".repeat(TITLE_MAX + 1),
363 });
364 expect(long.ok).toBe(false);
365 expect(longAtBirth.ok).toBe(false);
366 if (!long.ok && !longAtBirth.ok) {
367 expect(long.error).toBe("invalid");
368 expect(long.message).toBe(longAtBirth.message);
369 }
370 for (const body of ["", " ", "x".repeat(BODY_MAX + 1)]) {
371 const bad = editProposal(db, alice, { id: mine.id, title: mine.title, body });
372 expect(bad.ok).toBe(false);
373 if (!bad.ok) expect(bad.error).toBe("invalid");
374 }
375
376 // Options at this door are refused out loud, not dropped in silence.
377 const withOptions = editProposal(db, alice, {
378 id: mine.id,
379 title: "A better title",
380 body: mine.body,
381 options: ["Yes", "No", "Later"],
382 });
383 expect(withOptions.ok).toBe(false);
384 if (!withOptions.ok) {
385 expect(withOptions.error).toBe("invalid");
386 expect(withOptions.message).toBe(OPTIONS_LOCKED);
387 }
388 expect(title(mine.id)).toBe(mine.title);
389
390 const odd = editProposal(db, alice, { id: 1.5, title: "x", body: "y" });
391 expect(odd.ok && "impossible").toBe(false);
392 if (!odd.ok) expect(odd.error).toBe("invalid");
393 const nowhere = editProposal(db, alice, { id: 9999, title: "x", body: "y" });
394 expect(nowhere.ok).toBe(false);
395 if (!nowhere.ok) expect(nowhere.error).toBe("not_found");
396 });
397
398 test("a proposal that is no longer open keeps its text: that text is the record", () => {
399 const mine = made(alice);
400 expect(withdrawProposal(db, alice, { id: mine.id }).ok).toBe(true);
401 const late = editProposal(db, alice, {
402 id: mine.id,
403 title: "Second thoughts",
404 body: mine.body,
405 });
406 expect(late.ok).toBe(false);
407 if (!late.ok) {
408 expect(late.error).toBe("invalid");
409 expect(late.message).toContain("withdrawn");
410 }
411 expect(title(mine.id)).toBe(mine.title);
412
413 // And a decided one. Nothing votes yet, so the state is set by hand — the
414 // rule is the proposal's state and not how it got there.
415 const chosen = made(alice);
416 db.query("UPDATE proposal SET state = 'decided' WHERE id = ?1").run(
417 chosen.id,
418 );
419 const after = editProposal(db, alice, {
420 id: chosen.id,
421 title: "A tidier question",
422 body: chosen.body,
423 });
424 expect(after.ok).toBe(false);
425 if (!after.ok) {
426 expect(after.error).toBe("invalid");
427 expect(after.message).toContain("decided");
428 }
429 expect(title(chosen.id)).toBe(chosen.title);
430 });
431
257432 test("a proposal refused writes nothing at all", () => {
258433 const before = listProposals(db, anon);
259434 const refused = propose(db, alice, {
src/actions.ts +109 −2
@@ -36,6 +36,8 @@ interface ProposalRow {
3636 readonly author_name: string;
3737 readonly created_at: string;
3838 readonly updated_at: string;
39 /** When the proposer last corrected the text, or null: nobody has. */
40 readonly edited_at: string | null;
3941 }
4042
4143 /**
@@ -122,6 +124,21 @@ export const OPTIONS_MAX = 8;
122124 export const STATES: readonly State[] = ["open", "decided", "withdrawn"];
123125 /** What a rule that is not a rule is told. The forms live in `quorum.ts`. */
124126 const RULE_MESSAGE = `A rule is one of ${RULE_FORMS}.`;
127 /**
128 * What text that will not do is told — written once, so a correction is
129 * judged by exactly the sentence the first draft was.
130 */
131 const TITLE_MESSAGE = `A title is 1 to ${TITLE_MAX} characters.`;
132 const BODY_MESSAGE = `A paragraph is 1 to ${BODY_MAX} characters.`;
133 /**
134 * Why an edit never touches the options. What the group is choosing between
135 * cannot move under the people choosing: a ballot already cast names an
136 * option, and a proposal that swapped one would have votes for something
137 * nobody read. An option is added by accepting a counter, in the open, and
138 * removed by withdrawing the proposal and opening a better one.
139 */
140 export const OPTIONS_LOCKED =
141 "The options cannot be edited: a ballot already cast names one, so changing them would change what was voted on. A counter adds an option while the proposal is open.";
125142
126143 const SUMMARY =
127144 "SELECT p.*, (SELECT count(*) FROM option WHERE proposal_id = p.id) AS option_count FROM proposal p";
@@ -189,10 +206,10 @@ export function propose(
189206 return fail("signin_required", "Sign in to make a proposal.");
190207 const title = String(input.title ?? "").trim();
191208 if (title.length === 0 || title.length > TITLE_MAX)
192 return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`);
209 return fail("invalid", TITLE_MESSAGE);
193210 const body = String(input.body ?? "").trim();
194211 if (body.length === 0 || body.length > BODY_MAX)
195 return fail("invalid", `A paragraph is 1 to ${BODY_MAX} characters.`);
212 return fail("invalid", BODY_MESSAGE);
196213 // The rule is the proposer's, once: the text they gave is what is stored,
197214 // and `parseRule` is what says whether it is a rule at all.
198215 const rule = String(input.rule ?? "").trim() || "majority";
@@ -242,6 +259,61 @@ export function propose(
242259 return ok(created);
243260 }
244261
262 /**
263 * Correct the text: the title and the paragraph, and nothing else. The
264 * proposer only — an admin may withdraw anybody's proposal but does not put
265 * words in anybody's mouth — and only while it is open, because a decided or
266 * a withdrawn proposal's text is what the group decided on. The options are
267 * not here and never will be (`OPTIONS_LOCKED`).
268 *
269 * An edit is stamped, not hidden: `edited_at` is set, `/p/:id` says "edited"
270 * from it, and the thread underneath is untouched. A correction that changes
271 * nothing succeeds and stamps nothing, so the mark on the page only ever
272 * means somebody really did change the words.
273 */
274 export function editProposal(
275 db: Database,
276 who: Who,
277 input: { id: number; title: string; body: string; options?: unknown },
278 ): Result<Proposal> {
279 if (who.role === "anon")
280 return fail("signin_required", "Sign in to edit a proposal.");
281 if (!Number.isInteger(input.id))
282 return fail("invalid", "A proposal id is a whole number.");
283 const proposal = db
284 .query<ProposalRow, [number]>("SELECT * FROM proposal WHERE id = ?1")
285 .get(input.id);
286 if (proposal === null) return fail("not_found", "No such proposal.");
287 const refusal = editRefusal(who, proposal);
288 if (refusal !== undefined) return refusal;
289 // A caller that sends options is told why they are not editable rather
290 // than having them quietly dropped. When a ballot exists on this proposal
291 // the whole edit stops here too — that check belongs in these two lines,
292 // beside this one, the day voting lands.
293 if (input.options !== undefined && input.options !== null)
294 return fail("invalid", OPTIONS_LOCKED);
295 const title = String(input.title ?? "").trim();
296 if (title.length === 0 || title.length > TITLE_MAX)
297 return fail("invalid", TITLE_MESSAGE);
298 const body = String(input.body ?? "").trim();
299 if (body.length === 0 || body.length > BODY_MAX)
300 return fail("invalid", BODY_MESSAGE);
301 // Saving the same words is not an edit: no stamp, no event, no lie on the
302 // page about a correction nobody made.
303 if (title === proposal.title && body === proposal.body)
304 return ok(read(proposal));
305 const edited = read(
306 db
307 .query<
308 ProposalRow,
309 [string, string, number]
310 >("UPDATE proposal SET title = ?1, body = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), edited_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *")
311 .get(title, body, proposal.id)!,
312 );
313 emit("proposal.edited", edited);
314 return ok(edited);
315 }
316
245317 export function withdrawProposal(
246318 db: Database,
247319 who: Who,
@@ -780,6 +852,41 @@ export const canWithdraw = (
780852 proposal: Pick<Proposal, "author_id">,
781853 ): boolean => mine(who, proposal);
782854
855 /**
856 * Whether `who` may edit `proposal`: the proposer, and nobody else. This is
857 * the one permission in the app that is not `mine` — an admin withdraws any
858 * proposal and deletes any remark, both of which take something away, but
859 * editing puts words under somebody else's name, and there is no
860 * administrative reason to do that. An admin who wrote it is its proposer
861 * like anybody else. Whether it is still open is the action's own check.
862 */
863 export const canEdit = (
864 who: Who,
865 proposal: Pick<Proposal, "author_id">,
866 ): boolean => who.role !== "anon" && who.id === proposal.author_id;
867
868 /**
869 * Why an edit would be refused, or nothing at all when it would not: who is
870 * asking and where the proposal stands, in the sentence the action itself
871 * refuses with. The form on `/p/:id/edit` is drawn from this, so the page
872 * never says something the action would then disagree with.
873 */
874 export const editRefusal = (
875 who: Who,
876 proposal: Pick<Proposal, "author_id" | "state">,
877 ): Result<never> | undefined => {
878 if (who.role === "anon")
879 return fail("signin_required", "Sign in to edit a proposal.");
880 if (!canEdit(who, proposal))
881 return fail("forbidden", "Only the proposer edits a proposal.");
882 if (proposal.state !== "open")
883 return fail(
884 "invalid",
885 `This proposal is ${proposal.state}; only an open one can be corrected, and its text is the record now.`,
886 );
887 return undefined;
888 };
889
783890 /** Whether `who` may delete `remark`: its author, or an admin. */
784891 export const canDeleteRemark = (
785892 who: Who,
src/db.test.ts +23 −12
@@ -73,6 +73,13 @@ const tables = (db: Database): string[] =>
7373 .all()
7474 .map((row) => row.name);
7575
76 /** The columns a table has, for a migration that adds one. */
77 const columns = (db: Database, table: string): string[] =>
78 db
79 .query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?1)")
80 .all(table)
81 .map((row) => row.name);
82
7683 /** How many seeded rows each seeded table still holds. */
7784 const seeded = (db: Database): Record<string, number> =>
7885 Object.fromEntries(
@@ -267,17 +274,10 @@ test("a database with proposals in it takes the newest migration", () => {
267274
268275 const db = new Database(join(dir, "app.db"));
269276 db.exec("PRAGMA foreign_keys = ON");
270 // The roster arrives with the newest migration and not before it: if
271 // `member` were inserted among the ones production has already recorded,
272 // this table would be here already and production would never run it.
273 expect(
274 db
275 .query<
276 { name: string },
277 []
278 >("SELECT name FROM sqlite_master WHERE name = 'member'")
279 .get(),
280 ).toBeNull();
277 // `edited_at` arrives with the newest migration and not before it: if it
278 // were inserted among the ones production has already recorded, the column
279 // would be here already and production would never run it.
280 expect(columns(db, "proposal")).not.toContain("edited_at");
281281 expect(migrate(db)).toBe(migrations.length);
282282 expect(check(db)).toEqual({ version: migrations.length, integrity: "ok" });
283283 // The rows that were there are the rows that are there: no table was
@@ -302,7 +302,18 @@ test("a database with proposals in it takes the newest migration", () => {
302302 >("SELECT label, proposal_id FROM option WHERE id = 1")
303303 .get(),
304304 ).toEqual({ label: "The kitchen", proposal_id: 1 });
305 // And the roster the newest migration brings is there and empty.
305 // And the column the newest migration brings is there, null on a proposal
306 // that existed before it — nothing was edited by adding the column.
307 expect(columns(db, "proposal")).toContain("edited_at");
308 expect(
309 db
310 .query<
311 { edited_at: string | null },
312 []
313 >("SELECT edited_at FROM proposal WHERE id = 1")
314 .get()?.edited_at,
315 ).toBeNull();
316 // The roster is still there, and still one row per person.
306317 expect(
307318 db.query<{ n: number }, []>("SELECT count(*) AS n FROM member").get()?.n,
308319 ).toBe(0);
src/db.ts +6
@@ -91,6 +91,12 @@ export const migrations: string[] = [
9191 added_by TEXT NOT NULL,
9292 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
9393 )`,
94 // When the proposer last corrected the text, and null until they do. Not
95 // `updated_at`: a withdrawal and an accepted counter stamp that one too,
96 // and a page that read it would say "edited" about a proposal nobody
97 // edited. One column, no history table — the correction is shown, the
98 // draft before it is not kept.
99 `ALTER TABLE proposal ADD COLUMN edited_at TEXT`,
94100 ];
95101
96102 /**
src/live.test.ts +49
@@ -2,6 +2,7 @@ import { afterAll, expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { propose } from "./actions.ts";
56 import { openDb } from "./db.ts";
67 import {
78 emit,
@@ -243,6 +244,53 @@ test("a write by another process reaches an open page, through the same action",
243244 await reader.cancel();
244245 });
245246
247 test("a correction made by another process reaches an open page too", async () => {
248 // The proposal is opened here; the edit comes from somewhere else — the
249 // terminal, or a script — and the page this process is serving hears it.
250 const opened = propose(other, { role: "user", id: "u1", name: "tty" }, {
251 title: "Where do we met",
252 body: "One room for the retrospective.",
253 options: ["The kitchen", "The garden"],
254 });
255 if (!opened.ok) throw new Error(opened.message);
256 const stream = await fetch(base + "/events");
257 const reader = stream.body!.getReader();
258 await reader.read(); // the retry line: the stream is open and following
259 const child = Bun.spawnSync({
260 cmd: [
261 "bun",
262 "-e",
263 `import { editProposal } from ${JSON.stringify(join(import.meta.dir, "actions.ts"))};
264 import { openDb } from ${JSON.stringify(join(import.meta.dir, "db.ts"))};
265 const saved = editProposal(openDb(), { role: "user", id: "u1", name: "tty" }, {
266 id: ${opened.value.id},
267 title: "Where do we meet",
268 body: "One warm room for the retrospective.",
269 });
270 if (!saved.ok) throw new Error(saved.message);`,
271 ],
272 env: { ...process.env, FORGE_APP_DATA: dir },
273 });
274 expect(child.stderr.toString()).toBe("");
275 expect(child.exitCode).toBe(0);
276 let buffer = "";
277 const deadline = Date.now() + 3000;
278 while (!buffer.includes("proposal.edited") && Date.now() < deadline) {
279 const read = await Promise.race([
280 reader.read(),
281 Bun.sleep(3000).then(() => ({ value: undefined })),
282 ]);
283 buffer += new TextDecoder().decode(read.value);
284 }
285 expect(buffer).toContain("Where do we meet");
286 expect(buffer).toContain("A proposal's text was corrected");
287 await reader.cancel();
288 // And the page a browser would reload says the same thing.
289 const page = await (await fetch(`${base}/p/${opened.value.id}`)).text();
290 expect(page).toContain("One warm room for the retrospective.");
291 expect(page).toContain(">edited <time");
292 });
293
246294 test("every event carries the one line the page announces", async () => {
247295 // The announcement is the app's own sentence, from the type alone: a
248296 // reader hears what happened instead of the whole region read again. The
@@ -250,6 +298,7 @@ test("every event carries the one line the page announces", async () => {
250298 // roster inherit them rather than each inventing one.
251299 expect(summarize("proposal.opened")).toBe("A new proposal");
252300 expect(summarize("proposal.withdrawn")).toBe("A proposal was withdrawn");
301 expect(summarize("proposal.edited")).toBe("A proposal's text was corrected");
253302 expect(summarize("remark.added")).toBe("A new remark");
254303 expect(summarize("remark.removed")).toBe("A remark was deleted");
255304 expect(summarize("remark.accepted")).toBe("A counter became an option");
src/live.ts +1
@@ -35,6 +35,7 @@ export interface LiveEvent {
3535 const SUMMARIES: Record<string, string> = {
3636 "proposal.opened": "A new proposal",
3737 "proposal.withdrawn": "A proposal was withdrawn",
38 "proposal.edited": "A proposal's text was corrected",
3839 "remark.added": "A new remark",
3940 "remark.removed": "A remark was deleted",
4041 "remark.accepted": "A counter became an option",
src/mcp.ts +24
@@ -9,6 +9,7 @@
99 import type { Database } from "bun:sqlite";
1010 import {
1111 addRemark,
12 editProposal,
1213 getProposal,
1314 health,
1415 listProposals,
@@ -115,6 +116,29 @@ export const tools: readonly Tool[] = [
115116 : String(args.rule),
116117 }),
117118 },
119 {
120 name: "edit_proposal",
121 description:
122 "Correct the title and the paragraph of a proposal you opened, while it is open. Both fields every time: this is the text as it should now read, not a diff. The options cannot be edited at any door — add one by having its proposer accept a counter. The correction is shown on the proposal with the time it was made.",
123 inputSchema: {
124 type: "object",
125 properties: {
126 id: { type: "integer", minimum: 1 },
127 title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
128 body: { type: "string", minLength: 1, maxLength: BODY_MAX },
129 },
130 required: ["id", "title", "body"],
131 },
132 run: (db, who, args) =>
133 editProposal(db, who, {
134 id: Number(args.id),
135 title: String(args.title ?? ""),
136 body: String(args.body ?? ""),
137 // Not in the schema, and still refused out loud if an agent sends
138 // them: a tool that ignored a field would look as if it had taken it.
139 options: args.options,
140 }),
141 },
118142 {
119143 name: "withdraw_proposal",
120144 description:
src/pages/about.ts +1
@@ -27,6 +27,7 @@ const WORKS: readonly string[] = [
2727 "Read every proposal and any one of them, signed in or not.",
2828 "Say something on a proposal: a comment, a question for its author, or a counter arguing for another option — and delete your own again.",
2929 "Accept a counter on a proposal you opened: the option it names is added to it, and every ballot already cast still stands.",
30 "Correct the title and the paragraph of a proposal you opened while it is open — the options are not editable — and the proposal says it was corrected, with the time.",
3031 "Withdraw a proposal you opened while it is open; an admin withdraws any.",
3132 "Watch the list stay current as other people write, without reloading.",
3233 "Take the whole record away as a file: an admin downloads the database or its JSON, and puts one back at the terminal.",
src/pages/edit.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Correcting a proposal: the title and the paragraph, in a form already
3 * holding what is there now. A page of its own rather than a disclosure on
4 * the proposal, because the proposal is a live region and a form inside one
5 * is a form that can be swapped out from under somebody's hands.
6 *
7 * The options are shown here and cannot be touched: they are what the group
8 * is choosing between, and the page says so in the same sentence the action
9 * refuses with. There is no JavaScript in any of this — it is a form, it
10 * posts, and the proposal comes back.
11 */
12
13 import {
14 BODY_MAX,
15 OPTIONS_LOCKED,
16 TITLE_MAX,
17 type FullProposal,
18 } from "../actions.ts";
19 import { escape } from "../http.ts";
20 import type { Who } from "../who.ts";
21 import { layout, toast } from "./layout.ts";
22
23 export function editPage(input: {
24 who: Who;
25 proposal: FullProposal;
26 notice?: string | undefined;
27 }): string {
28 const { who, proposal, notice } = input;
29 const back = `/p/${proposal.id}`;
30 return layout(
31 `<section class="card">
32 <h2>Correct this proposal</h2>
33 <p class="muted small">The words, so the group argues about what you meant.
34 Everyone sees that it was corrected, and when.</p>
35 <form class="stack" method="post" action="/proposals/${proposal.id}/edit">
36 <label class="field">
37 <span>Title</span>
38 <input class="input" id="title" name="title" value="${escape(proposal.title)}" required maxlength="${TITLE_MAX}" autocomplete="off">
39 </label>
40 <label class="field">
41 <span>Paragraph</span>
42 <textarea class="input" id="body" name="body" rows="6" required maxlength="${BODY_MAX}">${escape(proposal.body)}</textarea>
43 </label>
44 <div class="row">
45 <button class="button" id="save">Save the correction</button>
46 <a class="button button-quiet" href="${escape(back)}">Cancel</a>
47 </div>
48 </form>
49 </section>
50 <section class="card">
51 <h3>The options stay as they are</h3>
52 <ol class="options">${proposal.options
53 .map((option) => `<li>${escape(option.label)}</li>`)
54 .join("")}</ol>
55 <p class="muted small">${escape(OPTIONS_LOCKED)}</p>
56 </section>
57 ${toast(notice)}`,
58 who,
59 `/p/${proposal.id}/edit`,
60 );
61 }
src/pages/proposal.ts +25 −6
@@ -1,8 +1,9 @@
11 /**
22 * One proposal: its title, its paragraph, its options in order, who wrote
3 * it and where it stands — and a Withdraw form for whoever may withdraw it
4 * while it is open. Below it the argument: a composer for whoever is signed
5 * in, and then the thread, oldest first. A guest sees all of it and no forms.
3 * it and where it stands — an Edit link for its proposer and a Withdraw form
4 * for whoever may withdraw it, while it is open. Below it the argument: a
5 * composer for whoever is signed in, and then the thread, oldest first. A
6 * guest sees all of it and no forms.
67 *
78 * The composer sits above the thread on purpose. The thread is the live
89 * region, it grows at the bottom, and it is the last thing on the page — so
@@ -20,6 +21,7 @@
2021 import {
2122 canAcceptCounter,
2223 canDeleteRemark,
24 canEdit,
2325 canWithdraw,
2426 KINDS,
2527 BODY_MAX,
@@ -58,14 +60,20 @@ export function proposalPage(input: {
5860 <button class="button button-quiet" id="withdraw">Withdraw</button>
5961 </form>`
6062 : "";
63 // The proposer corrects their own words, and only while it is open; the
64 // options are not on that page, and the action would refuse them anyway.
65 const edit =
66 proposal.state === "open" && canEdit(who, proposal)
67 ? `<a class="button button-quiet" id="edit" href="/p/${proposal.id}/edit">Edit</a>`
68 : "";
6169 return layout(
6270 `${liveStatus("proposal")}
63 <section id="proposal" data-live="proposal.opened proposal.withdrawn remark.accepted" data-status="proposal-status" aria-live="polite" class="card">
71 <section id="proposal" data-live="proposal.opened proposal.withdrawn proposal.edited remark.accepted" data-status="proposal-status" aria-live="polite" class="card">
6472 <div class="head-row">
6573 <h2>${escape(proposal.title)}</h2>
6674 <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span>
6775 </div>
68 <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time></p>
76 <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time>${edited(proposal)}</p>
6977 <p class="body">${escape(proposal.body)}</p>
7078 <p class="rule small">${rule(proposal)}</p>
7179 <ol class="options">${proposal.options
@@ -74,7 +82,7 @@ export function proposalPage(input: {
7482 `<li${option.id === proposal.decided_option_id ? ` class="chosen"` : ""}>${escape(option.label)}</li>`,
7583 )
7684 .join("")}</ol>
77 <div class="row">${withdraw}<a class="button button-quiet" href="/">All proposals</a></div>
85 <div class="row">${edit}${withdraw}<a class="button button-quiet" href="/">All proposals</a></div>
7886 </section>
7987 <section class="card">${composer(who, proposal)}</section>
8088 ${liveStatus("thread")}
@@ -88,6 +96,17 @@ export function proposalPage(input: {
8896 );
8997 }
9098
99 /**
100 * That the text was corrected, beside who wrote it and when — never
101 * silently, and never claimed of a proposal nobody edited: `edited_at` is
102 * set by the edit alone, where `updated_at` also moves when a proposal is
103 * withdrawn or takes a counter's option.
104 */
105 const edited = (proposal: FullProposal): string =>
106 proposal.edited_at === null
107 ? ""
108 : ` · <span class="edited">edited <time datetime="${escape(proposal.edited_at)}">${ago(proposal.edited_at)}</time></span>`;
109
91110 /** Where a remark is written. A guest is told how to get one instead. */
92111 const composer = (who: Who, proposal: FullProposal): string =>
93112 who.role === "anon"
src/routes.ts +70 −1
@@ -9,6 +9,8 @@ import {
99 acceptCounter,
1010 addRemark,
1111 deleteRemark,
12 editProposal,
13 editRefusal,
1214 exportRecord,
1315 exportSnapshot,
1416 getProposal,
@@ -36,6 +38,7 @@ import {
3638 import { events } from "./live.ts";
3739 import { rpc } from "./mcp.ts";
3840 import { about } from "./pages/about.ts";
41 import { editPage } from "./pages/edit.ts";
3942 import { home } from "./pages/home.ts";
4043 import { missing } from "./pages/layout.ts";
4144 import { proposalPage } from "./pages/proposal.ts";
@@ -53,7 +56,7 @@ export interface Context {
5356 }
5457
5558 export interface Route {
56 readonly method: "GET" | "POST" | "DELETE";
59 readonly method: "GET" | "POST" | "PATCH" | "DELETE";
5760 readonly path: string;
5861 readonly handle: (ctx: Context) => Response | Promise<Response>;
5962 }
@@ -100,6 +103,27 @@ const given = (value: unknown): string | undefined =>
100103 const optionsOf = (value: unknown): unknown[] =>
101104 Array.isArray(value) ? value : typeof value === "string" ? lines(value) : [];
102105
106 /**
107 * A correction over JSON, at `PATCH /api/proposals/:id` and at `POST` of the
108 * same path for a client that speaks no PATCH. Both fields every time: an
109 * edit is the text as it should now read, never a diff. `options` is passed
110 * through untouched so the action can say why it will not take them, rather
111 * than this door dropping them quietly.
112 */
113 const editJson = async (c: Context): Promise<Response> => {
114 const body = await readJson(c.request);
115 if (body === undefined) return json({ error: "too_large" }, 413);
116 const input = fields(body);
117 return answer(
118 editProposal(c.db, c.who, {
119 id: Number(c.params.id),
120 title: String(input.title ?? ""),
121 body: String(input.body ?? ""),
122 options: input.options,
123 }),
124 );
125 };
126
103127 export const routes: readonly Route[] = [
104128 // The pages. Forms post and come back; the lists stay live over /events.
105129 {
@@ -154,6 +178,49 @@ export const routes: readonly Route[] = [
154178 );
155179 },
156180 },
181 // Correcting one: a page of its own, drawn only for someone the action
182 // would let through, and saying what the action would say when it would
183 // not. The options are not on it.
184 {
185 method: "GET",
186 path: "/p/:id/edit",
187 handle: (c) => {
188 const { notice, clear } = readNotice(c.request);
189 const found = getProposal(c.db, c.who, { id: Number(c.params.id) });
190 if (!found.ok) return html(missing(found.message, c.who), 404, clear);
191 const refused = editRefusal(c.who, found.value);
192 if (refused !== undefined)
193 return html(
194 missing(refused.message, c.who),
195 STATUS[refused.error],
196 clear,
197 );
198 return html(
199 editPage({ who: c.who, proposal: found.value, notice }),
200 200,
201 clear,
202 );
203 },
204 },
205 {
206 method: "POST",
207 path: "/proposals/:id/edit",
208 handle: async (c) => {
209 const form = await readForm(c.request);
210 if (form === undefined) return text("too large", 413);
211 const where = pathOf(c.params.id ?? "");
212 const saved = editProposal(c.db, c.who, {
213 id: Number(c.params.id),
214 title: String(form.get("title") ?? ""),
215 body: String(form.get("body") ?? ""),
216 });
217 // Saved, and the proposal is what you wanted to see; refused, and the
218 // form is, with the sentence saying what to change.
219 return saved.ok
220 ? back(undefined, where)
221 : back(saved.message, where === "/" ? "/" : `${where}/edit`);
222 },
223 },
157224 {
158225 method: "POST",
159226 path: "/proposals",
@@ -266,6 +333,8 @@ export const routes: readonly Route[] = [
266333 );
267334 },
268335 },
336 { method: "PATCH", path: "/api/proposals/:id", handle: editJson },
337 { method: "POST", path: "/api/proposals/:id", handle: editJson },
269338 {
270339 method: "POST",
271340 path: "/api/proposals/:id/withdraw",
src/server.test.ts +198 −5
@@ -12,11 +12,8 @@ import { serve } from "./server.ts";
1212 import { sign } from "./who.ts";
1313
1414 const KEY = "a-key-only-the-forge-holds";
15 const server = serve({
16 port: 0,
17 dataDir: mkdtempSync(join(tmpdir(), "app-")),
18 identityKey: KEY,
19 });
15 const dataDir = mkdtempSync(join(tmpdir(), "app-"));
16 const server = serve({ port: 0, dataDir, identityKey: KEY });
2017 const base = `http://${server.hostname}:${server.port}`;
2118 afterAll(() => server.stop(true));
2219
@@ -442,6 +439,201 @@ test("the JSON API is the same actions with the same identity", async () => {
442439 ).toBe(404);
443440 });
444441
442 test("a proposer fixes a typo on the page, and the page says it was fixed", async () => {
443 const typist = as("u21", "typist");
444 const nosy = as("u22", "nosy");
445 const p = await open(typist, { title: "Where do we met" });
446 const path = `/p/${p.id}`;
447
448 // Nobody is told about an edit that has not happened, and only the
449 // proposer is offered the door.
450 const guest = await (await fetch(base + path)).text();
451 expect(guest).toContain("Where do we met");
452 // "edited" is the mark beside the byline, not the word in `data-live`.
453 expect(guest).not.toContain(">edited <time");
454 expect(guest).not.toContain(`href="${path}/edit"`);
455 expect(await (await api(path, {}, nosy)).text()).not.toContain(
456 `href="${path}/edit"`,
457 );
458 expect(await (await api(path, {}, typist)).text()).toContain(
459 `href="${path}/edit"`,
460 );
461
462 // The form arrives already holding the text it is there to correct, and
463 // nothing on it can change the options.
464 const form = await api(`${path}/edit`, {}, typist);
465 expect(form.status).toBe(200);
466 const drawn = await form.text();
467 expect(drawn).toContain('value="Where do we met"');
468 expect(drawn).toContain("One room for the retrospective.</textarea>");
469 expect(drawn).toContain("The options cannot be edited");
470 expect(drawn).not.toContain('name="options"');
471 // Somebody else's proposal, and a guest: the sentence the action refuses
472 // with, at the status the failure carries.
473 const notMine = await api(`${path}/edit`, {}, nosy);
474 expect(notMine.status).toBe(403);
475 expect(await notMine.text()).toContain("Only the proposer edits a proposal.");
476 expect((await fetch(`${base}${path}/edit`)).status).toBe(401);
477
478 const save = (fields: Record<string, string>, who = typist) =>
479 api(
480 `/proposals/${p.id}/edit`,
481 {
482 method: "POST",
483 headers: { "content-type": "application/x-www-form-urlencoded" },
484 body: new URLSearchParams(fields).toString(),
485 redirect: "manual",
486 },
487 who,
488 );
489 const saved = await save({
490 title: "Where do we meet <the kitchen?>",
491 body: "One warm room for the retrospective.",
492 });
493 expect(saved.status).toBe(303);
494 expect(saved.headers.get("location")).toBe(path);
495
496 const after = await (await fetch(base + path)).text();
497 // Escaped on the way out, like every other thing anybody typed.
498 expect(after).toContain("Where do we meet &lt;the kitchen?&gt;");
499 expect(after).not.toContain("<the kitchen?>");
500 expect(after).toContain("One warm room for the retrospective.");
501 expect(after).toContain(">edited <time");
502 // The thread is untouched, and so is the live region's list of types.
503 expect(after).toContain(
504 'data-live="proposal.opened proposal.withdrawn proposal.edited remark.accepted"',
505 );
506
507 // A refusal comes back to the form with the sentence, not to the proposal.
508 const empty = await save({ title: "", body: "Still a paragraph." });
509 expect(empty.status).toBe(303);
510 expect(empty.headers.get("location")).toBe(`${path}/edit`);
511 expect(empty.headers.get("set-cookie")).toContain("A%20title%20is%201%20to");
512 expect((await save({ title: "Nope", body: "Nope." }, nosy)).status).toBe(303);
513 expect(await (await fetch(base + path)).text()).toContain(
514 "Where do we meet &lt;the kitchen?&gt;",
515 );
516 });
517
518 test("a withdrawn proposal is not an edited one", async () => {
519 const dropper = as("u23", "dropper");
520 const p = await open(dropper, { title: "Something to drop" });
521 await api(`/api/proposals/${p.id}/withdraw`, { method: "POST" }, dropper);
522 // Withdrawing stamps `updated_at` too; only a correction says "edited".
523 const page = await (await fetch(`${base}/p/${p.id}`)).text();
524 expect(page).toContain("withdrawn");
525 expect(page).not.toContain(">edited <time");
526 // And there is no door back into the text of one.
527 expect((await api(`/p/${p.id}/edit`, {}, dropper)).status).toBe(422);
528 });
529
530 test("an edit over the API is the same edit, at PATCH or at POST", async () => {
531 const scribe = as("u24", "scribe");
532 const meddler = as("u25", "meddler");
533 const p = await open(scribe, { title: "Where do we met" });
534 const at = `/api/proposals/${p.id}`;
535 const text = { title: "Where do we meet", body: "One warm room." };
536
537 expect((await api(at, { method: "PATCH", ...jsonBody(text) })).status).toBe(
538 401,
539 );
540 expect(
541 (await api(at, { method: "PATCH", ...jsonBody(text) }, meddler)).status,
542 ).toBe(403);
543 expect(
544 (await api(at, { method: "PATCH", ...jsonBody(text) }, root)).status,
545 ).toBe(403);
546
547 const patched = await api(at, { method: "PATCH", ...jsonBody(text) }, scribe);
548 expect(patched.status).toBe(200);
549 const row = (await patched.json()) as {
550 title: string;
551 edited_at: string | null;
552 };
553 expect(row.title).toBe("Where do we meet");
554 expect(row.edited_at).not.toBeNull();
555
556 // The same path, POSTed, for a client that speaks no PATCH.
557 const posted = await api(
558 at,
559 { method: "POST", ...jsonBody({ ...text, body: "One warmer room." }) },
560 scribe,
561 );
562 expect(posted.status).toBe(200);
563 expect(((await posted.json()) as { body: string }).body).toBe(
564 "One warmer room.",
565 );
566
567 // The same sentences a first draft is judged by, and the options never.
568 const long = await api(
569 at,
570 { method: "PATCH", ...jsonBody({ ...text, title: "x".repeat(121) }) },
571 scribe,
572 );
573 expect(long.status).toBe(422);
574 expect(((await long.json()) as { message: string }).message).toBe(
575 "A title is 1 to 120 characters.",
576 );
577 const options = await api(
578 at,
579 { method: "PATCH", ...jsonBody({ ...text, options: ["Yes", "No"] }) },
580 scribe,
581 );
582 expect(options.status).toBe(422);
583 expect(((await options.json()) as { message: string }).message).toContain(
584 "The options cannot be edited",
585 );
586 expect(
587 (await api("/api/proposals/9999", { method: "PATCH", ...jsonBody(text) }, scribe))
588 .status,
589 ).toBe(404);
590 });
591
592 test("an agent corrects its own proposal, and nobody else's", async () => {
593 const agent = as("u26", "agent");
594 const p = await open(agent, { title: "Where do we met" });
595 const call = (name: string, args: unknown, who = {}) =>
596 rpc(
597 {
598 jsonrpc: "2.0",
599 id: 26,
600 method: "tools/call",
601 params: { name, arguments: args },
602 },
603 who,
604 );
605 const fixed = await call(
606 "edit_proposal",
607 { id: p.id, title: "Where do we meet", body: "One warm room." },
608 agent,
609 );
610 expect(fixed.result.isError).toBeUndefined();
611 expect(
612 (JSON.parse(fixed.result.content[0].text) as { edited_at: string | null })
613 .edited_at,
614 ).not.toBeNull();
615 // A bare caller is anonymous, and somebody else's proposal is not theirs
616 // to rewrite.
617 expect(
618 (await call("edit_proposal", { id: p.id, title: "No", body: "No." })).result
619 .isError,
620 ).toBe(true);
621 expect(
622 (
623 await call(
624 "edit_proposal",
625 { id: p.id, title: "No", body: "No." },
626 as("u27", "stranger"),
627 )
628 ).result.content[0].text,
629 ).toContain("Only the proposer edits a proposal.");
630 // An edit made by another process on the same file reaches an open page
631 // the same way a new proposal does; `live.test.ts` is where that is proven.
632 expect(await (await fetch(`${base}/p/${p.id}`)).text()).toContain(
633 "Where do we meet",
634 );
635 });
636
445637 test("the MCP server lists the tools and calls them as the caller", async () => {
446638 const hello = await rpc({
447639 jsonrpc: "2.0",
@@ -455,6 +647,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
455647 "list_proposals",
456648 "get_proposal",
457649 "propose",
650 "edit_proposal",
458651 "withdraw_proposal",
459652 "list_remarks",
460653 "add_remark",
src/tty.ts +115 −4
@@ -3,7 +3,8 @@
33 * `ssh`es to the app, over the same data as the page and through the same
44 * actions, as the account at the keyboard. j/k move, Enter opens the
55 * proposal under the cursor — with the argument on it underneath — n
6 * proposes, c says something, w withdraws, Esc goes back and q leaves.
6 * proposes, c says something, e corrects the text of one you opened, w
7 * withdraws, Esc goes back and q leaves.
78 * Plain ANSI, no dependencies.
89 *
910 * Nothing anyone wrote reaches this screen as a control character: what
@@ -14,6 +15,8 @@
1415 import {
1516 addRemark,
1617 canWithdraw,
18 editProposal,
19 editRefusal,
1720 getProposal,
1821 listProposals,
1922 listRemarks,
@@ -62,10 +65,25 @@ interface RemarkDraft {
6265 typing: string;
6366 }
6467
68 /**
69 * A proposal being corrected: the title, then the paragraph, each starting
70 * as what is there now. `was` is that text, kept to say afterwards whether
71 * anything actually changed. The options are not a step — they are not
72 * editable at this door either.
73 */
74 interface EditDraft {
75 step: "title" | "body";
76 id: number;
77 was: { title: string; body: string };
78 title: string;
79 typing: string;
80 }
81
6582 let cursor = 0;
6683 let open: number | undefined;
6784 let draft: Draft | undefined;
6885 let remarkDraft: RemarkDraft | undefined;
86 let editDraft: EditDraft | undefined;
6987 let said = "";
7088
7189 const proposals = (): ProposalSummary[] => {
@@ -111,6 +129,7 @@ function draw(): void {
111129 ];
112130 if (draft !== undefined) lines.push(...composer(draft));
113131 else if (remarkDraft !== undefined) lines.push(...remarkComposer(remarkDraft));
132 else if (editDraft !== undefined) lines.push(...editComposer(editDraft));
114133 else if (open !== undefined) lines.push(...detail(open));
115134 else lines.push(...list());
116135 lines.push("");
@@ -141,7 +160,12 @@ const detail = (id: number): string[] => {
141160 return [
142161 bold(oneLine(proposal.title)),
143162 dim(
144 `${oneLine(proposal.author_name)} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`,
163 `${oneLine(proposal.author_name)} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}${
164 // A correction is shown here as it is on the page: never silently.
165 proposal.edited_at === null
166 ? ""
167 : ` · edited ${proposal.edited_at.slice(0, 16).replace("T", " ")}`
168 }`,
145169 ),
146170 "",
147171 ...wrap(proposal.body),
@@ -256,16 +280,37 @@ const remarkComposer = (d: RemarkDraft): string[] => {
256280 return lines;
257281 };
258282
283 /**
284 * The text as it stands, to be corrected: the title first, then the
285 * paragraph, each arriving already typed. What is on the screen has been
286 * through `plain.ts` like everything else somebody else wrote — the buffer
287 * keeps the real characters, the terminal is shown safe ones.
288 */
289 const editComposer = (d: EditDraft): string[] => {
290 const cell = `${esc}7m ${esc}0m`;
291 const lines = [bold("Correcting a proposal"), ""];
292 lines.push(
293 d.step === "title"
294 ? `Title: ${oneLine(d.typing)}${cell}`
295 : dim(`Title: ${oneLine(d.title)}`),
296 );
297 if (d.step === "body") lines.push(`Paragraph: ${oneLine(d.typing)}${cell}`);
298 lines.push("");
299 lines.push(dim("The options are not editable; a counter adds one."));
300 return lines;
301 };
302
259303 const keys = (): string => {
260304 if (draft !== undefined) return "Enter accepts · Esc cancels";
261305 if (remarkDraft !== undefined) return "Enter accepts · Esc cancels";
306 if (editDraft !== undefined) return "Enter accepts · Esc cancels";
262307 if (open !== undefined)
263308 return me.role === "anon"
264309 ? "Esc back · q quit · sign in through the forge to say something"
265 : "Esc back · c say something · w withdraw · q quit";
310 : "Esc back · c say something · e correct · w withdraw · q quit";
266311 return me.role === "anon"
267312 ? "j/k move · Enter open · q quit · sign in through the forge to propose"
268 : "j/k move · Enter open · n propose · w withdraw · q quit";
313 : "j/k move · Enter open · n propose · e correct · w withdraw · q quit";
269314 };
270315
271316 /** The draft, finished: the same action every other door calls. */
@@ -311,6 +356,51 @@ function say(d: RemarkDraft, optionLabel: string): void {
311356 }
312357 }
313358
359 /** The correction, finished: the same action the page and the API call. */
360 function save(d: EditDraft, body: string): void {
361 const saved = editProposal(db, me, { id: d.id, title: d.title, body });
362 if (saved.ok) {
363 // The same words back is not a correction, and is not reported as one.
364 said =
365 saved.value.title === d.was.title && saved.value.body === d.was.body
366 ? "Nothing changed."
367 : "Corrected.";
368 editDraft = undefined;
369 open = d.id;
370 } else {
371 // Keep what was typed: the message says what to change.
372 said = saved.message;
373 d.step = "body";
374 d.typing = body;
375 }
376 }
377
378 /** The proposal under the cursor, opened for correction — if it may be. */
379 function correct(id: number | undefined): void {
380 if (id === undefined) {
381 said = "Nothing to correct.";
382 return;
383 }
384 const proposal = one(id);
385 if (proposal === undefined) {
386 said = "No such proposal.";
387 return;
388 }
389 // Exactly what the action would refuse with, asked before anything is typed.
390 const refused = editRefusal(me, proposal);
391 if (refused !== undefined) {
392 said = refused.message;
393 return;
394 }
395 editDraft = {
396 step: "title",
397 id,
398 was: { title: proposal.title, body: proposal.body },
399 title: "",
400 typing: proposal.title,
401 };
402 }
403
314404 function withdraw(id: number | undefined): void {
315405 if (id === undefined) {
316406 said = "Nothing to withdraw.";
@@ -398,6 +488,26 @@ process.stdin.on("data", (key: string) => {
398488 draw();
399489 return;
400490 }
491 if (editDraft !== undefined) {
492 const d = editDraft;
493 if (key === "\x1b") {
494 editDraft = undefined;
495 said = "Cancelled.";
496 } else if (key === "\x03") {
497 editDraft = undefined;
498 } else if (key === "\r") {
499 if (d.step === "title") {
500 d.title = d.typing.trim();
501 d.step = "body";
502 // The paragraph arrives already typed too: a typo is a keystroke
503 // away, not a paragraph away.
504 d.typing = d.was.body;
505 } else save(d, d.typing.trim());
506 } else if (key === "\x7f") d.typing = d.typing.slice(0, -1);
507 else if (key >= " ") d.typing += key;
508 draw();
509 return;
510 }
401511 if (key === "q" || key === "\x03") {
402512 out(`${esc}2J${esc}H`);
403513 process.exit(0);
@@ -440,6 +550,7 @@ process.stdin.on("data", (key: string) => {
440550 typing: "",
441551 };
442552 }
553 if (key === "e") correct(under);
443554 if (key === "w") withdraw(under);
444555 draw();
445556 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs