@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,118 @@
|
|
|
1
|
+
import { hasRole } from './access.ts'
|
|
2
|
+
import type { DocumentCommentAction, DocumentCommentContext, DocumentCommentEffect } from './contracts/document-comments.ts'
|
|
3
|
+
import type { DocumentComment, DocumentThread, InlineNode, JsonObject, ListItemNode, RichTextNode, TableCellNode, TableRowNode, TeachingDocument } from './contracts/document.ts'
|
|
4
|
+
|
|
5
|
+
export class DocumentCommentError extends Error {
|
|
6
|
+
readonly status: number
|
|
7
|
+
constructor(status: number, code: string) { super(code); this.status = status; this.name = 'DocumentCommentError' }
|
|
8
|
+
}
|
|
9
|
+
function fail(status: number, code: string): never { throw new DocumentCommentError(status, code) }
|
|
10
|
+
|
|
11
|
+
/** Canonical anchor v1 expands only nonempty styled-text links; other inline values are atoms. */
|
|
12
|
+
export function isDocumentTextLink(node: InlineNode): node is Extract<InlineNode, { type: 'link' }> & { children: Extract<InlineNode, { type: 'text' | 'break' }>[] } {
|
|
13
|
+
return node.type === 'link' && node.children.length > 0 && node.children.every(child => child.type === 'text' || child.type === 'break')
|
|
14
|
+
}
|
|
15
|
+
function inlineText(nodes: readonly InlineNode[]): string {
|
|
16
|
+
return nodes.map(node => node.type === 'text' ? node.text : node.type === 'break' ? '\n' : isDocumentTextLink(node) ? inlineText(node.children) : '\uFFFC').join('')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only addressable canonical text identities, in native document order; opaque data is never traversed. */
|
|
20
|
+
export function documentCommentTargets(document: TeachingDocument): Map<string, { order: number, text: string }> {
|
|
21
|
+
const targets = new Map<string, { order: number, text: string }>()
|
|
22
|
+
const add = (id: string, text: string) => { targets.set(id, { order: targets.size, text }) }
|
|
23
|
+
const rich = (node: RichTextNode): void => {
|
|
24
|
+
if (node.type === 'paragraph' || node.type === 'heading') add(node.id, inlineText(node.children))
|
|
25
|
+
else if (node.type === 'code') add(node.id, node.value)
|
|
26
|
+
else if (node.type === 'list') for (const item of node.children) {
|
|
27
|
+
const [first, ...rest] = item.children
|
|
28
|
+
add(item.id, first?.type === 'paragraph' ? inlineText(first.children) : '')
|
|
29
|
+
;(first?.type === 'paragraph' ? rest : item.children).forEach(rich)
|
|
30
|
+
}
|
|
31
|
+
else if (node.type === 'blockquote') {
|
|
32
|
+
const [first, ...rest] = node.children
|
|
33
|
+
if (first?.type === 'paragraph') { add(node.id, inlineText(first.children)); rest.forEach(rich) }
|
|
34
|
+
else node.children.forEach(rich)
|
|
35
|
+
} else if (node.type === 'table') for (const row of node.children) for (const cell of row.children) add(cell.id, inlineText(cell.children))
|
|
36
|
+
}
|
|
37
|
+
const block = (id: string): void => {
|
|
38
|
+
const value = document.blocks[id]
|
|
39
|
+
if (value?.type === 'text') value.content.forEach(rich)
|
|
40
|
+
else if (value?.type === 'code' && value.value !== undefined) add(id, value.value)
|
|
41
|
+
else if (value?.type === 'container' || value?.type === 'component') value.children?.forEach(block)
|
|
42
|
+
}
|
|
43
|
+
for (const id of document.sectionOrder) { const page = document.sections[id]!; add(`page:${id}`, page.title); page.children.forEach(block) }
|
|
44
|
+
return targets
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** No ordinal, offset clamping, surrogate splitting or guessed fallback identity. */
|
|
48
|
+
export function validateDocumentCommentAnchor(document: TeachingDocument, anchor: DocumentThread['anchor']): void {
|
|
49
|
+
if (!anchor) return
|
|
50
|
+
const targets = documentCommentTargets(document), a = targets.get(anchor.from.blockId), b = targets.get(anchor.to.blockId)
|
|
51
|
+
if (!a || !b) fail(422, 'comment-anchor-target-unavailable')
|
|
52
|
+
for (const [point, target] of [[anchor.from, a], [anchor.to, b]] as const) {
|
|
53
|
+
const offset = point.offset
|
|
54
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > target.text.length
|
|
55
|
+
|| offset > 0 && offset < target.text.length && /[\uD800-\uDBFF]/.test(target.text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(target.text[offset]!)) fail(422, 'comment-anchor-offset-invalid')
|
|
56
|
+
}
|
|
57
|
+
if (a.order > b.order || a.order === b.order && anchor.from.offset >= anchor.to.offset) fail(422, 'comment-anchor-range-invalid')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Shared native/editor permissions: editors may delete any discussion, but edit their own comment; a document owner may manage earlier authors. */
|
|
61
|
+
export function applyDocumentCommentAction(source: NonNullable<TeachingDocument['comments']>, action: DocumentCommentAction, context: DocumentCommentContext): { threads: NonNullable<TeachingDocument['comments']>, effect: DocumentCommentEffect } {
|
|
62
|
+
const member = context.member
|
|
63
|
+
if (!member?.id || !hasRole(member, 'editor')) fail(403, 'comment-permission-denied')
|
|
64
|
+
if (!Number.isFinite(Date.parse(context.now))) fail(422, 'comment-clock-invalid')
|
|
65
|
+
const allocate = () => { const id = context.allocate(); if (!id || id.length > 200 || ['__proto__', 'prototype', 'constructor'].includes(id)) fail(422, 'comment-id-invalid'); return id }
|
|
66
|
+
const content = (value: RichTextNode[]): RichTextNode[] => {
|
|
67
|
+
const result = structuredClone(value)
|
|
68
|
+
const fresh = (node: RichTextNode | ListItemNode | TableCellNode | TableRowNode): void => {
|
|
69
|
+
node.id = allocate()
|
|
70
|
+
if (node.type === 'list' || node.type === 'listItem' || node.type === 'blockquote' || node.type === 'table' || node.type === 'tableRow') node.children.forEach(fresh)
|
|
71
|
+
else if (node.type === 'extension') node.fallback.forEach(fresh)
|
|
72
|
+
}
|
|
73
|
+
if (context.allocateContentIds) result.forEach(fresh)
|
|
74
|
+
return result
|
|
75
|
+
}
|
|
76
|
+
const create = (value: RichTextNode[], metadata?: JsonObject): DocumentComment => ({ id: allocate(), type: 'comment', userId: member.id, createdAt: context.now, updatedAt: context.now, content: content(value), reactions: [], ...metadata ? { metadata: structuredClone(metadata) } : {} })
|
|
77
|
+
let thread: DocumentThread, commentId: string | undefined, changed = true
|
|
78
|
+
if (action.type === 'comment.create') {
|
|
79
|
+
const first = create(action.content, action.commentMetadata)
|
|
80
|
+
thread = { id: allocate(), type: 'thread', createdAt: context.now, updatedAt: context.now, resolved: false, comments: [first], ...action.anchor ? { anchor: structuredClone(action.anchor) } : {}, ...action.metadata ? { metadata: structuredClone(action.metadata) } : {} }
|
|
81
|
+
if (Object.hasOwn(source, thread.id)) fail(409, 'comment-id-repeated')
|
|
82
|
+
commentId = first.id
|
|
83
|
+
} else {
|
|
84
|
+
if (!Object.hasOwn(source, action.threadId)) fail(404, 'document-thread-missing')
|
|
85
|
+
if (source[action.threadId]!.deletedAt) fail(409, 'document-thread-deleted')
|
|
86
|
+
thread = structuredClone(source[action.threadId]!)
|
|
87
|
+
const find = (id: string) => { const comment = thread.comments.find(value => value.id === id); if (!comment) return fail(404, 'document-comment-missing'); if (comment.deletedAt) return fail(409, 'document-comment-deleted'); return comment }
|
|
88
|
+
switch (action.type) {
|
|
89
|
+
case 'comment.reply': { const comment = create(action.content, action.metadata); thread.comments.push(comment); commentId = comment.id; break }
|
|
90
|
+
case 'comment.update': {
|
|
91
|
+
const comment = find(action.commentId); if (comment.userId !== member.id && !hasRole(member, 'owner')) fail(403, 'comment-author-required')
|
|
92
|
+
commentId = comment.id; comment.content = content(action.content); comment.updatedAt = context.now
|
|
93
|
+
if (action.metadata) comment.metadata = structuredClone(action.metadata)
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
case 'comment.react': {
|
|
97
|
+
const comment = find(action.commentId), reaction = comment.reactions.find(value => value.emoji === action.emoji)
|
|
98
|
+
if (!action.emoji || action.emoji.length > 32) fail(422, 'comment-reaction-invalid')
|
|
99
|
+
commentId = comment.id; changed = Boolean(reaction?.userIds.includes(member.id)) !== action.active
|
|
100
|
+
if (changed && action.active) { if (reaction) reaction.userIds.push(member.id); else comment.reactions.push({ emoji: action.emoji, createdAt: context.now, userIds: [member.id] }) }
|
|
101
|
+
else if (changed && reaction) { reaction.userIds = reaction.userIds.filter(id => id !== member.id); comment.reactions = comment.reactions.filter(value => value.userIds.length) }
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
case 'comment.resolve': case 'comment.reopen':
|
|
105
|
+
changed = thread.resolved !== (action.type === 'comment.resolve')
|
|
106
|
+
if (changed) { thread.resolved = action.type === 'comment.resolve'; thread.resolvedBy = member.id; thread.resolvedUpdatedAt = context.now }
|
|
107
|
+
break
|
|
108
|
+
case 'comment.delete':
|
|
109
|
+
if (action.commentId) { find(action.commentId); commentId = action.commentId; thread.comments = thread.comments.filter(value => value.id !== commentId) }
|
|
110
|
+
else thread.comments = []
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
if (changed) thread.updatedAt = context.now
|
|
114
|
+
}
|
|
115
|
+
const threads = changed ? { ...source } : source, removed = !thread.comments.length
|
|
116
|
+
if (changed) { if (removed) delete threads[thread.id]; else Object.defineProperty(threads, thread.id, { value: thread, enumerable: true, configurable: true, writable: true }) }
|
|
117
|
+
return { threads, effect: { threadId: thread.id, ...commentId ? { commentId } : {}, changed, anchored: !removed && Boolean(thread.anchor), threadRemoved: removed, removedComments: Math.max(0, (source[thread.id]?.comments.length ?? 0) - thread.comments.length), comments: thread.comments.length, reactions: thread.comments.reduce((sum, comment) => sum + comment.reactions.length, 0) } }
|
|
118
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import jsonPatch from 'fast-json-patch'
|
|
2
|
+
import type { Operation } from 'fast-json-patch'
|
|
3
|
+
import { validateDocument } from './document.ts'
|
|
4
|
+
import { documentParameterAccess } from './parameters.ts'
|
|
5
|
+
import { DOCUMENT_PARAMETER_MAX_CHANGES, DOCUMENT_PARAMETER_MAX_PATH_DEPTH } from './contracts/authoring-actions.ts'
|
|
6
|
+
import type { DocumentComponentAction, DocumentComponentEffect, DocumentParameterChange } from './contracts/authoring-actions.ts'
|
|
7
|
+
import type { ComponentBlock, Definition, JsonObject, JsonSchema, JsonValue, TeachingDocument, VisualState } from './contracts/document.ts'
|
|
8
|
+
|
|
9
|
+
const { applyPatch, compare } = jsonPatch
|
|
10
|
+
|
|
11
|
+
export class DocumentComponentError extends Error {
|
|
12
|
+
readonly status: number
|
|
13
|
+
readonly detail?: Record<string, unknown>
|
|
14
|
+
constructor(status: number, code: string, detail?: Record<string, unknown>) { super(code); this.status = status; this.detail = detail }
|
|
15
|
+
}
|
|
16
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
17
|
+
const own = (value: object, key: string) => Object.hasOwn(value, key)
|
|
18
|
+
const fail = (status: number, code: string, detail?: Record<string, unknown>): never => { throw new DocumentComponentError(status, code, detail) }
|
|
19
|
+
const pointer = (path: readonly string[]) => '/' + path.map(key => key.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')
|
|
20
|
+
const unpointer = (path: string) => path.slice(1).split('/').map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
21
|
+
function component(document: TeachingDocument, id: string): ComponentBlock {
|
|
22
|
+
const block = own(document.blocks, id) ? document.blocks[id] : undefined
|
|
23
|
+
if (!block) return fail(404, 'component-block-missing', { blockId: id })
|
|
24
|
+
if (block.type !== 'component') return fail(422, 'component-block-type', { blockId: id })
|
|
25
|
+
return block
|
|
26
|
+
}
|
|
27
|
+
export function documentComponentSection(document: TeachingDocument, blockId: string): string {
|
|
28
|
+
component(document, blockId)
|
|
29
|
+
for (const sectionId of document.sectionOrder) {
|
|
30
|
+
const pending = [...document.sections[sectionId]!.children], seen = new Set<string>()
|
|
31
|
+
while (pending.length) {
|
|
32
|
+
const id = pending.pop()!
|
|
33
|
+
if (id === blockId) return sectionId
|
|
34
|
+
if (seen.has(id)) continue
|
|
35
|
+
seen.add(id)
|
|
36
|
+
const block = document.blocks[id]
|
|
37
|
+
if (block?.type === 'container' || block?.type === 'component') pending.push(...block.children ?? [])
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return fail(422, 'component-block-owner-missing', { blockId })
|
|
41
|
+
}
|
|
42
|
+
function stepOf(document: TeachingDocument, stepId: string, sectionId: string) {
|
|
43
|
+
const step = own(document.steps, stepId) ? document.steps[stepId] : undefined
|
|
44
|
+
if (!step) return fail(404, 'component-step-missing', { stepId })
|
|
45
|
+
if (step.sectionId !== sectionId) return fail(422, 'component-step-owner', { stepId, sectionId })
|
|
46
|
+
return step
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve the canonical shallow overlays, including all stable-step ancestors; no runtime snapshot is read. */
|
|
50
|
+
export function effectiveComponentParameters(document: TeachingDocument, blockId: string, stepId?: string): { parameters: JsonObject, visual?: VisualState, ownParameters?: JsonObject } {
|
|
51
|
+
const block = component(document, blockId)
|
|
52
|
+
if (stepId === undefined) return { parameters: structuredClone(block.parameters) }
|
|
53
|
+
const sectionId = documentComponentSection(document, blockId), target = stepOf(document, stepId, sectionId), chain: string[] = [], seen = new Set<string>()
|
|
54
|
+
let id: string | undefined = stepId
|
|
55
|
+
while (id !== undefined) {
|
|
56
|
+
if (seen.has(id)) return fail(422, 'component-step-cycle', { stepId })
|
|
57
|
+
seen.add(id); chain.unshift(id); id = stepOf(document, id, sectionId).inherits
|
|
58
|
+
}
|
|
59
|
+
let visual: VisualState | undefined
|
|
60
|
+
for (const id of chain) {
|
|
61
|
+
const value = document.steps[id]!.visuals.find(value => value.blockId === blockId)
|
|
62
|
+
if (value) visual = { ...visual, ...structuredClone(value), parameters: { ...visual?.parameters, ...value.parameters } }
|
|
63
|
+
}
|
|
64
|
+
const ownParameters = target.visuals.find(value => value.blockId === blockId)?.parameters
|
|
65
|
+
return { parameters: { ...structuredClone(block.parameters), ...structuredClone(visual?.parameters ?? {}) }, ...visual ? { visual } : {}, ...ownParameters !== undefined ? { ownParameters: structuredClone(ownParameters) } : {} }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validate domain parameter paths, then delegate actual object/array mutation to the public RFC6902 library. */
|
|
69
|
+
function patchParameters(source: JsonObject, changes: readonly DocumentParameterChange[]): JsonObject {
|
|
70
|
+
if (!changes.length || changes.length > DOCUMENT_PARAMETER_MAX_CHANGES) return fail(422, 'component-parameter-change-limit', { limits: { maxChanges: DOCUMENT_PARAMETER_MAX_CHANGES } })
|
|
71
|
+
const value = structuredClone(source)
|
|
72
|
+
for (const [index, change] of changes.entries()) {
|
|
73
|
+
const path = change.path
|
|
74
|
+
if (!path.length || path.length > DOCUMENT_PARAMETER_MAX_PATH_DEPTH || path.some(key => typeof key !== 'string' || key.length > 200 || key === '__proto__')) return fail(422, 'component-parameter-path-invalid', { index, limits: { maxPathDepth: DOCUMENT_PARAMETER_MAX_PATH_DEPTH, maxSegmentChars: 200 } })
|
|
75
|
+
let parent: JsonValue = value
|
|
76
|
+
for (const key of path.slice(0, -1)) {
|
|
77
|
+
if (!parent || typeof parent !== 'object' || !own(parent, key) || Array.isArray(parent) && !/^(0|[1-9][0-9]*)$/.test(key)) return fail(404, 'component-parameter-path-missing', { index, path })
|
|
78
|
+
parent = (parent as JsonObject)[key]!
|
|
79
|
+
}
|
|
80
|
+
const key = path.at(-1)!
|
|
81
|
+
if (!parent || typeof parent !== 'object') return fail(422, 'component-parameter-parent-type', { index, path })
|
|
82
|
+
if (Array.isArray(parent) && (!/^(0|[1-9][0-9]*)$/.test(key) || !Number.isSafeInteger(Number(key)) || Number(key) > parent.length)) return fail(422, 'component-parameter-array-index', { index, path })
|
|
83
|
+
if (change.op === 'remove' && !own(parent, key)) return fail(404, 'component-parameter-path-missing', { index, path })
|
|
84
|
+
const operation: Operation = change.op === 'remove' ? { op: 'remove', path: pointer(path) }
|
|
85
|
+
: { op: Array.isArray(parent) && own(parent, key) ? 'replace' : 'add', path: pointer(path), value: structuredClone(change.value) }
|
|
86
|
+
try { applyPatch(value, [operation], true, true, true) }
|
|
87
|
+
catch (error) { return fail(422, 'component-parameter-path-invalid', { index, path, reason: error instanceof Error ? error.name.slice(0, 80) : 'invalid-patch' }) }
|
|
88
|
+
}
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ParameterValue = JsonValue | typeof absentParameter
|
|
93
|
+
const absentParameter = Symbol('absent-parameter')
|
|
94
|
+
/** Annotation checks complement JSON Schema validity; they do not interpret schema branches or references. */
|
|
95
|
+
function guardParameterMutation(before: JsonObject, after: JsonObject, beforeSchemas: readonly JsonSchema[], afterSchemas: readonly JsonSchema[], newDefinition = false, changes: readonly DocumentParameterChange[] = [], restoring = false): void {
|
|
96
|
+
let visited = 0
|
|
97
|
+
const location = (path: string[]) => ({ path: path.slice(0, 8).map(part => part.slice(0, 80)), ...path.length > 8 || path.some(part => part.length > 80) ? { pathTruncated: true } : {} })
|
|
98
|
+
const inspect = (schemas: readonly JsonSchema[], value: JsonObject, path: string[], publicRequired: boolean) => {
|
|
99
|
+
let declared = false
|
|
100
|
+
for (const schema of schemas) {
|
|
101
|
+
const access = documentParameterAccess(schema, value, path)
|
|
102
|
+
if (!access.available) return fail(422, 'component-parameter-access-unavailable', { ...location(path), reason: access.reason?.slice(0, 120), next: 'Keep the draft and use an explicitly supported public schema path.' })
|
|
103
|
+
if (access.readOnly) return fail(422, 'component-parameter-read-only', location(path))
|
|
104
|
+
declared ||= access.public
|
|
105
|
+
}
|
|
106
|
+
if (publicRequired && !declared) return fail(422, 'component-parameter-not-public', location(path))
|
|
107
|
+
}
|
|
108
|
+
const check = (left: ParameterValue, right: ParameterValue, path: string[], beforePath = path) => {
|
|
109
|
+
if (left !== absentParameter) inspect(beforeSchemas, before, beforePath, !newDefinition)
|
|
110
|
+
if (right !== absentParameter) inspect(afterSchemas, after, path, true)
|
|
111
|
+
else if (newDefinition) inspect(afterSchemas, after, path, true)
|
|
112
|
+
}
|
|
113
|
+
const itemKeys = (values: JsonValue[]) => values.map(value => JSON.stringify(value))
|
|
114
|
+
const subsequence = (shorter: string[], longer: string[]): number[] | undefined => {
|
|
115
|
+
const matched: number[] = []; let next = 0
|
|
116
|
+
for (let index = 0; index < longer.length && next < shorter.length; index++) if (longer[index] === shorter[next]) { matched.push(index); next++ }
|
|
117
|
+
return next === shorter.length ? matched : undefined
|
|
118
|
+
}
|
|
119
|
+
// Domain set never inserts in the middle: explicit remove/append gives an exact transient index mapping.
|
|
120
|
+
const arrayLineage = (left: JsonValue[], right: JsonValue[], path: string[]) => {
|
|
121
|
+
if (!changes.length || changes.some(change => change.path.length <= path.length && change.path.every((part, index) => path[index] === part))) return
|
|
122
|
+
const relevant = changes.filter(change => path.every((part, index) => change.path[index] === part))
|
|
123
|
+
if (!relevant.length) return
|
|
124
|
+
const mapping = left.map((_, index) => index)
|
|
125
|
+
for (const change of relevant) if (change.path.length === path.length + 1) {
|
|
126
|
+
const index = Number(change.path.at(-1))
|
|
127
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > mapping.length) return
|
|
128
|
+
if (change.op === 'remove') mapping.splice(index, 1)
|
|
129
|
+
else if (index === mapping.length) mapping.push(-1)
|
|
130
|
+
}
|
|
131
|
+
return mapping.length === right.length ? mapping : undefined
|
|
132
|
+
}
|
|
133
|
+
const object = (value: ParameterValue): value is JsonObject | JsonValue[] => value !== absentParameter && value !== null && typeof value === 'object'
|
|
134
|
+
const walk = (left: ParameterValue, right: ParameterValue, path: string[], beforePath = path) => {
|
|
135
|
+
if (left !== absentParameter && right !== absentParameter && same(left, right)) return
|
|
136
|
+
if (++visited > 8192 || path.length > 128) return fail(422, 'component-parameter-access-unavailable', { reason: 'change-complexity-limit', limits: { maxChangedNodes: 8192, maxInspectionDepth: 128 }, next: 'Apply smaller public field changes separately.' })
|
|
137
|
+
check(left, right, path, beforePath)
|
|
138
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
139
|
+
const a = itemKeys(left), b = itemKeys(right)
|
|
140
|
+
// Reordering retains exact complete items, including their unknown and read-only data.
|
|
141
|
+
if (a.length === b.length && same([...a].sort(), [...b].sort())) return
|
|
142
|
+
const retained = subsequence(b, a)
|
|
143
|
+
if (retained) {
|
|
144
|
+
const keep = new Set(retained)
|
|
145
|
+
for (let index = 0; index < left.length; index++) if (!keep.has(index)) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const oldItems = subsequence(a, b)
|
|
149
|
+
if (oldItems) {
|
|
150
|
+
const keep = new Set(oldItems)
|
|
151
|
+
for (let index = 0; index < right.length; index++) if (!keep.has(index)) walk(absentParameter, right[index]!, [...path, String(index)], [...beforePath, String(index)])
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const mapping = arrayLineage(left, right, path)
|
|
155
|
+
if (mapping) {
|
|
156
|
+
const retained = new Set(mapping)
|
|
157
|
+
for (let index = 0; index < left.length; index++) if (!retained.has(index)) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
|
|
158
|
+
for (const [index, oldIndex] of mapping.entries()) walk(oldIndex < 0 ? absentParameter : left[oldIndex]!, right[index]!, [...path, String(index)], [...beforePath, String(oldIndex)])
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const oldUnmatched = a.flatMap((value, index) => value === b[index] ? [] : [{ value, index }])
|
|
162
|
+
const newUnmatched = b.flatMap((value, index) => value === a[index] ? [] : [{ value, index }])
|
|
163
|
+
if (!restoring && newUnmatched.some(item => oldUnmatched.some(old => item.value === old.value && item.index !== old.index))) return fail(422, 'component-parameter-array-change-unavailable', { ...location(path), next: 'Apply the exact item reorder and field changes in separate source-bound plans.' })
|
|
164
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
165
|
+
if (index >= right.length) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
|
|
166
|
+
else walk(index < left.length ? left[index]! : absentParameter, right[index]!, [...path, String(index)], [...beforePath, String(index)])
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (object(left) || object(right)) {
|
|
171
|
+
for (const key of new Set([...object(left) ? Object.keys(left) : [], ...object(right) ? Object.keys(right) : []])) {
|
|
172
|
+
walk(object(left) && own(left, key) ? (left as JsonObject)[key]! : absentParameter, object(right) && own(right, key) ? (right as JsonObject)[key]! : absentParameter, [...path, key], [...beforePath, key])
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const changed = new Set(compare(before, after).map(operation => unpointer(operation.path)[0]!))
|
|
177
|
+
if (!changed.size) return
|
|
178
|
+
check(before, after, [])
|
|
179
|
+
for (const key of changed) walk(own(before, key) ? before[key]! : absentParameter, own(after, key) ? after[key]! : absentParameter, [key])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function componentSchemas(definition: Definition, visual?: VisualState): JsonSchema[] {
|
|
183
|
+
const state = visual?.state ? definition.states.find(state => state.id === visual.state)?.parameterSchema : undefined
|
|
184
|
+
return [definition.parameterSchema, ...state === undefined ? [] : [state]]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Diff only changed parameter values; unchanged opaque or large fields never become request arguments. */
|
|
188
|
+
export function createDocumentParameterChanges(before: JsonObject, after: JsonObject): DocumentParameterChange[] {
|
|
189
|
+
const changes: DocumentParameterChange[] = compare(before, after).map(operation => {
|
|
190
|
+
if (operation.op === 'remove') return { op: 'remove', path: unpointer(operation.path) }
|
|
191
|
+
if (operation.op === 'add' || operation.op === 'replace') return { op: 'set', path: unpointer(operation.path), value: structuredClone(operation.value) as JsonValue }
|
|
192
|
+
return fail(422, 'component-parameter-diff-unavailable')
|
|
193
|
+
})
|
|
194
|
+
if (!changes.length) return []
|
|
195
|
+
if (changes.length <= DOCUMENT_PARAMETER_MAX_CHANGES && same(patchParameters(before, changes), after)) return changes
|
|
196
|
+
// A compare implementation may express array insertion differently from this domain's set semantics.
|
|
197
|
+
// Replacing only the affected top-level fields remains exact and is still subject to the request budget.
|
|
198
|
+
const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(key => !same(before[key], after[key])).map(key => own(after, key)
|
|
199
|
+
? { op: 'set' as const, path: [key], value: structuredClone(after[key]!) } : { op: 'remove' as const, path: [key] })
|
|
200
|
+
if (fields.length > DOCUMENT_PARAMETER_MAX_CHANGES) return fail(422, 'component-parameter-change-limit', { limits: { maxChanges: DOCUMENT_PARAMETER_MAX_CHANGES } })
|
|
201
|
+
return fields
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One source-bound component edit. Definition implementations and every unrelated authored field stay shared. */
|
|
205
|
+
export function applyDocumentComponentAction(source: TeachingDocument, action: DocumentComponentAction): { content: TeachingDocument, effect: DocumentComponentEffect } {
|
|
206
|
+
const original = component(source, action.blockId), sectionId = documentComponentSection(source, original.id), content = structuredClone(source), block = component(content, original.id)
|
|
207
|
+
const stepId = action.type === 'component.step-parameters.patch' || action.type === 'component.step-parameters.reset' ? action.stepId : undefined
|
|
208
|
+
if (action.type === 'component.parameters.patch') block.parameters = patchParameters(block.parameters, action.changes)
|
|
209
|
+
else if (action.type === 'component.definition.use') {
|
|
210
|
+
if (!own(content.definitions, action.definitionId)) return fail(404, 'component-definition-missing', { definitionId: action.definitionId })
|
|
211
|
+
block.definitionId = action.definitionId
|
|
212
|
+
if (action.changes?.length) block.parameters = patchParameters(block.parameters, action.changes)
|
|
213
|
+
} else {
|
|
214
|
+
const step = stepOf(content, action.stepId, sectionId), ownVisual = step.visuals.find(value => value.blockId === block.id)
|
|
215
|
+
if (action.type === 'component.step-parameters.reset') {
|
|
216
|
+
if (ownVisual) delete ownVisual.parameters
|
|
217
|
+
} else {
|
|
218
|
+
const effective = effectiveComponentParameters(source, block.id, action.stepId), patched = patchParameters(effective.parameters, action.changes)
|
|
219
|
+
const local = structuredClone(ownVisual?.parameters ?? {}), keys = new Set(action.changes.map(change => change.path[0]!))
|
|
220
|
+
const inherited = structuredClone(source), inheritedVisual = inherited.steps[action.stepId]!.visuals.find(value => value.blockId === block.id)
|
|
221
|
+
if (inheritedVisual) delete inheritedVisual.parameters
|
|
222
|
+
const lower = effectiveComponentParameters(inherited, block.id, action.stepId).parameters
|
|
223
|
+
for (const key of keys) {
|
|
224
|
+
if (!own(patched, key)) {
|
|
225
|
+
if (own(lower, key)) return fail(422, 'component-parameter-inherited-removal-unavailable', { path: [key], next: 'Reset the current parameter override to restore inheritance, or set a schema-valid value.' })
|
|
226
|
+
delete local[key]
|
|
227
|
+
} else Object.defineProperty(local, key, { value: structuredClone(patched[key]), configurable: true, enumerable: true, writable: true })
|
|
228
|
+
}
|
|
229
|
+
if (ownVisual) ownVisual.parameters = local
|
|
230
|
+
else step.visuals.push({ blockId: block.id, visible: effective.visual?.visible ?? false, parameters: local })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const checked = validateDocument(content)
|
|
234
|
+
if (!checked.document) return fail(422, 'component-parameters-invalid', { issueCount: checked.issues.length, issues: checked.issues.slice(0, 3).map(issue => ({ code: issue.code.slice(0, 64), path: issue.path.slice(0, 160), message: issue.message.slice(0, 160), severity: issue.severity })) })
|
|
235
|
+
const beforeDefinition = source.definitions[original.definitionId]!, afterDefinition = content.definitions[block.definitionId]!, newDefinition = original.definitionId !== block.definitionId
|
|
236
|
+
guardParameterMutation(original.parameters, block.parameters, componentSchemas(beforeDefinition), componentSchemas(afterDefinition), newDefinition, 'changes' in action ? action.changes : undefined, action.type === 'component.step-parameters.reset')
|
|
237
|
+
const steps: string[] = [], preserved: string[] = [], materialized: string[] = [], reset: string[] = []
|
|
238
|
+
for (const id of source.sections[sectionId]!.stepOrder) {
|
|
239
|
+
const before = effectiveComponentParameters(source, block.id, id), after = effectiveComponentParameters(content, block.id, id)
|
|
240
|
+
guardParameterMutation(before.parameters, after.parameters, componentSchemas(beforeDefinition, before.visual), componentSchemas(afterDefinition, after.visual), newDefinition, 'changes' in action ? action.changes : undefined, action.type === 'component.step-parameters.reset')
|
|
241
|
+
if (newDefinition || !same(before.parameters, after.parameters)) steps.push(id)
|
|
242
|
+
if (before.ownParameters !== undefined && same(before.ownParameters, after.ownParameters)) preserved.push(id)
|
|
243
|
+
else if (after.ownParameters !== undefined) materialized.push(id)
|
|
244
|
+
else if (before.ownParameters !== undefined) reset.push(id)
|
|
245
|
+
}
|
|
246
|
+
const changed = !same(source, content)
|
|
247
|
+
const references = (definitionId: string) => Object.values(source.blocks).filter(value => value.type === 'component' && value.definitionId === definitionId).map(value => value.id)
|
|
248
|
+
return { content, effect: { blockId: block.id, sectionId, layer: action.type === 'component.definition.use' ? 'definition' : stepId ? 'step' : 'base', ...stepId ? { stepId } : {}, definitionId: block.definitionId, ...block.definitionId !== original.definitionId ? { previousDefinitionId: original.definitionId } : {}, changed,
|
|
249
|
+
paths: 'changes' in action ? (action.changes ?? []).map(change => [...change.path]) : [], affected: { blocks: changed ? [block.id] : [], steps, pages: changed ? [sectionId] : [] }, overrides: { preserved, materialized, reset },
|
|
250
|
+
references: { before: references(original.definitionId), after: Object.values(content.blocks).filter(value => value.type === 'component' && value.definitionId === block.definitionId).map(value => value.id) } } }
|
|
251
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { DocumentBlock, DocumentEditOperation, TeachingDocument } from './contracts/document.ts'
|
|
2
|
+
|
|
3
|
+
/** Operations describe author intent; the caller validates the resulting complete document before committing it. */
|
|
4
|
+
export function applyDocumentOperations(source: TeachingDocument, operations: readonly DocumentEditOperation[]): TeachingDocument {
|
|
5
|
+
if (!operations.length || operations.length > 1000) throw new Error('document-operation-count')
|
|
6
|
+
const document = structuredClone(source)
|
|
7
|
+
const block = (id: string): DocumentBlock => {
|
|
8
|
+
if (!Object.hasOwn(document.blocks, id)) throw new Error('document-block-missing')
|
|
9
|
+
return document.blocks[id]!
|
|
10
|
+
}
|
|
11
|
+
const section = (id: string) => {
|
|
12
|
+
if (!Object.hasOwn(document.sections, id)) throw new Error('document-section-missing')
|
|
13
|
+
return document.sections[id]!
|
|
14
|
+
}
|
|
15
|
+
const step = (id: string) => {
|
|
16
|
+
if (!Object.hasOwn(document.steps, id)) throw new Error('document-step-missing')
|
|
17
|
+
return document.steps[id]!
|
|
18
|
+
}
|
|
19
|
+
const insert = (items: string[], index: number, id: string) => {
|
|
20
|
+
if (!Number.isInteger(index) || index < 0 || index > items.length || items.includes(id)) throw new Error('document-order-invalid')
|
|
21
|
+
items.splice(index, 0, id)
|
|
22
|
+
}
|
|
23
|
+
const descendants = (id: string, seen = new Set<string>()): Set<string> => {
|
|
24
|
+
if (seen.has(id)) throw new Error('document-tree-cycle')
|
|
25
|
+
seen.add(id)
|
|
26
|
+
const value = block(id)
|
|
27
|
+
if (value.type === 'container' || value.type === 'component') for (const child of value.children ?? []) descendants(child, seen)
|
|
28
|
+
return seen
|
|
29
|
+
}
|
|
30
|
+
const children = (sectionId: string, parentId?: string): string[] => {
|
|
31
|
+
const owner = section(sectionId)
|
|
32
|
+
if (parentId === undefined) return owner.children
|
|
33
|
+
const parent = block(parentId)
|
|
34
|
+
if (parent.type !== 'container' || !owner.children.some(id => descendants(id).has(parentId))) throw new Error('document-parent-invalid')
|
|
35
|
+
return parent.children
|
|
36
|
+
}
|
|
37
|
+
const detach = (id: string) => {
|
|
38
|
+
const parents = [
|
|
39
|
+
...Object.values(document.sections).map(value => value.children),
|
|
40
|
+
...Object.values(document.blocks).flatMap(value => value.type === 'container' || value.type === 'component' && value.children ? [value.children!] : []),
|
|
41
|
+
].filter(items => items.includes(id))
|
|
42
|
+
if (parents.length !== 1) throw new Error('document-parent-invalid')
|
|
43
|
+
parents[0]!.splice(parents[0]!.indexOf(id), 1)
|
|
44
|
+
}
|
|
45
|
+
const put = <T>(into: Record<string, T>, id: string, value: T) => {
|
|
46
|
+
if (!id || ['__proto__', 'prototype', 'constructor'].includes(id)) throw new Error('document-id-invalid')
|
|
47
|
+
Object.defineProperty(into, id, { value: structuredClone(value), enumerable: true, configurable: true, writable: true })
|
|
48
|
+
}
|
|
49
|
+
for (const operation of operations) {
|
|
50
|
+
switch (operation.type) {
|
|
51
|
+
case 'insertBlock': {
|
|
52
|
+
if (Object.hasOwn(document.blocks, operation.block.id)) throw new Error('document-id-repeated')
|
|
53
|
+
const list = children(operation.sectionId, operation.parentId)
|
|
54
|
+
insert(list, operation.index, operation.block.id)
|
|
55
|
+
put(document.blocks, operation.block.id, operation.block)
|
|
56
|
+
// New document content is visible by default. Later author operations
|
|
57
|
+
// may explicitly change its teaching states in this same transaction.
|
|
58
|
+
for (const id of section(operation.sectionId).stepOrder) step(id).visuals.push({ blockId: operation.block.id, visible: true })
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
case 'moveBlock': {
|
|
62
|
+
const moved = descendants(operation.blockId)
|
|
63
|
+
if (operation.parentId && moved.has(operation.parentId)) throw new Error('document-tree-cycle')
|
|
64
|
+
const list = children(operation.sectionId, operation.parentId)
|
|
65
|
+
detach(operation.blockId)
|
|
66
|
+
insert(list, operation.index, operation.blockId)
|
|
67
|
+
const targetSteps = new Set(section(operation.sectionId).stepOrder)
|
|
68
|
+
for (const value of Object.values(document.steps)) {
|
|
69
|
+
if (!targetSteps.has(value.id)) value.visuals = value.visuals.filter(visual => !moved.has(visual.blockId))
|
|
70
|
+
else for (const id of moved) if (!value.visuals.some(visual => visual.blockId === id)) value.visuals.push({ blockId: id, visible: true })
|
|
71
|
+
}
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
case 'deleteBlock': {
|
|
75
|
+
const removed = descendants(operation.blockId)
|
|
76
|
+
for (const narration of Object.values(document.narrations)) if (narration.content.some(node => node.type === 'cue' && node.target && removed.has(node.target.blockId))) throw new Error('document-block-referenced-by-cue')
|
|
77
|
+
const identities = new Set<string>(removed)
|
|
78
|
+
const visit = (value: unknown): void => {
|
|
79
|
+
if (Array.isArray(value)) { value.forEach(visit); return }
|
|
80
|
+
if (!value || typeof value !== 'object') return
|
|
81
|
+
for (const [key, child] of Object.entries(value)) { if (key === 'id' && typeof child === 'string') identities.add(child); else visit(child) }
|
|
82
|
+
}
|
|
83
|
+
for (const id of removed) visit(block(id))
|
|
84
|
+
// A deleted range becomes an unanchored thread; its discussion survives.
|
|
85
|
+
for (const thread of Object.values(document.comments ?? {})) if (thread.anchor && (identities.has(thread.anchor.from.blockId) || identities.has(thread.anchor.to.blockId))) delete thread.anchor
|
|
86
|
+
detach(operation.blockId)
|
|
87
|
+
for (const id of removed) delete document.blocks[id]
|
|
88
|
+
for (const value of Object.values(document.steps)) value.visuals = value.visuals.filter(visual => !removed.has(visual.blockId))
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
case 'updateText': {
|
|
92
|
+
const value = block(operation.blockId)
|
|
93
|
+
if (value.type !== 'text') throw new Error('document-block-type')
|
|
94
|
+
value.content = structuredClone(operation.content)
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
case 'updateSection': {
|
|
98
|
+
const value = section(operation.sectionId)
|
|
99
|
+
if (operation.title !== undefined) value.title = operation.title
|
|
100
|
+
if (operation.layout !== undefined) value.layout = structuredClone(operation.layout)
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
case 'setStepState': {
|
|
104
|
+
const value = step(operation.stepId)
|
|
105
|
+
value.visuals = structuredClone(operation.visuals)
|
|
106
|
+
if (operation.inherits !== undefined) value.inherits = operation.inherits
|
|
107
|
+
else delete value.inherits
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
case 'setNarration': {
|
|
111
|
+
put(document.narrations, operation.narration.id, operation.narration)
|
|
112
|
+
if (operation.stepId) {
|
|
113
|
+
const value = step(operation.stepId)
|
|
114
|
+
if (!value.narrationIds.includes(operation.narration.id)) insert(value.narrationIds, operation.index ?? value.narrationIds.length, operation.narration.id)
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
case 'setTiming':
|
|
119
|
+
if (operation.stepId && operation.sectionId) throw new Error('document-timing-scope')
|
|
120
|
+
if (operation.stepId) { step(operation.stepId); put(document.production.stepOverrides, operation.stepId, operation.value) }
|
|
121
|
+
else if (operation.sectionId) { section(operation.sectionId); document.production.sectionOverrides ??= {}; put(document.production.sectionOverrides, operation.sectionId, operation.value) }
|
|
122
|
+
else document.production.defaults = structuredClone(operation.value)
|
|
123
|
+
break
|
|
124
|
+
case 'replaceAsset':
|
|
125
|
+
if (!Object.hasOwn(document.assets, operation.asset.id)) throw new Error('document-asset-missing')
|
|
126
|
+
put(document.assets, operation.asset.id, operation.asset)
|
|
127
|
+
break
|
|
128
|
+
default: throw new Error('document-operation-unsupported')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return document
|
|
132
|
+
}
|