@skillcdn/console 0.1.5 → 0.1.7
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 +50 -2
- package/SKILLCDN.md +29 -0
- package/dist/cli/cli.d.ts +3 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +61 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +5 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/serve.d.ts +37 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +218 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/console.css +33 -10
- package/dist/console.d.ts +6 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +78 -13
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +5 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +4 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +4 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/schemas.d.ts +1 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +5 -0
- package/dist/schemas.js.map +1 -1
- package/dist/web.d.ts +16 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +149 -0
- package/dist/web.js.map +1 -0
- package/docs/specs/cli.md +92 -0
- package/docs/specs/rest.md +220 -0
- package/package.json +14 -3
- package/skills/building-a-console/SKILL.md +79 -0
- package/skills/working-the-board/SKILL.md +78 -0
- package/src/api.ts +235 -0
- package/src/cli/cli.ts +1114 -0
- package/src/cli/credentials.ts +68 -0
- package/src/cli/directory.ts +59 -0
- package/src/cli/format.ts +284 -0
- package/src/cli/help.ts +326 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/serve.ts +276 -0
- package/src/cli/terminal.ts +28 -0
- package/src/client.ts +533 -0
- package/src/components/board.tsx +112 -0
- package/src/components/connect.tsx +126 -0
- package/src/components/decision-form.tsx +136 -0
- package/src/components/decision-list.tsx +278 -0
- package/src/components/documents.tsx +569 -0
- package/src/components/event-feed.tsx +129 -0
- package/src/components/markdown.tsx +72 -0
- package/src/components/people.tsx +59 -0
- package/src/components/projects.tsx +364 -0
- package/src/components/runs.tsx +175 -0
- package/src/components/shell.tsx +284 -0
- package/src/components/sign-in.tsx +61 -0
- package/src/components/skills.tsx +78 -0
- package/src/components/task-form.tsx +229 -0
- package/src/components/task-view.tsx +272 -0
- package/src/components/tokens.tsx +223 -0
- package/src/components/ui.tsx +219 -0
- package/src/connect-page.tsx +148 -0
- package/src/connect.ts +27 -0
- package/src/console.tsx +1166 -0
- package/src/data.ts +493 -0
- package/src/docs-page.tsx +334 -0
- package/src/documents.ts +76 -0
- package/src/i18n/en.ts +537 -0
- package/src/i18n/index.ts +76 -0
- package/src/i18n/ko.ts +511 -0
- package/src/index.ts +142 -0
- package/src/limits.ts +61 -0
- package/src/router.ts +295 -0
- package/src/routes.ts +106 -0
- package/src/schemas.ts +776 -0
- package/src/styles/console.css +2432 -0
- package/src/text.ts +33 -0
- package/src/use-action.ts +37 -0
- package/src/vocabulary.ts +109 -0
- package/src/web.ts +170 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as z from "zod/mini";
|
|
5
|
+
|
|
6
|
+
// Where the command keeps what `login` was given: the console's address and the token, in the
|
|
7
|
+
// person's own configuration directory, readable by them alone where the system can say so.
|
|
8
|
+
// The token is a secret the person made; the file is the one place it rests.
|
|
9
|
+
|
|
10
|
+
export interface Credentials {
|
|
11
|
+
/** The origin of the console, without a path. */
|
|
12
|
+
readonly url: string;
|
|
13
|
+
/** A token made on the console's Agents page. */
|
|
14
|
+
readonly token: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CredentialStore {
|
|
18
|
+
load(): Promise<Credentials | undefined>;
|
|
19
|
+
/** Keeps the credentials and answers where. */
|
|
20
|
+
save(credentials: Credentials): Promise<string>;
|
|
21
|
+
clear(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const credentialsSchema = z.object({
|
|
25
|
+
url: z.string().check(z.minLength(1)),
|
|
26
|
+
token: z.string().check(z.minLength(1)),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const FILE_NAME = "credentials.json";
|
|
30
|
+
|
|
31
|
+
/** The directory the file lives in: under `XDG_CONFIG_HOME`, or `~/.config`. */
|
|
32
|
+
export function defaultConfigDir(env: Readonly<Record<string, string | undefined>>): string {
|
|
33
|
+
const base = env.XDG_CONFIG_HOME;
|
|
34
|
+
return join(
|
|
35
|
+
base === undefined || base.length === 0 ? join(homedir(), ".config") : base,
|
|
36
|
+
"skillcdn-console",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createFileStore(directory: string): CredentialStore {
|
|
41
|
+
const path = join(directory, FILE_NAME);
|
|
42
|
+
return {
|
|
43
|
+
async load() {
|
|
44
|
+
let content: string;
|
|
45
|
+
try {
|
|
46
|
+
content = await readFile(path, "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const parsed = credentialsSchema.safeParse(JSON.parse(content));
|
|
52
|
+
return parsed.success ? parsed.data : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
async save(credentials) {
|
|
58
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
59
|
+
await writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
60
|
+
// The mode above applies to a file made now; one that was there keeps its own, so it is set again.
|
|
61
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
62
|
+
return path;
|
|
63
|
+
},
|
|
64
|
+
async clear() {
|
|
65
|
+
await rm(path, { force: true });
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import * as z from "zod/mini";
|
|
4
|
+
|
|
5
|
+
// What a working directory says about the project it belongs to: `.skillcdn-console.json` in
|
|
6
|
+
// it or in a directory above it, as `.nvmrc` and its kind are found. Written by `console use`,
|
|
7
|
+
// and meant to be committed, so that everyone who works in the checkout, and every agent they
|
|
8
|
+
// run in it, is in the same project without saying so.
|
|
9
|
+
|
|
10
|
+
/** The file's name, in the working directory or one above it. */
|
|
11
|
+
export const DIRECTORY_FILE = ".skillcdn-console.json";
|
|
12
|
+
|
|
13
|
+
export interface DirectorySettings {
|
|
14
|
+
/** The key of the project the directory's work belongs to. */
|
|
15
|
+
readonly project: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DirectoryStore {
|
|
19
|
+
/** The settings of the working directory, or of the nearest directory above it that has them. */
|
|
20
|
+
load(): Promise<DirectorySettings | undefined>;
|
|
21
|
+
/** Writes the settings into the working directory and answers where. */
|
|
22
|
+
save(settings: DirectorySettings): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const settingsSchema = z.object({ project: z.string().check(z.minLength(1)) });
|
|
26
|
+
|
|
27
|
+
export function createDirectoryStore(cwd: string): DirectoryStore {
|
|
28
|
+
return {
|
|
29
|
+
async load() {
|
|
30
|
+
let directory = cwd;
|
|
31
|
+
for (;;) {
|
|
32
|
+
let content: string | undefined;
|
|
33
|
+
try {
|
|
34
|
+
content = await readFile(join(directory, DIRECTORY_FILE), "utf8");
|
|
35
|
+
} catch {
|
|
36
|
+
content = undefined;
|
|
37
|
+
}
|
|
38
|
+
if (content !== undefined) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = settingsSchema.safeParse(JSON.parse(content));
|
|
41
|
+
return parsed.success ? parsed.data : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const above = dirname(directory);
|
|
47
|
+
if (above === directory) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
directory = above;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async save(settings) {
|
|
54
|
+
const path = join(cwd, DIRECTORY_FILE);
|
|
55
|
+
await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
56
|
+
return path;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RestArtifact,
|
|
3
|
+
RestDecision,
|
|
4
|
+
RestDocument,
|
|
5
|
+
RestDocumentSummary,
|
|
6
|
+
RestDocuments,
|
|
7
|
+
RestProject,
|
|
8
|
+
RestRun,
|
|
9
|
+
RestSkills,
|
|
10
|
+
RestTask,
|
|
11
|
+
RestVersion,
|
|
12
|
+
RestVersionSummary,
|
|
13
|
+
} from "../schemas.js";
|
|
14
|
+
|
|
15
|
+
// What the command prints: one line per thing in a list, a few lines for one thing in full.
|
|
16
|
+
// Plain text, for an agent to read and a person to skim; ids are given, since the commands take
|
|
17
|
+
// them, and the words are the vocabulary's own, since the options take those.
|
|
18
|
+
|
|
19
|
+
const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
20
|
+
|
|
21
|
+
const indent = (text: string): string =>
|
|
22
|
+
text
|
|
23
|
+
.trimEnd()
|
|
24
|
+
.split("\n")
|
|
25
|
+
.map((line) => ` ${line}`)
|
|
26
|
+
.join("\n");
|
|
27
|
+
|
|
28
|
+
const asker = (decision: RestDecision): string =>
|
|
29
|
+
decision.run === null
|
|
30
|
+
? decision.raisedBy.login
|
|
31
|
+
: `${decision.run.agent} for ${decision.raisedBy.login}`;
|
|
32
|
+
|
|
33
|
+
/** One project: its key, its name, what the person is in it, and what waits in it. */
|
|
34
|
+
export function formatProjectLine(project: RestProject): string {
|
|
35
|
+
const notes: string[] = [project.role];
|
|
36
|
+
if (project.openDecisions > 0) {
|
|
37
|
+
notes.push(`${plural(project.openDecisions, "decision")} waiting`);
|
|
38
|
+
}
|
|
39
|
+
if (project.openRuns > 0) {
|
|
40
|
+
notes.push(`${plural(project.openRuns, "agent")} at work`);
|
|
41
|
+
}
|
|
42
|
+
return `${project.key} ${project.name} (${notes.join("; ")})`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatTaskLine(task: RestTask): string {
|
|
46
|
+
const notes = [`owner ${task.owner.login}`];
|
|
47
|
+
if (task.assignee !== null) {
|
|
48
|
+
notes.push(`assigned to ${task.assignee.login}`);
|
|
49
|
+
}
|
|
50
|
+
if (task.openDecisions > 0) {
|
|
51
|
+
notes.push(`${plural(task.openDecisions, "decision")} waiting`);
|
|
52
|
+
}
|
|
53
|
+
if (task.openRuns > 0) {
|
|
54
|
+
notes.push("an agent at work");
|
|
55
|
+
}
|
|
56
|
+
return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One task in full; with the decisions about it and the runs on it, when given. */
|
|
60
|
+
export function formatTask(
|
|
61
|
+
task: RestTask,
|
|
62
|
+
decisions?: readonly RestDecision[],
|
|
63
|
+
runs?: readonly RestRun[],
|
|
64
|
+
): string {
|
|
65
|
+
const lines = [
|
|
66
|
+
`#${task.number} ${task.title}`,
|
|
67
|
+
`state: ${task.state} priority: ${task.priority} owner: ${task.owner.login} assignee: ${task.assignee?.login ?? "nobody"}`,
|
|
68
|
+
`id: ${task.id}${task.parentId === null ? "" : ` part of: ${task.parentId}`}`,
|
|
69
|
+
];
|
|
70
|
+
if (task.links.length > 0) {
|
|
71
|
+
lines.push("links:");
|
|
72
|
+
for (const link of task.links) {
|
|
73
|
+
lines.push(` ${link.label === null ? link.url : `${link.label}: ${link.url}`}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (task.body.trim().length > 0) {
|
|
77
|
+
lines.push("", task.body.trimEnd());
|
|
78
|
+
}
|
|
79
|
+
if (decisions !== undefined) {
|
|
80
|
+
lines.push("", decisions.length === 0 ? "decisions: none" : "decisions:");
|
|
81
|
+
for (const decision of decisions) {
|
|
82
|
+
lines.push(` ${formatDecisionLine(decision)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (runs !== undefined) {
|
|
86
|
+
lines.push("", runs.length === 0 ? "runs: none" : "runs:");
|
|
87
|
+
for (const run of runs) {
|
|
88
|
+
lines.push(` ${formatRunLine(run)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatRunLine(run: RestRun): string {
|
|
95
|
+
const notes = [plural(run.reports.length, "report"), plural(run.artifacts.length, "artifact")];
|
|
96
|
+
if (run.waitingFor !== null) {
|
|
97
|
+
notes.push(`waiting for decision ${run.waitingFor}`);
|
|
98
|
+
}
|
|
99
|
+
return `${run.id} ${run.status} ${run.agent} for ${run.person.login} task #${run.taskNumber} (${notes.join(", ")})`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One run in full; with where each file handed in is read, when the caller knows. */
|
|
103
|
+
export function formatRun(run: RestRun, fileUrl?: (artifactId: string) => string): string {
|
|
104
|
+
const lines = [
|
|
105
|
+
formatRunLine(run),
|
|
106
|
+
`started: ${run.startedAt}${run.endedAt === null ? "" : ` ended: ${run.endedAt}`}`,
|
|
107
|
+
];
|
|
108
|
+
if (run.summary !== null) {
|
|
109
|
+
lines.push("", "summary:", indent(run.summary));
|
|
110
|
+
}
|
|
111
|
+
if (run.reports.length > 0) {
|
|
112
|
+
lines.push("", "reports:");
|
|
113
|
+
for (const report of run.reports) {
|
|
114
|
+
lines.push(` [${report.createdAt}]`, indent(report.body));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (run.artifacts.length > 0) {
|
|
118
|
+
lines.push("", "artifacts:");
|
|
119
|
+
for (const artifact of run.artifacts) {
|
|
120
|
+
lines.push(` ${formatArtifact(artifact, fileUrl)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What was handed in: the link, or the file with its size and where its bytes are read. */
|
|
127
|
+
export function formatArtifact(
|
|
128
|
+
artifact: RestArtifact,
|
|
129
|
+
fileUrl?: (artifactId: string) => string,
|
|
130
|
+
): string {
|
|
131
|
+
const what =
|
|
132
|
+
artifact.file === null
|
|
133
|
+
? (artifact.url ?? "")
|
|
134
|
+
: `${artifact.file.name} (${artifact.file.size} bytes, ${artifact.file.contentType}${fileUrl === undefined ? "" : `; read at ${fileUrl(artifact.id)}`})`;
|
|
135
|
+
return artifact.label === null ? what : `${artifact.label}: ${what}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SKILLS_WORDS: Readonly<Record<Exclude<RestSkills["status"], "none" | "ready">, string>> = {
|
|
139
|
+
indexing: "SkillCDN is still indexing the repository; ask again in a moment.",
|
|
140
|
+
failed: "SkillCDN could not index the repository.",
|
|
141
|
+
not_found: "SkillCDN does not serve this address to the console.",
|
|
142
|
+
unavailable: "SkillCDN could not be reached; ask again later.",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The organization's skills: where they are, and each with what an agent loads it by. */
|
|
146
|
+
export function formatSkills(skills: RestSkills): string {
|
|
147
|
+
if (skills.status === "none" || skills.address === null) {
|
|
148
|
+
return "No skills address: neither this project nor the console names one.";
|
|
149
|
+
}
|
|
150
|
+
const head = `skills at ${skills.address}, served by ${skills.source}: ${skills.status}`;
|
|
151
|
+
if (skills.status !== "ready") {
|
|
152
|
+
return `${head}\n${SKILLS_WORDS[skills.status]}`;
|
|
153
|
+
}
|
|
154
|
+
if (skills.items.length === 0) {
|
|
155
|
+
return `${head}\nNo skills at this address.`;
|
|
156
|
+
}
|
|
157
|
+
const lines = [head];
|
|
158
|
+
for (const skill of skills.items) {
|
|
159
|
+
lines.push(`${skill.name} ${skill.description}`, ` ${skill.uri ?? skill.path}`);
|
|
160
|
+
}
|
|
161
|
+
lines.push(
|
|
162
|
+
"",
|
|
163
|
+
"Load a skill by its URI through your SkillCDN connection. --json adds the page of each, for a person.",
|
|
164
|
+
);
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatDecisionLine(decision: RestDecision): string {
|
|
169
|
+
const state = decision.answer === null ? "waiting" : `answered ${decision.answer.option}`;
|
|
170
|
+
return `${decision.id} ${state} ${decision.question} (asked by ${asker(decision)})`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function formatOptions(decision: RestDecision): string {
|
|
174
|
+
return decision.options.map((option) => ` ${option.id}) ${option.label}`).join("\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The answer, with who gave it; or that there is none yet. */
|
|
178
|
+
export function formatAnswer(decision: RestDecision): string {
|
|
179
|
+
const { answer } = decision;
|
|
180
|
+
if (answer === null) {
|
|
181
|
+
return "waits for a person.";
|
|
182
|
+
}
|
|
183
|
+
const label = decision.options.find((option) => option.id === answer.option)?.label;
|
|
184
|
+
const lines = [
|
|
185
|
+
`Answered: ${answer.option}) ${label ?? answer.option}, by ${answer.by.login} at ${answer.at}`,
|
|
186
|
+
];
|
|
187
|
+
if (answer.note !== null && answer.note.length > 0) {
|
|
188
|
+
lines.push(`Note: ${answer.note}`);
|
|
189
|
+
}
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatDecision(decision: RestDecision): string {
|
|
194
|
+
const lines = [
|
|
195
|
+
`decision ${decision.id}: ${decision.question}`,
|
|
196
|
+
`asked by ${asker(decision)}${decision.taskNumber === null ? "" : `, about task #${decision.taskNumber}`}`,
|
|
197
|
+
];
|
|
198
|
+
if (decision.body.trim().length > 0) {
|
|
199
|
+
lines.push("", decision.body.trimEnd());
|
|
200
|
+
}
|
|
201
|
+
lines.push("", "options:", formatOptions(decision), "", formatAnswer(decision));
|
|
202
|
+
if (decision.outcome !== null && decision.outcome.trim().length > 0) {
|
|
203
|
+
lines.push("", "what followed:", indent(decision.outcome));
|
|
204
|
+
}
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Who wrote a version, and through which agent. */
|
|
209
|
+
const writer = (by: { readonly login: string }, agent: string | null): string =>
|
|
210
|
+
agent === null ? by.login : `${agent} for ${by.login}`;
|
|
211
|
+
|
|
212
|
+
/** One document in a list: its path, its title, its version, who wrote it last and when. */
|
|
213
|
+
export function formatDocumentLine(document: RestDocumentSummary): string {
|
|
214
|
+
const notes = [`v${document.version}`, `by ${writer(document.updatedBy, document.agent)}`];
|
|
215
|
+
if (document.archivedAt !== null) {
|
|
216
|
+
notes.push("archived");
|
|
217
|
+
}
|
|
218
|
+
return `${document.path} ${document.title} (${notes.join(", ")}; ${document.updatedAt})`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A folder's folders and pages, or the pages a search found. */
|
|
222
|
+
export function formatDocuments(listing: RestDocuments): string {
|
|
223
|
+
if (listing.folders.length === 0 && listing.items.length === 0) {
|
|
224
|
+
return "No pages here.";
|
|
225
|
+
}
|
|
226
|
+
const lines = listing.folders.map((folder) => `${folder}/`);
|
|
227
|
+
for (const document of listing.items) {
|
|
228
|
+
lines.push(formatDocumentLine(document));
|
|
229
|
+
}
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One document in full: its latest version, what it links to, what refers to it, and its files. */
|
|
234
|
+
export function formatDocument(
|
|
235
|
+
document: RestDocument,
|
|
236
|
+
fileUrl?: (fileId: string) => string,
|
|
237
|
+
): string {
|
|
238
|
+
const lines = [
|
|
239
|
+
`${document.path}: ${document.title}`,
|
|
240
|
+
`version ${document.version}, by ${writer(document.updatedBy, document.agent)} at ${document.updatedAt}${document.archivedAt === null ? "" : `; archived ${document.archivedAt}`}`,
|
|
241
|
+
`id: ${document.id}`,
|
|
242
|
+
];
|
|
243
|
+
if (document.body.trim().length > 0) {
|
|
244
|
+
lines.push("", document.body.trimEnd());
|
|
245
|
+
}
|
|
246
|
+
if (document.links.length > 0) {
|
|
247
|
+
lines.push("", "refers to:");
|
|
248
|
+
for (const link of document.links) {
|
|
249
|
+
lines.push(
|
|
250
|
+
` ${link.path}${link.title === null ? " (no page there yet)" : ` ${link.title}`}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (document.backlinks.length > 0) {
|
|
255
|
+
lines.push("", "referred to by:");
|
|
256
|
+
for (const backlink of document.backlinks) {
|
|
257
|
+
lines.push(
|
|
258
|
+
` ${backlink.kind} ${backlink.kind === "task" ? `#${backlink.number ?? "?"}` : (backlink.path ?? backlink.id)} ${backlink.title}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (document.files.length > 0) {
|
|
263
|
+
lines.push("", "files:");
|
|
264
|
+
for (const attached of document.files) {
|
|
265
|
+
const where = fileUrl === undefined ? "" : `; read at ${fileUrl(attached.id)}`;
|
|
266
|
+
const what = `${attached.file.name} (${attached.file.size} bytes, ${attached.file.contentType}${where})`;
|
|
267
|
+
lines.push(` ${attached.label === null ? what : `${attached.label}: ${what}`}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function formatVersionLine(version: RestVersionSummary): string {
|
|
274
|
+
return `v${version.number} ${version.title} (by ${writer(version.author, version.agent)}; ${version.createdAt})`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One version of a document, with its body as it was. */
|
|
278
|
+
export function formatVersion(path: string, version: RestVersion): string {
|
|
279
|
+
const lines = [`${path}: ${version.title}`, formatVersionLine(version)];
|
|
280
|
+
if (version.body.trim().length > 0) {
|
|
281
|
+
lines.push("", version.body.trimEnd());
|
|
282
|
+
}
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|