@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.1

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 (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +32 -2
  3. package/package.json +41 -3
  4. package/src/access.ts +18 -0
  5. package/src/anchors.ts +58 -0
  6. package/src/authoring.ts +56 -0
  7. package/src/blocks.ts +19 -0
  8. package/src/checks.ts +35 -0
  9. package/src/component-code.ts +6 -0
  10. package/src/contracts/agent.ts +76 -0
  11. package/src/contracts/anchors.ts +46 -0
  12. package/src/contracts/authoring-actions.ts +60 -0
  13. package/src/contracts/authoring-clients.ts +213 -0
  14. package/src/contracts/authoring.ts +180 -0
  15. package/src/contracts/checks.ts +29 -0
  16. package/src/contracts/classroom.ts +49 -0
  17. package/src/contracts/diagnostics.ts +37 -0
  18. package/src/contracts/document-audio.ts +150 -0
  19. package/src/contracts/document-comments.ts +31 -0
  20. package/src/contracts/document-history.ts +63 -0
  21. package/src/contracts/document-image.ts +32 -0
  22. package/src/contracts/document-reads.ts +57 -0
  23. package/src/contracts/document-voice.ts +44 -0
  24. package/src/contracts/document.ts +240 -0
  25. package/src/contracts/execution.ts +118 -0
  26. package/src/contracts/locale.ts +3 -0
  27. package/src/contracts/offline.ts +2 -0
  28. package/src/contracts/operations.ts +8 -0
  29. package/src/contracts/plugin.ts +183 -0
  30. package/src/contracts/presenter-panels.ts +17 -0
  31. package/src/contracts/preview.ts +386 -0
  32. package/src/contracts/production-assets.ts +61 -0
  33. package/src/contracts/production.ts +109 -0
  34. package/src/contracts/projects.ts +59 -0
  35. package/src/contracts/server.ts +108 -0
  36. package/src/contracts/service.ts +8 -0
  37. package/src/contracts/speech-settings.ts +16 -0
  38. package/src/contracts/speech.ts +149 -0
  39. package/src/contracts/steps.ts +15 -0
  40. package/src/contracts/transcription-adoption.ts +79 -0
  41. package/src/contracts/transcription.ts +112 -0
  42. package/src/contracts/voice.ts +97 -0
  43. package/src/contracts/voiceover-execution.ts +68 -0
  44. package/src/contracts/workspace.ts +458 -0
  45. package/src/contracts.ts +35 -0
  46. package/src/diagnostics.ts +7 -0
  47. package/src/document-authoring-actions.ts +47 -0
  48. package/src/document-caption-timings.ts +64 -0
  49. package/src/document-colors.ts +25 -0
  50. package/src/document-comments.ts +118 -0
  51. package/src/document-components.ts +251 -0
  52. package/src/document-operations.ts +132 -0
  53. package/src/document-pages.ts +215 -0
  54. package/src/document-reads.ts +81 -0
  55. package/src/document-schema.ts +85 -0
  56. package/src/document-voices.ts +153 -0
  57. package/src/document.ts +252 -0
  58. package/src/durations.ts +7 -0
  59. package/src/errors.ts +15 -0
  60. package/src/events.ts +32 -0
  61. package/src/execution.ts +25 -0
  62. package/src/fingerprint.ts +31 -0
  63. package/src/follow.ts +33 -0
  64. package/src/git.ts +12 -0
  65. package/src/html.ts +41 -0
  66. package/src/index.ts +82 -0
  67. package/src/ir.ts +367 -0
  68. package/src/jupyter-kernel.ts +9 -0
  69. package/src/kernel.ts +190 -0
  70. package/src/keyboard.ts +43 -0
  71. package/src/locale.ts +13 -0
  72. package/src/lock.ts +81 -0
  73. package/src/logging.ts +11 -0
  74. package/src/lower-myst.ts +312 -0
  75. package/src/node/assets.ts +23 -0
  76. package/src/node/authoring-actions.ts +50 -0
  77. package/src/node/authoring-clients.ts +14 -0
  78. package/src/node/document-audio.ts +22 -0
  79. package/src/node/document-history.ts +11 -0
  80. package/src/node/document-image.ts +8 -0
  81. package/src/node/document-reads.ts +10 -0
  82. package/src/node/document-voice.ts +7 -0
  83. package/src/node/ffmpeg.ts +29 -0
  84. package/src/node/file-version.ts +6 -0
  85. package/src/node/fonts.ts +22 -0
  86. package/src/node/index.ts +29 -0
  87. package/src/node/installed-packages.ts +11 -0
  88. package/src/node/lines.ts +24 -0
  89. package/src/node/locale.ts +7 -0
  90. package/src/node/network.ts +11 -0
  91. package/src/node/operator-speech.ts +91 -0
  92. package/src/node/packages.ts +74 -0
  93. package/src/node/paths.ts +7 -0
  94. package/src/node/projects.ts +7 -0
  95. package/src/node/resources.ts +86 -0
  96. package/src/node/revision.ts +6 -0
  97. package/src/node/run-directory.ts +70 -0
  98. package/src/node/strip-types.mjs +15 -0
  99. package/src/node/transcription-adoption.ts +9 -0
  100. package/src/node/transcription.ts +10 -0
  101. package/src/node/voiceover-routes.ts +73 -0
  102. package/src/node/voiceover.ts +199 -0
  103. package/src/page-source.ts +359 -0
  104. package/src/parameters.ts +81 -0
  105. package/src/preview-edit.ts +18 -0
  106. package/src/preview-events.ts +57 -0
  107. package/src/preview-frame.ts +1220 -0
  108. package/src/props.ts +17 -0
  109. package/src/published.ts +11 -0
  110. package/src/push-stream.ts +67 -0
  111. package/src/reading.ts +96 -0
  112. package/src/recording.ts +38 -0
  113. package/src/renderer-components.ts +30 -0
  114. package/src/requests.ts +28 -0
  115. package/src/responses.ts +49 -0
  116. package/src/run-outputs.ts +15 -0
  117. package/src/runner.py +454 -0
  118. package/src/services.ts +146 -0
  119. package/src/slides.ts +36 -0
  120. package/src/speech-words.ts +76 -0
  121. package/src/speech.ts +13 -0
  122. package/src/step-keys.ts +38 -0
  123. package/src/steps.ts +7 -0
  124. package/src/transcription-adoption.ts +105 -0
  125. package/src/uri-decoding.ts +8 -0
  126. package/src/urls.ts +13 -0
  127. package/src/views.ts +47 -0
  128. package/src/voice.ts +86 -0
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ import { DOCUMENT_ENTITY_COLLECTIONS, DOCUMENT_READ_MAX_RESPONSE_CHARS } from './contracts/document-reads.ts'
2
+ import type { DocumentOutlineAnswer, DocumentOutlineEntry, DocumentOutlineQuery, DocumentPartAnswer, DocumentPartQuery } from './contracts/document-reads.ts'
3
+ import type { JsonValue, TeachingDocument } from './contracts/document.ts'
4
+ import type { CanonicalSource } from './ir.ts'
5
+ import { fnv1a64 } from './fingerprint.ts'
6
+
7
+ export class DocumentReadError extends Error {
8
+ readonly status: number
9
+ constructor(status: number, message: string) { super(message); this.status = status }
10
+ }
11
+ const fits = (value: unknown) => JSON.stringify(value, null, 2).length <= DOCUMENT_READ_MAX_RESPONSE_CHARS
12
+ const fail = (status: number, code: string): never => { throw new DocumentReadError(status, code) }
13
+ /** Offsets index decoded UTF-16 text; a valid surrogate pair is always kept together. */
14
+ function boundary(text: string, offset: number): boolean {
15
+ return !(offset > 0 && offset < text.length && /[\uD800-\uDBFF]/.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(text[offset]!))
16
+ }
17
+ function preview(text: string): NonNullable<DocumentOutlineEntry['title']> {
18
+ let end = Math.min(text.length, 80)
19
+ if (!boundary(text, end)) end--
20
+ return { preview: text.slice(0, end), complete: end === text.length, totalChars: text.length }
21
+ }
22
+
23
+ export function documentOutline(content: TeachingDocument, source: CanonicalSource, query: DocumentOutlineQuery): DocumentOutlineAnswer {
24
+ const collection = query.collection ?? 'sections', offset = query.offset ?? 0
25
+ const records = content[collection] ?? {}, ids = collection === 'sections' ? content.sectionOrder : Object.keys(records)
26
+ if (offset > ids.length) return fail(400, 'document-read-offset-invalid')
27
+ const answer: DocumentOutlineAnswer = { source, documentSchemaVersion: content.schemaVersion,
28
+ counts: Object.fromEntries(DOCUMENT_ENTITY_COLLECTIONS.map(name => [name, Object.keys(content[name] ?? {}).length])) as DocumentOutlineAnswer['counts'],
29
+ collection, present: Object.hasOwn(content, collection), offset, total: ids.length, complete: offset === ids.length, entries: [] }
30
+ for (let i = offset; i < ids.length; i++) {
31
+ const id = ids[i]!, record = records[id] as unknown as Record<string, unknown>
32
+ const entry: DocumentOutlineEntry = { id }
33
+ if (typeof record.type === 'string') entry.type = record.type
34
+ if (typeof record.sectionId === 'string') entry.sectionId = record.sectionId
35
+ if (typeof record.title === 'string') entry.title = preview(record.title)
36
+ if (collection === 'sections') entry.counts = { children: (record.children as unknown[]).length, steps: (record.stepOrder as unknown[]).length }
37
+ if (collection === 'steps') entry.counts = { narrations: (record.narrationIds as unknown[]).length }
38
+ answer.entries.push(entry)
39
+ answer.complete = i + 1 === ids.length
40
+ if (answer.complete) delete answer.nextOffset
41
+ else answer.nextOffset = i + 1
42
+ if (fits(answer)) continue
43
+ answer.entries.pop(); answer.complete = false; answer.nextOffset = i
44
+ if (!answer.entries.length) return fail(413, 'document-read-response-too-large')
45
+ break
46
+ }
47
+ if (!fits(answer)) return fail(413, 'document-read-response-too-large')
48
+ return answer
49
+ }
50
+
51
+ export function documentPart(content: TeachingDocument, source: CanonicalSource, query: DocumentPartQuery): DocumentPartAnswer {
52
+ const { target } = query
53
+ let value: unknown = content[target.collection]
54
+ if ('id' in target) {
55
+ if (!value || typeof value !== 'object' || !Object.hasOwn(value, target.id)) return fail(404, 'document-entity-not-found')
56
+ value = (value as Record<string, unknown>)[target.id]
57
+ }
58
+ for (const field of target.path ?? []) {
59
+ if (!value || typeof value !== 'object' || !Object.hasOwn(value, field) || (Array.isArray(value) && (!/^(0|[1-9][0-9]*)$/.test(field) || !Number.isSafeInteger(Number(field))))) return fail(404, 'document-field-not-found')
60
+ value = (value as Record<string, unknown>)[field]
61
+ }
62
+ if (value === undefined) return fail(404, 'document-field-not-found')
63
+ const text = JSON.stringify(value), offset = query.offset ?? 0
64
+ if (offset > text.length || !boundary(text, offset)) return fail(400, 'document-read-offset-invalid')
65
+ const base = { source, target, valueDigest: { algorithm: 'fnv1a64' as const, serialization: 'JSON.stringify' as const, value: fnv1a64(text) } }
66
+ const whole: DocumentPartAnswer = { ...base, encoding: 'value', complete: true, value: value as JsonValue }
67
+ if (offset === 0 && fits(whole)) return whole
68
+ const fragment = (end: number): DocumentPartAnswer => ({ ...base, encoding: 'json-text-fragment', offsetUnit: 'utf16', offset, ...end < text.length ? { nextOffset: end } : {}, totalChars: text.length, complete: end === text.length, text: text.slice(offset, end) })
69
+ // Find the largest prefix whose fully escaped, indented response fits the actual text budget.
70
+ let low = offset, high = text.length
71
+ while (low < high) {
72
+ const mid = Math.ceil((low + high) / 2)
73
+ if (fits(fragment(mid))) low = mid
74
+ else high = mid - 1
75
+ }
76
+ if (!boundary(text, low)) low--
77
+ if (low === offset && offset < text.length) return fail(413, 'document-read-response-too-large')
78
+ const answer = fragment(low)
79
+ if (!fits(answer)) return fail(413, 'document-read-response-too-large')
80
+ return answer
81
+ }
@@ -0,0 +1,85 @@
1
+ import type { Schema } from '@cfworker/json-schema'
2
+ import { ASSET_TITLE_MAX_LENGTH, DOCUMENT_SCHEMA_VERSION } from './contracts/document.ts'
3
+
4
+ const ref = (name: string): Schema => ({ $ref: `#/$defs/${name}` })
5
+ const string: Schema = { type: 'string' }
6
+ const id: Schema = { type: 'string', minLength: 1, maxLength: 200, pattern: '^[^\\u0000-\\u001f]+$', not: { enum: ['__proto__', 'constructor', 'prototype'] } }
7
+ const positive: Schema = { type: 'number', exclusiveMinimum: 0 }
8
+ const nonnegative: Schema = { type: 'number', minimum: 0 }
9
+ const integer: Schema = { type: 'integer', minimum: 0 }
10
+ const list = (items: Schema): Schema => ({ type: 'array', items })
11
+ const ids: Schema = { type: 'array', items: id, uniqueItems: true }
12
+ const record = (value: Schema): Schema => ({ type: 'object', additionalProperties: value })
13
+ const object = (properties: Record<string, Schema>, required = Object.keys(properties)): Schema => ({ type: 'object', properties, required, additionalProperties: false })
14
+ const tag = (type: string): Schema => ({ const: type })
15
+ const scope = { only: list(string), audience: list(string) }
16
+ const style = object({ alignment: { enum: ['left', 'center', 'right', 'justify'] }, color: string, background: string }, [])
17
+ const exclusiveCode = [{ required: ['value'], not: { required: ['origin'] } }, { required: ['origin'], not: { required: ['value'] } }]
18
+ const rich = (type: string, properties: Record<string, Schema>, required: string[] = Object.keys(properties)): Schema => object({ id, type: tag(type), ...properties }, ['id', 'type', ...required])
19
+ const block = (type: string, properties: Record<string, Schema>, required: string[] = Object.keys(properties)): Schema => object({ id, type: tag(type), ...scope, ...properties }, ['id', 'type', ...required])
20
+
21
+ /** Public authoring schema; domain graph checks are shared by the UI, agent and compiler validator. */
22
+ export const DOCUMENT_JSON_SCHEMA: Schema = {
23
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
24
+ $id: 'urn:presmium:teaching-document:1',
25
+ ...object({
26
+ schemaVersion: { const: DOCUMENT_SCHEMA_VERSION }, id,
27
+ metadata: object({ title: string, description: string, tags: list(string), lang: { type: 'string', minLength: 1 }, plugins: list(string), presmium: record(ref('JsonObject')), renderers: record(ref('JsonObject')), execute: ref('JsonObject'), checks: ref('JsonObject'), translations: record(string), translationOf: object({ entry: string, digest: string }) }, ['title', 'lang']),
28
+ sectionOrder: { ...ids, minItems: 1 }, sections: record(ref('Section')), blocks: record(ref('Block')),
29
+ steps: record(ref('Step')), narrations: record(ref('Narration')), assets: record(ref('Asset')),
30
+ definitions: record(ref('Definition')), production: ref('Production'), comments: record(ref('Thread')),
31
+ }, ['schemaVersion', 'id', 'metadata', 'sectionOrder', 'sections', 'blocks', 'steps', 'narrations', 'assets', 'definitions', 'production']),
32
+ $defs: {
33
+ JsonValue: { anyOf: [{ type: ['null', 'boolean', 'number', 'string'] }, { type: 'array', items: ref('JsonValue') }, ref('JsonObject')] },
34
+ JsonObject: { type: 'object', additionalProperties: ref('JsonValue') },
35
+ JsonSchema: { anyOf: [{ type: 'boolean' }, ref('JsonObject')] },
36
+ Layout: object({ type: { enum: ['stack', 'columns', 'canvas', 'scrolly'] }, properties: ref('JsonObject'), renderers: record(ref('JsonObject')) }, ['type']),
37
+ Section: object({ id, title: string, level: { type: 'integer', minimum: 1, maximum: 6 }, children: ids, stepOrder: { ...ids, minItems: 1 }, layout: ref('Layout'), audience: list(string), config: record(ref('JsonObject')) }, ['id', 'title', 'children', 'stepOrder', 'layout']),
38
+ Mark: { oneOf: [object({ type: { enum: ['bold', 'italic', 'underline', 'strike', 'code'] } }), object({ type: { enum: ['color', 'background'] }, value: string })] },
39
+ Inline: { oneOf: [
40
+ object({ type: tag('text'), text: string, marks: list(ref('Mark')) }, ['type', 'text']),
41
+ object({ type: tag('link'), href: string, children: list(ref('Inline')) }),
42
+ object({ type: tag('math'), value: string }), object({ type: tag('break') }),
43
+ object({ type: tag('extension'), kind: id, schemaVersion: { type: 'integer', minimum: 1 }, data: ref('JsonObject'), fallback: string }),
44
+ ] },
45
+ RichTextNode: { oneOf: [
46
+ rich('paragraph', { children: list(ref('Inline')), style }, ['children']),
47
+ rich('heading', { level: { type: 'integer', minimum: 1, maximum: 6 }, children: list(ref('Inline')), style }, ['level', 'children']),
48
+ rich('list', { ordered: { type: 'boolean' }, start: { type: 'integer', minimum: 1 }, children: list(ref('ListItem')) }, ['ordered', 'children']),
49
+ rich('blockquote', { children: list(ref('RichTextNode')) }),
50
+ rich('code', { language: string, value: string }), rich('math', { value: string }),
51
+ rich('table', { children: list(ref('TableRow')), headerRows: integer, headerColumns: integer, columnWidths: list(positive) }, ['children']),
52
+ rich('extension', { kind: id, schemaVersion: { type: 'integer', minimum: 1 }, data: ref('JsonObject'), fallback: list(ref('RichTextNode')) }),
53
+ ] },
54
+ ListItem: rich('listItem', { children: list(ref('RichTextNode')), checked: { type: 'boolean' } }, ['children']),
55
+ TableRow: rich('tableRow', { children: list(ref('TableCell')) }),
56
+ TableCell: rich('tableCell', { children: list(ref('Inline')), style, colspan: { type: 'integer', minimum: 1 }, rowspan: { type: 'integer', minimum: 1 } }, ['children']),
57
+ CodeReference: object({ anchor: { type: 'string', minLength: 1 }, pin: string, repo: string, highlightRegions: record({ type: 'string', minLength: 1 }) }, ['anchor']),
58
+ VariantReference: object({ anchor: { type: 'string', minLength: 1 }, pin: string, repo: string }, ['anchor']),
59
+ CodeVariant: { ...object({ id, title: string, value: string, origin: ref('VariantReference') }, ['id', 'title']), oneOf: exclusiveCode },
60
+ CodeExecution: object({ args: record(string), stdin: string, timeoutMs: positive, executor: string, when: { enum: ['compile', 'live', 'both'] }, session: string }, ['when']),
61
+ Block: { oneOf: [
62
+ block('text', { content: list(ref('RichTextNode')), reveal: object({ type: tag('items'), order: ids }) }, ['content']),
63
+ block('container', { layout: { enum: ['stack', 'columns', 'tabs'] }, children: ids, properties: object({ title: string, columnWidths: list(positive), tabTitles: list(string) }, []) }),
64
+ block('image', { assetId: id, alt: string, caption: list(ref('Inline')), width: positive, alignment: { enum: ['left', 'center', 'right', 'justify'] } }, ['alt']),
65
+ { ...block('code', { language: string, value: string, origin: ref('CodeReference'), title: string, execution: ref('CodeExecution'), highlight: record(list({ type: 'integer', minimum: 1 })), variants: list(ref('CodeVariant')), echo: { type: 'boolean' } }, ['language']), oneOf: exclusiveCode },
66
+ block('component', { definitionId: id, parameters: ref('JsonObject'), children: ids }, ['definitionId', 'parameters']),
67
+ ] },
68
+ Visual: object({ blockId: id, visible: { type: 'boolean' }, state: id, parameters: ref('JsonObject') }, ['blockId', 'visible']),
69
+ Step: object({ id, sectionId: id, title: string, visuals: list(ref('Visual')), narrationIds: ids, inherits: id }, ['id', 'sectionId', 'visuals', 'narrationIds']),
70
+ NarrationNode: { oneOf: [object({ type: tag('text'), text: string }), object({ type: tag('pronunciation'), display: string, spoken: string }), object({ id, type: tag('cue'), action: { type: 'string', minLength: 1 }, target: object({ blockId: id, element: string }, ['blockId']), parameters: ref('JsonObject') }, ['id', 'type', 'action'])] },
71
+ Narration: object({ id, content: list(ref('NarrationNode')), provenance: object({ type: tag('transcription'), transcriptionId: { type: 'string', pattern: '^[a-f0-9]{64}$' }, revision: { type: 'string', pattern: '^[a-f0-9]{64}$' }, assetId: id, audioSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' }, segmentIds: { ...ids, minItems: 1 } }) }, ['id', 'content']),
72
+ Asset: object({ id, kind: { enum: ['image', 'audio', 'video', 'file'] }, location: { type: 'string', minLength: 1 }, title: { type: 'string', minLength: 1, maxLength: ASSET_TITLE_MAX_LENGTH }, fingerprint: string, mediaType: string }, ['id', 'kind', 'location']),
73
+ Implementation: { oneOf: [object({ type: tag('file'), path: { type: 'string', minLength: 1 }, exportName: string, fingerprint: string }, ['type', 'path']), object({ type: tag('module'), specifier: { type: 'string', minLength: 1 }, exportName: string }, ['type', 'specifier']), object({ type: tag('plugin'), plugin: id, name: id })] },
74
+ State: object({ id, title: string, parameterSchema: ref('JsonSchema'), legacyIndex: integer }, ['id']),
75
+ Definition: object({ id, kind: { type: 'string', minLength: 1 }, schemaVersion: { type: 'integer', minimum: 1 }, implementation: ref('Implementation'), parameterSchema: ref('JsonSchema'), states: list(ref('State')), data: ref('JsonObject') }, ['id', 'kind', 'schemaVersion', 'implementation', 'parameterSchema', 'states']),
76
+ VoiceSource: { oneOf: [object({ type: tag('none') }), object({ type: tag('tts'), voice: object({ voice: { type: 'string', minLength: 1 }, engine: string, rate: positive, style: string }, ['voice']) }), object({ type: tag('recording'), assetId: id, sourceRange: object({ startMs: nonnegative, endMs: nonnegative }) }, ['type', 'assetId'])] },
77
+ StepProduction: object({ narrationStart: { enum: ['with-animation', 'after-animation'] }, minimumHoldMs: nonnegative, trailingPauseMs: nonnegative, voiceSource: ref('VoiceSource') }, []),
78
+ MediaClip: object({ id, assetId: id, sourceRange: object({ startMs: nonnegative, endMs: nonnegative }), attachment: object({ stepId: id, offsetMs: nonnegative }), volume: { type: 'number', minimum: 0, maximum: 1 }, muted: { type: 'boolean' } }, ['id', 'assetId', 'sourceRange', 'attachment', 'volume']),
79
+ CaptionTiming: object({ narrationIds: ids, sourceFingerprint: { type: 'string', pattern: '^[a-f0-9]{16}$' }, playbackFingerprint: { type: 'string', pattern: '^[a-f0-9]{16}$' }, durationMs: { type: 'integer', minimum: 1 }, segments: { type: 'array', minItems: 1, maxItems: 32, items: object({ id, from: integer, to: integer, startMs: integer, endMs: integer }) } }),
80
+ Production: object({ defaults: ref('StepProduction'), sectionOverrides: record(ref('StepProduction')), stepOverrides: record(ref('StepProduction')), narrationOverrides: record(object({ narrationStart: { enum: ['with-animation', 'after-animation'] } })), cueTimings: record(object({ pauseMs: nonnegative })), captionTimings: record(ref('CaptionTiming')), mediaClips: list(ref('MediaClip')) }, ['defaults', 'stepOverrides', 'mediaClips']),
81
+ TextPoint: object({ blockId: { type: 'string', minLength: 1, maxLength: 205 }, offset: integer }),
82
+ Comment: object({ id, type: tag('comment'), userId: id, createdAt: string, updatedAt: string, deletedAt: string, content: list(ref('RichTextNode')), metadata: ref('JsonObject'), reactions: list(object({ emoji: string, createdAt: string, userIds: ids })) }, ['id', 'type', 'userId', 'createdAt', 'updatedAt', 'reactions']),
83
+ Thread: object({ id, type: tag('thread'), createdAt: string, updatedAt: string, resolved: { type: 'boolean' }, resolvedUpdatedAt: string, resolvedBy: id, metadata: ref('JsonObject'), deletedAt: string, anchor: object({ from: ref('TextPoint'), to: ref('TextPoint') }), comments: list(ref('Comment')) }, ['id', 'type', 'createdAt', 'updatedAt', 'resolved', 'comments']),
84
+ },
85
+ }
@@ -0,0 +1,153 @@
1
+ import type { StepProduction, TeachingDocument, VoiceSource } from './contracts/document.ts'
2
+
3
+ /** Authoring scopes use stable source IDs, independent of compiled numerical step bindings. */
4
+ export type DocumentVoiceScope = { kind: 'document' } | { kind: 'page', sectionId: string } | { kind: 'step', stepId: string } | { kind: 'steps', stepIds: readonly string[] }
5
+ export interface DocumentVoiceOptions { replaceOverrides?: boolean }
6
+ export interface DocumentStepVoice {
7
+ stepId: string
8
+ sectionId: string
9
+ value?: VoiceSource
10
+ inheritedFrom: 'step' | 'page' | 'document' | 'unset'
11
+ }
12
+ export interface DocumentVoiceImpact {
13
+ total: number
14
+ /** Steps whose effective value or inheritance source will change. */
15
+ updated: number
16
+ /** Steps protected by an existing local voiceSource, including none and recording. */
17
+ preserved: number
18
+ unchanged: number
19
+ /** Existing configuration slots, not steps: page + step counts equal none + tts + recording. */
20
+ overrides: { pages: number, steps: number, none: number, tts: number, recording: number }
21
+ }
22
+
23
+ function page(document: TeachingDocument, id: string) {
24
+ const section = Object.hasOwn(document.sections, id) && document.sections[id]
25
+ if (!section || !document.sectionOrder.includes(id)) throw new Error(`document-voice-page-missing: ${id}`)
26
+ return section
27
+ }
28
+
29
+ function step(document: TeachingDocument, id: string) {
30
+ const value = Object.hasOwn(document.steps, id) && document.steps[id]
31
+ if (!value || !page(document, value.sectionId).stepOrder.includes(id)) throw new Error(`document-voice-step-missing: ${id}`)
32
+ return value
33
+ }
34
+
35
+ /** Deduplicated in authored page/step order; every requested ID is checked before any change. */
36
+ export function documentVoiceStepIds(document: TeachingDocument, scope: DocumentVoiceScope): string[] {
37
+ const ordered = () => [...new Set(document.sectionOrder.flatMap(id => page(document, id).stepOrder))]
38
+ switch (scope.kind) {
39
+ case 'document': return ordered()
40
+ case 'page': return [...page(document, scope.sectionId).stepOrder]
41
+ case 'step': step(document, scope.stepId); return [scope.stepId]
42
+ case 'steps': {
43
+ if (!scope.stepIds.length) throw new Error('document-voice-steps-empty')
44
+ scope.stepIds.forEach(id => step(document, id))
45
+ const selected = new Set(scope.stepIds)
46
+ return ordered().filter(id => selected.has(id))
47
+ }
48
+ default: throw new Error('document-voice-scope-invalid')
49
+ }
50
+ }
51
+
52
+ /** An absent voice remains unset; none and recording retain their canonical identities. */
53
+ export function resolveDocumentStepVoice(document: TeachingDocument, stepId: string): DocumentStepVoice {
54
+ const { sectionId } = step(document, stepId), production = document.production
55
+ const layers = [[production.stepOverrides[stepId]?.voiceSource, 'step'], [production.sectionOverrides?.[sectionId]?.voiceSource, 'page'], [production.defaults.voiceSource, 'document']] as const
56
+ for (const [value, inheritedFrom] of layers) if (value !== undefined) return { stepId, sectionId, value, inheritedFrom }
57
+ return { stepId, sectionId, inheritedFrom: 'unset' }
58
+ }
59
+
60
+ function same(left: unknown, right: unknown): boolean {
61
+ if (Object.is(left, right)) return true
62
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object' || Array.isArray(left) !== Array.isArray(right)) return false
63
+ const a = Object.keys(left), b = Object.keys(right)
64
+ return a.length === b.length && a.every(key => Object.hasOwn(right, key) && same((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]))
65
+ }
66
+
67
+ function checkVoice(document: TeachingDocument, source: VoiceSource | null | undefined): void {
68
+ if (source === null || source === undefined) return
69
+ const keys = (value: object, allowed: string[]) => Object.keys(value).every(key => allowed.includes(key))
70
+ if (typeof source !== 'object' || Array.isArray(source)) throw new Error('document-voice-source-invalid')
71
+ if (source.type === 'none' && keys(source, ['type'])) return
72
+ if (source.type === 'tts' && keys(source, ['type', 'voice'])) {
73
+ const voice = source.voice
74
+ if (voice && typeof voice === 'object' && !Array.isArray(voice) && keys(voice, ['voice', 'engine', 'rate', 'style']) && typeof voice.voice === 'string' && voice.voice.length > 0
75
+ && (voice.engine === undefined || typeof voice.engine === 'string') && (voice.style === undefined || typeof voice.style === 'string')
76
+ && (voice.rate === undefined || typeof voice.rate === 'number' && Number.isFinite(voice.rate) && voice.rate > 0)) return
77
+ }
78
+ if (source.type === 'recording' && keys(source, ['type', 'assetId']) && typeof source.assetId === 'string') {
79
+ const asset = Object.hasOwn(document.assets, source.assetId) && document.assets[source.assetId]
80
+ if (!asset) throw new Error(`document-voice-asset-missing: ${source.assetId}`)
81
+ if (asset.kind === 'audio' || asset.kind === 'video') return
82
+ throw new Error(`document-voice-asset-kind: ${source.assetId}`)
83
+ }
84
+ throw new Error('document-voice-source-invalid')
85
+ }
86
+
87
+ function protectedStep(document: TeachingDocument, scope: DocumentVoiceScope, id: string): boolean {
88
+ if (scope.kind === 'step') return false
89
+ if (document.production.stepOverrides[id]?.voiceSource !== undefined) return true
90
+ return scope.kind === 'document' && document.production.sectionOverrides?.[document.steps[id]!.sectionId]?.voiceSource !== undefined
91
+ }
92
+
93
+ function settings(previous: StepProduction | undefined, source: VoiceSource | null | undefined): StepProduction | undefined {
94
+ if (same(previous?.voiceSource, source ?? undefined)) return previous
95
+ const next = { ...previous }
96
+ if (source === null || source === undefined) delete next.voiceSource
97
+ else next.voiceSource = structuredClone(source)
98
+ return Object.keys(next).length ? next : undefined
99
+ }
100
+
101
+ /**
102
+ * Page/document writes preserve descendant voice slots unless replaceOverrides is explicit.
103
+ * A single step always edits its own slot; a steps batch preserves existing step slots by default.
104
+ * null/undefined removes only voiceSource under the same scope/protection rules. Timing and opaque
105
+ * sibling fields survive, including when the last voice setting is removed. A no-op retains identity.
106
+ */
107
+ export function changeDocumentVoice(document: TeachingDocument, scope: DocumentVoiceScope, source: VoiceSource | null | undefined, options: DocumentVoiceOptions = {}): TeachingDocument {
108
+ const ids = documentVoiceStepIds(document, scope)
109
+ checkVoice(document, source)
110
+ const previous = document.production
111
+ let defaults = previous.defaults, sectionOverrides = previous.sectionOverrides, stepOverrides = previous.stepOverrides
112
+ const writeStep = (id: string, value: VoiceSource | null | undefined) => {
113
+ const next = settings(stepOverrides[id], value)
114
+ if (next === stepOverrides[id]) return
115
+ if (stepOverrides === previous.stepOverrides) stepOverrides = { ...stepOverrides }
116
+ if (next) stepOverrides[id] = next
117
+ else delete stepOverrides[id]
118
+ }
119
+ const writePage = (id: string, value: VoiceSource | null | undefined) => {
120
+ const next = settings(sectionOverrides?.[id], value)
121
+ if (next === sectionOverrides?.[id]) return
122
+ if (sectionOverrides === previous.sectionOverrides) sectionOverrides = { ...sectionOverrides }
123
+ if (next) sectionOverrides![id] = next
124
+ else delete sectionOverrides![id]
125
+ }
126
+ if (scope.kind === 'document') {
127
+ defaults = settings(defaults, source) ?? {}
128
+ if (options.replaceOverrides) {
129
+ document.sectionOrder.forEach(id => writePage(id, undefined))
130
+ ids.forEach(id => writeStep(id, undefined))
131
+ }
132
+ } else if (scope.kind === 'page') {
133
+ writePage(scope.sectionId, source)
134
+ if (options.replaceOverrides) ids.forEach(id => writeStep(id, undefined))
135
+ } else for (const id of ids) if (options.replaceOverrides || !protectedStep(document, scope, id)) writeStep(id, source)
136
+ if (defaults === previous.defaults && sectionOverrides === previous.sectionOverrides && stepOverrides === previous.stepOverrides) return document
137
+ return { ...document, production: { ...previous, defaults, ...sectionOverrides !== undefined ? { sectionOverrides } : {}, stepOverrides } }
138
+ }
139
+
140
+ /** Override counts include every existing local slot, even a page slot shadowed by a step slot. */
141
+ export function previewDocumentVoiceChange(document: TeachingDocument, scope: DocumentVoiceScope, source: VoiceSource | null | undefined, options: DocumentVoiceOptions = {}): DocumentVoiceImpact {
142
+ const ids = documentVoiceStepIds(document, scope), next = changeDocumentVoice(document, scope, source, options)
143
+ const impact: DocumentVoiceImpact = { total: ids.length, updated: 0, preserved: 0, unchanged: 0, overrides: { pages: 0, steps: 0, none: 0, tts: 0, recording: 0 } }
144
+ const count = (value: VoiceSource | undefined, kind: 'pages' | 'steps') => { if (value) { impact.overrides[kind]++; impact.overrides[value.type]++ } }
145
+ if (scope.kind === 'document') document.sectionOrder.forEach(id => count(document.production.sectionOverrides?.[id]?.voiceSource, 'pages'))
146
+ for (const id of ids) {
147
+ count(document.production.stepOverrides[id]?.voiceSource, 'steps')
148
+ if (!options.replaceOverrides && protectedStep(document, scope, id)) impact.preserved++
149
+ else if (!same(resolveDocumentStepVoice(document, id), resolveDocumentStepVoice(next, id))) impact.updated++
150
+ else impact.unchanged++
151
+ }
152
+ return impact
153
+ }