plotcoder-board 0.1.17 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +254 -84
- package/src/board/agents.js +4 -4
- package/src/board/fdx.js +29 -16
- package/src/board/fountain.d.ts +2 -0
- package/src/board/fountain.js +24 -6
- package/src/board/markdown.d.ts +2 -1
- package/src/board/markdown.js +32 -14
- package/src/board/numbering.d.ts +8 -1
- package/src/board/numbering.js +36 -3
- package/src/board/organize.js +3 -30
- package/src/board/readWall.d.ts +3 -1
- package/src/board/readWall.js +69 -9
- package/src/board/reducer.d.ts +8 -2
- package/src/board/reducer.js +52 -7
- package/src/board/words.js +1 -1
package/src/board/agents.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export const AGENTS = {
|
|
9
9
|
lead:
|
|
10
|
-
"A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you?
|
|
10
|
+
"A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Then your way in is: read the guide, make the four calls under Call these first, and skip the doors between — they are for wiring a server in.",
|
|
11
11
|
doors: [
|
|
12
12
|
{
|
|
13
13
|
id: "mcp",
|
|
@@ -18,7 +18,7 @@ export const AGENTS = {
|
|
|
18
18
|
{
|
|
19
19
|
id: "shell",
|
|
20
20
|
name: "The shell, for this session",
|
|
21
|
-
text: "npx -y plotcoder-board@latest call <tool> '{json}' makes one call, with no MCP and no restart. One server per call, so undo and the project you opened do not carry between calls; npx -y plotcoder-board@latest call --batch < calls.jsonl runs a file of calls — one per line, {\"tool\": \"…\", \"arguments\": {…}} — on one server, so they do. With the account door the sign-in is kept in the folder's .plotcoder between calls (PLOTCODER_SESSION=0 to sign in every time). PLOTCODER_PROJECT names the project for each call. The JSON tail is off on
|
|
21
|
+
text: "npx -y plotcoder-board@latest call <tool> '{json}' makes one call, with no MCP and no restart. One server per call, so undo and the project you opened do not carry between calls; npx -y plotcoder-board@latest call --batch < calls.jsonl runs a file of calls — one per line, {\"tool\": \"…\", \"arguments\": {…}} — on one server, so they do. With the account door the sign-in is kept in the folder's .plotcoder between calls (PLOTCODER_SESSION=0 to sign in every time). PLOTCODER_PROJECT names the project for each call. The JSON tail is off on every door; PLOTCODER_JSON=1 in the server's environment adds it. PLOTCODER_ROOT points the server at the folder whose wall you mean; without it, the folder you run it from.",
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
id: "hosted",
|
|
@@ -42,10 +42,10 @@ export const AGENTS = {
|
|
|
42
42
|
text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
|
|
43
43
|
},
|
|
44
44
|
],
|
|
45
|
-
firstNote: "Make these four before anything else; none depends on another, so any order is fine.
|
|
45
|
+
firstNote: "Make these four before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project; read_wall and list_reminders are about the wall you will work, so after open_project, open_board, new_project or empty_account, make those two again. Every reply's first line names the project it read and how many the account holds; list_projects lists them. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project (name, pages, and board for the first board's name), then read_wall and list_reminders again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
|
|
46
46
|
first: [
|
|
47
47
|
{ tool: "list_words", why: "the room's words, the app's meaning." },
|
|
48
|
-
{ tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
|
|
48
|
+
{ tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's." },
|
|
49
49
|
{ tool: "list_workflows", why: "what a writer can ask you for." },
|
|
50
50
|
{ tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything. Reminders live on the project and go with it." },
|
|
51
51
|
],
|
package/src/board/fdx.js
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// tags used, so the MCP server reads the same file the browser does.
|
|
12
12
|
|
|
13
13
|
import { parseScene, TRANSITION } from "./paginate.js";
|
|
14
|
-
import {
|
|
14
|
+
import { storyOrder } from "./readWall.js";
|
|
15
15
|
import { sceneHeading, standInFor } from "./fountain.js";
|
|
16
|
-
import { sceneNumbers } from "./numbering.js";
|
|
16
|
+
import { REVISION_HEX, revisionLine, revisionMarks, sceneNumbers } from "./numbering.js";
|
|
17
17
|
|
|
18
18
|
function escapeXml(text) {
|
|
19
19
|
return String(text)
|
|
@@ -33,14 +33,16 @@ function unescapeXml(text) {
|
|
|
33
33
|
.replace(/&/g, "&");
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function paragraph(type, text, extra = "") {
|
|
37
|
-
|
|
36
|
+
function paragraph(type, text, extra = "", revised = false) {
|
|
37
|
+
// A revised paragraph's text carries the revision's id, as Final Draft marks one.
|
|
38
|
+
const textAttr = revised ? ' RevisionID="1"' : "";
|
|
39
|
+
return ` <Paragraph Type="${type}"${extra}>\n <Text${textAttr}>${escapeXml(text)}</Text>\n </Paragraph>\n`;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
function speechParagraphs(speech) {
|
|
41
|
-
let out = paragraph("Character", speech.name.toUpperCase());
|
|
42
|
+
function speechParagraphs(speech, revisedAt = () => false) {
|
|
43
|
+
let out = paragraph("Character", speech.name.toUpperCase(), "", revisedAt(speech.at));
|
|
42
44
|
for (const part of speech.parts) {
|
|
43
|
-
out += paragraph(part.kind === "parenthetical" ? "Parenthetical" : "Dialogue", part.text);
|
|
45
|
+
out += paragraph(part.kind === "parenthetical" ? "Parenthetical" : "Dialogue", part.text, "", revisedAt(part.at ?? speech.at));
|
|
44
46
|
}
|
|
45
47
|
return out;
|
|
46
48
|
}
|
|
@@ -51,29 +53,34 @@ function speechParagraphs(speech) {
|
|
|
51
53
|
* when written and the change line as action when not, and a title page.
|
|
52
54
|
*/
|
|
53
55
|
export function toFdx(state, options = {}) {
|
|
54
|
-
const order =
|
|
56
|
+
const order = storyOrder(state);
|
|
55
57
|
const numbers = sceneNumbers(order, state.lock);
|
|
58
|
+
const marks = revisionMarks(state);
|
|
56
59
|
let content = "";
|
|
57
60
|
order.forEach((note, index) => {
|
|
58
61
|
const number = numbers.get(note.id) ?? index + 1;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
const mark = marks.get(note.id);
|
|
63
|
+
// A changed card with no changed text line (its headline, change or place moved) marks its heading.
|
|
64
|
+
const headingRevised = Boolean(mark?.revised && mark.lines.size === 0);
|
|
65
|
+
const revisedAt = (at) => Boolean(mark && typeof at === "number" && mark.lines.has(at));
|
|
66
|
+
content += paragraph("Scene Heading", sceneHeading(note).slice(1), ` Number="${number}"`, headingRevised).replace(
|
|
67
|
+
/<Text( RevisionID="1")?>/,
|
|
68
|
+
(_m, attr) => `<SceneProperties Length="" Page="" Title="${escapeXml(note.headline)}" />\n <Text${attr ?? ""}>`,
|
|
62
69
|
);
|
|
63
70
|
// Unwritten: the change line stands in as action, marked as every export marks it.
|
|
64
71
|
const elements = parseScene(note.text && note.text.trim() ? note.text : standInFor(note));
|
|
65
72
|
for (let i = 0; i < elements.length; i += 1) {
|
|
66
73
|
const element = elements[i];
|
|
67
|
-
if (element.kind === "action") content += paragraph("Action", element.text);
|
|
68
|
-
else if (element.kind === "transition") content += paragraph("Transition", element.text);
|
|
69
|
-
else if (element.kind === "centered") content += paragraph("General", element.text, ' Alignment="Center"');
|
|
74
|
+
if (element.kind === "action") content += paragraph("Action", element.text, "", revisedAt(element.at));
|
|
75
|
+
else if (element.kind === "transition") content += paragraph("Transition", element.text, "", revisedAt(element.at));
|
|
76
|
+
else if (element.kind === "centered") content += paragraph("General", element.text, ' Alignment="Center"', revisedAt(element.at));
|
|
70
77
|
else if (element.kind === "speech") {
|
|
71
78
|
const next = elements[i + 1];
|
|
72
79
|
if (next && next.kind === "speech" && next.dual) {
|
|
73
|
-
content += ` <Paragraph>\n <DualDialogue>\n${speechParagraphs(element)}${speechParagraphs(next)} </DualDialogue>\n </Paragraph>\n`;
|
|
80
|
+
content += ` <Paragraph>\n <DualDialogue>\n${speechParagraphs(element, revisedAt)}${speechParagraphs(next, revisedAt)} </DualDialogue>\n </Paragraph>\n`;
|
|
74
81
|
i += 1;
|
|
75
82
|
} else {
|
|
76
|
-
content += speechParagraphs(element);
|
|
83
|
+
content += speechParagraphs(element, revisedAt);
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
}
|
|
@@ -85,12 +92,18 @@ export function toFdx(state, options = {}) {
|
|
|
85
92
|
if (options.author) title.push(paragraph("General", `Written by ${options.author}`, ' Alignment="Center"'));
|
|
86
93
|
if (options.draftDate) title.push(paragraph("General", options.draftDate.slice(0, 10)));
|
|
87
94
|
title.push(paragraph("General", state.lock ? `Scene numbers locked ${String(state.lock.at).slice(0, 10)}.` : "Scene numbers follow the wall's order and are not locked."));
|
|
95
|
+
if (state.revision) title.push(paragraph("General", `${revisionLine(state)}; changed paragraphs are marked.`));
|
|
96
|
+
// The revision set Final Draft shows its marks from (round fourteen, entry 45).
|
|
97
|
+
const revisions = state.revision
|
|
98
|
+
? ` <Revisions ActiveSet="1" Location="7.50" RevisionMode="No" RevisionsShown="Active" ShowAllMarks="No" ShowAllSets="No">\n <Revision Color="${REVISION_HEX[state.revision.color] ?? "#000000"}" FullRevision="No" ID="1" Mark="*" Name="${escapeXml(state.revision.name)}" Style="" />\n </Revisions>\n`
|
|
99
|
+
: "";
|
|
88
100
|
|
|
89
101
|
return (
|
|
90
102
|
`<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n` +
|
|
91
103
|
`<FinalDraft DocumentType="Script" Template="No" Version="5">\n` +
|
|
92
104
|
` <Content>\n${content} </Content>\n` +
|
|
93
105
|
` <TitlePage>\n <Content>\n${title.join("")} </Content>\n </TitlePage>\n` +
|
|
106
|
+
revisions +
|
|
94
107
|
`</FinalDraft>\n`
|
|
95
108
|
);
|
|
96
109
|
}
|
package/src/board/fountain.d.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import type { BoardNote, BoardState } from "./reducer";
|
|
4
4
|
|
|
5
5
|
export declare function sceneHeading(note: BoardNote): string;
|
|
6
|
+
/** A heading split back into its place and its when. */
|
|
7
|
+
export declare function splitHeading(heading: string): { place: string; when: string };
|
|
6
8
|
/** The mark every export sets before an unwritten scene's change line. */
|
|
7
9
|
export declare const UNWRITTEN_MARK: string;
|
|
8
10
|
export declare function standInFor(note: Pick<BoardNote, "change">): string;
|
package/src/board/fountain.js
CHANGED
|
@@ -13,17 +13,29 @@
|
|
|
13
13
|
// except the heading and the action.
|
|
14
14
|
|
|
15
15
|
import { formatPages, boardEighths } from "./reducer.js";
|
|
16
|
-
import {
|
|
16
|
+
import { storyOrder } from "./readWall.js";
|
|
17
|
+
import { revisionLine, revisionMarks } from "./numbering.js";
|
|
17
18
|
|
|
18
19
|
function upper(text) {
|
|
19
20
|
return text.trim().replace(/\s+/g, " ").toUpperCase();
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* A forced scene heading: the place, or the headline when the card has none,
|
|
25
|
+
* and the card's when after a dash — THE PIER AT FENIT - NIGHT (R55).
|
|
26
|
+
*/
|
|
23
27
|
export function sceneHeading(note) {
|
|
24
28
|
const place = typeof note.location === "string" ? note.location.trim() : "";
|
|
29
|
+
const when = typeof note.when === "string" ? note.when.trim() : "";
|
|
25
30
|
const words = place || note.headline || "UNTITLED";
|
|
26
|
-
return `.${upper(words)}`;
|
|
31
|
+
return `.${upper(words)}${when ? ` - ${upper(when)}` : ""}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A heading split back into its place and its when: "THE PIER AT FENIT - NIGHT" → both. */
|
|
35
|
+
export function splitHeading(heading) {
|
|
36
|
+
const match = /^(.*?)\s+-\s+([^-]+)$/.exec(heading ?? "");
|
|
37
|
+
if (!match) return { place: (heading ?? "").trim(), when: "" };
|
|
38
|
+
return { place: match[1].trim(), when: match[2].trim() };
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
/**
|
|
@@ -71,12 +83,14 @@ export function titlePage({ title, credit, author, draftDate, notes }) {
|
|
|
71
83
|
*/
|
|
72
84
|
export function toFountain(state, options = {}) {
|
|
73
85
|
const nameOf = new Map((state.characters ?? []).map((character) => [character.id, character.name]));
|
|
74
|
-
const order =
|
|
86
|
+
const order = storyOrder(state);
|
|
75
87
|
const beats = order.filter((note) => note.rank === "beat").length;
|
|
88
|
+
const revisionOf = revisionMarks(state);
|
|
76
89
|
|
|
77
90
|
const notes = [];
|
|
78
91
|
if (options.premise) notes.push(`Premise: ${options.premise}`);
|
|
79
92
|
if (state.logline) notes.push(`Logline: ${state.logline}`);
|
|
93
|
+
if (state.revision) notes.push(`Revision: ${revisionLine(state)}; a changed scene carries a [[changed in the revision]] note.`);
|
|
80
94
|
notes.push(
|
|
81
95
|
`From the wall: ${order.length} card${order.length === 1 ? "" : "s"}, ${beats} beat${beats === 1 ? "" : "s"}, about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`,
|
|
82
96
|
);
|
|
@@ -104,9 +118,11 @@ export function toFountain(state, options = {}) {
|
|
|
104
118
|
body.push("");
|
|
105
119
|
}
|
|
106
120
|
const marks = [];
|
|
121
|
+
const revisionMarksFor = (_list, card) => Boolean(revisionOf.get(card.id)?.revised);
|
|
107
122
|
const cast = (note.characterIds ?? []).map((id) => nameOf.get(id)).filter(Boolean);
|
|
108
123
|
if (cast.length) marks.push(`with ${cast.join(", ")}`);
|
|
109
124
|
if (note.plants) marks.push("plants something to pay off later");
|
|
125
|
+
if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
|
|
110
126
|
if (marks.length) {
|
|
111
127
|
body.push(`[[${marks.join(" · ")}]]`);
|
|
112
128
|
body.push("");
|
|
@@ -212,7 +228,7 @@ function sameWords(a, b) {
|
|
|
212
228
|
* its text. Returns commands for the kernel, so every door applies the same.
|
|
213
229
|
*/
|
|
214
230
|
export function mergeFountain(state, parsed) {
|
|
215
|
-
const order =
|
|
231
|
+
const order = storyOrder(state);
|
|
216
232
|
const used = new Set();
|
|
217
233
|
const commands = [];
|
|
218
234
|
let cursor = 0; // where in the wall's order the last match was
|
|
@@ -243,6 +259,7 @@ export function mergeFountain(state, parsed) {
|
|
|
243
259
|
const anchor = order[cursor - 1] ?? order.at(-1);
|
|
244
260
|
const headline = scene.synopsis || titleCase(scene.heading);
|
|
245
261
|
const isPlace = scene.forced && Boolean(scene.synopsis);
|
|
262
|
+
const parts = splitHeading(scene.heading);
|
|
246
263
|
const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
|
|
247
264
|
// A marked body is an unwritten scene: its words are the change line, not a page.
|
|
248
265
|
const body = unmark(scene.text);
|
|
@@ -251,7 +268,8 @@ export function mergeFountain(state, parsed) {
|
|
|
251
268
|
id,
|
|
252
269
|
headline,
|
|
253
270
|
change: body.marked ? body.text || "What changes?" : scene.text ? firstSentence(scene.text) : "What changes?",
|
|
254
|
-
location: isPlace ? titleCase(
|
|
271
|
+
location: isPlace ? titleCase(parts.place) : "",
|
|
272
|
+
when: isPlace ? parts.when.toLowerCase() : "",
|
|
255
273
|
text: body.marked ? "" : scene.text,
|
|
256
274
|
x: anchor ? anchor.x + 40 : 140,
|
|
257
275
|
y: anchor ? anchor.y + 40 : 140,
|
package/src/board/markdown.d.ts
CHANGED
|
@@ -12,4 +12,5 @@ export declare function toPlainText(state: BoardState, options?: TakeOptions): s
|
|
|
12
12
|
|
|
13
13
|
export declare const GUTTER: number;
|
|
14
14
|
export declare const COLUMN: Record<"character" | "more" | "parenthetical" | "dialogue", number>;
|
|
15
|
-
|
|
15
|
+
/** A printed line set with spaces; `star` adds a revision's star in the right margin. */
|
|
16
|
+
export declare function setLine(line: Line, star?: boolean): string;
|
package/src/board/markdown.js
CHANGED
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
// It pastes into anything and reads as a script wherever the font is
|
|
18
18
|
// monospaced.
|
|
19
19
|
|
|
20
|
-
import {
|
|
21
|
-
import { sceneHeading, standInFor } from "./fountain.js";
|
|
20
|
+
import { storyOrder } from "./readWall.js";
|
|
21
|
+
import { sceneHeading, standInFor, UNWRITTEN_MARK } from "./fountain.js";
|
|
22
22
|
import { paginate, parseScene, WIDTH } from "./paginate.js";
|
|
23
|
-
import { sceneNumbers } from "./numbering.js";
|
|
23
|
+
import { revisionLine, revisionMarks, sceneNumbers } from "./numbering.js";
|
|
24
24
|
|
|
25
25
|
function upper(text) {
|
|
26
26
|
return text.trim().replace(/\s+/g, " ").toUpperCase();
|
|
@@ -64,11 +64,13 @@ function sceneMarkdown(text) {
|
|
|
64
64
|
* when the project has several boards; options.premise
|
|
65
65
|
*/
|
|
66
66
|
export function toMarkdown(state, options = {}) {
|
|
67
|
-
const order =
|
|
67
|
+
const order = storyOrder(state);
|
|
68
68
|
const numbers = sceneNumbers(order, state.lock);
|
|
69
|
+
const marks = revisionMarks(state);
|
|
69
70
|
const out = [`# ${documentTitle(options)}`, ""];
|
|
70
71
|
if (options.premise) out.push(`*${options.premise}*`, "");
|
|
71
72
|
if (state.logline) out.push(`**${state.logline}**`, "");
|
|
73
|
+
if (state.revision) out.push(`*${revisionLine(state)} · a scene changed since it began has \\* after its heading*`, "");
|
|
72
74
|
let beat = 0;
|
|
73
75
|
for (const note of order) {
|
|
74
76
|
if (note.rank === "beat") {
|
|
@@ -76,12 +78,14 @@ export function toMarkdown(state, options = {}) {
|
|
|
76
78
|
out.push(`## ${beat}. ${note.headline || "Untitled beat"}`, "");
|
|
77
79
|
}
|
|
78
80
|
const heading = sceneHeading(note).slice(1);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
const star = marks.get(note.id)?.revised ? " \\*" : "";
|
|
82
|
+
out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}${star}`.replace(/^### · /, "### "), "");
|
|
83
|
+
// The headline as a synopsis line — not under a beat, whose heading is the headline already.
|
|
84
|
+
if (note.rank !== "beat" && note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
|
|
81
85
|
if (note.text && note.text.trim()) out.push(...sceneMarkdown(note.text));
|
|
82
|
-
// Unwritten: the change line stands in
|
|
83
|
-
//
|
|
84
|
-
else out.push(
|
|
86
|
+
// Unwritten: the change line stands in after the mark in bold, a plain
|
|
87
|
+
// paragraph so it never reads as a second synopsis line (round fourteen, 30).
|
|
88
|
+
else out.push(`**${UNWRITTEN_MARK}**${standInFor(note).slice(UNWRITTEN_MARK.length)}`, "");
|
|
85
89
|
}
|
|
86
90
|
return `${out.join("\n").trimEnd()}\n`;
|
|
87
91
|
}
|
|
@@ -112,8 +116,15 @@ function columnLine(line) {
|
|
|
112
116
|
return text;
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
/** A printed line set with spaces
|
|
116
|
-
export function setLine(line) {
|
|
119
|
+
/** A printed line set with spaces, a revision's star in the right margin when `star`. Exported for the test; the file is `toPlainText`. */
|
|
120
|
+
export function setLine(line, star = false) {
|
|
121
|
+
const set = setLineBare(line);
|
|
122
|
+
if (!star) return set;
|
|
123
|
+
if (line.kind === "heading" && line.sceneNumber !== null && line.sceneNumber !== undefined) return `${set} *`;
|
|
124
|
+
return `${set.padEnd(GUTTER + WIDTH.action + 1)} *`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function setLineBare(line) {
|
|
117
128
|
const text = line.text ?? "";
|
|
118
129
|
switch (line.kind) {
|
|
119
130
|
case "blank":
|
|
@@ -151,8 +162,9 @@ export function setLine(line) {
|
|
|
151
162
|
* when the project has several boards
|
|
152
163
|
*/
|
|
153
164
|
export function toPlainText(state, options = {}) {
|
|
154
|
-
const order =
|
|
165
|
+
const order = storyOrder(state);
|
|
155
166
|
const numbers = sceneNumbers(order, state.lock);
|
|
167
|
+
const marks = revisionMarks(state);
|
|
156
168
|
const result = paginate(
|
|
157
169
|
order.map((note) => ({
|
|
158
170
|
id: note.id,
|
|
@@ -170,10 +182,16 @@ export function toPlainText(state, options = {}) {
|
|
|
170
182
|
} else {
|
|
171
183
|
out.push(centred(upper(title)));
|
|
172
184
|
}
|
|
185
|
+
if (state.revision) out.push("", centred(revisionLine(state).toUpperCase()));
|
|
173
186
|
out.push("", "");
|
|
187
|
+
// Pages run on with no gap: a page turn inside a paragraph is not a blank
|
|
188
|
+
// line in a text file (round fourteen, entry 33).
|
|
174
189
|
for (const page of result.pages) {
|
|
175
|
-
|
|
176
|
-
|
|
190
|
+
for (const line of page.lines) {
|
|
191
|
+
const mark = marks.get(line.noteId);
|
|
192
|
+
const star = Boolean(mark) && (line.kind === "heading" ? mark.revised && mark.lines.size === 0 : typeof line.src === "number" && mark.lines.has(line.src));
|
|
193
|
+
out.push(setLine(line, star));
|
|
194
|
+
}
|
|
177
195
|
}
|
|
178
196
|
return `${out.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd()}\n`;
|
|
179
197
|
}
|
package/src/board/numbering.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Type surface for numbering.js — locked scene numbers and revision marks (Roadmap 2, item 8).
|
|
2
2
|
|
|
3
|
-
import type { BoardNote } from "./reducer";
|
|
3
|
+
import type { BoardNote, BoardState } from "./reducer";
|
|
4
4
|
|
|
5
5
|
export type Lock = { at: string; numbers: Record<string, string> };
|
|
6
6
|
export type Snapshot = { headline: string; change: string; location: string; text: string };
|
|
@@ -11,3 +11,10 @@ export declare function lockFrom(order: ReadonlyArray<Pick<BoardNote, "id">>, ex
|
|
|
11
11
|
export declare function revisedLines(text: string | undefined, snapshotText: string | null | undefined): number[];
|
|
12
12
|
export declare function isRevised(note: BoardNote, snapshot: Snapshot | undefined): boolean;
|
|
13
13
|
export declare const REVISION_COLORS: readonly string[];
|
|
14
|
+
|
|
15
|
+
/** The industry's revision colours as Final Draft writes them. */
|
|
16
|
+
export declare const REVISION_HEX: Record<string, string>;
|
|
17
|
+
/** Card id → whether it changed since the revision's snapshot, and which source lines of its text did. Empty when no revision. */
|
|
18
|
+
export declare function revisionMarks(state: BoardState): Map<string, { revised: boolean; lines: Set<number> }>;
|
|
19
|
+
/** "Blue revision · 2026-09-17", or "" when none. */
|
|
20
|
+
export declare function revisionLine(state: BoardState): string;
|
package/src/board/numbering.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
// Until a draft goes out, scene numbers follow the wall's order. Lock them
|
|
4
4
|
// and they stop moving: every scene keeps the number it had, a scene added
|
|
5
5
|
// between 14 and 15 is 14A (then 14B), one added before the first is A1, and
|
|
6
|
-
// moving cards never renumbers what is locked. A
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// moving cards never renumbers what is locked. A scene added after the lock
|
|
7
|
+
// has no number of its own: its letter is its place between two locked
|
|
8
|
+
// numbers, so it follows the scene if the scene moves (round fourteen, 39).
|
|
9
|
+
// A revision is a name and a colour over a snapshot of the scenes; a line
|
|
10
|
+
// that differs from the snapshot is marked, so the changed lines print in the
|
|
11
|
+
// revision's colour with a star — on the page, in plain text, in Final Draft.
|
|
9
12
|
// All pure: the kernel holds the lock and the snapshot, this module reads them.
|
|
10
13
|
|
|
11
14
|
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
@@ -87,3 +90,33 @@ export function isRevised(note, snapshot) {
|
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
export const REVISION_COLORS = ["white", "blue", "pink", "yellow", "green", "goldenrod", "buff", "salmon", "cherry"];
|
|
93
|
+
|
|
94
|
+
/** The industry's revision colours as Final Draft writes them. */
|
|
95
|
+
export const REVISION_HEX = { white: "#FFFFFF", blue: "#5B8DEF", pink: "#E879A5", yellow: "#D9B400", green: "#4CAF6A", goldenrod: "#C99A1B", buff: "#C9A978", salmon: "#E28466", cherry: "#C0392B" };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What the revision marks on each card, for every export (round fourteen,
|
|
99
|
+
* entry 45: the marks lived only on the page). Card id → { revised, lines }:
|
|
100
|
+
* `revised` when anything on the card differs from the snapshot, `lines` the
|
|
101
|
+
* source lines of its text that do. Empty when no revision is in progress.
|
|
102
|
+
*/
|
|
103
|
+
export function revisionMarks(state) {
|
|
104
|
+
const marks = new Map();
|
|
105
|
+
const revision = state.revision;
|
|
106
|
+
if (!revision) return marks;
|
|
107
|
+
for (const note of state.notes) {
|
|
108
|
+
const snapshot = revision.snapshot?.[note.id];
|
|
109
|
+
const revised = isRevised(note, snapshot);
|
|
110
|
+
marks.set(note.id, { revised, lines: new Set(revised ? revisedLines(note.text, snapshot?.text ?? null) : []) });
|
|
111
|
+
}
|
|
112
|
+
return marks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One line naming the revision for the head of a document: "Blue revision · 2026-09-17". */
|
|
116
|
+
export function revisionLine(state) {
|
|
117
|
+
const revision = state.revision;
|
|
118
|
+
if (!revision) return "";
|
|
119
|
+
const colour = `${revision.color.charAt(0).toUpperCase()}${revision.color.slice(1)}`;
|
|
120
|
+
const name = revision.name && revision.name.toLowerCase() !== revision.color ? ` "${revision.name}"` : "";
|
|
121
|
+
return `${colour} revision${name} · ${String(revision.since).slice(0, 10)}`;
|
|
122
|
+
}
|
package/src/board/organize.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// Pure and DOM-free like the kernel: the app and the MCP server both call it,
|
|
17
17
|
// and it returns poses for apply_poses rather than touching anything.
|
|
18
18
|
|
|
19
|
-
import { readingOrder } from "./readWall.js";
|
|
19
|
+
import { readingOrder, storyOrder } from "./readWall.js";
|
|
20
20
|
import { NOTE_HEIGHT, NOTE_WIDTH } from "./reducer.js";
|
|
21
21
|
|
|
22
22
|
export const ROW_CARDS = 5;
|
|
@@ -36,35 +36,8 @@ const STEP_Y = NOTE_HEIGHT + GAP;
|
|
|
36
36
|
* given (or every card) take part; arrows to cards outside are ignored.
|
|
37
37
|
*/
|
|
38
38
|
export function arrowOrder(state, ids) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const reading = readingOrder(notes).map((note) => note.id);
|
|
42
|
-
const rank = new Map(reading.map((id, index) => [id, index]));
|
|
43
|
-
const preds = new Map(reading.map((id) => [id, []]));
|
|
44
|
-
for (const arrow of state.arrows) {
|
|
45
|
-
if (arrow.kind === "setup") continue;
|
|
46
|
-
if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
|
|
47
|
-
preds.get(arrow.to).push(arrow.from);
|
|
48
|
-
}
|
|
49
|
-
for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
|
|
50
|
-
|
|
51
|
-
const placed = new Set();
|
|
52
|
-
const visiting = new Set();
|
|
53
|
-
const order = [];
|
|
54
|
-
function visit(id) {
|
|
55
|
-
if (placed.has(id) || visiting.has(id)) return;
|
|
56
|
-
visiting.add(id);
|
|
57
|
-
for (const from of preds.get(id)) {
|
|
58
|
-
// A pair pointing both ways is a tie: reading order keeps it.
|
|
59
|
-
if (preds.get(from).includes(id)) continue;
|
|
60
|
-
visit(from);
|
|
61
|
-
}
|
|
62
|
-
visiting.delete(id);
|
|
63
|
-
placed.add(id);
|
|
64
|
-
order.push(id);
|
|
65
|
-
}
|
|
66
|
-
for (const id of reading) visit(id);
|
|
67
|
-
return order;
|
|
39
|
+
// One story order for the whole app: the reading's (R56).
|
|
40
|
+
return storyOrder(state, ids).map((note) => note.id);
|
|
68
41
|
}
|
|
69
42
|
|
|
70
43
|
/** Pull each group's members up to its first member, keeping their order. */
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -66,11 +66,13 @@ export type WallReading = {
|
|
|
66
66
|
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
|
67
67
|
findings: Finding[];
|
|
68
68
|
/** Questions the writer has left, for now: the same question, with when it was left. */
|
|
69
|
-
left: Array<Finding & { since: string }>;
|
|
69
|
+
left: Array<Finding & { since: string; why?: string }>;
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
/** Rows top to bottom, cards left to right within a row. */
|
|
73
73
|
export declare function readingOrder(notes: BoardNote[]): BoardNote[];
|
|
74
|
+
/** Story order: reading order with each follows arrow pulling its source in front of its target (R56). */
|
|
75
|
+
export declare function storyOrder(state: Pick<BoardState, "notes" | "arrows">, ids?: string[]): BoardNote[];
|
|
74
76
|
export declare function readWall(
|
|
75
77
|
state: BoardState,
|
|
76
78
|
options?: {
|
package/src/board/readWall.js
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
// Pure and DOM-free like the reducer, so the Reminders modal, the MCP server and
|
|
10
10
|
// the tests all read the same wall the same way.
|
|
11
11
|
//
|
|
12
|
-
// Order: the wall gives the order, and the wall is free (D20)
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// Order: the wall gives the order, and the wall is free (D20). Reading order is
|
|
13
|
+
// rows top to bottom, cards left to right within a row; story order is reading
|
|
14
|
+
// order with each follows arrow pulling its source in front of its target, the
|
|
15
|
+
// order organize lays the wall out in. Since round fourteen (entry 41) every
|
|
16
|
+
// reading uses story order, so a card wired between two others reads there
|
|
17
|
+
// before any tidy — the arrows are the writer's claim about the order, and
|
|
18
|
+
// where they say nothing the positions decide.
|
|
16
19
|
|
|
17
20
|
import { boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
|
|
18
21
|
|
|
@@ -46,6 +49,46 @@ const FILLER = new Set([
|
|
|
46
49
|
* Cards in reading order: banded into rows by y, then left to right. A free
|
|
47
50
|
* wall has no rows, so this is the order a person's eye takes across it.
|
|
48
51
|
*/
|
|
52
|
+
/**
|
|
53
|
+
* Story order: reading order, with each follows arrow pulling its source in
|
|
54
|
+
* front of its target; a pair pointing both ways is a tie and reading order
|
|
55
|
+
* keeps it. The order organize lays the wall out in, and the order every
|
|
56
|
+
* reading, numbering and page uses (R56). Returns the notes.
|
|
57
|
+
*/
|
|
58
|
+
export function storyOrder(state, ids) {
|
|
59
|
+
const scope = ids ? new Set(ids) : null;
|
|
60
|
+
const notes = state.notes.filter((note) => !scope || scope.has(note.id));
|
|
61
|
+
const reading = readingOrder(notes);
|
|
62
|
+
const byId = new Map(reading.map((note) => [note.id, note]));
|
|
63
|
+
const rank = new Map(reading.map((note, index) => [note.id, index]));
|
|
64
|
+
const preds = new Map(reading.map((note) => [note.id, []]));
|
|
65
|
+
for (const arrow of state.arrows ?? []) {
|
|
66
|
+
if (arrow.kind === "setup") continue;
|
|
67
|
+
if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
|
|
68
|
+
preds.get(arrow.to).push(arrow.from);
|
|
69
|
+
}
|
|
70
|
+
for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
|
|
71
|
+
const placed = new Set();
|
|
72
|
+
const visiting = new Set();
|
|
73
|
+
const order = [];
|
|
74
|
+
function visit(id) {
|
|
75
|
+
if (placed.has(id) || visiting.has(id)) return;
|
|
76
|
+
visiting.add(id);
|
|
77
|
+
for (const from of preds.get(id)) {
|
|
78
|
+
if (preds.get(from).includes(id)) continue;
|
|
79
|
+
visit(from);
|
|
80
|
+
}
|
|
81
|
+
visiting.delete(id);
|
|
82
|
+
placed.add(id);
|
|
83
|
+
order.push(byId.get(id));
|
|
84
|
+
}
|
|
85
|
+
for (const note of reading) visit(note.id);
|
|
86
|
+
return order;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The leading "Day three, night." of a headline, the convention the guide asks for until a card has a when: not a scene's words. */
|
|
90
|
+
const DAY_PREFIX = /^\s*day\s+[\w-]+(?:\s*,\s*[\w\s-]+?)?\s*[.:]\s*/i;
|
|
91
|
+
|
|
49
92
|
export function readingOrder(notes) {
|
|
50
93
|
const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
51
94
|
const rows = [];
|
|
@@ -98,6 +141,14 @@ function pages(eighths) {
|
|
|
98
141
|
return formatPages(Math.round(eighths));
|
|
99
142
|
}
|
|
100
143
|
|
|
144
|
+
/** "1 page", "3 pages", "4/8 of a page" — never "1 pages" (round thirteen, entry 15). */
|
|
145
|
+
function pagesWord(eighths) {
|
|
146
|
+
const n = pages(eighths);
|
|
147
|
+
if (n === "1") return "1 page";
|
|
148
|
+
if (/^\d+\/8$/.test(n)) return `${n} of a page`;
|
|
149
|
+
return `${n} pages`;
|
|
150
|
+
}
|
|
151
|
+
|
|
101
152
|
function quote(note) {
|
|
102
153
|
return `"${note.headline}"`;
|
|
103
154
|
}
|
|
@@ -114,7 +165,7 @@ export function readWall(state, options = {}) {
|
|
|
114
165
|
// People on a card of another board of the project (R51) are cast, and
|
|
115
166
|
// are not asked about here.
|
|
116
167
|
const elsewhere = new Set(Array.isArray(options.elsewhere) ? options.elsewhere : []);
|
|
117
|
-
const order =
|
|
168
|
+
const order = storyOrder(state);
|
|
118
169
|
const beats = order.filter((note) => note.rank === "beat");
|
|
119
170
|
|
|
120
171
|
// Runs: the scene pages strictly between consecutive beats, plus the opening
|
|
@@ -172,14 +223,21 @@ export function readWall(state, options = {}) {
|
|
|
172
223
|
|
|
173
224
|
// The sag detector (R25): one run out of proportion with the others.
|
|
174
225
|
const between = runs.filter((run) => run.from !== null && run.to !== null);
|
|
175
|
-
|
|
226
|
+
// A run of unsized, unwritten cards is the default page each, so a wall
|
|
227
|
+
// where every run is defaults measures nothing but card counts; the sag
|
|
228
|
+
// waits until some card in some run is sized or written (round fourteen, 12).
|
|
229
|
+
const claimed = between.some((run) => run.ids.some((id) => {
|
|
230
|
+
const note = byId.get(id);
|
|
231
|
+
return note && (note.lengthEighths !== null || (note.text ?? "").trim());
|
|
232
|
+
}));
|
|
233
|
+
if (between.length >= 2 && claimed) {
|
|
176
234
|
const typical = median(between.map((run) => run.eighths));
|
|
177
235
|
const longest = between.reduce((top, run) => (run.eighths > top.eighths ? run : top));
|
|
178
236
|
if (typical > 0 && longest.eighths > SAG_RATIO * typical) {
|
|
179
237
|
findings.push({
|
|
180
238
|
kind: "sag",
|
|
181
239
|
ids: [longest.from, longest.to],
|
|
182
|
-
text: `About ${
|
|
240
|
+
text: `About ${pagesWord(longest.eighths)} run between "${headline(longest.from)}" and "${headline(longest.to)}"; the median run here is about ${pagesWord(typical)} (a beat's own pages are in no run). Is something sagging there, or is it one long set piece?`,
|
|
183
241
|
});
|
|
184
242
|
}
|
|
185
243
|
}
|
|
@@ -267,7 +325,9 @@ export function readWall(state, options = {}) {
|
|
|
267
325
|
for (let j = i + 1; j < order.length; j += 1) {
|
|
268
326
|
const a = order[i];
|
|
269
327
|
const b = order[j];
|
|
270
|
-
|
|
328
|
+
// A leading "Day three." is the guide's convention for when a scene
|
|
329
|
+
// happens, not the scene's words (round fourteen, entry 13).
|
|
330
|
+
if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""))) {
|
|
271
331
|
findings.push({
|
|
272
332
|
kind: "duplicate",
|
|
273
333
|
ids: [a.id, b.id],
|
|
@@ -387,7 +447,7 @@ export function readWall(state, options = {}) {
|
|
|
387
447
|
(item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
|
|
388
448
|
);
|
|
389
449
|
if (!entry) return true;
|
|
390
|
-
left.push({ ...finding, since: entry.since });
|
|
450
|
+
left.push({ ...finding, since: entry.since, ...(entry.why ? { why: entry.why } : {}) });
|
|
391
451
|
return false;
|
|
392
452
|
});
|
|
393
453
|
|