plotcoder-board 0.1.14 → 0.1.16
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 +5 -5
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +279 -126
- package/src/board/fdx.js +3 -2
- package/src/board/fountain.d.ts +4 -0
- package/src/board/fountain.js +33 -7
- package/src/board/markdown.d.ts +15 -0
- package/src/board/markdown.js +179 -0
- package/src/board/project.d.ts +2 -0
- package/src/board/project.js +15 -0
- package/src/board/reducer.d.ts +2 -1
- package/src/board/reducer.js +54 -16
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
|
|
|
41
41
|
|
|
42
42
|
- **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
|
|
43
43
|
- **`window.plotcoder`** on the page, for a console or a CDP session.
|
|
44
|
-
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Seventy-
|
|
44
|
+
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Seventy-eight tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place, group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant`, `write_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
|
|
45
45
|
- **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
|
|
46
46
|
|
|
47
47
|
An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
|
|
@@ -51,9 +51,9 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
|
|
|
51
51
|
A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
|
|
52
52
|
else, asked to build a wall and to keep a log of everything that made the job
|
|
53
53
|
harder than it should have been. The friction log is the product; the wall is
|
|
54
|
-
just what produces it.
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
just what produces it. Thirteen rounds have been run, the first three through a
|
|
55
|
+
repo checkout and the rest through the account door; every finding from the
|
|
56
|
+
first twelve is fixed, and round thirteen's six chosen first are too. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
57
57
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
58
58
|
|
|
59
59
|
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
@@ -96,4 +96,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
96
96
|
|
|
97
97
|
## Status
|
|
98
98
|
|
|
99
|
-
Version 0.1.
|
|
99
|
+
Version 0.1.15. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
|
package/package.json
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { TEMPLATES } from "../src/board/templates.js";
|
|
42
42
|
import { wordSentence, wordsAsText } from "../src/board/words.js";
|
|
43
43
|
import { fromFountain, mergeFountain, toFountain } from "../src/board/fountain.js";
|
|
44
|
+
import { toMarkdown, toPlainText } from "../src/board/markdown.js";
|
|
44
45
|
import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
|
|
45
46
|
import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
|
|
46
47
|
import { paginate } from "../src/board/paginate.js";
|
|
@@ -74,6 +75,7 @@ import {
|
|
|
74
75
|
liftCast,
|
|
75
76
|
mergeRoster,
|
|
76
77
|
sameRoster,
|
|
78
|
+
scriptTitles,
|
|
77
79
|
withRoster,
|
|
78
80
|
} from "../src/board/project.js";
|
|
79
81
|
|
|
@@ -698,7 +700,18 @@ async function writeBoard(next, rev, base, boardId = null, roster = "merge") {
|
|
|
698
700
|
const boards = {};
|
|
699
701
|
for (const [id, state] of Object.entries(held.boards)) boards[id] = withRoster(state, project);
|
|
700
702
|
boards[boardId ?? project.activeBoardId] = withRoster(toWrite, project);
|
|
703
|
+
// The order matters on the bridge: the board frame carries the exact roster
|
|
704
|
+
// and lands first, so the wall records one undo step holding the card, the
|
|
705
|
+
// cast and the person together (R33, R51); the project frame that follows
|
|
706
|
+
// then changes nothing. The account door keeps the project first, because
|
|
707
|
+
// its live path composes a board's cast against the project row it holds.
|
|
708
|
+
if (base === ACCOUNT) {
|
|
709
|
+
await writeProject(project, boards, held.rev, held.base);
|
|
710
|
+
return writeBoardRaw(toWrite, rev, base, boardId);
|
|
711
|
+
}
|
|
712
|
+
const live = await writeBoardRaw(toWrite, rev, base, boardId);
|
|
701
713
|
await writeProject(project, boards, held.rev, held.base);
|
|
714
|
+
return live;
|
|
702
715
|
}
|
|
703
716
|
}
|
|
704
717
|
return writeBoardRaw(toWrite, rev, base, boardId);
|
|
@@ -746,6 +759,9 @@ const TRAIL_CAP = 50;
|
|
|
746
759
|
const trail = [];
|
|
747
760
|
/** What undo took back, newest last; a new change of this server's clears it. */
|
|
748
761
|
const undone = [];
|
|
762
|
+
/** The last read_wall's questions, and what changed since: a leave answers the reading in front of the agent (round thirteen, entry 19). */
|
|
763
|
+
let lastReading = null;
|
|
764
|
+
const sinceRead = [];
|
|
749
765
|
|
|
750
766
|
function describeCommand(command) {
|
|
751
767
|
switch (command.type) {
|
|
@@ -787,11 +803,42 @@ async function commit(command) {
|
|
|
787
803
|
|
|
788
804
|
const live = await writeBoard(next, rev, base, boardId, "exact");
|
|
789
805
|
trail.push({ before: state, after: canon(next), what: describeCommand(command) });
|
|
806
|
+
sinceRead.push(describeCommand(command));
|
|
790
807
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
791
808
|
undone.length = 0;
|
|
792
809
|
return { state: next, changed, result, live };
|
|
793
810
|
}
|
|
794
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Several kernel commands as one change. Read once; `build(step, current)` applies
|
|
814
|
+
* each command through the kernel against the running state; write once. One tool
|
|
815
|
+
* call is then one frame on the bridge — one ⌘Z on the wall — and one entry on this
|
|
816
|
+
* server's own trail, however many commands it took: create_note with its cast,
|
|
817
|
+
* set_plant with later, move_scene, an import. Before this, ⌘Z on the wall took the
|
|
818
|
+
* cast off an agent's new card and left the card (R33).
|
|
819
|
+
*/
|
|
820
|
+
async function commitAll(what, build) {
|
|
821
|
+
const { state, rev, base, boardId } = await readBoard();
|
|
822
|
+
let current = state;
|
|
823
|
+
let changed = false;
|
|
824
|
+
const step = (command) => {
|
|
825
|
+
const out = applyCommand(current, command);
|
|
826
|
+
if (out.changed) {
|
|
827
|
+
current = out.state;
|
|
828
|
+
changed = true;
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
};
|
|
832
|
+
const value = await build(step, () => current);
|
|
833
|
+
if (!changed) return { state: current, changed: false, value, live: base !== null };
|
|
834
|
+
const live = await writeBoard(current, rev, base, boardId, "exact");
|
|
835
|
+
trail.push({ before: state, after: canon(current), what });
|
|
836
|
+
sinceRead.push(what);
|
|
837
|
+
if (trail.length > TRAIL_CAP) trail.shift();
|
|
838
|
+
undone.length = 0;
|
|
839
|
+
return { state: current, changed: true, value, live };
|
|
840
|
+
}
|
|
841
|
+
|
|
795
842
|
/** Said once per session: that cards stack until organize (round seven, finding 11). */
|
|
796
843
|
/** Where a new card lands when the agent gives no position: after the last card in reading order, wrapping five wide, so cards never stack (round eleven, finding 14). */
|
|
797
844
|
function nextPlace(state) {
|
|
@@ -1045,22 +1092,37 @@ server.registerTool(
|
|
|
1045
1092
|
{
|
|
1046
1093
|
title: "Set card length",
|
|
1047
1094
|
description:
|
|
1048
|
-
"Set how long cards run, in pages. An ordinary scene is about 1; a quick beat might be 0.25; a set piece might be 3 or 4. This is an estimate the writer owns — set it when you are told a length or when the card plainly describes one, and do not silently re-estimate a card someone has already sized.",
|
|
1095
|
+
"Set how long cards run, in pages. An ordinary scene is about 1; a quick beat might be 0.25; a set piece might be 3 or 4. This is an estimate the writer owns — set it when you are told a length or when the card plainly describes one, and do not silently re-estimate a card someone has already sized. Pass pages \"unsized\" (or 0) to take a length away: the card claims nothing again and reads as about a page, the way a new card does.",
|
|
1049
1096
|
inputSchema: {
|
|
1050
1097
|
ids: z.array(z.string()).min(1),
|
|
1051
|
-
pages:
|
|
1098
|
+
pages: z
|
|
1099
|
+
.union([pagesSchema, z.literal(0), z.literal("unsized"), z.null()])
|
|
1100
|
+
.describe('Pages; a fraction is fine. "unsized" (or 0) takes the length away.'),
|
|
1052
1101
|
},
|
|
1053
1102
|
},
|
|
1054
1103
|
async (args) => {
|
|
1055
|
-
const
|
|
1104
|
+
const unsizing = args.pages === "unsized" || args.pages === 0 || args.pages === null;
|
|
1105
|
+
const { state, changed, result, live } = await commit({
|
|
1056
1106
|
type: "set_length",
|
|
1057
1107
|
ids: args.ids,
|
|
1058
|
-
lengthEighths: toEighths(args.pages),
|
|
1108
|
+
lengthEighths: unsizing ? null : toEighths(args.pages),
|
|
1059
1109
|
});
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1110
|
+
if (!changed) {
|
|
1111
|
+
const these = args.ids.length === 1 ? "the card is" : "those cards are";
|
|
1112
|
+
return ok(
|
|
1113
|
+
unsizing
|
|
1114
|
+
? `Nothing to unsize: ${these} unsized already, or not on the board (list_board for the ids).`
|
|
1115
|
+
: `Nothing changed: ${these} at ${args.pages} page(s) already, or not on the board (list_board for the ids).`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
const runtime = `The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`;
|
|
1119
|
+
if (unsizing) {
|
|
1120
|
+
return ok(
|
|
1121
|
+
`${result.length} card(s) unsized${where(live)}: no length claimed, so each reads as about a page until someone sizes it, and list_board says "unsized". ${runtime}`,
|
|
1122
|
+
result,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return ok(`${result.length} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}. ${runtime}`, result);
|
|
1064
1126
|
},
|
|
1065
1127
|
);
|
|
1066
1128
|
|
|
@@ -1106,45 +1168,47 @@ server.registerTool(
|
|
|
1106
1168
|
},
|
|
1107
1169
|
async (args) => {
|
|
1108
1170
|
const landing = args.x === undefined && args.y === undefined ? nextPlace((await readBoard()).state) : { x: args.x, y: args.y };
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
person
|
|
1136
|
-
|
|
1171
|
+
const names = (args.characters ?? []).map((name) => name.trim()).filter(Boolean);
|
|
1172
|
+
const added = [];
|
|
1173
|
+
// The card, anyone new in its cast, and the casting land as one change, so
|
|
1174
|
+
// one ⌘Z on the wall takes back the whole call and not just the cast.
|
|
1175
|
+
const { value: result, live } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
|
|
1176
|
+
let made = step({
|
|
1177
|
+
type: "create_note",
|
|
1178
|
+
headline: args.headline,
|
|
1179
|
+
change: args.change,
|
|
1180
|
+
// One colour unless the agent chooses: a wall an agent builds in one go
|
|
1181
|
+
// would otherwise stripe through the cycle, and a writer reads a pattern
|
|
1182
|
+
// into it (round four, finding 17). The wall's own new-card button keeps
|
|
1183
|
+
// cycling for a person adding cards by hand.
|
|
1184
|
+
color: args.color ?? "yellow",
|
|
1185
|
+
rank: args.rank,
|
|
1186
|
+
lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
|
|
1187
|
+
plants: args.plants,
|
|
1188
|
+
location: args.location,
|
|
1189
|
+
x: landing.x,
|
|
1190
|
+
y: landing.y,
|
|
1191
|
+
}).result;
|
|
1192
|
+
if (names.length && made?.id) {
|
|
1193
|
+
const ids = [];
|
|
1194
|
+
for (const name of names) {
|
|
1195
|
+
const wanted = name.toLowerCase();
|
|
1196
|
+
let person = current().characters.find((item) => item.id === name) ?? current().characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
1197
|
+
if (!person) {
|
|
1198
|
+
person = step({ type: "add_character", name }).result;
|
|
1199
|
+
if (person) added.push(`${person.name} (${person.id})`);
|
|
1200
|
+
}
|
|
1201
|
+
if (person && !ids.includes(person.id)) ids.push(person.id);
|
|
1202
|
+
}
|
|
1203
|
+
if (ids.length) {
|
|
1204
|
+
const cast = step({ type: "set_cast", ids: [made.id], characterIds: ids });
|
|
1205
|
+
// The card as it is now, cast and all, so the reply's JSON agrees with its prose.
|
|
1206
|
+
made = cast.state.notes.find((note) => note.id === made.id) ?? made;
|
|
1137
1207
|
}
|
|
1138
|
-
if (person) ids.push(person.id);
|
|
1139
|
-
}
|
|
1140
|
-
if (ids.length) {
|
|
1141
|
-
const cast = await commit({ type: "set_cast", ids: [result.id], characterIds: ids });
|
|
1142
|
-
// The card as it is now, cast and all, so the reply's JSON agrees with its prose.
|
|
1143
|
-
const after = cast.state.notes.find((note) => note.id === result.id);
|
|
1144
|
-
if (after) result = after;
|
|
1145
1208
|
}
|
|
1146
|
-
|
|
1147
|
-
}
|
|
1209
|
+
return made;
|
|
1210
|
+
});
|
|
1211
|
+
const castLine = names.length && result?.id ? ` Cast: ${names.join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.` : "";
|
|
1148
1212
|
const landed = [
|
|
1149
1213
|
result?.rank === "beat" ? "a beat" : "a scene",
|
|
1150
1214
|
result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
|
|
@@ -1222,13 +1286,20 @@ server.registerTool(
|
|
|
1222
1286
|
{
|
|
1223
1287
|
title: "Delete note",
|
|
1224
1288
|
description:
|
|
1225
|
-
"Remove a card from the board.
|
|
1289
|
+
"Remove a card from the board. Its arrows go with it and it leaves its group; the reply names each arrow by its cards and says what the group kept, and undo brings all of it back.",
|
|
1226
1290
|
inputSchema: { id: z.string() },
|
|
1227
1291
|
},
|
|
1228
1292
|
async (args) => {
|
|
1229
|
-
const { result } = await commit({ type: "delete_note", id: args.id });
|
|
1293
|
+
const { result, live } = await commit({ type: "delete_note", id: args.id });
|
|
1230
1294
|
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1231
|
-
|
|
1295
|
+
// Say what went with the card (round thirteen, entry 17).
|
|
1296
|
+
const arrows = result.arrows.length
|
|
1297
|
+
? ` Took ${result.arrows.length === 1 ? "its arrow" : `its ${result.arrows.length} arrows`} with it: ${result.arrows.map((arrow) => `"${arrow.fromHeadline}" → "${arrow.toHeadline}" (${arrow.kind})`).join(", ")}.`
|
|
1298
|
+
: " No arrow touched it.";
|
|
1299
|
+
const groups = result.groups
|
|
1300
|
+
.map((group) => (group.dissolved ? ` Its group "${group.title}" dissolved: a frame needs two cards.` : ` Left its group "${group.title}", which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`))
|
|
1301
|
+
.join("");
|
|
1302
|
+
return ok(`Deleted "${result.headline}"${where(live)}.${arrows}${groups}`, result);
|
|
1232
1303
|
},
|
|
1233
1304
|
);
|
|
1234
1305
|
|
|
@@ -1249,6 +1320,8 @@ server.registerTool(
|
|
|
1249
1320
|
const { boards: boardsForRead } = await readProject();
|
|
1250
1321
|
const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1251
1322
|
const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead) });
|
|
1323
|
+
lastReading = { findings: reading.findings };
|
|
1324
|
+
sinceRead.length = 0;
|
|
1252
1325
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
1253
1326
|
const ids = reading.runs[index]?.ids ?? [];
|
|
1254
1327
|
return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
|
|
@@ -1326,7 +1399,7 @@ server.registerTool(
|
|
|
1326
1399
|
{
|
|
1327
1400
|
title: "Leave a question, for now",
|
|
1328
1401
|
description:
|
|
1329
|
-
"Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. The wall keeps
|
|
1402
|
+
"Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. A leave answers the reading in front of you: edits change the questions, so make the writer's changes first, read_wall, then leave what they still want left — a question that changed or went since the last reading is refused, with what it was. The wall keeps a left question and asks it again on its own the moment it would read differently — a card in it changes, a page moves, the median shifts — so a left question is never a dismissal; ask_again brings one back now. Only on the writer's word: never leave a question unasked.",
|
|
1330
1403
|
inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
|
|
1331
1404
|
},
|
|
1332
1405
|
async (args) => {
|
|
@@ -1336,7 +1409,21 @@ server.registerTool(
|
|
|
1336
1409
|
const matches = reading.findings.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1337
1410
|
if (matches.length === 0) {
|
|
1338
1411
|
if (already.length) return ok(`Already left: [${args.kind}] ${already[0].text} It stays left until the question would read differently; ask_again brings it back.`);
|
|
1339
|
-
|
|
1412
|
+
// The reading the agent was answering, when the last read_wall had this question (round thirteen, entry 19).
|
|
1413
|
+
const earlier = lastReading?.findings.find((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1414
|
+
const now = reading.findings.filter((finding) => finding.kind === args.kind);
|
|
1415
|
+
if (earlier) {
|
|
1416
|
+
const since = sinceRead.length
|
|
1417
|
+
? `${sinceRead.length} change${sinceRead.length === 1 ? "" : "s"} landed since (${[...new Set(sinceRead)].join(", ")})`
|
|
1418
|
+
: "the wall changed elsewhere since";
|
|
1419
|
+
const state_ = now.length
|
|
1420
|
+
? `the wall now asks ${now.length === 1 ? "it differently" : `${now.length} questions of that kind`}: ${now.map((finding) => `${finding.text} (ids: ${finding.ids.join(", ")})`).join("; ")}`
|
|
1421
|
+
: "the wall no longer asks it — the cards answered it";
|
|
1422
|
+
return ok(
|
|
1423
|
+
`Not left. When you last read the wall it asked [${earlier.kind}] ${earlier.text}${earlier.ids.length ? ` (ids: ${earlier.ids.join(", ")})` : ""}; ${since}, and ${state_}. A leave answers the reading in front of you: make the writer's edits first, read_wall, then leave what they still want left.`,
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
return ok(`The wall is not asking a question of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""}. read_wall lists the questions it asks now, each with its kind and ids.${sinceRead.length ? ` ${sinceRead.length} change(s) landed since the last read_wall, so read it again first.` : ""}`);
|
|
1340
1427
|
}
|
|
1341
1428
|
if (matches.length > 1) {
|
|
1342
1429
|
return ok(`The wall asks ${matches.length} questions of kind "${args.kind}"; pass ids to say which:\n${matches.map((finding) => ` - ${finding.text} (ids: ${finding.ids.join(", ")})`).join("\n")}`);
|
|
@@ -1392,46 +1479,41 @@ server.registerTool(
|
|
|
1392
1479
|
if (!state.arrows.some(isFollows)) {
|
|
1393
1480
|
return ok("The wall has no follows arrows, so there is no story order to move within: create_arrow the sequence first, or move_note the card by position.");
|
|
1394
1481
|
}
|
|
1395
|
-
const trailBefore = trail.length;
|
|
1396
1482
|
let removed = 0;
|
|
1397
1483
|
let drawn = 0;
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
if (
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1484
|
+
// The whole move — its dozen arrows and the tidy — as one change: one frame on
|
|
1485
|
+
// the bridge, one ⌘Z on the wall, one step for undo here.
|
|
1486
|
+
const { state: final, live } = await commitAll(`move_scene "${card.headline}"`, (step, current) => {
|
|
1487
|
+
const run = (command) => {
|
|
1488
|
+
const done = step(command);
|
|
1489
|
+
if (done.changed) {
|
|
1490
|
+
if (command.type === "delete_arrow") removed += 1;
|
|
1491
|
+
if (command.type === "create_arrow") drawn += 1;
|
|
1492
|
+
}
|
|
1493
|
+
return done;
|
|
1494
|
+
};
|
|
1495
|
+
// Leave: what pointed at the card points at what the card pointed at.
|
|
1496
|
+
const ins = state.arrows.filter((arrow) => isFollows(arrow) && arrow.to === card.id);
|
|
1497
|
+
const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
|
|
1498
|
+
for (const arrow of [...ins, ...outs]) run({ type: "delete_arrow", id: arrow.id });
|
|
1499
|
+
for (const before of ins) for (const after of outs) if (before.from !== after.to) run({ type: "create_arrow", from: before.from, to: after.to, kind: "follows" });
|
|
1500
|
+
// Land: between the target and what followed it (or what led to it).
|
|
1501
|
+
const mid = current();
|
|
1502
|
+
if (args.after) {
|
|
1503
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
|
|
1504
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1505
|
+
run({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
|
|
1506
|
+
}
|
|
1507
|
+
run({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
|
|
1508
|
+
} else {
|
|
1509
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
|
|
1510
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1511
|
+
run({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
|
|
1512
|
+
}
|
|
1513
|
+
run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
|
|
1425
1514
|
}
|
|
1426
|
-
|
|
1427
|
-
}
|
|
1428
|
-
const { state: linked } = await readBoard();
|
|
1429
|
-
const tidied = await step({ type: "apply_poses", poses: organizePoses(linked, {}) });
|
|
1430
|
-
const final = tidied.state;
|
|
1431
|
-
// One step for undo: the whole move, not its dozen arrows.
|
|
1432
|
-
trail.splice(trailBefore);
|
|
1433
|
-
trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
|
|
1434
|
-
undone.length = 0;
|
|
1515
|
+
run({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1516
|
+
});
|
|
1435
1517
|
const order = readingOrder(final.notes);
|
|
1436
1518
|
const group = final.groups.find((item) => item.noteIds.includes(card.id));
|
|
1437
1519
|
const groupLine = group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
|
|
@@ -1605,23 +1687,65 @@ server.registerTool(
|
|
|
1605
1687
|
{
|
|
1606
1688
|
title: "Export the wall as Fountain",
|
|
1607
1689
|
description:
|
|
1608
|
-
"The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
|
|
1690
|
+
"The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action after the mark [Unwritten] until the scene is written. Titled for the project, a one-board film being its project. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
|
|
1609
1691
|
inputSchema: { path: z.string().optional() },
|
|
1610
1692
|
},
|
|
1611
1693
|
async (args) => {
|
|
1612
1694
|
const { state } = await readBoard();
|
|
1613
1695
|
const { project } = await readProject();
|
|
1614
1696
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1697
|
+
const titles = scriptTitles(project, board);
|
|
1698
|
+
const text = toFountain(state, { ...titles, premise: project.premise || undefined, draftDate: new Date().toISOString() });
|
|
1699
|
+
if (args.path) {
|
|
1700
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1701
|
+
fs.writeFileSync(args.path, text);
|
|
1702
|
+
return ok(`Wrote ${text.split("\n").length} lines of Fountain, titled "${titles.title}", to ${args.path}.`);
|
|
1703
|
+
}
|
|
1704
|
+
return ok(text);
|
|
1705
|
+
},
|
|
1706
|
+
);
|
|
1707
|
+
|
|
1708
|
+
server.registerTool(
|
|
1709
|
+
"export_markdown",
|
|
1710
|
+
{
|
|
1711
|
+
title: "Export the wall as Markdown",
|
|
1712
|
+
description:
|
|
1713
|
+
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line in italics after the mark [Unwritten], so a reader can tell a placeholder from a page. Pass a path to write a .md file; otherwise the text comes back.",
|
|
1714
|
+
inputSchema: { path: z.string().optional() },
|
|
1715
|
+
},
|
|
1716
|
+
async (args) => {
|
|
1717
|
+
const { state } = await readBoard();
|
|
1718
|
+
const { project } = await readProject();
|
|
1719
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1720
|
+
const titles = scriptTitles(project, board);
|
|
1721
|
+
const text = toMarkdown(state, { ...titles, premise: project.premise || undefined });
|
|
1722
|
+
if (args.path) {
|
|
1723
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1724
|
+
fs.writeFileSync(args.path, text);
|
|
1725
|
+
return ok(`Wrote ${text.split("\n").length} lines of Markdown, titled "${titles.title}", to ${path.resolve(args.path)}.`);
|
|
1726
|
+
}
|
|
1727
|
+
return ok(text);
|
|
1728
|
+
},
|
|
1729
|
+
);
|
|
1730
|
+
|
|
1731
|
+
server.registerTool(
|
|
1732
|
+
"export_text",
|
|
1733
|
+
{
|
|
1734
|
+
title: "Export the script as plain text",
|
|
1735
|
+
description:
|
|
1736
|
+
"The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten]. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path to write a .txt file; otherwise the text comes back.",
|
|
1737
|
+
inputSchema: { path: z.string().optional() },
|
|
1738
|
+
},
|
|
1739
|
+
async (args) => {
|
|
1740
|
+
const { state } = await readBoard();
|
|
1741
|
+
const { project } = await readProject();
|
|
1742
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1743
|
+
const titles = scriptTitles(project, board);
|
|
1744
|
+
const text = toPlainText(state, titles);
|
|
1621
1745
|
if (args.path) {
|
|
1622
1746
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1623
1747
|
fs.writeFileSync(args.path, text);
|
|
1624
|
-
return ok(`Wrote ${text.split("\n").length} lines of
|
|
1748
|
+
return ok(`Wrote ${text.split("\n").length} lines of plain text, titled "${titles.title}", to ${path.resolve(args.path)}.`);
|
|
1625
1749
|
}
|
|
1626
1750
|
return ok(text);
|
|
1627
1751
|
},
|
|
@@ -1696,8 +1820,10 @@ server.registerTool(
|
|
|
1696
1820
|
const { state } = await readBoard();
|
|
1697
1821
|
const parsed = fromFountain(source);
|
|
1698
1822
|
const { commands, matched } = mergeFountain(state, parsed);
|
|
1699
|
-
|
|
1700
|
-
|
|
1823
|
+
// The whole import as one change, so one undo takes every scene back.
|
|
1824
|
+
const { live } = await commitAll(`import_fountain (${parsed.scenes.length} scene(s))`, (step) => {
|
|
1825
|
+
for (const command of commands) step(command);
|
|
1826
|
+
});
|
|
1701
1827
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1702
1828
|
const created = matched.filter((item) => item.created).length;
|
|
1703
1829
|
return ok(
|
|
@@ -1762,18 +1888,19 @@ server.registerTool(
|
|
|
1762
1888
|
{
|
|
1763
1889
|
title: "Export as Final Draft",
|
|
1764
1890
|
description:
|
|
1765
|
-
"The open board as a Final Draft .fdx: a heading per card with its scene number by wall order, the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action when unwritten, and a title page. Pass a path to write the file; otherwise the XML comes back.",
|
|
1891
|
+
"The open board as a Final Draft .fdx: a heading per card with its scene number by wall order, the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action after the mark [Unwritten] when unwritten, and a title page for the project (a one-board film is its project). Pass a path to write the file; otherwise the XML comes back.",
|
|
1766
1892
|
inputSchema: { path: z.string().optional() },
|
|
1767
1893
|
},
|
|
1768
1894
|
async (args) => {
|
|
1769
1895
|
const { state } = await readBoard();
|
|
1770
1896
|
const { project } = await readProject();
|
|
1771
1897
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1772
|
-
const
|
|
1898
|
+
const titles = scriptTitles(project, board);
|
|
1899
|
+
const xml = toFdx(state, { ...titles, draftDate: new Date().toISOString() });
|
|
1773
1900
|
if (args.path) {
|
|
1774
1901
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1775
1902
|
fs.writeFileSync(args.path, xml);
|
|
1776
|
-
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s) to ${args.path}.`);
|
|
1903
|
+
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}", to ${args.path}.`);
|
|
1777
1904
|
}
|
|
1778
1905
|
return ok(xml);
|
|
1779
1906
|
},
|
|
@@ -1793,8 +1920,10 @@ server.registerTool(
|
|
|
1793
1920
|
const { state } = await readBoard();
|
|
1794
1921
|
const parsed = fromFdx(source);
|
|
1795
1922
|
const { commands, matched } = mergeFountain(state, parsed);
|
|
1796
|
-
|
|
1797
|
-
|
|
1923
|
+
// The whole import as one change, so one undo takes every scene back.
|
|
1924
|
+
const { live } = await commitAll(`import_fdx (${parsed.scenes.length} scene(s))`, (step) => {
|
|
1925
|
+
for (const command of commands) step(command);
|
|
1926
|
+
});
|
|
1798
1927
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1799
1928
|
const created = matched.filter((item) => item.created).length;
|
|
1800
1929
|
const receipt = describeSetAside(parsed.setAside);
|
|
@@ -2107,39 +2236,40 @@ server.registerTool(
|
|
|
2107
2236
|
},
|
|
2108
2237
|
},
|
|
2109
2238
|
async (args) => {
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
});
|
|
2115
|
-
let laterLine = "";
|
|
2239
|
+
// A series plant (R50): the fold pays off on another board of the project.
|
|
2240
|
+
// The kernel cannot check the board exists; this door can, before anything lands.
|
|
2241
|
+
let target = null;
|
|
2242
|
+
let forgetting = false;
|
|
2116
2243
|
if (args.plants && args.later !== undefined) {
|
|
2117
|
-
// A series plant (R50): the fold pays off on another board of the project.
|
|
2118
|
-
// The kernel cannot check the board exists; this door can.
|
|
2119
2244
|
const { project } = await readProject();
|
|
2120
|
-
const {
|
|
2121
|
-
const here = now.notes.filter((note) => args.ids.includes(note.id));
|
|
2245
|
+
const { boardId: current } = await readBoard();
|
|
2122
2246
|
if (args.later.trim() === "") {
|
|
2123
|
-
|
|
2124
|
-
if (cleared.changed) {
|
|
2125
|
-
result = cleared.result;
|
|
2126
|
-
live = cleared.live;
|
|
2127
|
-
changed = true;
|
|
2128
|
-
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
2129
|
-
}
|
|
2247
|
+
forgetting = true;
|
|
2130
2248
|
} else {
|
|
2131
|
-
|
|
2249
|
+
target = findBoard(project, args.later);
|
|
2132
2250
|
if (!target) return ok(`No board called "${args.later}" yet. A fold pays off later on a board of the project: new_board "${args.later}" makes it (empty), open_board back to this one, then set_plant again with later.`);
|
|
2133
2251
|
if (target.id === (current ?? project.activeBoardId)) return ok(`"${target.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to the scene, kind 'setup'.`);
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
// The fold and the board it pays off on land as one change: one ⌘Z on the wall.
|
|
2255
|
+
const { value, live, changed } = await commitAll("set_plant", (step, current) => {
|
|
2256
|
+
let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
|
|
2257
|
+
let laterLine = "";
|
|
2258
|
+
if (forgetting) {
|
|
2259
|
+
const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
|
|
2260
|
+
if (cleared.changed) {
|
|
2261
|
+
result = cleared.result;
|
|
2262
|
+
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
2139
2263
|
}
|
|
2264
|
+
} else if (target) {
|
|
2265
|
+
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
|
|
2266
|
+
if (named.changed) result = named.result;
|
|
2267
|
+
const here = current().notes.filter((note) => args.ids.includes(note.id));
|
|
2140
2268
|
laterLine = ` ${here.length} card(s) pay off later, on "${target.name}": read_wall stops asking where they come back, and the card says so.`;
|
|
2141
2269
|
}
|
|
2142
|
-
|
|
2270
|
+
return { result, laterLine };
|
|
2271
|
+
});
|
|
2272
|
+
const { result, laterLine } = value;
|
|
2143
2273
|
const count = result?.length ?? 0;
|
|
2144
2274
|
if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
|
|
2145
2275
|
return ok(
|
|
@@ -2411,6 +2541,29 @@ server.registerTool(
|
|
|
2411
2541
|
},
|
|
2412
2542
|
);
|
|
2413
2543
|
|
|
2544
|
+
server.registerTool(
|
|
2545
|
+
"add_to_group",
|
|
2546
|
+
{
|
|
2547
|
+
title: "Add cards to a group",
|
|
2548
|
+
description:
|
|
2549
|
+
"Put one or more cards into a group that already exists — the agent's side of dragging a card into a frame. The frame reaches the cards where they are; nothing moves. A card is in one group at a time, so it leaves any other frame on the way, and a frame left with one card dissolves. Needs the group's id and the cards' ids from list_board; organize keeps a group together as a block.",
|
|
2550
|
+
inputSchema: { id: z.string(), noteIds: z.array(z.string()).min(1) },
|
|
2551
|
+
},
|
|
2552
|
+
async (args) => {
|
|
2553
|
+
const { state, changed, result, live } = await commit({ type: "add_to_group", id: args.id, noteIds: args.noteIds });
|
|
2554
|
+
if (!changed) {
|
|
2555
|
+
if (!state.groups.some((group) => group.id === args.id)) return ok(`No group with id ${args.id}. Call list_board for the real ids; create_group makes a new frame.`);
|
|
2556
|
+
const missing = args.noteIds.filter((id) => !state.notes.some((note) => note.id === id));
|
|
2557
|
+
return ok(missing.length ? `Nothing added: not on the board — ${missing.join(", ")}. Call list_board to check the ids.` : "Nothing added: those cards are in that group already.");
|
|
2558
|
+
}
|
|
2559
|
+
const names = result.added.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ");
|
|
2560
|
+
const left = result.left
|
|
2561
|
+
.map((group) => (group.dissolved ? ` "${group.title}" dissolved on the way: a frame needs two cards.` : ` Left "${group.title}", which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`))
|
|
2562
|
+
.join("");
|
|
2563
|
+
return ok(`Added ${names} to "${result.group.title}", which now holds ${result.group.noteIds.length} cards${where(live)}. The frame reaches them where they are; organize lays the group out as a block.${left}`, result);
|
|
2564
|
+
},
|
|
2565
|
+
);
|
|
2566
|
+
|
|
2414
2567
|
server.registerTool(
|
|
2415
2568
|
"rename_group",
|
|
2416
2569
|
{
|
package/src/board/fdx.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { parseScene, TRANSITION } from "./paginate.js";
|
|
14
14
|
import { readingOrder } from "./readWall.js";
|
|
15
|
-
import { sceneHeading } from "./fountain.js";
|
|
15
|
+
import { sceneHeading, standInFor } from "./fountain.js";
|
|
16
16
|
import { sceneNumbers } from "./numbering.js";
|
|
17
17
|
|
|
18
18
|
function escapeXml(text) {
|
|
@@ -60,7 +60,8 @@ export function toFdx(state, options = {}) {
|
|
|
60
60
|
"<Text>",
|
|
61
61
|
`<SceneProperties Length="" Page="" Title="${escapeXml(note.headline)}" />\n <Text>`,
|
|
62
62
|
);
|
|
63
|
-
|
|
63
|
+
// Unwritten: the change line stands in as action, marked as every export marks it.
|
|
64
|
+
const elements = parseScene(note.text && note.text.trim() ? note.text : standInFor(note));
|
|
64
65
|
for (let i = 0; i < elements.length; i += 1) {
|
|
65
66
|
const element = elements[i];
|
|
66
67
|
if (element.kind === "action") content += paragraph("Action", element.text);
|
package/src/board/fountain.d.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import type { BoardNote, BoardState } from "./reducer";
|
|
4
4
|
|
|
5
5
|
export declare function sceneHeading(note: BoardNote): string;
|
|
6
|
+
/** The mark every export sets before an unwritten scene's change line. */
|
|
7
|
+
export declare const UNWRITTEN_MARK: string;
|
|
8
|
+
export declare function standInFor(note: Pick<BoardNote, "change">): string;
|
|
9
|
+
export declare function unmark(text: string | null | undefined): { text: string; marked: boolean };
|
|
6
10
|
|
|
7
11
|
export declare function titlePage(titles: {
|
|
8
12
|
title?: string;
|
package/src/board/fountain.js
CHANGED
|
@@ -26,6 +26,28 @@ export function sceneHeading(note) {
|
|
|
26
26
|
return `.${upper(words)}`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The mark every export sets before an unwritten scene's change line, so a
|
|
31
|
+
* reader can tell a placeholder from a page (round thirteen, entry 27). One
|
|
32
|
+
* mark, the same words in Markdown, plain text, Fountain and Final Draft;
|
|
33
|
+
* coming back in, a body that is the mark and the card's change line is
|
|
34
|
+
* still unwritten.
|
|
35
|
+
*/
|
|
36
|
+
export const UNWRITTEN_MARK = "[Unwritten]";
|
|
37
|
+
|
|
38
|
+
/** What stands in for an unwritten scene's body: the mark, then the change line. */
|
|
39
|
+
export function standInFor(note) {
|
|
40
|
+
const change = (note.change ?? "").trim();
|
|
41
|
+
return change ? `${UNWRITTEN_MARK} ${change}` : UNWRITTEN_MARK;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A body without its mark, and whether it carried one. */
|
|
45
|
+
export function unmark(text) {
|
|
46
|
+
const trimmed = (text ?? "").trim();
|
|
47
|
+
if (!trimmed.startsWith(UNWRITTEN_MARK)) return { text: trimmed, marked: false };
|
|
48
|
+
return { text: trimmed.slice(UNWRITTEN_MARK.length).trim(), marked: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/** The title page block. `titles` is what the writer would put above the script. */
|
|
30
52
|
export function titlePage({ title, credit, author, draftDate, notes }) {
|
|
31
53
|
const lines = [];
|
|
@@ -89,8 +111,9 @@ export function toFountain(state, options = {}) {
|
|
|
89
111
|
body.push(`[[${marks.join(" · ")}]]`);
|
|
90
112
|
body.push("");
|
|
91
113
|
}
|
|
92
|
-
// The scene's text when it is written; the change line stands in until
|
|
93
|
-
|
|
114
|
+
// The scene's text when it is written; the change line stands in until
|
|
115
|
+
// then, marked so a printed page never passes a placeholder off as a scene.
|
|
116
|
+
body.push(note.text && note.text.trim() ? note.text.trim() : standInFor(note));
|
|
94
117
|
body.push("");
|
|
95
118
|
}
|
|
96
119
|
|
|
@@ -206,9 +229,10 @@ export function mergeFountain(state, parsed) {
|
|
|
206
229
|
if (found) {
|
|
207
230
|
used.add(found.id);
|
|
208
231
|
cursor = order.indexOf(found) + 1;
|
|
209
|
-
// A scene whose body is the card's own change line is the
|
|
210
|
-
// unwritten card coming back: still unwritten, not a page.
|
|
211
|
-
const
|
|
232
|
+
// A scene whose body is the mark, or the card's own change line, is the
|
|
233
|
+
// export of an unwritten card coming back: still unwritten, not a page.
|
|
234
|
+
const body = unmark(scene.text);
|
|
235
|
+
const standIn = !(found.text ?? "").trim() && (body.marked || sameWords(body.text, found.change ?? ""));
|
|
212
236
|
if ((found.text ?? "") !== scene.text && !standIn) {
|
|
213
237
|
commands.push({ type: "set_text", id: found.id, text: scene.text });
|
|
214
238
|
}
|
|
@@ -220,13 +244,15 @@ export function mergeFountain(state, parsed) {
|
|
|
220
244
|
const headline = scene.synopsis || titleCase(scene.heading);
|
|
221
245
|
const isPlace = scene.forced && Boolean(scene.synopsis);
|
|
222
246
|
const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
|
|
247
|
+
// A marked body is an unwritten scene: its words are the change line, not a page.
|
|
248
|
+
const body = unmark(scene.text);
|
|
223
249
|
commands.push({
|
|
224
250
|
type: "create_note",
|
|
225
251
|
id,
|
|
226
252
|
headline,
|
|
227
|
-
change: scene.text ? firstSentence(scene.text) : "What changes?",
|
|
253
|
+
change: body.marked ? body.text || "What changes?" : scene.text ? firstSentence(scene.text) : "What changes?",
|
|
228
254
|
location: isPlace ? titleCase(scene.heading) : "",
|
|
229
|
-
text: scene.text,
|
|
255
|
+
text: body.marked ? "" : scene.text,
|
|
230
256
|
x: anchor ? anchor.x + 40 : 140,
|
|
231
257
|
y: anchor ? anchor.y + 40 : 140,
|
|
232
258
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Line } from "./paginate";
|
|
2
|
+
import type { BoardState } from "./reducer";
|
|
3
|
+
|
|
4
|
+
/** What a document carries above the script: the board's name, the project's when it has several boards, the premise. */
|
|
5
|
+
export type TakeOptions = { title?: string; project?: string; premise?: string };
|
|
6
|
+
|
|
7
|
+
/** The wall as Markdown (R54): title, premise, logline, beats as headings, a heading per scene, the text or the change line. */
|
|
8
|
+
export declare function toMarkdown(state: BoardState, options?: TakeOptions): string;
|
|
9
|
+
|
|
10
|
+
/** The script as plain text, set as it prints: Courier's columns kept with spaces, scene numbers in the margins. */
|
|
11
|
+
export declare function toPlainText(state: BoardState, options?: TakeOptions): string;
|
|
12
|
+
|
|
13
|
+
export declare const GUTTER: number;
|
|
14
|
+
export declare const COLUMN: Record<"character" | "more" | "parenthetical" | "dialogue", number>;
|
|
15
|
+
export declare function setLine(line: Line): string;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Take the pages with you (R54): the wall as Markdown, and the script as
|
|
2
|
+
// plain text — for a collaborator who lives in Google Docs rather than in a
|
|
3
|
+
// screenwriting app. Both are pure and shared by every door: the Pages
|
|
4
|
+
// panel's sheet, the console, and the MCP server's export_markdown and
|
|
5
|
+
// export_text.
|
|
6
|
+
//
|
|
7
|
+
// Markdown is the wall read out, in wall order: the board as the title, the
|
|
8
|
+
// premise and the logline under it, beats as second-level headings, one
|
|
9
|
+
// third-level heading per scene from its place (with its scene number, locked
|
|
10
|
+
// or by wall order), the headline as a synopsis line, then the scene's text —
|
|
11
|
+
// set by what each element is, from the same rules the paginator reads by —
|
|
12
|
+
// or, unwritten, its change line. Nothing invisible: Markdown has no notes
|
|
13
|
+
// that print as nothing, so the cast and the fold stay on the wall.
|
|
14
|
+
//
|
|
15
|
+
// Plain text is the script as it prints: the paginator's lines, Courier's
|
|
16
|
+
// columns kept with spaces, scene numbers in both margins, no page numbers.
|
|
17
|
+
// It pastes into anything and reads as a script wherever the font is
|
|
18
|
+
// monospaced.
|
|
19
|
+
|
|
20
|
+
import { readingOrder } from "./readWall.js";
|
|
21
|
+
import { sceneHeading, standInFor } from "./fountain.js";
|
|
22
|
+
import { paginate, parseScene, WIDTH } from "./paginate.js";
|
|
23
|
+
import { sceneNumbers } from "./numbering.js";
|
|
24
|
+
|
|
25
|
+
function upper(text) {
|
|
26
|
+
return text.trim().replace(/\s+/g, " ").toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function documentTitle(options) {
|
|
30
|
+
const title = options.title || "Untitled";
|
|
31
|
+
return options.project && options.project !== title ? `${options.project} · ${title}` : title;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A scene's text as Markdown paragraphs: action as it is, a speech as its cue in bold with the lines hard-broken under it. */
|
|
35
|
+
function sceneMarkdown(text) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const element of parseScene(text)) {
|
|
38
|
+
switch (element.kind) {
|
|
39
|
+
case "break":
|
|
40
|
+
out.push("---", "");
|
|
41
|
+
break;
|
|
42
|
+
case "speech": {
|
|
43
|
+
const lines = [`**${element.name}**`];
|
|
44
|
+
for (const part of element.parts) lines.push(part.kind === "parenthetical" ? `*${part.text}*` : part.text);
|
|
45
|
+
// Two trailing spaces: Markdown's line break, so the cue and its lines stay on their own lines.
|
|
46
|
+
out.push(lines.join(" \n"), "");
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "centered":
|
|
50
|
+
case "transition":
|
|
51
|
+
case "action":
|
|
52
|
+
default:
|
|
53
|
+
out.push(element.text, "");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The whole wall as Markdown.
|
|
61
|
+
*
|
|
62
|
+
* @param state the board
|
|
63
|
+
* @param options.title the board's name; options.project the project's name
|
|
64
|
+
* when the project has several boards; options.premise
|
|
65
|
+
*/
|
|
66
|
+
export function toMarkdown(state, options = {}) {
|
|
67
|
+
const order = readingOrder(state.notes);
|
|
68
|
+
const numbers = sceneNumbers(order, state.lock);
|
|
69
|
+
const out = [`# ${documentTitle(options)}`, ""];
|
|
70
|
+
if (options.premise) out.push(`*${options.premise}*`, "");
|
|
71
|
+
if (state.logline) out.push(`**${state.logline}**`, "");
|
|
72
|
+
let beat = 0;
|
|
73
|
+
for (const note of order) {
|
|
74
|
+
if (note.rank === "beat") {
|
|
75
|
+
beat += 1;
|
|
76
|
+
out.push(`## ${beat}. ${note.headline || "Untitled beat"}`, "");
|
|
77
|
+
}
|
|
78
|
+
const heading = sceneHeading(note).slice(1);
|
|
79
|
+
out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}`.replace(/^### · /, "### "), "");
|
|
80
|
+
if (note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
|
|
81
|
+
if (note.text && note.text.trim()) out.push(...sceneMarkdown(note.text));
|
|
82
|
+
// Unwritten: the change line stands in, marked and in italics, so a reader
|
|
83
|
+
// in Docs can tell the one written scene from sixteen placeholders.
|
|
84
|
+
else out.push(`*${standInFor(note)}*`, "");
|
|
85
|
+
}
|
|
86
|
+
return `${out.join("\n").trimEnd()}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The column the script's body starts at: room for a scene number on the left. */
|
|
90
|
+
export const GUTTER = 5;
|
|
91
|
+
|
|
92
|
+
/** Where each kind of line starts, in characters from the body's left edge — the print stylesheet's columns. */
|
|
93
|
+
export const COLUMN = { character: 22, more: 22, parenthetical: 16, dialogue: 10 };
|
|
94
|
+
|
|
95
|
+
const DUAL_RIGHT = 32;
|
|
96
|
+
const DUAL_CUE = 8;
|
|
97
|
+
|
|
98
|
+
function pad(n) {
|
|
99
|
+
return " ".repeat(Math.max(0, n));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function centred(text, width = WIDTH.action) {
|
|
103
|
+
return pad(GUTTER + Math.floor((width - text.length) / 2)) + text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One line of a dual-dialogue column, from the column's own left edge. */
|
|
107
|
+
function columnLine(line) {
|
|
108
|
+
if (!line || line.kind === "blank") return "";
|
|
109
|
+
const text = line.text ?? "";
|
|
110
|
+
if (line.kind === "character") return pad(DUAL_CUE) + text;
|
|
111
|
+
if (line.kind === "parenthetical") return pad(4) + text;
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A printed line set with spaces. Exported for the test; the file is `toPlainText`. */
|
|
116
|
+
export function setLine(line) {
|
|
117
|
+
const text = line.text ?? "";
|
|
118
|
+
switch (line.kind) {
|
|
119
|
+
case "blank":
|
|
120
|
+
return "";
|
|
121
|
+
case "heading": {
|
|
122
|
+
const number = line.sceneNumber === null || line.sceneNumber === undefined ? "" : String(line.sceneNumber);
|
|
123
|
+
if (!number) return pad(GUTTER) + text;
|
|
124
|
+
return `${number.padEnd(GUTTER)}${text.padEnd(WIDTH.action)} ${number}`.trimEnd();
|
|
125
|
+
}
|
|
126
|
+
case "character":
|
|
127
|
+
case "more":
|
|
128
|
+
case "parenthetical":
|
|
129
|
+
case "dialogue":
|
|
130
|
+
return pad(GUTTER + COLUMN[line.kind]) + text;
|
|
131
|
+
case "transition":
|
|
132
|
+
return pad(GUTTER + WIDTH.action - text.length) + text;
|
|
133
|
+
case "centered":
|
|
134
|
+
return centred(text);
|
|
135
|
+
case "dual": {
|
|
136
|
+
const left = pad(GUTTER) + columnLine(line.left);
|
|
137
|
+
const right = columnLine(line.right);
|
|
138
|
+
return right ? `${left.padEnd(GUTTER + DUAL_RIGHT)}${right}` : left.trimEnd();
|
|
139
|
+
}
|
|
140
|
+
case "action":
|
|
141
|
+
default:
|
|
142
|
+
return pad(GUTTER) + text;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The script as plain text, set as it prints.
|
|
148
|
+
*
|
|
149
|
+
* @param state the board
|
|
150
|
+
* @param options.title the board's name; options.project the project's name
|
|
151
|
+
* when the project has several boards
|
|
152
|
+
*/
|
|
153
|
+
export function toPlainText(state, options = {}) {
|
|
154
|
+
const order = readingOrder(state.notes);
|
|
155
|
+
const numbers = sceneNumbers(order, state.lock);
|
|
156
|
+
const result = paginate(
|
|
157
|
+
order.map((note) => ({
|
|
158
|
+
id: note.id,
|
|
159
|
+
heading: sceneHeading(note).slice(1),
|
|
160
|
+
text: note.text,
|
|
161
|
+
change: standInFor(note),
|
|
162
|
+
written: Boolean(note.text && note.text.trim()),
|
|
163
|
+
number: numbers.get(note.id) ?? undefined,
|
|
164
|
+
})),
|
|
165
|
+
);
|
|
166
|
+
const title = options.title || "Untitled";
|
|
167
|
+
const out = [];
|
|
168
|
+
if (options.project && options.project !== title) {
|
|
169
|
+
out.push(centred(upper(options.project)), "", centred(title));
|
|
170
|
+
} else {
|
|
171
|
+
out.push(centred(upper(title)));
|
|
172
|
+
}
|
|
173
|
+
out.push("", "");
|
|
174
|
+
for (const page of result.pages) {
|
|
175
|
+
if (page.number > 1) out.push("");
|
|
176
|
+
for (const line of page.lines) out.push(setLine(line));
|
|
177
|
+
}
|
|
178
|
+
return `${out.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd()}\n`;
|
|
179
|
+
}
|
package/src/board/project.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export declare function setActiveBoard(project: ProjectRecord, id: string, now?:
|
|
|
67
67
|
export declare function renameProject(project: ProjectRecord, name: string, now?: string): ProjectRecord;
|
|
68
68
|
export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
|
|
69
69
|
export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
|
|
70
|
+
/** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
|
|
71
|
+
export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; project?: string };
|
|
70
72
|
export declare function findBoard(project: ProjectRecord, key: string): BoardMeta | null;
|
|
71
73
|
export declare function reidentifyProject(
|
|
72
74
|
project: ProjectRecord,
|
package/src/board/project.js
CHANGED
|
@@ -289,6 +289,21 @@ export function setPremise(project, premise, now = nowIso()) {
|
|
|
289
289
|
return touch(project, { premise: next }, now);
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/**
|
|
293
|
+
* What a script going out is called (round thirteen, entry 26): a named
|
|
294
|
+
* project is the title — a one-board film is its project — with the board's
|
|
295
|
+
* name beside it only when the project has several boards; an untitled
|
|
296
|
+
* project's board is the title. One rule for Markdown, plain text, Fountain
|
|
297
|
+
* and Final Draft, through every door.
|
|
298
|
+
*/
|
|
299
|
+
export function scriptTitles(project, board) {
|
|
300
|
+
const boardName = (board?.name ?? "").trim() || "Untitled";
|
|
301
|
+
const named = typeof project?.name === "string" && project.name.trim() && project.name !== DEFAULT_PROJECT_NAME;
|
|
302
|
+
if (!named) return { title: boardName };
|
|
303
|
+
if ((project.boards ?? []).length > 1) return { title: boardName, project: project.name };
|
|
304
|
+
return { title: project.name };
|
|
305
|
+
}
|
|
306
|
+
|
|
292
307
|
export function boardById(project, id) {
|
|
293
308
|
return project.boards.find((board) => board.id === id) ?? null;
|
|
294
309
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -139,7 +139,7 @@ export declare function sameName(a: string, b: string): boolean;
|
|
|
139
139
|
export type Command =
|
|
140
140
|
| { type: "set_logline"; logline: string }
|
|
141
141
|
| { type: "set_rank"; ids: string[]; rank: NoteRank }
|
|
142
|
-
| { type: "set_length"; ids: string[]; lengthEighths: number }
|
|
142
|
+
| { type: "set_length"; ids: string[]; lengthEighths: number | null }
|
|
143
143
|
| { type: "set_target"; targetEighths: number }
|
|
144
144
|
| {
|
|
145
145
|
type: "create_note";
|
|
@@ -167,6 +167,7 @@ export type Command =
|
|
|
167
167
|
| { type: "settle_note"; id: string }
|
|
168
168
|
| { type: "create_group"; title?: string; noteIds: string[] }
|
|
169
169
|
| { type: "ungroup"; id: string }
|
|
170
|
+
| { type: "add_to_group"; id: string; noteIds: string[] }
|
|
170
171
|
| { type: "rename_group"; id: string; title: string }
|
|
171
172
|
| { type: "create_arrow"; from: string; to: string; kind?: ArrowKind }
|
|
172
173
|
| { type: "delete_arrow"; id: string }
|
package/src/board/reducer.js
CHANGED
|
@@ -497,11 +497,12 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
497
497
|
case "set_length": {
|
|
498
498
|
const ids = new Set(command.ids);
|
|
499
499
|
if (ids.size === 0) return { state, changed: false };
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
500
|
+
// null unsizes: the card claims nothing again and reads as about a page
|
|
501
|
+
// (round thirteen, entry 16: there was no way back from a length).
|
|
502
|
+
const lengthEighths =
|
|
503
|
+
command.lengthEighths === null
|
|
504
|
+
? null
|
|
505
|
+
: clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS);
|
|
505
506
|
const touched = [];
|
|
506
507
|
const notes = state.notes.map((note) => {
|
|
507
508
|
if (!ids.has(note.id) || note.lengthEighths === lengthEighths) return note;
|
|
@@ -575,23 +576,32 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
575
576
|
}
|
|
576
577
|
|
|
577
578
|
case "delete_note": {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
579
|
+
const gone = state.notes.find((note) => note.id === command.id);
|
|
580
|
+
if (!gone) return { state, changed: false };
|
|
581
|
+
const headlineOf = (id) => state.notes.find((note) => note.id === id)?.headline ?? id;
|
|
581
582
|
const notes = state.notes.filter((note) => note.id !== command.id);
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
583
|
+
const taken = state.arrows.filter((arrow) => arrow.from === command.id || arrow.to === command.id);
|
|
584
|
+
const arrows = state.arrows.filter((arrow) => !taken.includes(arrow));
|
|
585
|
+
const left = [];
|
|
585
586
|
const groups = pruneGroups(
|
|
586
|
-
state.groups.map((group) =>
|
|
587
|
-
|
|
588
|
-
noteIds
|
|
589
|
-
|
|
587
|
+
state.groups.map((group) => {
|
|
588
|
+
if (!group.noteIds.includes(command.id)) return group;
|
|
589
|
+
const noteIds = group.noteIds.filter((id) => id !== command.id);
|
|
590
|
+
left.push({ id: group.id, title: group.title, remaining: noteIds.length, dissolved: noteIds.length < 2 });
|
|
591
|
+
return { ...group, noteIds };
|
|
592
|
+
}),
|
|
590
593
|
);
|
|
594
|
+
// The result says what went with the card, so a door can say it too
|
|
595
|
+
// (round thirteen, entry 17: "Deleted card." and nothing of the arrows).
|
|
591
596
|
return {
|
|
592
597
|
state: { ...state, notes, arrows, groups },
|
|
593
598
|
changed: true,
|
|
594
|
-
result: {
|
|
599
|
+
result: {
|
|
600
|
+
id: command.id,
|
|
601
|
+
headline: gone.headline,
|
|
602
|
+
arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
|
|
603
|
+
groups: left,
|
|
604
|
+
},
|
|
595
605
|
};
|
|
596
606
|
}
|
|
597
607
|
|
|
@@ -672,6 +682,34 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
672
682
|
};
|
|
673
683
|
}
|
|
674
684
|
|
|
685
|
+
case "add_to_group": {
|
|
686
|
+
// The agent's twin of dragging a card into a frame (round thirteen,
|
|
687
|
+
// entry 18). Membership only: the frame reaches the card where it is,
|
|
688
|
+
// and a card leaves any other group on the way, as create_group does.
|
|
689
|
+
const group = state.groups.find((item) => item.id === command.id);
|
|
690
|
+
if (!group) return { state, changed: false };
|
|
691
|
+
const joining = command.noteIds.filter(
|
|
692
|
+
(id, index) =>
|
|
693
|
+
command.noteIds.indexOf(id) === index &&
|
|
694
|
+
!group.noteIds.includes(id) &&
|
|
695
|
+
state.notes.some((note) => note.id === id),
|
|
696
|
+
);
|
|
697
|
+
if (joining.length === 0) return { state, changed: false };
|
|
698
|
+
const idSet = new Set(joining);
|
|
699
|
+
const left = [];
|
|
700
|
+
const groups = pruneGroups(
|
|
701
|
+
state.groups.map((existing) => {
|
|
702
|
+
if (existing.id === group.id) return { ...existing, noteIds: [...existing.noteIds, ...joining] };
|
|
703
|
+
if (!existing.noteIds.some((id) => idSet.has(id))) return existing;
|
|
704
|
+
const noteIds = existing.noteIds.filter((id) => !idSet.has(id));
|
|
705
|
+
left.push({ id: existing.id, title: existing.title, remaining: noteIds.length, dissolved: noteIds.length < 2 });
|
|
706
|
+
return { ...existing, noteIds };
|
|
707
|
+
}),
|
|
708
|
+
);
|
|
709
|
+
const joined = groups.find((item) => item.id === group.id);
|
|
710
|
+
return { state: { ...state, groups }, changed: true, result: { group: joined, added: joining, left } };
|
|
711
|
+
}
|
|
712
|
+
|
|
675
713
|
case "rename_group": {
|
|
676
714
|
let renamed = false;
|
|
677
715
|
const groups = state.groups.map((group) => {
|