@graview/core 0.1.8 → 0.1.10
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 +8 -2
- package/dist/app.d.ts +41 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js.map +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/city.d.ts +29 -0
- package/dist/city.d.ts.map +1 -1
- package/dist/city.js +66 -0
- package/dist/city.js.map +1 -1
- package/dist/cli/check/arrangement.d.ts.map +1 -1
- package/dist/cli/check/arrangement.js +3 -2
- package/dist/cli/check/arrangement.js.map +1 -1
- package/dist/cli/check/computed.d.ts +14 -0
- package/dist/cli/check/computed.d.ts.map +1 -0
- package/dist/cli/check/computed.js +34 -0
- package/dist/cli/check/computed.js.map +1 -0
- package/dist/cli/check/index.d.ts +1 -0
- package/dist/cli/check/index.d.ts.map +1 -1
- package/dist/cli/check/index.js +1 -0
- package/dist/cli/check/index.js.map +1 -1
- package/dist/cli/check/lenses.d.ts +10 -0
- package/dist/cli/check/lenses.d.ts.map +1 -1
- package/dist/cli/check/lenses.js +37 -19
- package/dist/cli/check/lenses.js.map +1 -1
- package/dist/cli/check/relations.d.ts.map +1 -1
- package/dist/cli/check/relations.js +2 -1
- package/dist/cli/check/relations.js.map +1 -1
- package/dist/cli/check/views.d.ts.map +1 -1
- package/dist/cli/check/views.js +8 -2
- package/dist/cli/check/views.js.map +1 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +4 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/describe.d.ts.map +1 -1
- package/dist/cli/describe.js +54 -1
- package/dist/cli/describe.js.map +1 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/describe.d.ts +3 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +12 -0
- package/dist/describe.js.map +1 -0
- package/dist/document/blocks.d.ts +182 -0
- package/dist/document/blocks.d.ts.map +1 -0
- package/dist/document/blocks.js +382 -0
- package/dist/document/blocks.js.map +1 -0
- package/dist/document/compile.d.ts.map +1 -1
- package/dist/document/compile.js +57 -15
- package/dist/document/compile.js.map +1 -1
- package/dist/document/computed-values.d.ts +33 -0
- package/dist/document/computed-values.d.ts.map +1 -0
- package/dist/document/computed-values.js +60 -0
- package/dist/document/computed-values.js.map +1 -0
- package/dist/document/computed.d.ts +42 -0
- package/dist/document/computed.d.ts.map +1 -0
- package/dist/document/computed.js +173 -0
- package/dist/document/computed.js.map +1 -0
- package/dist/document/describe-place.d.ts +95 -0
- package/dist/document/describe-place.d.ts.map +1 -0
- package/dist/document/describe-place.js +257 -0
- package/dist/document/describe-place.js.map +1 -0
- package/dist/document/diff.d.ts.map +1 -1
- package/dist/document/diff.js +72 -3
- package/dist/document/diff.js.map +1 -1
- package/dist/document/edit.d.ts +1 -1
- package/dist/document/edit.d.ts.map +1 -1
- package/dist/document/edit.js +816 -103
- package/dist/document/edit.js.map +1 -1
- package/dist/document/expr/analyze.d.ts +22 -0
- package/dist/document/expr/analyze.d.ts.map +1 -1
- package/dist/document/expr/analyze.js +49 -3
- package/dist/document/expr/analyze.js.map +1 -1
- package/dist/document/expr/evaluate.d.ts +7 -1
- package/dist/document/expr/evaluate.d.ts.map +1 -1
- package/dist/document/expr/evaluate.js +95 -6
- package/dist/document/expr/evaluate.js.map +1 -1
- package/dist/document/expr/index.d.ts +1 -1
- package/dist/document/expr/index.d.ts.map +1 -1
- package/dist/document/expr/index.js +1 -1
- package/dist/document/expr/index.js.map +1 -1
- package/dist/document/index.d.ts +10 -2
- package/dist/document/index.d.ts.map +1 -1
- package/dist/document/index.js +5 -1
- package/dist/document/index.js.map +1 -1
- package/dist/document/rules.d.ts +2 -1
- package/dist/document/rules.d.ts.map +1 -1
- package/dist/document/rules.js +5 -1
- package/dist/document/rules.js.map +1 -1
- package/dist/document/schema.d.ts +35 -3
- package/dist/document/schema.d.ts.map +1 -1
- package/dist/document/schema.js +22 -2
- package/dist/document/schema.js.map +1 -1
- package/dist/document/template.d.ts +17 -2
- package/dist/document/template.d.ts.map +1 -1
- package/dist/document/template.js +136 -16
- package/dist/document/template.js.map +1 -1
- package/dist/document/thumbnail.d.ts +34 -0
- package/dist/document/thumbnail.d.ts.map +1 -0
- package/dist/document/thumbnail.js +165 -0
- package/dist/document/thumbnail.js.map +1 -0
- package/dist/document/to-document.d.ts.map +1 -1
- package/dist/document/to-document.js +4 -1
- package/dist/document/to-document.js.map +1 -1
- package/dist/document/views.d.ts +47 -3
- package/dist/document/views.d.ts.map +1 -1
- package/dist/document/views.js +289 -21
- package/dist/document/views.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/places.d.ts +186 -0
- package/dist/places.d.ts.map +1 -0
- package/dist/places.js +358 -0
- package/dist/places.js.map +1 -0
- package/dist/scaffold/project.d.ts.map +1 -1
- package/dist/scaffold/project.js +9 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/ui.d.ts.map +1 -1
- package/dist/scaffold/ui.js +13 -10
- package/dist/scaffold/ui.js.map +1 -1
- package/dist/scene-districts.d.ts +24 -0
- package/dist/scene-districts.d.ts.map +1 -0
- package/dist/scene-districts.js +27 -0
- package/dist/scene-districts.js.map +1 -0
- package/dist/schema/define-node.d.ts +3 -0
- package/dist/schema/define-node.d.ts.map +1 -1
- package/dist/schema/define-node.js +3 -1
- package/dist/schema/define-node.js.map +1 -1
- package/dist/schema/types.d.ts +17 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/theme/layers.d.ts +82 -0
- package/dist/theme/layers.d.ts.map +1 -0
- package/dist/theme/layers.js +79 -0
- package/dist/theme/layers.js.map +1 -0
- package/dist/theme/look.d.ts +96 -0
- package/dist/theme/look.d.ts.map +1 -0
- package/dist/theme/look.js +110 -0
- package/dist/theme/look.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/views/types.d.ts +19 -0
- package/dist/views/types.d.ts.map +1 -1
- package/dist/views/types.js +14 -0
- package/dist/views/types.js.map +1 -1
- package/package.json +5 -1
package/dist/document/edit.js
CHANGED
|
@@ -4,8 +4,11 @@ import { printExpr } from "./expr/print.js";
|
|
|
4
4
|
import { error } from "./findings.js";
|
|
5
5
|
import { coerce } from "./migrate.js";
|
|
6
6
|
import { ActSpec, EDGE_NAME, EdgeSpec, FIELD_NAME, FIELD_TYPES, FieldSpec, KindSpec, NAME, RuleSpec, } from "./schema.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { SHIPPED_LENSES, isShippedLens, SHIPPED_LENS_NAMES } from "../places.js";
|
|
8
|
+
import { placeSlug } from "../views/types.js";
|
|
9
|
+
import { computedOf, validateComputed } from "./computed.js";
|
|
10
|
+
import { parseTemplate, templateBraces, TemplateError } from "./template.js";
|
|
11
|
+
import { validateViews, VIEW_SLOTS } from "./views.js";
|
|
9
12
|
/** The ops, for tool schemas and the guide. */
|
|
10
13
|
export const EDIT_OPS = [
|
|
11
14
|
"add-kind",
|
|
@@ -30,6 +33,11 @@ export const EDIT_OPS = [
|
|
|
30
33
|
"set-describe",
|
|
31
34
|
"set-view",
|
|
32
35
|
"set-glance",
|
|
36
|
+
"add-lens",
|
|
37
|
+
"remove-lens",
|
|
38
|
+
"set-home",
|
|
39
|
+
"arrange-pages",
|
|
40
|
+
"set-computed",
|
|
33
41
|
];
|
|
34
42
|
/** Words the rule language keeps: a field or relation renamed to one could not be named in a rule. */
|
|
35
43
|
const KEYWORDS = new Set(["and", "or", "not", "in", "where", "true", "false", "null"]);
|
|
@@ -39,6 +47,7 @@ const kindName = z.string().regex(NAME, 'kind names are lower-case words joined
|
|
|
39
47
|
const fieldName = z.string().regex(FIELD_NAME, 'field names are one word or camelCase, like "dueDate"');
|
|
40
48
|
const edgeName = z.string().regex(EDGE_NAME, 'relation names are one camelCase word, like "tendedBy"');
|
|
41
49
|
const actName = z.string().regex(NAME, 'act names are lower-case words joined by hyphens, like "mark-paid"');
|
|
50
|
+
const lensTitle = z.string().min(1).max(80);
|
|
42
51
|
const SHAPES = {
|
|
43
52
|
"add-kind": z.object({ op: z.literal("add-kind"), kind: kindName, act: z.boolean().optional() }).passthrough(),
|
|
44
53
|
"rename-kind": z.object({ op: z.literal("rename-kind"), kind: kindName, to: kindName, noun: z.string().min(1).max(40).optional(), plural: z.string().min(1).max(40).optional() }).strict(),
|
|
@@ -60,8 +69,41 @@ const SHAPES = {
|
|
|
60
69
|
"set-brand": z.object({ op: z.literal("set-brand"), accent: z.union([z.string(), z.null()]), name: z.string().min(1).max(60).optional() }).strict(),
|
|
61
70
|
"set-label": z.object({ op: z.literal("set-label"), kind: kindName, field: fieldName.optional(), label: z.union([z.string().min(1).max(300), z.null()]) }).strict(),
|
|
62
71
|
"set-describe": z.object({ op: z.literal("set-describe"), kind: kindName, describe: z.union([z.string().min(1).max(300), z.null()]) }).strict(),
|
|
63
|
-
|
|
72
|
+
// A kind's slot; the front page (`slot: "home"`, no kind); or a blocks lens's blocks, by its title (FR-84).
|
|
73
|
+
"set-view": z.object({ op: z.literal("set-view"), kind: kindName.optional(), slot: z.enum([...VIEW_SLOTS, "home"]).optional(), lens: lensTitle.optional(), blocks: z.union([z.array(z.unknown()).min(1), z.null()]) }).strict(),
|
|
64
74
|
"set-glance": z.object({ op: z.literal("set-glance"), kind: kindName, fields: z.array(fieldName).max(20) }).strict(),
|
|
75
|
+
"add-lens": z
|
|
76
|
+
.object({
|
|
77
|
+
op: z.literal("add-lens"),
|
|
78
|
+
title: lensTitle,
|
|
79
|
+
lens: z.string().min(1).max(40).optional(),
|
|
80
|
+
on: kindName.optional(),
|
|
81
|
+
bindings: z.record(z.string(), z.unknown()).optional(),
|
|
82
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
83
|
+
at: z.number().int().min(0).optional(),
|
|
84
|
+
replace: lensTitle.optional(),
|
|
85
|
+
})
|
|
86
|
+
.strict(),
|
|
87
|
+
"remove-lens": z.object({ op: z.literal("remove-lens"), title: lensTitle, on: kindName.optional() }).strict(),
|
|
88
|
+
"set-home": z.object({ op: z.literal("set-home"), blocks: z.union([z.array(z.unknown()).min(1), z.null()]) }).strict(),
|
|
89
|
+
"arrange-pages": z
|
|
90
|
+
.object({
|
|
91
|
+
op: z.literal("arrange-pages"),
|
|
92
|
+
order: z.union([z.array(kindName).max(40), z.null()]).optional(),
|
|
93
|
+
hide: z.union([z.array(kindName).max(40), z.null()]).optional(),
|
|
94
|
+
first: z.union([z.string().min(1).max(80), z.null()]).optional(),
|
|
95
|
+
})
|
|
96
|
+
.strict(),
|
|
97
|
+
"set-computed": z
|
|
98
|
+
.object({
|
|
99
|
+
op: z.literal("set-computed"),
|
|
100
|
+
kind: kindName,
|
|
101
|
+
name: fieldName,
|
|
102
|
+
expr: z.union([z.string().min(1).max(2000), z.null()]).optional(),
|
|
103
|
+
label: z.union([z.string().min(1).max(60), z.null()]).optional(),
|
|
104
|
+
description: z.union([z.string().min(1).max(500), z.null()]).optional(),
|
|
105
|
+
})
|
|
106
|
+
.strict(),
|
|
65
107
|
};
|
|
66
108
|
const NONE = new Set();
|
|
67
109
|
const clone = (v) => structuredClone(v);
|
|
@@ -114,6 +156,11 @@ function kindsOf(doc, e, ctx) {
|
|
|
114
156
|
return new Set([lit]);
|
|
115
157
|
if (e.fn === "if" && e.args.length === 3)
|
|
116
158
|
return union(kindsOf(doc, e.args[1], ctx), kindsOf(doc, e.args[2], ctx));
|
|
159
|
+
// A set handed through: its first, in an order, or the first of several that is not empty.
|
|
160
|
+
if ((e.fn === "first" || e.fn === "sort") && e.args[0])
|
|
161
|
+
return kindsOf(doc, e.args[0], ctx);
|
|
162
|
+
if (e.fn === "either")
|
|
163
|
+
return e.args.reduce((all, arg) => union(all, kindsOf(doc, arg, ctx)), NONE);
|
|
117
164
|
return NONE;
|
|
118
165
|
}
|
|
119
166
|
default:
|
|
@@ -165,8 +212,9 @@ function mapNames(doc, e, ctx, rename) {
|
|
|
165
212
|
const name = rename({ role: x.fn === "all" ? "kind" : "edge", kinds: NONE, name: literal });
|
|
166
213
|
args = name === literal ? [...x.args] : [{ ...first, value: name }];
|
|
167
214
|
}
|
|
168
|
-
else if ((x.fn === "every" || x.fn === "some") && x.args.length === 2) {
|
|
169
|
-
|
|
215
|
+
else if (((x.fn === "every" || x.fn === "some") && x.args.length === 2) || (x.fn === "sort" && x.args.length >= 2)) {
|
|
216
|
+
// Read once per member, with the member as its subject: its names are the members'.
|
|
217
|
+
args = [go(x.args[0], at), go(x.args[1], kindsOf(doc, x.args[0], at)), ...x.args.slice(2).map((a) => go(a, at))];
|
|
170
218
|
}
|
|
171
219
|
else if ((x.fn === "sum" || x.fn === "min" || x.fn === "max") && x.args.length === 2) {
|
|
172
220
|
const second = x.args[1];
|
|
@@ -179,7 +227,7 @@ function mapNames(doc, e, ctx, rename) {
|
|
|
179
227
|
field = second.t === "ident" ? { ...second, name } : { ...second, value: name };
|
|
180
228
|
}
|
|
181
229
|
else
|
|
182
|
-
field = go(second,
|
|
230
|
+
field = go(second, members); // sum(out('includes'), list * units): each member's own list and units
|
|
183
231
|
args = [go(x.args[0], at), field];
|
|
184
232
|
}
|
|
185
233
|
else
|
|
@@ -201,7 +249,8 @@ function renamer(doc, r) {
|
|
|
201
249
|
return r.to;
|
|
202
250
|
return site.name;
|
|
203
251
|
}
|
|
204
|
-
|
|
252
|
+
// A field, or a computed field (FR-83): read by the same bare name.
|
|
253
|
+
if ((site.role === "name" || site.role === "field") && site.name === r.from && hasKind(site.kinds, r.kind) && (doc.kinds[r.kind]?.fields?.[r.from] || doc.kinds[r.kind]?.computed?.[r.from] !== undefined))
|
|
205
254
|
return r.to;
|
|
206
255
|
return site.name;
|
|
207
256
|
};
|
|
@@ -234,12 +283,18 @@ function parts(source) {
|
|
|
234
283
|
throw err;
|
|
235
284
|
}
|
|
236
285
|
}
|
|
237
|
-
/**
|
|
286
|
+
/**
|
|
287
|
+
* A template with the rename applied inside its braces; the words around them
|
|
288
|
+
* are kept, and so is what each brace says after its bar — `| money`,
|
|
289
|
+
* `| plural: 'front'` — exactly as it was written.
|
|
290
|
+
*/
|
|
238
291
|
function rewriteTemplate(doc, source, ctx, r, prose) {
|
|
239
292
|
const ps = parts(source);
|
|
240
293
|
if (!ps)
|
|
241
294
|
return source;
|
|
295
|
+
const braces = templateBraces(source);
|
|
242
296
|
let changed = false;
|
|
297
|
+
let brace = 0;
|
|
243
298
|
const out = ps.map((p) => {
|
|
244
299
|
if (p.text !== undefined) {
|
|
245
300
|
const t = prose ? prose(p.text) : p.text;
|
|
@@ -247,11 +302,15 @@ function rewriteTemplate(doc, source, ctx, r, prose) {
|
|
|
247
302
|
changed = true;
|
|
248
303
|
return t;
|
|
249
304
|
}
|
|
305
|
+
const { inner, bar } = braces[brace++];
|
|
250
306
|
const next = mapNames(doc, p.expr, ctx, renamer(doc, r));
|
|
251
307
|
if (next === p.expr)
|
|
252
|
-
return `{${
|
|
308
|
+
return `{${inner}}`;
|
|
253
309
|
changed = true;
|
|
254
|
-
|
|
310
|
+
const tail = bar >= 0 ? inner.slice(bar) : "";
|
|
311
|
+
const lead = /^\s*/.exec(inner)[0];
|
|
312
|
+
const gap = bar >= 0 ? /\s*$/.exec(inner.slice(0, bar))[0] : /\s*$/.exec(inner)[0];
|
|
313
|
+
return `{${lead}${printExpr(next)}${gap}${tail}}`;
|
|
255
314
|
});
|
|
256
315
|
return changed ? out.join("") : source;
|
|
257
316
|
}
|
|
@@ -488,25 +547,8 @@ class Editor {
|
|
|
488
547
|
this.said.push(e.describe === null ? `A ${e.kind} has no summary line.` : `A ${e.kind}'s summary line reads "${e.describe}".`);
|
|
489
548
|
return;
|
|
490
549
|
}
|
|
491
|
-
case "set-view":
|
|
492
|
-
|
|
493
|
-
return;
|
|
494
|
-
const views = (this.doc.views ??= {});
|
|
495
|
-
if (e.blocks === null) {
|
|
496
|
-
if (views[e.kind])
|
|
497
|
-
delete views[e.kind][e.slot];
|
|
498
|
-
if (views[e.kind] && Object.keys(views[e.kind]).length === 0)
|
|
499
|
-
delete views[e.kind];
|
|
500
|
-
if (Object.keys(views).length === 0)
|
|
501
|
-
delete this.doc.views;
|
|
502
|
-
this.said.push(`A ${e.kind} ${e.slot} goes back to Graview's own look.`);
|
|
503
|
-
}
|
|
504
|
-
else {
|
|
505
|
-
(views[e.kind] ??= {})[e.slot] = e.blocks;
|
|
506
|
-
this.said.push(`A ${e.kind} ${e.slot} gets a look of its own (${e.blocks.length} block${e.blocks.length === 1 ? "" : "s"}).`);
|
|
507
|
-
}
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
550
|
+
case "set-view":
|
|
551
|
+
return this.setView(i, e);
|
|
510
552
|
case "set-glance": {
|
|
511
553
|
// What a glance at one says (FR-39): fields of this kind, each once, in the order given; none takes the choice away.
|
|
512
554
|
const spec = this.kind(i, e.kind);
|
|
@@ -533,7 +575,280 @@ class Editor {
|
|
|
533
575
|
}
|
|
534
576
|
return;
|
|
535
577
|
}
|
|
578
|
+
case "add-lens":
|
|
579
|
+
return this.addLens(i, e);
|
|
580
|
+
case "remove-lens":
|
|
581
|
+
return this.removeLens(i, e);
|
|
582
|
+
case "set-home":
|
|
583
|
+
return this.setHome(i, e.blocks);
|
|
584
|
+
case "arrange-pages":
|
|
585
|
+
return this.arrangePages(i, e);
|
|
586
|
+
case "set-computed":
|
|
587
|
+
return this.setComputed(i, e);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// ── views, the front page, lenses, the arrangement, what is worked out (FR-84) ──
|
|
591
|
+
/** Blocks checked where they will stand: an error refuses the edit at that path, so a chat is told before anything is previewed. */
|
|
592
|
+
blocksHold(i, at) {
|
|
593
|
+
const found = validateViews(this.doc).filter((f) => f.severity === "error" || f.path.startsWith("lenses."));
|
|
594
|
+
const here = found.filter((f) => f.path === at || f.path.startsWith(`${at}.`));
|
|
595
|
+
for (const f of here)
|
|
596
|
+
this.fail(i, `blocks${f.path.slice(at.length)}`, f.message, f.fix);
|
|
597
|
+
return here.length === 0;
|
|
598
|
+
}
|
|
599
|
+
setView(i, e) {
|
|
600
|
+
if (e.lens !== undefined) {
|
|
601
|
+
if (e.kind !== undefined || e.slot !== undefined)
|
|
602
|
+
return this.fail(i, "lens", "a lens's blocks are set by its title alone; leave out kind and slot");
|
|
603
|
+
const index = this.lensIndex(i, e.lens);
|
|
604
|
+
if (index < 0)
|
|
605
|
+
return;
|
|
606
|
+
const lens = this.doc.lenses[index];
|
|
607
|
+
if (lens.name !== "blocks")
|
|
608
|
+
return this.fail(i, "lens", `"${e.lens}" is drawn by the ${String(lens.name)} lens, not from blocks`, "add-lens with replace to draw it from blocks instead");
|
|
609
|
+
if (e.blocks === null)
|
|
610
|
+
return this.fail(i, "blocks", "a blocks lens is its blocks; remove-lens takes it away");
|
|
611
|
+
const was = lens.options?.blocks;
|
|
612
|
+
lens.options = { ...(lens.options ?? {}), blocks: e.blocks };
|
|
613
|
+
if (!this.blocksHold(i, `lenses.${index}.options.blocks`))
|
|
614
|
+
return;
|
|
615
|
+
this.said.push(`The lens "${e.lens}" is drawn from ${blockWords(e.blocks)}${was ? " now" : ""}.`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (e.slot === "home") {
|
|
619
|
+
if (e.kind !== undefined)
|
|
620
|
+
return this.fail(i, "kind", "the front page is no kind's; leave out kind");
|
|
621
|
+
return this.setHome(i, e.blocks);
|
|
622
|
+
}
|
|
623
|
+
if (e.kind === undefined || e.slot === undefined)
|
|
624
|
+
return this.fail(i, e.kind === undefined ? "kind" : "slot", 'set-view names a kind and a slot ("card", "row" or "page"), the front page (slot "home"), or a lens by its title');
|
|
625
|
+
if (!this.kind(i, e.kind))
|
|
626
|
+
return;
|
|
627
|
+
const views = (this.doc.views ??= {});
|
|
628
|
+
if (Array.isArray(views[e.kind]))
|
|
629
|
+
return this.fail(i, "kind", `"${e.kind}" here is the front page's blocks, not a kind's views`);
|
|
630
|
+
const noun = nounOf(this.doc, e.kind);
|
|
631
|
+
if (e.blocks === null) {
|
|
632
|
+
if (views[e.kind])
|
|
633
|
+
delete views[e.kind][e.slot];
|
|
634
|
+
if (views[e.kind] && Object.keys(views[e.kind]).length === 0)
|
|
635
|
+
delete views[e.kind];
|
|
636
|
+
if (Object.keys(views).length === 0)
|
|
637
|
+
delete this.doc.views;
|
|
638
|
+
this.said.push(`${cap(withA(noun))} ${e.slot} goes back to Graview's own look.`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
(views[e.kind] ??= {})[e.slot] = e.blocks;
|
|
642
|
+
if (!this.blocksHold(i, `views.${e.kind}.${e.slot}`))
|
|
643
|
+
return;
|
|
644
|
+
this.said.push(`${cap(withA(noun))} ${e.slot} gets a look of its own: ${blockWords(e.blocks)}.`);
|
|
645
|
+
}
|
|
646
|
+
setHome(i, blocks) {
|
|
647
|
+
const views = (this.doc.views ??= {});
|
|
648
|
+
if (views.home !== undefined && !Array.isArray(views.home))
|
|
649
|
+
return this.fail(i, "blocks", "this app has a kind called home, whose views stand where the front page's blocks would");
|
|
650
|
+
const had = Array.isArray(views.home);
|
|
651
|
+
if (blocks === null) {
|
|
652
|
+
delete views.home;
|
|
653
|
+
if (Object.keys(views).length === 0)
|
|
654
|
+
delete this.doc.views;
|
|
655
|
+
this.said.push(had ? "The front page goes back to Graview's own." : "The front page was already Graview's own.");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
views.home = blocks;
|
|
659
|
+
if (!this.blocksHold(i, "views.home"))
|
|
660
|
+
return;
|
|
661
|
+
this.said.push(had ? `The front page changes: ${blockWords(blocks)}.` : `The front page gets a view of its own: ${blockWords(blocks)}.`);
|
|
662
|
+
}
|
|
663
|
+
/** The lens a title names (and an `on`, when two lenses share a title), or -1 having said why. */
|
|
664
|
+
lensIndex(i, title, on, path = "title") {
|
|
665
|
+
const lenses = this.doc.lenses ?? [];
|
|
666
|
+
const matches = lenses.flatMap((lens, index) => (isObject(lens) && typeof lens["title"] === "string" && (lens["title"] === title || placeSlug(lens["title"]) === placeSlug(title)) && (on === undefined || lensStandsOn(lens, on)) ? [index] : []));
|
|
667
|
+
if (matches.length === 1)
|
|
668
|
+
return matches[0];
|
|
669
|
+
const titles = lenses.flatMap((lens) => (isObject(lens) && typeof lens["title"] === "string" ? [`"${lens["title"]}"`] : []));
|
|
670
|
+
if (matches.length === 0)
|
|
671
|
+
this.fail(i, path, `there is no lens "${title}"${titles.length ? `; the lenses are ${titles.join(", ")}` : ""}`);
|
|
672
|
+
else
|
|
673
|
+
this.fail(i, "on", `${matches.length} lenses are called "${title}"; say which kind it stands on with "on"`);
|
|
674
|
+
return -1;
|
|
675
|
+
}
|
|
676
|
+
addLens(i, e) {
|
|
677
|
+
const name = e.lens ?? "blocks";
|
|
678
|
+
if (!isShippedLens(name))
|
|
679
|
+
return this.fail(i, "lens", `"${name}" is not a lens Graview draws`, `one of ${SHIPPED_LENS_NAMES.join(", ")}`);
|
|
680
|
+
if (e.on !== undefined && !this.kind(i, e.on))
|
|
681
|
+
return;
|
|
682
|
+
const lenses = (this.doc.lenses ??= []);
|
|
683
|
+
let at = lenses.length;
|
|
684
|
+
let was;
|
|
685
|
+
if (e.replace !== undefined) {
|
|
686
|
+
at = this.lensIndex(i, e.replace, e.on, "replace");
|
|
687
|
+
if (at < 0)
|
|
688
|
+
return;
|
|
689
|
+
was = lenses[at];
|
|
690
|
+
}
|
|
691
|
+
const lens = { name, title: e.title, ...(e.on !== undefined ? { on: e.on } : {}), ...(e.bindings !== undefined ? { bindings: e.bindings } : {}), ...(e.options !== undefined ? { options: e.options } : {}) };
|
|
692
|
+
// A title is an address: one lens a title on a kind.
|
|
693
|
+
const clash = lenses.findIndex((other, index) => index !== at && isObject(other) && typeof other["title"] === "string" && placeSlug(other["title"]) === placeSlug(e.title) && (e.on === undefined || other["on"] === undefined || other["on"] === e.on));
|
|
694
|
+
if (clash >= 0)
|
|
695
|
+
return this.fail(i, "title", `there is already a lens called "${String(lenses[clash]["title"])}"`, 'give it its own title, or say "replace" with the title of the lens it takes the place of');
|
|
696
|
+
if (name === "blocks") {
|
|
697
|
+
if (e.on === undefined)
|
|
698
|
+
return this.fail(i, "on", "a lens drawn from blocks says which kind it is a place of", 'say "on": "<kind>"');
|
|
699
|
+
if (!Array.isArray(e.options?.blocks) || e.options.blocks.length === 0)
|
|
700
|
+
return this.fail(i, "options.blocks", 'a lens drawn from blocks has blocks: "options": {"blocks": [{"headline": "…"}, {"list": "all(\'<kind>\')", "as": "card"}]}');
|
|
701
|
+
}
|
|
702
|
+
if (was)
|
|
703
|
+
lenses[at] = lens;
|
|
704
|
+
else
|
|
705
|
+
lenses.splice(e.at === undefined ? lenses.length : Math.min(e.at, lenses.length), 0, lens);
|
|
706
|
+
const index = was ? at : lenses.indexOf(lens);
|
|
707
|
+
if (name === "blocks" && !this.blocksHold(i, `lenses.${index}.options.blocks`))
|
|
708
|
+
return;
|
|
709
|
+
const over = lens.on ?? kindsBound(lens)[0];
|
|
710
|
+
const where = over ? `, over ${pluralWords(this.doc, over)}` : "";
|
|
711
|
+
if (!was) {
|
|
712
|
+
this.said.push(`A lens "${e.title}" is added${where}.`);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const retitled = was["title"] !== e.title;
|
|
716
|
+
// Where the app opens follows a lens it named, by its new title (FR-80).
|
|
717
|
+
let follows = "";
|
|
718
|
+
if (retitled && namesLens(this.doc.pages?.first, String(was["title"]))) {
|
|
719
|
+
this.doc.pages.first = e.title;
|
|
720
|
+
follows = "; the app still opens on it";
|
|
721
|
+
}
|
|
722
|
+
this.said.push(retitled ? `The lens "${String(was["title"])}" is now "${e.title}"${where}${follows}.` : `The lens "${e.title}" changes${where}.`);
|
|
723
|
+
}
|
|
724
|
+
removeLens(i, e) {
|
|
725
|
+
const index = this.lensIndex(i, e.title, e.on);
|
|
726
|
+
if (index < 0)
|
|
727
|
+
return;
|
|
728
|
+
const said = this.removeLensAt(index);
|
|
729
|
+
this.said.push(`${cap(said.replace(/ \(the app opens at its home again\)$/, ""))} is removed${said.endsWith("again)") ? "; the app opens at its home again" : ""}.`);
|
|
730
|
+
}
|
|
731
|
+
arrangePages(i, e) {
|
|
732
|
+
if (e.order === undefined && e.hide === undefined && e.first === undefined)
|
|
733
|
+
return this.fail(i, "", 'arrange-pages says at least one of "order", "hide" or "first"');
|
|
734
|
+
const kinds = Object.keys(this.doc.kinds);
|
|
735
|
+
for (const part of ["order", "hide"]) {
|
|
736
|
+
for (const [n, kind] of (e[part] ?? []).entries()) {
|
|
737
|
+
if (!kinds.includes(kind))
|
|
738
|
+
return this.fail(i, `${part}.${n}`, `"${kind}" is not a kind this app has; it has ${kinds.join(", ")}`);
|
|
739
|
+
if (e[part].indexOf(kind) !== n)
|
|
740
|
+
return this.fail(i, `${part}.${n}`, `"${kind}" is named twice`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (typeof e.first === "string" && !this.opens(e.first)) {
|
|
744
|
+
const titles = (this.doc.lenses ?? []).flatMap((lens) => (isObject(lens) && typeof lens["title"] === "string" ? [`"${lens["title"]}"`] : []));
|
|
745
|
+
return this.fail(i, "first", `"${e.first}" is not a place, a kind or "home"`, `name a place (${titles.join(", ") || "none is declared"}), a kind (${kinds.join(", ")}) or "home"`);
|
|
746
|
+
}
|
|
747
|
+
const pages = (this.doc.pages ??= {});
|
|
748
|
+
const said = [];
|
|
749
|
+
if (e.order !== undefined) {
|
|
750
|
+
if (e.order === null || e.order.length === 0) {
|
|
751
|
+
delete pages.order;
|
|
752
|
+
said.push("the kinds go back to the order they were declared in");
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
pages.order = [...e.order];
|
|
756
|
+
said.push(`${listOf(e.order.map((k) => pluralWords(this.doc, k)))} come first, in that order`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (e.hide !== undefined) {
|
|
760
|
+
if (e.hide === null || e.hide.length === 0) {
|
|
761
|
+
delete pages.hide;
|
|
762
|
+
said.push("the front page shows every kind");
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
pages.hide = [...e.hide];
|
|
766
|
+
said.push(`the front page leaves off ${listOf(e.hide.map((k) => pluralWords(this.doc, k)))}`);
|
|
767
|
+
}
|
|
536
768
|
}
|
|
769
|
+
if (e.first !== undefined) {
|
|
770
|
+
if (e.first === null || e.first.trim().toLowerCase() === "home") {
|
|
771
|
+
if (e.first === null)
|
|
772
|
+
delete pages.first;
|
|
773
|
+
else
|
|
774
|
+
pages.first = e.first;
|
|
775
|
+
said.push("the app opens at its home");
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
pages.first = e.first;
|
|
779
|
+
said.push(`the app opens on "${e.first}"`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (Object.keys(pages).length === 0)
|
|
783
|
+
delete this.doc.pages;
|
|
784
|
+
this.said.push(`${cap(listOf(said))}.`);
|
|
785
|
+
}
|
|
786
|
+
/** Whether `first` names something the app can open on: home, a kind or its plural, a titled lens. */
|
|
787
|
+
opens(first) {
|
|
788
|
+
const word = first.trim();
|
|
789
|
+
if (word.toLowerCase() === "home" || word === "/")
|
|
790
|
+
return true;
|
|
791
|
+
if (this.doc.kinds[word])
|
|
792
|
+
return true;
|
|
793
|
+
if (Object.keys(this.doc.kinds).some((k) => placeSlug(pluralWords(this.doc, k)) === placeSlug(word)))
|
|
794
|
+
return true;
|
|
795
|
+
return (this.doc.lenses ?? []).some((lens) => isObject(lens) && typeof lens["title"] === "string" && namesLens(word, lens["title"]));
|
|
796
|
+
}
|
|
797
|
+
setComputed(i, e) {
|
|
798
|
+
const spec = this.kind(i, e.kind);
|
|
799
|
+
if (!spec)
|
|
800
|
+
return;
|
|
801
|
+
const name = e.name;
|
|
802
|
+
const plural = cap(pluralWords(this.doc, e.kind));
|
|
803
|
+
const existing = spec.computed?.[name];
|
|
804
|
+
if (e.expr === null) {
|
|
805
|
+
if (existing === undefined)
|
|
806
|
+
return this.fail(i, "name", `${e.kind} works out nothing called "${name}"${spec.computed ? `; it works out ${Object.keys(spec.computed).join(", ")}` : ""}`);
|
|
807
|
+
const gone = this.dropComputed(e.kind, name).slice(1);
|
|
808
|
+
this.said.push(`${plural} no longer work out "${name}"${gone.length ? `; ${listOf(gone)} ${gone.length === 1 ? "goes" : "go"} with it` : ""}.`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (existing === undefined) {
|
|
812
|
+
if (e.expr === undefined)
|
|
813
|
+
return this.fail(i, "expr", `to work out "${name}", give its expression, like "list * units"`);
|
|
814
|
+
if (spec.fields[name] || spec.edges?.[name])
|
|
815
|
+
return this.fail(i, "name", `${e.kind} already has a ${spec.fields[name] ? "field" : "relation"} called "${name}"`);
|
|
816
|
+
if (KEYWORDS.has(name))
|
|
817
|
+
return this.fail(i, "name", `"${name}" is a word the rule language keeps; pick another name`);
|
|
818
|
+
if (Object.keys(spec.computed ?? {}).length >= 20)
|
|
819
|
+
return this.fail(i, "name", "a kind works out at most 20 computed fields");
|
|
820
|
+
}
|
|
821
|
+
const was = existing === undefined ? {} : typeof existing === "string" ? { expr: existing } : { ...existing };
|
|
822
|
+
const next = { ...was };
|
|
823
|
+
if (e.expr !== undefined)
|
|
824
|
+
next.expr = e.expr;
|
|
825
|
+
for (const key of ["label", "description"]) {
|
|
826
|
+
if (e[key] === null)
|
|
827
|
+
delete next[key];
|
|
828
|
+
else if (e[key] !== undefined)
|
|
829
|
+
next[key] = e[key];
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
parseExpr(next.expr);
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
if (err instanceof ExprSyntaxError)
|
|
836
|
+
return this.fail(i, "expr", `${err.sentence} (at character ${err.at + 1})`);
|
|
837
|
+
throw err;
|
|
838
|
+
}
|
|
839
|
+
// Kept short where it was said short: a bare expression is the same field as { expr }.
|
|
840
|
+
(spec.computed ??= {})[name] = next.label === undefined && next.description === undefined ? next.expr : next;
|
|
841
|
+
const findings = validateComputed(new Map(Object.entries(this.doc.kinds).map(([kind, k]) => [kind, { fields: new Set(Object.keys(k.fields)), edges: new Set(Object.keys(k.edges ?? {})), computed: computedOf(k) }])), (kind, n) => `kinds.${kind}.computed.${n}`).filter((f) => f.severity === "error" && f.path === `kinds.${e.kind}.computed.${name}`);
|
|
842
|
+
for (const f of findings)
|
|
843
|
+
this.fail(i, "expr", f.message, f.fix);
|
|
844
|
+
if (findings.length > 0)
|
|
845
|
+
return;
|
|
846
|
+
if (existing === undefined)
|
|
847
|
+
this.said.push(`${plural} work out "${name}": ${next.expr}${next.label ? `, shown as "${next.label}"` : ""}.`);
|
|
848
|
+
else if (was.expr !== next.expr)
|
|
849
|
+
this.said.push(`How ${pluralWords(this.doc, e.kind)} work out "${name}" changes: ${next.expr}.`);
|
|
850
|
+
else
|
|
851
|
+
this.said.push(`${plural}' "${name}" is ${next.label ? `shown as "${next.label}"` : "shown by its name"}${next.description ? `, described as "${next.description}"` : ""}.`);
|
|
537
852
|
}
|
|
538
853
|
// ── kinds ──
|
|
539
854
|
addKind(i, e) {
|
|
@@ -599,10 +914,17 @@ class Editor {
|
|
|
599
914
|
spec.noun = e.noun;
|
|
600
915
|
else if (spec.noun === words(from) || spec.noun === from)
|
|
601
916
|
spec.noun = words(to);
|
|
917
|
+
const pluralWas = spec.plural ?? `${words(from)}s`;
|
|
602
918
|
if (e.plural)
|
|
603
919
|
spec.plural = e.plural;
|
|
604
920
|
else if (spec.plural && (spec.plural === `${words(from)}s` || spec.plural === `${from}s`))
|
|
605
921
|
spec.plural = `${words(to)}s`;
|
|
922
|
+
// Where the app opens, when it named the kind by its plural, follows the plural (FR-80).
|
|
923
|
+
const pluralNow = spec.plural ?? `${words(to)}s`;
|
|
924
|
+
if (typeof this.doc.pages?.first === "string" && placeSlug(this.doc.pages.first) === placeSlug(pluralWas) && placeSlug(pluralWas) !== placeSlug(pluralNow)) {
|
|
925
|
+
this.doc.pages.first = pluralNow;
|
|
926
|
+
touched.push("where the app opens");
|
|
927
|
+
}
|
|
606
928
|
// Acts named for the kind follow it: add-vendor → add-supplier.
|
|
607
929
|
for (const name of Object.keys(this.doc.acts ?? {})) {
|
|
608
930
|
const act = this.doc.acts[name];
|
|
@@ -635,6 +957,7 @@ class Editor {
|
|
|
635
957
|
if (!gone.includes(what))
|
|
636
958
|
gone.push(what);
|
|
637
959
|
}
|
|
960
|
+
const removedPlural = this.doc.kinds[kind].plural ?? `${words(kind)}s`;
|
|
638
961
|
delete this.doc.kinds[kind];
|
|
639
962
|
this.kindOrigin.delete(kind);
|
|
640
963
|
// Relations that only led to it go with it.
|
|
@@ -666,7 +989,7 @@ class Editor {
|
|
|
666
989
|
gone.push(`rule ${name}`);
|
|
667
990
|
}
|
|
668
991
|
}
|
|
669
|
-
if (this.doc.views?.[kind]) {
|
|
992
|
+
if (this.doc.views?.[kind] && !Array.isArray(this.doc.views[kind])) {
|
|
670
993
|
delete this.doc.views[kind];
|
|
671
994
|
if (Object.keys(this.doc.views).length === 0)
|
|
672
995
|
delete this.doc.views;
|
|
@@ -679,9 +1002,35 @@ class Editor {
|
|
|
679
1002
|
if (this.doc.policy.sees.length === 0)
|
|
680
1003
|
delete this.doc.policy.sees;
|
|
681
1004
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1005
|
+
/*
|
|
1006
|
+
* What sweeps the kind (`all('offer')`) goes as what names a removed
|
|
1007
|
+
* field does: computed fields that work it out, blocks on the front page,
|
|
1008
|
+
* in other kinds' views and in blocks lenses. A lens that stood on the
|
|
1009
|
+
* kind, or drew by it, goes, and `pages.first` with it when it named it.
|
|
1010
|
+
*/
|
|
1011
|
+
const swept = { t: "kind", from: kind, to: "\u0000" };
|
|
1012
|
+
for (const [other, spec] of Object.entries(this.doc.kinds)) {
|
|
1013
|
+
for (const [name, c] of Object.entries(spec.computed ?? {})) {
|
|
1014
|
+
if (exprMentions(this.doc, typeof c === "string" ? c : c.expr, new Set([other]), swept))
|
|
1015
|
+
gone.push(...this.dropComputed(other, name));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
gone.push(...this.pruneViews(swept), ...this.pruneLenses(swept));
|
|
1019
|
+
// The arrangement names kinds (FR-80): a kind that is gone is no longer ordered or hidden, nor where the app opens.
|
|
1020
|
+
if (this.doc.pages) {
|
|
1021
|
+
for (const part of ["order", "hide"]) {
|
|
1022
|
+
const named = this.doc.pages[part];
|
|
1023
|
+
if (Array.isArray(named))
|
|
1024
|
+
this.doc.pages[part] = named.filter((k) => k !== kind);
|
|
1025
|
+
}
|
|
1026
|
+
const first = this.doc.pages.first;
|
|
1027
|
+
if (typeof first === "string" && (first === kind || placeSlug(first) === placeSlug(removedPlural))) {
|
|
1028
|
+
delete this.doc.pages.first;
|
|
1029
|
+
gone.push("where the app opens (it opens at its home again)");
|
|
1030
|
+
}
|
|
1031
|
+
if (Object.keys(this.doc.pages).length === 0)
|
|
1032
|
+
delete this.doc.pages;
|
|
1033
|
+
}
|
|
685
1034
|
for (const f of [...this.fills])
|
|
686
1035
|
if (f.kind === kind)
|
|
687
1036
|
this.fills.splice(this.fills.indexOf(f), 1);
|
|
@@ -766,8 +1115,7 @@ class Editor {
|
|
|
766
1115
|
x.from = to;
|
|
767
1116
|
}
|
|
768
1117
|
const prose = proseSwap(field, to);
|
|
769
|
-
|
|
770
|
-
f.label = prose(f.label);
|
|
1118
|
+
// A label is what the field is called on screen because its name does not say it: a rename leaves it as written ("List price, per unit" is not "Price price, per unit").
|
|
771
1119
|
if (f.description)
|
|
772
1120
|
f.description = prose(f.description);
|
|
773
1121
|
this.said.push(`${kind}'s ${field} is renamed to ${to}; its values are kept${touched.length ? `, and ${listOf(touched)} now say${touched.length === 1 ? "s" : ""} ${to}` : ""}.`);
|
|
@@ -842,6 +1190,8 @@ class Editor {
|
|
|
842
1190
|
if (Object.keys(spec.fields).length === 1)
|
|
843
1191
|
return this.fail(i, "field", `${kind} keeps at least one field; remove the kind instead`);
|
|
844
1192
|
const r = { t: "field", kind, from: field, to: "\u0000" };
|
|
1193
|
+
if (this.lensesDrawnBy(i, r))
|
|
1194
|
+
return;
|
|
845
1195
|
const gone = this.dropMentions(r);
|
|
846
1196
|
delete spec.fields[field];
|
|
847
1197
|
this.fieldOrigin.delete(`${kind}.${field}`);
|
|
@@ -924,6 +1274,9 @@ class Editor {
|
|
|
924
1274
|
return;
|
|
925
1275
|
if (!spec.edges?.[relation])
|
|
926
1276
|
return this.fail(i, "relation", `${kind} has no relation "${relation}"`);
|
|
1277
|
+
const others = [...sources(this.doc, relation)].filter((k) => k !== kind);
|
|
1278
|
+
if (others.length === 0 && this.lensesDrawnBy(i, { t: "edge", from: relation, to: "\u0000" }))
|
|
1279
|
+
return;
|
|
927
1280
|
const gone = [];
|
|
928
1281
|
this.dropRelation(kind, relation, gone);
|
|
929
1282
|
this.said.push(`The "${relation}" relation from ${kind} is removed, with every link of that kind${gone.length ? `; ${listOf(gone)} ${gone.length === 1 ? "goes" : "go"} with it` : ""}.`);
|
|
@@ -1038,6 +1391,15 @@ class Editor {
|
|
|
1038
1391
|
for (const e of Object.values(spec.edges ?? {}))
|
|
1039
1392
|
if (Array.isArray(e.to) && e.to.includes(r.from))
|
|
1040
1393
|
e.to = e.to.map((t) => (t === r.from ? r.to : t));
|
|
1394
|
+
// What it works out (FR-83): each expression read from this kind's records, renamed in the tree.
|
|
1395
|
+
for (const [name, c] of Object.entries(spec.computed ?? {})) {
|
|
1396
|
+
const was = typeof c === "string" ? c : c.expr;
|
|
1397
|
+
const next = exprAt(was, here);
|
|
1398
|
+
if (next === was)
|
|
1399
|
+
continue;
|
|
1400
|
+
spec.computed[name] = typeof c === "string" ? next : { ...c, expr: next };
|
|
1401
|
+
touched.push(`${kind}'s ${name}`);
|
|
1402
|
+
}
|
|
1041
1403
|
}
|
|
1042
1404
|
// Acts: what they act on and make, the fields they write, guards, refusals, computed values.
|
|
1043
1405
|
const renamedArgs = new Map();
|
|
@@ -1082,7 +1444,7 @@ class Editor {
|
|
|
1082
1444
|
else
|
|
1083
1445
|
act.effects[t.index].set = renamed;
|
|
1084
1446
|
}
|
|
1085
|
-
if (act.writes && hasKind(on, r.kind) && act.writes.includes(r.from)) {
|
|
1447
|
+
if (act.writes && (hasKind(on, r.kind) || act.creates === r.kind) && act.writes.includes(r.from)) {
|
|
1086
1448
|
act.writes = act.writes.map((w) => (w === r.from ? r.to : w));
|
|
1087
1449
|
args.set(r.from, r.to);
|
|
1088
1450
|
}
|
|
@@ -1184,49 +1546,49 @@ class Editor {
|
|
|
1184
1546
|
if (JSON.stringify(doc.policy) !== before)
|
|
1185
1547
|
touched.push("the policy");
|
|
1186
1548
|
}
|
|
1187
|
-
// Views.
|
|
1549
|
+
// Views: each kind's slots, and the home's blocks, which are about no one record (FR-81).
|
|
1188
1550
|
if (doc.views) {
|
|
1189
|
-
if (r.t === "kind" && doc.views[r.from]) {
|
|
1551
|
+
if (r.t === "kind" && doc.views[r.from] && !Array.isArray(doc.views[r.from])) {
|
|
1190
1552
|
const views = {};
|
|
1191
1553
|
for (const [k, v] of Object.entries(doc.views))
|
|
1192
1554
|
views[k === r.from ? r.to : k] = v;
|
|
1193
1555
|
doc.views = views;
|
|
1194
1556
|
}
|
|
1195
1557
|
for (const [kind, slots] of Object.entries(doc.views)) {
|
|
1196
|
-
|
|
1558
|
+
if (kind === "home" && Array.isArray(slots)) {
|
|
1559
|
+
const before = JSON.stringify(slots);
|
|
1560
|
+
doc.views.home = mapBlocks(slots, (b) => rewriteBlock(doc, b, NONE, r, null));
|
|
1561
|
+
if (JSON.stringify(doc.views.home) !== before)
|
|
1562
|
+
touched.push("the front page");
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
const was = r.t === "kind" && kind === r.to ? r.from : kind;
|
|
1566
|
+
const ctx = new Set([was]);
|
|
1197
1567
|
for (const slot of VIEW_SLOTS) {
|
|
1198
1568
|
if (!Array.isArray(slots[slot]))
|
|
1199
1569
|
continue;
|
|
1200
1570
|
const before = JSON.stringify(slots[slot]);
|
|
1201
|
-
slots[slot] = mapBlocks(slots[slot], (b) => rewriteBlock(doc, b, ctx, r,
|
|
1571
|
+
slots[slot] = mapBlocks(slots[slot], (b) => rewriteBlock(doc, b, ctx, r, was));
|
|
1202
1572
|
if (JSON.stringify(slots[slot]) !== before)
|
|
1203
1573
|
touched.push(`the ${kind} ${slot}`);
|
|
1204
1574
|
}
|
|
1205
1575
|
}
|
|
1206
1576
|
}
|
|
1207
|
-
// Lenses
|
|
1577
|
+
// Lenses: the kinds they stand on, what they bind, what they are told, and what a blocks lens says.
|
|
1208
1578
|
for (const lens of doc.lenses ?? []) {
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
if (r.t === "kind" && r.from in bindings) {
|
|
1214
|
-
bindings[r.to] = bindings[r.from];
|
|
1215
|
-
delete bindings[r.from];
|
|
1216
|
-
}
|
|
1217
|
-
if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
1218
|
-
const b = bindings[r.kind];
|
|
1219
|
-
for (const [k, v] of Object.entries(b))
|
|
1220
|
-
if (v === r.from)
|
|
1221
|
-
b[k] = r.to;
|
|
1222
|
-
}
|
|
1223
|
-
if (JSON.stringify(bindings) !== before)
|
|
1224
|
-
touched.push(`the lens ${String(lens["name"] ?? "")}`.trim());
|
|
1579
|
+
const before = JSON.stringify(lens);
|
|
1580
|
+
rewriteLens(doc, lens, r);
|
|
1581
|
+
if (JSON.stringify(lens) !== before)
|
|
1582
|
+
touched.push(lens["title"] ? `the lens "${String(lens["title"])}"` : `the ${String(lens["name"] ?? "")} lens`);
|
|
1225
1583
|
}
|
|
1226
|
-
// Pages name kinds.
|
|
1584
|
+
// Pages name kinds: in the order, the kinds the home leaves off, and where the app opens (FR-80).
|
|
1227
1585
|
if (r.t === "kind" && doc.pages) {
|
|
1228
1586
|
const before = JSON.stringify(doc.pages);
|
|
1229
|
-
|
|
1587
|
+
for (const part of ["order", "hide"])
|
|
1588
|
+
if (Array.isArray(doc.pages[part]))
|
|
1589
|
+
doc.pages[part] = doc.pages[part].map((k) => (k === r.from ? r.to : k));
|
|
1590
|
+
if (doc.pages.first === r.from)
|
|
1591
|
+
doc.pages.first = r.to;
|
|
1230
1592
|
if (JSON.stringify(doc.pages) !== before)
|
|
1231
1593
|
touched.push("the pages");
|
|
1232
1594
|
}
|
|
@@ -1237,7 +1599,7 @@ class Editor {
|
|
|
1237
1599
|
* it, rules that judge it, view blocks that show it; templates that show it fall
|
|
1238
1600
|
* back to Graview's own. Returns what went, as words.
|
|
1239
1601
|
*/
|
|
1240
|
-
dropMentions(r) {
|
|
1602
|
+
dropMentions(r, worked = false) {
|
|
1241
1603
|
const doc = this.doc;
|
|
1242
1604
|
const gone = [];
|
|
1243
1605
|
for (const [kind, spec] of Object.entries(doc.kinds)) {
|
|
@@ -1248,12 +1610,21 @@ class Editor {
|
|
|
1248
1610
|
}
|
|
1249
1611
|
}
|
|
1250
1612
|
}
|
|
1613
|
+
// What a kind works out from it cannot be worked out, and goes — with what reads that in turn (FR-83).
|
|
1614
|
+
for (const [kind, spec] of Object.entries(doc.kinds)) {
|
|
1615
|
+
for (const [name, c] of Object.entries(spec.computed ?? {})) {
|
|
1616
|
+
if (r.t === "field" && r.kind === kind && r.from === name)
|
|
1617
|
+
continue;
|
|
1618
|
+
if (exprMentions(doc, typeof c === "string" ? c : c.expr, new Set([kind]), r))
|
|
1619
|
+
gone.push(...this.dropComputed(kind, name));
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1251
1622
|
for (const [name, act] of Object.entries(doc.acts ?? {})) {
|
|
1252
1623
|
const on = subjectKinds(act);
|
|
1253
1624
|
let uses = exprMentions(doc, act.allowedWhen, on, r) || templateMentions(doc, act.refusal, on, r);
|
|
1254
1625
|
if (r.t === "edge")
|
|
1255
1626
|
uses ||= act.connects === r.from || act.severs === r.from || (act.effects ?? []).some((e) => e.connect === r.from || e.sever === r.from);
|
|
1256
|
-
if (r.t === "field") {
|
|
1627
|
+
if (r.t === "field" && !worked) {
|
|
1257
1628
|
// An act that sets other things too loses only this field; one that did nothing else goes.
|
|
1258
1629
|
for (const t of setTargets(act)) {
|
|
1259
1630
|
if (!hasKind(t.kinds, r.kind) || t.kinds === "*")
|
|
@@ -1264,7 +1635,7 @@ class Editor {
|
|
|
1264
1635
|
if (isObject(v) && typeof v["expr"] === "string" && exprMentions(doc, v["expr"], t.kinds, r))
|
|
1265
1636
|
uses = true;
|
|
1266
1637
|
}
|
|
1267
|
-
if (act.writes && hasKind(on, r.kind) && act.writes.includes(r.from)) {
|
|
1638
|
+
if (act.writes && (hasKind(on, r.kind) || act.creates === r.kind) && act.writes.includes(r.from)) {
|
|
1268
1639
|
act.writes = act.writes.filter((w) => w !== r.from);
|
|
1269
1640
|
if (act.writes.length === 0)
|
|
1270
1641
|
delete act.writes;
|
|
@@ -1297,35 +1668,193 @@ class Editor {
|
|
|
1297
1668
|
}
|
|
1298
1669
|
if (doc.rules && Object.keys(doc.rules).length === 0)
|
|
1299
1670
|
delete doc.rules;
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1671
|
+
gone.push(...this.pruneViews(r));
|
|
1672
|
+
gone.push(...this.pruneLenses(r));
|
|
1673
|
+
return gone;
|
|
1674
|
+
}
|
|
1675
|
+
/** Computed fields being dropped, so a cycle among them ends. */
|
|
1676
|
+
dropping = new Set();
|
|
1677
|
+
/** A computed field goes, and everything that reads it: views, glances, templates, rules, and computed fields of its own. */
|
|
1678
|
+
dropComputed(kind, name) {
|
|
1679
|
+
const key = `${kind}.${name}`;
|
|
1680
|
+
if (this.dropping.has(key) || this.doc.kinds[kind]?.computed?.[name] === undefined)
|
|
1681
|
+
return [];
|
|
1682
|
+
this.dropping.add(key);
|
|
1683
|
+
const gone = [`${kind}'s ${name}`, ...this.dropMentions({ t: "field", kind, from: name, to: "\u0000" }, true)];
|
|
1684
|
+
const spec = this.doc.kinds[kind];
|
|
1685
|
+
if (spec) {
|
|
1686
|
+
delete spec.computed[name];
|
|
1687
|
+
if (Object.keys(spec.computed).length === 0)
|
|
1688
|
+
delete spec.computed;
|
|
1689
|
+
if (spec.glance?.includes(name)) {
|
|
1690
|
+
spec.glance = spec.glance.filter((field) => field !== name);
|
|
1691
|
+
if (spec.glance.length === 0)
|
|
1692
|
+
delete spec.glance;
|
|
1314
1693
|
}
|
|
1315
|
-
if (Object.keys(doc.views).length === 0)
|
|
1316
|
-
delete doc.views;
|
|
1317
1694
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1695
|
+
this.dropping.delete(key);
|
|
1696
|
+
return gone;
|
|
1697
|
+
}
|
|
1698
|
+
/** View blocks that show what is going: every kind's slots, the home, and each blocks lens's blocks are pruned (see pruneBlock). */
|
|
1699
|
+
pruneViews(r) {
|
|
1700
|
+
const doc = this.doc;
|
|
1701
|
+
const gone = [];
|
|
1702
|
+
if (!doc.views)
|
|
1703
|
+
return gone;
|
|
1704
|
+
for (const [kind, slots] of Object.entries(doc.views)) {
|
|
1705
|
+
if (kind === "home" && Array.isArray(slots)) {
|
|
1706
|
+
const before = JSON.stringify(slots);
|
|
1707
|
+
doc.views.home = mapBlocks(slots, (b) => pruneBlock(doc, b, NONE, r, null));
|
|
1708
|
+
if (JSON.stringify(doc.views.home) !== before)
|
|
1709
|
+
gone.push("part of the front page");
|
|
1710
|
+
if (doc.views.home.length === 0)
|
|
1711
|
+
delete doc.views.home;
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
for (const slot of VIEW_SLOTS) {
|
|
1715
|
+
if (!Array.isArray(slots[slot]))
|
|
1716
|
+
continue;
|
|
1717
|
+
const before = JSON.stringify(slots[slot]);
|
|
1718
|
+
slots[slot] = mapBlocks(slots[slot], (b) => pruneBlock(doc, b, new Set([kind]), r, kind));
|
|
1719
|
+
if (JSON.stringify(slots[slot]) !== before)
|
|
1720
|
+
gone.push(`part of the ${kind} ${slot}`);
|
|
1721
|
+
if (slots[slot].length === 0)
|
|
1722
|
+
delete slots[slot];
|
|
1325
1723
|
}
|
|
1724
|
+
if (Object.keys(slots).length === 0)
|
|
1725
|
+
delete doc.views[kind];
|
|
1326
1726
|
}
|
|
1727
|
+
if (Object.keys(doc.views).length === 0)
|
|
1728
|
+
delete doc.views;
|
|
1327
1729
|
return gone;
|
|
1328
1730
|
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Lenses, after a removal. A binding to what is going is dropped; a lens
|
|
1733
|
+
* that can no longer draw — a role it draws by is gone, it stood on a
|
|
1734
|
+
* removed kind, its blocks are all gone — goes, and so does `pages.first`
|
|
1735
|
+
* when it named it. (`remove-field` and `remove-relation` are refused
|
|
1736
|
+
* before they get here when a lens draws by the thing: see lensesDrawnBy.)
|
|
1737
|
+
*/
|
|
1738
|
+
pruneLenses(r) {
|
|
1739
|
+
const doc = this.doc;
|
|
1740
|
+
const gone = [];
|
|
1741
|
+
const lenses = doc.lenses ?? [];
|
|
1742
|
+
const leaving = [];
|
|
1743
|
+
lenses.forEach((lens, index) => {
|
|
1744
|
+
if (!isObject(lens))
|
|
1745
|
+
return;
|
|
1746
|
+
let draws = true;
|
|
1747
|
+
const bindings = lens["bindings"];
|
|
1748
|
+
const required = requiredRoles(lens);
|
|
1749
|
+
if (isObject(bindings)) {
|
|
1750
|
+
if (lensBinds(lens) === "entities") {
|
|
1751
|
+
for (const [role, b] of Object.entries(bindings)) {
|
|
1752
|
+
if (!isObject(b))
|
|
1753
|
+
continue;
|
|
1754
|
+
const hit = (r.t === "kind" && b["kind"] === r.from) ||
|
|
1755
|
+
(r.t === "edge" && (b["edge"] === r.from || (Array.isArray(b["path"]) && b["path"].includes(r.from)))) ||
|
|
1756
|
+
(r.t === "field" && b["field"] === r.from && roleKind(bindings, role) === r.kind);
|
|
1757
|
+
if (!hit)
|
|
1758
|
+
continue;
|
|
1759
|
+
if (required.includes(role) || r.t === "kind")
|
|
1760
|
+
draws = false;
|
|
1761
|
+
else
|
|
1762
|
+
delete bindings[role];
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
else {
|
|
1766
|
+
if (r.t === "kind" && isObject(bindings[r.from])) {
|
|
1767
|
+
delete bindings[r.from];
|
|
1768
|
+
if (!Object.values(bindings).some(isObject))
|
|
1769
|
+
draws = false;
|
|
1770
|
+
}
|
|
1771
|
+
if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
1772
|
+
const b = bindings[r.kind];
|
|
1773
|
+
for (const [role, v] of Object.entries(b))
|
|
1774
|
+
if (v === r.from) {
|
|
1775
|
+
if (required.includes(role))
|
|
1776
|
+
draws = false;
|
|
1777
|
+
else
|
|
1778
|
+
delete b[role];
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
if (r.t === "kind" && lens["on"] === r.from)
|
|
1784
|
+
draws = false;
|
|
1785
|
+
const options = lens["options"];
|
|
1786
|
+
if (isObject(options)) {
|
|
1787
|
+
if (Array.isArray(options["blocks"])) {
|
|
1788
|
+
const blocks = mapBlocks(options["blocks"], (b) => pruneBlock(doc, b, NONE, r, null));
|
|
1789
|
+
if (blocks.length === 0)
|
|
1790
|
+
draws = false;
|
|
1791
|
+
options["blocks"] = blocks;
|
|
1792
|
+
}
|
|
1793
|
+
for (const [key, value] of Object.entries(options))
|
|
1794
|
+
if (key !== "blocks" && value === r.from && (r.t !== "field" || lensStandsOn(lens, r.kind)))
|
|
1795
|
+
delete options[key];
|
|
1796
|
+
}
|
|
1797
|
+
if (!draws)
|
|
1798
|
+
leaving.push(index);
|
|
1799
|
+
});
|
|
1800
|
+
for (const index of leaving.reverse())
|
|
1801
|
+
gone.push(this.removeLensAt(index));
|
|
1802
|
+
return gone.reverse();
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* THE LENSES THAT DRAW BY A FIELD OR A RELATION — a role the lens cannot
|
|
1806
|
+
* draw without (a timeline's start, a coverage's link). Removing one is
|
|
1807
|
+
* refused, with a finding naming each lens and role: whether to drop the
|
|
1808
|
+
* picture or bind it to something else is the person's to say.
|
|
1809
|
+
*/
|
|
1810
|
+
lensesDrawnBy(i, r) {
|
|
1811
|
+
let refused = false;
|
|
1812
|
+
(this.doc.lenses ?? []).forEach((lens, index) => {
|
|
1813
|
+
if (!isObject(lens) || !isObject(lens["bindings"]))
|
|
1814
|
+
return;
|
|
1815
|
+
const bindings = lens["bindings"];
|
|
1816
|
+
const required = requiredRoles(lens);
|
|
1817
|
+
const title = String(lens["title"] ?? lens["name"] ?? index);
|
|
1818
|
+
const roles = [];
|
|
1819
|
+
if (lensBinds(lens) === "entities") {
|
|
1820
|
+
for (const [role, b] of Object.entries(bindings)) {
|
|
1821
|
+
if (!isObject(b) || !required.includes(role))
|
|
1822
|
+
continue;
|
|
1823
|
+
if (r.t === "field" && b["field"] === r.from && roleKind(bindings, role) === r.kind)
|
|
1824
|
+
roles.push(role);
|
|
1825
|
+
if (r.t === "edge" && (b["edge"] === r.from || (Array.isArray(b["path"]) && b["path"].includes(r.from))))
|
|
1826
|
+
roles.push(role);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
else if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
1830
|
+
for (const [role, v] of Object.entries(bindings[r.kind]))
|
|
1831
|
+
if (v === r.from && required.includes(role))
|
|
1832
|
+
roles.push(role);
|
|
1833
|
+
}
|
|
1834
|
+
for (const role of roles) {
|
|
1835
|
+
refused = true;
|
|
1836
|
+
const what = r.t === "field" ? `${r.kind}'s ${r.from}` : `the "${r.from}" relation`;
|
|
1837
|
+
this.fail(i, "", `the lens "${title}" (lenses.${index}) draws by its ${role}, which is ${what}, and cannot draw without it`, `remove the lens first ({"op": "remove-lens", "title": "${title}"}), or bind ${role} to something else`);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
return refused;
|
|
1841
|
+
}
|
|
1842
|
+
/** Remove the lens at an index; `pages.first` that named it is dropped, so the app opens at its home. */
|
|
1843
|
+
removeLensAt(index) {
|
|
1844
|
+
const lens = this.doc.lenses[index];
|
|
1845
|
+
const title = lens?.["title"] ? String(lens["title"]) : undefined;
|
|
1846
|
+
this.doc.lenses.splice(index, 1);
|
|
1847
|
+
if (this.doc.lenses.length === 0)
|
|
1848
|
+
delete this.doc.lenses;
|
|
1849
|
+
let said = title ? `the lens "${title}"` : `the ${String(lens?.["name"] ?? "")} lens`;
|
|
1850
|
+
if (title && this.doc.pages?.first !== undefined && namesLens(this.doc.pages.first, title)) {
|
|
1851
|
+
delete this.doc.pages.first;
|
|
1852
|
+
if (Object.keys(this.doc.pages).length === 0)
|
|
1853
|
+
delete this.doc.pages;
|
|
1854
|
+
said += " (the app opens at its home again)";
|
|
1855
|
+
}
|
|
1856
|
+
return said;
|
|
1857
|
+
}
|
|
1329
1858
|
}
|
|
1330
1859
|
/** A `$name` reference or bare argument name in a guard, renamed (for an argument that followed its field). */
|
|
1331
1860
|
function rewriteBinding(source, from, to) {
|
|
@@ -1348,15 +1877,6 @@ function swapToken(name, from, to) {
|
|
|
1348
1877
|
}
|
|
1349
1878
|
return name;
|
|
1350
1879
|
}
|
|
1351
|
-
function deepSwap(v, from, to) {
|
|
1352
|
-
if (v === from)
|
|
1353
|
-
return to;
|
|
1354
|
-
if (Array.isArray(v))
|
|
1355
|
-
return v.map((x) => deepSwap(x, from, to));
|
|
1356
|
-
if (isObject(v))
|
|
1357
|
-
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k === from ? to : k, deepSwap(x, from, to)]));
|
|
1358
|
-
return v;
|
|
1359
|
-
}
|
|
1360
1880
|
const listOf = (xs) => (xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
1361
1881
|
/** Map every block of a view, nested ones included; a block mapped to null is dropped. */
|
|
1362
1882
|
function mapBlocks(blocks, f) {
|
|
@@ -1377,11 +1897,183 @@ function mapBlocks(blocks, f) {
|
|
|
1377
1897
|
}
|
|
1378
1898
|
return out;
|
|
1379
1899
|
}
|
|
1900
|
+
// ── words for what changed ───────────────────────────────────────────────────
|
|
1901
|
+
const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
1902
|
+
const withA = (noun) => `${/^[aeiou]/i.test(noun) ? "an" : "a"} ${noun}`;
|
|
1903
|
+
const nounOf = (doc, kind) => doc.kinds[kind]?.noun ?? words(kind);
|
|
1904
|
+
/** A kind's plural as a person reads it: "packages", "people and teams". */
|
|
1905
|
+
function pluralWords(doc, kind) {
|
|
1906
|
+
const plural = doc.kinds[kind]?.plural;
|
|
1907
|
+
return plural ? plural.charAt(0).toLowerCase() + plural.slice(1) : `${words(kind)}s`;
|
|
1908
|
+
}
|
|
1909
|
+
/** The kinds a lens binds, for saying where it stands. */
|
|
1910
|
+
function kindsBound(lens) {
|
|
1911
|
+
const bindings = lens["bindings"];
|
|
1912
|
+
if (!isObject(bindings))
|
|
1913
|
+
return [];
|
|
1914
|
+
if (lensBinds(lens) === "entities")
|
|
1915
|
+
return Object.values(bindings).flatMap((b) => (isObject(b) && typeof b["kind"] === "string" ? [b["kind"]] : []));
|
|
1916
|
+
return Object.keys(bindings).filter((k) => isObject(bindings[k]));
|
|
1917
|
+
}
|
|
1918
|
+
/** What a list of blocks is, in words: "a headline, a figure and a list of records". */
|
|
1919
|
+
function blockWords(blocks) {
|
|
1920
|
+
const said = blocks.map((b) => {
|
|
1921
|
+
if (!isObject(b))
|
|
1922
|
+
return "a block";
|
|
1923
|
+
if ("list" in b)
|
|
1924
|
+
return "a list of records";
|
|
1925
|
+
if ("headline" in b)
|
|
1926
|
+
return "a headline";
|
|
1927
|
+
if ("figure" in b)
|
|
1928
|
+
return b["figure"] === true ? "its picture" : "a figure";
|
|
1929
|
+
if ("title" in b)
|
|
1930
|
+
return "a title";
|
|
1931
|
+
if ("text" in b)
|
|
1932
|
+
return "words";
|
|
1933
|
+
if ("badge" in b)
|
|
1934
|
+
return "a badge";
|
|
1935
|
+
if ("field" in b)
|
|
1936
|
+
return `its ${String(b["field"])}`;
|
|
1937
|
+
if ("progress" in b)
|
|
1938
|
+
return "a progress bar";
|
|
1939
|
+
if ("group" in b)
|
|
1940
|
+
return "a group";
|
|
1941
|
+
if ("when" in b)
|
|
1942
|
+
return "a condition";
|
|
1943
|
+
if ("divider" in b)
|
|
1944
|
+
return "a divider";
|
|
1945
|
+
return "a block";
|
|
1946
|
+
});
|
|
1947
|
+
return listOf(said);
|
|
1948
|
+
}
|
|
1949
|
+
// ── lenses as a change sees them ─────────────────────────────────────────────
|
|
1950
|
+
/** How a lens binds: a kind's fields to its roles, or kinds, relations and fields to its roles, or nothing. */
|
|
1951
|
+
function lensBinds(lens) {
|
|
1952
|
+
if (lens["binds"] === "fields" || lens["binds"] === "entities")
|
|
1953
|
+
return lens["binds"];
|
|
1954
|
+
const name = String(lens["name"] ?? "");
|
|
1955
|
+
return isShippedLens(name) ? SHIPPED_LENSES[name].binds : "fields";
|
|
1956
|
+
}
|
|
1957
|
+
/** The roles a lens cannot draw without: its own, or the shipped lens's. */
|
|
1958
|
+
function requiredRoles(lens) {
|
|
1959
|
+
if (Array.isArray(lens["requiredRoles"]))
|
|
1960
|
+
return lens["requiredRoles"];
|
|
1961
|
+
const name = String(lens["name"] ?? "");
|
|
1962
|
+
return isShippedLens(name) ? SHIPPED_LENSES[name].requiredRoles : [];
|
|
1963
|
+
}
|
|
1964
|
+
/** For a lens that binds kinds: the kind a role's field is read from — the kind its `on` role binds, else the first kind bound. */
|
|
1965
|
+
function roleKind(bindings, role) {
|
|
1966
|
+
const b = bindings[role];
|
|
1967
|
+
if (isObject(b) && typeof b["kind"] === "string")
|
|
1968
|
+
return b["kind"];
|
|
1969
|
+
const on = isObject(b) && typeof b["on"] === "string" ? bindings[b["on"]] : undefined;
|
|
1970
|
+
if (isObject(on) && typeof on["kind"] === "string")
|
|
1971
|
+
return on["kind"];
|
|
1972
|
+
const first = Object.values(bindings).find((x) => isObject(x) && typeof x["kind"] === "string");
|
|
1973
|
+
return first?.["kind"];
|
|
1974
|
+
}
|
|
1975
|
+
/** Whether a lens stands on a kind: its `on`, a kind it binds fields of, or a kind a role binds. */
|
|
1976
|
+
function lensStandsOn(lens, kind) {
|
|
1977
|
+
if (lens["on"] === kind)
|
|
1978
|
+
return true;
|
|
1979
|
+
const bindings = lens["bindings"];
|
|
1980
|
+
if (!isObject(bindings))
|
|
1981
|
+
return false;
|
|
1982
|
+
if (lensBinds(lens) === "entities")
|
|
1983
|
+
return Object.values(bindings).some((b) => isObject(b) && b["kind"] === kind);
|
|
1984
|
+
return isObject(bindings[kind]);
|
|
1985
|
+
}
|
|
1986
|
+
/** Whether `pages.first` names this lens: by its title, or by its address word. */
|
|
1987
|
+
const namesLens = (first, title) => typeof first === "string" && (first.trim() === title || placeSlug(first) === placeSlug(title));
|
|
1988
|
+
/**
|
|
1989
|
+
* A rename, wherever a lens says the name: the kind it stands `on`, the
|
|
1990
|
+
* kinds and fields its bindings name, the plain words its options give
|
|
1991
|
+
* (a coverage's `rowGroup`, a board's `fillFrom`), and every expression and
|
|
1992
|
+
* template of a blocks lens — through the parser, as everywhere else.
|
|
1993
|
+
*/
|
|
1994
|
+
function rewriteLens(doc, lens, r) {
|
|
1995
|
+
if (r.t === "kind" && lens["on"] === r.from)
|
|
1996
|
+
lens["on"] = r.to;
|
|
1997
|
+
const bindings = lens["bindings"];
|
|
1998
|
+
if (isObject(bindings)) {
|
|
1999
|
+
if (lensBinds(lens) === "entities") {
|
|
2000
|
+
// Which kind each role's field is read from, before the change moves anything.
|
|
2001
|
+
const kindOf = new Map(Object.keys(bindings).map((role) => [role, roleKind(bindings, role)]));
|
|
2002
|
+
for (const [role, b] of Object.entries(bindings)) {
|
|
2003
|
+
if (!isObject(b))
|
|
2004
|
+
continue;
|
|
2005
|
+
if (r.t === "kind" && b["kind"] === r.from)
|
|
2006
|
+
b["kind"] = r.to;
|
|
2007
|
+
if (r.t === "edge" && b["edge"] === r.from)
|
|
2008
|
+
b["edge"] = r.to;
|
|
2009
|
+
if (r.t === "edge" && Array.isArray(b["path"]))
|
|
2010
|
+
b["path"] = b["path"].map((step) => (step === r.from ? r.to : step));
|
|
2011
|
+
if (r.t === "field" && b["field"] === r.from && kindOf.get(role) === r.kind)
|
|
2012
|
+
b["field"] = r.to;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
else {
|
|
2016
|
+
if (r.t === "kind" && r.from in bindings)
|
|
2017
|
+
lens["bindings"] = Object.fromEntries(Object.entries(bindings).map(([k, v]) => [k === r.from ? r.to : k, v]));
|
|
2018
|
+
if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
2019
|
+
const b = bindings[r.kind];
|
|
2020
|
+
for (const [k, v] of Object.entries(b))
|
|
2021
|
+
if (v === r.from)
|
|
2022
|
+
b[k] = r.to;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const options = lens["options"];
|
|
2027
|
+
if (isObject(options)) {
|
|
2028
|
+
for (const [key, value] of Object.entries(options)) {
|
|
2029
|
+
if (key === "blocks" && Array.isArray(value))
|
|
2030
|
+
options["blocks"] = mapBlocks(value, (b) => rewriteBlock(doc, b, NONE, r, null));
|
|
2031
|
+
else if (value === r.from && (r.t !== "field" || lensStandsOn(lens, r.kind)))
|
|
2032
|
+
options[key] = r.to;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
/** The kinds a list block's records are, read from its source before the change: what its sort key and group are read from. */
|
|
2037
|
+
function listMembers(doc, source, ctx) {
|
|
2038
|
+
if (typeof source !== "string")
|
|
2039
|
+
return NONE;
|
|
2040
|
+
try {
|
|
2041
|
+
return kindsOf(doc, parseExpr(source), ctx);
|
|
2042
|
+
}
|
|
2043
|
+
catch (err) {
|
|
2044
|
+
if (err instanceof ExprSyntaxError)
|
|
2045
|
+
return NONE;
|
|
2046
|
+
throw err;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
/** A list's sort (`"net"`, or `{ by, direction }`) and group (`"type"`, or `{ by, headings }`): the key each names, read per member. */
|
|
2050
|
+
const keyOf = (v) => (typeof v === "string" ? v : isObject(v) && typeof v["by"] === "string" ? v["by"] : undefined);
|
|
2051
|
+
const withKey = (v, key) => (typeof v === "string" ? key : { ...v, by: key });
|
|
2052
|
+
/**
|
|
2053
|
+
* One block with a rename applied wherever it names: templates (a title, a
|
|
2054
|
+
* text, a badge, a headline), expressions (a figure, a tone, a condition, a
|
|
2055
|
+
* progress), a list's source, and its sort key and group — read from the
|
|
2056
|
+
* records the list lists, not the record the view is about — and a field
|
|
2057
|
+
* block's field. `kind` is the kind the blocks are about; null for the home
|
|
2058
|
+
* and a blocks lens.
|
|
2059
|
+
*/
|
|
1380
2060
|
function rewriteBlock(doc, b, ctx, r, kind) {
|
|
1381
2061
|
const next = { ...b };
|
|
1382
|
-
for (const key of ["title", "text", "badge"])
|
|
2062
|
+
for (const key of ["title", "text", "badge", "headline"])
|
|
1383
2063
|
if (typeof next[key] === "string")
|
|
1384
2064
|
next[key] = rewriteTemplate(doc, next[key], ctx, r);
|
|
2065
|
+
if (typeof next["figure"] === "string")
|
|
2066
|
+
next["figure"] = rewriteExpr(doc, next["figure"], ctx, r);
|
|
2067
|
+
if (typeof next["list"] === "string") {
|
|
2068
|
+
const members = listMembers(doc, next["list"], ctx);
|
|
2069
|
+
next["list"] = rewriteExpr(doc, next["list"], ctx, r);
|
|
2070
|
+
const sort = keyOf(next["sort"]);
|
|
2071
|
+
if (sort !== undefined)
|
|
2072
|
+
next["sort"] = withKey(next["sort"], rewriteExpr(doc, sort, members, r));
|
|
2073
|
+
const group = keyOf(next["group"]);
|
|
2074
|
+
if (group !== undefined)
|
|
2075
|
+
next["group"] = withKey(next["group"], renamer(doc, r)({ role: "field", kinds: members, name: group }));
|
|
2076
|
+
}
|
|
1385
2077
|
if (isObject(next["tone"]) && typeof next["tone"]["expr"] === "string")
|
|
1386
2078
|
next["tone"] = { expr: rewriteExpr(doc, next["tone"]["expr"], ctx, r) };
|
|
1387
2079
|
if (typeof next["when"] === "string")
|
|
@@ -1397,21 +2089,42 @@ function rewriteBlock(doc, b, ctx, r, kind) {
|
|
|
1397
2089
|
next["field"] = r.to;
|
|
1398
2090
|
return next;
|
|
1399
2091
|
}
|
|
1400
|
-
|
|
2092
|
+
/**
|
|
2093
|
+
* One block, with what a removal takes from it: the block goes when what it
|
|
2094
|
+
* shows cannot be shown (its template, its figure, its list's records, its
|
|
2095
|
+
* condition names the thing); a list keeps its records and loses only an
|
|
2096
|
+
* order or a grouping by a field that is gone.
|
|
2097
|
+
*/
|
|
2098
|
+
function pruneBlock(doc, b, ctx, r, kind) {
|
|
1401
2099
|
if (r.t === "field" && r.kind === kind && b["field"] === r.from)
|
|
1402
|
-
return
|
|
1403
|
-
for (const key of ["title", "text", "badge"])
|
|
2100
|
+
return null;
|
|
2101
|
+
for (const key of ["title", "text", "badge", "headline"])
|
|
1404
2102
|
if (typeof b[key] === "string" && templateMentions(doc, b[key], ctx, r))
|
|
1405
|
-
return
|
|
2103
|
+
return null;
|
|
2104
|
+
if (typeof b["figure"] === "string" && exprMentions(doc, b["figure"], ctx, r))
|
|
2105
|
+
return null;
|
|
1406
2106
|
if (isObject(b["tone"]) && exprMentions(doc, b["tone"]["expr"], ctx, r))
|
|
1407
|
-
return
|
|
2107
|
+
return null;
|
|
1408
2108
|
if (typeof b["when"] === "string" && exprMentions(doc, b["when"], ctx, r))
|
|
1409
|
-
return
|
|
2109
|
+
return null;
|
|
1410
2110
|
if (isObject(b["progress"]))
|
|
1411
2111
|
for (const k of ["value", "max"])
|
|
1412
2112
|
if (exprMentions(doc, b["progress"][k], ctx, r))
|
|
1413
|
-
return
|
|
1414
|
-
|
|
2113
|
+
return null;
|
|
2114
|
+
if (typeof b["list"] === "string") {
|
|
2115
|
+
if (exprMentions(doc, b["list"], ctx, r))
|
|
2116
|
+
return null;
|
|
2117
|
+
const members = listMembers(doc, b["list"], ctx);
|
|
2118
|
+
const next = { ...b };
|
|
2119
|
+
const sort = keyOf(b["sort"]);
|
|
2120
|
+
if (sort !== undefined && exprMentions(doc, sort, members, r))
|
|
2121
|
+
delete next["sort"];
|
|
2122
|
+
const group = keyOf(b["group"]);
|
|
2123
|
+
if (group !== undefined && renamer(doc, r)({ role: "field", kinds: members, name: group }) !== group)
|
|
2124
|
+
delete next["group"];
|
|
2125
|
+
return next;
|
|
2126
|
+
}
|
|
2127
|
+
return b;
|
|
1415
2128
|
}
|
|
1416
2129
|
/**
|
|
1417
2130
|
* Apply structural edits to a document, in order. Returns the new document and what
|