@graview/core 0.1.9 → 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.
Files changed (130) hide show
  1. package/dist/app.d.ts +41 -2
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/capabilities.js +1 -1
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/cli/check/arrangement.d.ts.map +1 -1
  7. package/dist/cli/check/arrangement.js +3 -2
  8. package/dist/cli/check/arrangement.js.map +1 -1
  9. package/dist/cli/check/computed.d.ts +14 -0
  10. package/dist/cli/check/computed.d.ts.map +1 -0
  11. package/dist/cli/check/computed.js +34 -0
  12. package/dist/cli/check/computed.js.map +1 -0
  13. package/dist/cli/check/index.d.ts +1 -0
  14. package/dist/cli/check/index.d.ts.map +1 -1
  15. package/dist/cli/check/index.js +1 -0
  16. package/dist/cli/check/index.js.map +1 -1
  17. package/dist/cli/check/lenses.d.ts +10 -0
  18. package/dist/cli/check/lenses.d.ts.map +1 -1
  19. package/dist/cli/check/lenses.js +37 -19
  20. package/dist/cli/check/lenses.js.map +1 -1
  21. package/dist/cli/check/relations.d.ts.map +1 -1
  22. package/dist/cli/check/relations.js +2 -1
  23. package/dist/cli/check/relations.js.map +1 -1
  24. package/dist/cli/check/views.d.ts.map +1 -1
  25. package/dist/cli/check/views.js +8 -2
  26. package/dist/cli/check/views.js.map +1 -1
  27. package/dist/cli/check.d.ts.map +1 -1
  28. package/dist/cli/check.js +4 -1
  29. package/dist/cli/check.js.map +1 -1
  30. package/dist/cli/describe.d.ts.map +1 -1
  31. package/dist/cli/describe.js +54 -1
  32. package/dist/cli/describe.js.map +1 -1
  33. package/dist/cli/index.d.ts +1 -1
  34. package/dist/cli/index.d.ts.map +1 -1
  35. package/dist/cli/index.js +21 -1
  36. package/dist/cli/index.js.map +1 -1
  37. package/dist/describe.d.ts +3 -0
  38. package/dist/describe.d.ts.map +1 -0
  39. package/dist/describe.js +12 -0
  40. package/dist/describe.js.map +1 -0
  41. package/dist/document/blocks.d.ts +182 -0
  42. package/dist/document/blocks.d.ts.map +1 -0
  43. package/dist/document/blocks.js +382 -0
  44. package/dist/document/blocks.js.map +1 -0
  45. package/dist/document/compile.d.ts.map +1 -1
  46. package/dist/document/compile.js +57 -15
  47. package/dist/document/compile.js.map +1 -1
  48. package/dist/document/computed-values.d.ts +33 -0
  49. package/dist/document/computed-values.d.ts.map +1 -0
  50. package/dist/document/computed-values.js +60 -0
  51. package/dist/document/computed-values.js.map +1 -0
  52. package/dist/document/computed.d.ts +42 -0
  53. package/dist/document/computed.d.ts.map +1 -0
  54. package/dist/document/computed.js +173 -0
  55. package/dist/document/computed.js.map +1 -0
  56. package/dist/document/describe-place.d.ts +95 -0
  57. package/dist/document/describe-place.d.ts.map +1 -0
  58. package/dist/document/describe-place.js +257 -0
  59. package/dist/document/describe-place.js.map +1 -0
  60. package/dist/document/diff.d.ts.map +1 -1
  61. package/dist/document/diff.js +72 -3
  62. package/dist/document/diff.js.map +1 -1
  63. package/dist/document/edit.d.ts +1 -1
  64. package/dist/document/edit.d.ts.map +1 -1
  65. package/dist/document/edit.js +816 -103
  66. package/dist/document/edit.js.map +1 -1
  67. package/dist/document/expr/analyze.d.ts +22 -0
  68. package/dist/document/expr/analyze.d.ts.map +1 -1
  69. package/dist/document/expr/analyze.js +49 -3
  70. package/dist/document/expr/analyze.js.map +1 -1
  71. package/dist/document/expr/evaluate.d.ts +7 -1
  72. package/dist/document/expr/evaluate.d.ts.map +1 -1
  73. package/dist/document/expr/evaluate.js +95 -6
  74. package/dist/document/expr/evaluate.js.map +1 -1
  75. package/dist/document/expr/index.d.ts +1 -1
  76. package/dist/document/expr/index.d.ts.map +1 -1
  77. package/dist/document/expr/index.js +1 -1
  78. package/dist/document/expr/index.js.map +1 -1
  79. package/dist/document/index.d.ts +8 -2
  80. package/dist/document/index.d.ts.map +1 -1
  81. package/dist/document/index.js +4 -1
  82. package/dist/document/index.js.map +1 -1
  83. package/dist/document/rules.d.ts +2 -1
  84. package/dist/document/rules.d.ts.map +1 -1
  85. package/dist/document/rules.js +5 -1
  86. package/dist/document/rules.js.map +1 -1
  87. package/dist/document/schema.d.ts +35 -3
  88. package/dist/document/schema.d.ts.map +1 -1
  89. package/dist/document/schema.js +22 -2
  90. package/dist/document/schema.js.map +1 -1
  91. package/dist/document/template.d.ts +17 -2
  92. package/dist/document/template.d.ts.map +1 -1
  93. package/dist/document/template.js +136 -16
  94. package/dist/document/template.js.map +1 -1
  95. package/dist/document/to-document.d.ts.map +1 -1
  96. package/dist/document/to-document.js +4 -1
  97. package/dist/document/to-document.js.map +1 -1
  98. package/dist/document/views.d.ts +47 -3
  99. package/dist/document/views.d.ts.map +1 -1
  100. package/dist/document/views.js +289 -21
  101. package/dist/document/views.js.map +1 -1
  102. package/dist/index.d.ts +3 -1
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +2 -0
  105. package/dist/index.js.map +1 -1
  106. package/dist/places.d.ts +186 -0
  107. package/dist/places.d.ts.map +1 -0
  108. package/dist/places.js +358 -0
  109. package/dist/places.js.map +1 -0
  110. package/dist/scaffold/project.d.ts.map +1 -1
  111. package/dist/scaffold/project.js +9 -0
  112. package/dist/scaffold/project.js.map +1 -1
  113. package/dist/scaffold/ui.d.ts.map +1 -1
  114. package/dist/scaffold/ui.js +13 -10
  115. package/dist/scaffold/ui.js.map +1 -1
  116. package/dist/schema/define-node.d.ts +3 -0
  117. package/dist/schema/define-node.d.ts.map +1 -1
  118. package/dist/schema/define-node.js +3 -1
  119. package/dist/schema/define-node.js.map +1 -1
  120. package/dist/schema/types.d.ts +17 -0
  121. package/dist/schema/types.d.ts.map +1 -1
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.d.ts.map +1 -1
  124. package/dist/version.js +1 -1
  125. package/dist/version.js.map +1 -1
  126. package/dist/views/types.d.ts +19 -0
  127. package/dist/views/types.d.ts.map +1 -1
  128. package/dist/views/types.js +14 -0
  129. package/dist/views/types.js.map +1 -1
  130. package/package.json +5 -1
@@ -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 { parseTemplate, TemplateError } from "./template.js";
8
- import { VIEW_SLOTS } from "./views.js";
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
- "set-view": z.object({ op: z.literal("set-view"), kind: kindName, slot: z.enum(VIEW_SLOTS), blocks: z.union([z.array(z.unknown()).min(1), z.null()]) }).strict(),
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
- args = [go(x.args[0], at), go(x.args[1], kindsOf(doc, x.args[0], at))];
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, at);
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
- if ((site.role === "name" || site.role === "field") && site.name === r.from && hasKind(site.kinds, r.kind) && doc.kinds[r.kind]?.fields?.[r.from])
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
- /** A template with the rename applied inside its braces; the words around them are kept. */
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 `{${p.source}${p.format ? `|${p.format}` : ""}}`;
308
+ return `{${inner}}`;
253
309
  changed = true;
254
- return `{${printExpr(next)}${p.format ? `|${p.format}` : ""}}`;
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
- if (!this.kind(i, e.kind))
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
- for (const lens of this.doc.lenses ?? [])
683
- if (isObject(lens["bindings"]))
684
- delete lens["bindings"][kind];
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
- if (f.label)
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
- const ctx = new Set([r.t === "kind" && kind === r.to ? r.from : kind]);
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, kind));
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 bind kinds (keys) to fields (values).
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 bindings = lens["bindings"];
1210
- if (!isObject(bindings))
1211
- continue;
1212
- const before = JSON.stringify(bindings);
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
- doc.pages = deepSwap(doc.pages, r.from, r.to);
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
- if (doc.views) {
1301
- for (const [kind, slots] of Object.entries(doc.views)) {
1302
- for (const slot of VIEW_SLOTS) {
1303
- if (!Array.isArray(slots[slot]))
1304
- continue;
1305
- const before = JSON.stringify(slots[slot]);
1306
- slots[slot] = mapBlocks(slots[slot], (b) => (blockMentions(doc, b, new Set([kind]), r, kind) ? null : b));
1307
- if (slots[slot].length === 0)
1308
- delete slots[slot];
1309
- if (JSON.stringify(slots[slot]) !== before)
1310
- gone.push(`part of the ${kind} ${slot}`);
1311
- }
1312
- if (Object.keys(slots).length === 0)
1313
- delete doc.views[kind];
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
- for (const lens of doc.lenses ?? []) {
1319
- const bindings = lens["bindings"];
1320
- if (r.t === "field" && isObject(bindings) && isObject(bindings[r.kind])) {
1321
- const b = bindings[r.kind];
1322
- for (const [k, v] of Object.entries(b))
1323
- if (v === r.from)
1324
- delete b[k];
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
- function blockMentions(doc, b, ctx, r, kind) {
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 true;
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 true;
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 true;
2107
+ return null;
1408
2108
  if (typeof b["when"] === "string" && exprMentions(doc, b["when"], ctx, r))
1409
- return true;
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 true;
1414
- return false;
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