@lincyaw/kernel 1.0.0-rc.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +7 -7
  2. package/package.json +6 -2
  3. package/src/access.ts +18 -0
  4. package/src/anchors.ts +5 -1
  5. package/src/authoring.ts +56 -0
  6. package/src/blocks.ts +0 -8
  7. package/src/component-code.ts +6 -0
  8. package/src/contracts/agent.ts +5 -82
  9. package/src/contracts/anchors.ts +46 -0
  10. package/src/contracts/authoring-actions.ts +60 -0
  11. package/src/contracts/authoring-clients.ts +213 -0
  12. package/src/contracts/authoring.ts +180 -0
  13. package/src/contracts/checks.ts +29 -0
  14. package/src/contracts/classroom.ts +49 -0
  15. package/src/contracts/diagnostics.ts +37 -0
  16. package/src/contracts/document-audio.ts +150 -0
  17. package/src/contracts/document-comments.ts +31 -0
  18. package/src/contracts/document-history.ts +63 -0
  19. package/src/contracts/document-image.ts +32 -0
  20. package/src/contracts/document-reads.ts +57 -0
  21. package/src/contracts/document-voice.ts +44 -0
  22. package/src/contracts/document.ts +240 -0
  23. package/src/contracts/execution.ts +4 -5
  24. package/src/contracts/locale.ts +3 -0
  25. package/src/contracts/offline.ts +2 -0
  26. package/src/contracts/plugin.ts +183 -0
  27. package/src/contracts/presenter-panels.ts +17 -0
  28. package/src/contracts/preview.ts +305 -310
  29. package/src/contracts/production-assets.ts +61 -0
  30. package/src/contracts/production.ts +109 -0
  31. package/src/contracts/projects.ts +59 -0
  32. package/src/contracts/server.ts +1 -51
  33. package/src/contracts/speech-settings.ts +16 -0
  34. package/src/contracts/speech.ts +82 -70
  35. package/src/contracts/steps.ts +15 -0
  36. package/src/contracts/transcription-adoption.ts +79 -0
  37. package/src/contracts/transcription.ts +112 -0
  38. package/src/contracts/voice.ts +97 -0
  39. package/src/contracts/voiceover-execution.ts +68 -0
  40. package/src/contracts/workspace.ts +458 -0
  41. package/src/contracts.ts +28 -278
  42. package/src/diagnostics.ts +7 -0
  43. package/src/document-authoring-actions.ts +47 -0
  44. package/src/document-caption-timings.ts +64 -0
  45. package/src/document-colors.ts +25 -0
  46. package/src/document-comments.ts +118 -0
  47. package/src/document-components.ts +251 -0
  48. package/src/document-operations.ts +132 -0
  49. package/src/document-pages.ts +215 -0
  50. package/src/document-reads.ts +81 -0
  51. package/src/document-schema.ts +85 -0
  52. package/src/document-voices.ts +153 -0
  53. package/src/document.ts +252 -0
  54. package/src/durations.ts +7 -0
  55. package/src/errors.ts +8 -0
  56. package/src/execution.ts +5 -1
  57. package/src/fingerprint.ts +1 -1
  58. package/src/follow.ts +26 -30
  59. package/src/git.ts +1 -1
  60. package/src/index.ts +66 -13
  61. package/src/ir.ts +54 -5
  62. package/src/jupyter-kernel.ts +9 -0
  63. package/src/kernel.ts +38 -25
  64. package/src/keyboard.ts +43 -0
  65. package/src/locale.ts +13 -0
  66. package/src/lock.ts +4 -8
  67. package/src/logging.ts +1 -1
  68. package/src/{markdown.ts → lower-myst.ts} +2 -1
  69. package/src/node/assets.ts +23 -0
  70. package/src/node/authoring-actions.ts +50 -0
  71. package/src/node/authoring-clients.ts +14 -0
  72. package/src/node/document-audio.ts +22 -0
  73. package/src/node/document-history.ts +11 -0
  74. package/src/node/document-image.ts +8 -0
  75. package/src/node/document-reads.ts +10 -0
  76. package/src/node/document-voice.ts +7 -0
  77. package/src/node/ffmpeg.ts +29 -0
  78. package/src/node/file-version.ts +6 -0
  79. package/src/node/fonts.ts +22 -0
  80. package/src/node/index.ts +29 -133
  81. package/src/node/installed-packages.ts +11 -0
  82. package/src/node/lines.ts +24 -0
  83. package/src/node/locale.ts +7 -0
  84. package/src/node/network.ts +11 -0
  85. package/src/node/operator-speech.ts +91 -0
  86. package/src/node/packages.ts +74 -0
  87. package/src/node/paths.ts +7 -0
  88. package/src/node/projects.ts +7 -0
  89. package/src/node/resources.ts +86 -0
  90. package/src/node/revision.ts +6 -0
  91. package/src/node/run-directory.ts +70 -0
  92. package/src/node/transcription-adoption.ts +9 -0
  93. package/src/node/transcription.ts +10 -0
  94. package/src/node/voiceover-routes.ts +73 -0
  95. package/src/node/voiceover.ts +199 -0
  96. package/src/page-source.ts +359 -0
  97. package/src/parameters.ts +81 -0
  98. package/src/preview-edit.ts +18 -0
  99. package/src/preview-events.ts +57 -0
  100. package/src/preview-frame.ts +1220 -0
  101. package/src/props.ts +17 -0
  102. package/src/push-stream.ts +67 -0
  103. package/src/reading.ts +12 -7
  104. package/src/recording.ts +38 -0
  105. package/src/renderer-components.ts +30 -0
  106. package/src/responses.ts +49 -0
  107. package/src/run-outputs.ts +15 -0
  108. package/src/runner.py +3 -3
  109. package/src/services.ts +6 -6
  110. package/src/slides.ts +36 -0
  111. package/src/speech-words.ts +76 -0
  112. package/src/speech.ts +13 -0
  113. package/src/step-keys.ts +38 -0
  114. package/src/steps.ts +7 -0
  115. package/src/transcription-adoption.ts +105 -0
  116. package/src/uri-decoding.ts +8 -0
  117. package/src/urls.ts +13 -0
  118. package/src/views.ts +47 -0
  119. package/src/voice.ts +86 -0
package/src/contracts.ts CHANGED
@@ -1,285 +1,35 @@
1
- import type { AgentTool } from './contracts/agent.ts'
2
- import type { EnvironmentProvider, EnvironmentSpec, Executor } from './contracts/execution.ts'
3
- import type { NodeServiceContext, ServerContext, ServerContribution } from './contracts/server.ts'
4
- import { defineService } from './contracts/service.ts'
5
- import type { ServiceToken } from './contracts/service.ts'
6
- import type { DirectiveSpec, IRDocument, IRPass, Renderer, RendererContribution, RoleSpec } from './ir.ts'
7
-
8
1
  export * from './contracts/agent.ts'
2
+ export * from './contracts/authoring.ts'
3
+ export * from './contracts/document-reads.ts'
4
+ export * from './contracts/anchors.ts'
5
+ export * from './contracts/checks.ts'
6
+ export * from './contracts/classroom.ts'
7
+ export * from './contracts/diagnostics.ts'
9
8
  export * from './contracts/execution.ts'
9
+ export * from './contracts/locale.ts'
10
+ export * from './contracts/offline.ts'
10
11
  export * from './contracts/operations.ts'
12
+ export * from './contracts/plugin.ts'
13
+ export * from './contracts/presenter-panels.ts'
14
+ export * from './contracts/production.ts'
15
+ export * from './contracts/production-assets.ts'
16
+ export * from './contracts/document-audio.ts'
17
+ export * from './contracts/document-voice.ts'
18
+ export * from './contracts/voiceover-execution.ts'
11
19
  export * from './contracts/preview.ts'
20
+ export * from './contracts/document.ts'
12
21
  export * from './contracts/server.ts'
13
22
  export * from './contracts/service.ts'
14
23
  export * from './contracts/speech.ts'
15
-
16
- export const KERNEL_VERSION = '1.0.0'
17
-
18
- export type Capability = 'fs' | 'network' | 'exec' | 'git'
19
-
20
- export interface ServiceRequirement {
21
- token: ServiceToken<unknown>
22
- optional?: boolean
23
- }
24
-
25
- export interface PluginManifest {
26
- id: string
27
- version: string
28
- kernel: string
29
- provides?: ServiceToken<unknown>[]
30
- requires?: ServiceRequirement[]
31
- capabilities?: Capability[]
32
- }
33
-
34
- export type PresentationMode = 'slides' | 'document'
35
-
36
- export interface Logger {
37
- debug(...args: unknown[]): void
38
- info(...args: unknown[]): void
39
- warn(...args: unknown[]): void
40
- error(...args: unknown[]): void
41
- }
42
-
43
- export type Unsubscribe = () => void
44
-
45
- export interface DomainEvent<Type extends string = string, Payload = unknown> {
46
- type: Type
47
- payload: Payload
48
- }
49
-
50
- export interface EventBus {
51
- emit(event: DomainEvent): void
52
- on<E extends DomainEvent>(type: E['type'], handler: (event: E) => void): Unsubscribe
53
- }
54
-
55
- export interface PluginContext {
56
- config: Record<string, unknown>
57
- mode: PresentationMode
58
- provide<T>(token: ServiceToken<T>, impl: T): void
59
- get<T>(token: ServiceToken<T>): T
60
- tryGet<T>(token: ServiceToken<T>): T | undefined
61
- events: EventBus
62
- log: Logger
63
- environments?: Readonly<Record<string, EnvironmentSpec>>
64
- }
65
-
66
- export interface PluginDefinition {
67
- manifest: PluginManifest
68
- setup(ctx: PluginContext): void | Promise<void>
69
- dispose?(): void | Promise<void>
70
- }
71
-
72
- export type PluginStatus = 'started' | 'degraded' | 'disabled' | 'failed'
73
-
74
- export interface PluginReport {
75
- id: string
76
- status: PluginStatus
77
- reason?: string
78
- missing?: string[]
79
- }
80
-
81
- export interface StartReport {
82
- order: string[]
83
- plugins: PluginReport[]
84
- cycles: string[][]
85
- }
86
-
87
- export type KernelEvent =
88
- | DomainEvent<'kernel.plugin.started', { id: string }>
89
- | DomainEvent<'kernel.plugin.degraded', { id: string; missing: string[] }>
90
- | DomainEvent<'kernel.plugin.disabled', { id: string; reason: string }>
91
- | DomainEvent<'kernel.plugin.failed', { id: string; error: string }>
92
-
93
- export type Locale = 'zh-CN' | 'en'
94
-
95
- export const DEFAULT_LANG = 'zh-CN'
96
-
97
- export function localeOf(lang: string | null | undefined): Locale | undefined {
98
- const tag = lang?.trim().toLowerCase() ?? ''
99
- if (tag === 'zh' || tag.startsWith('zh-')) return 'zh-CN'
100
- if (tag === 'en' || tag.startsWith('en-')) return 'en'
101
- return undefined
102
- }
103
-
104
- export function deckLocale(lang?: string): Locale {
105
- return localeOf(lang || DEFAULT_LANG) ?? 'en'
106
- }
107
-
108
- export interface StepRange {
109
- from: number
110
- to?: number
111
- }
112
-
113
- export interface StepState {
114
- active: boolean
115
- reached: boolean
116
- done: boolean
117
- }
118
-
119
- export function stepStateAt(range: StepRange, step: number): StepState {
120
- const reached = step >= range.from
121
- const done = range.to !== undefined && step >= range.to
122
- return { active: reached && !done, reached, done }
123
- }
124
-
125
- export interface Cursor {
126
- slide: number
127
- step: number
128
- }
129
-
130
- export type AnchorString = `${string}:${string}`
131
-
132
- export type ResolutionStatus = 'exact' | 'moved' | 'fuzzy' | 'lost'
133
-
134
- export interface ResolvedLocation {
135
- file: string
136
- startLine: number
137
- endLine: number
138
- text: string
139
- }
140
-
141
- export interface Resolution {
142
- status: ResolutionStatus
143
- location?: ResolvedLocation
144
- fingerprint?: string
145
- candidates?: ResolvedLocation[]
146
- message?: string
147
- }
148
-
149
- export interface SourceReader {
150
- read(file: string, ref?: string): Promise<string | undefined>
151
- }
152
-
153
- export interface RecordedAnchor {
154
- fingerprint: string
155
- file: string
156
- startLine: number
157
- }
158
-
159
- export interface AnchorResolver {
160
- scheme: string
161
- resolve(anchor: AnchorString, src: SourceReader, recorded?: RecordedAnchor, ref?: string): Promise<Resolution>
162
- }
163
-
164
- export interface AnchorResolverRegistry {
165
- register(resolver: AnchorResolver): Unsubscribe
166
- resolve(anchor: AnchorString, src: SourceReader, recorded?: RecordedAnchor, ref?: string): Promise<Resolution>
167
- schemes(): string[]
168
- }
169
-
170
- export interface AnchorLockFile {
171
- version: 1
172
- anchors: Record<string, RecordedAnchor>
173
- }
174
-
175
- export function isRenderable(resolution: Resolution): boolean {
176
- return resolution.status === 'exact' || resolution.status === 'moved'
177
- }
178
-
179
- export type Severity = 'error' | 'warning' | 'info'
180
-
181
- export interface Finding {
182
- checkId: string
183
- severity: Severity
184
- message: string
185
- source?: { file: string; line?: number }
186
- hint?: string
187
- }
188
-
189
- export interface CheckContext {
190
- deckDir: string
191
- workspaceRoot: string
192
- headmatter: Record<string, unknown>
193
- src: SourceReader
194
- resolvers: AnchorResolverRegistry
195
- lock: AnchorLockFile
196
- ir: IRDocument
197
- service?<T>(token: ServiceToken<T>): T | undefined
198
- }
199
-
200
- export interface Check {
201
- id: string
202
- run(ctx: CheckContext): Promise<Finding[]>
203
- }
204
-
205
- export interface PresmiumNodePlugin {
206
- id: string
207
- version?: string
208
- kernel?: string
209
- resolvers?: AnchorResolver[]
210
- checks?: Check[]
211
- anchors?(ctx: CheckContext): AnchorString[] | Promise<AnchorString[]>
212
- directives?: DirectiveSpec[]
213
- passes?: IRPass[]
214
- executors?: Executor[]
215
- renderer?: Renderer
216
- rendererExtensions?: Record<string, RendererContribution>
217
- vitePlugins?(ctx: { deckDir: string, workspaceRoot: string, config: Record<string, unknown> }): unknown[]
218
- services?(ctx: NodeServiceContext): [ServiceToken<unknown>, unknown][]
219
- environments?: Record<string, EnvironmentProvider>
220
- server?(ctx: ServerContext): ServerContribution | Promise<ServerContribution>
221
- roles?: RoleSpec[]
222
- tools?: AgentTool[]
223
- operator?: boolean
224
- }
225
-
226
- export type Role = 'presenter' | 'learner'
227
-
228
- export interface Participant {
229
- id: string
230
- name: string
231
- role: Role
232
- }
233
-
234
- export type SessionStatus = 'idle' | 'active' | 'ended'
235
-
236
- export interface SessionSnapshot {
237
- sessionId?: string
238
- status: SessionStatus
239
- me?: Participant
240
- participants: Participant[]
241
- presenterCursor?: Cursor
242
- }
243
-
244
- export interface SessionService {
245
- snapshot(): SessionSnapshot
246
- subscribe(listener: (snapshot: SessionSnapshot) => void): Unsubscribe
247
- create(name: string): Promise<string>
248
- join(sessionId: string, name: string): Promise<void>
249
- end(): Promise<void>
250
- leave(): void
251
- archive?(): Promise<string>
252
- readonly personal?: ClassroomStore
253
- }
254
-
255
- export const SESSION = defineService<SessionService>('presmium.session')
256
-
257
- export interface StoredItem<T> {
258
- id: string
259
- value: T
260
- }
261
-
262
- export interface ClassroomStore {
263
- list<T>(collection: string): StoredItem<T>[]
264
- get<T>(collection: string, id: string): T | undefined
265
- put<T>(collection: string, id: string, value: T): void
266
- remove(collection: string, id: string): void
267
- subscribe(collection: string, listener: () => void): Unsubscribe
268
- }
269
-
270
- export const CLASSROOM_STORE = defineService<ClassroomStore>('presmium.classroomStore')
271
-
272
- export interface PresenterPanel {
273
- id: string
274
- title: string
275
- component: unknown
276
- order?: number
277
- }
278
-
279
- export interface PresenterPanels {
280
- register(panel: PresenterPanel): Unsubscribe
281
- list(): PresenterPanel[]
282
- subscribe(listener: () => void): Unsubscribe
283
- }
284
-
285
- export const PRESENTER_PANELS = defineService<PresenterPanels>('presmium.presenterPanels')
24
+ export * from './contracts/speech-settings.ts'
25
+ export * from './contracts/transcription.ts'
26
+ export * from './contracts/transcription-adoption.ts'
27
+ export * from './contracts/voice.ts'
28
+ export * from './contracts/steps.ts'
29
+ export * from './contracts/workspace.ts'
30
+ export * from './contracts/document-comments.ts'
31
+ export * from './contracts/authoring-actions.ts'
32
+ export * from './contracts/document-history.ts'
33
+ export * from './contracts/authoring-clients.ts'
34
+ export * from './contracts/projects.ts'
35
+ export * from './contracts/document-image.ts'
@@ -0,0 +1,7 @@
1
+ import type { Diagnostic, DiagnosticOrigin, Finding } from './contracts.ts'
2
+ import { fnv1a64 } from './fingerprint.ts'
3
+
4
+ export function diagnosticOf(document: string, origin: DiagnosticOrigin, finding: Finding & Pick<Diagnostic, 'authoringSource'>, renderer?: 'web' | 'slides', revision?: string): Diagnostic {
5
+ const id = fnv1a64(JSON.stringify([document, origin, renderer, finding.checkId, finding.severity, finding.message, finding.source?.file, finding.source?.line, finding.hint, ...finding.authoringSource ? [finding.authoringSource] : []]))
6
+ return { ...finding, ...finding.source ? { source: { ...finding.source } } : {}, ...finding.authoringSource ? { authoringSource: { ...finding.authoringSource } } : {}, id, document, origin, ...renderer ? { renderer } : {}, ...revision ? { revision } : {} }
7
+ }
@@ -0,0 +1,47 @@
1
+ import { applyDocumentCaptionTiming } from './document-caption-timings.ts'
2
+ import { hasRole } from './access.ts'
3
+ import { applyDocumentCommentAction, DocumentCommentError, validateDocumentCommentAnchor } from './document-comments.ts'
4
+ import { changeDocumentVoice, documentVoiceStepIds, previewDocumentVoiceChange } from './document-voices.ts'
5
+ import { applyDocumentComponentAction } from './document-components.ts'
6
+ import type { DocumentAuthoringAction, DocumentAuthoringActionEffect } from './contracts/authoring-actions.ts'
7
+ import type { DocumentCommentContext } from './contracts/document-comments.ts'
8
+ import type { TeachingDocument } from './contracts/document.ts'
9
+
10
+ /** Fixed context is supplied by the owning adapter exactly once per semantic plan. */
11
+ export function applyDocumentAuthoringAction(document: TeachingDocument, action: DocumentAuthoringAction, context: DocumentCommentContext): { content: TeachingDocument, effect: DocumentAuthoringActionEffect } {
12
+ if (!context.member || !hasRole(context.member, 'editor')) throw new DocumentCommentError(403, 'authoring-permission-denied')
13
+ if (action.type === 'component.parameters.patch' || action.type === 'component.step-parameters.patch' || action.type === 'component.step-parameters.reset' || action.type === 'component.definition.use') {
14
+ const { content, effect } = applyDocumentComponentAction(document, action)
15
+ return { content, effect: { kind: 'component', value: effect } }
16
+ }
17
+ if (action.type === 'caption.timing.set' || action.type === 'caption.timing.reset') {
18
+ const result = applyDocumentCaptionTiming(document, action)
19
+ return { content: result.content, effect: { kind: 'caption', value: result.effect } }
20
+ }
21
+ if (action.type === 'voice.set') {
22
+ if (action.source !== null && action.source.type !== 'tts' && action.source.type !== 'none') throw new DocumentCommentError(422, 'voice-recording-requires-adoption')
23
+ const content = changeDocumentVoice(document, action.scope, action.source, { replaceOverrides: action.replaceOverrides }), recordings = { preserved: 0, removed: 0 }
24
+ const check = (before: typeof document.production.defaults.voiceSource, after: typeof before) => { if (before?.type === 'recording') { if (after?.type === 'recording' && before.assetId === after.assetId) recordings.preserved++; else recordings.removed++ } }
25
+ if (action.scope.kind === 'document') check(document.production.defaults.voiceSource, content.production.defaults.voiceSource)
26
+ const pages = action.scope.kind === 'document' ? document.sectionOrder : action.scope.kind === 'page' ? [action.scope.sectionId] : []
27
+ for (const id of pages) check(document.production.sectionOverrides?.[id]?.voiceSource, content.production.sectionOverrides?.[id]?.voiceSource)
28
+ for (const id of documentVoiceStepIds(document, action.scope)) check(document.production.stepOverrides[id]?.voiceSource, content.production.stepOverrides[id]?.voiceSource)
29
+ return { content, effect: { kind: 'voice', scope: action.scope.kind, value: previewDocumentVoiceChange(document, action.scope, action.source, { replaceOverrides: action.replaceOverrides }), recordings } }
30
+ }
31
+ if (action.type === 'comment.create') validateDocumentCommentAnchor(document, action.anchor ?? undefined)
32
+ let source = document.comments ?? {}, detached = false
33
+ if (action.type !== 'comment.create' && Object.hasOwn(source, action.threadId)) {
34
+ const thread = source[action.threadId]!
35
+ try { validateDocumentCommentAnchor(document, thread.anchor) } catch (error) {
36
+ if (!(error instanceof DocumentCommentError) || error.status !== 422) throw error
37
+ const { anchor: _anchor, ...unanchored } = thread
38
+ source = { ...source, [thread.id]: unanchored }; detached = true
39
+ }
40
+ }
41
+ const { threads, effect } = applyDocumentCommentAction(source, action, context)
42
+ if (detached) { effect.anchorDetached = true; effect.changed = true }
43
+ const content = { ...document }
44
+ if (Object.keys(threads).length) content.comments = threads
45
+ else delete content.comments
46
+ return { content, effect: { kind: 'comment', value: effect } }
47
+ }
@@ -0,0 +1,64 @@
1
+ import { fnv1a64 } from './fingerprint.ts'
2
+ import type { CaptionTiming, NarrationNode, TeachingDocument } from './contracts/document.ts'
3
+
4
+ export const CAPTION_TIMING_MAX_SEGMENTS = 32
5
+ export type DocumentCaptionTimingAction = { type: 'caption.timing.set', stepId: string, value: CaptionTiming } | { type: 'caption.timing.reset', stepId: string }
6
+ export class DocumentCaptionTimingError extends Error {
7
+ readonly status: number
8
+ constructor(status: number, message: string) { super(message); this.status = status }
9
+ }
10
+ export function narrationCaptionText(content: readonly NarrationNode[]): string {
11
+ return content.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.display : '').join('')
12
+ }
13
+ /** No second editable caption text. Separators are part of this exact display projection. */
14
+ export function stepCaptionText(document: TeachingDocument, stepId: string): string {
15
+ return (document.steps[stepId]?.narrationIds ?? []).map(id => narrationCaptionText(document.narrations[id]?.content ?? [])).join('\n')
16
+ }
17
+ export function captionSourceFingerprint(document: TeachingDocument, stepId: string): string {
18
+ const step = document.steps[stepId]
19
+ if (!step) throw new DocumentCaptionTimingError(404, 'caption-step-missing')
20
+ const timing = { ...document.production.defaults, ...document.production.sectionOverrides?.[step.sectionId], ...document.production.stepOverrides[stepId] }
21
+ return fnv1a64(JSON.stringify({ document: document.id, stepId, sectionId: step.sectionId, lang: document.metadata.lang, timing,
22
+ narrations: step.narrationIds.map(id => ({ id, content: document.narrations[id]?.content,
23
+ start: document.production.stepOverrides[stepId]?.narrationStart ?? document.production.narrationOverrides?.[id]?.narrationStart ?? timing.narrationStart })),
24
+ pauses: step.narrationIds.flatMap(id => document.narrations[id]?.content.flatMap(node => node.type === 'cue' ? [[node.id, document.production.cueTimings?.[node.id]]] : []) ?? []),
25
+ }))
26
+ }
27
+
28
+ /** Unicode grapheme partitions, complete original text and monotonic author times; no interpolation. */
29
+ export function captionTimingProblem(value: CaptionTiming, text: string): string | undefined {
30
+ if (!text.trim() || !Number.isSafeInteger(value.durationMs) || value.durationMs <= 0) return 'caption-timing-unavailable'
31
+ if (!value.segments.length || value.segments.length > CAPTION_TIMING_MAX_SEGMENTS) return 'caption-segment-limit'
32
+ const boundaries = new Set([0, text.length, ...Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text), part => part.index)])
33
+ const ids = new Set<string>()
34
+ let offset = 0, end = 0
35
+ for (const segment of value.segments) {
36
+ if (!segment.id || ids.has(segment.id)) return 'caption-segment-id-invalid'
37
+ ids.add(segment.id)
38
+ if (!Number.isSafeInteger(segment.from) || !Number.isSafeInteger(segment.to) || segment.from !== offset || segment.to <= segment.from
39
+ || !boundaries.has(segment.from) || !boundaries.has(segment.to) || !text.slice(segment.from, segment.to).trim()) return 'caption-text-range-invalid'
40
+ if (!Number.isSafeInteger(segment.startMs) || !Number.isSafeInteger(segment.endMs) || segment.startMs < end || segment.endMs <= segment.startMs || segment.endMs > value.durationMs) return 'caption-time-range-invalid'
41
+ offset = segment.to; end = segment.endMs
42
+ }
43
+ return offset === text.length ? undefined : 'caption-text-incomplete'
44
+ }
45
+
46
+ /** The shared semantic mutation uses the existing document transaction and its complete source CAS. */
47
+ export function applyDocumentCaptionTiming(document: TeachingDocument, action: DocumentCaptionTimingAction) {
48
+ const step = document.steps[action.stepId]
49
+ if (!step) throw new DocumentCaptionTimingError(404, 'caption-step-missing')
50
+ const content = structuredClone(document)
51
+ if (action.type === 'caption.timing.reset') {
52
+ if (content.production.captionTimings) delete content.production.captionTimings[action.stepId]
53
+ } else {
54
+ const value = action.value
55
+ if (JSON.stringify(value.narrationIds) !== JSON.stringify(step.narrationIds) || value.sourceFingerprint !== captionSourceFingerprint(document, step.id)) throw new DocumentCaptionTimingError(409, 'caption-source-changed')
56
+ const issue = captionTimingProblem(value, stepCaptionText(document, step.id))
57
+ if (issue) throw new DocumentCaptionTimingError(422, issue)
58
+ if (step.narrationIds.some(id => document.narrations[id]?.content.some(node => node.type === 'cue' && node.action === 'next'))) throw new DocumentCaptionTimingError(422, 'caption-cross-step-unsupported')
59
+ content.production.captionTimings ??= {}
60
+ Object.defineProperty(content.production.captionTimings, step.id, { value: structuredClone(value), configurable: true, enumerable: true, writable: true })
61
+ }
62
+ return { content, effect: { stepId: step.id, segments: action.type === 'caption.timing.set' ? action.value.segments.length : 0,
63
+ changed: JSON.stringify(content.production.captionTimings) !== JSON.stringify(document.production.captionTimings), mode: action.type === 'caption.timing.set' ? 'manual' as const : 'automatic' as const } }
64
+ }
@@ -0,0 +1,25 @@
1
+ /** Semantic colours an author picks for text and backgrounds; the saved value is the design token itself. */
2
+ export type DocumentColorName = 'muted' | 'accent' | 'ok' | 'warn' | 'danger'
3
+ export type DocumentColorKind = 'text' | 'background'
4
+
5
+ const token = (name: string) => `var(--pm-color-${name})`
6
+ const BACKGROUND_TOKENS: Record<DocumentColorName, string> = { muted: 'muted-soft', accent: 'accent-soft', ok: 'ok-soft', warn: 'warn-soft', danger: 'danger-soft' }
7
+
8
+ export const DOCUMENT_COLOR_NAMES: readonly DocumentColorName[] = ['muted', 'accent', 'ok', 'warn', 'danger']
9
+
10
+ export function documentColorToken(name: DocumentColorName, kind: DocumentColorKind): string {
11
+ return token(kind === 'text' ? name : BACKGROUND_TOKENS[name])
12
+ }
13
+
14
+ // Earlier editors saved BlockNote's palette names; they are shown as the nearest semantic colour.
15
+ const LEGACY_NAMES: Record<string, DocumentColorName> = { gray: 'muted', brown: 'warn', red: 'danger', orange: 'warn', yellow: 'warn', green: 'ok', blue: 'accent', purple: 'accent', pink: 'danger' }
16
+
17
+ // The grey background was first saved as the page-inset token `sunk`, which hardly shows in dark mode.
18
+ const LEGACY_MUTED_BACKGROUND = token('sunk')
19
+
20
+ /** The CSS colour that paints a saved document colour value; the saved value itself is never rewritten. */
21
+ export function documentColorValue(value: string, kind: DocumentColorKind): string {
22
+ if (kind === 'background' && value === LEGACY_MUTED_BACKGROUND) return documentColorToken('muted', kind)
23
+ const legacy = LEGACY_NAMES[value]
24
+ return legacy ? documentColorToken(legacy, kind) : value
25
+ }
@@ -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
+ }