@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.
Files changed (119) hide show
  1. package/README.md +7 -7
  2. package/package.json +6 -2
  3. package/src/access.ts +18 -0
  4. package/src/anchors.ts +5 -1
  5. package/src/authoring.ts +56 -0
  6. package/src/blocks.ts +0 -8
  7. package/src/component-code.ts +6 -0
  8. package/src/contracts/agent.ts +5 -82
  9. package/src/contracts/anchors.ts +46 -0
  10. package/src/contracts/authoring-actions.ts +60 -0
  11. package/src/contracts/authoring-clients.ts +213 -0
  12. package/src/contracts/authoring.ts +180 -0
  13. package/src/contracts/checks.ts +29 -0
  14. package/src/contracts/classroom.ts +49 -0
  15. package/src/contracts/diagnostics.ts +37 -0
  16. package/src/contracts/document-audio.ts +150 -0
  17. package/src/contracts/document-comments.ts +31 -0
  18. package/src/contracts/document-history.ts +63 -0
  19. package/src/contracts/document-image.ts +32 -0
  20. package/src/contracts/document-reads.ts +57 -0
  21. package/src/contracts/document-voice.ts +44 -0
  22. package/src/contracts/document.ts +240 -0
  23. package/src/contracts/execution.ts +4 -5
  24. package/src/contracts/locale.ts +3 -0
  25. package/src/contracts/offline.ts +2 -0
  26. package/src/contracts/plugin.ts +183 -0
  27. package/src/contracts/presenter-panels.ts +17 -0
  28. package/src/contracts/preview.ts +305 -310
  29. package/src/contracts/production-assets.ts +61 -0
  30. package/src/contracts/production.ts +109 -0
  31. package/src/contracts/projects.ts +59 -0
  32. package/src/contracts/server.ts +1 -51
  33. package/src/contracts/speech-settings.ts +16 -0
  34. package/src/contracts/speech.ts +82 -70
  35. package/src/contracts/steps.ts +15 -0
  36. package/src/contracts/transcription-adoption.ts +79 -0
  37. package/src/contracts/transcription.ts +112 -0
  38. package/src/contracts/voice.ts +97 -0
  39. package/src/contracts/voiceover-execution.ts +68 -0
  40. package/src/contracts/workspace.ts +458 -0
  41. package/src/contracts.ts +28 -278
  42. package/src/diagnostics.ts +7 -0
  43. package/src/document-authoring-actions.ts +47 -0
  44. package/src/document-caption-timings.ts +64 -0
  45. package/src/document-colors.ts +25 -0
  46. package/src/document-comments.ts +118 -0
  47. package/src/document-components.ts +251 -0
  48. package/src/document-operations.ts +132 -0
  49. package/src/document-pages.ts +215 -0
  50. package/src/document-reads.ts +81 -0
  51. package/src/document-schema.ts +85 -0
  52. package/src/document-voices.ts +153 -0
  53. package/src/document.ts +252 -0
  54. package/src/durations.ts +7 -0
  55. package/src/errors.ts +8 -0
  56. package/src/execution.ts +5 -1
  57. package/src/fingerprint.ts +1 -1
  58. package/src/follow.ts +26 -30
  59. package/src/git.ts +1 -1
  60. package/src/index.ts +66 -13
  61. package/src/ir.ts +54 -5
  62. package/src/jupyter-kernel.ts +9 -0
  63. package/src/kernel.ts +38 -25
  64. package/src/keyboard.ts +43 -0
  65. package/src/locale.ts +13 -0
  66. package/src/lock.ts +4 -8
  67. package/src/logging.ts +1 -1
  68. package/src/{markdown.ts → lower-myst.ts} +2 -1
  69. package/src/node/assets.ts +23 -0
  70. package/src/node/authoring-actions.ts +50 -0
  71. package/src/node/authoring-clients.ts +14 -0
  72. package/src/node/document-audio.ts +22 -0
  73. package/src/node/document-history.ts +11 -0
  74. package/src/node/document-image.ts +8 -0
  75. package/src/node/document-reads.ts +10 -0
  76. package/src/node/document-voice.ts +7 -0
  77. package/src/node/ffmpeg.ts +29 -0
  78. package/src/node/file-version.ts +6 -0
  79. package/src/node/fonts.ts +22 -0
  80. package/src/node/index.ts +29 -133
  81. package/src/node/installed-packages.ts +11 -0
  82. package/src/node/lines.ts +24 -0
  83. package/src/node/locale.ts +7 -0
  84. package/src/node/network.ts +11 -0
  85. package/src/node/operator-speech.ts +91 -0
  86. package/src/node/packages.ts +74 -0
  87. package/src/node/paths.ts +7 -0
  88. package/src/node/projects.ts +7 -0
  89. package/src/node/resources.ts +86 -0
  90. package/src/node/revision.ts +6 -0
  91. package/src/node/run-directory.ts +70 -0
  92. package/src/node/transcription-adoption.ts +9 -0
  93. package/src/node/transcription.ts +10 -0
  94. package/src/node/voiceover-routes.ts +73 -0
  95. package/src/node/voiceover.ts +199 -0
  96. package/src/page-source.ts +359 -0
  97. package/src/parameters.ts +81 -0
  98. package/src/preview-edit.ts +18 -0
  99. package/src/preview-events.ts +57 -0
  100. package/src/preview-frame.ts +1220 -0
  101. package/src/props.ts +17 -0
  102. package/src/push-stream.ts +67 -0
  103. package/src/reading.ts +12 -7
  104. package/src/recording.ts +38 -0
  105. package/src/renderer-components.ts +30 -0
  106. package/src/responses.ts +49 -0
  107. package/src/run-outputs.ts +15 -0
  108. package/src/runner.py +3 -3
  109. package/src/services.ts +6 -6
  110. package/src/slides.ts +36 -0
  111. package/src/speech-words.ts +76 -0
  112. package/src/speech.ts +13 -0
  113. package/src/step-keys.ts +38 -0
  114. package/src/steps.ts +7 -0
  115. package/src/transcription-adoption.ts +105 -0
  116. package/src/uri-decoding.ts +8 -0
  117. package/src/urls.ts +13 -0
  118. package/src/views.ts +47 -0
  119. 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
+ }