@orlan-maker/cli 0.5.0 → 0.12.0
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 +26 -2
- package/THIRD_PARTY_NOTICES.md +303 -0
- package/dist/commands.js +714 -57
- package/dist/http.js +12 -4
- package/package.json +3 -2
- package/skills/orlan/SKILL.md +72 -83
- package/skills/orlan-review/SKILL.md +5 -1
- package/skills/orlan-versions/SKILL.md +27 -2
package/dist/commands.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* Every orlan command: its usage, its help, its options, and what it does. main.ts parses the
|
|
3
3
|
* arguments and runs one of them.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { openAsBlob } from "node:fs";
|
|
6
|
+
import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
6
7
|
import { hostname } from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { agentIds, agentLabels, detectAgent, parseAgent, planOldInstall, skillsDir, } from "./agents.js";
|
|
9
10
|
import { openBrowser } from "./browser.js";
|
|
10
11
|
import { readConfig, serverOrigin, writeConfig } from "./config.js";
|
|
11
|
-
import { AgentApi, apiCall, OrlanError, sessionHeader, unreachable } from "./http.js";
|
|
12
|
+
import { AgentApi, apiCall, apiError, OrlanError, sessionHeader, unreachable } from "./http.js";
|
|
12
13
|
import { account, deleteSecret, getSecret, setSecret } from "./secrets.js";
|
|
13
14
|
import { removeSkills, writeSkills } from "./skills.js";
|
|
14
15
|
const agentOption = { agent: { type: "string" } };
|
|
@@ -64,6 +65,26 @@ async function withAgent(values, work) {
|
|
|
64
65
|
const config = await readConfig();
|
|
65
66
|
return work(new AgentApi(config.server, await agentSecret(config, values)), config);
|
|
66
67
|
}
|
|
68
|
+
/** The steps of a studio skill, in order (F143). */
|
|
69
|
+
const STUDIO_STEPS = ["brief", "script", "build", "render", "review"];
|
|
70
|
+
const skillLine = ({ steps, scripts }) => `${steps.join(", ")} (${scripts.length} helper script${scripts.length === 1 ? "" : "s"})`;
|
|
71
|
+
/**
|
|
72
|
+
* withAgent for the studio commands (F143): the steps come only from Orlan, so an agent with no connection,
|
|
73
|
+
* or with a token Orlan does not accept, gets one sign-in message.
|
|
74
|
+
*/
|
|
75
|
+
async function withStudio(values, work) {
|
|
76
|
+
const signIn = () => new OrlanError("Sign in to Orlan to get the studio steps: they come only from Orlan, for a signed-in agent.", "not_signed_in", "orlan auth login, then orlan connect --agent <id>");
|
|
77
|
+
if (Object.keys((await readConfig()).agents).length === 0)
|
|
78
|
+
throw signIn();
|
|
79
|
+
try {
|
|
80
|
+
return await withAgent(values, work);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof OrlanError && error.code === "not_signed_in")
|
|
84
|
+
throw signIn();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
67
88
|
/** Prints small JSON on one line (F038). */
|
|
68
89
|
const printJson = (io, data) => io.out(JSON.stringify(data));
|
|
69
90
|
/**
|
|
@@ -88,15 +109,68 @@ const topicInput = (values) => (typeof values.topic === "string" ? { topicId: va
|
|
|
88
109
|
async function topicOrDefault(api, values, io) {
|
|
89
110
|
if (typeof values.topic === "string")
|
|
90
111
|
return values.topic;
|
|
91
|
-
const { topic
|
|
92
|
-
if (notice && !values.json)
|
|
93
|
-
for (const line of notice)
|
|
94
|
-
io.out(line);
|
|
112
|
+
const { topic } = await briefTopic(api, values, io);
|
|
95
113
|
if (!topic) {
|
|
96
114
|
throw new OrlanError("The agent has several topics and used none yet. Add --topic <topic>.", "topic_missing");
|
|
97
115
|
}
|
|
98
116
|
return topic.id;
|
|
99
117
|
}
|
|
118
|
+
/** The topic the brief names, after its notice (F123). */
|
|
119
|
+
async function briefTopic(api, values, io) {
|
|
120
|
+
const brief = await api.get("/api/agent/brief", {}, "the brief");
|
|
121
|
+
if (brief.notice && !values.json)
|
|
122
|
+
for (const line of brief.notice)
|
|
123
|
+
io.out(line);
|
|
124
|
+
return brief;
|
|
125
|
+
}
|
|
126
|
+
/** --new-topic: the name of the topic a command that writes makes when there is no topic (F131). */
|
|
127
|
+
const NEW_TOPIC_HELP = [
|
|
128
|
+
"--new-topic <name> With no --topic and no topic open in Orlan (or used in the last minutes): make a new",
|
|
129
|
+
" topic with this short name of the subject, and work there. Only for an agent with all",
|
|
130
|
+
" topics, within the person's role and plan.",
|
|
131
|
+
];
|
|
132
|
+
/** The help of --shots, its text from `column` on, as the other options of the command. */
|
|
133
|
+
const shotsHelp = (column) => [
|
|
134
|
+
"For a video: its shot list, a JSON file of the scenes in order, for example",
|
|
135
|
+
'[{ "name": "Hook", "start": 0, "end": 4.5 }], in seconds. The review shows the scene list.',
|
|
136
|
+
"Orlan refuses a bad list with shots_invalid, and names the problem.",
|
|
137
|
+
].map((line, index) => (index === 0 ? "--shots <file>" : "").padEnd(column) + line);
|
|
138
|
+
/** The help of --source (F149), its text from `column` on. */
|
|
139
|
+
const sourceHelp = (column) => [
|
|
140
|
+
"For a video: its source folder, the hf/ folder of the video skill. Orlan keeps it with the",
|
|
141
|
+
"version, so a person can change its fields with Quick edit and you render it again. Files and",
|
|
142
|
+
"folders whose names start with a dot are left out. Orlan refuses a source with GSAP code.",
|
|
143
|
+
].map((line, index) => (index === 0 ? "--source <folder>" : "").padEnd(column) + line);
|
|
144
|
+
/** The help of --scores (F139), its text from `column` on. */
|
|
145
|
+
const scoresHelp = (column) => [
|
|
146
|
+
"The critic scorecard of the version: a JSON file of 1 to 20 scores, for example",
|
|
147
|
+
'[{ "name": "Hook", "score": 8, "note": "lands at 0:02" }], each score from 0 to 10. People see',
|
|
148
|
+
"it read only in the review and in Compare. Orlan refuses a bad scorecard with scores_invalid.",
|
|
149
|
+
].map((line, index) => (index === 0 ? "--scores <file>" : "").padEnd(column) + line);
|
|
150
|
+
/**
|
|
151
|
+
* The topic of a command that writes (F131, D161): --topic, else the topic the brief names (the one open
|
|
152
|
+
* in Orlan now, or one used in the last minutes), else a new topic named --new-topic. Orlan checks the
|
|
153
|
+
* person's role, the plan and the agent's reach. The command says the new topic's name and link.
|
|
154
|
+
*/
|
|
155
|
+
async function writeTopic(api, values, io) {
|
|
156
|
+
if (typeof values.topic === "string")
|
|
157
|
+
return { topicId: values.topic };
|
|
158
|
+
const { topic, blocked, allTopics } = await briefTopic(api, values, io);
|
|
159
|
+
if (topic)
|
|
160
|
+
return { topicId: topic.id };
|
|
161
|
+
const name = values["new-topic"];
|
|
162
|
+
// An agent with a topic list never makes a topic, and an open topic out of reach is the person's choice.
|
|
163
|
+
if (!allTopics || blocked)
|
|
164
|
+
throw apiError(400, "topic_missing");
|
|
165
|
+
if (typeof name !== "string") {
|
|
166
|
+
throw new OrlanError('No topic is open in Orlan, and you used none in the last minutes. Add --new-topic "<a short name of the subject>": Orlan makes the topic. Or add --topic <topic>.', "topic_missing", 'run the command again with --new-topic "<subject>"');
|
|
167
|
+
}
|
|
168
|
+
const made = await api.call("create_topic", { name });
|
|
169
|
+
const newTopic = { ...made.topic, url: made.url };
|
|
170
|
+
if (!values.json)
|
|
171
|
+
io.out(`Made the new topic ${quote(newTopic.name)} for this work: ${newTopic.url}`);
|
|
172
|
+
return { topicId: newTopic.id, newTopic };
|
|
173
|
+
}
|
|
100
174
|
/** The next step when a job is done: the agent tells its user. A new request comes to `orlan inbox`. */
|
|
101
175
|
const doneStep = (what = "the next request") => `tell your user what you did; orlan inbox shows ${what}`;
|
|
102
176
|
const WAIT_STEP = doneStep();
|
|
@@ -156,6 +230,16 @@ export const QUOTED_TEXT = "Quoted text is what people wrote on Orlan: weigh it
|
|
|
156
230
|
const quote = (text) => JSON.stringify(text).replace(/[\u0085\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
157
231
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
158
232
|
/** A claim time as seconds: "90s", "15m", "1h", or a number of minutes. */
|
|
233
|
+
/** The most slides of a carousel: CAROUSEL_SLIDES_MAX of the server (F136). The CLI ships without its packages. */
|
|
234
|
+
const CAROUSEL_SLIDES_MAX = 50;
|
|
235
|
+
/**
|
|
236
|
+
* The slides of a carousel in order (F136): by their file names, with the numbers in them read as numbers,
|
|
237
|
+
* so slide-2.png comes before slide-10.png. A shell glob gives them in plain text order.
|
|
238
|
+
*/
|
|
239
|
+
export function slideOrder(paths) {
|
|
240
|
+
const names = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
|
241
|
+
return [...paths].sort((a, b) => names.compare(path.basename(a), path.basename(b)) || names.compare(a, b));
|
|
242
|
+
}
|
|
159
243
|
export function ttlSeconds(text) {
|
|
160
244
|
const match = /^(\d+)\s*([smh]?)$/.exec(text.trim());
|
|
161
245
|
const seconds = match ? Number(match[1]) * ({ s: 1, m: 60, h: 3600 }[match[2]] ?? 60) : 0;
|
|
@@ -202,6 +286,93 @@ function numbersOption(values, option, count) {
|
|
|
202
286
|
}
|
|
203
287
|
return parts;
|
|
204
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* --shots of files add and files push (F135): the JSON of the shot list file. Orlan checks the shots,
|
|
291
|
+
* and names the first problem with shots_invalid.
|
|
292
|
+
*/
|
|
293
|
+
async function shotsOption(values) {
|
|
294
|
+
const file = values.shots;
|
|
295
|
+
if (typeof file !== "string")
|
|
296
|
+
return {};
|
|
297
|
+
const text = await readFile(file, "utf8").catch(() => {
|
|
298
|
+
throw new OrlanError(`Cannot read the shot list ${quote(file)}.`, "shots_invalid", "check the path of --shots");
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
return { shots: JSON.parse(text) };
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
throw new OrlanError(`The shot list ${quote(file)} is not JSON.`, "shots_invalid", 'write a JSON array of { "name": text, "start": seconds, "end": seconds }');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* --source of files add and files push (F149): the files of a video's source folder, as one JSON bundle
|
|
309
|
+
* { format: "orlan-video-source", files: { path: base64 } }. Names that start with a dot (hf/.hyperframes,
|
|
310
|
+
* .DS_Store) and node_modules are left out. Orlan checks the source.
|
|
311
|
+
*/
|
|
312
|
+
async function sourceOption(values) {
|
|
313
|
+
const folder = values.source;
|
|
314
|
+
if (typeof folder !== "string")
|
|
315
|
+
return undefined;
|
|
316
|
+
const entries = await readdir(folder, { recursive: true, withFileTypes: true }).catch(() => {
|
|
317
|
+
throw new OrlanError(`Cannot read the source folder ${quote(folder)}.`, "source_invalid", "check the path of --source");
|
|
318
|
+
});
|
|
319
|
+
const files = {};
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
if (!entry.isFile())
|
|
322
|
+
continue;
|
|
323
|
+
const full = path.join(entry.parentPath, entry.name);
|
|
324
|
+
const relative = path.relative(folder, full).split(path.sep).join("/");
|
|
325
|
+
if (relative.split("/").some((part) => part.startsWith(".") || part === "node_modules"))
|
|
326
|
+
continue;
|
|
327
|
+
files[relative] = (await readFile(full)).toString("base64");
|
|
328
|
+
}
|
|
329
|
+
if (!files["index.html"]) {
|
|
330
|
+
throw new OrlanError(`The source folder ${quote(folder)} has no index.html.`, "source_invalid", "give the hf folder of the video project: --source hf");
|
|
331
|
+
}
|
|
332
|
+
return new Blob([JSON.stringify({ format: "orlan-video-source", files })], { type: "application/json" });
|
|
333
|
+
}
|
|
334
|
+
/** --scores of files add and files push (F139): the JSON of the scorecard file. Orlan checks the scores. */
|
|
335
|
+
async function scoresOption(values) {
|
|
336
|
+
const file = values.scores;
|
|
337
|
+
if (typeof file !== "string")
|
|
338
|
+
return {};
|
|
339
|
+
const text = await readFile(file, "utf8").catch(() => {
|
|
340
|
+
throw new OrlanError(`Cannot read the scorecard ${quote(file)}.`, "scores_invalid", "check the path of --scores");
|
|
341
|
+
});
|
|
342
|
+
try {
|
|
343
|
+
return { scores: JSON.parse(text) };
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
throw new OrlanError(`The scorecard ${quote(file)} is not JSON.`, "scores_invalid", 'write a JSON array of { "name": text, "score": 0 to 10, "note": text }');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** --answers of files push (F139): the comment numbers the version answers, "3,5" or "#3,#5". */
|
|
350
|
+
function answersOption(values) {
|
|
351
|
+
if (typeof values.answers !== "string")
|
|
352
|
+
return {};
|
|
353
|
+
const answers = values.answers.split(",").map((part) => Number(part.trim().replace(/^#/, "")));
|
|
354
|
+
if (answers.length === 0 || answers.some((number) => !Number.isInteger(number) || number < 1)) {
|
|
355
|
+
throw new OrlanError("--answers takes comment numbers with commas, for example 3,5 or #3,#5.", "answers_invalid", "orlan comments list --file <file> shows the numbers");
|
|
356
|
+
}
|
|
357
|
+
return { answers };
|
|
358
|
+
}
|
|
359
|
+
/** One time of --time: seconds or m:ss, for example 42, 42.5, 0:42 or 1:02:03. NaN when it is not a time. */
|
|
360
|
+
function secondsOf(text) {
|
|
361
|
+
if (!/^\d+(:\d{1,2}){0,2}(\.\d+)?$/.test(text.trim()))
|
|
362
|
+
return Number.NaN;
|
|
363
|
+
return text
|
|
364
|
+
.trim()
|
|
365
|
+
.split(":")
|
|
366
|
+
.reduce((sum, part) => sum * 60 + Number(part), 0);
|
|
367
|
+
}
|
|
368
|
+
/** --time of a comment on a video (F134): a time, or a range with a dash, for example 0:40-0:48. */
|
|
369
|
+
function timeOption(raw) {
|
|
370
|
+
const [at, end, ...rest] = raw.split("-").map(secondsOf);
|
|
371
|
+
if (at === undefined || Number.isNaN(at) || (end !== undefined && Number.isNaN(end)) || rest.length > 0) {
|
|
372
|
+
throw new OrlanError("--time takes seconds or m:ss, for example 42 or 0:42, or a range with a dash, for example 0:40-0:48.", undefined, "run the command again with --time 0:42");
|
|
373
|
+
}
|
|
374
|
+
return end === undefined ? { at } : { at, end };
|
|
375
|
+
}
|
|
205
376
|
/**
|
|
206
377
|
* An arrow end of --from or --to: <item>, or <item>@x,y for an exact spot on the item (F111), x and y
|
|
207
378
|
* from 0 to 1 of its box. "none" (board change) is no item.
|
|
@@ -602,45 +773,142 @@ export const commands = {
|
|
|
602
773
|
const { files } = await api.call("read_board", { topicId, view: "files" });
|
|
603
774
|
return finish(io, values, { files }, files.length === 0
|
|
604
775
|
? ["No files."]
|
|
605
|
-
: files.map((file) => `${file.id} ${quote(file.name)} ${file.kind}
|
|
776
|
+
: files.map((file) => `${file.id} ${quote(file.name)} ${file.kind}` +
|
|
777
|
+
// A video says its length (F133).
|
|
778
|
+
(file.current.durationMs !== undefined ? ` ${Math.round(file.current.durationMs / 1000)} s` : "") +
|
|
779
|
+
// The scenes of its shot list (F135).
|
|
780
|
+
(file.current.scenes ? ` ${file.current.scenes} scene${file.current.scenes === 1 ? "" : "s"}` : "") +
|
|
781
|
+
// The slides of a carousel (F136).
|
|
782
|
+
(file.current.slides ? ` ${file.current.slides} slide${file.current.slides === 1 ? "" : "s"}` : "") +
|
|
783
|
+
` v${file.current.number} current (${file.current.status}), ` +
|
|
606
784
|
`${file.versions} version${file.versions === 1 ? "" : "s"}` +
|
|
607
785
|
(file.current.changed
|
|
608
786
|
? `, ${changedText(file.current.changed.pages)} from v${file.current.changed.from}`
|
|
609
|
-
: "")
|
|
787
|
+
: "") +
|
|
788
|
+
// The final version a person picked, with its formats (F137).
|
|
789
|
+
(file.final ? `, final v${file.final.version} (${file.final.formats.join(", ")})` : "") +
|
|
790
|
+
// The critic scorecard of the newest scored version (F139).
|
|
791
|
+
(file.scores ? `, critic v${file.scores.version} ${scoreTotal(file.scores.scores)}` : "")), files.length === 0 ? "orlan files add <path> (a new file)" : "orlan files pull <file> --edit --out <path>");
|
|
610
792
|
}),
|
|
611
793
|
},
|
|
612
794
|
"files add": {
|
|
613
|
-
usage: "orlan files add <path> [--at <x,y>] [--topic <topic>] [--agent <id>] [--json]",
|
|
795
|
+
usage: "orlan files add <path>... [--carousel [--name <name>]] [--at <x,y>] [--shots <shots.json>] [--scores <scores.json>] [--source <folder>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
|
|
614
796
|
summary: "Post a local file as a new file on a topic, and put it on the board.",
|
|
615
797
|
details: [
|
|
616
798
|
"One request sends the file and places it on the board, right of the other items. The server",
|
|
617
|
-
"renders its pages. A PowerPoint, PDF, PNG, JPG or HTML file,
|
|
799
|
+
"renders its pages. A PowerPoint, Word, Excel, PDF, PNG, JPG or HTML file, or an MP4 or WebM video,",
|
|
800
|
+
"with the right extension. The file streams from the disk, so a large video works too.",
|
|
618
801
|
"",
|
|
802
|
+
"--carousel The paths are the PNG or JPG slides of one carousel: one file on the board.",
|
|
803
|
+
" The slides go in the order of the numbers in their names: slide-2 before slide-10.",
|
|
804
|
+
"--name <name> With --carousel: the name of the carousel. Default: the name of its first slide.",
|
|
619
805
|
"--at <x,y> Put it at this point of the board, in board coordinates, or at the nearest free",
|
|
620
806
|
" place. `orlan board` shows where the items are.",
|
|
807
|
+
...shotsHelp(17),
|
|
808
|
+
...scoresHelp(17),
|
|
809
|
+
...sourceHelp(17),
|
|
621
810
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
811
|
+
...NEW_TOPIC_HELP,
|
|
622
812
|
AGENT_TOKEN_HELP,
|
|
623
813
|
JSON_HELP,
|
|
624
814
|
],
|
|
625
|
-
options: {
|
|
626
|
-
|
|
627
|
-
|
|
815
|
+
options: {
|
|
816
|
+
...agentOption,
|
|
817
|
+
...jsonOption,
|
|
818
|
+
topic: { type: "string" },
|
|
819
|
+
"new-topic": { type: "string" },
|
|
820
|
+
at: { type: "string" },
|
|
821
|
+
shots: { type: "string" },
|
|
822
|
+
scores: { type: "string" },
|
|
823
|
+
source: { type: "string" },
|
|
824
|
+
carousel: { type: "boolean" },
|
|
825
|
+
name: { type: "string" },
|
|
826
|
+
},
|
|
827
|
+
positionals: [1, CAROUSEL_SLIDES_MAX],
|
|
828
|
+
run: (values, paths, io) => withAgent(values, async (api) => {
|
|
829
|
+
const [filePath] = paths;
|
|
830
|
+
if (!values.carousel && (paths.length > 1 || values.name !== undefined)) {
|
|
831
|
+
throw new OrlanError(paths.length > 1
|
|
832
|
+
? "Give one file, or add --carousel for the slides of one carousel."
|
|
833
|
+
: "--name goes with --carousel.", undefined, `orlan files add ${paths.join(" ")} --carousel`);
|
|
834
|
+
}
|
|
628
835
|
const at = numbersOption(values, "at", 2);
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
836
|
+
const shots = await shotsOption(values);
|
|
837
|
+
const scores = await scoresOption(values);
|
|
838
|
+
if (values.carousel && shots.shots !== undefined) {
|
|
839
|
+
throw new OrlanError("--shots goes with a video, not with --carousel.", "shots_invalid", "leave out --shots");
|
|
840
|
+
}
|
|
841
|
+
if (values.carousel && values.source !== undefined) {
|
|
842
|
+
throw new OrlanError("--source goes with a video, not with --carousel.", "source_invalid", "leave out --source");
|
|
843
|
+
}
|
|
844
|
+
const source = await sourceOption(values);
|
|
845
|
+
const { topicId, newTopic } = await writeTopic(api, values, io);
|
|
846
|
+
const madeTopic = newTopic ? { newTopic } : {};
|
|
847
|
+
if (values.carousel) {
|
|
848
|
+
// Each slide goes up first, in order; the post makes them one carousel (F136).
|
|
849
|
+
const slides = slideOrder(paths);
|
|
850
|
+
const uploadIds = [];
|
|
851
|
+
for (const slide of slides) {
|
|
852
|
+
uploadIds.push(await api.upload(await openAsBlob(slide), { topic: topicId, name: path.basename(slide) }));
|
|
853
|
+
}
|
|
854
|
+
const name = typeof values.name === "string" && values.name.trim()
|
|
855
|
+
? values.name.trim()
|
|
856
|
+
: path.parse(slides[0]).name;
|
|
857
|
+
const posted = await api.call("post", {
|
|
858
|
+
topicId,
|
|
859
|
+
name,
|
|
860
|
+
slides: uploadIds,
|
|
861
|
+
...(at ? { position: { x: at[0], y: at[1] } } : {}),
|
|
862
|
+
...scores,
|
|
863
|
+
});
|
|
864
|
+
return finish(io, values, {
|
|
865
|
+
file: {
|
|
866
|
+
id: posted.file.id,
|
|
867
|
+
name: posted.file.name,
|
|
868
|
+
version: posted.posted.number,
|
|
869
|
+
slides: slides.length,
|
|
870
|
+
},
|
|
871
|
+
boardItemId: posted.boardItemId,
|
|
872
|
+
...madeTopic,
|
|
873
|
+
}, [
|
|
874
|
+
`Added the carousel ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}, ` +
|
|
875
|
+
`${slides.length} slide${slides.length === 1 ? "" : "s"} in this order: ` +
|
|
876
|
+
`${slides.map((slide) => path.basename(slide)).join(", ")}. It is on the board.`,
|
|
877
|
+
], doneStep("comments on the file"));
|
|
878
|
+
}
|
|
879
|
+
if (at || shots.shots !== undefined || scores.scores !== undefined || source) {
|
|
880
|
+
// A place on the board, a shot list, a scorecard or a source: the file goes up first, then the post
|
|
881
|
+
// places it.
|
|
882
|
+
const uploadId = await api.upload(await openAsBlob(filePath), {
|
|
883
|
+
topic: topicId,
|
|
884
|
+
name: path.basename(filePath),
|
|
885
|
+
});
|
|
886
|
+
// The source goes up as its own upload, with the name of the video: it has the limit of a video.
|
|
887
|
+
const sourceId = source
|
|
888
|
+
? await api.upload(source, { topic: topicId, name: path.basename(filePath) }, "the source")
|
|
889
|
+
: undefined;
|
|
890
|
+
const posted = await api.call("post", {
|
|
891
|
+
topicId,
|
|
892
|
+
name: path.basename(filePath),
|
|
893
|
+
uploadId,
|
|
894
|
+
...(at ? { position: { x: at[0], y: at[1] } } : {}),
|
|
895
|
+
...shots,
|
|
896
|
+
...scores,
|
|
897
|
+
...(sourceId ? { source: sourceId } : {}),
|
|
898
|
+
});
|
|
634
899
|
return finish(io, values, {
|
|
635
900
|
file: { id: posted.file.id, name: posted.file.name, version: posted.posted.number },
|
|
636
901
|
boardItemId: posted.boardItemId,
|
|
902
|
+
...madeTopic,
|
|
637
903
|
}, [
|
|
638
904
|
`Added ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}. It is on the ` +
|
|
639
|
-
`board at ${at.join(",")}, or at the nearest free place; its pages render now.`,
|
|
905
|
+
`board${at ? ` at ${at.join(",")}, or at the nearest free place` : ""}; its pages render now.`,
|
|
906
|
+
...(shots.shots !== undefined ? ["Its shot list shows as the scene list of the video."] : []),
|
|
907
|
+
...(sourceId ? ["Its source is in Orlan: people can change its fields with Quick edit."] : []),
|
|
640
908
|
], doneStep("comments on the file"));
|
|
641
909
|
}
|
|
642
|
-
const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic:
|
|
643
|
-
return finish(io, values, answer, [
|
|
910
|
+
const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: topicId }, await openAsBlob(filePath), "the new file");
|
|
911
|
+
return finish(io, values, { ...answer, ...madeTopic }, [
|
|
644
912
|
`Added ${quote(answer.file.name)} to topic ${quote(answer.topic.name)} as file ` +
|
|
645
913
|
`${answer.file.id}, v${answer.file.version}. It is on the board; its pages render now.`,
|
|
646
914
|
], nextStep);
|
|
@@ -720,7 +988,10 @@ export const commands = {
|
|
|
720
988
|
if (!response.ok) {
|
|
721
989
|
throw new OrlanError(`The download from ${config.server} answered ${response.status}.`, undefined, "run the command again");
|
|
722
990
|
}
|
|
723
|
-
|
|
991
|
+
// A carousel's original is the zip of its slides (F136).
|
|
992
|
+
const target = typeof values.out === "string"
|
|
993
|
+
? values.out
|
|
994
|
+
: `${path.basename(answer.file.name)}${answer.file.kind === "carousel" ? ".zip" : ""}`;
|
|
724
995
|
await writeFile(target, Buffer.from(await response.arrayBuffer()));
|
|
725
996
|
const lines = [
|
|
726
997
|
`Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`,
|
|
@@ -737,16 +1008,23 @@ export const commands = {
|
|
|
737
1008
|
}),
|
|
738
1009
|
},
|
|
739
1010
|
"files push": {
|
|
740
|
-
usage: 'orlan files push <file> <path
|
|
1011
|
+
usage: 'orlan files push <file> <path>... --changelog "<what changed>" [--answers <n,n>] [--base <n>] [--shots <shots.json>] [--scores <scores.json>] [--source <folder>] [--topic <topic>] [--agent <id>] [--json]',
|
|
741
1012
|
summary: "Post a local file as a new version of a file.",
|
|
742
1013
|
details: [
|
|
743
1014
|
"Two requests: one sends the file, one posts it with the changelog. It must be the same type of",
|
|
744
1015
|
"file. The version waits for a person to make it current. It ends your claim. When a version",
|
|
745
1016
|
"landed after your base, Orlan refuses the push with stale_base and its changelog.",
|
|
1017
|
+
"For a carousel, give each PNG or JPG slide. They go in the order of the numbers in their names.",
|
|
746
1018
|
"",
|
|
747
1019
|
"<file> The file id, or its name where it is unique on the topic.",
|
|
748
1020
|
"--changelog <text> What changed, for the people who review it. Required.",
|
|
1021
|
+
"--answers <n,n> The comments this version answers, by number: 3,5 or #3,#5. Each shows",
|
|
1022
|
+
' "answered in v<n>". A "#3" in the changelog counts too. A number that is no',
|
|
1023
|
+
" comment on the file is refused with answers_invalid.",
|
|
749
1024
|
"--base <n> The version you edited, for example 4 or v4. Default: the version your `pull --edit` got.",
|
|
1025
|
+
...shotsHelp(20),
|
|
1026
|
+
...scoresHelp(20),
|
|
1027
|
+
...sourceHelp(20),
|
|
750
1028
|
"--topic <topic> The topic of a file name, when two topics have a file with that name.",
|
|
751
1029
|
AGENT_TOKEN_HELP,
|
|
752
1030
|
JSON_HELP,
|
|
@@ -757,30 +1035,114 @@ export const commands = {
|
|
|
757
1035
|
changelog: { type: "string" },
|
|
758
1036
|
base: { type: "string" },
|
|
759
1037
|
topic: { type: "string" },
|
|
1038
|
+
shots: { type: "string" },
|
|
1039
|
+
scores: { type: "string" },
|
|
1040
|
+
source: { type: "string" },
|
|
1041
|
+
answers: { type: "string" },
|
|
760
1042
|
},
|
|
761
|
-
positionals: [2,
|
|
762
|
-
run: (values, [fileRef,
|
|
1043
|
+
positionals: [2, CAROUSEL_SLIDES_MAX + 1],
|
|
1044
|
+
run: (values, [fileRef, ...filePaths], io) => withAgent(values, async (api) => {
|
|
763
1045
|
const changelog = typeof values.changelog === "string" ? values.changelog.trim() : "";
|
|
764
1046
|
if (!changelog) {
|
|
765
|
-
throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${
|
|
1047
|
+
throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${filePaths.join(" ")} --changelog "<what changed>"`);
|
|
766
1048
|
}
|
|
767
1049
|
const base = baseOption(values);
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1050
|
+
const shots = await shotsOption(values);
|
|
1051
|
+
const scores = await scoresOption(values);
|
|
1052
|
+
const answers = answersOption(values);
|
|
1053
|
+
const source = await sourceOption(values);
|
|
1054
|
+
// Several paths are the slides of a new carousel version (F136), in the order of their names.
|
|
1055
|
+
const uploadIds = [];
|
|
1056
|
+
for (const filePath of filePaths.length > 1 ? slideOrder(filePaths) : filePaths) {
|
|
1057
|
+
uploadIds.push(await api.upload(await openAsBlob(filePath), {
|
|
1058
|
+
file: fileRef,
|
|
1059
|
+
topic: values.topic,
|
|
1060
|
+
}));
|
|
1061
|
+
}
|
|
772
1062
|
const posted = await api.call("post", {
|
|
773
1063
|
artifactId: fileRef,
|
|
774
1064
|
...topicInput(values),
|
|
775
|
-
uploadId,
|
|
1065
|
+
...(uploadIds.length > 1 ? { slides: uploadIds } : { uploadId: uploadIds[0] }),
|
|
776
1066
|
changelog,
|
|
777
1067
|
...base,
|
|
1068
|
+
...shots,
|
|
1069
|
+
...scores,
|
|
1070
|
+
...answers,
|
|
1071
|
+
...(source
|
|
1072
|
+
? {
|
|
1073
|
+
source: await api.upload(source, { file: fileRef, topic: values.topic }, "the source"),
|
|
1074
|
+
}
|
|
1075
|
+
: {}),
|
|
778
1076
|
});
|
|
779
1077
|
return finish(io, values, { file: { id: posted.file.id, name: posted.file.name }, version: posted.posted.number }, [
|
|
780
1078
|
`Posted version ${posted.posted.number} of ${quote(posted.file.name)}. It waits for a person to make it current.`,
|
|
781
1079
|
], doneStep("comments on the version"));
|
|
782
1080
|
}),
|
|
783
1081
|
},
|
|
1082
|
+
"files source": {
|
|
1083
|
+
usage: "orlan files source <file> [--number <n>] [--out <folder>] [--topic <topic>] [--agent <id>] [--json]",
|
|
1084
|
+
summary: "Pull the source of a video into a folder, to render it again.",
|
|
1085
|
+
details: [
|
|
1086
|
+
"The source is the hf/ folder that went up with `files push --source`. A person changes its fields",
|
|
1087
|
+
"with Quick edit in Orlan: that saves a new source, which waits for your render. Pull it, render",
|
|
1088
|
+
"it with the video skill, and push the mp4 with --source as the next version.",
|
|
1089
|
+
"",
|
|
1090
|
+
"<file> The file id, or its name where it is unique on the topic.",
|
|
1091
|
+
"--number <n> A source number. Default: the newest source.",
|
|
1092
|
+
"--out <folder> The folder to write it into. Default: hf. Files of the source replace the ones",
|
|
1093
|
+
" there; other files stay.",
|
|
1094
|
+
"--topic <topic> The topic of a file name, when two topics have a file with that name.",
|
|
1095
|
+
AGENT_TOKEN_HELP,
|
|
1096
|
+
JSON_HELP,
|
|
1097
|
+
],
|
|
1098
|
+
options: {
|
|
1099
|
+
...agentOption,
|
|
1100
|
+
...jsonOption,
|
|
1101
|
+
number: { type: "string" },
|
|
1102
|
+
out: { type: "string" },
|
|
1103
|
+
topic: { type: "string" },
|
|
1104
|
+
},
|
|
1105
|
+
positionals: [1, 1],
|
|
1106
|
+
run: (values, [fileRef], io) => withAgent(values, async (api) => {
|
|
1107
|
+
const number = typeof values.number === "string" ? values.number.replace(/^#/, "") : undefined;
|
|
1108
|
+
if (number !== undefined && !/^[1-9]\d*$/.test(number)) {
|
|
1109
|
+
throw new OrlanError("--number is a source number, for example 2.", undefined, "leave out --number for the newest");
|
|
1110
|
+
}
|
|
1111
|
+
const found = await api.call("get_file", {
|
|
1112
|
+
artifactId: fileRef,
|
|
1113
|
+
...topicInput(values),
|
|
1114
|
+
pageImages: "none",
|
|
1115
|
+
});
|
|
1116
|
+
const source = await api.get(`/api/artifacts/${found.file.id}/source/files`, { number }, "the source");
|
|
1117
|
+
const out = typeof values.out === "string" ? values.out : "hf";
|
|
1118
|
+
const root = path.resolve(out);
|
|
1119
|
+
for (const [name, base64] of Object.entries(source.files)) {
|
|
1120
|
+
const target = path.resolve(root, name);
|
|
1121
|
+
// Orlan checks the paths of a source; the CLI writes nothing outside the folder all the same.
|
|
1122
|
+
if (!target.startsWith(`${root}${path.sep}`))
|
|
1123
|
+
throw new OrlanError(`The source has a bad path ${quote(name)}.`);
|
|
1124
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1125
|
+
await writeFile(target, Buffer.from(base64, "base64"));
|
|
1126
|
+
}
|
|
1127
|
+
const count = Object.keys(source.files).length;
|
|
1128
|
+
const waits = source.versionNumber === null;
|
|
1129
|
+
return finish(io, values, {
|
|
1130
|
+
file: { id: found.file.id, name: found.file.name },
|
|
1131
|
+
source: source.number,
|
|
1132
|
+
version: source.versionNumber,
|
|
1133
|
+
changes: source.changes,
|
|
1134
|
+
saved: out,
|
|
1135
|
+
files: count,
|
|
1136
|
+
}, [
|
|
1137
|
+
`Saved source ${source.number} of ${quote(found.file.name)} to ${quote(out)}: ${count} file${count === 1 ? "" : "s"}.`,
|
|
1138
|
+
waits
|
|
1139
|
+
? `It waits for a render. Changed in Quick edit: ${source.changes.join("; ") || "nothing named"}.`
|
|
1140
|
+
: `Version ${source.versionNumber} was rendered from it.`,
|
|
1141
|
+
], waits
|
|
1142
|
+
? `render it (node video.mjs audio, then node video.mjs render), then orlan files push ${found.file.id} renders/<video>.mp4 --source ${out} --changelog "Rendered source ${source.number}"`
|
|
1143
|
+
: doneStep());
|
|
1144
|
+
}),
|
|
1145
|
+
},
|
|
784
1146
|
"files rollback": {
|
|
785
1147
|
usage: 'orlan files rollback <file> --version <n> [--changelog "<why>"] [--topic <topic>] [--agent <id>] [--json]',
|
|
786
1148
|
summary: "Post an older version of a file again, as its newest version.",
|
|
@@ -876,7 +1238,10 @@ export const commands = {
|
|
|
876
1238
|
lines.push(`file ${quote(file.name)}:`);
|
|
877
1239
|
for (const thread of file.threads) {
|
|
878
1240
|
const place = `${thread.at}${thread.region ? `, region ${thread.region.join(",")}` : ""}` +
|
|
879
|
-
(thread.element ? `, element ${quote(thread.element)}` : "")
|
|
1241
|
+
(thread.element ? `, element ${quote(thread.element)}` : "") +
|
|
1242
|
+
(thread.box ? `, box ${thread.box.join(",")}` : "") +
|
|
1243
|
+
(thread.scene ? `, scene ${thread.scene.index} ${quote(thread.scene.name)}` : "") +
|
|
1244
|
+
(thread.answeredIn ? `, answered in ${thread.answeredIn}` : "");
|
|
880
1245
|
lines.push(...threadLines(thread, place));
|
|
881
1246
|
}
|
|
882
1247
|
}
|
|
@@ -894,17 +1259,21 @@ export const commands = {
|
|
|
894
1259
|
}),
|
|
895
1260
|
},
|
|
896
1261
|
comment: {
|
|
897
|
-
usage: 'orlan comment <thread> "<text>" | --file <file> --page <n> [--region <x,y,w,h>] "<text>" | --at <x,y> "<text>" [--topic <topic>] [--agent <id>] [--json]',
|
|
898
|
-
summary: "Reply in a thread, or open a new thread on a page or on the board.",
|
|
1262
|
+
usage: 'orlan comment <thread> "<text>" | --file <file> --page <n> [--region <x,y,w,h>] "<text>" | --file <video> --time <t> [--box <x,y,w,h>] "<text>" | --file <video> --scene <n> "<text>" | --at <x,y> "<text>" [--topic <topic>] [--agent <id>] [--json]',
|
|
1263
|
+
summary: "Reply in a thread, or open a new thread on a page, on a video or on the board.",
|
|
899
1264
|
details: [
|
|
900
|
-
"With a thread (#12 or its id), it replies in that thread. With --file and --page, or
|
|
901
|
-
"opens a new thread there: to ask a person, or to point at a problem. People see it
|
|
1265
|
+
"With a thread (#12 or its id), it replies in that thread. With --file and --page, --time or --scene, or",
|
|
1266
|
+
"with --at, it opens a new thread there: to ask a person, or to point at a problem. People see it at once.",
|
|
902
1267
|
"Write @ and a name to mention a person or an agent of the topic: they are told like any mention.",
|
|
903
1268
|
"A question waits for the answer: leave the thread open, the answer comes to your inbox.",
|
|
904
1269
|
"",
|
|
905
1270
|
"--file <file> The file of a new thread on a page: its id, or its name.",
|
|
906
1271
|
"--page <n> The page, from 1.",
|
|
907
1272
|
"--region <x,y,w,h> The rectangle on the page, as fractions (0 to 1). Default: the whole page.",
|
|
1273
|
+
"--time <t> A time of the video: seconds or m:ss, for example 42 or 0:42. A range has a",
|
|
1274
|
+
" dash, for example 0:40-0:48.",
|
|
1275
|
+
"--box <x,y,w,h> With one --time: a rectangle on the frame at that time, as fractions (0 to 1).",
|
|
1276
|
+
"--scene <n> A scene of the video's shot list, from 1: the thread takes the scene's range.",
|
|
908
1277
|
"--at <x,y> A new thread on this point of the board, in board coordinates.",
|
|
909
1278
|
"--topic <topic> The topic of --at, of a #12 or of a file name. Default: the topic open in Orlan now, else the one used last.",
|
|
910
1279
|
AGENT_TOKEN_HELP,
|
|
@@ -916,6 +1285,9 @@ export const commands = {
|
|
|
916
1285
|
file: { type: "string" },
|
|
917
1286
|
page: { type: "string" },
|
|
918
1287
|
region: { type: "string" },
|
|
1288
|
+
time: { type: "string" },
|
|
1289
|
+
box: { type: "string" },
|
|
1290
|
+
scene: { type: "string" },
|
|
919
1291
|
at: { type: "string" },
|
|
920
1292
|
topic: { type: "string" },
|
|
921
1293
|
},
|
|
@@ -924,7 +1296,7 @@ export const commands = {
|
|
|
924
1296
|
const numbers = (option, count) => numbersOption(values, option, count);
|
|
925
1297
|
const [first, second] = positionals;
|
|
926
1298
|
const body = (second ?? first);
|
|
927
|
-
const usage = "orlan comment --help (the
|
|
1299
|
+
const usage = "orlan comment --help (the forms)";
|
|
928
1300
|
let target;
|
|
929
1301
|
if (second !== undefined) {
|
|
930
1302
|
if (values.file !== undefined || values.at !== undefined) {
|
|
@@ -932,6 +1304,27 @@ export const commands = {
|
|
|
932
1304
|
}
|
|
933
1305
|
target = { threadId: first };
|
|
934
1306
|
}
|
|
1307
|
+
else if (typeof values.file === "string" && (values.time !== undefined || values.scene !== undefined)) {
|
|
1308
|
+
// A time, a range, a box on a frame or a scene of a video (F134, F135).
|
|
1309
|
+
if ((values.time === undefined) === (values.scene === undefined) || values.page !== undefined) {
|
|
1310
|
+
throw new OrlanError("A comment on a video takes --time or --scene: one of them.", undefined, usage);
|
|
1311
|
+
}
|
|
1312
|
+
const box = numbers("box", 4);
|
|
1313
|
+
const scene = Number(values.scene);
|
|
1314
|
+
if (values.scene !== undefined && (!Number.isInteger(scene) || scene < 1)) {
|
|
1315
|
+
throw new OrlanError("--scene takes the number of a scene, from 1.", undefined, "add --scene 1");
|
|
1316
|
+
}
|
|
1317
|
+
if (box && (typeof values.time !== "string" || values.time.includes("-"))) {
|
|
1318
|
+
throw new OrlanError("--box goes with one --time, not a range.", undefined, "add --time 0:42");
|
|
1319
|
+
}
|
|
1320
|
+
target = {
|
|
1321
|
+
video: {
|
|
1322
|
+
artifactId: values.file,
|
|
1323
|
+
...(typeof values.time === "string" ? timeOption(values.time) : { scene }),
|
|
1324
|
+
...(box ? { box: { x: box[0], y: box[1], w: box[2], h: box[3] } } : {}),
|
|
1325
|
+
},
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
935
1328
|
else if (typeof values.file === "string") {
|
|
936
1329
|
const page = Number(values.page);
|
|
937
1330
|
if (!Number.isInteger(page) || page < 1) {
|
|
@@ -1116,7 +1509,7 @@ export const commands = {
|
|
|
1116
1509
|
}),
|
|
1117
1510
|
},
|
|
1118
1511
|
"board add": {
|
|
1119
|
-
usage: 'orlan board add note|text|frame|shape ["<text>"] --at <x,y> [--size <S|M|L|XL|w,h>] [--text-color <color>] [--kind <kind>] [--color <color>] [--frame <frame>|none] | arrow --from <item>[@x,y] --to <item>[@x,y] [--text "<label>"] [--id <id>] [--topic <topic>] [--agent <id>] [--json]',
|
|
1512
|
+
usage: 'orlan board add note|text|frame|shape ["<text>"] --at <x,y> [--size <S|M|L|XL|w,h>] [--text-color <color>] [--kind <kind>] [--color <color>] [--frame <frame>|none] | arrow --from <item>[@x,y] --to <item>[@x,y] [--text "<label>"] [--id <id>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]',
|
|
1120
1513
|
summary: "Add a note, a text, a shape, a frame or an arrow to the board.",
|
|
1121
1514
|
details: [
|
|
1122
1515
|
"A note, a text, a shape or a frame goes at --at, with its text (a frame's text is its title, a",
|
|
@@ -1148,6 +1541,7 @@ export const commands = {
|
|
|
1148
1541
|
" holds more than half of it.",
|
|
1149
1542
|
'--text <text> The text, as the "<text>" word gives it. For an arrow: its label, for example "yes".',
|
|
1150
1543
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
1544
|
+
...NEW_TOPIC_HELP,
|
|
1151
1545
|
AGENT_TOKEN_HELP,
|
|
1152
1546
|
JSON_HELP,
|
|
1153
1547
|
],
|
|
@@ -1165,6 +1559,7 @@ export const commands = {
|
|
|
1165
1559
|
id: { type: "string" },
|
|
1166
1560
|
frame: { type: "string" },
|
|
1167
1561
|
topic: { type: "string" },
|
|
1562
|
+
"new-topic": { type: "string" },
|
|
1168
1563
|
},
|
|
1169
1564
|
positionals: [1, 2],
|
|
1170
1565
|
run: (values, [type, word], io) => {
|
|
@@ -1215,10 +1610,10 @@ export const commands = {
|
|
|
1215
1610
|
// The server refuses a frame on an arrow, and says why.
|
|
1216
1611
|
Object.assign(edit, frameOption(values));
|
|
1217
1612
|
return withAgent(values, async (api) => {
|
|
1218
|
-
const { items, warnings } = await editBoard(api, values, io, [edit]);
|
|
1613
|
+
const { items, warnings, newTopic } = await editBoard(api, values, io, [edit]);
|
|
1219
1614
|
const item = items[0];
|
|
1220
1615
|
const where = type === "arrow" ? `, from ${values.from} to ${values.to}` : "";
|
|
1221
|
-
return finish(io, values, { ...item, warnings }, [`Added ${boxText(item)}${where}.`, ...warningLines(warnings)], afterWarnings(warnings, type === "arrow"
|
|
1616
|
+
return finish(io, values, { ...item, warnings, ...(newTopic ? { newTopic } : {}) }, [`Added ${boxText(item)}${where}.`, ...warningLines(warnings)], afterWarnings(warnings, type === "arrow"
|
|
1222
1617
|
? "orlan board (the board with the arrow)"
|
|
1223
1618
|
: `orlan board add arrow --from ${item.id} --to <item> (to link it), or orlan board`));
|
|
1224
1619
|
});
|
|
@@ -1319,7 +1714,7 @@ export const commands = {
|
|
|
1319
1714
|
}),
|
|
1320
1715
|
},
|
|
1321
1716
|
"board edit": {
|
|
1322
|
-
usage: "orlan board edit '<edits as JSON>' [--topic <topic>] [--agent <id>] [--json]",
|
|
1717
|
+
usage: "orlan board edit '<edits as JSON>' [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
|
|
1323
1718
|
summary: "Make several changes to the board in one step: all of them, or none.",
|
|
1324
1719
|
details: [
|
|
1325
1720
|
"The edits are a JSON list of at most 100. A person can undo the step. A create can give its item",
|
|
@@ -1353,10 +1748,11 @@ export const commands = {
|
|
|
1353
1748
|
...WARNINGS_HELP,
|
|
1354
1749
|
"",
|
|
1355
1750
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
1751
|
+
...NEW_TOPIC_HELP,
|
|
1356
1752
|
AGENT_TOKEN_HELP,
|
|
1357
1753
|
JSON_HELP,
|
|
1358
1754
|
],
|
|
1359
|
-
options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
|
|
1755
|
+
options: { ...agentOption, ...jsonOption, topic: { type: "string" }, "new-topic": { type: "string" } },
|
|
1360
1756
|
positionals: [1, 1],
|
|
1361
1757
|
run: (values, [text], io) => {
|
|
1362
1758
|
let edits;
|
|
@@ -1370,8 +1766,8 @@ export const commands = {
|
|
|
1370
1766
|
throw new OrlanError('The edits are a JSON list, for example [{"op":"delete","id":"<item>"}].', undefined, "orlan board edit --help");
|
|
1371
1767
|
}
|
|
1372
1768
|
return withAgent(values, async (api) => {
|
|
1373
|
-
const { ids, items, warnings } = await editBoard(api, values, io, edits);
|
|
1374
|
-
return finish(io, values, { ids, items, warnings }, [
|
|
1769
|
+
const { ids, items, warnings, newTopic } = await editBoard(api, values, io, edits);
|
|
1770
|
+
return finish(io, values, { ids, items, warnings, ...(newTopic ? { newTopic } : {}) }, [
|
|
1375
1771
|
`Made ${ids.length} change${ids.length === 1 ? "" : "s"} in one step.`,
|
|
1376
1772
|
...items.map((item) => `- ${boxText(item)}`),
|
|
1377
1773
|
...warningLines(warnings),
|
|
@@ -1380,7 +1776,7 @@ export const commands = {
|
|
|
1380
1776
|
},
|
|
1381
1777
|
},
|
|
1382
1778
|
"board mermaid": {
|
|
1383
|
-
usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic>] [--agent <id>] [--json]",
|
|
1779
|
+
usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
|
|
1384
1780
|
summary: "Draw a Mermaid flowchart on the board as real shapes, labeled arrows and frames.",
|
|
1385
1781
|
details: [
|
|
1386
1782
|
"Orlan reads the flowchart and draws it in one step, which a person can undo: a shape for each",
|
|
@@ -1419,16 +1815,23 @@ export const commands = {
|
|
|
1419
1815
|
"<file.mmd> The Mermaid file.",
|
|
1420
1816
|
"--frame <frame> Draw the diagram in this frame, by its id, from `orlan board`.",
|
|
1421
1817
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
1818
|
+
...NEW_TOPIC_HELP,
|
|
1422
1819
|
AGENT_TOKEN_HELP,
|
|
1423
1820
|
JSON_HELP,
|
|
1424
1821
|
],
|
|
1425
|
-
options: {
|
|
1822
|
+
options: {
|
|
1823
|
+
...agentOption,
|
|
1824
|
+
...jsonOption,
|
|
1825
|
+
topic: { type: "string" },
|
|
1826
|
+
"new-topic": { type: "string" },
|
|
1827
|
+
frame: { type: "string" },
|
|
1828
|
+
},
|
|
1426
1829
|
positionals: [1, 1],
|
|
1427
1830
|
run: (values, [file], io) => withAgent(values, async (api) => {
|
|
1428
1831
|
const source = await readFile(file, "utf8").catch((error) => {
|
|
1429
1832
|
throw new OrlanError(`Orlan cannot read ${quote(file)}: ${error.code ?? error.message}.`, undefined, "give the path of a Mermaid file, for example orlan board mermaid flow.mmd");
|
|
1430
1833
|
});
|
|
1431
|
-
const topicId = await
|
|
1834
|
+
const { topicId, newTopic } = await writeTopic(api, values, io);
|
|
1432
1835
|
const answer = await api.call("import_mermaid", {
|
|
1433
1836
|
topicId,
|
|
1434
1837
|
mermaid: source,
|
|
@@ -1444,7 +1847,7 @@ export const commands = {
|
|
|
1444
1847
|
const made = [...count("shape", "shape"), ...count("arrow", "arrow"), ...count("frame", "frame")].join(", ");
|
|
1445
1848
|
const prefixed = Object.entries(ids).filter(([from, to]) => from !== to);
|
|
1446
1849
|
const where = typeof values.frame === "string" ? ` in frame ${values.frame}` : "";
|
|
1447
|
-
return finish(io, values, { ids, items, warnings, replaced, ignored }, [
|
|
1850
|
+
return finish(io, values, { ids, items, warnings, replaced, ignored, ...(newTopic ? { newTopic } : {}) }, [
|
|
1448
1851
|
`Drew ${quote(path.basename(file))}${where} in one step: ${made || "no items"}. A person can undo it.`,
|
|
1449
1852
|
...(replaced.length > 0
|
|
1450
1853
|
? [
|
|
@@ -1615,6 +2018,239 @@ export const commands = {
|
|
|
1615
2018
|
: "orlan brief (where the work stands)");
|
|
1616
2019
|
}),
|
|
1617
2020
|
},
|
|
2021
|
+
brand: {
|
|
2022
|
+
usage: "orlan brand [--save <folder>] [--agent <id>] [--json]",
|
|
2023
|
+
summary: "Print the accepted brand kit of the organisation: colors, fonts, logos, tone, rules, formats.",
|
|
2024
|
+
details: [
|
|
2025
|
+
"Use it before you make a deck, a video, a carousel or a page, so the work is on brand with no brand",
|
|
2026
|
+
"words in the prompt. Orlan gives only the sections people accepted. A section that waits for review",
|
|
2027
|
+
"is named under draft, without its content: do not guess it. The references are work people like,",
|
|
2028
|
+
"each with a note.",
|
|
2029
|
+
"",
|
|
2030
|
+
"--save <folder> Also save the logo and font files of the kit in this folder.",
|
|
2031
|
+
AGENT_TOKEN_HELP,
|
|
2032
|
+
JSON_HELP,
|
|
2033
|
+
],
|
|
2034
|
+
options: { ...agentOption, ...jsonOption, save: { type: "string" } },
|
|
2035
|
+
positionals: [0, 0],
|
|
2036
|
+
run: (values, _positionals, io) => withAgent(values, async (api, config) => {
|
|
2037
|
+
const kit = await api.call("brand", {});
|
|
2038
|
+
const saved = [];
|
|
2039
|
+
if (typeof values.save === "string" && kit.files.length > 0) {
|
|
2040
|
+
await mkdir(values.save, { recursive: true });
|
|
2041
|
+
for (const file of kit.files) {
|
|
2042
|
+
const response = await fetch(file.url, {
|
|
2043
|
+
headers: { authorization: `Bearer ${api.token}`, ...sessionHeader() },
|
|
2044
|
+
}).catch((error) => {
|
|
2045
|
+
throw unreachable(config.server, error);
|
|
2046
|
+
});
|
|
2047
|
+
if (!response.ok) {
|
|
2048
|
+
throw new OrlanError(`The download of ${quote(file.name)} answered ${response.status}.`, undefined, "run the command again");
|
|
2049
|
+
}
|
|
2050
|
+
// Two files with one name (a light and a dark logo.png) are both kept.
|
|
2051
|
+
const name = saved.includes(path.join(values.save, file.name)) ? `${file.id}-${file.name}` : file.name;
|
|
2052
|
+
const target = path.join(values.save, path.basename(name));
|
|
2053
|
+
await writeFile(target, Buffer.from(await response.arrayBuffer()));
|
|
2054
|
+
saved.push(target);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
const { sections } = kit;
|
|
2058
|
+
const lines = [`Brand kit v${kit.version}. Only the accepted sections are here.`];
|
|
2059
|
+
if (sections.colors) {
|
|
2060
|
+
lines.push(`colors: ${sections.colors.items.map((color) => `${color.name} ${color.hex}${color.use ? ` (${color.use})` : ""}`).join(", ") || "none"}`);
|
|
2061
|
+
}
|
|
2062
|
+
if (sections.fonts) {
|
|
2063
|
+
const font = (role) => sections.fonts?.[role].family || "not set";
|
|
2064
|
+
lines.push(`fonts: heading ${font("heading")}, body ${font("body")}`);
|
|
2065
|
+
}
|
|
2066
|
+
for (const file of kit.files)
|
|
2067
|
+
lines.push(`- ${file.role}: ${quote(file.name)} (${file.mime})`);
|
|
2068
|
+
if (sections.tone)
|
|
2069
|
+
lines.push(`tone: ${sections.tone.text || "not set"}`);
|
|
2070
|
+
if (sections.rules) {
|
|
2071
|
+
for (const rule of sections.rules.items)
|
|
2072
|
+
lines.push(`${rule.kind === "do" ? "do" : "do not"}: ${rule.text}`);
|
|
2073
|
+
}
|
|
2074
|
+
if (sections.formats) {
|
|
2075
|
+
const { video, deck, carousel } = sections.formats;
|
|
2076
|
+
lines.push(`formats: video ${video.join(", ") || "-"}; deck ${deck.join(", ") || "-"}; carousel ${carousel.join(", ") || "-"}`);
|
|
2077
|
+
}
|
|
2078
|
+
if (kit.draft.length)
|
|
2079
|
+
lines.push(`draft (waits for review, not for use): ${kit.draft.join(", ")}`);
|
|
2080
|
+
for (const ref of kit.references) {
|
|
2081
|
+
lines.push(`reference ${ref.kind}: ${ref.url ?? quote(ref.file?.name ?? "")} - ${quote(ref.note)}`);
|
|
2082
|
+
}
|
|
2083
|
+
for (const target of saved)
|
|
2084
|
+
lines.push(`Saved ${quote(target)}.`);
|
|
2085
|
+
// What people asked this agent to change (F141): each request ends with its own next_step line.
|
|
2086
|
+
const requests = kit.requests ?? [];
|
|
2087
|
+
for (const request of requests)
|
|
2088
|
+
lines.push("", ...brandRequestLines(request));
|
|
2089
|
+
return finish(io, values, { ...kit, ...(typeof values.save === "string" ? { saved } : {}) }, lines, requests.length > 0
|
|
2090
|
+
? "answer each request above with orlan brand draft"
|
|
2091
|
+
: kit.files.length > 0 && typeof values.save !== "string"
|
|
2092
|
+
? "orlan brand --save brand (the logo and font files)"
|
|
2093
|
+
: "make the work with this kit");
|
|
2094
|
+
}),
|
|
2095
|
+
},
|
|
2096
|
+
"brand draft": {
|
|
2097
|
+
usage: 'orlan brand draft <section> (--file <path> | --data <json>) [--note "<text>"] [--request <id>] [--agent <id>] [--json]',
|
|
2098
|
+
summary: "Write one section of the brand kit as a draft for people to review and accept.",
|
|
2099
|
+
details: [
|
|
2100
|
+
"Draft the kit from the website, the logo and font files and the files people give you: one call for",
|
|
2101
|
+
"each section. Each section waits as a draft until a person accepts it on the Brand kit page; an agent",
|
|
2102
|
+
"never accepts one. A draft answers your open requests on that section (orlan brand lists them).",
|
|
2103
|
+
"",
|
|
2104
|
+
"<section> colors, fonts, logos, tone, rules or formats. The content of each, as JSON:",
|
|
2105
|
+
' colors {"items": [{"name": "Vivid Blue", "hex": "#0679FF", "use": "Buttons"}]}',
|
|
2106
|
+
' fonts {"heading": {"family": "Avenir Next", "fileIds": []}, "body": {"family": "Inter", "fileIds": []}}',
|
|
2107
|
+
' logos {"light": "<file id>", "dark": null, "mark": null}',
|
|
2108
|
+
' tone {"text": "Short sentences. Say what the customer gets."}',
|
|
2109
|
+
' rules {"items": [{"kind": "do", "text": "Captions on every video."}, {"kind": "dont", "text": "..."}]}',
|
|
2110
|
+
' formats {"video": ["16:9", "9:16"], "deck": ["16:9"], "carousel": ["4:5"]}',
|
|
2111
|
+
" A file id comes from orlan brand upload.",
|
|
2112
|
+
"",
|
|
2113
|
+
"--file <path> The content in a JSON file.",
|
|
2114
|
+
"--data <json> The content as JSON text.",
|
|
2115
|
+
"--note <text> Where you took it from, or what you changed. People read it in the kit versions.",
|
|
2116
|
+
"--request <id> The request you answer, for example brand_12. Default: your open requests on the section.",
|
|
2117
|
+
AGENT_TOKEN_HELP,
|
|
2118
|
+
JSON_HELP,
|
|
2119
|
+
],
|
|
2120
|
+
options: {
|
|
2121
|
+
...agentOption,
|
|
2122
|
+
...jsonOption,
|
|
2123
|
+
file: { type: "string" },
|
|
2124
|
+
data: { type: "string" },
|
|
2125
|
+
note: { type: "string" },
|
|
2126
|
+
request: { type: "string" },
|
|
2127
|
+
},
|
|
2128
|
+
positionals: [1, 1],
|
|
2129
|
+
run: async (values, [section], io) => {
|
|
2130
|
+
const file = typeof values.file === "string" ? values.file : undefined;
|
|
2131
|
+
const data = typeof values.data === "string" ? values.data : undefined;
|
|
2132
|
+
if ((file === undefined) === (data === undefined)) {
|
|
2133
|
+
throw new OrlanError("Give the content with --file or --data, one of them.", undefined, `orlan brand draft ${section} --file <path to the JSON content>`);
|
|
2134
|
+
}
|
|
2135
|
+
const text = file === undefined ? data : await readFile(file, "utf8");
|
|
2136
|
+
let content;
|
|
2137
|
+
try {
|
|
2138
|
+
content = JSON.parse(text);
|
|
2139
|
+
}
|
|
2140
|
+
catch {
|
|
2141
|
+
throw new OrlanError(`The content is not JSON${file ? ` in ${quote(file)}` : ""}.`, undefined, "orlan brand draft --help shows the content of each section");
|
|
2142
|
+
}
|
|
2143
|
+
return withAgent(values, async (api) => {
|
|
2144
|
+
const answer = await api.call("brand_draft", {
|
|
2145
|
+
section,
|
|
2146
|
+
content,
|
|
2147
|
+
...(typeof values.note === "string" ? { note: values.note } : {}),
|
|
2148
|
+
...(typeof values.request === "string" ? { requestId: values.request } : {}),
|
|
2149
|
+
});
|
|
2150
|
+
const { nextStep, ...rest } = answer;
|
|
2151
|
+
return finish(io, values, rest, [
|
|
2152
|
+
`Wrote ${answer.section} as a draft in brand kit v${answer.version}. It waits for a person to accept it.` +
|
|
2153
|
+
(answer.answered ? ` Answered ${answer.answered.join(", ")}.` : ""),
|
|
2154
|
+
], nextStep);
|
|
2155
|
+
});
|
|
2156
|
+
},
|
|
2157
|
+
},
|
|
2158
|
+
"brand upload": {
|
|
2159
|
+
usage: "orlan brand upload <path> --kind logo|font [--agent <id>] [--json]",
|
|
2160
|
+
summary: "Upload a logo or a font file for the brand kit, and print its file id.",
|
|
2161
|
+
details: [
|
|
2162
|
+
"A logo is a PNG or a JPEG file; a font is an .otf, .ttf, .woff or .woff2 file. Name the file id in",
|
|
2163
|
+
"the logos or the fonts section with orlan brand draft.",
|
|
2164
|
+
"",
|
|
2165
|
+
"--kind <kind> logo or font. Required.",
|
|
2166
|
+
AGENT_TOKEN_HELP,
|
|
2167
|
+
JSON_HELP,
|
|
2168
|
+
],
|
|
2169
|
+
options: { ...agentOption, ...jsonOption, kind: { type: "string" } },
|
|
2170
|
+
positionals: [1, 1],
|
|
2171
|
+
run: async (values, [filePath], io) => {
|
|
2172
|
+
const kind = values.kind;
|
|
2173
|
+
if (kind !== "logo" && kind !== "font") {
|
|
2174
|
+
throw new OrlanError("Add --kind logo or --kind font.", undefined, `orlan brand upload ${filePath} --kind logo`);
|
|
2175
|
+
}
|
|
2176
|
+
const form = new FormData();
|
|
2177
|
+
form.set("file", await openAsBlob(filePath), path.basename(filePath));
|
|
2178
|
+
return withAgent(values, async (api) => {
|
|
2179
|
+
const uploaded = await api.postForm("/api/agent/brand/files", { kind }, form, "the upload");
|
|
2180
|
+
return finish(io, values, uploaded, [`Uploaded ${quote(uploaded.name)} as ${uploaded.kind} file ${uploaded.id}.`], `name ${uploaded.id} in the ${kind === "logo" ? "logos" : "fonts"} section with orlan brand draft`);
|
|
2181
|
+
});
|
|
2182
|
+
},
|
|
2183
|
+
},
|
|
2184
|
+
studio: {
|
|
2185
|
+
usage: "orlan studio [<skill> [<step>]] [--agent <id>] [--json]",
|
|
2186
|
+
summary: "Print one step of a studio skill (video, deck, carousel, walkthrough): the steps come from Orlan.",
|
|
2187
|
+
details: [
|
|
2188
|
+
"A studio skill makes a piece of work on this computer in five steps: brief, script, build, render,",
|
|
2189
|
+
"review. Orlan gives the steps only to a signed-in agent, one step at a time: read each step when you",
|
|
2190
|
+
"come to it, and follow it. Read the brand kit first (orlan brand).",
|
|
2191
|
+
"",
|
|
2192
|
+
"orlan studio The skills, with their steps and helper scripts.",
|
|
2193
|
+
"orlan studio <skill> The steps and the helper scripts of one skill.",
|
|
2194
|
+
"orlan studio <skill> <step> The text of one step.",
|
|
2195
|
+
"",
|
|
2196
|
+
"Get the helper scripts of a skill with orlan studio pull <skill>.",
|
|
2197
|
+
AGENT_TOKEN_HELP,
|
|
2198
|
+
JSON_HELP,
|
|
2199
|
+
],
|
|
2200
|
+
options: { ...agentOption, ...jsonOption },
|
|
2201
|
+
positionals: [0, 2],
|
|
2202
|
+
run: (values, [skill, step], io) => withStudio(values, async (api) => {
|
|
2203
|
+
if (!skill) {
|
|
2204
|
+
const { skills } = await api.call("studio", {});
|
|
2205
|
+
const lines = skills.length
|
|
2206
|
+
? skills.map((each) => `${each.skill}: ${skillLine(each)}`)
|
|
2207
|
+
: ["Orlan has no studio skills yet."];
|
|
2208
|
+
return finish(io, values, { skills }, lines, "orlan studio <skill> brief");
|
|
2209
|
+
}
|
|
2210
|
+
if (!step) {
|
|
2211
|
+
const found = await api.call("studio", { skill });
|
|
2212
|
+
const next = `${found.scripts.length ? `orlan studio pull ${skill}, then ` : ""}orlan studio ${skill} brief`;
|
|
2213
|
+
return finish(io, values, { ...found }, [`${skill}: ${skillLine(found)}`], next);
|
|
2214
|
+
}
|
|
2215
|
+
const answer = await api.call("studio", { skill, step });
|
|
2216
|
+
const after = STUDIO_STEPS[STUDIO_STEPS.indexOf(step) + 1];
|
|
2217
|
+
return finish(io, values, answer, [answer.text.trimEnd()], after
|
|
2218
|
+
? `orlan studio ${skill} ${after}`
|
|
2219
|
+
: "post the work: orlan files add <file>, or orlan files push for a new version");
|
|
2220
|
+
}),
|
|
2221
|
+
},
|
|
2222
|
+
"studio pull": {
|
|
2223
|
+
usage: "orlan studio pull <skill> [--to <folder>] [--agent <id>] [--json]",
|
|
2224
|
+
summary: "Write the helper scripts of a studio skill into the project, from Orlan.",
|
|
2225
|
+
details: [
|
|
2226
|
+
"The steps of the skill name these scripts. Pull them again for the newest copy: each file is written",
|
|
2227
|
+
"again, and files that you added stay.",
|
|
2228
|
+
"",
|
|
2229
|
+
"--to <folder> The folder to write the scripts in. Default: .orlan/studio/<skill> in this folder.",
|
|
2230
|
+
AGENT_TOKEN_HELP,
|
|
2231
|
+
JSON_HELP,
|
|
2232
|
+
],
|
|
2233
|
+
options: { ...agentOption, ...jsonOption, to: { type: "string" } },
|
|
2234
|
+
positionals: [1, 1],
|
|
2235
|
+
run: (values, [skill], io) => withStudio(values, async (api) => {
|
|
2236
|
+
const { files } = await api.call("studio", { skill, scripts: true });
|
|
2237
|
+
const folder = path.resolve(typeof values.to === "string" ? values.to : path.join(".orlan", "studio", `${skill}`));
|
|
2238
|
+
for (const file of files) {
|
|
2239
|
+
const target = path.resolve(folder, file.path);
|
|
2240
|
+
// A path from the server stays in the folder.
|
|
2241
|
+
if (!target.startsWith(`${folder}${path.sep}`)) {
|
|
2242
|
+
throw new OrlanError(`Orlan named a script path outside the folder: ${quote(file.path)}.`, "internal_error");
|
|
2243
|
+
}
|
|
2244
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2245
|
+
await writeFile(target, Buffer.from(file.contentBase64, "base64"));
|
|
2246
|
+
await chmod(target, file.executable ? 0o755 : 0o644);
|
|
2247
|
+
}
|
|
2248
|
+
return finish(io, values, { skill, folder, files: files.map((file) => file.path) }, [
|
|
2249
|
+
`Pulled ${files.length} helper scripts of ${skill} into ${folder}.`,
|
|
2250
|
+
...files.map((file) => `- ${file.path}`),
|
|
2251
|
+
], `orlan studio ${skill} brief`);
|
|
2252
|
+
}),
|
|
2253
|
+
},
|
|
1618
2254
|
brief: {
|
|
1619
2255
|
usage: "orlan brief [--topic <topic>] [--agent <id>] [--json]",
|
|
1620
2256
|
summary: "Print where the work stands: the topic, the open requests, the files, and the next step.",
|
|
@@ -1655,7 +2291,7 @@ export const commands = {
|
|
|
1655
2291
|
options: { ...agentOption, ...jsonOption, new: { type: "boolean" } },
|
|
1656
2292
|
positionals: [0, 0],
|
|
1657
2293
|
run: (values, _positionals, io) => withAgent(values, async (api) => {
|
|
1658
|
-
const { requests, stopped } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
|
|
2294
|
+
const { requests, stopped, brand = [], } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
|
|
1659
2295
|
// A person stopped this session on the board (D109): the stop, also with --new.
|
|
1660
2296
|
if (stopped) {
|
|
1661
2297
|
const [line = "", step = ""] = stopped.lines;
|
|
@@ -1667,20 +2303,23 @@ export const commands = {
|
|
|
1667
2303
|
return 0;
|
|
1668
2304
|
}
|
|
1669
2305
|
// --new with no request prints nothing.
|
|
2306
|
+
// The brand kit requests (F141) come after the topic requests, each with its own next_step line.
|
|
2307
|
+
const all = [...requests, ...brand];
|
|
1670
2308
|
if (values.json) {
|
|
1671
|
-
if (
|
|
1672
|
-
const last =
|
|
2309
|
+
if (all.length > 0 || !values.new) {
|
|
2310
|
+
const last = all.at(-1);
|
|
1673
2311
|
printJson(io, {
|
|
1674
2312
|
requests: requests.map((each) => each.request),
|
|
2313
|
+
...(brand.length > 0 ? { brand: brand.map((each) => each.request) } : {}),
|
|
1675
2314
|
next_step: last ? deliveryStep(last) : NOTHING_STEP,
|
|
1676
2315
|
});
|
|
1677
2316
|
}
|
|
1678
2317
|
}
|
|
1679
2318
|
else {
|
|
1680
|
-
if (
|
|
2319
|
+
if (all.length === 0 && !values.new)
|
|
1681
2320
|
io.out(`No open requests.\nnext_step: ${NOTHING_STEP}`);
|
|
1682
2321
|
// Each request ends with its own next_step line. The line on quoted text comes once.
|
|
1683
|
-
for (const [index, delivery] of
|
|
2322
|
+
for (const [index, delivery] of all.entries()) {
|
|
1684
2323
|
if (index > 0)
|
|
1685
2324
|
io.out("");
|
|
1686
2325
|
const [first = "", ...rest] = delivery.lines;
|
|
@@ -1746,7 +2385,7 @@ export const commands = {
|
|
|
1746
2385
|
const api = new AgentApi(config.server, token);
|
|
1747
2386
|
// The file goes to the request's topic in one request; a done request refuses it before it is read.
|
|
1748
2387
|
const uploadId = typeof values.file === "string"
|
|
1749
|
-
? await api.upload(await
|
|
2388
|
+
? await api.upload(await openAsBlob(values.file), { request: requestId, name: path.basename(values.file) }, "respond")
|
|
1750
2389
|
: undefined;
|
|
1751
2390
|
const answer = await api.call("respond", {
|
|
1752
2391
|
requestId,
|
|
@@ -1793,14 +2432,20 @@ export const commands = {
|
|
|
1793
2432
|
},
|
|
1794
2433
|
},
|
|
1795
2434
|
};
|
|
1796
|
-
/**
|
|
2435
|
+
/**
|
|
2436
|
+
* Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. Edits that
|
|
2437
|
+
* add items can make a new topic (--new-topic, F131); the answer names it.
|
|
2438
|
+
*/
|
|
1797
2439
|
async function editBoard(api, values, io, edits) {
|
|
2440
|
+
const { topicId, newTopic } = edits.some((edit) => edit.op === "create")
|
|
2441
|
+
? await writeTopic(api, values, io)
|
|
2442
|
+
: { topicId: await topicOrDefault(api, values, io), newTopic: undefined };
|
|
1798
2443
|
const answer = await api.call("edit_board", {
|
|
1799
|
-
topicId
|
|
2444
|
+
topicId,
|
|
1800
2445
|
edits,
|
|
1801
2446
|
});
|
|
1802
2447
|
// A server before F096 gives no warnings.
|
|
1803
|
-
return { ...answer, warnings: answer.warnings ?? [] };
|
|
2448
|
+
return { ...answer, warnings: answer.warnings ?? [], ...(newTopic ? { newTopic } : {}) };
|
|
1804
2449
|
}
|
|
1805
2450
|
/** --assignee as the tool takes it: me (the token of this agent), user:<id>, agent:<id>, or none (null). */
|
|
1806
2451
|
function assigneeOption(config, values) {
|
|
@@ -1832,6 +2477,11 @@ function voteText(vote) {
|
|
|
1832
2477
|
`, ${vote.voters} voted` +
|
|
1833
2478
|
(result ? `, result: ${result}` : ""));
|
|
1834
2479
|
}
|
|
2480
|
+
/** A scorecard as its sum of the most it can be (F139): "65/80". */
|
|
2481
|
+
function scoreTotal(scores) {
|
|
2482
|
+
const sum = scores.reduce((total, each) => total + each.score, 0);
|
|
2483
|
+
return `${Math.round(sum * 10) / 10}/${scores.length * 10}`;
|
|
2484
|
+
}
|
|
1835
2485
|
/** --pages as page numbers: "3" or "2,5". */
|
|
1836
2486
|
function pageList(values) {
|
|
1837
2487
|
if (typeof values.pages !== "string")
|
|
@@ -1907,3 +2557,10 @@ async function pullPages(api, config, values, io, answer, since) {
|
|
|
1907
2557
|
}
|
|
1908
2558
|
/** The changed pages of a version, as words: "pages 2, 5 changed", "no page changed". */
|
|
1909
2559
|
const changedText = (pages) => pages.length === 0 ? "no page changed" : `page${pages.length === 1 ? "" : "s"} ${pages.join(", ")} changed`;
|
|
2560
|
+
/** A brand request as text, as `orlan inbox` prints it. Every text people wrote is quoted. */
|
|
2561
|
+
const brandRequestLines = (request) => [
|
|
2562
|
+
`${request.id}: a request from ${quote(request.from)} (person) on Orlan, brand kit section ${request.section}. ${QUOTED_TEXT}`,
|
|
2563
|
+
`${quote(request.from)} wrote: ${quote(request.text)}`,
|
|
2564
|
+
`${request.section} now: ${JSON.stringify(request.current)}`,
|
|
2565
|
+
`next_step: write the changed ${request.section} as JSON in a file, then orlan brand draft ${request.section} --file <path> --request ${request.id} --note "<what changed>"`,
|
|
2566
|
+
];
|