@vectoriox/iox-ui 5.60.0 → 5.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +846 -296
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/generation-core/index.d.ts +221 -55
- package/package.json +1 -1
- package/src/generation-core/ai-run.ts +40 -3
- package/src/generation-core/plan-edits.ts +251 -52
- package/src/generation-core/plan-wire.ts +544 -0
- package/src/generation-core/public-api.ts +2 -1
- package/src/generation-core/strict-schema.ts +121 -0
- package/src/generation-core/site-plan-schema.ts +0 -252
|
@@ -3,9 +3,10 @@ import {
|
|
|
3
3
|
} from './json-schema';
|
|
4
4
|
import { patternById, patternOptionalParts, patternSlots, patternTakesBinding } from './patterns';
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
sectionPatternIds,
|
|
8
|
-
|
|
6
|
+
contentTypeToWire, dataSourceToWire, expandWireContentType, expandWireDataSource, expandWireField, expandWirePage, expandWireSection, fieldToWire,
|
|
7
|
+
pageToWire, PLAN_PARTS, PlanPart, PlanWireShapeError, sectionPatternIds, sectionToWire, wireContentTypeJsonSchema, wireDataSourceJsonSchema,
|
|
8
|
+
wireFieldJsonSchema, WireOptions, wirePageJsonSchema, wireSectionJsonSchema,
|
|
9
|
+
} from './plan-wire';
|
|
9
10
|
import {
|
|
10
11
|
ContentFieldPlan, ContentTypePlan, DataSourcePlan, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType, SitePlan,
|
|
11
12
|
} from './site-plan.model';
|
|
@@ -67,10 +68,35 @@ export const PLAN_EDIT_SHAPE: Readonly<Record<`${PlanEditOp}`, ShapeEntry>> = {
|
|
|
67
68
|
[PlanEditOp.SwapPattern]: { op: "'swapPattern'", page: 'string — a page key', index: 'integer — the section', pattern: 'string — the pattern it becomes (its slots kept where the new one has them)' },
|
|
68
69
|
[PlanEditOp.SetSlot]: { op: "'setSlot'", page: 'string — a page key', index: 'integer — the section', slot: "string — one of the pattern's slots", text: "string — the words, in the owner's language and voice" },
|
|
69
70
|
[PlanEditOp.AddContentType]: { op: "'addContentType'", contentType: 'contentType — a unique name', 'dataSources?': 'dataSource[] — the list / single sources its pages read' },
|
|
70
|
-
[PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it" },
|
|
71
|
+
[PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it; absent: what the type's seed already holds for it, else ''" },
|
|
71
72
|
[PlanEditOp.SetSectionData]: { op: "'setSectionData'", page: 'string — a page key', index: 'integer — the section', binding: 'string — the list source the section repeats' },
|
|
72
73
|
};
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The plan part each op changes (vectoriox/iox-cms#47): a sentence is answered in the plan's two parts, one request each —
|
|
77
|
+
* the structure's edits first, then the pages' written knowing them.
|
|
78
|
+
*/
|
|
79
|
+
export const PLAN_EDIT_OP_PART: Readonly<Record<`${PlanEditOp}`, PlanPart>> = {
|
|
80
|
+
[PlanEditOp.AddPage]: PlanPart.Pages,
|
|
81
|
+
[PlanEditOp.RemovePage]: PlanPart.Pages,
|
|
82
|
+
[PlanEditOp.RenamePage]: PlanPart.Pages,
|
|
83
|
+
[PlanEditOp.MergePages]: PlanPart.Pages,
|
|
84
|
+
[PlanEditOp.MovePage]: PlanPart.Pages,
|
|
85
|
+
[PlanEditOp.AddSection]: PlanPart.Pages,
|
|
86
|
+
[PlanEditOp.RemoveSection]: PlanPart.Pages,
|
|
87
|
+
[PlanEditOp.MoveSection]: PlanPart.Pages,
|
|
88
|
+
[PlanEditOp.SwapPattern]: PlanPart.Pages,
|
|
89
|
+
[PlanEditOp.SetSlot]: PlanPart.Pages,
|
|
90
|
+
[PlanEditOp.SetSectionData]: PlanPart.Pages,
|
|
91
|
+
[PlanEditOp.AddContentType]: PlanPart.Structure,
|
|
92
|
+
[PlanEditOp.AddField]: PlanPart.Structure,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The ops written in one part, in `PlanEditOp` order. */
|
|
96
|
+
export function planEditOps(part: PlanPart): PlanEditOp[] {
|
|
97
|
+
return Object.values(PlanEditOp).filter(op => PLAN_EDIT_OP_PART[op] === part);
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
export enum PlanChangeKind {
|
|
75
101
|
Page = 'page',
|
|
76
102
|
Section = 'section',
|
|
@@ -206,10 +232,14 @@ function findSection(d: Draft, pageAt: number, index: unknown, c: Collector, pat
|
|
|
206
232
|
|
|
207
233
|
const setSections = (d: Draft, at: number, sections: PageSectionPlan[]) => { d.pages[at] = { ...d.pages[at], sections }; };
|
|
208
234
|
|
|
235
|
+
/** A child added to a page or type the same edits added is part of that addition — the map marks the parent only. */
|
|
236
|
+
const addedHere = (changed: ReadonlyArray<PlanChange>, kind: PlanChangeKind, at: { page?: string; name?: string }) =>
|
|
237
|
+
changed.some(ch => ch.kind === kind && ch.action === PlanChangeAction.Added && (kind === PlanChangeKind.Page ? ch.page === at.page : ch.name === at.name));
|
|
238
|
+
|
|
209
239
|
/** One handler per op — a data table, never an if-chain. */
|
|
210
240
|
const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
|
|
211
241
|
[PlanEditOp.AddPage]: (d, e, c, path, changed) => {
|
|
212
|
-
const page =
|
|
242
|
+
const page = e.page as PagePlan;
|
|
213
243
|
if (pageIndex(d, page.key) >= 0) return c.add(PlanEditIssueCode.Duplicate, `${path}.page.key`, `page "${page.key}" is already in the plan`);
|
|
214
244
|
const after = e.after === undefined ? d.pages.length - 1 : findPage(d, e.after, c, `${path}.after`);
|
|
215
245
|
if (after < 0 && e.after !== undefined) return;
|
|
@@ -257,9 +287,9 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
|
|
|
257
287
|
if (at < 0) return;
|
|
258
288
|
const sections = [...d.pages[at].sections];
|
|
259
289
|
const index = e.at === undefined ? sections.length : clamp(e.at, sections.length);
|
|
260
|
-
sections.splice(index, 0,
|
|
290
|
+
sections.splice(index, 0, e.section as PageSectionPlan);
|
|
261
291
|
setSections(d, at, sections);
|
|
262
|
-
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
|
|
292
|
+
if (!addedHere(changed, PlanChangeKind.Page, { page: d.pages[at].key })) changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
|
|
263
293
|
},
|
|
264
294
|
[PlanEditOp.RemoveSection]: (d, e, c, path, changed) => {
|
|
265
295
|
const at = findPage(d, e.page, c, `${path}.page`);
|
|
@@ -304,7 +334,7 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
|
|
|
304
334
|
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
|
|
305
335
|
},
|
|
306
336
|
[PlanEditOp.AddContentType]: (d, e, c, path, changed) => {
|
|
307
|
-
const type =
|
|
337
|
+
const type = e.contentType as ContentTypePlan;
|
|
308
338
|
if (d.contentTypes.some(t => t.name === type.name)) return c.add(PlanEditIssueCode.Duplicate, `${path}.contentType.name`, `content type "${type.name}" is already in the plan`);
|
|
309
339
|
d.contentTypes.push(type);
|
|
310
340
|
changed.push({ kind: PlanChangeKind.ContentType, action: PlanChangeAction.Added, name: type.name });
|
|
@@ -319,9 +349,9 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
|
|
|
319
349
|
if (at < 0) return c.add(PlanEditIssueCode.UnknownContentType, `${path}.contentType`, `no content type "${String(e.contentType)}" in the plan`);
|
|
320
350
|
const type = d.contentTypes[at];
|
|
321
351
|
if (type.fields.some(f => f.name === e.field.name)) return c.add(PlanEditIssueCode.Duplicate, `${path}.field.name`, `"${type.name}" already has a field "${e.field.name}"`);
|
|
322
|
-
const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? '' } : undefined;
|
|
352
|
+
const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? type.seed[e.field.name] ?? '' } : undefined;
|
|
323
353
|
d.contentTypes[at] = { ...type, fields: [...type.fields, e.field], ...(seed ? { seed } : {}) };
|
|
324
|
-
changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
|
|
354
|
+
if (!addedHere(changed, PlanChangeKind.ContentType, { name: type.name })) changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
|
|
325
355
|
},
|
|
326
356
|
[PlanEditOp.SetSectionData]: (d, e, c, path, changed) => {
|
|
327
357
|
const at = findPage(d, e.page, c, `${path}.page`);
|
|
@@ -360,7 +390,11 @@ export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>):
|
|
|
360
390
|
|
|
361
391
|
// ── The format a model writes edits in ─────────────────────────────────────────────────────────────────
|
|
362
392
|
|
|
363
|
-
|
|
393
|
+
/** Each part's answer format name. */
|
|
394
|
+
export const PLAN_EDITS_PART_SCHEMA_NAMES: Readonly<Record<PlanPart, string>> = {
|
|
395
|
+
[PlanPart.Structure]: 'plan_structure_edits',
|
|
396
|
+
[PlanPart.Pages]: 'plan_page_edits',
|
|
397
|
+
};
|
|
364
398
|
|
|
365
399
|
/** A sentence's answer: the edits, or why the ops cannot say it (one line, in the owner's language). */
|
|
366
400
|
export enum PlanEditsOutcome {
|
|
@@ -375,54 +409,219 @@ export interface PlanEditsAnswer {
|
|
|
375
409
|
reason: string;
|
|
376
410
|
}
|
|
377
411
|
|
|
378
|
-
/**
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Each part's answer, explained. The structure is asked first, so it says whether the sentence is a plan change at all
|
|
414
|
+
* (`outcome`); the pages are asked only when it is, and write only their edits.
|
|
415
|
+
*/
|
|
416
|
+
export const PLAN_EDITS_ANSWER_SHAPES: Readonly<Record<PlanPart, ShapeEntry>> = {
|
|
417
|
+
[PlanPart.Structure]: {
|
|
418
|
+
outcome: "'edits' | 'cannot' — cannot: no operation of either part (below) can do what the sentence asks",
|
|
419
|
+
edits: 'edit[] — the content types and fields the sentence adds, in order; empty when it adds none, or cannot',
|
|
420
|
+
reason: "string — when cannot, ONE short line for the owner in their language (what to do instead); else ''",
|
|
421
|
+
},
|
|
422
|
+
[PlanPart.Pages]: {
|
|
423
|
+
edits: 'edit[] — the page and section edits, in order, made after the structure edits (already applied); empty when none',
|
|
424
|
+
},
|
|
383
425
|
};
|
|
384
426
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
427
|
+
/**
|
|
428
|
+
* ONE flat edit per part on the wire (vectoriox/iox-cms#45, #47): an object whose keys are ALL required — the 13 ops as
|
|
429
|
+
* `anyOf` branches came to 395 optional parameters against the Claude API's 24, and one flat edit for all of them to 95
|
|
430
|
+
* properties, a grammar Claude refuses (≤ 60). `op` says which keys are read (`PLAN_EDIT_WIRE_KEYS`, from
|
|
431
|
+
* `PLAN_EDIT_SHAPE`); every other key holds its none. A new page, section, content type or field travels as a list of
|
|
432
|
+
* exactly one, in the plan wire's format — and an added page or content type travels WITHOUT its children: each of its
|
|
433
|
+
* sections is an `addSection` after it, each of its fields an `addField` (a new type's samples ride on its `seed`).
|
|
434
|
+
*/
|
|
435
|
+
interface EditWireKey {
|
|
436
|
+
text: string;
|
|
437
|
+
none: unknown;
|
|
438
|
+
schema(): JsonSchema;
|
|
439
|
+
/** The wire value → the edit's (default: as written). */
|
|
440
|
+
expand?(value: unknown): unknown;
|
|
441
|
+
/** The edit's value → the wire's (default: as is). */
|
|
442
|
+
toWire?(value: any): unknown;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const NO_INDEX = -1;
|
|
446
|
+
/** A new page or content type on the wire: its children are edits of their own. */
|
|
447
|
+
const CHILDLESS: WireOptions = { children: false };
|
|
448
|
+
|
|
449
|
+
/** A list holding exactly one object: none → absent, one → it (expanded), more → the list (the gate refuses it). */
|
|
450
|
+
const oneOf = (schema: () => JsonSchema, expand: (v: unknown) => unknown, toWire: (v: any) => unknown, what: string): EditWireKey => ({
|
|
451
|
+
text: `${what}[] — exactly one when the op takes it`,
|
|
452
|
+
none: [],
|
|
453
|
+
schema: () => jsonListOf(schema()),
|
|
454
|
+
expand: v => (Array.isArray(v) ? (v.length === 0 ? undefined : v.length === 1 ? expand(v[0]) : v) : v),
|
|
455
|
+
toWire: v => [toWire(v)],
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const textKey = (text: string): EditWireKey => ({ text: `string — ${text}`, none: '', schema: jsonText });
|
|
459
|
+
const indexKey = (text: string): EditWireKey => ({ text: `integer — ${text}`, none: NO_INDEX, schema: jsonInteger });
|
|
460
|
+
|
|
461
|
+
const EDIT_WIRE: Readonly<Record<string, EditWireKey>> = {
|
|
462
|
+
page: textKey('a page key'),
|
|
463
|
+
name: textKey('renamePage: its new name (the menu label)'),
|
|
464
|
+
route: textKey('renamePage: its new route'),
|
|
465
|
+
after: textKey('addPage: the key of the page it follows in the menu'),
|
|
466
|
+
into: textKey('mergePages: the page that stays'),
|
|
467
|
+
from: textKey('mergePages: the page whose sections move into it, then removed'),
|
|
468
|
+
index: indexKey('the section, 0-based on its page (addSection: the place the new one goes)'),
|
|
469
|
+
to: indexKey('movePage / moveSection: the new 0-based place'),
|
|
470
|
+
pattern: { text: 'string — swapPattern: the section pattern it becomes', none: '', schema: () => jsonOneOf(['', ...sectionPatternIds()]) },
|
|
471
|
+
slot: textKey("setSlot: one of the pattern's slots"),
|
|
472
|
+
text: textKey("setSlot: the words, in the owner's language and voice"),
|
|
473
|
+
binding: textKey('setSectionData: the list source the section repeats'),
|
|
474
|
+
contentType: textKey('addField: a content type of the plan'),
|
|
475
|
+
newPage: {
|
|
476
|
+
...oneOf(() => wirePageJsonSchema(CHILDLESS), v => expandWirePage(v, CHILDLESS), p => pageToWire(p, CHILDLESS), 'page'),
|
|
477
|
+
text: 'page[] — exactly one when the op takes it, WITHOUT sections: each is an addSection after it',
|
|
478
|
+
},
|
|
479
|
+
newSection: oneOf(wireSectionJsonSchema, expandWireSection, sectionToWire, 'section'),
|
|
480
|
+
newContentType: {
|
|
481
|
+
...oneOf(() => wireContentTypeJsonSchema(CHILDLESS), v => expandWireContentType(v, CHILDLESS), t => contentTypeToWire(t, CHILDLESS), 'contentType'),
|
|
482
|
+
text: "contentType[] — exactly one when the op takes it, WITHOUT fields: each is an addField after it (the samples' values stay in its seed)",
|
|
483
|
+
},
|
|
484
|
+
dataSources: {
|
|
485
|
+
text: 'dataSource[] — addContentType: the list / single sources its pages read', none: [],
|
|
486
|
+
schema: () => jsonListOf(wireDataSourceJsonSchema()),
|
|
487
|
+
expand: v => (Array.isArray(v) ? v.map(expandWireDataSource) : v),
|
|
488
|
+
toWire: (v: DataSourcePlan[]) => v.map(dataSourceToWire),
|
|
489
|
+
},
|
|
490
|
+
newField: oneOf(wireFieldJsonSchema, expandWireField, fieldToWire, 'field'),
|
|
491
|
+
sample: { text: "string | string[] — addField: the samples' value for it", none: '', schema: () => jsonAnyOf([jsonText(), jsonListOf(jsonText())]) },
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/** Each op's keys (`PLAN_EDIT_SHAPE`) → the wire key that carries it. */
|
|
495
|
+
export const PLAN_EDIT_WIRE_KEYS: Readonly<Record<`${PlanEditOp}`, Readonly<Record<string, string>>>> = {
|
|
496
|
+
[PlanEditOp.AddPage]: { page: 'newPage', after: 'after' },
|
|
497
|
+
[PlanEditOp.RemovePage]: { page: 'page' },
|
|
498
|
+
[PlanEditOp.RenamePage]: { page: 'page', name: 'name', route: 'route' },
|
|
499
|
+
[PlanEditOp.MergePages]: { into: 'into', from: 'from' },
|
|
500
|
+
[PlanEditOp.MovePage]: { page: 'page', to: 'to' },
|
|
501
|
+
[PlanEditOp.AddSection]: { page: 'page', section: 'newSection', at: 'index' },
|
|
502
|
+
[PlanEditOp.RemoveSection]: { page: 'page', index: 'index' },
|
|
503
|
+
[PlanEditOp.MoveSection]: { page: 'page', from: 'index', to: 'to' },
|
|
504
|
+
[PlanEditOp.SwapPattern]: { page: 'page', index: 'index', pattern: 'pattern' },
|
|
505
|
+
[PlanEditOp.SetSlot]: { page: 'page', index: 'index', slot: 'slot', text: 'text' },
|
|
506
|
+
[PlanEditOp.AddContentType]: { contentType: 'newContentType', dataSources: 'dataSources' },
|
|
507
|
+
[PlanEditOp.AddField]: { contentType: 'contentType', field: 'newField', sample: 'sample' },
|
|
508
|
+
[PlanEditOp.SetSectionData]: { page: 'page', index: 'index', binding: 'binding' },
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
/** The wire keys a part's edit carries: every key its ops read, in `EDIT_WIRE` order. */
|
|
512
|
+
export function planEditWireKeys(part: PlanPart): string[] {
|
|
513
|
+
const read = new Set(planEditOps(part).flatMap(op => Object.values(PLAN_EDIT_WIRE_KEYS[op])));
|
|
514
|
+
return Object.keys(EDIT_WIRE).filter(k => read.has(k));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const isEditWireNone = (wireKey: string, v: unknown) => {
|
|
518
|
+
const none = EDIT_WIRE[wireKey].none;
|
|
519
|
+
return v === undefined || (Array.isArray(none) ? Array.isArray(v) && v.length === 0 : v === none) || (wireKey === 'sample' && Array.isArray(v) && v.length === 0);
|
|
520
|
+
};
|
|
521
|
+
const noneText = (none: unknown) => (Array.isArray(none) ? '[]' : typeof none === 'string' ? "''" : String(none));
|
|
522
|
+
const opsText = (part: PlanPart) => planEditOps(part).map(op => `'${op}'`).join(' | ');
|
|
523
|
+
|
|
524
|
+
/** A part's flat edit, as a shape table: every key required, each with its none. */
|
|
525
|
+
export function planEditWireShape(part: PlanPart): ShapeEntry {
|
|
526
|
+
return {
|
|
527
|
+
op: `${opsText(part)} — which keys below are read`,
|
|
528
|
+
...Object.fromEntries(planEditWireKeys(part).map(k => [k, `${EDIT_WIRE[k].text} — ${noneText(EDIT_WIRE[k].none)} when the op does not read it`])),
|
|
401
529
|
};
|
|
402
|
-
return jsonShapedObject(entry, Object.fromEntries(shapeKeys(entry).map(key => [key, valueOf(key)])), op);
|
|
403
530
|
}
|
|
404
531
|
|
|
405
|
-
/**
|
|
406
|
-
export function
|
|
407
|
-
return
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
532
|
+
/** Per op of a part, the wire keys it reads, in `PLAN_EDIT_SHAPE`'s words (`?` = may hold its none) — what the model reads. */
|
|
533
|
+
export function planEditWireOps(part: PlanPart): Record<string, Record<string, string>> {
|
|
534
|
+
return Object.fromEntries(planEditOps(part).map(op => {
|
|
535
|
+
const entry = PLAN_EDIT_SHAPE[op];
|
|
536
|
+
const keys = PLAN_EDIT_WIRE_KEYS[op];
|
|
537
|
+
return [op, Object.fromEntries(Object.entries(keys).map(([planKey, wireKey]) => {
|
|
538
|
+
const optionalKey = `${planKey}?` in entry;
|
|
539
|
+
return [`${wireKey}${optionalKey ? '?' : ''}`, entry[optionalKey ? `${planKey}?` : planKey]];
|
|
540
|
+
}))];
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Op keys no wire key carries, or wire keys that do not exist — always empty (a spec and the builder hold it). */
|
|
545
|
+
export function planEditWireGaps(): string[] {
|
|
546
|
+
const gaps: string[] = [];
|
|
547
|
+
for (const op of OPS) {
|
|
548
|
+
const keys = PLAN_EDIT_WIRE_KEYS[op as PlanEditOp] ?? {};
|
|
549
|
+
for (const planKey of shapeKeys(PLAN_EDIT_SHAPE[op as PlanEditOp])) {
|
|
550
|
+
if (planKey === 'op') continue;
|
|
551
|
+
if (!keys[planKey]) gaps.push(`${op}.${planKey}`);
|
|
552
|
+
else if (!EDIT_WIRE[keys[planKey]]) gaps.push(`${op}.${planKey} → ${keys[planKey]}`);
|
|
553
|
+
}
|
|
554
|
+
for (const planKey of Object.keys(keys)) if (!shapeKeys(PLAN_EDIT_SHAPE[op as PlanEditOp]).includes(planKey)) gaps.push(`${op}.${planKey} (not an op key)`);
|
|
555
|
+
}
|
|
556
|
+
return gaps;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const ANSWER_SCHEMAS: Readonly<Record<string, (part: PlanPart) => JsonSchema>> = {
|
|
560
|
+
outcome: () => jsonOneOf(Object.values(PlanEditsOutcome)),
|
|
561
|
+
reason: () => jsonText(),
|
|
562
|
+
edits: part => jsonListOf(jsonShapedObject(planEditWireShape(part), {
|
|
563
|
+
op: jsonOneOf(planEditOps(part)),
|
|
564
|
+
...Object.fromEntries(planEditWireKeys(part).map(k => [k, EDIT_WIRE[k].schema()])),
|
|
565
|
+
}, `edit ${part}`)),
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/** The format one part of a sentence's answer is written in — one request each (vectoriox/iox-cms#47). */
|
|
569
|
+
export function planEditsJsonSchema(part: PlanPart): JsonSchema {
|
|
570
|
+
const gaps = planEditWireGaps();
|
|
571
|
+
if (gaps.length) throw new PlanWireShapeError(`the plan edits wire does not carry ${gaps.join(', ')}`);
|
|
572
|
+
const shape = PLAN_EDITS_ANSWER_SHAPES[part];
|
|
573
|
+
return jsonShapedObject(shape, Object.fromEntries(shapeKeys(shape).map(k => [k, ANSWER_SCHEMAS[k](part)])), `planEdits ${part}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** One flat edit → the op's edit: the keys it reads, a none left out where the op may go without it. Never repaired. */
|
|
577
|
+
export function expandWireEdit(wire: unknown): PlanEdit | unknown {
|
|
578
|
+
if (!isPlainObject(wire)) return wire;
|
|
579
|
+
const keys = PLAN_EDIT_WIRE_KEYS[wire['op'] as PlanEditOp];
|
|
580
|
+
if (!keys) return wire;
|
|
581
|
+
const entry = PLAN_EDIT_SHAPE[wire['op'] as PlanEditOp];
|
|
582
|
+
const out: PlainObject = Object.fromEntries(Object.entries(wire).filter(([k]) => k === 'op' || !(k in EDIT_WIRE)));
|
|
583
|
+
for (const [planKey, wireKey] of Object.entries(keys)) {
|
|
584
|
+
const raw = wire[wireKey];
|
|
585
|
+
if (`${planKey}?` in entry && isEditWireNone(wireKey, raw)) continue;
|
|
586
|
+
const value = EDIT_WIRE[wireKey].expand ? EDIT_WIRE[wireKey].expand!(raw) : raw;
|
|
587
|
+
if (value !== undefined) out[planKey] = value;
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** An edit's own keys → its flat wire edit, in its part's keys. */
|
|
593
|
+
function flatEdit(edit: PlanEdit): PlainObject {
|
|
594
|
+
const part = PLAN_EDIT_OP_PART[edit.op];
|
|
595
|
+
const out: PlainObject = { op: edit.op, ...Object.fromEntries(planEditWireKeys(part).map(k => [k, Array.isArray(EDIT_WIRE[k].none) ? [] : EDIT_WIRE[k].none])) };
|
|
596
|
+
for (const [planKey, wireKey] of Object.entries(PLAN_EDIT_WIRE_KEYS[edit.op])) {
|
|
597
|
+
const value = (edit as unknown as PlainObject)[planKey];
|
|
598
|
+
if (value === undefined) continue;
|
|
599
|
+
out[wireKey] = EDIT_WIRE[wireKey].toWire ? EDIT_WIRE[wireKey].toWire!(value) : value;
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
412
602
|
}
|
|
413
603
|
|
|
414
|
-
/**
|
|
604
|
+
/** The children an added page or content type travels without, as the edits that add them after it. */
|
|
605
|
+
function childEdits(edit: PlanEdit): PlanEdit[] {
|
|
606
|
+
if (edit.op === PlanEditOp.AddPage) return edit.page.sections.map(section => ({ op: PlanEditOp.AddSection, page: edit.page.key, section }));
|
|
607
|
+
if (edit.op === PlanEditOp.AddContentType) return edit.contentType.fields.map(field => ({ op: PlanEditOp.AddField, contentType: edit.contentType.name, field }));
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** An edit → the flat wire edits a model would write for it: itself, then its children (fakes and specs write them). */
|
|
612
|
+
export function planEditToWire(edit: PlanEdit): PlainObject[] {
|
|
613
|
+
return [flatEdit(edit), ...childEdits(edit).map(flatEdit)];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Edits → each part's flat wire edits, in order (what each part's answer would hold). */
|
|
617
|
+
export function planEditsToWire(edits: ReadonlyArray<PlanEdit>): Record<PlanPart, PlainObject[]> {
|
|
618
|
+
const out = Object.fromEntries(PLAN_PARTS.map(part => [part, [] as PlainObject[]])) as Record<PlanPart, PlainObject[]>;
|
|
619
|
+
for (const edit of edits) out[PLAN_EDIT_OP_PART[edit.op]].push(...planEditToWire(edit));
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** A model's answer (either part's) → edits (each flat edit expanded); never repaired. */
|
|
415
624
|
export function planEditsFromWire(answer: unknown): PlanEditsAnswer | unknown {
|
|
416
625
|
if (!isPlainObject(answer) || !Array.isArray(answer['edits'])) return answer;
|
|
417
|
-
|
|
418
|
-
if (!isPlainObject(raw)) return raw;
|
|
419
|
-
const e: PlainObject = { ...raw };
|
|
420
|
-
if (isPlainObject(e['page']) && Array.isArray((e['page'] as PlainObject)['sections'])) {
|
|
421
|
-
e['page'] = { ...(e['page'] as PlainObject), sections: ((e['page'] as PlainObject)['sections'] as unknown[]).map(sectionFromWire) };
|
|
422
|
-
}
|
|
423
|
-
if (isPlainObject(e['section'])) e['section'] = sectionFromWire(e['section']);
|
|
424
|
-
if (isPlainObject(e['contentType'])) e['contentType'] = contentTypeFromWire(e['contentType']);
|
|
425
|
-
return e;
|
|
426
|
-
});
|
|
427
|
-
return { ...answer, edits };
|
|
626
|
+
return { ...answer, edits: (answer['edits'] as unknown[]).map(expandWireEdit) };
|
|
428
627
|
}
|