@graview/core 0.1.9 → 0.1.12
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 +19 -8
- package/dist/app.d.ts +41 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js.map +1 -1
- package/dist/blocks.d.ts +5 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +14 -0
- package/dist/blocks.js.map +1 -0
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/check.d.ts +8 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +19 -0
- package/dist/check.js.map +1 -0
- 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/brand.d.ts.map +1 -1
- package/dist/cli/check/brand.js +21 -0
- package/dist/cli/check/brand.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/hidden-reads.d.ts +4 -0
- package/dist/cli/check/hidden-reads.d.ts.map +1 -0
- package/dist/cli/check/hidden-reads.js +152 -0
- package/dist/cli/check/hidden-reads.js.map +1 -0
- package/dist/cli/check/index.d.ts +2 -0
- package/dist/cli/check/index.d.ts.map +1 -1
- package/dist/cli/check/index.js +2 -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 +13 -3
- package/dist/cli/check/views.js.map +1 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +5 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +1 -1
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/describe.d.ts.map +1 -1
- package/dist/cli/describe.js +66 -1
- package/dist/cli/describe.js.map +1 -1
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +23 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/columns.d.ts +73 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/columns.js +100 -0
- package/dist/columns.js.map +1 -0
- package/dist/conformance/index.js +1 -1
- package/dist/conformance/index.js.map +1 -1
- package/dist/describe.d.ts +5 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +19 -0
- package/dist/describe.js.map +1 -0
- package/dist/document/blocks.d.ts +187 -0
- package/dist/document/blocks.d.ts.map +1 -0
- package/dist/document/blocks.js +372 -0
- package/dist/document/blocks.js.map +1 -0
- package/dist/document/compile-checked.d.ts +8 -0
- package/dist/document/compile-checked.d.ts.map +1 -0
- package/dist/document/compile-checked.js +36 -0
- package/dist/document/compile-checked.js.map +1 -0
- package/dist/document/compile.d.ts +8 -6
- package/dist/document/compile.d.ts.map +1 -1
- package/dist/document/compile.js +68 -46
- 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 +286 -0
- package/dist/document/describe-place.js.map +1 -0
- package/dist/document/diff.d.ts.map +1 -1
- package/dist/document/diff.js +78 -4
- 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 +908 -107
- 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 +51 -4
- 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 +121 -8
- 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/expr/parse.d.ts +2 -1
- package/dist/document/expr/parse.d.ts.map +1 -1
- package/dist/document/expr/parse.js +6 -3
- package/dist/document/expr/parse.js.map +1 -1
- package/dist/document/expr/print.d.ts.map +1 -1
- package/dist/document/expr/print.js +4 -2
- package/dist/document/expr/print.js.map +1 -1
- package/dist/document/graview-template.d.ts +20 -12
- package/dist/document/graview-template.d.ts.map +1 -1
- package/dist/document/graview-template.js +5 -44
- package/dist/document/graview-template.js.map +1 -1
- package/dist/document/index.d.ts +8 -8
- package/dist/document/index.d.ts.map +1 -1
- package/dist/document/index.js +5 -5
- package/dist/document/index.js.map +1 -1
- package/dist/document/instantiate-template.d.ts +14 -0
- package/dist/document/instantiate-template.d.ts.map +1 -0
- package/dist/document/instantiate-template.js +46 -0
- package/dist/document/instantiate-template.js.map +1 -0
- 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 +46 -5
- package/dist/document/schema.d.ts.map +1 -1
- package/dist/document/schema.js +31 -3
- package/dist/document/schema.js.map +1 -1
- package/dist/document/template.d.ts +33 -2
- package/dist/document/template.d.ts.map +1 -1
- package/dist/document/template.js +156 -18
- package/dist/document/template.js.map +1 -1
- package/dist/document/thumbnail.d.ts +5 -4
- package/dist/document/thumbnail.d.ts.map +1 -1
- package/dist/document/thumbnail.js +29 -21
- package/dist/document/thumbnail.js.map +1 -1
- package/dist/document/to-document.d.ts.map +1 -1
- package/dist/document/to-document.js +6 -1
- package/dist/document/to-document.js.map +1 -1
- package/dist/document/views.d.ts +58 -3
- package/dist/document/views.d.ts.map +1 -1
- package/dist/document/views.js +356 -24
- package/dist/document/views.js.map +1 -1
- package/dist/figures.d.ts +3 -0
- package/dist/figures.d.ts.map +1 -0
- package/dist/figures.js +14 -0
- package/dist/figures.js.map +1 -0
- package/dist/index.d.ts +3 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -7
- package/dist/index.js.map +1 -1
- package/dist/mutations/types.d.ts +12 -0
- package/dist/mutations/types.d.ts.map +1 -1
- package/dist/places.d.ts +197 -0
- package/dist/places.d.ts.map +1 -0
- package/dist/places.js +376 -0
- package/dist/places.js.map +1 -0
- package/dist/scaffold/domain.js +1 -1
- package/dist/scaffold/from-template.d.ts.map +1 -1
- package/dist/scaffold/from-template.js +3 -3
- package/dist/scaffold/from-template.js.map +1 -1
- package/dist/scaffold/project.d.ts.map +1 -1
- package/dist/scaffold/project.js +13 -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 +5 -1
- package/dist/scene-districts.d.ts.map +1 -1
- package/dist/scene-districts.js +11 -4
- package/dist/scene-districts.js.map +1 -1
- package/dist/scene.d.ts +7 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +16 -0
- package/dist/scene.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/types.d.ts +9 -0
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/theme/types.js.map +1 -1
- 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 +21 -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(),
|
|
@@ -57,11 +66,52 @@ const SHAPES = {
|
|
|
57
66
|
"remove-act": z.object({ op: z.literal("remove-act"), act: actName }).strict(),
|
|
58
67
|
"add-rule": z.object({ op: z.literal("add-rule"), rule: actName, replace: z.boolean().optional() }).passthrough(),
|
|
59
68
|
"remove-rule": z.object({ op: z.literal("remove-rule"), rule: actName }).strict(),
|
|
60
|
-
"set-brand": z
|
|
69
|
+
"set-brand": z
|
|
70
|
+
.object({
|
|
71
|
+
op: z.literal("set-brand"),
|
|
72
|
+
accent: z.union([z.string(), z.null()]).optional(),
|
|
73
|
+
name: z.string().min(1).max(60).optional(),
|
|
74
|
+
currency: z.union([z.string(), z.null()]).optional(),
|
|
75
|
+
locale: z.union([z.string(), z.null()]).optional(),
|
|
76
|
+
})
|
|
77
|
+
.strict(),
|
|
61
78
|
"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
79
|
"set-describe": z.object({ op: z.literal("set-describe"), kind: kindName, describe: z.union([z.string().min(1).max(300), z.null()]) }).strict(),
|
|
63
|
-
|
|
80
|
+
// A kind's slot; the front page (`slot: "home"`, no kind); or a blocks lens's blocks, by its title (FR-84).
|
|
81
|
+
"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
82
|
"set-glance": z.object({ op: z.literal("set-glance"), kind: kindName, fields: z.array(fieldName).max(20) }).strict(),
|
|
83
|
+
"add-lens": z
|
|
84
|
+
.object({
|
|
85
|
+
op: z.literal("add-lens"),
|
|
86
|
+
title: lensTitle,
|
|
87
|
+
lens: z.string().min(1).max(40).optional(),
|
|
88
|
+
on: kindName.optional(),
|
|
89
|
+
bindings: z.record(z.string(), z.unknown()).optional(),
|
|
90
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
91
|
+
at: z.number().int().min(0).optional(),
|
|
92
|
+
replace: lensTitle.optional(),
|
|
93
|
+
})
|
|
94
|
+
.strict(),
|
|
95
|
+
"remove-lens": z.object({ op: z.literal("remove-lens"), title: lensTitle, on: kindName.optional() }).strict(),
|
|
96
|
+
"set-home": z.object({ op: z.literal("set-home"), blocks: z.union([z.array(z.unknown()).min(1), z.null()]) }).strict(),
|
|
97
|
+
"arrange-pages": z
|
|
98
|
+
.object({
|
|
99
|
+
op: z.literal("arrange-pages"),
|
|
100
|
+
order: z.union([z.array(kindName).max(40), z.null()]).optional(),
|
|
101
|
+
hide: z.union([z.array(kindName).max(40), z.null()]).optional(),
|
|
102
|
+
first: z.union([z.string().min(1).max(80), z.null()]).optional(),
|
|
103
|
+
})
|
|
104
|
+
.strict(),
|
|
105
|
+
"set-computed": z
|
|
106
|
+
.object({
|
|
107
|
+
op: z.literal("set-computed"),
|
|
108
|
+
kind: kindName,
|
|
109
|
+
name: fieldName,
|
|
110
|
+
expr: z.union([z.string().min(1).max(2000), z.null()]).optional(),
|
|
111
|
+
label: z.union([z.string().min(1).max(60), z.null()]).optional(),
|
|
112
|
+
description: z.union([z.string().min(1).max(500), z.null()]).optional(),
|
|
113
|
+
})
|
|
114
|
+
.strict(),
|
|
65
115
|
};
|
|
66
116
|
const NONE = new Set();
|
|
67
117
|
const clone = (v) => structuredClone(v);
|
|
@@ -106,6 +156,12 @@ function kindsOf(doc, e, ctx) {
|
|
|
106
156
|
return kindsOf(doc, e.set, ctx);
|
|
107
157
|
case "call": {
|
|
108
158
|
const lit = e.args[0]?.t === "lit" && typeof e.args[0].value === "string" ? e.args[0].value : undefined;
|
|
159
|
+
// A walk from every member of a set (FR-101): its relation is its second word.
|
|
160
|
+
const walked = e.args.length === 2 && e.args[1]?.t === "lit" && typeof e.args[1].value === "string" ? e.args[1].value : undefined;
|
|
161
|
+
if (e.fn === "out" && walked)
|
|
162
|
+
return targets(doc, kindsOf(doc, e.args[0], ctx), walked);
|
|
163
|
+
if (e.fn === "in" && walked)
|
|
164
|
+
return sources(doc, walked);
|
|
109
165
|
if (e.fn === "out" && lit)
|
|
110
166
|
return targets(doc, "*", lit);
|
|
111
167
|
if (e.fn === "in" && lit)
|
|
@@ -114,6 +170,11 @@ function kindsOf(doc, e, ctx) {
|
|
|
114
170
|
return new Set([lit]);
|
|
115
171
|
if (e.fn === "if" && e.args.length === 3)
|
|
116
172
|
return union(kindsOf(doc, e.args[1], ctx), kindsOf(doc, e.args[2], ctx));
|
|
173
|
+
// A set handed through: its first, in an order, or the first of several that is not empty.
|
|
174
|
+
if ((e.fn === "first" || e.fn === "sort") && e.args[0])
|
|
175
|
+
return kindsOf(doc, e.args[0], ctx);
|
|
176
|
+
if (e.fn === "either")
|
|
177
|
+
return e.args.reduce((all, arg) => union(all, kindsOf(doc, arg, ctx)), NONE);
|
|
117
178
|
return NONE;
|
|
118
179
|
}
|
|
119
180
|
default:
|
|
@@ -165,8 +226,15 @@ function mapNames(doc, e, ctx, rename) {
|
|
|
165
226
|
const name = rename({ role: x.fn === "all" ? "kind" : "edge", kinds: NONE, name: literal });
|
|
166
227
|
args = name === literal ? [...x.args] : [{ ...first, value: name }];
|
|
167
228
|
}
|
|
168
|
-
else if ((x.fn === "
|
|
169
|
-
|
|
229
|
+
else if ((x.fn === "out" || x.fn === "in") && x.args.length === 2 && x.args[1].t === "lit" && typeof x.args[1].value === "string") {
|
|
230
|
+
// out(S, 'edge'): the set is read where the walk stands, and its relation renamed like any walk's (FR-101).
|
|
231
|
+
const relation = x.args[1];
|
|
232
|
+
const name = rename({ role: "edge", kinds: NONE, name: relation.value });
|
|
233
|
+
args = [go(first, at), name === relation.value ? relation : { ...relation, value: name }];
|
|
234
|
+
}
|
|
235
|
+
else if (((x.fn === "every" || x.fn === "some") && x.args.length === 2) || (x.fn === "sort" && x.args.length >= 2)) {
|
|
236
|
+
// Read once per member, with the member as its subject: its names are the members'.
|
|
237
|
+
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
238
|
}
|
|
171
239
|
else if ((x.fn === "sum" || x.fn === "min" || x.fn === "max") && x.args.length === 2) {
|
|
172
240
|
const second = x.args[1];
|
|
@@ -179,7 +247,7 @@ function mapNames(doc, e, ctx, rename) {
|
|
|
179
247
|
field = second.t === "ident" ? { ...second, name } : { ...second, value: name };
|
|
180
248
|
}
|
|
181
249
|
else
|
|
182
|
-
field = go(second,
|
|
250
|
+
field = go(second, members); // sum(out('includes'), list * units): each member's own list and units
|
|
183
251
|
args = [go(x.args[0], at), field];
|
|
184
252
|
}
|
|
185
253
|
else
|
|
@@ -201,7 +269,8 @@ function renamer(doc, r) {
|
|
|
201
269
|
return r.to;
|
|
202
270
|
return site.name;
|
|
203
271
|
}
|
|
204
|
-
|
|
272
|
+
// A field, or a computed field (FR-83): read by the same bare name.
|
|
273
|
+
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
274
|
return r.to;
|
|
206
275
|
return site.name;
|
|
207
276
|
};
|
|
@@ -234,12 +303,18 @@ function parts(source) {
|
|
|
234
303
|
throw err;
|
|
235
304
|
}
|
|
236
305
|
}
|
|
237
|
-
/**
|
|
306
|
+
/**
|
|
307
|
+
* A template with the rename applied inside its braces; the words around them
|
|
308
|
+
* are kept, and so is what each brace says after its bar — `| money`,
|
|
309
|
+
* `| plural: 'front'` — exactly as it was written.
|
|
310
|
+
*/
|
|
238
311
|
function rewriteTemplate(doc, source, ctx, r, prose) {
|
|
239
312
|
const ps = parts(source);
|
|
240
313
|
if (!ps)
|
|
241
314
|
return source;
|
|
315
|
+
const braces = templateBraces(source);
|
|
242
316
|
let changed = false;
|
|
317
|
+
let brace = 0;
|
|
243
318
|
const out = ps.map((p) => {
|
|
244
319
|
if (p.text !== undefined) {
|
|
245
320
|
const t = prose ? prose(p.text) : p.text;
|
|
@@ -247,11 +322,15 @@ function rewriteTemplate(doc, source, ctx, r, prose) {
|
|
|
247
322
|
changed = true;
|
|
248
323
|
return t;
|
|
249
324
|
}
|
|
325
|
+
const { inner, bar } = braces[brace++];
|
|
250
326
|
const next = mapNames(doc, p.expr, ctx, renamer(doc, r));
|
|
251
327
|
if (next === p.expr)
|
|
252
|
-
return `{${
|
|
328
|
+
return `{${inner}}`;
|
|
253
329
|
changed = true;
|
|
254
|
-
|
|
330
|
+
const tail = bar >= 0 ? inner.slice(bar) : "";
|
|
331
|
+
const lead = /^\s*/.exec(inner)[0];
|
|
332
|
+
const gap = bar >= 0 ? /\s*$/.exec(inner.slice(0, bar))[0] : /\s*$/.exec(inner)[0];
|
|
333
|
+
return `{${lead}${printExpr(next)}${gap}${tail}}`;
|
|
255
334
|
});
|
|
256
335
|
return changed ? out.join("") : source;
|
|
257
336
|
}
|
|
@@ -440,16 +519,45 @@ class Editor {
|
|
|
440
519
|
return;
|
|
441
520
|
}
|
|
442
521
|
case "set-brand": {
|
|
522
|
+
// The colours and the money (FR-100) are set apart: what an edit does not name stays as it was.
|
|
523
|
+
const brand = { ...(this.doc.brand ?? {}) };
|
|
443
524
|
if (e.accent === null) {
|
|
444
|
-
delete
|
|
525
|
+
delete brand["accent"];
|
|
445
526
|
this.said.push("The app goes back to Graview's colours.");
|
|
446
527
|
}
|
|
447
|
-
else {
|
|
528
|
+
else if (e.accent !== undefined) {
|
|
448
529
|
if (!/^#[0-9a-fA-F]{6}$/.test(e.accent))
|
|
449
530
|
return this.fail(i, "accent", 'an accent is a colour like "#c2577a"');
|
|
450
|
-
|
|
531
|
+
brand["accent"] = e.accent;
|
|
451
532
|
this.said.push(`The app's accent colour becomes ${e.accent}.`);
|
|
452
533
|
}
|
|
534
|
+
if (e.currency === null) {
|
|
535
|
+
delete brand["currency"];
|
|
536
|
+
this.said.push("Money is said with no currency.");
|
|
537
|
+
}
|
|
538
|
+
else if (e.currency !== undefined) {
|
|
539
|
+
if (!/^[A-Z]{3}$/.test(e.currency))
|
|
540
|
+
return this.fail(i, "currency", 'a currency is its three-letter code, like "USD" or "EUR"');
|
|
541
|
+
brand["currency"] = e.currency;
|
|
542
|
+
this.said.push(`Money is said in ${e.currency}.`);
|
|
543
|
+
}
|
|
544
|
+
if (e.locale === null)
|
|
545
|
+
delete brand["locale"];
|
|
546
|
+
else if (e.locale !== undefined) {
|
|
547
|
+
if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(e.locale))
|
|
548
|
+
return this.fail(i, "locale", 'a locale is a language tag, like "en-US" or "de-DE"');
|
|
549
|
+
brand["locale"] = e.locale;
|
|
550
|
+
this.said.push(`Money is written for ${e.locale}.`);
|
|
551
|
+
}
|
|
552
|
+
if (e.name) {
|
|
553
|
+
brand["name"] = e.name;
|
|
554
|
+
this.said.push(`The app's wordmark says ${e.name}.`);
|
|
555
|
+
}
|
|
556
|
+
// A brand that holds only a name holds nothing to apply.
|
|
557
|
+
if (Object.keys(brand).every((key) => key === "name"))
|
|
558
|
+
delete this.doc.brand;
|
|
559
|
+
else
|
|
560
|
+
this.doc.brand = brand;
|
|
453
561
|
return;
|
|
454
562
|
}
|
|
455
563
|
case "set-label": {
|
|
@@ -488,25 +596,8 @@ class Editor {
|
|
|
488
596
|
this.said.push(e.describe === null ? `A ${e.kind} has no summary line.` : `A ${e.kind}'s summary line reads "${e.describe}".`);
|
|
489
597
|
return;
|
|
490
598
|
}
|
|
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
|
-
}
|
|
599
|
+
case "set-view":
|
|
600
|
+
return this.setView(i, e);
|
|
510
601
|
case "set-glance": {
|
|
511
602
|
// What a glance at one says (FR-39): fields of this kind, each once, in the order given; none takes the choice away.
|
|
512
603
|
const spec = this.kind(i, e.kind);
|
|
@@ -533,7 +624,311 @@ class Editor {
|
|
|
533
624
|
}
|
|
534
625
|
return;
|
|
535
626
|
}
|
|
627
|
+
case "add-lens":
|
|
628
|
+
return this.addLens(i, e);
|
|
629
|
+
case "remove-lens":
|
|
630
|
+
return this.removeLens(i, e);
|
|
631
|
+
case "set-home":
|
|
632
|
+
return this.setHome(i, e.blocks);
|
|
633
|
+
case "arrange-pages":
|
|
634
|
+
return this.arrangePages(i, e);
|
|
635
|
+
case "set-computed":
|
|
636
|
+
return this.setComputed(i, e);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// ── views, the front page, lenses, the arrangement, what is worked out (FR-84) ──
|
|
640
|
+
/** Blocks checked where they will stand: an error refuses the edit at that path, so a chat is told before anything is previewed. */
|
|
641
|
+
blocksHold(i, at) {
|
|
642
|
+
const found = validateViews(this.doc).filter((f) => f.severity === "error" || f.path.startsWith("lenses."));
|
|
643
|
+
const here = found.filter((f) => f.path === at || f.path.startsWith(`${at}.`));
|
|
644
|
+
for (const f of here)
|
|
645
|
+
this.fail(i, `blocks${f.path.slice(at.length)}`, f.message, f.fix);
|
|
646
|
+
return here.length === 0;
|
|
647
|
+
}
|
|
648
|
+
setView(i, e) {
|
|
649
|
+
if (e.lens !== undefined) {
|
|
650
|
+
if (e.kind !== undefined || e.slot !== undefined)
|
|
651
|
+
return this.fail(i, "lens", "a lens's blocks are set by its title alone; leave out kind and slot");
|
|
652
|
+
const index = this.lensIndex(i, e.lens);
|
|
653
|
+
if (index < 0)
|
|
654
|
+
return;
|
|
655
|
+
const lens = this.doc.lenses[index];
|
|
656
|
+
if (lens.name !== "blocks")
|
|
657
|
+
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");
|
|
658
|
+
if (e.blocks === null)
|
|
659
|
+
return this.fail(i, "blocks", "a blocks lens is its blocks; remove-lens takes it away");
|
|
660
|
+
const was = lens.options?.blocks;
|
|
661
|
+
lens.options = { ...(lens.options ?? {}), blocks: e.blocks };
|
|
662
|
+
if (!this.blocksHold(i, `lenses.${index}.options.blocks`))
|
|
663
|
+
return;
|
|
664
|
+
this.said.push(`The lens "${e.lens}" is drawn from ${blockWords(e.blocks)}${was ? " now" : ""}.`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (e.slot === "home") {
|
|
668
|
+
if (e.kind !== undefined)
|
|
669
|
+
return this.fail(i, "kind", "the front page is no kind's; leave out kind");
|
|
670
|
+
return this.setHome(i, e.blocks);
|
|
671
|
+
}
|
|
672
|
+
if (e.kind === undefined || e.slot === undefined)
|
|
673
|
+
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');
|
|
674
|
+
if (!this.kind(i, e.kind))
|
|
675
|
+
return;
|
|
676
|
+
const views = (this.doc.views ??= {});
|
|
677
|
+
if (Array.isArray(views[e.kind]))
|
|
678
|
+
return this.fail(i, "kind", `"${e.kind}" here is the front page's blocks, not a kind's views`);
|
|
679
|
+
const noun = nounOf(this.doc, e.kind);
|
|
680
|
+
if (e.blocks === null) {
|
|
681
|
+
if (views[e.kind])
|
|
682
|
+
delete views[e.kind][e.slot];
|
|
683
|
+
if (views[e.kind] && Object.keys(views[e.kind]).length === 0)
|
|
684
|
+
delete views[e.kind];
|
|
685
|
+
if (Object.keys(views).length === 0)
|
|
686
|
+
delete this.doc.views;
|
|
687
|
+
this.said.push(`${cap(withA(noun))} ${e.slot} goes back to Graview's own look.`);
|
|
688
|
+
return;
|
|
536
689
|
}
|
|
690
|
+
(views[e.kind] ??= {})[e.slot] = e.blocks;
|
|
691
|
+
if (!this.blocksHold(i, `views.${e.kind}.${e.slot}`))
|
|
692
|
+
return;
|
|
693
|
+
this.said.push(`${cap(withA(noun))} ${e.slot} gets a look of its own: ${blockWords(e.blocks)}.`);
|
|
694
|
+
}
|
|
695
|
+
setHome(i, blocks) {
|
|
696
|
+
const views = (this.doc.views ??= {});
|
|
697
|
+
if (views.home !== undefined && !Array.isArray(views.home))
|
|
698
|
+
return this.fail(i, "blocks", "this app has a kind called home, whose views stand where the front page's blocks would");
|
|
699
|
+
const had = Array.isArray(views.home);
|
|
700
|
+
if (blocks === null) {
|
|
701
|
+
delete views.home;
|
|
702
|
+
if (Object.keys(views).length === 0)
|
|
703
|
+
delete this.doc.views;
|
|
704
|
+
this.said.push(had ? "The front page goes back to Graview's own." : "The front page was already Graview's own.");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
views.home = blocks;
|
|
708
|
+
if (!this.blocksHold(i, "views.home"))
|
|
709
|
+
return;
|
|
710
|
+
this.said.push(had ? `The front page changes: ${blockWords(blocks)}.` : `The front page gets a view of its own: ${blockWords(blocks)}.`);
|
|
711
|
+
}
|
|
712
|
+
/** The lens a title names (and an `on`, when two lenses share a title), or -1 having said why. */
|
|
713
|
+
lensIndex(i, title, on, path = "title") {
|
|
714
|
+
const lenses = this.doc.lenses ?? [];
|
|
715
|
+
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] : []));
|
|
716
|
+
if (matches.length === 1)
|
|
717
|
+
return matches[0];
|
|
718
|
+
const titles = lenses.flatMap((lens) => (isObject(lens) && typeof lens["title"] === "string" ? [`"${lens["title"]}"`] : []));
|
|
719
|
+
if (matches.length === 0)
|
|
720
|
+
this.fail(i, path, `there is no lens "${title}"${titles.length ? `; the lenses are ${titles.join(", ")}` : ""}`);
|
|
721
|
+
else
|
|
722
|
+
this.fail(i, "on", `${matches.length} lenses are called "${title}"; say which kind it stands on with "on"`);
|
|
723
|
+
return -1;
|
|
724
|
+
}
|
|
725
|
+
addLens(i, e) {
|
|
726
|
+
const name = e.lens ?? "blocks";
|
|
727
|
+
if (!isShippedLens(name))
|
|
728
|
+
return this.fail(i, "lens", `"${name}" is not a lens Graview draws`, `one of ${SHIPPED_LENS_NAMES.join(", ")}`);
|
|
729
|
+
if (e.on !== undefined && !this.kind(i, e.on))
|
|
730
|
+
return;
|
|
731
|
+
const lenses = (this.doc.lenses ??= []);
|
|
732
|
+
let at = lenses.length;
|
|
733
|
+
let was;
|
|
734
|
+
if (e.replace !== undefined) {
|
|
735
|
+
at = this.lensIndex(i, e.replace, e.on, "replace");
|
|
736
|
+
if (at < 0)
|
|
737
|
+
return;
|
|
738
|
+
was = lenses[at];
|
|
739
|
+
}
|
|
740
|
+
const lens = { name, title: e.title, ...(e.on !== undefined ? { on: e.on } : {}), ...(e.bindings !== undefined ? { bindings: e.bindings } : {}), ...(e.options !== undefined ? { options: e.options } : {}) };
|
|
741
|
+
// A title is an address: one lens a title on a kind.
|
|
742
|
+
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));
|
|
743
|
+
if (clash >= 0)
|
|
744
|
+
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');
|
|
745
|
+
if (name === "blocks") {
|
|
746
|
+
if (e.on === undefined)
|
|
747
|
+
return this.fail(i, "on", "a lens drawn from blocks says which kind it is a place of", 'say "on": "<kind>"');
|
|
748
|
+
if (!Array.isArray(e.options?.blocks) || e.options.blocks.length === 0)
|
|
749
|
+
return this.fail(i, "options.blocks", 'a lens drawn from blocks has blocks: "options": {"blocks": [{"headline": "…"}, {"list": "all(\'<kind>\')", "as": "card"}]}');
|
|
750
|
+
}
|
|
751
|
+
if (name === "columns" && !this.columnsHold(i, e.bindings))
|
|
752
|
+
return;
|
|
753
|
+
if (was)
|
|
754
|
+
lenses[at] = lens;
|
|
755
|
+
else
|
|
756
|
+
lenses.splice(e.at === undefined ? lenses.length : Math.min(e.at, lenses.length), 0, lens);
|
|
757
|
+
const index = was ? at : lenses.indexOf(lens);
|
|
758
|
+
if (name === "blocks" && !this.blocksHold(i, `lenses.${index}.options.blocks`))
|
|
759
|
+
return;
|
|
760
|
+
const over = lens.on ?? kindsBound(lens)[0];
|
|
761
|
+
const where = over ? `, over ${pluralWords(this.doc, over)}` : "";
|
|
762
|
+
if (!was) {
|
|
763
|
+
this.said.push(`A lens "${e.title}" is added${where}.`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const retitled = was["title"] !== e.title;
|
|
767
|
+
// Where the app opens follows a lens it named, by its new title (FR-80).
|
|
768
|
+
let follows = "";
|
|
769
|
+
if (retitled && namesLens(this.doc.pages?.first, String(was["title"]))) {
|
|
770
|
+
this.doc.pages.first = e.title;
|
|
771
|
+
follows = "; the app still opens on it";
|
|
772
|
+
}
|
|
773
|
+
this.said.push(retitled ? `The lens "${String(was["title"])}" is now "${e.title}"${where}${follows}.` : `The lens "${e.title}" changes${where}.`);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* A STATUS BOARD'S BINDINGS (FR-97), held before it is added: each kind it
|
|
777
|
+
* binds is a kind, and its `column` is a field of that kind whose values
|
|
778
|
+
* are choices — the columns. Refused at the binding's path otherwise, so a
|
|
779
|
+
* chat is told which word to change rather than shown an empty board.
|
|
780
|
+
*/
|
|
781
|
+
columnsHold(i, bindings) {
|
|
782
|
+
const example = '"bindings": {"<kind>": {"column": "<a choice field, like status>"}}';
|
|
783
|
+
if (!isObject(bindings) || Object.keys(bindings).length === 0)
|
|
784
|
+
return (this.fail(i, "bindings", "a board binds a kind's choice field to its columns", example), false);
|
|
785
|
+
for (const [kind, roles] of Object.entries(bindings)) {
|
|
786
|
+
if (!isObject(this.doc.kinds[kind]))
|
|
787
|
+
return (this.fail(i, `bindings.${kind}`, `"${kind}" is not a kind this app has; it has ${Object.keys(this.doc.kinds).join(", ")}`), false);
|
|
788
|
+
const field = isObject(roles) ? roles["column"] : undefined;
|
|
789
|
+
const fields = this.doc.kinds[kind].fields;
|
|
790
|
+
if (typeof field !== "string")
|
|
791
|
+
return (this.fail(i, `bindings.${kind}.column`, `the board does not say which field of ${kind} its columns are`, example), false);
|
|
792
|
+
if (!(field in fields))
|
|
793
|
+
return (this.fail(i, `bindings.${kind}.column`, `${kind} has no field "${field}"; it has ${Object.keys(fields).join(", ")}`), false);
|
|
794
|
+
if (fields[field]["type"] !== "enum") {
|
|
795
|
+
const choices = Object.keys(fields).filter((one) => fields[one]["type"] === "enum");
|
|
796
|
+
return (this.fail(i, `bindings.${kind}.column`, `${kind}'s ${field} is not a choice, so it has no columns`, choices.length > 0 ? `bind one of ${choices.join(", ")}` : `give ${kind} a choice field first: add-field with "type": "enum" and its "options", in the order the columns go`), false);
|
|
797
|
+
}
|
|
798
|
+
const others = Object.keys(roles).filter((role) => role !== "column");
|
|
799
|
+
if (others.length > 0)
|
|
800
|
+
return (this.fail(i, `bindings.${kind}.${others[0]}`, `a board binds only "column"`), false);
|
|
801
|
+
}
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
removeLens(i, e) {
|
|
805
|
+
const index = this.lensIndex(i, e.title, e.on);
|
|
806
|
+
if (index < 0)
|
|
807
|
+
return;
|
|
808
|
+
const said = this.removeLensAt(index);
|
|
809
|
+
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" : ""}.`);
|
|
810
|
+
}
|
|
811
|
+
arrangePages(i, e) {
|
|
812
|
+
if (e.order === undefined && e.hide === undefined && e.first === undefined)
|
|
813
|
+
return this.fail(i, "", 'arrange-pages says at least one of "order", "hide" or "first"');
|
|
814
|
+
const kinds = Object.keys(this.doc.kinds);
|
|
815
|
+
for (const part of ["order", "hide"]) {
|
|
816
|
+
for (const [n, kind] of (e[part] ?? []).entries()) {
|
|
817
|
+
if (!kinds.includes(kind))
|
|
818
|
+
return this.fail(i, `${part}.${n}`, `"${kind}" is not a kind this app has; it has ${kinds.join(", ")}`);
|
|
819
|
+
if (e[part].indexOf(kind) !== n)
|
|
820
|
+
return this.fail(i, `${part}.${n}`, `"${kind}" is named twice`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (typeof e.first === "string" && !this.opens(e.first)) {
|
|
824
|
+
const titles = (this.doc.lenses ?? []).flatMap((lens) => (isObject(lens) && typeof lens["title"] === "string" ? [`"${lens["title"]}"`] : []));
|
|
825
|
+
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"`);
|
|
826
|
+
}
|
|
827
|
+
const pages = (this.doc.pages ??= {});
|
|
828
|
+
const said = [];
|
|
829
|
+
if (e.order !== undefined) {
|
|
830
|
+
if (e.order === null || e.order.length === 0) {
|
|
831
|
+
delete pages.order;
|
|
832
|
+
said.push("the kinds go back to the order they were declared in");
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
pages.order = [...e.order];
|
|
836
|
+
said.push(`${listOf(e.order.map((k) => pluralWords(this.doc, k)))} come first, in that order`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (e.hide !== undefined) {
|
|
840
|
+
if (e.hide === null || e.hide.length === 0) {
|
|
841
|
+
delete pages.hide;
|
|
842
|
+
said.push("the front page shows every kind");
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
pages.hide = [...e.hide];
|
|
846
|
+
said.push(`the front page leaves off ${listOf(e.hide.map((k) => pluralWords(this.doc, k)))}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (e.first !== undefined) {
|
|
850
|
+
if (e.first === null || e.first.trim().toLowerCase() === "home") {
|
|
851
|
+
if (e.first === null)
|
|
852
|
+
delete pages.first;
|
|
853
|
+
else
|
|
854
|
+
pages.first = e.first;
|
|
855
|
+
said.push("the app opens at its home");
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
pages.first = e.first;
|
|
859
|
+
said.push(`the app opens on "${e.first}"`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (Object.keys(pages).length === 0)
|
|
863
|
+
delete this.doc.pages;
|
|
864
|
+
this.said.push(`${cap(listOf(said))}.`);
|
|
865
|
+
}
|
|
866
|
+
/** Whether `first` names something the app can open on: home, a kind or its plural, a titled lens. */
|
|
867
|
+
opens(first) {
|
|
868
|
+
const word = first.trim();
|
|
869
|
+
if (word.toLowerCase() === "home" || word === "/")
|
|
870
|
+
return true;
|
|
871
|
+
if (this.doc.kinds[word])
|
|
872
|
+
return true;
|
|
873
|
+
if (Object.keys(this.doc.kinds).some((k) => placeSlug(pluralWords(this.doc, k)) === placeSlug(word)))
|
|
874
|
+
return true;
|
|
875
|
+
return (this.doc.lenses ?? []).some((lens) => isObject(lens) && typeof lens["title"] === "string" && namesLens(word, lens["title"]));
|
|
876
|
+
}
|
|
877
|
+
setComputed(i, e) {
|
|
878
|
+
const spec = this.kind(i, e.kind);
|
|
879
|
+
if (!spec)
|
|
880
|
+
return;
|
|
881
|
+
const name = e.name;
|
|
882
|
+
const plural = cap(pluralWords(this.doc, e.kind));
|
|
883
|
+
const existing = spec.computed?.[name];
|
|
884
|
+
if (e.expr === null) {
|
|
885
|
+
if (existing === undefined)
|
|
886
|
+
return this.fail(i, "name", `${e.kind} works out nothing called "${name}"${spec.computed ? `; it works out ${Object.keys(spec.computed).join(", ")}` : ""}`);
|
|
887
|
+
const gone = this.dropComputed(e.kind, name).slice(1);
|
|
888
|
+
this.said.push(`${plural} no longer work out "${name}"${gone.length ? `; ${listOf(gone)} ${gone.length === 1 ? "goes" : "go"} with it` : ""}.`);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (existing === undefined) {
|
|
892
|
+
if (e.expr === undefined)
|
|
893
|
+
return this.fail(i, "expr", `to work out "${name}", give its expression, like "list * units"`);
|
|
894
|
+
if (spec.fields[name] || spec.edges?.[name])
|
|
895
|
+
return this.fail(i, "name", `${e.kind} already has a ${spec.fields[name] ? "field" : "relation"} called "${name}"`);
|
|
896
|
+
if (KEYWORDS.has(name))
|
|
897
|
+
return this.fail(i, "name", `"${name}" is a word the rule language keeps; pick another name`);
|
|
898
|
+
if (Object.keys(spec.computed ?? {}).length >= 20)
|
|
899
|
+
return this.fail(i, "name", "a kind works out at most 20 computed fields");
|
|
900
|
+
}
|
|
901
|
+
const was = existing === undefined ? {} : typeof existing === "string" ? { expr: existing } : { ...existing };
|
|
902
|
+
const next = { ...was };
|
|
903
|
+
if (e.expr !== undefined)
|
|
904
|
+
next.expr = e.expr;
|
|
905
|
+
for (const key of ["label", "description"]) {
|
|
906
|
+
if (e[key] === null)
|
|
907
|
+
delete next[key];
|
|
908
|
+
else if (e[key] !== undefined)
|
|
909
|
+
next[key] = e[key];
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
parseExpr(next.expr);
|
|
913
|
+
}
|
|
914
|
+
catch (err) {
|
|
915
|
+
if (err instanceof ExprSyntaxError)
|
|
916
|
+
return this.fail(i, "expr", `${err.sentence} (at character ${err.at + 1})`);
|
|
917
|
+
throw err;
|
|
918
|
+
}
|
|
919
|
+
// Kept short where it was said short: a bare expression is the same field as { expr }.
|
|
920
|
+
(spec.computed ??= {})[name] = next.label === undefined && next.description === undefined ? next.expr : next;
|
|
921
|
+
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}`);
|
|
922
|
+
for (const f of findings)
|
|
923
|
+
this.fail(i, "expr", f.message, f.fix);
|
|
924
|
+
if (findings.length > 0)
|
|
925
|
+
return;
|
|
926
|
+
if (existing === undefined)
|
|
927
|
+
this.said.push(`${plural} work out "${name}": ${next.expr}${next.label ? `, shown as "${next.label}"` : ""}.`);
|
|
928
|
+
else if (was.expr !== next.expr)
|
|
929
|
+
this.said.push(`How ${pluralWords(this.doc, e.kind)} work out "${name}" changes: ${next.expr}.`);
|
|
930
|
+
else
|
|
931
|
+
this.said.push(`${plural}' "${name}" is ${next.label ? `shown as "${next.label}"` : "shown by its name"}${next.description ? `, described as "${next.description}"` : ""}.`);
|
|
537
932
|
}
|
|
538
933
|
// ── kinds ──
|
|
539
934
|
addKind(i, e) {
|
|
@@ -599,10 +994,17 @@ class Editor {
|
|
|
599
994
|
spec.noun = e.noun;
|
|
600
995
|
else if (spec.noun === words(from) || spec.noun === from)
|
|
601
996
|
spec.noun = words(to);
|
|
997
|
+
const pluralWas = spec.plural ?? `${words(from)}s`;
|
|
602
998
|
if (e.plural)
|
|
603
999
|
spec.plural = e.plural;
|
|
604
1000
|
else if (spec.plural && (spec.plural === `${words(from)}s` || spec.plural === `${from}s`))
|
|
605
1001
|
spec.plural = `${words(to)}s`;
|
|
1002
|
+
// Where the app opens, when it named the kind by its plural, follows the plural (FR-80).
|
|
1003
|
+
const pluralNow = spec.plural ?? `${words(to)}s`;
|
|
1004
|
+
if (typeof this.doc.pages?.first === "string" && placeSlug(this.doc.pages.first) === placeSlug(pluralWas) && placeSlug(pluralWas) !== placeSlug(pluralNow)) {
|
|
1005
|
+
this.doc.pages.first = pluralNow;
|
|
1006
|
+
touched.push("where the app opens");
|
|
1007
|
+
}
|
|
606
1008
|
// Acts named for the kind follow it: add-vendor → add-supplier.
|
|
607
1009
|
for (const name of Object.keys(this.doc.acts ?? {})) {
|
|
608
1010
|
const act = this.doc.acts[name];
|
|
@@ -635,6 +1037,7 @@ class Editor {
|
|
|
635
1037
|
if (!gone.includes(what))
|
|
636
1038
|
gone.push(what);
|
|
637
1039
|
}
|
|
1040
|
+
const removedPlural = this.doc.kinds[kind].plural ?? `${words(kind)}s`;
|
|
638
1041
|
delete this.doc.kinds[kind];
|
|
639
1042
|
this.kindOrigin.delete(kind);
|
|
640
1043
|
// Relations that only led to it go with it.
|
|
@@ -666,7 +1069,7 @@ class Editor {
|
|
|
666
1069
|
gone.push(`rule ${name}`);
|
|
667
1070
|
}
|
|
668
1071
|
}
|
|
669
|
-
if (this.doc.views?.[kind]) {
|
|
1072
|
+
if (this.doc.views?.[kind] && !Array.isArray(this.doc.views[kind])) {
|
|
670
1073
|
delete this.doc.views[kind];
|
|
671
1074
|
if (Object.keys(this.doc.views).length === 0)
|
|
672
1075
|
delete this.doc.views;
|
|
@@ -679,9 +1082,35 @@ class Editor {
|
|
|
679
1082
|
if (this.doc.policy.sees.length === 0)
|
|
680
1083
|
delete this.doc.policy.sees;
|
|
681
1084
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1085
|
+
/*
|
|
1086
|
+
* What sweeps the kind (`all('offer')`) goes as what names a removed
|
|
1087
|
+
* field does: computed fields that work it out, blocks on the front page,
|
|
1088
|
+
* in other kinds' views and in blocks lenses. A lens that stood on the
|
|
1089
|
+
* kind, or drew by it, goes, and `pages.first` with it when it named it.
|
|
1090
|
+
*/
|
|
1091
|
+
const swept = { t: "kind", from: kind, to: "\u0000" };
|
|
1092
|
+
for (const [other, spec] of Object.entries(this.doc.kinds)) {
|
|
1093
|
+
for (const [name, c] of Object.entries(spec.computed ?? {})) {
|
|
1094
|
+
if (exprMentions(this.doc, typeof c === "string" ? c : c.expr, new Set([other]), swept))
|
|
1095
|
+
gone.push(...this.dropComputed(other, name));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
gone.push(...this.pruneViews(swept), ...this.pruneLenses(swept));
|
|
1099
|
+
// The arrangement names kinds (FR-80): a kind that is gone is no longer ordered or hidden, nor where the app opens.
|
|
1100
|
+
if (this.doc.pages) {
|
|
1101
|
+
for (const part of ["order", "hide"]) {
|
|
1102
|
+
const named = this.doc.pages[part];
|
|
1103
|
+
if (Array.isArray(named))
|
|
1104
|
+
this.doc.pages[part] = named.filter((k) => k !== kind);
|
|
1105
|
+
}
|
|
1106
|
+
const first = this.doc.pages.first;
|
|
1107
|
+
if (typeof first === "string" && (first === kind || placeSlug(first) === placeSlug(removedPlural))) {
|
|
1108
|
+
delete this.doc.pages.first;
|
|
1109
|
+
gone.push("where the app opens (it opens at its home again)");
|
|
1110
|
+
}
|
|
1111
|
+
if (Object.keys(this.doc.pages).length === 0)
|
|
1112
|
+
delete this.doc.pages;
|
|
1113
|
+
}
|
|
685
1114
|
for (const f of [...this.fills])
|
|
686
1115
|
if (f.kind === kind)
|
|
687
1116
|
this.fills.splice(this.fills.indexOf(f), 1);
|
|
@@ -766,8 +1195,7 @@ class Editor {
|
|
|
766
1195
|
x.from = to;
|
|
767
1196
|
}
|
|
768
1197
|
const prose = proseSwap(field, to);
|
|
769
|
-
|
|
770
|
-
f.label = prose(f.label);
|
|
1198
|
+
// 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
1199
|
if (f.description)
|
|
772
1200
|
f.description = prose(f.description);
|
|
773
1201
|
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 +1270,8 @@ class Editor {
|
|
|
842
1270
|
if (Object.keys(spec.fields).length === 1)
|
|
843
1271
|
return this.fail(i, "field", `${kind} keeps at least one field; remove the kind instead`);
|
|
844
1272
|
const r = { t: "field", kind, from: field, to: "\u0000" };
|
|
1273
|
+
if (this.lensesDrawnBy(i, r))
|
|
1274
|
+
return;
|
|
845
1275
|
const gone = this.dropMentions(r);
|
|
846
1276
|
delete spec.fields[field];
|
|
847
1277
|
this.fieldOrigin.delete(`${kind}.${field}`);
|
|
@@ -924,6 +1354,9 @@ class Editor {
|
|
|
924
1354
|
return;
|
|
925
1355
|
if (!spec.edges?.[relation])
|
|
926
1356
|
return this.fail(i, "relation", `${kind} has no relation "${relation}"`);
|
|
1357
|
+
const others = [...sources(this.doc, relation)].filter((k) => k !== kind);
|
|
1358
|
+
if (others.length === 0 && this.lensesDrawnBy(i, { t: "edge", from: relation, to: "\u0000" }))
|
|
1359
|
+
return;
|
|
927
1360
|
const gone = [];
|
|
928
1361
|
this.dropRelation(kind, relation, gone);
|
|
929
1362
|
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 +1471,15 @@ class Editor {
|
|
|
1038
1471
|
for (const e of Object.values(spec.edges ?? {}))
|
|
1039
1472
|
if (Array.isArray(e.to) && e.to.includes(r.from))
|
|
1040
1473
|
e.to = e.to.map((t) => (t === r.from ? r.to : t));
|
|
1474
|
+
// What it works out (FR-83): each expression read from this kind's records, renamed in the tree.
|
|
1475
|
+
for (const [name, c] of Object.entries(spec.computed ?? {})) {
|
|
1476
|
+
const was = typeof c === "string" ? c : c.expr;
|
|
1477
|
+
const next = exprAt(was, here);
|
|
1478
|
+
if (next === was)
|
|
1479
|
+
continue;
|
|
1480
|
+
spec.computed[name] = typeof c === "string" ? next : { ...c, expr: next };
|
|
1481
|
+
touched.push(`${kind}'s ${name}`);
|
|
1482
|
+
}
|
|
1041
1483
|
}
|
|
1042
1484
|
// Acts: what they act on and make, the fields they write, guards, refusals, computed values.
|
|
1043
1485
|
const renamedArgs = new Map();
|
|
@@ -1082,7 +1524,7 @@ class Editor {
|
|
|
1082
1524
|
else
|
|
1083
1525
|
act.effects[t.index].set = renamed;
|
|
1084
1526
|
}
|
|
1085
|
-
if (act.writes && hasKind(on, r.kind) && act.writes.includes(r.from)) {
|
|
1527
|
+
if (act.writes && (hasKind(on, r.kind) || act.creates === r.kind) && act.writes.includes(r.from)) {
|
|
1086
1528
|
act.writes = act.writes.map((w) => (w === r.from ? r.to : w));
|
|
1087
1529
|
args.set(r.from, r.to);
|
|
1088
1530
|
}
|
|
@@ -1184,49 +1626,49 @@ class Editor {
|
|
|
1184
1626
|
if (JSON.stringify(doc.policy) !== before)
|
|
1185
1627
|
touched.push("the policy");
|
|
1186
1628
|
}
|
|
1187
|
-
// Views.
|
|
1629
|
+
// Views: each kind's slots, and the home's blocks, which are about no one record (FR-81).
|
|
1188
1630
|
if (doc.views) {
|
|
1189
|
-
if (r.t === "kind" && doc.views[r.from]) {
|
|
1631
|
+
if (r.t === "kind" && doc.views[r.from] && !Array.isArray(doc.views[r.from])) {
|
|
1190
1632
|
const views = {};
|
|
1191
1633
|
for (const [k, v] of Object.entries(doc.views))
|
|
1192
1634
|
views[k === r.from ? r.to : k] = v;
|
|
1193
1635
|
doc.views = views;
|
|
1194
1636
|
}
|
|
1195
1637
|
for (const [kind, slots] of Object.entries(doc.views)) {
|
|
1196
|
-
|
|
1638
|
+
if (kind === "home" && Array.isArray(slots)) {
|
|
1639
|
+
const before = JSON.stringify(slots);
|
|
1640
|
+
doc.views.home = mapBlocks(slots, (b) => rewriteBlock(doc, b, NONE, r, null));
|
|
1641
|
+
if (JSON.stringify(doc.views.home) !== before)
|
|
1642
|
+
touched.push("the front page");
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
const was = r.t === "kind" && kind === r.to ? r.from : kind;
|
|
1646
|
+
const ctx = new Set([was]);
|
|
1197
1647
|
for (const slot of VIEW_SLOTS) {
|
|
1198
1648
|
if (!Array.isArray(slots[slot]))
|
|
1199
1649
|
continue;
|
|
1200
1650
|
const before = JSON.stringify(slots[slot]);
|
|
1201
|
-
slots[slot] = mapBlocks(slots[slot], (b) => rewriteBlock(doc, b, ctx, r,
|
|
1651
|
+
slots[slot] = mapBlocks(slots[slot], (b) => rewriteBlock(doc, b, ctx, r, was));
|
|
1202
1652
|
if (JSON.stringify(slots[slot]) !== before)
|
|
1203
1653
|
touched.push(`the ${kind} ${slot}`);
|
|
1204
1654
|
}
|
|
1205
1655
|
}
|
|
1206
1656
|
}
|
|
1207
|
-
// Lenses
|
|
1657
|
+
// Lenses: the kinds they stand on, what they bind, what they are told, and what a blocks lens says.
|
|
1208
1658
|
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());
|
|
1659
|
+
const before = JSON.stringify(lens);
|
|
1660
|
+
rewriteLens(doc, lens, r);
|
|
1661
|
+
if (JSON.stringify(lens) !== before)
|
|
1662
|
+
touched.push(lens["title"] ? `the lens "${String(lens["title"])}"` : `the ${String(lens["name"] ?? "")} lens`);
|
|
1225
1663
|
}
|
|
1226
|
-
// Pages name kinds.
|
|
1664
|
+
// Pages name kinds: in the order, the kinds the home leaves off, and where the app opens (FR-80).
|
|
1227
1665
|
if (r.t === "kind" && doc.pages) {
|
|
1228
1666
|
const before = JSON.stringify(doc.pages);
|
|
1229
|
-
|
|
1667
|
+
for (const part of ["order", "hide"])
|
|
1668
|
+
if (Array.isArray(doc.pages[part]))
|
|
1669
|
+
doc.pages[part] = doc.pages[part].map((k) => (k === r.from ? r.to : k));
|
|
1670
|
+
if (doc.pages.first === r.from)
|
|
1671
|
+
doc.pages.first = r.to;
|
|
1230
1672
|
if (JSON.stringify(doc.pages) !== before)
|
|
1231
1673
|
touched.push("the pages");
|
|
1232
1674
|
}
|
|
@@ -1237,7 +1679,7 @@ class Editor {
|
|
|
1237
1679
|
* it, rules that judge it, view blocks that show it; templates that show it fall
|
|
1238
1680
|
* back to Graview's own. Returns what went, as words.
|
|
1239
1681
|
*/
|
|
1240
|
-
dropMentions(r) {
|
|
1682
|
+
dropMentions(r, worked = false) {
|
|
1241
1683
|
const doc = this.doc;
|
|
1242
1684
|
const gone = [];
|
|
1243
1685
|
for (const [kind, spec] of Object.entries(doc.kinds)) {
|
|
@@ -1248,12 +1690,21 @@ class Editor {
|
|
|
1248
1690
|
}
|
|
1249
1691
|
}
|
|
1250
1692
|
}
|
|
1693
|
+
// What a kind works out from it cannot be worked out, and goes — with what reads that in turn (FR-83).
|
|
1694
|
+
for (const [kind, spec] of Object.entries(doc.kinds)) {
|
|
1695
|
+
for (const [name, c] of Object.entries(spec.computed ?? {})) {
|
|
1696
|
+
if (r.t === "field" && r.kind === kind && r.from === name)
|
|
1697
|
+
continue;
|
|
1698
|
+
if (exprMentions(doc, typeof c === "string" ? c : c.expr, new Set([kind]), r))
|
|
1699
|
+
gone.push(...this.dropComputed(kind, name));
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1251
1702
|
for (const [name, act] of Object.entries(doc.acts ?? {})) {
|
|
1252
1703
|
const on = subjectKinds(act);
|
|
1253
1704
|
let uses = exprMentions(doc, act.allowedWhen, on, r) || templateMentions(doc, act.refusal, on, r);
|
|
1254
1705
|
if (r.t === "edge")
|
|
1255
1706
|
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") {
|
|
1707
|
+
if (r.t === "field" && !worked) {
|
|
1257
1708
|
// An act that sets other things too loses only this field; one that did nothing else goes.
|
|
1258
1709
|
for (const t of setTargets(act)) {
|
|
1259
1710
|
if (!hasKind(t.kinds, r.kind) || t.kinds === "*")
|
|
@@ -1264,7 +1715,7 @@ class Editor {
|
|
|
1264
1715
|
if (isObject(v) && typeof v["expr"] === "string" && exprMentions(doc, v["expr"], t.kinds, r))
|
|
1265
1716
|
uses = true;
|
|
1266
1717
|
}
|
|
1267
|
-
if (act.writes && hasKind(on, r.kind) && act.writes.includes(r.from)) {
|
|
1718
|
+
if (act.writes && (hasKind(on, r.kind) || act.creates === r.kind) && act.writes.includes(r.from)) {
|
|
1268
1719
|
act.writes = act.writes.filter((w) => w !== r.from);
|
|
1269
1720
|
if (act.writes.length === 0)
|
|
1270
1721
|
delete act.writes;
|
|
@@ -1297,35 +1748,193 @@ class Editor {
|
|
|
1297
1748
|
}
|
|
1298
1749
|
if (doc.rules && Object.keys(doc.rules).length === 0)
|
|
1299
1750
|
delete doc.rules;
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1751
|
+
gone.push(...this.pruneViews(r));
|
|
1752
|
+
gone.push(...this.pruneLenses(r));
|
|
1753
|
+
return gone;
|
|
1754
|
+
}
|
|
1755
|
+
/** Computed fields being dropped, so a cycle among them ends. */
|
|
1756
|
+
dropping = new Set();
|
|
1757
|
+
/** A computed field goes, and everything that reads it: views, glances, templates, rules, and computed fields of its own. */
|
|
1758
|
+
dropComputed(kind, name) {
|
|
1759
|
+
const key = `${kind}.${name}`;
|
|
1760
|
+
if (this.dropping.has(key) || this.doc.kinds[kind]?.computed?.[name] === undefined)
|
|
1761
|
+
return [];
|
|
1762
|
+
this.dropping.add(key);
|
|
1763
|
+
const gone = [`${kind}'s ${name}`, ...this.dropMentions({ t: "field", kind, from: name, to: "\u0000" }, true)];
|
|
1764
|
+
const spec = this.doc.kinds[kind];
|
|
1765
|
+
if (spec) {
|
|
1766
|
+
delete spec.computed[name];
|
|
1767
|
+
if (Object.keys(spec.computed).length === 0)
|
|
1768
|
+
delete spec.computed;
|
|
1769
|
+
if (spec.glance?.includes(name)) {
|
|
1770
|
+
spec.glance = spec.glance.filter((field) => field !== name);
|
|
1771
|
+
if (spec.glance.length === 0)
|
|
1772
|
+
delete spec.glance;
|
|
1314
1773
|
}
|
|
1315
|
-
if (Object.keys(doc.views).length === 0)
|
|
1316
|
-
delete doc.views;
|
|
1317
1774
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1775
|
+
this.dropping.delete(key);
|
|
1776
|
+
return gone;
|
|
1777
|
+
}
|
|
1778
|
+
/** View blocks that show what is going: every kind's slots, the home, and each blocks lens's blocks are pruned (see pruneBlock). */
|
|
1779
|
+
pruneViews(r) {
|
|
1780
|
+
const doc = this.doc;
|
|
1781
|
+
const gone = [];
|
|
1782
|
+
if (!doc.views)
|
|
1783
|
+
return gone;
|
|
1784
|
+
for (const [kind, slots] of Object.entries(doc.views)) {
|
|
1785
|
+
if (kind === "home" && Array.isArray(slots)) {
|
|
1786
|
+
const before = JSON.stringify(slots);
|
|
1787
|
+
doc.views.home = mapBlocks(slots, (b) => pruneBlock(doc, b, NONE, r, null));
|
|
1788
|
+
if (JSON.stringify(doc.views.home) !== before)
|
|
1789
|
+
gone.push("part of the front page");
|
|
1790
|
+
if (doc.views.home.length === 0)
|
|
1791
|
+
delete doc.views.home;
|
|
1792
|
+
continue;
|
|
1325
1793
|
}
|
|
1794
|
+
for (const slot of VIEW_SLOTS) {
|
|
1795
|
+
if (!Array.isArray(slots[slot]))
|
|
1796
|
+
continue;
|
|
1797
|
+
const before = JSON.stringify(slots[slot]);
|
|
1798
|
+
slots[slot] = mapBlocks(slots[slot], (b) => pruneBlock(doc, b, new Set([kind]), r, kind));
|
|
1799
|
+
if (JSON.stringify(slots[slot]) !== before)
|
|
1800
|
+
gone.push(`part of the ${kind} ${slot}`);
|
|
1801
|
+
if (slots[slot].length === 0)
|
|
1802
|
+
delete slots[slot];
|
|
1803
|
+
}
|
|
1804
|
+
if (Object.keys(slots).length === 0)
|
|
1805
|
+
delete doc.views[kind];
|
|
1326
1806
|
}
|
|
1807
|
+
if (Object.keys(doc.views).length === 0)
|
|
1808
|
+
delete doc.views;
|
|
1327
1809
|
return gone;
|
|
1328
1810
|
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Lenses, after a removal. A binding to what is going is dropped; a lens
|
|
1813
|
+
* that can no longer draw — a role it draws by is gone, it stood on a
|
|
1814
|
+
* removed kind, its blocks are all gone — goes, and so does `pages.first`
|
|
1815
|
+
* when it named it. (`remove-field` and `remove-relation` are refused
|
|
1816
|
+
* before they get here when a lens draws by the thing: see lensesDrawnBy.)
|
|
1817
|
+
*/
|
|
1818
|
+
pruneLenses(r) {
|
|
1819
|
+
const doc = this.doc;
|
|
1820
|
+
const gone = [];
|
|
1821
|
+
const lenses = doc.lenses ?? [];
|
|
1822
|
+
const leaving = [];
|
|
1823
|
+
lenses.forEach((lens, index) => {
|
|
1824
|
+
if (!isObject(lens))
|
|
1825
|
+
return;
|
|
1826
|
+
let draws = true;
|
|
1827
|
+
const bindings = lens["bindings"];
|
|
1828
|
+
const required = requiredRoles(lens);
|
|
1829
|
+
if (isObject(bindings)) {
|
|
1830
|
+
if (lensBinds(lens) === "entities") {
|
|
1831
|
+
for (const [role, b] of Object.entries(bindings)) {
|
|
1832
|
+
if (!isObject(b))
|
|
1833
|
+
continue;
|
|
1834
|
+
const hit = (r.t === "kind" && b["kind"] === r.from) ||
|
|
1835
|
+
(r.t === "edge" && (b["edge"] === r.from || (Array.isArray(b["path"]) && b["path"].includes(r.from)))) ||
|
|
1836
|
+
(r.t === "field" && b["field"] === r.from && roleKind(bindings, role) === r.kind);
|
|
1837
|
+
if (!hit)
|
|
1838
|
+
continue;
|
|
1839
|
+
if (required.includes(role) || r.t === "kind")
|
|
1840
|
+
draws = false;
|
|
1841
|
+
else
|
|
1842
|
+
delete bindings[role];
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
if (r.t === "kind" && isObject(bindings[r.from])) {
|
|
1847
|
+
delete bindings[r.from];
|
|
1848
|
+
if (!Object.values(bindings).some(isObject))
|
|
1849
|
+
draws = false;
|
|
1850
|
+
}
|
|
1851
|
+
if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
1852
|
+
const b = bindings[r.kind];
|
|
1853
|
+
for (const [role, v] of Object.entries(b))
|
|
1854
|
+
if (v === r.from) {
|
|
1855
|
+
if (required.includes(role))
|
|
1856
|
+
draws = false;
|
|
1857
|
+
else
|
|
1858
|
+
delete b[role];
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if (r.t === "kind" && lens["on"] === r.from)
|
|
1864
|
+
draws = false;
|
|
1865
|
+
const options = lens["options"];
|
|
1866
|
+
if (isObject(options)) {
|
|
1867
|
+
if (Array.isArray(options["blocks"])) {
|
|
1868
|
+
const blocks = mapBlocks(options["blocks"], (b) => pruneBlock(doc, b, NONE, r, null));
|
|
1869
|
+
if (blocks.length === 0)
|
|
1870
|
+
draws = false;
|
|
1871
|
+
options["blocks"] = blocks;
|
|
1872
|
+
}
|
|
1873
|
+
for (const [key, value] of Object.entries(options))
|
|
1874
|
+
if (key !== "blocks" && value === r.from && (r.t !== "field" || lensStandsOn(lens, r.kind)))
|
|
1875
|
+
delete options[key];
|
|
1876
|
+
}
|
|
1877
|
+
if (!draws)
|
|
1878
|
+
leaving.push(index);
|
|
1879
|
+
});
|
|
1880
|
+
for (const index of leaving.reverse())
|
|
1881
|
+
gone.push(this.removeLensAt(index));
|
|
1882
|
+
return gone.reverse();
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* THE LENSES THAT DRAW BY A FIELD OR A RELATION — a role the lens cannot
|
|
1886
|
+
* draw without (a timeline's start, a coverage's link). Removing one is
|
|
1887
|
+
* refused, with a finding naming each lens and role: whether to drop the
|
|
1888
|
+
* picture or bind it to something else is the person's to say.
|
|
1889
|
+
*/
|
|
1890
|
+
lensesDrawnBy(i, r) {
|
|
1891
|
+
let refused = false;
|
|
1892
|
+
(this.doc.lenses ?? []).forEach((lens, index) => {
|
|
1893
|
+
if (!isObject(lens) || !isObject(lens["bindings"]))
|
|
1894
|
+
return;
|
|
1895
|
+
const bindings = lens["bindings"];
|
|
1896
|
+
const required = requiredRoles(lens);
|
|
1897
|
+
const title = String(lens["title"] ?? lens["name"] ?? index);
|
|
1898
|
+
const roles = [];
|
|
1899
|
+
if (lensBinds(lens) === "entities") {
|
|
1900
|
+
for (const [role, b] of Object.entries(bindings)) {
|
|
1901
|
+
if (!isObject(b) || !required.includes(role))
|
|
1902
|
+
continue;
|
|
1903
|
+
if (r.t === "field" && b["field"] === r.from && roleKind(bindings, role) === r.kind)
|
|
1904
|
+
roles.push(role);
|
|
1905
|
+
if (r.t === "edge" && (b["edge"] === r.from || (Array.isArray(b["path"]) && b["path"].includes(r.from))))
|
|
1906
|
+
roles.push(role);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
else if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
1910
|
+
for (const [role, v] of Object.entries(bindings[r.kind]))
|
|
1911
|
+
if (v === r.from && required.includes(role))
|
|
1912
|
+
roles.push(role);
|
|
1913
|
+
}
|
|
1914
|
+
for (const role of roles) {
|
|
1915
|
+
refused = true;
|
|
1916
|
+
const what = r.t === "field" ? `${r.kind}'s ${r.from}` : `the "${r.from}" relation`;
|
|
1917
|
+
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`);
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
return refused;
|
|
1921
|
+
}
|
|
1922
|
+
/** Remove the lens at an index; `pages.first` that named it is dropped, so the app opens at its home. */
|
|
1923
|
+
removeLensAt(index) {
|
|
1924
|
+
const lens = this.doc.lenses[index];
|
|
1925
|
+
const title = lens?.["title"] ? String(lens["title"]) : undefined;
|
|
1926
|
+
this.doc.lenses.splice(index, 1);
|
|
1927
|
+
if (this.doc.lenses.length === 0)
|
|
1928
|
+
delete this.doc.lenses;
|
|
1929
|
+
let said = title ? `the lens "${title}"` : `the ${String(lens?.["name"] ?? "")} lens`;
|
|
1930
|
+
if (title && this.doc.pages?.first !== undefined && namesLens(this.doc.pages.first, title)) {
|
|
1931
|
+
delete this.doc.pages.first;
|
|
1932
|
+
if (Object.keys(this.doc.pages).length === 0)
|
|
1933
|
+
delete this.doc.pages;
|
|
1934
|
+
said += " (the app opens at its home again)";
|
|
1935
|
+
}
|
|
1936
|
+
return said;
|
|
1937
|
+
}
|
|
1329
1938
|
}
|
|
1330
1939
|
/** A `$name` reference or bare argument name in a guard, renamed (for an argument that followed its field). */
|
|
1331
1940
|
function rewriteBinding(source, from, to) {
|
|
@@ -1348,15 +1957,6 @@ function swapToken(name, from, to) {
|
|
|
1348
1957
|
}
|
|
1349
1958
|
return name;
|
|
1350
1959
|
}
|
|
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
1960
|
const listOf = (xs) => (xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
1361
1961
|
/** Map every block of a view, nested ones included; a block mapped to null is dropped. */
|
|
1362
1962
|
function mapBlocks(blocks, f) {
|
|
@@ -1377,11 +1977,186 @@ function mapBlocks(blocks, f) {
|
|
|
1377
1977
|
}
|
|
1378
1978
|
return out;
|
|
1379
1979
|
}
|
|
1980
|
+
// ── words for what changed ───────────────────────────────────────────────────
|
|
1981
|
+
const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
1982
|
+
const withA = (noun) => `${/^[aeiou]/i.test(noun) ? "an" : "a"} ${noun}`;
|
|
1983
|
+
const nounOf = (doc, kind) => doc.kinds[kind]?.noun ?? words(kind);
|
|
1984
|
+
/** A kind's plural as a person reads it: "packages", "people and teams". */
|
|
1985
|
+
function pluralWords(doc, kind) {
|
|
1986
|
+
const plural = doc.kinds[kind]?.plural;
|
|
1987
|
+
return plural ? plural.charAt(0).toLowerCase() + plural.slice(1) : `${words(kind)}s`;
|
|
1988
|
+
}
|
|
1989
|
+
/** The kinds a lens binds, for saying where it stands. */
|
|
1990
|
+
function kindsBound(lens) {
|
|
1991
|
+
const bindings = lens["bindings"];
|
|
1992
|
+
if (!isObject(bindings))
|
|
1993
|
+
return [];
|
|
1994
|
+
if (lensBinds(lens) === "entities")
|
|
1995
|
+
return Object.values(bindings).flatMap((b) => (isObject(b) && typeof b["kind"] === "string" ? [b["kind"]] : []));
|
|
1996
|
+
return Object.keys(bindings).filter((k) => isObject(bindings[k]));
|
|
1997
|
+
}
|
|
1998
|
+
/** What a list of blocks is, in words: "a headline, a figure and a list of records". */
|
|
1999
|
+
function blockWords(blocks) {
|
|
2000
|
+
const said = blocks.map((b) => {
|
|
2001
|
+
if (!isObject(b))
|
|
2002
|
+
return "a block";
|
|
2003
|
+
if ("list" in b)
|
|
2004
|
+
return "a list of records";
|
|
2005
|
+
if ("headline" in b)
|
|
2006
|
+
return "a headline";
|
|
2007
|
+
if ("figure" in b)
|
|
2008
|
+
return b["figure"] === true ? "its picture" : "a figure";
|
|
2009
|
+
if ("title" in b)
|
|
2010
|
+
return "a title";
|
|
2011
|
+
if ("text" in b)
|
|
2012
|
+
return "words";
|
|
2013
|
+
if ("badge" in b)
|
|
2014
|
+
return "a badge";
|
|
2015
|
+
if ("field" in b)
|
|
2016
|
+
return `its ${String(b["field"])}`;
|
|
2017
|
+
if ("progress" in b)
|
|
2018
|
+
return "a progress bar";
|
|
2019
|
+
if ("group" in b)
|
|
2020
|
+
return "a group";
|
|
2021
|
+
if ("when" in b)
|
|
2022
|
+
return "a condition";
|
|
2023
|
+
if ("divider" in b)
|
|
2024
|
+
return "a divider";
|
|
2025
|
+
return "a block";
|
|
2026
|
+
});
|
|
2027
|
+
return listOf(said);
|
|
2028
|
+
}
|
|
2029
|
+
// ── lenses as a change sees them ─────────────────────────────────────────────
|
|
2030
|
+
/** How a lens binds: a kind's fields to its roles, or kinds, relations and fields to its roles, or nothing. */
|
|
2031
|
+
function lensBinds(lens) {
|
|
2032
|
+
if (lens["binds"] === "fields" || lens["binds"] === "entities")
|
|
2033
|
+
return lens["binds"];
|
|
2034
|
+
const name = String(lens["name"] ?? "");
|
|
2035
|
+
return isShippedLens(name) ? SHIPPED_LENSES[name].binds : "fields";
|
|
2036
|
+
}
|
|
2037
|
+
/** The roles a lens cannot draw without: its own, or the shipped lens's. */
|
|
2038
|
+
function requiredRoles(lens) {
|
|
2039
|
+
if (Array.isArray(lens["requiredRoles"]))
|
|
2040
|
+
return lens["requiredRoles"];
|
|
2041
|
+
const name = String(lens["name"] ?? "");
|
|
2042
|
+
return isShippedLens(name) ? SHIPPED_LENSES[name].requiredRoles : [];
|
|
2043
|
+
}
|
|
2044
|
+
/** 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. */
|
|
2045
|
+
function roleKind(bindings, role) {
|
|
2046
|
+
const b = bindings[role];
|
|
2047
|
+
if (isObject(b) && typeof b["kind"] === "string")
|
|
2048
|
+
return b["kind"];
|
|
2049
|
+
const on = isObject(b) && typeof b["on"] === "string" ? bindings[b["on"]] : undefined;
|
|
2050
|
+
if (isObject(on) && typeof on["kind"] === "string")
|
|
2051
|
+
return on["kind"];
|
|
2052
|
+
const first = Object.values(bindings).find((x) => isObject(x) && typeof x["kind"] === "string");
|
|
2053
|
+
return first?.["kind"];
|
|
2054
|
+
}
|
|
2055
|
+
/** Whether a lens stands on a kind: its `on`, a kind it binds fields of, or a kind a role binds. */
|
|
2056
|
+
function lensStandsOn(lens, kind) {
|
|
2057
|
+
if (lens["on"] === kind)
|
|
2058
|
+
return true;
|
|
2059
|
+
const bindings = lens["bindings"];
|
|
2060
|
+
if (!isObject(bindings))
|
|
2061
|
+
return false;
|
|
2062
|
+
if (lensBinds(lens) === "entities")
|
|
2063
|
+
return Object.values(bindings).some((b) => isObject(b) && b["kind"] === kind);
|
|
2064
|
+
return isObject(bindings[kind]);
|
|
2065
|
+
}
|
|
2066
|
+
/** Whether `pages.first` names this lens: by its title, or by its address word. */
|
|
2067
|
+
const namesLens = (first, title) => typeof first === "string" && (first.trim() === title || placeSlug(first) === placeSlug(title));
|
|
2068
|
+
/**
|
|
2069
|
+
* A rename, wherever a lens says the name: the kind it stands `on`, the
|
|
2070
|
+
* kinds and fields its bindings name, the plain words its options give
|
|
2071
|
+
* (a coverage's `rowGroup`, a board's `fillFrom`), and every expression and
|
|
2072
|
+
* template of a blocks lens — through the parser, as everywhere else.
|
|
2073
|
+
*/
|
|
2074
|
+
function rewriteLens(doc, lens, r) {
|
|
2075
|
+
if (r.t === "kind" && lens["on"] === r.from)
|
|
2076
|
+
lens["on"] = r.to;
|
|
2077
|
+
const bindings = lens["bindings"];
|
|
2078
|
+
if (isObject(bindings)) {
|
|
2079
|
+
if (lensBinds(lens) === "entities") {
|
|
2080
|
+
// Which kind each role's field is read from, before the change moves anything.
|
|
2081
|
+
const kindOf = new Map(Object.keys(bindings).map((role) => [role, roleKind(bindings, role)]));
|
|
2082
|
+
for (const [role, b] of Object.entries(bindings)) {
|
|
2083
|
+
if (!isObject(b))
|
|
2084
|
+
continue;
|
|
2085
|
+
if (r.t === "kind" && b["kind"] === r.from)
|
|
2086
|
+
b["kind"] = r.to;
|
|
2087
|
+
if (r.t === "edge" && b["edge"] === r.from)
|
|
2088
|
+
b["edge"] = r.to;
|
|
2089
|
+
if (r.t === "edge" && Array.isArray(b["path"]))
|
|
2090
|
+
b["path"] = b["path"].map((step) => (step === r.from ? r.to : step));
|
|
2091
|
+
if (r.t === "field" && b["field"] === r.from && kindOf.get(role) === r.kind)
|
|
2092
|
+
b["field"] = r.to;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
else {
|
|
2096
|
+
if (r.t === "kind" && r.from in bindings)
|
|
2097
|
+
lens["bindings"] = Object.fromEntries(Object.entries(bindings).map(([k, v]) => [k === r.from ? r.to : k, v]));
|
|
2098
|
+
if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
2099
|
+
const b = bindings[r.kind];
|
|
2100
|
+
for (const [k, v] of Object.entries(b))
|
|
2101
|
+
if (v === r.from)
|
|
2102
|
+
b[k] = r.to;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
const options = lens["options"];
|
|
2107
|
+
if (isObject(options)) {
|
|
2108
|
+
for (const [key, value] of Object.entries(options)) {
|
|
2109
|
+
if (key === "blocks" && Array.isArray(value))
|
|
2110
|
+
options["blocks"] = mapBlocks(value, (b) => rewriteBlock(doc, b, NONE, r, null));
|
|
2111
|
+
else if (value === r.from && (r.t !== "field" || lensStandsOn(lens, r.kind)))
|
|
2112
|
+
options[key] = r.to;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
/** The kinds a list block's records are, read from its source before the change: what its sort key and group are read from. */
|
|
2117
|
+
function listMembers(doc, source, ctx) {
|
|
2118
|
+
if (typeof source !== "string")
|
|
2119
|
+
return NONE;
|
|
2120
|
+
try {
|
|
2121
|
+
return kindsOf(doc, parseExpr(source), ctx);
|
|
2122
|
+
}
|
|
2123
|
+
catch (err) {
|
|
2124
|
+
if (err instanceof ExprSyntaxError)
|
|
2125
|
+
return NONE;
|
|
2126
|
+
throw err;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
/** A list's sort (`"net"`, or `{ by, direction }`) and group (`"type"`, or `{ by, headings }`): the key each names, read per member. */
|
|
2130
|
+
const keyOf = (v) => (typeof v === "string" ? v : isObject(v) && typeof v["by"] === "string" ? v["by"] : undefined);
|
|
2131
|
+
const withKey = (v, key) => (typeof v === "string" ? key : { ...v, by: key });
|
|
2132
|
+
/**
|
|
2133
|
+
* One block with a rename applied wherever it names: templates (a title, a
|
|
2134
|
+
* text, a badge, a headline), expressions (a figure, a tone, a condition, a
|
|
2135
|
+
* progress), a list's source, and its sort key and group — read from the
|
|
2136
|
+
* records the list lists, not the record the view is about — and a field
|
|
2137
|
+
* block's field. `kind` is the kind the blocks are about; null for the home
|
|
2138
|
+
* and a blocks lens.
|
|
2139
|
+
*/
|
|
1380
2140
|
function rewriteBlock(doc, b, ctx, r, kind) {
|
|
1381
2141
|
const next = { ...b };
|
|
1382
|
-
for (const key of ["title", "text", "badge"])
|
|
2142
|
+
for (const key of ["title", "text", "badge", "headline"])
|
|
1383
2143
|
if (typeof next[key] === "string")
|
|
1384
2144
|
next[key] = rewriteTemplate(doc, next[key], ctx, r);
|
|
2145
|
+
if (typeof next["figure"] === "string")
|
|
2146
|
+
next["figure"] = rewriteExpr(doc, next["figure"], ctx, r);
|
|
2147
|
+
// A figure's and a meter's label is a template (FR-99); a field's is words.
|
|
2148
|
+
if ((typeof next["figure"] === "string" || "progress" in next) && typeof next["label"] === "string")
|
|
2149
|
+
next["label"] = rewriteTemplate(doc, next["label"], ctx, r);
|
|
2150
|
+
if (typeof next["list"] === "string") {
|
|
2151
|
+
const members = listMembers(doc, next["list"], ctx);
|
|
2152
|
+
next["list"] = rewriteExpr(doc, next["list"], ctx, r);
|
|
2153
|
+
const sort = keyOf(next["sort"]);
|
|
2154
|
+
if (sort !== undefined)
|
|
2155
|
+
next["sort"] = withKey(next["sort"], rewriteExpr(doc, sort, members, r));
|
|
2156
|
+
const group = keyOf(next["group"]);
|
|
2157
|
+
if (group !== undefined)
|
|
2158
|
+
next["group"] = withKey(next["group"], renamer(doc, r)({ role: "field", kinds: members, name: group }));
|
|
2159
|
+
}
|
|
1385
2160
|
if (isObject(next["tone"]) && typeof next["tone"]["expr"] === "string")
|
|
1386
2161
|
next["tone"] = { expr: rewriteExpr(doc, next["tone"]["expr"], ctx, r) };
|
|
1387
2162
|
if (typeof next["when"] === "string")
|
|
@@ -1397,21 +2172,47 @@ function rewriteBlock(doc, b, ctx, r, kind) {
|
|
|
1397
2172
|
next["field"] = r.to;
|
|
1398
2173
|
return next;
|
|
1399
2174
|
}
|
|
1400
|
-
|
|
2175
|
+
/**
|
|
2176
|
+
* One block, with what a removal takes from it: the block goes when what it
|
|
2177
|
+
* shows cannot be shown (its template, its figure, its list's records, its
|
|
2178
|
+
* condition names the thing); a list keeps its records and loses only an
|
|
2179
|
+
* order or a grouping by a field that is gone.
|
|
2180
|
+
*/
|
|
2181
|
+
function pruneBlock(doc, b, ctx, r, kind) {
|
|
1401
2182
|
if (r.t === "field" && r.kind === kind && b["field"] === r.from)
|
|
1402
|
-
return
|
|
1403
|
-
for (const key of ["title", "text", "badge"])
|
|
2183
|
+
return null;
|
|
2184
|
+
for (const key of ["title", "text", "badge", "headline"])
|
|
1404
2185
|
if (typeof b[key] === "string" && templateMentions(doc, b[key], ctx, r))
|
|
1405
|
-
return
|
|
2186
|
+
return null;
|
|
2187
|
+
if (typeof b["figure"] === "string" && exprMentions(doc, b["figure"], ctx, r))
|
|
2188
|
+
return null;
|
|
1406
2189
|
if (isObject(b["tone"]) && exprMentions(doc, b["tone"]["expr"], ctx, r))
|
|
1407
|
-
return
|
|
2190
|
+
return null;
|
|
1408
2191
|
if (typeof b["when"] === "string" && exprMentions(doc, b["when"], ctx, r))
|
|
1409
|
-
return
|
|
2192
|
+
return null;
|
|
1410
2193
|
if (isObject(b["progress"]))
|
|
1411
2194
|
for (const k of ["value", "max"])
|
|
1412
2195
|
if (exprMentions(doc, b["progress"][k], ctx, r))
|
|
1413
|
-
return
|
|
1414
|
-
|
|
2196
|
+
return null;
|
|
2197
|
+
// A label that says what is gone goes; the figure or meter stays (FR-99).
|
|
2198
|
+
if ((typeof b["figure"] === "string" || "progress" in b) && typeof b["label"] === "string" && templateMentions(doc, b["label"], ctx, r)) {
|
|
2199
|
+
const { label: _gone, ...kept } = b;
|
|
2200
|
+
return kept;
|
|
2201
|
+
}
|
|
2202
|
+
if (typeof b["list"] === "string") {
|
|
2203
|
+
if (exprMentions(doc, b["list"], ctx, r))
|
|
2204
|
+
return null;
|
|
2205
|
+
const members = listMembers(doc, b["list"], ctx);
|
|
2206
|
+
const next = { ...b };
|
|
2207
|
+
const sort = keyOf(b["sort"]);
|
|
2208
|
+
if (sort !== undefined && exprMentions(doc, sort, members, r))
|
|
2209
|
+
delete next["sort"];
|
|
2210
|
+
const group = keyOf(b["group"]);
|
|
2211
|
+
if (group !== undefined && renamer(doc, r)({ role: "field", kinds: members, name: group }) !== group)
|
|
2212
|
+
delete next["group"];
|
|
2213
|
+
return next;
|
|
2214
|
+
}
|
|
2215
|
+
return b;
|
|
1415
2216
|
}
|
|
1416
2217
|
/**
|
|
1417
2218
|
* Apply structural edits to a document, in order. Returns the new document and what
|