@lincyaw/kernel 1.0.0-rc.0 → 1.0.0-rc.2
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 +7 -7
- package/package.json +6 -2
- package/src/access.ts +18 -0
- package/src/anchors.ts +5 -1
- package/src/authoring.ts +56 -0
- package/src/blocks.ts +0 -8
- package/src/component-code.ts +6 -0
- package/src/contracts/agent.ts +5 -82
- package/src/contracts/anchors.ts +46 -0
- package/src/contracts/authoring-actions.ts +60 -0
- package/src/contracts/authoring-clients.ts +213 -0
- package/src/contracts/authoring.ts +180 -0
- package/src/contracts/checks.ts +29 -0
- package/src/contracts/classroom.ts +49 -0
- package/src/contracts/diagnostics.ts +37 -0
- package/src/contracts/document-audio.ts +150 -0
- package/src/contracts/document-comments.ts +31 -0
- package/src/contracts/document-history.ts +63 -0
- package/src/contracts/document-image.ts +32 -0
- package/src/contracts/document-reads.ts +57 -0
- package/src/contracts/document-voice.ts +44 -0
- package/src/contracts/document.ts +240 -0
- package/src/contracts/execution.ts +4 -5
- package/src/contracts/locale.ts +3 -0
- package/src/contracts/offline.ts +2 -0
- package/src/contracts/plugin.ts +183 -0
- package/src/contracts/presenter-panels.ts +17 -0
- package/src/contracts/preview.ts +305 -310
- package/src/contracts/production-assets.ts +61 -0
- package/src/contracts/production.ts +109 -0
- package/src/contracts/projects.ts +59 -0
- package/src/contracts/server.ts +1 -51
- package/src/contracts/speech-settings.ts +16 -0
- package/src/contracts/speech.ts +82 -70
- package/src/contracts/steps.ts +15 -0
- package/src/contracts/transcription-adoption.ts +79 -0
- package/src/contracts/transcription.ts +112 -0
- package/src/contracts/voice.ts +97 -0
- package/src/contracts/voiceover-execution.ts +68 -0
- package/src/contracts/workspace.ts +458 -0
- package/src/contracts.ts +28 -278
- package/src/diagnostics.ts +7 -0
- package/src/document-authoring-actions.ts +47 -0
- package/src/document-caption-timings.ts +64 -0
- package/src/document-colors.ts +25 -0
- package/src/document-comments.ts +118 -0
- package/src/document-components.ts +251 -0
- package/src/document-operations.ts +132 -0
- package/src/document-pages.ts +215 -0
- package/src/document-reads.ts +81 -0
- package/src/document-schema.ts +85 -0
- package/src/document-voices.ts +153 -0
- package/src/document.ts +252 -0
- package/src/durations.ts +7 -0
- package/src/errors.ts +8 -0
- package/src/execution.ts +5 -1
- package/src/fingerprint.ts +1 -1
- package/src/follow.ts +26 -30
- package/src/git.ts +1 -1
- package/src/index.ts +66 -13
- package/src/ir.ts +54 -5
- package/src/jupyter-kernel.ts +9 -0
- package/src/kernel.ts +38 -25
- package/src/keyboard.ts +43 -0
- package/src/locale.ts +13 -0
- package/src/lock.ts +4 -8
- package/src/logging.ts +1 -1
- package/src/{markdown.ts → lower-myst.ts} +2 -1
- package/src/node/assets.ts +23 -0
- package/src/node/authoring-actions.ts +50 -0
- package/src/node/authoring-clients.ts +14 -0
- package/src/node/document-audio.ts +22 -0
- package/src/node/document-history.ts +11 -0
- package/src/node/document-image.ts +8 -0
- package/src/node/document-reads.ts +10 -0
- package/src/node/document-voice.ts +7 -0
- package/src/node/ffmpeg.ts +29 -0
- package/src/node/file-version.ts +6 -0
- package/src/node/fonts.ts +22 -0
- package/src/node/index.ts +29 -133
- package/src/node/installed-packages.ts +11 -0
- package/src/node/lines.ts +24 -0
- package/src/node/locale.ts +7 -0
- package/src/node/network.ts +11 -0
- package/src/node/operator-speech.ts +91 -0
- package/src/node/packages.ts +74 -0
- package/src/node/paths.ts +7 -0
- package/src/node/projects.ts +7 -0
- package/src/node/resources.ts +86 -0
- package/src/node/revision.ts +6 -0
- package/src/node/run-directory.ts +70 -0
- package/src/node/transcription-adoption.ts +9 -0
- package/src/node/transcription.ts +10 -0
- package/src/node/voiceover-routes.ts +73 -0
- package/src/node/voiceover.ts +199 -0
- package/src/page-source.ts +359 -0
- package/src/parameters.ts +81 -0
- package/src/preview-edit.ts +18 -0
- package/src/preview-events.ts +57 -0
- package/src/preview-frame.ts +1220 -0
- package/src/props.ts +17 -0
- package/src/push-stream.ts +67 -0
- package/src/reading.ts +12 -7
- package/src/recording.ts +38 -0
- package/src/renderer-components.ts +30 -0
- package/src/responses.ts +49 -0
- package/src/run-outputs.ts +15 -0
- package/src/runner.py +3 -3
- package/src/services.ts +6 -6
- package/src/slides.ts +36 -0
- package/src/speech-words.ts +76 -0
- package/src/speech.ts +13 -0
- package/src/step-keys.ts +38 -0
- package/src/steps.ts +7 -0
- package/src/transcription-adoption.ts +105 -0
- package/src/uri-decoding.ts +8 -0
- package/src/urls.ts +13 -0
- package/src/views.ts +47 -0
- package/src/voice.ts +86 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import jsonPatch from 'fast-json-patch'
|
|
2
|
+
import type { Operation } from 'fast-json-patch'
|
|
3
|
+
import { validateDocument } from './document.ts'
|
|
4
|
+
import { documentParameterAccess } from './parameters.ts'
|
|
5
|
+
import { DOCUMENT_PARAMETER_MAX_CHANGES, DOCUMENT_PARAMETER_MAX_PATH_DEPTH } from './contracts/authoring-actions.ts'
|
|
6
|
+
import type { DocumentComponentAction, DocumentComponentEffect, DocumentParameterChange } from './contracts/authoring-actions.ts'
|
|
7
|
+
import type { ComponentBlock, Definition, JsonObject, JsonSchema, JsonValue, TeachingDocument, VisualState } from './contracts/document.ts'
|
|
8
|
+
|
|
9
|
+
const { applyPatch, compare } = jsonPatch
|
|
10
|
+
|
|
11
|
+
export class DocumentComponentError extends Error {
|
|
12
|
+
readonly status: number
|
|
13
|
+
readonly detail?: Record<string, unknown>
|
|
14
|
+
constructor(status: number, code: string, detail?: Record<string, unknown>) { super(code); this.status = status; this.detail = detail }
|
|
15
|
+
}
|
|
16
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
17
|
+
const own = (value: object, key: string) => Object.hasOwn(value, key)
|
|
18
|
+
const fail = (status: number, code: string, detail?: Record<string, unknown>): never => { throw new DocumentComponentError(status, code, detail) }
|
|
19
|
+
const pointer = (path: readonly string[]) => '/' + path.map(key => key.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')
|
|
20
|
+
const unpointer = (path: string) => path.slice(1).split('/').map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
21
|
+
function component(document: TeachingDocument, id: string): ComponentBlock {
|
|
22
|
+
const block = own(document.blocks, id) ? document.blocks[id] : undefined
|
|
23
|
+
if (!block) return fail(404, 'component-block-missing', { blockId: id })
|
|
24
|
+
if (block.type !== 'component') return fail(422, 'component-block-type', { blockId: id })
|
|
25
|
+
return block
|
|
26
|
+
}
|
|
27
|
+
export function documentComponentSection(document: TeachingDocument, blockId: string): string {
|
|
28
|
+
component(document, blockId)
|
|
29
|
+
for (const sectionId of document.sectionOrder) {
|
|
30
|
+
const pending = [...document.sections[sectionId]!.children], seen = new Set<string>()
|
|
31
|
+
while (pending.length) {
|
|
32
|
+
const id = pending.pop()!
|
|
33
|
+
if (id === blockId) return sectionId
|
|
34
|
+
if (seen.has(id)) continue
|
|
35
|
+
seen.add(id)
|
|
36
|
+
const block = document.blocks[id]
|
|
37
|
+
if (block?.type === 'container' || block?.type === 'component') pending.push(...block.children ?? [])
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return fail(422, 'component-block-owner-missing', { blockId })
|
|
41
|
+
}
|
|
42
|
+
function stepOf(document: TeachingDocument, stepId: string, sectionId: string) {
|
|
43
|
+
const step = own(document.steps, stepId) ? document.steps[stepId] : undefined
|
|
44
|
+
if (!step) return fail(404, 'component-step-missing', { stepId })
|
|
45
|
+
if (step.sectionId !== sectionId) return fail(422, 'component-step-owner', { stepId, sectionId })
|
|
46
|
+
return step
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve the canonical shallow overlays, including all stable-step ancestors; no runtime snapshot is read. */
|
|
50
|
+
export function effectiveComponentParameters(document: TeachingDocument, blockId: string, stepId?: string): { parameters: JsonObject, visual?: VisualState, ownParameters?: JsonObject } {
|
|
51
|
+
const block = component(document, blockId)
|
|
52
|
+
if (stepId === undefined) return { parameters: structuredClone(block.parameters) }
|
|
53
|
+
const sectionId = documentComponentSection(document, blockId), target = stepOf(document, stepId, sectionId), chain: string[] = [], seen = new Set<string>()
|
|
54
|
+
let id: string | undefined = stepId
|
|
55
|
+
while (id !== undefined) {
|
|
56
|
+
if (seen.has(id)) return fail(422, 'component-step-cycle', { stepId })
|
|
57
|
+
seen.add(id); chain.unshift(id); id = stepOf(document, id, sectionId).inherits
|
|
58
|
+
}
|
|
59
|
+
let visual: VisualState | undefined
|
|
60
|
+
for (const id of chain) {
|
|
61
|
+
const value = document.steps[id]!.visuals.find(value => value.blockId === blockId)
|
|
62
|
+
if (value) visual = { ...visual, ...structuredClone(value), parameters: { ...visual?.parameters, ...value.parameters } }
|
|
63
|
+
}
|
|
64
|
+
const ownParameters = target.visuals.find(value => value.blockId === blockId)?.parameters
|
|
65
|
+
return { parameters: { ...structuredClone(block.parameters), ...structuredClone(visual?.parameters ?? {}) }, ...visual ? { visual } : {}, ...ownParameters !== undefined ? { ownParameters: structuredClone(ownParameters) } : {} }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validate domain parameter paths, then delegate actual object/array mutation to the public RFC6902 library. */
|
|
69
|
+
function patchParameters(source: JsonObject, changes: readonly DocumentParameterChange[]): JsonObject {
|
|
70
|
+
if (!changes.length || changes.length > DOCUMENT_PARAMETER_MAX_CHANGES) return fail(422, 'component-parameter-change-limit', { limits: { maxChanges: DOCUMENT_PARAMETER_MAX_CHANGES } })
|
|
71
|
+
const value = structuredClone(source)
|
|
72
|
+
for (const [index, change] of changes.entries()) {
|
|
73
|
+
const path = change.path
|
|
74
|
+
if (!path.length || path.length > DOCUMENT_PARAMETER_MAX_PATH_DEPTH || path.some(key => typeof key !== 'string' || key.length > 200 || key === '__proto__')) return fail(422, 'component-parameter-path-invalid', { index, limits: { maxPathDepth: DOCUMENT_PARAMETER_MAX_PATH_DEPTH, maxSegmentChars: 200 } })
|
|
75
|
+
let parent: JsonValue = value
|
|
76
|
+
for (const key of path.slice(0, -1)) {
|
|
77
|
+
if (!parent || typeof parent !== 'object' || !own(parent, key) || Array.isArray(parent) && !/^(0|[1-9][0-9]*)$/.test(key)) return fail(404, 'component-parameter-path-missing', { index, path })
|
|
78
|
+
parent = (parent as JsonObject)[key]!
|
|
79
|
+
}
|
|
80
|
+
const key = path.at(-1)!
|
|
81
|
+
if (!parent || typeof parent !== 'object') return fail(422, 'component-parameter-parent-type', { index, path })
|
|
82
|
+
if (Array.isArray(parent) && (!/^(0|[1-9][0-9]*)$/.test(key) || !Number.isSafeInteger(Number(key)) || Number(key) > parent.length)) return fail(422, 'component-parameter-array-index', { index, path })
|
|
83
|
+
if (change.op === 'remove' && !own(parent, key)) return fail(404, 'component-parameter-path-missing', { index, path })
|
|
84
|
+
const operation: Operation = change.op === 'remove' ? { op: 'remove', path: pointer(path) }
|
|
85
|
+
: { op: Array.isArray(parent) && own(parent, key) ? 'replace' : 'add', path: pointer(path), value: structuredClone(change.value) }
|
|
86
|
+
try { applyPatch(value, [operation], true, true, true) }
|
|
87
|
+
catch (error) { return fail(422, 'component-parameter-path-invalid', { index, path, reason: error instanceof Error ? error.name.slice(0, 80) : 'invalid-patch' }) }
|
|
88
|
+
}
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ParameterValue = JsonValue | typeof absentParameter
|
|
93
|
+
const absentParameter = Symbol('absent-parameter')
|
|
94
|
+
/** Annotation checks complement JSON Schema validity; they do not interpret schema branches or references. */
|
|
95
|
+
function guardParameterMutation(before: JsonObject, after: JsonObject, beforeSchemas: readonly JsonSchema[], afterSchemas: readonly JsonSchema[], newDefinition = false, changes: readonly DocumentParameterChange[] = [], restoring = false): void {
|
|
96
|
+
let visited = 0
|
|
97
|
+
const location = (path: string[]) => ({ path: path.slice(0, 8).map(part => part.slice(0, 80)), ...path.length > 8 || path.some(part => part.length > 80) ? { pathTruncated: true } : {} })
|
|
98
|
+
const inspect = (schemas: readonly JsonSchema[], value: JsonObject, path: string[], publicRequired: boolean) => {
|
|
99
|
+
let declared = false
|
|
100
|
+
for (const schema of schemas) {
|
|
101
|
+
const access = documentParameterAccess(schema, value, path)
|
|
102
|
+
if (!access.available) return fail(422, 'component-parameter-access-unavailable', { ...location(path), reason: access.reason?.slice(0, 120), next: 'Keep the draft and use an explicitly supported public schema path.' })
|
|
103
|
+
if (access.readOnly) return fail(422, 'component-parameter-read-only', location(path))
|
|
104
|
+
declared ||= access.public
|
|
105
|
+
}
|
|
106
|
+
if (publicRequired && !declared) return fail(422, 'component-parameter-not-public', location(path))
|
|
107
|
+
}
|
|
108
|
+
const check = (left: ParameterValue, right: ParameterValue, path: string[], beforePath = path) => {
|
|
109
|
+
if (left !== absentParameter) inspect(beforeSchemas, before, beforePath, !newDefinition)
|
|
110
|
+
if (right !== absentParameter) inspect(afterSchemas, after, path, true)
|
|
111
|
+
else if (newDefinition) inspect(afterSchemas, after, path, true)
|
|
112
|
+
}
|
|
113
|
+
const itemKeys = (values: JsonValue[]) => values.map(value => JSON.stringify(value))
|
|
114
|
+
const subsequence = (shorter: string[], longer: string[]): number[] | undefined => {
|
|
115
|
+
const matched: number[] = []; let next = 0
|
|
116
|
+
for (let index = 0; index < longer.length && next < shorter.length; index++) if (longer[index] === shorter[next]) { matched.push(index); next++ }
|
|
117
|
+
return next === shorter.length ? matched : undefined
|
|
118
|
+
}
|
|
119
|
+
// Domain set never inserts in the middle: explicit remove/append gives an exact transient index mapping.
|
|
120
|
+
const arrayLineage = (left: JsonValue[], right: JsonValue[], path: string[]) => {
|
|
121
|
+
if (!changes.length || changes.some(change => change.path.length <= path.length && change.path.every((part, index) => path[index] === part))) return
|
|
122
|
+
const relevant = changes.filter(change => path.every((part, index) => change.path[index] === part))
|
|
123
|
+
if (!relevant.length) return
|
|
124
|
+
const mapping = left.map((_, index) => index)
|
|
125
|
+
for (const change of relevant) if (change.path.length === path.length + 1) {
|
|
126
|
+
const index = Number(change.path.at(-1))
|
|
127
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > mapping.length) return
|
|
128
|
+
if (change.op === 'remove') mapping.splice(index, 1)
|
|
129
|
+
else if (index === mapping.length) mapping.push(-1)
|
|
130
|
+
}
|
|
131
|
+
return mapping.length === right.length ? mapping : undefined
|
|
132
|
+
}
|
|
133
|
+
const object = (value: ParameterValue): value is JsonObject | JsonValue[] => value !== absentParameter && value !== null && typeof value === 'object'
|
|
134
|
+
const walk = (left: ParameterValue, right: ParameterValue, path: string[], beforePath = path) => {
|
|
135
|
+
if (left !== absentParameter && right !== absentParameter && same(left, right)) return
|
|
136
|
+
if (++visited > 8192 || path.length > 128) return fail(422, 'component-parameter-access-unavailable', { reason: 'change-complexity-limit', limits: { maxChangedNodes: 8192, maxInspectionDepth: 128 }, next: 'Apply smaller public field changes separately.' })
|
|
137
|
+
check(left, right, path, beforePath)
|
|
138
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
139
|
+
const a = itemKeys(left), b = itemKeys(right)
|
|
140
|
+
// Reordering retains exact complete items, including their unknown and read-only data.
|
|
141
|
+
if (a.length === b.length && same([...a].sort(), [...b].sort())) return
|
|
142
|
+
const retained = subsequence(b, a)
|
|
143
|
+
if (retained) {
|
|
144
|
+
const keep = new Set(retained)
|
|
145
|
+
for (let index = 0; index < left.length; index++) if (!keep.has(index)) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const oldItems = subsequence(a, b)
|
|
149
|
+
if (oldItems) {
|
|
150
|
+
const keep = new Set(oldItems)
|
|
151
|
+
for (let index = 0; index < right.length; index++) if (!keep.has(index)) walk(absentParameter, right[index]!, [...path, String(index)], [...beforePath, String(index)])
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const mapping = arrayLineage(left, right, path)
|
|
155
|
+
if (mapping) {
|
|
156
|
+
const retained = new Set(mapping)
|
|
157
|
+
for (let index = 0; index < left.length; index++) if (!retained.has(index)) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
|
|
158
|
+
for (const [index, oldIndex] of mapping.entries()) walk(oldIndex < 0 ? absentParameter : left[oldIndex]!, right[index]!, [...path, String(index)], [...beforePath, String(oldIndex)])
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const oldUnmatched = a.flatMap((value, index) => value === b[index] ? [] : [{ value, index }])
|
|
162
|
+
const newUnmatched = b.flatMap((value, index) => value === a[index] ? [] : [{ value, index }])
|
|
163
|
+
if (!restoring && newUnmatched.some(item => oldUnmatched.some(old => item.value === old.value && item.index !== old.index))) return fail(422, 'component-parameter-array-change-unavailable', { ...location(path), next: 'Apply the exact item reorder and field changes in separate source-bound plans.' })
|
|
164
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
165
|
+
if (index >= right.length) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
|
|
166
|
+
else walk(index < left.length ? left[index]! : absentParameter, right[index]!, [...path, String(index)], [...beforePath, String(index)])
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (object(left) || object(right)) {
|
|
171
|
+
for (const key of new Set([...object(left) ? Object.keys(left) : [], ...object(right) ? Object.keys(right) : []])) {
|
|
172
|
+
walk(object(left) && own(left, key) ? (left as JsonObject)[key]! : absentParameter, object(right) && own(right, key) ? (right as JsonObject)[key]! : absentParameter, [...path, key], [...beforePath, key])
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const changed = new Set(compare(before, after).map(operation => unpointer(operation.path)[0]!))
|
|
177
|
+
if (!changed.size) return
|
|
178
|
+
check(before, after, [])
|
|
179
|
+
for (const key of changed) walk(own(before, key) ? before[key]! : absentParameter, own(after, key) ? after[key]! : absentParameter, [key])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function componentSchemas(definition: Definition, visual?: VisualState): JsonSchema[] {
|
|
183
|
+
const state = visual?.state ? definition.states.find(state => state.id === visual.state)?.parameterSchema : undefined
|
|
184
|
+
return [definition.parameterSchema, ...state === undefined ? [] : [state]]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Diff only changed parameter values; unchanged opaque or large fields never become request arguments. */
|
|
188
|
+
export function createDocumentParameterChanges(before: JsonObject, after: JsonObject): DocumentParameterChange[] {
|
|
189
|
+
const changes: DocumentParameterChange[] = compare(before, after).map(operation => {
|
|
190
|
+
if (operation.op === 'remove') return { op: 'remove', path: unpointer(operation.path) }
|
|
191
|
+
if (operation.op === 'add' || operation.op === 'replace') return { op: 'set', path: unpointer(operation.path), value: structuredClone(operation.value) as JsonValue }
|
|
192
|
+
return fail(422, 'component-parameter-diff-unavailable')
|
|
193
|
+
})
|
|
194
|
+
if (!changes.length) return []
|
|
195
|
+
if (changes.length <= DOCUMENT_PARAMETER_MAX_CHANGES && same(patchParameters(before, changes), after)) return changes
|
|
196
|
+
// A compare implementation may express array insertion differently from this domain's set semantics.
|
|
197
|
+
// Replacing only the affected top-level fields remains exact and is still subject to the request budget.
|
|
198
|
+
const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(key => !same(before[key], after[key])).map(key => own(after, key)
|
|
199
|
+
? { op: 'set' as const, path: [key], value: structuredClone(after[key]!) } : { op: 'remove' as const, path: [key] })
|
|
200
|
+
if (fields.length > DOCUMENT_PARAMETER_MAX_CHANGES) return fail(422, 'component-parameter-change-limit', { limits: { maxChanges: DOCUMENT_PARAMETER_MAX_CHANGES } })
|
|
201
|
+
return fields
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One source-bound component edit. Definition implementations and every unrelated authored field stay shared. */
|
|
205
|
+
export function applyDocumentComponentAction(source: TeachingDocument, action: DocumentComponentAction): { content: TeachingDocument, effect: DocumentComponentEffect } {
|
|
206
|
+
const original = component(source, action.blockId), sectionId = documentComponentSection(source, original.id), content = structuredClone(source), block = component(content, original.id)
|
|
207
|
+
const stepId = action.type === 'component.step-parameters.patch' || action.type === 'component.step-parameters.reset' ? action.stepId : undefined
|
|
208
|
+
if (action.type === 'component.parameters.patch') block.parameters = patchParameters(block.parameters, action.changes)
|
|
209
|
+
else if (action.type === 'component.definition.use') {
|
|
210
|
+
if (!own(content.definitions, action.definitionId)) return fail(404, 'component-definition-missing', { definitionId: action.definitionId })
|
|
211
|
+
block.definitionId = action.definitionId
|
|
212
|
+
if (action.changes?.length) block.parameters = patchParameters(block.parameters, action.changes)
|
|
213
|
+
} else {
|
|
214
|
+
const step = stepOf(content, action.stepId, sectionId), ownVisual = step.visuals.find(value => value.blockId === block.id)
|
|
215
|
+
if (action.type === 'component.step-parameters.reset') {
|
|
216
|
+
if (ownVisual) delete ownVisual.parameters
|
|
217
|
+
} else {
|
|
218
|
+
const effective = effectiveComponentParameters(source, block.id, action.stepId), patched = patchParameters(effective.parameters, action.changes)
|
|
219
|
+
const local = structuredClone(ownVisual?.parameters ?? {}), keys = new Set(action.changes.map(change => change.path[0]!))
|
|
220
|
+
const inherited = structuredClone(source), inheritedVisual = inherited.steps[action.stepId]!.visuals.find(value => value.blockId === block.id)
|
|
221
|
+
if (inheritedVisual) delete inheritedVisual.parameters
|
|
222
|
+
const lower = effectiveComponentParameters(inherited, block.id, action.stepId).parameters
|
|
223
|
+
for (const key of keys) {
|
|
224
|
+
if (!own(patched, key)) {
|
|
225
|
+
if (own(lower, key)) return fail(422, 'component-parameter-inherited-removal-unavailable', { path: [key], next: 'Reset the current parameter override to restore inheritance, or set a schema-valid value.' })
|
|
226
|
+
delete local[key]
|
|
227
|
+
} else Object.defineProperty(local, key, { value: structuredClone(patched[key]), configurable: true, enumerable: true, writable: true })
|
|
228
|
+
}
|
|
229
|
+
if (ownVisual) ownVisual.parameters = local
|
|
230
|
+
else step.visuals.push({ blockId: block.id, visible: effective.visual?.visible ?? false, parameters: local })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const checked = validateDocument(content)
|
|
234
|
+
if (!checked.document) return fail(422, 'component-parameters-invalid', { issueCount: checked.issues.length, issues: checked.issues.slice(0, 3).map(issue => ({ code: issue.code.slice(0, 64), path: issue.path.slice(0, 160), message: issue.message.slice(0, 160), severity: issue.severity })) })
|
|
235
|
+
const beforeDefinition = source.definitions[original.definitionId]!, afterDefinition = content.definitions[block.definitionId]!, newDefinition = original.definitionId !== block.definitionId
|
|
236
|
+
guardParameterMutation(original.parameters, block.parameters, componentSchemas(beforeDefinition), componentSchemas(afterDefinition), newDefinition, 'changes' in action ? action.changes : undefined, action.type === 'component.step-parameters.reset')
|
|
237
|
+
const steps: string[] = [], preserved: string[] = [], materialized: string[] = [], reset: string[] = []
|
|
238
|
+
for (const id of source.sections[sectionId]!.stepOrder) {
|
|
239
|
+
const before = effectiveComponentParameters(source, block.id, id), after = effectiveComponentParameters(content, block.id, id)
|
|
240
|
+
guardParameterMutation(before.parameters, after.parameters, componentSchemas(beforeDefinition, before.visual), componentSchemas(afterDefinition, after.visual), newDefinition, 'changes' in action ? action.changes : undefined, action.type === 'component.step-parameters.reset')
|
|
241
|
+
if (newDefinition || !same(before.parameters, after.parameters)) steps.push(id)
|
|
242
|
+
if (before.ownParameters !== undefined && same(before.ownParameters, after.ownParameters)) preserved.push(id)
|
|
243
|
+
else if (after.ownParameters !== undefined) materialized.push(id)
|
|
244
|
+
else if (before.ownParameters !== undefined) reset.push(id)
|
|
245
|
+
}
|
|
246
|
+
const changed = !same(source, content)
|
|
247
|
+
const references = (definitionId: string) => Object.values(source.blocks).filter(value => value.type === 'component' && value.definitionId === definitionId).map(value => value.id)
|
|
248
|
+
return { content, effect: { blockId: block.id, sectionId, layer: action.type === 'component.definition.use' ? 'definition' : stepId ? 'step' : 'base', ...stepId ? { stepId } : {}, definitionId: block.definitionId, ...block.definitionId !== original.definitionId ? { previousDefinitionId: original.definitionId } : {}, changed,
|
|
249
|
+
paths: 'changes' in action ? (action.changes ?? []).map(change => [...change.path]) : [], affected: { blocks: changed ? [block.id] : [], steps, pages: changed ? [sectionId] : [] }, overrides: { preserved, materialized, reset },
|
|
250
|
+
references: { before: references(original.definitionId), after: Object.values(content.blocks).filter(value => value.type === 'component' && value.definitionId === block.definitionId).map(value => value.id) } } }
|
|
251
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { DocumentBlock, DocumentEditOperation, TeachingDocument } from './contracts/document.ts'
|
|
2
|
+
|
|
3
|
+
/** Operations describe author intent; the caller validates the resulting complete document before committing it. */
|
|
4
|
+
export function applyDocumentOperations(source: TeachingDocument, operations: readonly DocumentEditOperation[]): TeachingDocument {
|
|
5
|
+
if (!operations.length || operations.length > 1000) throw new Error('document-operation-count')
|
|
6
|
+
const document = structuredClone(source)
|
|
7
|
+
const block = (id: string): DocumentBlock => {
|
|
8
|
+
if (!Object.hasOwn(document.blocks, id)) throw new Error('document-block-missing')
|
|
9
|
+
return document.blocks[id]!
|
|
10
|
+
}
|
|
11
|
+
const section = (id: string) => {
|
|
12
|
+
if (!Object.hasOwn(document.sections, id)) throw new Error('document-section-missing')
|
|
13
|
+
return document.sections[id]!
|
|
14
|
+
}
|
|
15
|
+
const step = (id: string) => {
|
|
16
|
+
if (!Object.hasOwn(document.steps, id)) throw new Error('document-step-missing')
|
|
17
|
+
return document.steps[id]!
|
|
18
|
+
}
|
|
19
|
+
const insert = (items: string[], index: number, id: string) => {
|
|
20
|
+
if (!Number.isInteger(index) || index < 0 || index > items.length || items.includes(id)) throw new Error('document-order-invalid')
|
|
21
|
+
items.splice(index, 0, id)
|
|
22
|
+
}
|
|
23
|
+
const descendants = (id: string, seen = new Set<string>()): Set<string> => {
|
|
24
|
+
if (seen.has(id)) throw new Error('document-tree-cycle')
|
|
25
|
+
seen.add(id)
|
|
26
|
+
const value = block(id)
|
|
27
|
+
if (value.type === 'container' || value.type === 'component') for (const child of value.children ?? []) descendants(child, seen)
|
|
28
|
+
return seen
|
|
29
|
+
}
|
|
30
|
+
const children = (sectionId: string, parentId?: string): string[] => {
|
|
31
|
+
const owner = section(sectionId)
|
|
32
|
+
if (parentId === undefined) return owner.children
|
|
33
|
+
const parent = block(parentId)
|
|
34
|
+
if (parent.type !== 'container' || !owner.children.some(id => descendants(id).has(parentId))) throw new Error('document-parent-invalid')
|
|
35
|
+
return parent.children
|
|
36
|
+
}
|
|
37
|
+
const detach = (id: string) => {
|
|
38
|
+
const parents = [
|
|
39
|
+
...Object.values(document.sections).map(value => value.children),
|
|
40
|
+
...Object.values(document.blocks).flatMap(value => value.type === 'container' || value.type === 'component' && value.children ? [value.children!] : []),
|
|
41
|
+
].filter(items => items.includes(id))
|
|
42
|
+
if (parents.length !== 1) throw new Error('document-parent-invalid')
|
|
43
|
+
parents[0]!.splice(parents[0]!.indexOf(id), 1)
|
|
44
|
+
}
|
|
45
|
+
const put = <T>(into: Record<string, T>, id: string, value: T) => {
|
|
46
|
+
if (!id || ['__proto__', 'prototype', 'constructor'].includes(id)) throw new Error('document-id-invalid')
|
|
47
|
+
Object.defineProperty(into, id, { value: structuredClone(value), enumerable: true, configurable: true, writable: true })
|
|
48
|
+
}
|
|
49
|
+
for (const operation of operations) {
|
|
50
|
+
switch (operation.type) {
|
|
51
|
+
case 'insertBlock': {
|
|
52
|
+
if (Object.hasOwn(document.blocks, operation.block.id)) throw new Error('document-id-repeated')
|
|
53
|
+
const list = children(operation.sectionId, operation.parentId)
|
|
54
|
+
insert(list, operation.index, operation.block.id)
|
|
55
|
+
put(document.blocks, operation.block.id, operation.block)
|
|
56
|
+
// New document content is visible by default. Later author operations
|
|
57
|
+
// may explicitly change its teaching states in this same transaction.
|
|
58
|
+
for (const id of section(operation.sectionId).stepOrder) step(id).visuals.push({ blockId: operation.block.id, visible: true })
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
case 'moveBlock': {
|
|
62
|
+
const moved = descendants(operation.blockId)
|
|
63
|
+
if (operation.parentId && moved.has(operation.parentId)) throw new Error('document-tree-cycle')
|
|
64
|
+
const list = children(operation.sectionId, operation.parentId)
|
|
65
|
+
detach(operation.blockId)
|
|
66
|
+
insert(list, operation.index, operation.blockId)
|
|
67
|
+
const targetSteps = new Set(section(operation.sectionId).stepOrder)
|
|
68
|
+
for (const value of Object.values(document.steps)) {
|
|
69
|
+
if (!targetSteps.has(value.id)) value.visuals = value.visuals.filter(visual => !moved.has(visual.blockId))
|
|
70
|
+
else for (const id of moved) if (!value.visuals.some(visual => visual.blockId === id)) value.visuals.push({ blockId: id, visible: true })
|
|
71
|
+
}
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
case 'deleteBlock': {
|
|
75
|
+
const removed = descendants(operation.blockId)
|
|
76
|
+
for (const narration of Object.values(document.narrations)) if (narration.content.some(node => node.type === 'cue' && node.target && removed.has(node.target.blockId))) throw new Error('document-block-referenced-by-cue')
|
|
77
|
+
const identities = new Set<string>(removed)
|
|
78
|
+
const visit = (value: unknown): void => {
|
|
79
|
+
if (Array.isArray(value)) { value.forEach(visit); return }
|
|
80
|
+
if (!value || typeof value !== 'object') return
|
|
81
|
+
for (const [key, child] of Object.entries(value)) { if (key === 'id' && typeof child === 'string') identities.add(child); else visit(child) }
|
|
82
|
+
}
|
|
83
|
+
for (const id of removed) visit(block(id))
|
|
84
|
+
// A deleted range becomes an unanchored thread; its discussion survives.
|
|
85
|
+
for (const thread of Object.values(document.comments ?? {})) if (thread.anchor && (identities.has(thread.anchor.from.blockId) || identities.has(thread.anchor.to.blockId))) delete thread.anchor
|
|
86
|
+
detach(operation.blockId)
|
|
87
|
+
for (const id of removed) delete document.blocks[id]
|
|
88
|
+
for (const value of Object.values(document.steps)) value.visuals = value.visuals.filter(visual => !removed.has(visual.blockId))
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
case 'updateText': {
|
|
92
|
+
const value = block(operation.blockId)
|
|
93
|
+
if (value.type !== 'text') throw new Error('document-block-type')
|
|
94
|
+
value.content = structuredClone(operation.content)
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
case 'updateSection': {
|
|
98
|
+
const value = section(operation.sectionId)
|
|
99
|
+
if (operation.title !== undefined) value.title = operation.title
|
|
100
|
+
if (operation.layout !== undefined) value.layout = structuredClone(operation.layout)
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
case 'setStepState': {
|
|
104
|
+
const value = step(operation.stepId)
|
|
105
|
+
value.visuals = structuredClone(operation.visuals)
|
|
106
|
+
if (operation.inherits !== undefined) value.inherits = operation.inherits
|
|
107
|
+
else delete value.inherits
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
case 'setNarration': {
|
|
111
|
+
put(document.narrations, operation.narration.id, operation.narration)
|
|
112
|
+
if (operation.stepId) {
|
|
113
|
+
const value = step(operation.stepId)
|
|
114
|
+
if (!value.narrationIds.includes(operation.narration.id)) insert(value.narrationIds, operation.index ?? value.narrationIds.length, operation.narration.id)
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
case 'setTiming':
|
|
119
|
+
if (operation.stepId && operation.sectionId) throw new Error('document-timing-scope')
|
|
120
|
+
if (operation.stepId) { step(operation.stepId); put(document.production.stepOverrides, operation.stepId, operation.value) }
|
|
121
|
+
else if (operation.sectionId) { section(operation.sectionId); document.production.sectionOverrides ??= {}; put(document.production.sectionOverrides, operation.sectionId, operation.value) }
|
|
122
|
+
else document.production.defaults = structuredClone(operation.value)
|
|
123
|
+
break
|
|
124
|
+
case 'replaceAsset':
|
|
125
|
+
if (!Object.hasOwn(document.assets, operation.asset.id)) throw new Error('document-asset-missing')
|
|
126
|
+
put(document.assets, operation.asset.id, operation.asset)
|
|
127
|
+
break
|
|
128
|
+
default: throw new Error('document-operation-unsupported')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return document
|
|
132
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { ListItemNode, RichTextNode, TableCellNode, TableRowNode, TeachingDocument } from './contracts/document.ts'
|
|
2
|
+
import type { DocumentPageDeletionImpact, DocumentPageIds } from './contracts/authoring.ts'
|
|
3
|
+
import type { PageDeletePlan } from './contracts/workspace.ts'
|
|
4
|
+
|
|
5
|
+
export const DOCUMENT_PAGE_DEFAULT_TIMING = { minimumHoldMs: 4000, trailingPauseMs: 500 } as const
|
|
6
|
+
|
|
7
|
+
/** The native insertion and server preflight reserve the same declared page identities. */
|
|
8
|
+
export function allocateDocumentPage(document: TeachingDocument, allocate: () => string): DocumentPageIds {
|
|
9
|
+
const used = documentIds(document)
|
|
10
|
+
for (const id of document.sectionOrder) used.add(`page:${id}`)
|
|
11
|
+
const next = () => {
|
|
12
|
+
const id = allocate()
|
|
13
|
+
if (!id || id.length > 200 || used.has(id) || used.has(`page:${id}`)) throw new Error('page-id-collision')
|
|
14
|
+
used.add(id); return id
|
|
15
|
+
}
|
|
16
|
+
const sectionId = next(); used.add(`page:${sectionId}`)
|
|
17
|
+
return { sectionId, blockId: next(), nodeId: next(), stepId: next() }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function documentPageDefaults({ sectionId, blockId, nodeId, stepId }: DocumentPageIds, title: string) {
|
|
21
|
+
return {
|
|
22
|
+
section: { id: sectionId, title, children: [blockId], stepOrder: [stepId], layout: { type: 'stack' as const } },
|
|
23
|
+
block: { id: blockId, type: 'text' as const, content: [{ id: nodeId, type: 'paragraph' as const, children: [] }] },
|
|
24
|
+
step: { id: stepId, sectionId, visuals: [{ blockId, visible: true }], narrationIds: [] },
|
|
25
|
+
timing: { ...DOCUMENT_PAGE_DEFAULT_TIMING },
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createDocumentPage(source: TeachingDocument, after: string | null, title: string, allocate: () => string): { document: TeachingDocument, ids: DocumentPageIds } {
|
|
30
|
+
if (after !== null && (!source.sections[after] || !source.sectionOrder.includes(after))) throw new Error('page-boundary')
|
|
31
|
+
if (!title.trim()) throw new Error('page-title-required')
|
|
32
|
+
const ids = allocateDocumentPage(source, allocate), page = documentPageDefaults(ids, title.trim()), document = structuredClone(source)
|
|
33
|
+
document.sections[ids.sectionId] = page.section; document.blocks[ids.blockId] = page.block; document.steps[ids.stepId] = page.step
|
|
34
|
+
document.production.stepOverrides[ids.stepId] = page.timing
|
|
35
|
+
document.sectionOrder.splice(after === null ? document.sectionOrder.length : document.sectionOrder.indexOf(after) + 1, 0, ids.sectionId)
|
|
36
|
+
return { document, ids }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renameDocumentPage(source: TeachingDocument, section: string, title: string): TeachingDocument {
|
|
40
|
+
if (!source.sections[section] || !source.sectionOrder.includes(section)) throw new Error('page-boundary')
|
|
41
|
+
if (!title.trim()) throw new Error('page-title-required')
|
|
42
|
+
const document = structuredClone(source)
|
|
43
|
+
document.sections[section]!.title = title.trim()
|
|
44
|
+
return document
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function moveDocumentPage(source: TeachingDocument, section: string, before: string | null): TeachingDocument {
|
|
48
|
+
if (!source.sections[section] || !source.sectionOrder.includes(section) || before !== null && (!source.sections[before] || !source.sectionOrder.includes(before))) throw new Error('page-boundary')
|
|
49
|
+
const document = structuredClone(source)
|
|
50
|
+
if (before !== section) {
|
|
51
|
+
document.sectionOrder = document.sectionOrder.filter(id => id !== section)
|
|
52
|
+
document.sectionOrder.splice(before === null ? document.sectionOrder.length : document.sectionOrder.indexOf(before), 0, section)
|
|
53
|
+
}
|
|
54
|
+
return document
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function pageContents(document: TeachingDocument, section: string): Set<string> {
|
|
58
|
+
const ids = new Set<string>()
|
|
59
|
+
const visit = (id: string) => { if (ids.has(id)) return; ids.add(id); const block = document.blocks[id]; if (block?.type === 'container' || block?.type === 'component') block.children?.forEach(visit) }
|
|
60
|
+
document.sections[section]?.children.forEach(visit)
|
|
61
|
+
return ids
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Deletion only owns declared entities. Opaque plugin data is never interpreted as identity. */
|
|
65
|
+
export function pageDeletionImpact(document: TeachingDocument, section: string): DocumentPageDeletionImpact {
|
|
66
|
+
const page = document.sections[section]
|
|
67
|
+
if (!page || !document.sectionOrder.includes(section)) throw new Error('page-boundary')
|
|
68
|
+
const blocks = pageContents(document, section), steps = new Set(page.stepOrder)
|
|
69
|
+
const candidates = new Set(page.stepOrder.flatMap(id => document.steps[id]!.narrationIds))
|
|
70
|
+
const outsideSteps = Object.values(document.steps).filter(step => !steps.has(step.id)), uses = new Map<string, string[]>()
|
|
71
|
+
for (const step of outsideSteps) for (const id of step.narrationIds) { const list = uses.get(id) ?? []; list.push(step.id); uses.set(id, list) }
|
|
72
|
+
const usedElsewhere = new Set(uses.keys())
|
|
73
|
+
const removed = new Set([...candidates].filter(id => !usedElsewhere.has(id))), cues: string[] = []
|
|
74
|
+
const references: DocumentPageDeletionImpact['references'] = []
|
|
75
|
+
for (const [id, narration] of Object.entries(document.narrations)) for (const node of narration.content) if (node.type === 'cue') {
|
|
76
|
+
if (removed.has(id)) cues.push(node.id)
|
|
77
|
+
else if (node.target && blocks.has(node.target.blockId)) references.push({ type: 'cue', narrationId: id, cueId: node.id, blockId: node.target.blockId, stepIds: uses.get(id) ?? [] })
|
|
78
|
+
}
|
|
79
|
+
for (const step of outsideSteps) if (step.inherits && steps.has(step.inherits)) references.push({ type: 'inheritance', stepId: step.id, inheritedStepId: step.inherits })
|
|
80
|
+
const targets = new Set<string>(blocks)
|
|
81
|
+
if (document.sectionOrder.length > 1) targets.add(`page:${section}`)
|
|
82
|
+
const rich = (node: RichTextNode | ListItemNode | TableRowNode | TableCellNode): void => {
|
|
83
|
+
targets.add(node.id)
|
|
84
|
+
switch (node.type) {
|
|
85
|
+
case 'list': case 'listItem': case 'blockquote': case 'table': case 'tableRow': node.children.forEach(rich); break
|
|
86
|
+
case 'extension': node.fallback.forEach(rich); break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const id of blocks) { const block = document.blocks[id]; if (block?.type === 'text') block.content.forEach(rich) }
|
|
90
|
+
return { sectionId: section, retainedEmpty: document.sectionOrder.length === 1, blocks: [...blocks], steps: [...steps], narrations: [...removed], retainedNarrations: [...candidates].filter(id => !removed.has(id)), cues,
|
|
91
|
+
clips: document.production.mediaClips.filter(clip => steps.has(clip.attachment.stepId)).map(clip => clip.id),
|
|
92
|
+
detachedComments: Object.values(document.comments ?? {}).filter(thread => thread.anchor && (targets.has(thread.anchor.from.blockId) || targets.has(thread.anchor.to.blockId))).map(thread => thread.id), references }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function pageDeletion(document: TeachingDocument, directory: string, version: string, section: string): PageDeletePlan {
|
|
96
|
+
const impact = pageDeletionImpact(document, section)
|
|
97
|
+
return { document: directory, section, revision: version, source: { path: `${directory ? `${directory}/` : ''}lecture.json`, version },
|
|
98
|
+
counts: { steps: impact.steps.length, blocks: impact.blocks.length, narration: impact.narrations.length + impact.retainedNarrations.length }, last: impact.retainedEmpty, allowed: !impact.references.length,
|
|
99
|
+
...impact.references.length ? { reason: 'page-referenced', references: impact.references.map(reference => ({ document: directory, section: reference.type === 'inheritance' ? document.steps[reference.stepId]!.sectionId : reference.stepIds[0] ? document.steps[reference.stepIds[0]]!.sectionId : section, ...reference.type === 'cue' ? { block: reference.blockId } : {} })) } : {} }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function removeDocumentPage(source: TeachingDocument, section: string, allocate: () => string): TeachingDocument {
|
|
103
|
+
const impact = pageDeletionImpact(source, section)
|
|
104
|
+
if (impact.references.length) throw new Error('page-referenced')
|
|
105
|
+
const document = structuredClone(source), page = document.sections[section]!
|
|
106
|
+
for (const id of impact.blocks) delete document.blocks[id]
|
|
107
|
+
for (const id of impact.steps) { delete document.steps[id]; delete document.production.stepOverrides[id]; if (document.production.captionTimings) delete document.production.captionTimings[id] }
|
|
108
|
+
for (const id of impact.narrations) { delete document.narrations[id]; if (document.production.narrationOverrides) delete document.production.narrationOverrides[id] }
|
|
109
|
+
for (const id of impact.cues) if (document.production.cueTimings) delete document.production.cueTimings[id]
|
|
110
|
+
if (document.production.sectionOverrides) delete document.production.sectionOverrides[section]
|
|
111
|
+
const clips = new Set(impact.clips)
|
|
112
|
+
document.production.mediaClips = document.production.mediaClips.filter(clip => !clips.has(clip.id))
|
|
113
|
+
for (const id of impact.detachedComments) delete document.comments![id]!.anchor
|
|
114
|
+
if (!impact.retainedEmpty) { document.sectionOrder = document.sectionOrder.filter(id => id !== section); delete document.sections[section] }
|
|
115
|
+
else {
|
|
116
|
+
const id = allocate()
|
|
117
|
+
if (!id || id.length > 200 || documentIds(source).has(id)) throw new Error('page-id-collision')
|
|
118
|
+
page.children = []; page.stepOrder = [id]; document.steps[id] = { id, sectionId: section, visuals: [], narrationIds: [] }
|
|
119
|
+
document.production.stepOverrides[id] = { ...DOCUMENT_PAGE_DEFAULT_TIMING }
|
|
120
|
+
}
|
|
121
|
+
return document
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Copy owned entities and explicit document references; plugin payloads and discussions retain their meaning. */
|
|
125
|
+
export function duplicateDocumentPage(source: TeachingDocument, section: string, title: string, allocate: (oldId: string) => string): { document: TeachingDocument, section: string, mapping: Record<string, string> } {
|
|
126
|
+
const page = source.sections[section]
|
|
127
|
+
if (!page || !source.sectionOrder.includes(section)) throw new Error('page-boundary')
|
|
128
|
+
const document = structuredClone(source), identities = new Map<string, string>(), used = documentIds(source)
|
|
129
|
+
const copyId = (id: string) => { let next = identities.get(id); if (!next) { next = allocate(id); if (!next || next.length > 200 || used.has(next)) throw new Error('page-copy-id-collision'); used.add(next); identities.set(id, next) } return next }
|
|
130
|
+
const reference = (id: string) => identities.get(id) ?? id
|
|
131
|
+
const blocks = pageContents(source, section), narrations = new Set(page.stepOrder.flatMap(id => source.steps[id]!.narrationIds))
|
|
132
|
+
type RichEntity = RichTextNode | ListItemNode | TableRowNode | TableCellNode
|
|
133
|
+
const rich = (node: RichEntity): void => {
|
|
134
|
+
node.id = copyId(node.id)
|
|
135
|
+
switch (node.type) {
|
|
136
|
+
case 'list': case 'listItem': case 'blockquote': case 'table': case 'tableRow': node.children.forEach(rich); break
|
|
137
|
+
case 'extension': node.fallback.forEach(rich); break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const id = copyId(section)
|
|
141
|
+
for (const step of page.stepOrder) copyId(step)
|
|
142
|
+
for (const block of blocks) copyId(block)
|
|
143
|
+
for (const narration of narrations) copyId(narration)
|
|
144
|
+
for (const blockId of blocks) {
|
|
145
|
+
const block = structuredClone(source.blocks[blockId]!)
|
|
146
|
+
block.id = reference(blockId)
|
|
147
|
+
if (block.type === 'text') {
|
|
148
|
+
block.content.forEach(rich)
|
|
149
|
+
if (block.reveal) block.reveal.order = block.reveal.order.map(reference)
|
|
150
|
+
} else if (block.type === 'container' || block.type === 'component') {
|
|
151
|
+
if (block.children) block.children = block.children.map(reference)
|
|
152
|
+
} else if (block.type === 'code') {
|
|
153
|
+
if (block.highlight) block.highlight = Object.fromEntries(Object.entries(block.highlight).map(([step, value]) => [reference(step), value]))
|
|
154
|
+
if (block.origin?.highlightRegions) block.origin.highlightRegions = Object.fromEntries(Object.entries(block.origin.highlightRegions).map(([step, value]) => [reference(step), value]))
|
|
155
|
+
block.variants?.forEach(variant => { variant.id = copyId(variant.id) })
|
|
156
|
+
}
|
|
157
|
+
document.blocks[block.id] = block
|
|
158
|
+
}
|
|
159
|
+
for (const narrationId of narrations) {
|
|
160
|
+
const narration = structuredClone(source.narrations[narrationId]!)
|
|
161
|
+
narration.id = reference(narrationId)
|
|
162
|
+
for (const node of narration.content) if (node.type === 'cue') {
|
|
163
|
+
const old = node.id
|
|
164
|
+
node.id = copyId(old)
|
|
165
|
+
if (node.target) node.target.blockId = reference(node.target.blockId)
|
|
166
|
+
if (document.production.cueTimings?.[old]) document.production.cueTimings[node.id] = structuredClone(document.production.cueTimings[old]!)
|
|
167
|
+
}
|
|
168
|
+
document.narrations[narration.id] = narration
|
|
169
|
+
if (document.production.narrationOverrides?.[narrationId]) document.production.narrationOverrides[narration.id] = structuredClone(document.production.narrationOverrides[narrationId]!)
|
|
170
|
+
}
|
|
171
|
+
for (const stepId of page.stepOrder) {
|
|
172
|
+
const step = structuredClone(source.steps[stepId]!)
|
|
173
|
+
step.id = reference(stepId); step.sectionId = id
|
|
174
|
+
step.visuals.forEach(visual => { visual.blockId = reference(visual.blockId) })
|
|
175
|
+
step.narrationIds = step.narrationIds.map(reference)
|
|
176
|
+
if (step.inherits) step.inherits = reference(step.inherits)
|
|
177
|
+
document.steps[step.id] = step
|
|
178
|
+
if (document.production.stepOverrides[stepId]) document.production.stepOverrides[step.id] = structuredClone(document.production.stepOverrides[stepId]!)
|
|
179
|
+
const timing = source.production.captionTimings?.[stepId]
|
|
180
|
+
// A copied identity needs a new explicit media/time confirmation; retain the proposal as stale.
|
|
181
|
+
if (timing) {
|
|
182
|
+
document.production.captionTimings ??= {}
|
|
183
|
+
document.production.captionTimings[step.id] = { ...structuredClone(timing), narrationIds: timing.narrationIds.map(reference) }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (document.production.sectionOverrides?.[section]) document.production.sectionOverrides[id] = structuredClone(document.production.sectionOverrides[section]!)
|
|
187
|
+
for (const clip of source.production.mediaClips) if (page.stepOrder.includes(clip.attachment.stepId)) {
|
|
188
|
+
const copied = structuredClone(clip)
|
|
189
|
+
copied.id = copyId(clip.id); copied.attachment.stepId = reference(clip.attachment.stepId)
|
|
190
|
+
document.production.mediaClips.push(copied)
|
|
191
|
+
}
|
|
192
|
+
document.sections[id] = { ...structuredClone(page), id, title, children: page.children.map(reference), stepOrder: page.stepOrder.map(reference) }
|
|
193
|
+
document.sectionOrder.splice(document.sectionOrder.indexOf(section) + 1, 0, id)
|
|
194
|
+
return { document, section: id, mapping: Object.fromEntries(identities) }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Collect declared identities only; arbitrary plugin data and discussion metadata are opaque. */
|
|
198
|
+
function documentIds(document: TeachingDocument): Set<string> {
|
|
199
|
+
const ids = new Set([document.id, ...Object.keys(document.sections), ...Object.keys(document.blocks), ...Object.keys(document.steps), ...Object.keys(document.narrations), ...Object.keys(document.assets), ...Object.keys(document.definitions)])
|
|
200
|
+
const rich = (node: RichTextNode | ListItemNode | TableRowNode | TableCellNode): void => {
|
|
201
|
+
ids.add(node.id)
|
|
202
|
+
switch (node.type) {
|
|
203
|
+
case 'list': case 'listItem': case 'blockquote': case 'table': case 'tableRow': node.children.forEach(rich); break
|
|
204
|
+
case 'extension': node.fallback.forEach(rich); break
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const block of Object.values(document.blocks)) {
|
|
208
|
+
if (block.type === 'text') block.content.forEach(rich)
|
|
209
|
+
else if (block.type === 'code') block.variants?.forEach(variant => ids.add(variant.id))
|
|
210
|
+
}
|
|
211
|
+
for (const narration of Object.values(document.narrations)) for (const node of narration.content) if (node.type === 'cue') ids.add(node.id)
|
|
212
|
+
for (const clip of document.production.mediaClips) ids.add(clip.id)
|
|
213
|
+
for (const thread of Object.values(document.comments ?? {})) { ids.add(thread.id); for (const comment of thread.comments) { ids.add(comment.id); comment.content?.forEach(rich) } }
|
|
214
|
+
return ids
|
|
215
|
+
}
|