@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.
- package/LICENSE +202 -0
- package/README.md +32 -2
- package/package.json +41 -3
- package/src/access.ts +18 -0
- package/src/anchors.ts +58 -0
- package/src/authoring.ts +56 -0
- package/src/blocks.ts +19 -0
- package/src/checks.ts +35 -0
- package/src/component-code.ts +6 -0
- package/src/contracts/agent.ts +76 -0
- package/src/contracts/anchors.ts +46 -0
- package/src/contracts/authoring-actions.ts +60 -0
- package/src/contracts/authoring-clients.ts +213 -0
- package/src/contracts/authoring.ts +180 -0
- package/src/contracts/checks.ts +29 -0
- package/src/contracts/classroom.ts +49 -0
- package/src/contracts/diagnostics.ts +37 -0
- package/src/contracts/document-audio.ts +150 -0
- package/src/contracts/document-comments.ts +31 -0
- package/src/contracts/document-history.ts +63 -0
- package/src/contracts/document-image.ts +32 -0
- package/src/contracts/document-reads.ts +57 -0
- package/src/contracts/document-voice.ts +44 -0
- package/src/contracts/document.ts +240 -0
- package/src/contracts/execution.ts +118 -0
- package/src/contracts/locale.ts +3 -0
- package/src/contracts/offline.ts +2 -0
- package/src/contracts/operations.ts +8 -0
- package/src/contracts/plugin.ts +183 -0
- package/src/contracts/presenter-panels.ts +17 -0
- package/src/contracts/preview.ts +386 -0
- package/src/contracts/production-assets.ts +61 -0
- package/src/contracts/production.ts +109 -0
- package/src/contracts/projects.ts +59 -0
- package/src/contracts/server.ts +108 -0
- package/src/contracts/service.ts +8 -0
- package/src/contracts/speech-settings.ts +16 -0
- package/src/contracts/speech.ts +149 -0
- package/src/contracts/steps.ts +15 -0
- package/src/contracts/transcription-adoption.ts +79 -0
- package/src/contracts/transcription.ts +112 -0
- package/src/contracts/voice.ts +97 -0
- package/src/contracts/voiceover-execution.ts +68 -0
- package/src/contracts/workspace.ts +458 -0
- package/src/contracts.ts +35 -0
- package/src/diagnostics.ts +7 -0
- package/src/document-authoring-actions.ts +47 -0
- package/src/document-caption-timings.ts +64 -0
- package/src/document-colors.ts +25 -0
- package/src/document-comments.ts +118 -0
- package/src/document-components.ts +251 -0
- package/src/document-operations.ts +132 -0
- package/src/document-pages.ts +215 -0
- package/src/document-reads.ts +81 -0
- package/src/document-schema.ts +85 -0
- package/src/document-voices.ts +153 -0
- package/src/document.ts +252 -0
- package/src/durations.ts +7 -0
- package/src/errors.ts +15 -0
- package/src/events.ts +32 -0
- package/src/execution.ts +25 -0
- package/src/fingerprint.ts +31 -0
- package/src/follow.ts +33 -0
- package/src/git.ts +12 -0
- package/src/html.ts +41 -0
- package/src/index.ts +82 -0
- package/src/ir.ts +367 -0
- package/src/jupyter-kernel.ts +9 -0
- package/src/kernel.ts +190 -0
- package/src/keyboard.ts +43 -0
- package/src/locale.ts +13 -0
- package/src/lock.ts +81 -0
- package/src/logging.ts +11 -0
- package/src/lower-myst.ts +312 -0
- package/src/node/assets.ts +23 -0
- package/src/node/authoring-actions.ts +50 -0
- package/src/node/authoring-clients.ts +14 -0
- package/src/node/document-audio.ts +22 -0
- package/src/node/document-history.ts +11 -0
- package/src/node/document-image.ts +8 -0
- package/src/node/document-reads.ts +10 -0
- package/src/node/document-voice.ts +7 -0
- package/src/node/ffmpeg.ts +29 -0
- package/src/node/file-version.ts +6 -0
- package/src/node/fonts.ts +22 -0
- package/src/node/index.ts +29 -0
- package/src/node/installed-packages.ts +11 -0
- package/src/node/lines.ts +24 -0
- package/src/node/locale.ts +7 -0
- package/src/node/network.ts +11 -0
- package/src/node/operator-speech.ts +91 -0
- package/src/node/packages.ts +74 -0
- package/src/node/paths.ts +7 -0
- package/src/node/projects.ts +7 -0
- package/src/node/resources.ts +86 -0
- package/src/node/revision.ts +6 -0
- package/src/node/run-directory.ts +70 -0
- package/src/node/strip-types.mjs +15 -0
- package/src/node/transcription-adoption.ts +9 -0
- package/src/node/transcription.ts +10 -0
- package/src/node/voiceover-routes.ts +73 -0
- package/src/node/voiceover.ts +199 -0
- package/src/page-source.ts +359 -0
- package/src/parameters.ts +81 -0
- package/src/preview-edit.ts +18 -0
- package/src/preview-events.ts +57 -0
- package/src/preview-frame.ts +1220 -0
- package/src/props.ts +17 -0
- package/src/published.ts +11 -0
- package/src/push-stream.ts +67 -0
- package/src/reading.ts +96 -0
- package/src/recording.ts +38 -0
- package/src/renderer-components.ts +30 -0
- package/src/requests.ts +28 -0
- package/src/responses.ts +49 -0
- package/src/run-outputs.ts +15 -0
- package/src/runner.py +454 -0
- package/src/services.ts +146 -0
- package/src/slides.ts +36 -0
- package/src/speech-words.ts +76 -0
- package/src/speech.ts +13 -0
- package/src/step-keys.ts +38 -0
- package/src/steps.ts +7 -0
- package/src/transcription-adoption.ts +105 -0
- package/src/uri-decoding.ts +8 -0
- package/src/urls.ts +13 -0
- package/src/views.ts +47 -0
- package/src/voice.ts +86 -0
|
@@ -0,0 +1,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
|
+
}
|