eidosmd 0.1.0 → 0.3.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 +50 -29
- package/browser/dist/assets/index-Cc3cNWHY.css +1 -0
- package/browser/dist/assets/index-DQgCQRa5.js +46 -0
- package/browser/dist/favicon.svg +5 -0
- package/browser/dist/index.html +15 -0
- package/browser/dist/mark.svg +4 -0
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/agents.js +1 -1
- package/dist/src/commands/canvas.js +77 -0
- package/dist/src/commands/check.js +10 -4
- package/dist/src/commands/configure.js +201 -0
- package/dist/src/commands/framework.js +37 -7
- package/dist/src/commands/index.js +4 -4
- package/dist/src/commands/init.js +5 -0
- package/dist/src/commands/instructions.js +1 -1
- package/dist/src/commands/list.js +8 -8
- package/dist/src/commands/migrate.js +32 -0
- package/dist/src/commands/new.js +3 -3
- package/dist/src/commands/property.js +125 -0
- package/dist/src/commands/seeds.js +5 -5
- package/dist/src/commands/setup.js +131 -0
- package/dist/src/commands/version.js +44 -0
- package/dist/src/commands/whoami.js +4 -4
- package/dist/src/context.js +5 -5
- package/dist/src/core/blueprint.js +16 -11
- package/dist/src/core/canvas-schema.js +148 -0
- package/dist/src/core/canvas.js +732 -0
- package/dist/src/core/check.js +221 -70
- package/dist/src/core/convert.js +7 -6
- package/dist/src/core/edits.js +1381 -0
- package/dist/src/core/framework-markdown.js +119 -34
- package/dist/src/core/framework-model.js +62 -10
- package/dist/src/core/framework-structured.js +222 -47
- package/dist/src/core/framework.js +13 -13
- package/dist/src/core/frontmatter.js +61 -1
- package/dist/src/core/git.js +84 -0
- package/dist/src/core/index-leaf.js +2 -2
- package/dist/src/core/links.js +87 -0
- package/dist/src/core/markdown.js +16 -9
- package/dist/src/core/me.js +16 -8
- package/dist/src/core/migrate.js +319 -0
- package/dist/src/core/naming.js +1 -1
- package/dist/src/core/regions.js +117 -0
- package/dist/src/core/root.js +2 -2
- package/dist/src/core/scaffold.js +33 -27
- package/dist/src/core/seed.js +187 -71
- package/dist/src/core/server.js +1410 -53
- package/dist/src/core/settings.js +232 -0
- package/dist/src/core/store.js +315 -0
- package/dist/src/core/template.js +32 -0
- package/dist/src/core/versions.js +84 -0
- package/dist/src/output.js +4 -1
- package/dist/src/program.js +421 -41
- package/instructions/authoring.md +15 -12
- package/instructions/configuring.md +81 -34
- package/instructions/init-required.md +4 -4
- package/instructions/overview.md +21 -9
- package/instructions/validating.md +9 -6
- package/package.json +21 -12
- package/standard/EIDOS.md +142 -259
- package/standard/seeds/README.md +12 -16
- package/standard/seeds/book/Framework.yaml +61 -0
- package/standard/seeds/book/README.md +10 -5
- package/standard/seeds/book/_gitignore +9 -3
- package/standard/seeds/book/me.md +1 -1
- package/standard/seeds/book/roles/README.md +3 -3
- package/standard/seeds/book/roles/framework-owner.md +2 -2
- package/standard/seeds/book/{shapes → templates}/chapter.full.md +0 -8
- package/standard/seeds/book/{shapes → templates}/chapter.sketch.md +0 -7
- package/standard/seeds/book/{shapes → templates}/frame.market.md +0 -6
- package/standard/seeds/book/templates/frame.premise.md +17 -0
- package/standard/seeds/book/{shapes → templates}/frame.reader.md +0 -6
- package/standard/seeds/book/{shapes → templates}/frame.voice.md +0 -7
- package/standard/seeds/research/Framework.yaml +61 -0
- package/standard/seeds/research/README.md +10 -5
- package/standard/seeds/research/_gitignore +9 -3
- package/standard/seeds/research/me.md +1 -1
- package/standard/seeds/research/roles/README.md +3 -3
- package/standard/seeds/research/roles/framework-owner.md +2 -2
- package/standard/seeds/research/{shapes → templates}/frame.ethics.md +0 -6
- package/standard/seeds/research/{shapes → templates}/frame.method.md +0 -7
- package/standard/seeds/research/{shapes → templates}/frame.prior-work.md +0 -6
- package/standard/seeds/research/{shapes → templates}/frame.question.md +0 -7
- package/standard/seeds/research/{shapes → templates}/investigation.full.md +0 -8
- package/standard/seeds/research/{shapes → templates}/investigation.note.md +0 -7
- package/standard/seeds/software/Framework.yaml +62 -0
- package/standard/seeds/software/README.md +7 -6
- package/standard/seeds/software/_gitignore +9 -3
- package/standard/seeds/software/me.md +1 -1
- package/standard/seeds/software/roles/README.md +3 -3
- package/standard/seeds/software/roles/framework-owner.md +2 -2
- package/standard/seeds/software/roles/project-manager.md +2 -2
- package/standard/seeds/software/roles/stakeholder.md +1 -1
- package/standard/seeds/software/{shapes → templates}/frame.architecture.md +0 -7
- package/standard/seeds/software/{shapes → templates}/frame.audience.md +1 -8
- package/standard/seeds/software/{shapes → templates}/frame.criteria.md +0 -8
- package/standard/seeds/software/{shapes → templates}/frame.market.md +0 -8
- package/standard/seeds/software/{shapes → templates}/spec.full.md +0 -8
- package/standard/seeds/software/{shapes → templates}/spec.micro.md +0 -9
- package/browser/index.html +0 -268
- package/dist/src/commands/convert.js +0 -30
- package/dist/src/core/shape.js +0 -26
- package/standard/seeds/book/Framework.md +0 -87
- package/standard/seeds/book/shapes/frame.premise.md +0 -24
- package/standard/seeds/research/Framework.md +0 -88
- package/standard/seeds/software/Framework.md +0 -88
- /package/standard/seeds/software/{shapes → templates}/.gitkeep +0 -0
package/dist/src/core/server.js
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
// The local browser's server: a plain Node http server on 127.0.0.1 serving
|
|
2
|
-
//
|
|
3
|
-
// through the same core function the matching command uses (`new`
|
|
4
|
-
// `index` rebuilds, `check` validates), so nothing is possible
|
|
5
|
-
// not possible from the shell.
|
|
6
|
-
//
|
|
2
|
+
// the built page (browser/dist) and a JSON API. Every write the page can make
|
|
3
|
+
// goes through the same core function the matching command uses (`new`
|
|
4
|
+
// scaffolds, `index` rebuilds, `check` validates), so nothing is possible
|
|
5
|
+
// here that is not possible from the shell. Reads come from the store, which
|
|
6
|
+
// keeps the parsed root until a file changes and indexes it for search.
|
|
7
7
|
import { createServer as createHttpServer } from 'node:http';
|
|
8
|
-
import {
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
9
11
|
import path from 'node:path';
|
|
10
12
|
import { marked } from 'marked';
|
|
11
|
-
import { loadRoot } from '../context.js';
|
|
12
13
|
import { checkRoot } from './check.js';
|
|
13
14
|
import { buildIndexes } from './index-leaf.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
15
|
+
import { applyPlan, editContext, EditError, moveWithLinks, planDocAdd, planDocRemove, renameRoleFiles } from './edits.js';
|
|
16
|
+
import { embeddedIndexOf, frameworkToDocument, parseFrameworkStructured, serializeDocument, withEmbeddedIndex } from './framework-structured.js';
|
|
17
|
+
import { FRAMEWORK_DIR, FrameworkError, propertiesFor } from './framework.js';
|
|
18
|
+
import { readVersions, recordVersion, tagPrefix, VersionError } from './versions.js';
|
|
19
|
+
import { moveFile, gitHead, gitLog, gitShowFile, gitUser } from './git.js';
|
|
20
|
+
import { actingUser, matchesIdentity, readSettings, writeSettings } from './settings.js';
|
|
21
|
+
import { formatFrontmatter } from './frontmatter.js';
|
|
22
|
+
import { findRole, isRoleName, listRoles, meFile, readActor, renderActor, roleFile, writeActor } from './me.js';
|
|
16
23
|
import { scaffoldBlueprint, ScaffoldError } from './scaffold.js';
|
|
17
|
-
import { blueprintId, blueprintTitle, propertyString,
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
24
|
+
import { blueprintId, blueprintTitle, propertyString, resolveVariant } from './blueprint.js';
|
|
25
|
+
import { loadTemplate, stripFrontmatter } from './template.js';
|
|
26
|
+
import { unitOf } from './framework-model.js';
|
|
27
|
+
import { regionsAsHtml } from './regions.js';
|
|
28
|
+
import { Store } from './store.js';
|
|
29
|
+
import { CliError, today } from '../output.js';
|
|
20
30
|
import { browserDir, packageVersion, readStandard, standardVersion } from '../paths.js';
|
|
21
31
|
import { guideText, GUIDES } from '../commands/instructions.js';
|
|
22
|
-
import {
|
|
32
|
+
import { ANCHORS, EDGE_CAPS, CanvasError, createCanvas, findEndpoint, findNode, FRAME_REF, findPage, listCanvases, loadCanvas, nextCommentId, nextNodeId, renderComments, renderPage, replacePageContents, saveCanvas, toJsonCanvas, canvasProperties, readBox, readStyle, sketchValues } from './canvas.js';
|
|
23
33
|
class HttpError extends Error {
|
|
24
34
|
status;
|
|
25
35
|
constructor(status, message) {
|
|
@@ -34,9 +44,11 @@ function json(response, status, body) {
|
|
|
34
44
|
}
|
|
35
45
|
async function readBody(request) {
|
|
36
46
|
const chunks = [];
|
|
47
|
+
let size = 0;
|
|
37
48
|
for await (const chunk of request) {
|
|
38
49
|
chunks.push(chunk);
|
|
39
|
-
|
|
50
|
+
size += chunk.length;
|
|
51
|
+
if (size > 8 * 1024 * 1024) {
|
|
40
52
|
throw new HttpError(413, 'request body too large');
|
|
41
53
|
}
|
|
42
54
|
}
|
|
@@ -55,15 +67,16 @@ async function readBody(request) {
|
|
|
55
67
|
function rel(root, file) {
|
|
56
68
|
return path.relative(root, file).split(path.sep).join('/');
|
|
57
69
|
}
|
|
70
|
+
const str = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null);
|
|
58
71
|
function blueprintSummary(blueprint) {
|
|
59
|
-
const {
|
|
72
|
+
const { variant, declared } = resolveVariant(blueprint);
|
|
60
73
|
return {
|
|
61
74
|
id: blueprintId(blueprint),
|
|
62
75
|
title: blueprintTitle(blueprint),
|
|
63
76
|
summary: propertyString(blueprint, 'summary'),
|
|
64
77
|
collection: blueprint.collection.name,
|
|
65
78
|
group: blueprint.group,
|
|
66
|
-
|
|
79
|
+
variant: variant?.name ?? declared,
|
|
67
80
|
path: blueprint.rel,
|
|
68
81
|
};
|
|
69
82
|
}
|
|
@@ -79,38 +92,187 @@ function findByPath(context, relPath) {
|
|
|
79
92
|
function findingsFor(context, blueprint) {
|
|
80
93
|
return checkRoot(context.framework, context.blueprints, { only: new Set([blueprint.path]), standardVersion: standardVersion() }).findings.filter((finding) => finding.path === blueprint.rel);
|
|
81
94
|
}
|
|
95
|
+
function propertyView(property) {
|
|
96
|
+
return { name: property.name, type: property.type, core: property.core, owner: property.owner, required: property.required, options: property.options ?? null, meaning: property.meaning, applies_to: property.appliesTo, canvas: property.canvas ?? null, tools: property.tools ?? {} };
|
|
97
|
+
}
|
|
98
|
+
// Markdown a person reads in the root, rendered: a tool's region is shown as
|
|
99
|
+
// it is, never rendered, since its contents are the tool's to draw.
|
|
100
|
+
function renderMarkdown(text) {
|
|
101
|
+
return marked.parse(regionsAsHtml(text), { async: false });
|
|
102
|
+
}
|
|
103
|
+
// The values a property already takes across a collection, for the editor
|
|
104
|
+
// to offer; a canvas hint's keys count as values too. A property that
|
|
105
|
+
// declares options offers them first, in their declared order, then any
|
|
106
|
+
// value in use that the list does not hold, so a filter still finds it.
|
|
107
|
+
function knownValues(context, collectionName) {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const property of propertiesFor(context.framework, collectionName)) {
|
|
110
|
+
if (!['Text', 'List'].includes(property.type) || ['id', 'title', 'summary'].includes(property.name))
|
|
111
|
+
continue;
|
|
112
|
+
if (property.type === 'List' && !property.options)
|
|
113
|
+
continue;
|
|
114
|
+
const values = new Set();
|
|
115
|
+
for (const key of Object.keys(property.canvas?.styles ?? {}))
|
|
116
|
+
values.add(key);
|
|
117
|
+
for (const blueprint of context.blueprints) {
|
|
118
|
+
if (blueprint.collection.name !== collectionName)
|
|
119
|
+
continue;
|
|
120
|
+
for (const value of heldValues(blueprint, property.name))
|
|
121
|
+
values.add(value);
|
|
122
|
+
}
|
|
123
|
+
const listed = withOptionsFirst(property, values);
|
|
124
|
+
if (listed.length > 0)
|
|
125
|
+
out[property.name] = listed;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
// The strings a blueprint holds under a property: the Text value, or a List's elements.
|
|
130
|
+
function heldValues(blueprint, name) {
|
|
131
|
+
const held = blueprint.properties?.[name];
|
|
132
|
+
if (Array.isArray(held))
|
|
133
|
+
return held.filter((item) => typeof item === 'string' && item.trim() !== '');
|
|
134
|
+
const value = propertyString(blueprint, name);
|
|
135
|
+
return value === null ? [] : [value];
|
|
136
|
+
}
|
|
137
|
+
function withOptionsFirst(property, values) {
|
|
138
|
+
const rest = [...values].filter((value) => !property.options?.includes(value)).sort((a, b) => a.localeCompare(b));
|
|
139
|
+
return [...(property.options ?? []), ...rest];
|
|
140
|
+
}
|
|
82
141
|
function blueprintDetail(context, blueprint) {
|
|
83
142
|
const raw = readFileSync(blueprint.path, 'utf8');
|
|
84
|
-
const {
|
|
85
|
-
const
|
|
143
|
+
const { variant } = resolveVariant(blueprint);
|
|
144
|
+
const template = variant ? loadTemplate(context.framework, variant) : null;
|
|
86
145
|
return {
|
|
87
146
|
...blueprintSummary(blueprint),
|
|
88
147
|
properties: blueprint.properties ?? {},
|
|
89
148
|
frontmatter_error: blueprint.frontmatterError,
|
|
149
|
+
schema: propertiesFor(context.framework, blueprint.collection.name).map(propertyView),
|
|
150
|
+
known_values: knownValues(context, blueprint.collection.name),
|
|
90
151
|
sections: blueprint.sections.map((section) => section.heading.text),
|
|
91
|
-
|
|
92
|
-
|
|
152
|
+
template_sections: template?.sections ?? [],
|
|
153
|
+
body: blueprint.body,
|
|
154
|
+
html: renderMarkdown(blueprint.body),
|
|
93
155
|
raw,
|
|
94
156
|
findings: findingsFor(context, blueprint),
|
|
95
157
|
};
|
|
96
158
|
}
|
|
97
|
-
|
|
159
|
+
// Whether git is read at all here: the root's switch, and then whether the
|
|
160
|
+
// root sits in a repository and who git says is here.
|
|
161
|
+
function gitInfo(root, shared = readSettings(root).shared) {
|
|
162
|
+
const enabled = shared.features.git;
|
|
163
|
+
const head = enabled ? gitHead(root) : null;
|
|
164
|
+
return { enabled, available: head !== null, head, user: head === null ? null : gitUser(root) };
|
|
165
|
+
}
|
|
166
|
+
// A top-level document: README.md or one the framework declares, addressed
|
|
167
|
+
// by its path from the root. Nothing else at the root is served as a doc.
|
|
168
|
+
function topLevelFile(context, relPath) {
|
|
169
|
+
const wanted = decodeURIComponent(relPath).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
170
|
+
const file = path.resolve(context.root, wanted);
|
|
171
|
+
const declared = new Set([path.resolve(context.root, 'README.md'), ...context.framework.topLevel.map((doc) => path.resolve(path.dirname(context.framework.file), doc.path))]);
|
|
172
|
+
if (!file.startsWith(context.root + path.sep) || !file.endsWith('.md') || !declared.has(file)) {
|
|
173
|
+
throw new HttpError(404, `no top-level document at ${wanted}`);
|
|
174
|
+
}
|
|
175
|
+
const doc = context.framework.topLevel.find((entry) => path.resolve(path.dirname(context.framework.file), entry.path) === file);
|
|
176
|
+
return { file, rel: rel(context.root, file), title: doc?.title ?? path.basename(file, '.md'), description: doc?.description ?? '', declared: doc !== undefined };
|
|
177
|
+
}
|
|
178
|
+
function docView(context, relPath) {
|
|
179
|
+
const { file, rel: relative, title, description, declared } = topLevelFile(context, relPath);
|
|
180
|
+
if (!existsSync(file))
|
|
181
|
+
throw new HttpError(404, `${relative} does not exist`);
|
|
182
|
+
const raw = readFileSync(file, 'utf8');
|
|
183
|
+
const git = gitInfo(context.root);
|
|
184
|
+
return { path: relative, title, description, declared, html: renderMarkdown(raw), raw, git: git.available, history: git.available ? gitLog(context.root, relative, 20) : [] };
|
|
185
|
+
}
|
|
186
|
+
function rootSnapshot(context, store) {
|
|
98
187
|
const actor = readActor(context.root);
|
|
188
|
+
const settings = readSettings(context.root);
|
|
189
|
+
const git = gitInfo(context.root, settings.shared);
|
|
99
190
|
return {
|
|
100
191
|
root: context.root,
|
|
101
192
|
name: path.basename(context.root),
|
|
102
193
|
cli_version: packageVersion(),
|
|
103
194
|
standard_version: standardVersion(),
|
|
195
|
+
search_engine: store.engine,
|
|
196
|
+
// the machine the server runs on, for the page to name its file manager
|
|
197
|
+
platform: process.platform,
|
|
198
|
+
git_head: git.head,
|
|
199
|
+
git,
|
|
200
|
+
settings,
|
|
104
201
|
framework: { file: rel(context.root, context.framework.file), format: context.framework.format, problems: context.framework.problems, ...frameworkToDocument(context.framework) },
|
|
105
202
|
blueprints: context.blueprints.map(blueprintSummary),
|
|
106
203
|
roles: listRoles(context.root).map((role) => ({ name: role.name, title: role.title, summary: role.summary })),
|
|
204
|
+
// the health check's totals, for the sidebar's badge, and whether this
|
|
205
|
+
// root counts a warning as a failure
|
|
206
|
+
check: (() => {
|
|
207
|
+
const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
|
|
208
|
+
return { errors: report.errors, warnings: report.warnings, strict: settings.shared.check.strict };
|
|
209
|
+
})(),
|
|
210
|
+
canvases: listCanvases(context.framework).map((canvas) => ({ id: canvas.id, title: canvas.title, pages: canvas.pages.length })),
|
|
107
211
|
actor: { exists: actor.exists, role: actor.actor.role, ownership: actor.actor.ownership, experience: actor.actor.experience, capacity: actor.actor.capacity, path: rel(context.root, meFile(context.root)) },
|
|
108
212
|
guides: GUIDES,
|
|
109
213
|
};
|
|
110
214
|
}
|
|
215
|
+
// One collection as a table: the schema's properties for this collection as
|
|
216
|
+
// columns, one row per blueprint with everything its frontmatter holds.
|
|
217
|
+
function collectionTable(context, name) {
|
|
218
|
+
const collection = context.framework.collections.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase());
|
|
219
|
+
if (!collection) {
|
|
220
|
+
throw new HttpError(404, `no collection '${name}'`);
|
|
221
|
+
}
|
|
222
|
+
const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
|
|
223
|
+
const counts = new Map();
|
|
224
|
+
for (const finding of report.findings) {
|
|
225
|
+
if (!finding.path)
|
|
226
|
+
continue;
|
|
227
|
+
const entry = counts.get(finding.path) ?? { errors: 0, warnings: 0 };
|
|
228
|
+
if (finding.level === 'error')
|
|
229
|
+
entry.errors += 1;
|
|
230
|
+
else
|
|
231
|
+
entry.warnings += 1;
|
|
232
|
+
counts.set(finding.path, entry);
|
|
233
|
+
}
|
|
234
|
+
const rows = context.blueprints
|
|
235
|
+
.filter((blueprint) => blueprint.collection === collection)
|
|
236
|
+
.map((blueprint) => ({
|
|
237
|
+
...blueprintSummary(blueprint),
|
|
238
|
+
properties: blueprint.properties ?? {},
|
|
239
|
+
frontmatter_error: blueprint.frontmatterError,
|
|
240
|
+
findings: counts.get(blueprint.rel) ?? { errors: 0, warnings: 0 },
|
|
241
|
+
}));
|
|
242
|
+
return {
|
|
243
|
+
collection: {
|
|
244
|
+
name: collection.name,
|
|
245
|
+
description: collection.description,
|
|
246
|
+
unit: unitOf(collection),
|
|
247
|
+
framing: collection.framing,
|
|
248
|
+
// variants whose template file does not exist: nothing to scaffold a new blueprint from
|
|
249
|
+
missing_templates: collection.variants.filter((variant) => !existsSync(path.join(context.framework.root, FRAMEWORK_DIR, variant.template))).map((variant) => variant.name),
|
|
250
|
+
variants: collection.variants.map((variant) => ({ name: variant.name, default: variant.isDefault, description: variant.description })),
|
|
251
|
+
grouping: collection.grouping ? { label: collection.grouping.label, property: collection.grouping.property, groups: collection.grouping.groups } : null,
|
|
252
|
+
},
|
|
253
|
+
columns: propertiesFor(context.framework, collection.name).map(propertyView),
|
|
254
|
+
known_values: knownValues(context, collection.name),
|
|
255
|
+
rows,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// The values a property takes anywhere in the root, for a sketch's fields;
|
|
259
|
+
// declared options first, in order.
|
|
260
|
+
function knownValuesAll(context, name) {
|
|
261
|
+
const values = new Set();
|
|
262
|
+
const property = context.framework.schema.custom.find((candidate) => candidate.name === name);
|
|
263
|
+
for (const key of Object.keys(property?.canvas?.styles ?? {}))
|
|
264
|
+
values.add(key);
|
|
265
|
+
for (const blueprint of context.blueprints) {
|
|
266
|
+
for (const value of heldValues(blueprint, name))
|
|
267
|
+
values.add(value);
|
|
268
|
+
}
|
|
269
|
+
return property ? withOptionsFirst(property, values) : [...values];
|
|
270
|
+
}
|
|
111
271
|
function checkSnapshot(context) {
|
|
112
272
|
const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
|
|
113
273
|
return {
|
|
274
|
+
// whether this root counts a warning as a failure: the owner's setting
|
|
275
|
+
strict: readSettings(context.root).shared.check.strict,
|
|
114
276
|
root: report.root,
|
|
115
277
|
eidos_version: report.eidosVersion,
|
|
116
278
|
naming: report.naming,
|
|
@@ -128,6 +290,118 @@ function indexRebuild(context) {
|
|
|
128
290
|
missing_summary: results.flatMap((result) => result.missingSummary),
|
|
129
291
|
};
|
|
130
292
|
}
|
|
293
|
+
// --- the framework, templates, and me.md, for the settings panel -----------
|
|
294
|
+
function templatesView(context) {
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const collection of context.framework.collections) {
|
|
297
|
+
for (const variant of collection.variants) {
|
|
298
|
+
const template = loadTemplate(context.framework, variant);
|
|
299
|
+
out.push({ collection: collection.name, variant: variant.name, path: variant.template, exists: template !== null, sections: template?.sections ?? [] });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
function frameworkView(context) {
|
|
305
|
+
const { framework } = context;
|
|
306
|
+
return {
|
|
307
|
+
file: rel(context.root, framework.file),
|
|
308
|
+
format: framework.format,
|
|
309
|
+
problems: framework.problems,
|
|
310
|
+
document: frameworkToDocument(framework),
|
|
311
|
+
templates: templatesView(context),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// The whole document replaced, the generated index carried over untouched.
|
|
315
|
+
// A document that cannot be read at all is refused; one with problems is
|
|
316
|
+
// written and the problems returned, as `check` would report them.
|
|
317
|
+
// Settings renames a collection or a template through its document; the files
|
|
318
|
+
// follow: a collection's folder, and a template's file, moved when the old one
|
|
319
|
+
// exists and the new one does not. Nothing is overwritten.
|
|
320
|
+
function applyRenames(context, given) {
|
|
321
|
+
if (!given || typeof given !== 'object')
|
|
322
|
+
return;
|
|
323
|
+
const renames = given;
|
|
324
|
+
const inside = (base, target) => target.startsWith(base + path.sep) && !path.relative(base, target).includes('..');
|
|
325
|
+
// a top-level doc's file follows its title, and every link to it follows the file
|
|
326
|
+
const docMoves = [];
|
|
327
|
+
for (const [from, to] of Object.entries(renames.docs ?? {})) {
|
|
328
|
+
const source = path.resolve(context.root, from);
|
|
329
|
+
const target = path.resolve(context.root, to);
|
|
330
|
+
if (!inside(context.root, source) || !inside(context.root, target) || source === target || path.dirname(source) !== context.root || path.dirname(target) !== context.root || !/\.md$/i.test(target))
|
|
331
|
+
continue;
|
|
332
|
+
if (existsSync(source) && !existsSync(target))
|
|
333
|
+
docMoves.push({ from: source, to: target });
|
|
334
|
+
}
|
|
335
|
+
if (docMoves.length > 0)
|
|
336
|
+
moveWithLinks(context.root, docMoves);
|
|
337
|
+
const templatesDir = path.join(context.root, FRAMEWORK_DIR, 'templates');
|
|
338
|
+
for (const [from, to] of Object.entries(renames.templates ?? {})) {
|
|
339
|
+
const source = path.resolve(context.root, FRAMEWORK_DIR, from);
|
|
340
|
+
const target = path.resolve(context.root, FRAMEWORK_DIR, to);
|
|
341
|
+
if (!inside(templatesDir, source) || !inside(templatesDir, target) || source === target)
|
|
342
|
+
continue;
|
|
343
|
+
if (existsSync(source) && !existsSync(target))
|
|
344
|
+
moveFile(context.root, source, target);
|
|
345
|
+
}
|
|
346
|
+
for (const [from, to] of Object.entries(renames.folders ?? {})) {
|
|
347
|
+
const source = path.resolve(context.root, from);
|
|
348
|
+
const target = path.resolve(context.root, to);
|
|
349
|
+
if (!inside(context.root, source) || !inside(context.root, target) || source === target || path.dirname(source) !== context.root || path.dirname(target) !== context.root)
|
|
350
|
+
continue;
|
|
351
|
+
if (existsSync(source) && !existsSync(target))
|
|
352
|
+
moveFile(context.root, source, target);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Settings removes a top-level doc's file when the entry is removed on
|
|
356
|
+
// purpose: one <name>.md straight under the root, never anything else.
|
|
357
|
+
function applyDeletes(context, given) {
|
|
358
|
+
if (!given || typeof given !== 'object')
|
|
359
|
+
return;
|
|
360
|
+
const deletes = given;
|
|
361
|
+
for (const name of Array.isArray(deletes.docs) ? deletes.docs : []) {
|
|
362
|
+
if (typeof name !== 'string' || !/^[^/\\]+\.md$/i.test(name) || name.startsWith('.'))
|
|
363
|
+
continue;
|
|
364
|
+
const file = path.join(context.root, name);
|
|
365
|
+
if (existsSync(file))
|
|
366
|
+
unlinkSync(file);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function saveFramework(context, body) {
|
|
370
|
+
const given = (typeof body['document'] === 'object' && body['document'] !== null ? body['document'] : body);
|
|
371
|
+
if (!Array.isArray(given['folders'])) {
|
|
372
|
+
throw new HttpError(400, 'expected { document } with a folders list');
|
|
373
|
+
}
|
|
374
|
+
const document = { ...given };
|
|
375
|
+
delete document['index'];
|
|
376
|
+
let text = serializeDocument(document);
|
|
377
|
+
const current = readFileSync(context.framework.file, 'utf8');
|
|
378
|
+
const index = embeddedIndexOf(current);
|
|
379
|
+
if (index) {
|
|
380
|
+
text = withEmbeddedIndex(text, index);
|
|
381
|
+
}
|
|
382
|
+
let parsed;
|
|
383
|
+
try {
|
|
384
|
+
parsed = parseFrameworkStructured(text, context.root, context.framework.file);
|
|
385
|
+
}
|
|
386
|
+
catch (cause) {
|
|
387
|
+
if (cause instanceof FrameworkError)
|
|
388
|
+
throw new HttpError(400, cause.message);
|
|
389
|
+
throw cause;
|
|
390
|
+
}
|
|
391
|
+
writeFileSync(context.framework.file, text, 'utf8');
|
|
392
|
+
return { file: rel(context.root, context.framework.file), problems: parsed.problems };
|
|
393
|
+
}
|
|
394
|
+
// A template file, addressed relative to `.eidos/` as the framework declares it.
|
|
395
|
+
function templateFile(context, relPath) {
|
|
396
|
+
const wanted = decodeURIComponent(relPath).replace(/\\/g, '/');
|
|
397
|
+
const base = path.join(context.root, FRAMEWORK_DIR);
|
|
398
|
+
const file = path.resolve(base, wanted);
|
|
399
|
+
if (!file.startsWith(base + path.sep) || !file.endsWith('.md')) {
|
|
400
|
+
throw new HttpError(400, `a template lives under ${FRAMEWORK_DIR}/ and ends in .md`);
|
|
401
|
+
}
|
|
402
|
+
return file;
|
|
403
|
+
}
|
|
404
|
+
// --- blueprint writes -----------------------------------------------------------
|
|
131
405
|
function createBlueprint(context, body) {
|
|
132
406
|
const text = (value) => (typeof value === 'string' && value.trim() !== '' ? value : null);
|
|
133
407
|
const set = {};
|
|
@@ -143,7 +417,7 @@ function createBlueprint(context, body) {
|
|
|
143
417
|
result = scaffoldBlueprint(context.framework, context.blueprints, {
|
|
144
418
|
collection: text(body['collection']) ?? '',
|
|
145
419
|
title: text(body['title']) ?? '',
|
|
146
|
-
|
|
420
|
+
variant: text(body['variant']),
|
|
147
421
|
group: text(body['group']),
|
|
148
422
|
id: text(body['id']),
|
|
149
423
|
summary: text(body['summary']),
|
|
@@ -159,67 +433,1148 @@ function createBlueprint(context, body) {
|
|
|
159
433
|
}
|
|
160
434
|
mkdirSync(path.dirname(result.path), { recursive: true });
|
|
161
435
|
writeFileSync(result.path, result.content, 'utf8');
|
|
162
|
-
return { path: result.rel, id: result.id, collection: result.collection.name, group: result.group,
|
|
436
|
+
return { path: result.rel, id: result.id, collection: result.collection.name, group: result.group, variant: result.variant.name, notes: result.notes };
|
|
437
|
+
}
|
|
438
|
+
// `{ text }` replaces the file; `{ properties, body }` writes the frontmatter
|
|
439
|
+
// in the house style from an ordered list of [key, value] pairs (or an object,
|
|
440
|
+
// in its key order) above the given body.
|
|
441
|
+
function blueprintText(body, rules = []) {
|
|
442
|
+
if (typeof body['text'] === 'string') {
|
|
443
|
+
return body['text'].replace(/\r\n?/g, '\n');
|
|
444
|
+
}
|
|
445
|
+
const properties = body['properties'];
|
|
446
|
+
const bodyText = typeof body['body'] === 'string' ? body['body'].replace(/\r\n?/g, '\n') : null;
|
|
447
|
+
if (properties === undefined || bodyText === null) {
|
|
448
|
+
throw new HttpError(400, 'expected { text } or { properties, body }');
|
|
449
|
+
}
|
|
450
|
+
let entries;
|
|
451
|
+
if (Array.isArray(properties)) {
|
|
452
|
+
entries = properties
|
|
453
|
+
.filter((pair) => Array.isArray(pair) && typeof pair[0] === 'string' && pair[0].trim() !== '')
|
|
454
|
+
.map((pair) => [String(pair[0]).trim(), pair[1]]);
|
|
455
|
+
}
|
|
456
|
+
else if (typeof properties === 'object' && properties !== null) {
|
|
457
|
+
entries = Object.entries(properties);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
throw new HttpError(400, 'properties must be a list of [key, value] pairs or an object');
|
|
461
|
+
}
|
|
462
|
+
// The root's on-save rules: a named property takes the date or the time,
|
|
463
|
+
// added at the end when the file lacked it.
|
|
464
|
+
for (const rule of rules) {
|
|
465
|
+
const value = rule.set === 'today' ? today() : localMinute();
|
|
466
|
+
const at = entries.findIndex(([key]) => key === rule.property);
|
|
467
|
+
if (at === -1)
|
|
468
|
+
entries.push([rule.property, value]);
|
|
469
|
+
else
|
|
470
|
+
entries[at] = [rule.property, value];
|
|
471
|
+
}
|
|
472
|
+
const frontmatter = formatFrontmatter(entries);
|
|
473
|
+
return frontmatter + (bodyText.startsWith('\n') ? bodyText : '\n' + bodyText);
|
|
474
|
+
}
|
|
475
|
+
// `YYYY-MM-DDTHH:mm` in local time, the Date & time form the standard names.
|
|
476
|
+
function localMinute() {
|
|
477
|
+
const now = new Date();
|
|
478
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
479
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
163
480
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
481
|
+
// --- canvas maps ---------------------------------------------------------------
|
|
482
|
+
function canvasSummary(context, canvas) {
|
|
483
|
+
return {
|
|
484
|
+
id: canvas.id,
|
|
485
|
+
title: canvas.title,
|
|
486
|
+
path: rel(context.root, canvas.file),
|
|
487
|
+
pages: canvas.pages.map((page) => ({ id: page.id, title: page.title, nodes: page.nodes.length, edges: page.edges.length })),
|
|
488
|
+
problems: canvas.problems,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// The comment author: the user this machine acts as, else the user the git
|
|
492
|
+
// identity matches (by email, then name), else the git name with the me.md
|
|
493
|
+
// role beside it, else that role alone, else "me". Yes, it can be faked;
|
|
494
|
+
// it is attribution, not authentication.
|
|
495
|
+
function commentAuthor(context) {
|
|
496
|
+
const settings = readSettings(context.root);
|
|
497
|
+
const meRole = readActor(context.root).actor.role;
|
|
498
|
+
const acting = actingUser(settings);
|
|
499
|
+
if (acting)
|
|
500
|
+
return { name: acting.name, role: acting.role ?? meRole, user: acting.alias ?? acting.name };
|
|
501
|
+
const git = gitInfo(context.root, settings.shared);
|
|
502
|
+
if (git.user) {
|
|
503
|
+
const identity = git.user;
|
|
504
|
+
const match = settings.shared.users.find((user) => matchesIdentity(user, identity));
|
|
505
|
+
if (match)
|
|
506
|
+
return { name: match.name, role: match.role ?? meRole, user: match.alias ?? match.name };
|
|
507
|
+
return { name: git.user.name, role: meRole, user: null };
|
|
168
508
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
509
|
+
return { name: meRole ?? 'me', role: null, user: null };
|
|
510
|
+
}
|
|
511
|
+
const signed = (author) => ({ author: author.name, ...(author.role ? { role: author.role } : {}), ...(author.user ? { user: author.user } : {}) });
|
|
512
|
+
function pageView(context, canvas, page) {
|
|
513
|
+
const nodes = renderPage(context.framework, context.blueprints, page);
|
|
514
|
+
return {
|
|
515
|
+
canvas: canvasSummary(context, canvas),
|
|
516
|
+
// `raw` is the page as the file holds it: what the browser's undo puts back
|
|
517
|
+
page: { id: page.id, title: page.title, frames: page.frames, edges: page.edges, raw: { frames: page.frames, nodes: page.nodes, edges: page.edges, layout: page.layout, comments: page.comments } },
|
|
518
|
+
nodes,
|
|
519
|
+
comments: renderComments(page, nodes),
|
|
520
|
+
author: commentAuthor(context),
|
|
521
|
+
// each styling property with its value styles, and the color per value alone for the legend
|
|
522
|
+
palette: canvasProperties(context.framework).map((property) => {
|
|
523
|
+
const styles = property.canvas.styles ?? {};
|
|
524
|
+
const color = Object.fromEntries(Object.entries(styles).flatMap(([value, style]) => (style.color ? [[value, style.color]] : [])));
|
|
525
|
+
return { property: property.name, type: property.type, applies_to: property.appliesTo, styles, color: Object.keys(color).length > 0 ? color : null, show: property.canvas.show === true };
|
|
526
|
+
}),
|
|
527
|
+
// schema properties a sketch may hold: those applying to every collection, with the values the root uses
|
|
528
|
+
sketch_properties: propertiesFor(context.framework, '')
|
|
529
|
+
.filter((property) => !property.core && property.appliesTo === 'all')
|
|
530
|
+
.map((property) => ({ ...propertyView(property), values: knownValuesAll(context, property.name) })),
|
|
531
|
+
collections: context.framework.collections.map((collection) => ({
|
|
532
|
+
name: collection.name,
|
|
533
|
+
variants: collection.variants.map((variant) => ({ name: variant.name, default: variant.isDefault })),
|
|
534
|
+
groups: collection.grouping?.groups.map((group) => group.name) ?? [],
|
|
535
|
+
})),
|
|
536
|
+
git: gitInfo(context.root).available,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function mutateCanvas(context, canvasId, change) {
|
|
540
|
+
const canvas = loadCanvas(context.framework, canvasId);
|
|
541
|
+
change(canvas);
|
|
542
|
+
saveCanvas(canvas);
|
|
543
|
+
return loadCanvas(context.framework, canvasId);
|
|
544
|
+
}
|
|
545
|
+
const slug = (title, fallback) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || fallback;
|
|
546
|
+
// Every canvas route, dispatched on the rest of the path after /api/canvas.
|
|
547
|
+
async function handleCanvas(store, method, segments, request, response) {
|
|
548
|
+
const [canvasId, ...rest] = segments;
|
|
549
|
+
if (canvasId === undefined || canvasId === '') {
|
|
550
|
+
if (method === 'GET') {
|
|
551
|
+
const current = store.context();
|
|
552
|
+
json(response, 200, listCanvases(current.framework).map((canvas) => canvasSummary(current, canvas)));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (method === 'POST') {
|
|
556
|
+
const body = await readBody(request);
|
|
557
|
+
const current = store.context();
|
|
558
|
+
json(response, 201, canvasSummary(current, createCanvas(current.framework, str(body['title']) ?? '')));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
throw new HttpError(404, 'not found');
|
|
562
|
+
}
|
|
563
|
+
let current = store.context();
|
|
564
|
+
// GET /api/canvas/<id> the canvas and its first page
|
|
565
|
+
// PUT /api/canvas/<id> { title }
|
|
566
|
+
// DELETE /api/canvas/<id>
|
|
567
|
+
if (rest.length === 0) {
|
|
568
|
+
if (method === 'GET') {
|
|
569
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
570
|
+
const page = canvas.pages[0];
|
|
571
|
+
json(response, 200, page ? pageView(current, canvas, page) : { canvas: canvasSummary(current, canvas), page: null, nodes: [] });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (method === 'PUT') {
|
|
575
|
+
const body = await readBody(request);
|
|
576
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
577
|
+
const title = str(body['title']);
|
|
578
|
+
if (!title)
|
|
579
|
+
throw new CanvasError('a canvas needs a title');
|
|
580
|
+
draft.title = title;
|
|
581
|
+
});
|
|
582
|
+
json(response, 200, canvasSummary(current, canvas));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (method === 'DELETE') {
|
|
586
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
587
|
+
rmSync(canvas.file);
|
|
588
|
+
json(response, 200, { deleted: canvas.id });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
throw new HttpError(404, 'not found');
|
|
592
|
+
}
|
|
593
|
+
if (rest[0] === 'export' && method === 'GET') {
|
|
594
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
595
|
+
const page = findPage(canvas, rest[1] ?? canvas.pages[0]?.id ?? '');
|
|
596
|
+
const text = JSON.stringify(toJsonCanvas(current.framework, current.blueprints, page), null, 2);
|
|
597
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-disposition': `attachment; filename="${canvas.id}-${page.id}.canvas"` });
|
|
598
|
+
response.end(text);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (rest[0] === 'pages' && rest.length === 1 && method === 'POST') {
|
|
602
|
+
const body = await readBody(request);
|
|
603
|
+
const title = str(body['title']) ?? 'Untitled';
|
|
604
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
605
|
+
const id = str(body['id']) ?? slug(title, `page-${draft.pages.length + 1}`);
|
|
606
|
+
if (draft.pages.some((page) => page.id === id))
|
|
607
|
+
throw new CanvasError(`page '${id}' already exists`, 409);
|
|
608
|
+
draft.pages.push({ id, title, frames: [], nodes: [], edges: [], layout: {}, comments: [] });
|
|
609
|
+
});
|
|
610
|
+
json(response, 201, canvasSummary(current, canvas));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (rest[0] !== 'pages' || rest[1] === undefined) {
|
|
614
|
+
throw new HttpError(404, 'not found');
|
|
615
|
+
}
|
|
616
|
+
const pageId = rest[1];
|
|
617
|
+
const tail = rest.slice(2);
|
|
618
|
+
if (tail.length === 0 && method === 'GET') {
|
|
619
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
620
|
+
json(response, 200, pageView(current, canvas, findPage(canvas, pageId)));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const body = method === 'GET' ? {} : await readBody(request);
|
|
624
|
+
// PUT pages/<p> { title } renames; { page: { frames, nodes, edges, layout, comments } } replaces the contents whole (undo, multi-select edits)
|
|
625
|
+
if (tail.length === 0 && method === 'PUT') {
|
|
626
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
627
|
+
const page = findPage(draft, pageId);
|
|
628
|
+
const title = str(body['title']);
|
|
629
|
+
if (title)
|
|
630
|
+
page.title = title;
|
|
631
|
+
if (typeof body['page'] === 'object' && body['page'] !== null)
|
|
632
|
+
replacePageContents(page, body['page']);
|
|
633
|
+
});
|
|
634
|
+
json(response, 200, pageView(current, canvas, findPage(canvas, pageId)));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (tail.length === 0 && method === 'DELETE') {
|
|
638
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
639
|
+
findPage(draft, pageId);
|
|
640
|
+
if (draft.pages.length === 1)
|
|
641
|
+
throw new CanvasError('a canvas keeps at least one page');
|
|
642
|
+
draft.pages = draft.pages.filter((page) => page.id !== pageId);
|
|
643
|
+
});
|
|
644
|
+
json(response, 200, canvasSummary(current, canvas));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
let changed = null;
|
|
648
|
+
// POST nodes one of { sketch: { label, note?, properties? } } | { sticky: { text, color? } } | { text: { text, size?, font? } } | { annotation: { text, color? } } | { blueprint } + frame?, link?, locked?, style?, box?, x?, y?
|
|
649
|
+
// PUT nodes/<n> the same fields, replacing what is given; { x, y } stores a layout override; { x: null } clears it
|
|
650
|
+
// DELETE nodes/<n> removes the node, its edges, and its comments
|
|
651
|
+
// POST nodes/<n>/promote { collection, title?, variant?, group?, summary? }
|
|
652
|
+
// POST edges { from, to, from_anchor?, to_anchor? }; PUT edges { from, to, label?, label_at?, bend?, anchors, color?, style?, start?, end? }; DELETE edges { from, to }
|
|
653
|
+
// POST frames { title, id?, x?, y?, w?, h?, color?, members? }; PUT frames/<f> { title?, x?, y?, w?, h?, color?, locked?, frame? }; DELETE frames/<f>?contents=delete
|
|
654
|
+
// PUT layout { positions: { <node>: { x, y } } } pins several at once; DELETE layout clears every override
|
|
655
|
+
// POST comments { x, y, node?, text }; POST comments/<c>/replies { text }; PUT comments/<c> { resolved?, x?, y?, node? }; DELETE comments/<c>
|
|
656
|
+
// PUT comments/<c>/messages/<i> { text }; DELETE comments/<c>/messages/<i>
|
|
657
|
+
if (tail[0] === 'nodes' && tail.length === 1 && method === 'POST') {
|
|
658
|
+
let created = '';
|
|
659
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
660
|
+
const page = findPage(draft, pageId);
|
|
661
|
+
const node = { id: str(body['id']) ?? nextNodeId(page) };
|
|
662
|
+
if (page.nodes.some((candidate) => candidate.id === node.id))
|
|
663
|
+
throw new CanvasError(`node '${node.id}' already exists`, 409);
|
|
664
|
+
applyNodeFields(node, body);
|
|
665
|
+
if (!node.blueprint && !node.sketch && !node.sticky && !node.text && !node.annotation)
|
|
666
|
+
throw new CanvasError('a node is { blueprint }, { sketch: { label } }, { sticky: { text } }, { text: { text } }, or { annotation: { text } }');
|
|
667
|
+
page.nodes.push(node);
|
|
668
|
+
if (typeof body['x'] === 'number' && typeof body['y'] === 'number')
|
|
669
|
+
page.layout[node.id] = { x: body['x'], y: body['y'] };
|
|
670
|
+
created = node.id;
|
|
671
|
+
});
|
|
672
|
+
const page = findPage(changed, pageId);
|
|
673
|
+
json(response, 201, { node: renderPage(current.framework, current.blueprints, page).find((node) => node.id === created) ?? null, ...pageView(current, changed, page) });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (tail[0] === 'nodes' && tail[1] !== undefined && tail.length === 2 && method === 'PUT') {
|
|
677
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
678
|
+
const page = findPage(draft, pageId);
|
|
679
|
+
const node = findNode(page, tail[1] ?? '');
|
|
680
|
+
applyNodeFields(node, body);
|
|
681
|
+
if (body['x'] === null)
|
|
682
|
+
delete page.layout[node.id];
|
|
683
|
+
else if (typeof body['x'] === 'number' && typeof body['y'] === 'number')
|
|
684
|
+
page.layout[node.id] = { x: body['x'], y: body['y'] };
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
else if (tail[0] === 'nodes' && tail[1] !== undefined && tail.length === 2 && method === 'DELETE') {
|
|
688
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
689
|
+
const page = findPage(draft, pageId);
|
|
690
|
+
findNode(page, tail[1] ?? '');
|
|
691
|
+
removeNodes(page, [tail[1] ?? '']);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
else if (tail[0] === 'nodes' && tail[1] !== undefined && tail[2] === 'promote' && method === 'POST') {
|
|
695
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
696
|
+
const node = findNode(findPage(canvas, pageId), tail[1]);
|
|
697
|
+
if (!node.sketch)
|
|
698
|
+
throw new HttpError(400, `node '${node.id}' is already a blueprint`);
|
|
699
|
+
const sketch = node.sketch;
|
|
700
|
+
const set = sketchValues(current.framework, sketch);
|
|
701
|
+
const created = createBlueprint(current, {
|
|
702
|
+
collection: str(body['collection']) ?? '',
|
|
703
|
+
title: str(body['title']) ?? (sketch.label.trim() === '' ? '' : sketch.label),
|
|
704
|
+
variant: str(body['variant']) ?? undefined,
|
|
705
|
+
group: str(body['group']) ?? undefined,
|
|
706
|
+
summary: str(body['summary']) ?? sketch.note ?? undefined,
|
|
707
|
+
set,
|
|
708
|
+
});
|
|
709
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
710
|
+
const target = findNode(findPage(draft, pageId), tail[1] ?? '');
|
|
711
|
+
target.blueprint = String(created['id']);
|
|
712
|
+
delete target.sketch;
|
|
713
|
+
});
|
|
714
|
+
store.invalidate();
|
|
715
|
+
current = store.context();
|
|
716
|
+
}
|
|
717
|
+
else if (tail[0] === 'edges' && (method === 'POST' || method === 'PUT')) {
|
|
718
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
719
|
+
const page = findPage(draft, pageId);
|
|
720
|
+
const from = str(body['from']);
|
|
721
|
+
const to = str(body['to']);
|
|
722
|
+
if (!from || !to)
|
|
723
|
+
throw new CanvasError('an edge needs from and to');
|
|
724
|
+
if (from === to)
|
|
725
|
+
throw new CanvasError('an edge needs two different ends');
|
|
726
|
+
findEndpoint(page, from);
|
|
727
|
+
findEndpoint(page, to);
|
|
728
|
+
const existing = page.edges.find((edge) => edge.from === from && edge.to === to);
|
|
729
|
+
const label = str(body['label']);
|
|
730
|
+
const edge = existing ?? { from, to };
|
|
731
|
+
if (label)
|
|
732
|
+
edge.label = label;
|
|
733
|
+
else if (body['label'] === null || body['label'] === '')
|
|
734
|
+
delete edge.label;
|
|
735
|
+
if (typeof body['bend'] === 'number')
|
|
736
|
+
edge.bend = Math.round(body['bend']);
|
|
737
|
+
else if (body['bend'] === null)
|
|
738
|
+
delete edge.bend;
|
|
739
|
+
for (const key of ['from_anchor', 'to_anchor']) {
|
|
740
|
+
const given = body[key];
|
|
741
|
+
if (given === null)
|
|
742
|
+
delete edge[key];
|
|
743
|
+
else if (typeof given === 'string' && ANCHORS.includes(given))
|
|
744
|
+
edge[key] = given;
|
|
745
|
+
}
|
|
746
|
+
// the look: a color, dashed or solid, a cap at either end, the label's place along the line
|
|
747
|
+
if (typeof body['label_at'] === 'number')
|
|
748
|
+
edge.label_at = Math.round(Math.min(1, Math.max(0, body['label_at'])) * 100) / 100;
|
|
749
|
+
else if (body['label_at'] === null)
|
|
750
|
+
delete edge.label_at;
|
|
751
|
+
const color = str(body['color']);
|
|
752
|
+
if (color)
|
|
753
|
+
edge.color = color;
|
|
754
|
+
else if (body['color'] === null || body['color'] === '')
|
|
755
|
+
delete edge.color;
|
|
756
|
+
if (body['style'] === 'dashed')
|
|
757
|
+
edge.style = 'dashed';
|
|
758
|
+
else if (body['style'] === 'solid' || body['style'] === null)
|
|
759
|
+
delete edge.style;
|
|
760
|
+
for (const key of ['start', 'end']) {
|
|
761
|
+
const given = body[key];
|
|
762
|
+
if (given === null)
|
|
763
|
+
delete edge[key];
|
|
764
|
+
else if (typeof given === 'string' && EDGE_CAPS.includes(given))
|
|
765
|
+
edge[key] = given;
|
|
766
|
+
}
|
|
767
|
+
if (!existing)
|
|
768
|
+
page.edges.push(edge);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
else if (tail[0] === 'edges' && method === 'DELETE') {
|
|
772
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
773
|
+
const page = findPage(draft, pageId);
|
|
774
|
+
page.edges = page.edges.filter((edge) => !(edge.from === body['from'] && edge.to === body['to']));
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
else if (tail[0] === 'frames' && tail.length === 1 && method === 'POST') {
|
|
778
|
+
let created = '';
|
|
779
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
780
|
+
const page = findPage(draft, pageId);
|
|
781
|
+
const title = str(body['title']) ?? `Frame ${page.frames.length + 1}`;
|
|
782
|
+
let id = str(body['id']) ?? slug(title, `frame-${page.frames.length + 1}`);
|
|
783
|
+
let n = 1;
|
|
784
|
+
while (page.frames.some((frame) => frame.id === id))
|
|
785
|
+
id = `${slug(title, 'frame')}-${(n += 1)}`;
|
|
786
|
+
const frame = { id, title };
|
|
787
|
+
applyFrameFields(frame, body);
|
|
788
|
+
page.frames.push(frame);
|
|
789
|
+
// Nodes inside a drawn frame belong to it.
|
|
790
|
+
if (frame.x !== undefined) {
|
|
791
|
+
const members = Array.isArray(body['members']) ? new Set(body['members'].map(String)) : null;
|
|
792
|
+
for (const node of page.nodes) {
|
|
793
|
+
if (members?.has(node.id))
|
|
794
|
+
node.frame = id;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
created = id;
|
|
798
|
+
});
|
|
799
|
+
const page = findPage(changed, pageId);
|
|
800
|
+
json(response, 200, { frame: page.frames.find((frame) => frame.id === created) ?? null, ...pageView(current, changed, page) });
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
else if (tail[0] === 'frames' && tail[1] !== undefined && method === 'PUT') {
|
|
804
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
805
|
+
const page = findPage(draft, pageId);
|
|
806
|
+
const frame = page.frames.find((candidate) => candidate.id === tail[1]);
|
|
807
|
+
if (!frame)
|
|
808
|
+
throw new CanvasError(`no frame '${tail[1]}'`, 404);
|
|
809
|
+
applyFrameFields(frame, body);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
else if (tail[0] === 'frames' && tail[1] !== undefined && method === 'DELETE') {
|
|
813
|
+
// ?contents=delete takes the attached nodes with it; otherwise they stay, unattached.
|
|
814
|
+
const withContents = new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('contents') === 'delete';
|
|
815
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
816
|
+
const page = findPage(draft, pageId);
|
|
817
|
+
const id = tail[1] ?? '';
|
|
818
|
+
if (!page.frames.some((frame) => frame.id === id))
|
|
819
|
+
throw new CanvasError(`no frame '${id}' on page '${page.id}'`, 404);
|
|
820
|
+
if (withContents)
|
|
821
|
+
removeNodes(page, page.nodes.filter((node) => node.frame === id).map((node) => node.id));
|
|
822
|
+
page.frames = page.frames.filter((frame) => frame.id !== id);
|
|
823
|
+
for (const node of page.nodes)
|
|
824
|
+
if (node.frame === id)
|
|
825
|
+
delete node.frame;
|
|
826
|
+
for (const frame of page.frames)
|
|
827
|
+
if (frame.frame === id)
|
|
828
|
+
delete frame.frame;
|
|
829
|
+
page.edges = page.edges.filter((edge) => edge.from !== `${FRAME_REF}${id}` && edge.to !== `${FRAME_REF}${id}`);
|
|
830
|
+
dropUnpointedAnnotations(page);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
else if (tail[0] === 'layout' && method === 'PUT') {
|
|
834
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
835
|
+
const page = findPage(draft, pageId);
|
|
836
|
+
const positions = typeof body['positions'] === 'object' && body['positions'] !== null ? body['positions'] : {};
|
|
837
|
+
for (const [id, at] of Object.entries(positions)) {
|
|
838
|
+
findNode(page, id);
|
|
839
|
+
if (at === null)
|
|
840
|
+
delete page.layout[id];
|
|
841
|
+
else if (typeof at === 'object' && at !== null && typeof at['x'] === 'number' && typeof at['y'] === 'number') {
|
|
842
|
+
page.layout[id] = { x: Math.round(at.x), y: Math.round(at.y) };
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
else if (tail[0] === 'layout' && method === 'DELETE') {
|
|
848
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
849
|
+
findPage(draft, pageId).layout = {};
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
else if (tail[0] === 'comments' && tail.length === 1 && method === 'POST') {
|
|
853
|
+
const text = str(body['text']);
|
|
854
|
+
if (!text)
|
|
855
|
+
throw new HttpError(400, 'a comment needs text');
|
|
856
|
+
if (typeof body['x'] !== 'number' || typeof body['y'] !== 'number')
|
|
857
|
+
throw new HttpError(400, 'a comment needs a point { x, y }');
|
|
858
|
+
let created = '';
|
|
859
|
+
const author = commentAuthor(current);
|
|
860
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
861
|
+
const page = findPage(draft, pageId);
|
|
862
|
+
const comment = { id: nextCommentId(page), x: Math.round(body['x']), y: Math.round(body['y']), messages: [{ ...signed(author), text, at: new Date().toISOString() }] };
|
|
863
|
+
const node = str(body['node']);
|
|
864
|
+
if (node) {
|
|
865
|
+
findNode(page, node);
|
|
866
|
+
comment.node = node;
|
|
867
|
+
}
|
|
868
|
+
page.comments.push(comment);
|
|
869
|
+
created = comment.id;
|
|
870
|
+
});
|
|
871
|
+
const page = findPage(changed, pageId);
|
|
872
|
+
json(response, 201, { comment: page.comments.find((comment) => comment.id === created) ?? null, ...pageView(current, changed, page) });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail[2] === 'replies' && method === 'POST') {
|
|
876
|
+
const text = str(body['text']);
|
|
877
|
+
if (!text)
|
|
878
|
+
throw new HttpError(400, 'a reply needs text');
|
|
879
|
+
const author = commentAuthor(current);
|
|
880
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
881
|
+
const comment = findComment(findPage(draft, pageId), tail[1] ?? '');
|
|
882
|
+
comment.messages.push({ ...signed(author), text, at: new Date().toISOString() });
|
|
883
|
+
delete comment.resolved;
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail[2] === 'messages' && tail[3] !== undefined && (method === 'PUT' || method === 'DELETE')) {
|
|
887
|
+
// One message of a thread, rewritten or removed; a thread whose last
|
|
888
|
+
// message goes is gone itself.
|
|
889
|
+
const index = Number.parseInt(tail[3], 10);
|
|
890
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
891
|
+
const page = findPage(draft, pageId);
|
|
892
|
+
const comment = findComment(page, tail[1] ?? '');
|
|
893
|
+
const message = comment.messages[index];
|
|
894
|
+
if (!Number.isInteger(index) || !message)
|
|
895
|
+
throw new CanvasError(`comment ${comment.id} has no message ${tail[3]}`, 404);
|
|
896
|
+
if (method === 'PUT') {
|
|
897
|
+
const text = str(body['text']);
|
|
898
|
+
if (!text)
|
|
899
|
+
throw new HttpError(400, 'a message needs text');
|
|
900
|
+
message.text = text;
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
comment.messages.splice(index, 1);
|
|
904
|
+
if (comment.messages.length === 0)
|
|
905
|
+
page.comments = page.comments.filter((candidate) => candidate.id !== comment.id);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail.length === 2 && method === 'PUT') {
|
|
909
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
910
|
+
const page = findPage(draft, pageId);
|
|
911
|
+
const comment = findComment(page, tail[1] ?? '');
|
|
912
|
+
if (body['resolved'] === true)
|
|
913
|
+
comment.resolved = true;
|
|
914
|
+
else if (body['resolved'] === false)
|
|
915
|
+
delete comment.resolved;
|
|
916
|
+
if (typeof body['x'] === 'number' && typeof body['y'] === 'number') {
|
|
917
|
+
comment.x = Math.round(body['x']);
|
|
918
|
+
comment.y = Math.round(body['y']);
|
|
919
|
+
}
|
|
920
|
+
if (body['node'] === null)
|
|
921
|
+
delete comment.node;
|
|
922
|
+
else if (typeof body['node'] === 'string' && body['node'] !== '') {
|
|
923
|
+
findNode(page, body['node']);
|
|
924
|
+
comment.node = body['node'];
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail.length === 2 && method === 'DELETE') {
|
|
929
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
930
|
+
const page = findPage(draft, pageId);
|
|
931
|
+
findComment(page, tail[1] ?? '');
|
|
932
|
+
page.comments = page.comments.filter((comment) => comment.id !== tail[1]);
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
throw new HttpError(404, 'not found');
|
|
937
|
+
}
|
|
938
|
+
json(response, 200, pageView(current, changed, findPage(changed, pageId)));
|
|
939
|
+
}
|
|
940
|
+
function findComment(page, id) {
|
|
941
|
+
const comment = page.comments.find((candidate) => candidate.id === id);
|
|
942
|
+
if (!comment)
|
|
943
|
+
throw new CanvasError(`no comment '${id}' on page '${page.id}'`, 404);
|
|
944
|
+
return comment;
|
|
945
|
+
}
|
|
946
|
+
// Nodes gone from a page take their place, their edges, and the comments
|
|
947
|
+
// pinned to them; an annotation left pointing at nothing goes too.
|
|
948
|
+
function removeNodes(page, ids) {
|
|
949
|
+
const gone = new Set(ids);
|
|
950
|
+
page.nodes = page.nodes.filter((node) => !gone.has(node.id));
|
|
951
|
+
page.edges = page.edges.filter((edge) => !gone.has(edge.from) && !gone.has(edge.to));
|
|
952
|
+
page.comments = page.comments.filter((comment) => !comment.node || !gone.has(comment.node));
|
|
953
|
+
for (const id of gone)
|
|
954
|
+
delete page.layout[id];
|
|
955
|
+
dropUnpointedAnnotations(page);
|
|
956
|
+
}
|
|
957
|
+
function dropUnpointedAnnotations(page) {
|
|
958
|
+
const unpointed = page.nodes.filter((node) => node.annotation && !page.edges.some((edge) => edge.from === node.id)).map((node) => node.id);
|
|
959
|
+
if (unpointed.length > 0)
|
|
960
|
+
removeNodes(page, unpointed);
|
|
961
|
+
}
|
|
962
|
+
function applyFrameFields(frame, body) {
|
|
963
|
+
const title = str(body['title']);
|
|
964
|
+
if (title)
|
|
965
|
+
frame.title = title;
|
|
966
|
+
if (typeof body['x'] === 'number' && typeof body['y'] === 'number' && typeof body['w'] === 'number' && typeof body['h'] === 'number') {
|
|
967
|
+
frame.x = Math.round(body['x']);
|
|
968
|
+
frame.y = Math.round(body['y']);
|
|
969
|
+
frame.w = Math.max(40, Math.round(body['w']));
|
|
970
|
+
frame.h = Math.max(40, Math.round(body['h']));
|
|
971
|
+
}
|
|
972
|
+
else if (body['x'] === null) {
|
|
973
|
+
delete frame.x;
|
|
974
|
+
delete frame.y;
|
|
975
|
+
delete frame.w;
|
|
976
|
+
delete frame.h;
|
|
977
|
+
}
|
|
978
|
+
if (body['color'] === null || body['color'] === '')
|
|
979
|
+
delete frame.color;
|
|
980
|
+
else if (typeof body['color'] === 'string')
|
|
981
|
+
frame.color = body['color'];
|
|
982
|
+
if (body['frame'] === null || body['frame'] === '')
|
|
983
|
+
delete frame.frame;
|
|
984
|
+
else if (typeof body['frame'] === 'string' && body['frame'] !== frame.id)
|
|
985
|
+
frame.frame = body['frame'];
|
|
986
|
+
if (body['locked'] === true)
|
|
987
|
+
frame.locked = true;
|
|
988
|
+
else if (body['locked'] === false)
|
|
989
|
+
delete frame.locked;
|
|
990
|
+
}
|
|
991
|
+
function applyNodeFields(node, body) {
|
|
992
|
+
if (typeof body['blueprint'] === 'string' && body['blueprint'].trim() !== '') {
|
|
993
|
+
node.blueprint = body['blueprint'].trim();
|
|
994
|
+
delete node.sketch;
|
|
995
|
+
delete node.sticky;
|
|
996
|
+
delete node.text;
|
|
997
|
+
delete node.annotation;
|
|
998
|
+
}
|
|
999
|
+
if (typeof body['sticky'] === 'object' && body['sticky'] !== null) {
|
|
1000
|
+
const given = body['sticky'];
|
|
1001
|
+
const sticky = node.sticky ?? { text: '' };
|
|
1002
|
+
if (typeof given['text'] === 'string')
|
|
1003
|
+
sticky.text = given['text'];
|
|
1004
|
+
if (given['color'] === null || given['color'] === '')
|
|
1005
|
+
delete sticky.color;
|
|
1006
|
+
else if (typeof given['color'] === 'string')
|
|
1007
|
+
sticky.color = given['color'];
|
|
1008
|
+
node.sticky = sticky;
|
|
1009
|
+
delete node.blueprint;
|
|
1010
|
+
delete node.sketch;
|
|
1011
|
+
delete node.text;
|
|
1012
|
+
delete node.annotation;
|
|
1013
|
+
}
|
|
1014
|
+
if (typeof body['text'] === 'object' && body['text'] !== null) {
|
|
1015
|
+
const given = body['text'];
|
|
1016
|
+
const item = node.text ?? { text: '' };
|
|
1017
|
+
if (typeof given['text'] === 'string')
|
|
1018
|
+
item.text = given['text'];
|
|
1019
|
+
if (given['size'] === 'small' || given['size'] === 'medium' || given['size'] === 'large')
|
|
1020
|
+
item.size = given['size'];
|
|
1021
|
+
else if (given['size'] === null)
|
|
1022
|
+
delete item.size;
|
|
1023
|
+
if (typeof given['font'] === 'number' && given['font'] >= 8 && given['font'] <= 64)
|
|
1024
|
+
item.font = Math.round(given['font']);
|
|
1025
|
+
else if (given['font'] === null)
|
|
1026
|
+
delete item.font;
|
|
1027
|
+
node.text = item;
|
|
1028
|
+
delete node.blueprint;
|
|
1029
|
+
delete node.sketch;
|
|
1030
|
+
delete node.sticky;
|
|
1031
|
+
delete node.annotation;
|
|
1032
|
+
}
|
|
1033
|
+
if (typeof body['annotation'] === 'object' && body['annotation'] !== null) {
|
|
1034
|
+
const given = body['annotation'];
|
|
1035
|
+
const item = node.annotation ?? { text: '' };
|
|
1036
|
+
if (typeof given['text'] === 'string')
|
|
1037
|
+
item.text = given['text'];
|
|
1038
|
+
if (given['color'] === null || given['color'] === '')
|
|
1039
|
+
delete item.color;
|
|
1040
|
+
else if (typeof given['color'] === 'string')
|
|
1041
|
+
item.color = given['color'];
|
|
1042
|
+
node.annotation = item;
|
|
1043
|
+
delete node.blueprint;
|
|
1044
|
+
delete node.sketch;
|
|
1045
|
+
delete node.sticky;
|
|
1046
|
+
delete node.text;
|
|
1047
|
+
}
|
|
1048
|
+
if (body['locked'] === true)
|
|
1049
|
+
node.locked = true;
|
|
1050
|
+
else if (body['locked'] === false)
|
|
1051
|
+
delete node.locked;
|
|
1052
|
+
if (typeof body['sketch'] === 'object' && body['sketch'] !== null) {
|
|
1053
|
+
const given = body['sketch'];
|
|
1054
|
+
const sketch = node.sketch ?? { label: '' };
|
|
1055
|
+
if (typeof given['label'] === 'string')
|
|
1056
|
+
sketch.label = given['label'];
|
|
1057
|
+
for (const key of ['kind', 'status', 'note']) {
|
|
1058
|
+
if (given[key] === null || given[key] === '')
|
|
1059
|
+
delete sketch[key];
|
|
1060
|
+
else if (typeof given[key] === 'string')
|
|
1061
|
+
sketch[key] = given[key];
|
|
1062
|
+
}
|
|
1063
|
+
if (typeof given['properties'] === 'object' && given['properties'] !== null) {
|
|
1064
|
+
const properties = { ...(sketch.properties ?? {}) };
|
|
1065
|
+
for (const [key, value] of Object.entries(given['properties'])) {
|
|
1066
|
+
if (value === null || value === '')
|
|
1067
|
+
delete properties[key];
|
|
1068
|
+
else if (typeof value === 'string')
|
|
1069
|
+
properties[key] = value;
|
|
1070
|
+
}
|
|
1071
|
+
// the legacy pair is superseded by a value held by name
|
|
1072
|
+
if (sketch.kind !== undefined && Object.keys(properties).length > 0)
|
|
1073
|
+
delete sketch.kind;
|
|
1074
|
+
if (sketch.status !== undefined && Object.keys(properties).length > 0)
|
|
1075
|
+
delete sketch.status;
|
|
1076
|
+
if (Object.keys(properties).length > 0)
|
|
1077
|
+
sketch.properties = properties;
|
|
1078
|
+
else
|
|
1079
|
+
delete sketch.properties;
|
|
1080
|
+
}
|
|
1081
|
+
node.sketch = sketch;
|
|
1082
|
+
delete node.blueprint;
|
|
1083
|
+
delete node.sticky;
|
|
1084
|
+
delete node.text;
|
|
1085
|
+
delete node.annotation;
|
|
1086
|
+
}
|
|
1087
|
+
if (body['style'] === null) {
|
|
1088
|
+
delete node.style;
|
|
1089
|
+
delete node.styled;
|
|
1090
|
+
}
|
|
1091
|
+
else if (typeof body['style'] === 'object' && body['style'] !== null) {
|
|
1092
|
+
const given = body['style'];
|
|
1093
|
+
const style = { ...(node.style ?? {}) };
|
|
1094
|
+
for (const key of ['shape', 'color', 'fill', 'fill_color', 'size']) {
|
|
1095
|
+
if (given[key] === null || given[key] === '')
|
|
1096
|
+
delete style[key];
|
|
1097
|
+
else if (given[key] !== undefined)
|
|
1098
|
+
style[key] = given[key];
|
|
1099
|
+
}
|
|
1100
|
+
const read = readStyle(style);
|
|
1101
|
+
if (read)
|
|
1102
|
+
node.style = read;
|
|
1103
|
+
else
|
|
1104
|
+
delete node.style;
|
|
1105
|
+
}
|
|
1106
|
+
if (body['styled'] === true)
|
|
1107
|
+
node.styled = true;
|
|
1108
|
+
else if (body['styled'] === false)
|
|
1109
|
+
delete node.styled;
|
|
1110
|
+
if (body['box'] === null)
|
|
1111
|
+
delete node.box;
|
|
1112
|
+
else if (typeof body['box'] === 'object' && body['box'] !== null) {
|
|
1113
|
+
const box = readBox(body['box']);
|
|
1114
|
+
if (box)
|
|
1115
|
+
node.box = box;
|
|
1116
|
+
}
|
|
1117
|
+
if (body['frame'] === null || body['frame'] === '')
|
|
1118
|
+
delete node.frame;
|
|
1119
|
+
else if (typeof body['frame'] === 'string')
|
|
1120
|
+
node.frame = body['frame'];
|
|
1121
|
+
if (body['link'] === null)
|
|
1122
|
+
delete node.link;
|
|
1123
|
+
else if (typeof body['link'] === 'object' && body['link'] !== null) {
|
|
1124
|
+
const given = body['link'];
|
|
1125
|
+
const link = {};
|
|
1126
|
+
if (typeof given['page'] === 'string' && given['page'] !== '')
|
|
1127
|
+
link.page = given['page'];
|
|
1128
|
+
if (typeof given['canvas'] === 'string' && given['canvas'] !== '')
|
|
1129
|
+
link.canvas = given['canvas'];
|
|
1130
|
+
if (Object.keys(link).length > 0)
|
|
1131
|
+
node.link = link;
|
|
1132
|
+
else
|
|
1133
|
+
delete node.link;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// --- the page itself ----------------------------------------------------------
|
|
1137
|
+
const distDir = path.join(browserDir, 'dist');
|
|
1138
|
+
const MIME = {
|
|
1139
|
+
'.html': 'text/html; charset=utf-8',
|
|
1140
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
1141
|
+
'.css': 'text/css; charset=utf-8',
|
|
1142
|
+
'.svg': 'image/svg+xml',
|
|
1143
|
+
'.png': 'image/png',
|
|
1144
|
+
'.ico': 'image/x-icon',
|
|
1145
|
+
'.json': 'application/json; charset=utf-8',
|
|
1146
|
+
'.woff2': 'font/woff2',
|
|
1147
|
+
'.woff': 'font/woff',
|
|
1148
|
+
'.map': 'application/json',
|
|
1149
|
+
};
|
|
1150
|
+
// A file from the built bundle, or the page for any route the app owns.
|
|
1151
|
+
function serveStatic(pathname, response) {
|
|
1152
|
+
const wanted = path.normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
|
|
1153
|
+
let file = path.join(distDir, wanted);
|
|
1154
|
+
if (!file.startsWith(distDir) || !existsSync(file) || statSync(file).isDirectory()) {
|
|
1155
|
+
file = path.join(distDir, 'index.html');
|
|
1156
|
+
}
|
|
1157
|
+
if (!existsSync(file)) {
|
|
1158
|
+
throw new HttpError(500, `the browser page is missing at ${file}; in a checkout, build it with \`pnpm build\``);
|
|
1159
|
+
}
|
|
1160
|
+
const body = readFileSync(file);
|
|
1161
|
+
const type = MIME[path.extname(file)] ?? 'application/octet-stream';
|
|
1162
|
+
const cache = file.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable';
|
|
1163
|
+
response.writeHead(200, { 'content-type': type, 'content-length': body.length, 'cache-control': cache });
|
|
1164
|
+
response.end(body);
|
|
173
1165
|
}
|
|
174
1166
|
export function createServer(options) {
|
|
175
|
-
const
|
|
176
|
-
const context = () => loadRoot({ root: options.root }, options.cwd);
|
|
1167
|
+
const store = new Store({ root: options.root, cwd: options.cwd, watch: options.watch ?? true });
|
|
177
1168
|
const handle = async (request, response) => {
|
|
178
1169
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
179
1170
|
const method = request.method ?? 'GET';
|
|
180
1171
|
const segments = url.pathname.split('/').filter((segment) => segment !== '');
|
|
181
|
-
if (segments.length === 0 || (segments[0] !== 'api' && !url.pathname.includes('.'))) {
|
|
182
|
-
if (!existsSync(page)) {
|
|
183
|
-
throw new HttpError(500, `the browser page is missing at ${page}`);
|
|
184
|
-
}
|
|
185
|
-
const html = readFileSync(page);
|
|
186
|
-
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': html.length });
|
|
187
|
-
response.end(html);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
1172
|
if (segments[0] !== 'api') {
|
|
191
|
-
|
|
1173
|
+
serveStatic(url.pathname, response);
|
|
1174
|
+
return;
|
|
192
1175
|
}
|
|
193
1176
|
const resource = segments[1] ?? '';
|
|
194
1177
|
const rest = segments.slice(2).join('/');
|
|
195
1178
|
if (resource === 'root' && method === 'GET') {
|
|
196
|
-
json(response, 200, rootSnapshot(context()));
|
|
1179
|
+
json(response, 200, rootSnapshot(store.context(), store));
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (resource === 'events' && method === 'GET') {
|
|
1183
|
+
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
1184
|
+
response.write(': connected\n\n');
|
|
1185
|
+
const off = store.onChange((paths) => response.write(`data: ${JSON.stringify({ type: 'change', paths })}\n\n`));
|
|
1186
|
+
request.on('close', off);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (resource === 'search' && method === 'GET') {
|
|
1190
|
+
const query = url.searchParams.get('q') ?? '';
|
|
1191
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 20) || 20));
|
|
1192
|
+
json(response, 200, { query, hits: query.trim() === '' ? [] : store.search(query, limit) });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (resource === 'collections' && rest !== '' && method === 'GET') {
|
|
1196
|
+
json(response, 200, collectionTable(store.context(), decodeURIComponent(rest)));
|
|
197
1197
|
return;
|
|
198
1198
|
}
|
|
199
1199
|
if (resource === 'blueprints' && rest === '' && method === 'GET') {
|
|
200
|
-
json(response, 200, context().blueprints.map(blueprintSummary));
|
|
1200
|
+
json(response, 200, store.context().blueprints.map(blueprintSummary));
|
|
201
1201
|
return;
|
|
202
1202
|
}
|
|
203
1203
|
if (resource === 'blueprints' && rest === '' && method === 'POST') {
|
|
204
|
-
|
|
1204
|
+
const created = createBlueprint(store.context(), await readBody(request));
|
|
1205
|
+
store.invalidate();
|
|
1206
|
+
json(response, 201, created);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
// GET /api/blueprints/<path>/history: the commits that touched the file, newest first; empty without git.
|
|
1210
|
+
if (resource === 'blueprints' && rest.endsWith('/history') && method === 'GET') {
|
|
1211
|
+
const current = store.context();
|
|
1212
|
+
const blueprint = findByPath(current, rest.slice(0, -'/history'.length));
|
|
1213
|
+
const git = gitInfo(current.root);
|
|
1214
|
+
json(response, 200, { path: blueprint.rel, git: git.available, history: git.available ? gitLog(current.root, blueprint.rel) : [] });
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
// POST /api/blueprints/<path>/diff { sha }: open the configured diff viewer on the file then and now.
|
|
1218
|
+
if (resource === 'blueprints' && rest.endsWith('/diff') && method === 'POST') {
|
|
1219
|
+
const current = store.context();
|
|
1220
|
+
const blueprint = findByPath(current, rest.slice(0, -'/diff'.length));
|
|
1221
|
+
const body = await readBody(request);
|
|
1222
|
+
const sha = typeof body['sha'] === 'string' ? body['sha'].trim() : '';
|
|
1223
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha))
|
|
1224
|
+
throw new HttpError(400, 'expected { sha }');
|
|
1225
|
+
const git = gitInfo(current.root);
|
|
1226
|
+
if (!git.enabled)
|
|
1227
|
+
throw new HttpError(400, 'git features are off for this root (Plugins → Git)');
|
|
1228
|
+
if (!git.available)
|
|
1229
|
+
throw new HttpError(400, 'this root is not in a git repository');
|
|
1230
|
+
const viewer = readSettings(current.root).local.diff_viewer;
|
|
1231
|
+
if (!viewer)
|
|
1232
|
+
throw new HttpError(400, 'no diff viewer set; name one under Plugins (a command with {a} and {b})');
|
|
1233
|
+
const then = gitShowFile(current.root, sha, blueprint.rel);
|
|
1234
|
+
if (then === null)
|
|
1235
|
+
throw new HttpError(404, `${sha.slice(0, 7)} has no ${blueprint.rel}`);
|
|
1236
|
+
const temp = path.join(tmpdir(), `eidos-${sha.slice(0, 7)}-${path.basename(blueprint.path)}`);
|
|
1237
|
+
writeFileSync(temp, then, 'utf8');
|
|
1238
|
+
const quote = (file) => `"${file.replace(/"/g, '\\"')}"`;
|
|
1239
|
+
const command = viewer.replace(/\{a\}/g, quote(temp)).replace(/\{b\}/g, quote(blueprint.path));
|
|
1240
|
+
const child = spawn(command, { cwd: current.root, shell: true, detached: true, stdio: 'ignore' });
|
|
1241
|
+
child.on('error', () => undefined);
|
|
1242
|
+
child.unref();
|
|
1243
|
+
json(response, 200, { launched: command, then: temp });
|
|
205
1244
|
return;
|
|
206
1245
|
}
|
|
207
1246
|
if (resource === 'blueprints' && rest !== '' && method === 'GET') {
|
|
208
|
-
const current = context();
|
|
1247
|
+
const current = store.context();
|
|
209
1248
|
json(response, 200, blueprintDetail(current, findByPath(current, rest)));
|
|
210
1249
|
return;
|
|
211
1250
|
}
|
|
212
1251
|
if (resource === 'blueprints' && rest !== '' && method === 'PUT') {
|
|
213
|
-
const current = context();
|
|
214
|
-
|
|
1252
|
+
const current = store.context();
|
|
1253
|
+
const blueprint = findByPath(current, rest);
|
|
1254
|
+
writeFileSync(blueprint.path, blueprintText(await readBody(request), readSettings(current.root).shared.on_save), 'utf8');
|
|
1255
|
+
store.invalidate();
|
|
1256
|
+
const fresh = store.context();
|
|
1257
|
+
const reloaded = fresh.blueprints.find((candidate) => candidate.rel === blueprint.rel);
|
|
1258
|
+
json(response, 200, { path: blueprint.rel, findings: reloaded ? findingsFor(fresh, reloaded) : [] });
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (resource === 'blueprints' && rest !== '' && method === 'DELETE') {
|
|
1262
|
+
const current = store.context();
|
|
1263
|
+
const blueprint = findByPath(current, rest);
|
|
1264
|
+
rmSync(blueprint.path);
|
|
1265
|
+
store.invalidate();
|
|
1266
|
+
json(response, 200, { deleted: blueprint.rel });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
// GET /api/files?dir=<root-relative>&ext=md: the folders and files under one folder of the root, for a picker.
|
|
1270
|
+
if (resource === 'files' && method === 'GET') {
|
|
1271
|
+
const current = store.context();
|
|
1272
|
+
const wanted = (url.searchParams.get('dir') ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
1273
|
+
const dir = path.resolve(current.root, wanted);
|
|
1274
|
+
if (dir !== current.root && !dir.startsWith(current.root + path.sep))
|
|
1275
|
+
throw new HttpError(400, 'a folder inside the root');
|
|
1276
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
1277
|
+
throw new HttpError(404, `no folder ${wanted || '/'}`);
|
|
1278
|
+
const ext = url.searchParams.get('ext');
|
|
1279
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
1280
|
+
.filter((entry) => !entry.name.startsWith('.') && entry.name !== 'node_modules')
|
|
1281
|
+
.filter((entry) => entry.isDirectory() || !ext || entry.name.toLowerCase().endsWith(`.${ext.toLowerCase()}`))
|
|
1282
|
+
.map((entry) => ({ name: entry.name, path: rel(current.root, path.join(dir, entry.name)), kind: entry.isDirectory() ? 'dir' : 'file' }))
|
|
1283
|
+
.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
|
|
1284
|
+
json(response, 200, { dir: wanted, entries });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
// The CLI's own settings: two YAML files under .eidos/plugins/eidosmd/.
|
|
1288
|
+
if (resource === 'settings' && method === 'GET') {
|
|
1289
|
+
json(response, 200, readSettings(store.context().root));
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (resource === 'settings' && method === 'PUT') {
|
|
1293
|
+
const body = await readBody(request);
|
|
1294
|
+
json(response, 200, writeSettings(store.context().root, { shared: body['shared'], local: body['local'] }));
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (resource === 'git' && method === 'GET') {
|
|
1298
|
+
json(response, 200, gitInfo(store.context().root));
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
// Top-level documents: README.md and what ## Top-Level declares, read and written as text.
|
|
1302
|
+
if (resource === 'docs' && rest !== '' && method === 'GET') {
|
|
1303
|
+
json(response, 200, docView(store.context(), rest));
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
// POST /api/docs { title, description? }: a top-level doc declared under
|
|
1307
|
+
// top_level and written at the root, named for its title, the same plan as
|
|
1308
|
+
// `eidos configure:doc add`. { path, title } writes the file alone, for a
|
|
1309
|
+
// framework draft that declares it itself.
|
|
1310
|
+
if (resource === 'docs' && rest === '' && method === 'POST') {
|
|
1311
|
+
const current = store.context();
|
|
1312
|
+
const body = await readBody(request);
|
|
1313
|
+
if (typeof body['path'] !== 'string') {
|
|
1314
|
+
const title = str(body['title']);
|
|
1315
|
+
if (!title)
|
|
1316
|
+
throw new HttpError(400, 'expected { title }');
|
|
1317
|
+
let plan;
|
|
1318
|
+
try {
|
|
1319
|
+
plan = planDocAdd(editContext(current.root), title, str(body['description']) ?? '');
|
|
1320
|
+
}
|
|
1321
|
+
catch (cause) {
|
|
1322
|
+
if (cause instanceof EditError)
|
|
1323
|
+
throw new HttpError(400, cause.message);
|
|
1324
|
+
throw cause;
|
|
1325
|
+
}
|
|
1326
|
+
applyPlan(plan, standardVersion());
|
|
1327
|
+
store.invalidate();
|
|
1328
|
+
const made = store.context().framework.topLevel.find((doc) => doc.title === title);
|
|
1329
|
+
json(response, 201, { path: made ? path.basename(made.path) : '', title });
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const name = body['path'].trim().replace(/\\/g, '/');
|
|
1333
|
+
if (!/^[^/]+\.md$/i.test(name) || name.startsWith('.'))
|
|
1334
|
+
throw new HttpError(400, 'a top-level document is one <name>.md straight under the root');
|
|
1335
|
+
const file = path.join(current.root, name);
|
|
1336
|
+
if (existsSync(file))
|
|
1337
|
+
throw new HttpError(409, `${name} already exists`);
|
|
1338
|
+
const title = typeof body['title'] === 'string' && body['title'].trim() !== '' ? body['title'].trim() : path.basename(name, '.md');
|
|
1339
|
+
writeFileSync(file, `# ${title}\n\n`, 'utf8');
|
|
1340
|
+
store.invalidate();
|
|
1341
|
+
json(response, 201, { path: name, title });
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
// DELETE /api/docs/<path>: the entry under top_level and the file, the same plan as `eidos configure:doc remove`.
|
|
1345
|
+
if (resource === 'docs' && rest !== '' && method === 'DELETE') {
|
|
1346
|
+
const current = store.context();
|
|
1347
|
+
const { file, rel: relative, declared } = topLevelFile(current, rest);
|
|
1348
|
+
if (declared) {
|
|
1349
|
+
let plan;
|
|
1350
|
+
try {
|
|
1351
|
+
plan = planDocRemove(editContext(current.root), path.basename(file), { preserve: false });
|
|
1352
|
+
}
|
|
1353
|
+
catch (cause) {
|
|
1354
|
+
if (cause instanceof EditError)
|
|
1355
|
+
throw new HttpError(400, cause.message);
|
|
1356
|
+
throw cause;
|
|
1357
|
+
}
|
|
1358
|
+
applyPlan(plan, standardVersion());
|
|
1359
|
+
}
|
|
1360
|
+
else if (existsSync(file))
|
|
1361
|
+
rmSync(file);
|
|
1362
|
+
store.invalidate();
|
|
1363
|
+
json(response, 200, { deleted: relative });
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (resource === 'docs' && rest !== '' && method === 'PUT') {
|
|
1367
|
+
const current = store.context();
|
|
1368
|
+
const { file, rel: relative } = topLevelFile(current, rest);
|
|
1369
|
+
const body = await readBody(request);
|
|
1370
|
+
if (typeof body['text'] !== 'string')
|
|
1371
|
+
throw new HttpError(400, 'expected { text }');
|
|
1372
|
+
writeFileSync(file, body['text'].replace(/\r\n?/g, '\n'), 'utf8');
|
|
1373
|
+
store.invalidate();
|
|
1374
|
+
json(response, 200, docView(store.context(), relative));
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
if (resource === 'versions' && method === 'GET') {
|
|
1378
|
+
json(response, 200, { versions: readVersions(store.context().root), git_head: gitInfo(store.context().root).head, tag_prefix: tagPrefix(store.context().root) });
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
// POST /api/versions { version, commit?, tag? }: a snapshot of the root, only because someone asked.
|
|
1382
|
+
if (resource === 'versions' && method === 'POST') {
|
|
1383
|
+
const current = store.context();
|
|
1384
|
+
const body = await readBody(request);
|
|
1385
|
+
let recorded;
|
|
1386
|
+
try {
|
|
1387
|
+
recorded = recordVersion(current.root, { version: str(body['version']) ?? '', commit: str(body['commit']), tag: body['tag'] === true });
|
|
1388
|
+
}
|
|
1389
|
+
catch (cause) {
|
|
1390
|
+
if (cause instanceof VersionError)
|
|
1391
|
+
throw new HttpError(400, cause.message);
|
|
1392
|
+
throw cause;
|
|
1393
|
+
}
|
|
1394
|
+
store.invalidate();
|
|
1395
|
+
json(response, 201, { ...recorded.version, tagged: recorded.tagged, versions: readVersions(current.root) });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (resource === 'framework' && method === 'GET') {
|
|
1399
|
+
json(response, 200, frameworkView(store.context()));
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (resource === 'framework' && method === 'PUT') {
|
|
1403
|
+
const body = await readBody(request);
|
|
1404
|
+
applyRenames(store.context(), body['renames']);
|
|
1405
|
+
applyDeletes(store.context(), body['deletes']);
|
|
1406
|
+
const saved = saveFramework(store.context(), body);
|
|
1407
|
+
store.invalidate();
|
|
1408
|
+
json(response, 200, { ...saved, ...frameworkView(store.context()) });
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
// A finding's fix, by code: the one so far strips the frontmatter block a template must not carry.
|
|
1412
|
+
if (resource === 'fix' && method === 'POST') {
|
|
1413
|
+
const current = store.context();
|
|
1414
|
+
const body = await readBody(request);
|
|
1415
|
+
const code = body['code'];
|
|
1416
|
+
const target = body['path'];
|
|
1417
|
+
if (typeof code !== 'string' || typeof target !== 'string')
|
|
1418
|
+
throw new HttpError(400, 'expected { code, path }');
|
|
1419
|
+
if (code !== 'template-frontmatter')
|
|
1420
|
+
throw new HttpError(400, `no fix for ${code}`);
|
|
1421
|
+
const templatesDir = path.join(current.root, FRAMEWORK_DIR, 'templates');
|
|
1422
|
+
const file = path.resolve(current.root, target);
|
|
1423
|
+
if (!file.startsWith(templatesDir + path.sep) || !existsSync(file))
|
|
1424
|
+
throw new HttpError(400, 'not a template of this root');
|
|
1425
|
+
writeFileSync(file, stripFrontmatter(readFileSync(file, 'utf8')), 'utf8');
|
|
1426
|
+
store.invalidate();
|
|
1427
|
+
json(response, 200, { fixed: target });
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// The roles: one response contract per file under .eidos/roles/, the framework's own,
|
|
1431
|
+
// read, written, and removed by name (its kebab-case file name).
|
|
1432
|
+
if (resource === 'roles' && rest === '' && method === 'GET') {
|
|
1433
|
+
const current = store.context();
|
|
1434
|
+
json(response, 200, listRoles(current.root).map((role) => ({ name: role.name, title: role.title, summary: role.summary, path: rel(current.root, role.file) })));
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (resource === 'roles' && rest !== '') {
|
|
1438
|
+
const current = store.context();
|
|
1439
|
+
const name = decodeURIComponent(rest);
|
|
1440
|
+
if (!isRoleName(name))
|
|
1441
|
+
throw new HttpError(400, 'a role name is kebab-case: letters, digits, and dashes');
|
|
1442
|
+
const file = roleFile(current.root, name);
|
|
1443
|
+
if (method === 'GET') {
|
|
1444
|
+
json(response, 200, { name, path: rel(current.root, file), exists: existsSync(file), text: existsSync(file) ? readFileSync(file, 'utf8') : '' });
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (method === 'PUT') {
|
|
1448
|
+
const body = await readBody(request);
|
|
1449
|
+
// { rename } moves the file to the new name, me.md and the users following
|
|
1450
|
+
if (typeof body['rename'] === 'string') {
|
|
1451
|
+
const to = body['rename'].trim();
|
|
1452
|
+
if (!isRoleName(to))
|
|
1453
|
+
throw new HttpError(400, 'a role name is kebab-case: letters, digits, and dashes');
|
|
1454
|
+
if (!existsSync(file))
|
|
1455
|
+
throw new HttpError(404, `no role '${name}'`);
|
|
1456
|
+
if (to !== name && existsSync(roleFile(current.root, to)))
|
|
1457
|
+
throw new HttpError(409, `a role '${to}' already exists`);
|
|
1458
|
+
if (to !== name)
|
|
1459
|
+
renameRoleFiles(current.root, name, to);
|
|
1460
|
+
store.invalidate();
|
|
1461
|
+
json(response, 200, { name: to, path: rel(current.root, roleFile(current.root, to)) });
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (typeof body['text'] !== 'string')
|
|
1465
|
+
throw new HttpError(400, 'expected { text }');
|
|
1466
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1467
|
+
writeFileSync(file, body['text'].replace(/\r\n?/g, '\n'), 'utf8');
|
|
1468
|
+
store.invalidate();
|
|
1469
|
+
json(response, 200, { name, path: rel(current.root, file) });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (method === 'DELETE') {
|
|
1473
|
+
if (!existsSync(file))
|
|
1474
|
+
throw new HttpError(404, `no role '${name}'`);
|
|
1475
|
+
unlinkSync(file);
|
|
1476
|
+
store.invalidate();
|
|
1477
|
+
json(response, 200, { deleted: rel(current.root, file) });
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (resource === 'templates' && rest === '' && method === 'GET') {
|
|
1482
|
+
json(response, 200, templatesView(store.context()));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
if (resource === 'templates' && rest !== '' && method === 'GET') {
|
|
1486
|
+
const current = store.context();
|
|
1487
|
+
const file = templateFile(current, rest);
|
|
1488
|
+
json(response, 200, { path: rel(path.join(current.root, FRAMEWORK_DIR), file), exists: existsSync(file), text: existsSync(file) ? readFileSync(file, 'utf8') : '' });
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (resource === 'templates' && rest !== '' && method === 'PUT') {
|
|
1492
|
+
const current = store.context();
|
|
1493
|
+
const file = templateFile(current, rest);
|
|
1494
|
+
const body = await readBody(request);
|
|
1495
|
+
if (typeof body['text'] !== 'string')
|
|
1496
|
+
throw new HttpError(400, 'expected { text }');
|
|
1497
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1498
|
+
writeFileSync(file, body['text'].replace(/\r\n?/g, '\n'), 'utf8');
|
|
1499
|
+
store.invalidate();
|
|
1500
|
+
json(response, 200, { path: rel(path.join(current.root, FRAMEWORK_DIR), file) });
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (resource === 'me' && method === 'GET') {
|
|
1504
|
+
const current = store.context();
|
|
1505
|
+
const actor = readActor(current.root);
|
|
1506
|
+
json(response, 200, { exists: actor.exists, text: actor.text, actor: actor.actor, path: rel(current.root, meFile(current.root)), roles: listRoles(current.root).map((role) => ({ name: role.name, title: role.title, summary: role.summary })) });
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
// me.md as text, or built from its parts: a role (by name, else a custom
|
|
1510
|
+
// one as written), a note, and the three calibration axes.
|
|
1511
|
+
if (resource === 'me' && method === 'PUT') {
|
|
1512
|
+
const current = store.context();
|
|
1513
|
+
const body = await readBody(request);
|
|
1514
|
+
if (typeof body['text'] === 'string') {
|
|
1515
|
+
writeActor(current.root, body['text'].replace(/\r\n?/g, '\n'));
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
const field = (name) => (typeof body[name] === 'string' && body[name].trim() !== '' ? body[name].trim() : null);
|
|
1519
|
+
const wanted = field('role');
|
|
1520
|
+
const role = wanted ? findRole(current.root, wanted) : null;
|
|
1521
|
+
writeActor(current.root, renderActor(role, role ? null : wanted, { ownership: field('ownership'), experience: field('experience'), capacity: field('capacity') }, field('note')));
|
|
1522
|
+
}
|
|
1523
|
+
json(response, 200, { path: rel(current.root, meFile(current.root)) });
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
// A file of the root opened in the external editor from Preferences, else the system's default app.
|
|
1527
|
+
if (resource === 'open' && method === 'POST') {
|
|
1528
|
+
const current = store.context();
|
|
1529
|
+
const body = await readBody(request);
|
|
1530
|
+
const target = typeof body['path'] === 'string' ? body['path'] : '';
|
|
1531
|
+
const file = path.resolve(current.root, target);
|
|
1532
|
+
if (target === '' || !file.startsWith(current.root + path.sep) || !existsSync(file) || !statSync(file).isFile())
|
|
1533
|
+
throw new HttpError(400, 'not a file of this root');
|
|
1534
|
+
const editor = readSettings(current.root).local.editor;
|
|
1535
|
+
const quote = (given) => `"${given.replace(/"/g, '\\"')}"`;
|
|
1536
|
+
const command = editor ? editor.replace(/\{file\}/g, quote(file)) : process.platform === 'darwin' ? `open ${quote(file)}` : process.platform === 'win32' ? `start "" ${quote(file)}` : `xdg-open ${quote(file)}`;
|
|
1537
|
+
const child = spawn(command, { cwd: current.root, shell: true, detached: true, stdio: 'ignore' });
|
|
1538
|
+
child.on('error', () => undefined);
|
|
1539
|
+
child.unref();
|
|
1540
|
+
json(response, 200, { launched: command });
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
// POST /api/reveal { path }: the file shown in the machine's file manager, selected where the platform can (Finder, Explorer, the FileManager1 desktop service on Linux, else the folder opened).
|
|
1544
|
+
if (resource === 'reveal' && method === 'POST') {
|
|
1545
|
+
const current = store.context();
|
|
1546
|
+
const body = await readBody(request);
|
|
1547
|
+
const target = typeof body['path'] === 'string' ? body['path'] : '';
|
|
1548
|
+
const file = path.resolve(current.root, target);
|
|
1549
|
+
if (target === '' || !file.startsWith(current.root + path.sep) || !existsSync(file))
|
|
1550
|
+
throw new HttpError(400, 'not a file of this root');
|
|
1551
|
+
const launch = (command, args, onError) => {
|
|
1552
|
+
const child = spawn(command, args, { cwd: current.root, detached: true, stdio: 'ignore' });
|
|
1553
|
+
child.on('error', () => onError?.());
|
|
1554
|
+
child.on('exit', (code) => code !== 0 && onError?.());
|
|
1555
|
+
child.unref();
|
|
1556
|
+
};
|
|
1557
|
+
if (process.platform === 'darwin')
|
|
1558
|
+
launch('open', ['-R', file]);
|
|
1559
|
+
else if (process.platform === 'win32')
|
|
1560
|
+
launch('explorer', [`/select,${file}`]);
|
|
1561
|
+
else
|
|
1562
|
+
launch('dbus-send', ['--session', '--dest=org.freedesktop.FileManager1', '--type=method_call', '/org/freedesktop/FileManager1', 'org.freedesktop.FileManager1.ShowItems', `array:string:file://${file}`, 'string:'], () => launch('xdg-open', [path.dirname(file)]));
|
|
1563
|
+
json(response, 200, { revealed: rel(current.root, file) });
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (resource === 'canvas') {
|
|
1567
|
+
await handleCanvas(store, method, segments.slice(2), request, response);
|
|
215
1568
|
return;
|
|
216
1569
|
}
|
|
217
1570
|
if (resource === 'check' && method === 'GET') {
|
|
218
|
-
json(response, 200, checkSnapshot(context()));
|
|
1571
|
+
json(response, 200, checkSnapshot(store.context()));
|
|
219
1572
|
return;
|
|
220
1573
|
}
|
|
221
1574
|
if (resource === 'index' && method === 'POST') {
|
|
222
|
-
|
|
1575
|
+
const result = indexRebuild(store.context());
|
|
1576
|
+
store.invalidate();
|
|
1577
|
+
json(response, 200, result);
|
|
223
1578
|
return;
|
|
224
1579
|
}
|
|
225
1580
|
if (resource === 'standard' && method === 'GET') {
|
|
@@ -236,9 +1591,9 @@ export function createServer(options) {
|
|
|
236
1591
|
}
|
|
237
1592
|
throw new HttpError(404, 'not found');
|
|
238
1593
|
};
|
|
239
|
-
|
|
1594
|
+
const server = createHttpServer((request, response) => {
|
|
240
1595
|
handle(request, response).catch((cause) => {
|
|
241
|
-
if (cause instanceof HttpError) {
|
|
1596
|
+
if (cause instanceof HttpError || cause instanceof CanvasError) {
|
|
242
1597
|
json(response, cause.status, { error: cause.message });
|
|
243
1598
|
}
|
|
244
1599
|
else if (cause instanceof CliError) {
|
|
@@ -249,6 +1604,8 @@ export function createServer(options) {
|
|
|
249
1604
|
}
|
|
250
1605
|
});
|
|
251
1606
|
});
|
|
1607
|
+
server.on('close', () => store.close());
|
|
1608
|
+
return server;
|
|
252
1609
|
}
|
|
253
1610
|
// The first free port at or after `preferred`, on the loopback interface.
|
|
254
1611
|
export function listen(server, preferred) {
|