@lincyaw/workbench 0.1.0 → 0.2.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 (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -0,0 +1,27 @@
1
+ import { createContext, useContext, useSyncExternalStore } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { MESSAGES } from './messages.ts'
4
+ import type { WorkbenchMessages } from './messages.ts'
5
+ import type { Workbench, WorkbenchState } from './workbench.ts'
6
+
7
+ const WORKBENCH = createContext<Workbench | undefined>(undefined)
8
+
9
+ /** Gives the panels drawn in it one workbench. */
10
+ export function WorkbenchProvider({ workbench, children }: { workbench: Workbench, children?: ReactNode }) {
11
+ return <WORKBENCH.Provider value={workbench}>{children}</WORKBENCH.Provider>
12
+ }
13
+
14
+ export function useWorkbench(): Workbench {
15
+ const workbench = useContext(WORKBENCH)
16
+ if (!workbench) throw new Error('a workbench panel is drawn outside WorkbenchProvider')
17
+ return workbench
18
+ }
19
+
20
+ export function useWorkbenchState<T>(select: (state: WorkbenchState) => T): T {
21
+ const workbench = useWorkbench()
22
+ return useSyncExternalStore(workbench.subscribe, () => select(workbench.getState()))
23
+ }
24
+
25
+ export function useMessages(): WorkbenchMessages {
26
+ return MESSAGES[useWorkbenchState(state => state.locale)]
27
+ }
@@ -0,0 +1,113 @@
1
+ import { errorMessage } from '@lincyaw/kernel'
2
+ import type { Diagnostic, DiagnosticSnapshot, PreviewDiagnostics } from '@lincyaw/kernel'
3
+ import type { WorkbenchStore } from './workbench.ts'
4
+ import type { WorkspaceClient } from './workspace-client.ts'
5
+
6
+ type Repairer = (diagnostics: readonly Diagnostic[]) => void | Promise<void>
7
+
8
+ /**
9
+ * The open document's current diagnostics: the snapshots the workspace server pushes (marked unavailable while the
10
+ * stream fails), the preview's runtime findings reported to it, and the repair the conversation is asked for.
11
+ */
12
+ export class DocumentDiagnostics {
13
+ readonly #client: WorkspaceClient
14
+ readonly #store: WorkbenchStore
15
+ readonly #repairers = new Set<Repairer>()
16
+ #unsubscribe?: () => void
17
+ #scope = 0
18
+ #repairRequest = 0
19
+ #reporting: Promise<void> = Promise.resolve()
20
+
21
+ constructor(client: WorkspaceClient, store: WorkbenchStore) {
22
+ this.#client = client
23
+ this.#store = store
24
+ }
25
+
26
+ subscribe(dir: string): void {
27
+ if (this.#client.capabilities?.diagnostics === 'none') {
28
+ this.#unsubscribe?.(); this.#unsubscribe = undefined
29
+ // An unsupported producer is absent; it has not reported a successful check.
30
+ this.#store.set({ diagnostics: undefined, isDiagnosticsUnavailable: false })
31
+ return
32
+ }
33
+ const scope = this.#scope
34
+ let streamFailed = false
35
+ let liveReceived = false
36
+ const current = () => scope === this.#scope && this.#store.state.document === dir
37
+ const received = (diagnostics: DiagnosticSnapshot, live = false) => {
38
+ if (!current() || (!live && liveReceived)) return
39
+ if (live) {
40
+ liveReceived = true
41
+ streamFailed = false
42
+ }
43
+ this.#store.set({ diagnostics, isDiagnosticsUnavailable: streamFailed })
44
+ }
45
+ const failed = () => {
46
+ streamFailed = true
47
+ if (current() && !this.#store.state.isDiagnosticsUnavailable) this.#store.set({ isDiagnosticsUnavailable: true })
48
+ }
49
+ this.#unsubscribe = this.#client.subscribeDiagnostics(dir, snapshot => received(snapshot, true), failed)
50
+ void this.#client.readDiagnostics(dir).then(snapshot => received(snapshot), () => {
51
+ if (current() && !this.#store.state.diagnostics && !this.#store.state.isDiagnosticsUnavailable) this.#store.set({ isDiagnosticsUnavailable: true })
52
+ })
53
+ }
54
+
55
+ retry(): void {
56
+ const document = this.#store.state.document
57
+ if (document === undefined) return
58
+ this.#unsubscribe?.()
59
+ this.#scope++
60
+ this.subscribe(document)
61
+ }
62
+
63
+ /** Tells the workspace server a preview's runtime findings, one report at a time. */
64
+ reportPreview(report: PreviewDiagnostics): Promise<void> {
65
+ if (this.#client.capabilities?.diagnostics === 'none') return Promise.resolve()
66
+ const scope = this.#scope
67
+ this.#reporting = this.#reporting.then(async () => {
68
+ try {
69
+ await this.#client.reportDiagnostics(report)
70
+ } catch {
71
+ if (scope === this.#scope && this.#store.state.document === report.document && !this.#store.state.isDiagnosticsUnavailable) this.#store.set({ isDiagnosticsUnavailable: true })
72
+ }
73
+ })
74
+ return this.#reporting
75
+ }
76
+
77
+ onRepair(repairer: Repairer): () => void {
78
+ this.#repairers.add(repairer)
79
+ this.#store.set({ canRepairDiagnostics: true })
80
+ return () => {
81
+ this.#repairers.delete(repairer)
82
+ this.#store.set({ canRepairDiagnostics: this.#repairers.size > 0 })
83
+ }
84
+ }
85
+
86
+ async requestRepair(): Promise<void> {
87
+ const { state } = this.#store
88
+ const diagnostics = state.diagnostics?.diagnostics
89
+ if (!diagnostics?.length || this.#repairers.size === 0 || state.isRepairingDiagnostics || state.isDiagnosticsUnavailable) return
90
+ const request = ++this.#repairRequest
91
+ this.#store.set({ isRepairingDiagnostics: true, diagnosticsRepairFailure: undefined })
92
+ try {
93
+ await Promise.all([...this.#repairers].map(repairer => repairer(diagnostics)))
94
+ } catch (error) {
95
+ if (request === this.#repairRequest) this.#store.set({ diagnosticsRepairFailure: errorMessage(error) })
96
+ } finally {
97
+ if (request === this.#repairRequest) this.#store.set({ isRepairingDiagnostics: false })
98
+ }
99
+ }
100
+
101
+ /** Stops following the document; reports and repairs still under way no longer change the state. */
102
+ stop(): void {
103
+ this.#repairRequest++
104
+ this.#unsubscribe?.()
105
+ this.#unsubscribe = undefined
106
+ this.#scope++
107
+ }
108
+
109
+ dispose(): void {
110
+ this.stop()
111
+ this.#repairers.clear()
112
+ }
113
+ }
@@ -0,0 +1,179 @@
1
+ export type DraftStoreResult = 'committed' | 'superseded' | 'failed'
2
+ export interface DraftStoreCommit { generation: number, done: Promise<DraftStoreResult> }
3
+ interface Operation extends DraftStoreCommit {
4
+ key: string
5
+ raw?: string
6
+ markers: readonly [string, string][]
7
+ finish(result: DraftStoreResult): void
8
+ }
9
+ const DATABASE = 'presmium.document-drafts.v1'
10
+ const ENTRIES = 'backups'
11
+
12
+ /** One actor's latest snapshot replaces only its pending snapshot, never its in-flight transaction. */
13
+ export class DocumentDraftStore {
14
+ #database?: Promise<IDBDatabase>
15
+ #channel?: BroadcastChannel
16
+ #closing = false
17
+ #generation = 0
18
+ #flight?: Operation
19
+ #pending?: Operation
20
+ readonly #confirmed = new Map<string, string>()
21
+ readonly #foreign = new Set<string>()
22
+ readonly #scope: string
23
+ readonly #writerOwner?: string
24
+ readonly #family?: 'comment' | 'component'
25
+
26
+ constructor(scope: string, changed?: () => void, notifications?: { owner?: string, family?: 'comment' | 'component' }) {
27
+ this.#scope = scope
28
+ const writerOwner = this.#writerOwner = notifications?.owner, family = this.#family = notifications?.family
29
+ if (typeof BroadcastChannel !== 'undefined') {
30
+ try {
31
+ this.#channel = new BroadcastChannel(DATABASE)
32
+ this.#channel.onmessage = event => { if (!this.#closing && event.data?.scope === scope && (!family || !event.data.family || event.data.family === family) && (!writerOwner || event.data.owner !== writerOwner)) changed?.() }
33
+ } catch { /* Reopening still reads actual storage when cross-tab notification is unavailable. */ }
34
+ }
35
+ }
36
+
37
+ conflicted(key: string): boolean { return this.#foreign.has(key) }
38
+ owns(key: string, raw: string): boolean { return this.#confirmed.get(key) === raw }
39
+ release(key: string): void { if (this.#flight?.key !== key && this.#pending?.key !== key) this.#confirmed.delete(key) }
40
+
41
+ #db(): Promise<IDBDatabase> {
42
+ if (!this.#database) {
43
+ const work = new Promise<IDBDatabase>((resolve, reject) => {
44
+ let finished = false
45
+ const request = indexedDB.open(DATABASE, 1)
46
+ request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(ENTRIES)) request.result.createObjectStore(ENTRIES) }
47
+ request.onerror = () => { finished = true; reject(request.error) }
48
+ request.onblocked = () => { finished = true; reject(new Error('document-draft-storage-blocked')) }
49
+ request.onsuccess = () => {
50
+ const db = request.result
51
+ if (finished) { db.close(); return }
52
+ finished = true
53
+ const reset = () => { if (this.#database === work) this.#database = undefined }
54
+ db.onversionchange = () => { db.close(); reset() }
55
+ db.onclose = reset
56
+ resolve(db)
57
+ }
58
+ })
59
+ this.#database = work
60
+ void work.catch(() => { if (this.#database === work) this.#database = undefined })
61
+ }
62
+ return this.#database
63
+ }
64
+
65
+ async read(prefixes: readonly string[], signal?: AbortSignal, maxEntries?: number): Promise<Map<string, unknown>> {
66
+ if (this.#closing) throw new Error('document-draft-storage-closed')
67
+ if (maxEntries !== undefined && (!Number.isSafeInteger(maxEntries) || maxEntries < 1)) throw new Error('document-draft-read-limit')
68
+ signal?.throwIfAborted()
69
+ const db = await new Promise<IDBDatabase>((resolve, reject) => {
70
+ const cancel = () => { signal?.removeEventListener('abort', cancel); reject(signal?.reason) }
71
+ signal?.addEventListener('abort', cancel, { once: true })
72
+ this.#db().then(value => { signal?.removeEventListener('abort', cancel); resolve(value) }, error => { signal?.removeEventListener('abort', cancel); reject(error) })
73
+ })
74
+ signal?.throwIfAborted()
75
+ if (this.#closing) throw new Error('document-draft-storage-closed')
76
+ return new Promise((resolve, reject) => {
77
+ const values = new Map<string, unknown>(), tx = db.transaction(ENTRIES, 'readonly'), store = tx.objectStore(ENTRIES)
78
+ let exceeded = false
79
+ const cancel = () => { try { tx.abort() } catch { /* The readonly transaction may already have completed. */ } }
80
+ const stop = () => signal?.removeEventListener('abort', cancel)
81
+ signal?.addEventListener('abort', cancel, { once: true })
82
+ tx.oncomplete = () => { stop(); signal?.aborted ? reject(signal.reason) : resolve(values) }
83
+ tx.onabort = () => { stop(); reject(signal?.aborted ? signal.reason : exceeded ? new Error('document-draft-read-limit') : tx.error ?? new Error('document-draft-read-aborted')) }
84
+ tx.onerror = () => { stop(); reject(tx.error) }
85
+ for (const prefix of prefixes) {
86
+ const request = store.openCursor(IDBKeyRange.bound(prefix, `${prefix}\uffff`))
87
+ request.onsuccess = () => {
88
+ const cursor = request.result
89
+ if (cursor) {
90
+ const key = String(cursor.key)
91
+ if (maxEntries !== undefined && values.size >= maxEntries && !values.has(key)) { exceeded = true; tx.abort(); return }
92
+ values.set(key, cursor.value); cursor.continue()
93
+ }
94
+ }
95
+ }
96
+ })
97
+ }
98
+
99
+ #notify(): void { if (!this.#closing) this.#channel?.postMessage({ scope: this.#scope, ...this.#writerOwner ? { owner: this.#writerOwner } : {}, ...this.#family ? { family: this.#family } : {} }) }
100
+
101
+ async #commit(key: string | undefined, raw: string | undefined, markers: readonly [string, string][]): Promise<{ committed: boolean, foreign: boolean }> {
102
+ try {
103
+ const db = await this.#db()
104
+ return await new Promise(resolve => {
105
+ const tx = db.transaction(ENTRIES, 'readwrite', { durability: 'strict' }), store = tx.objectStore(ENTRIES)
106
+ let foreign = false
107
+ tx.oncomplete = () => resolve({ committed: true, foreign })
108
+ tx.onabort = () => resolve({ committed: false, foreign })
109
+ tx.onerror = () => { /* The abort event delivers the terminal transaction result. */ }
110
+ if (tx.durability !== 'strict') { tx.abort(); return }
111
+ const apply = () => {
112
+ for (const [marker, value] of markers) store.put(value, marker)
113
+ }
114
+ if (key === undefined) { apply(); return }
115
+ const request = store.get(key), expected = this.#confirmed.get(key)
116
+ request.onsuccess = () => {
117
+ foreign = request.result !== expected
118
+ if (raw !== undefined) {
119
+ if (foreign) { tx.abort(); return }
120
+ store.put(raw, key)
121
+ } else if (!foreign) store.delete(key)
122
+ apply()
123
+ }
124
+ })
125
+ } catch { return { committed: false, foreign: false } }
126
+ }
127
+
128
+ #stage(key: string, raw: string | undefined, markers: readonly [string, string][]): DraftStoreCommit | undefined {
129
+ if (this.#closing) return
130
+ let finish!: (result: DraftStoreResult) => void
131
+ const done = new Promise<DraftStoreResult>(resolve => finish = resolve)
132
+ const next: Operation = { generation: ++this.#generation, key, raw, markers, done, finish }
133
+ this.#pending?.finish('superseded')
134
+ this.#pending = next
135
+ void this.#drain()
136
+ return next
137
+ }
138
+
139
+ put(key: string, raw: string): DraftStoreCommit | undefined { return this.#stage(key, raw, []) }
140
+ remove(key: string, markers: readonly [string, string][]): DraftStoreCommit | undefined { return this.#stage(key, undefined, markers) }
141
+
142
+ async #drain(): Promise<void> {
143
+ if (this.#flight || !this.#pending) return
144
+ const operation = this.#pending
145
+ this.#pending = undefined; this.#flight = operation
146
+ const result = await this.#commit(operation.key, operation.raw, operation.markers)
147
+ if (result.foreign) { this.#foreign.add(operation.key); this.#confirmed.delete(operation.key) }
148
+ if (result.committed) {
149
+ if (operation.raw === undefined) this.#confirmed.delete(operation.key)
150
+ else this.#confirmed.set(operation.key, operation.raw)
151
+ }
152
+ this.#flight = undefined
153
+ operation.finish(result.committed ? 'committed' : 'failed')
154
+ if (result.committed) this.#notify()
155
+ if (this.#pending) void this.#drain()
156
+ else if (this.#closing) this.#closeDatabase()
157
+ }
158
+
159
+ /** A candidate copy must be strictly committed before the actor switches to it. */
160
+ async create(key: string, raw: string): Promise<boolean> {
161
+ if (this.#closing) return false
162
+ const result = await this.#commit(key, raw, [])
163
+ if (result.committed) { this.#confirmed.set(key, raw); this.#notify() }
164
+ return result.committed
165
+ }
166
+
167
+ async mark(markers: readonly [string, string][]): Promise<boolean> {
168
+ if (this.#closing) return false
169
+ const result = await this.#commit(undefined, undefined, markers)
170
+ if (result.committed) this.#notify()
171
+ return result.committed
172
+ }
173
+
174
+ #closeDatabase(): void { void this.#database?.then(db => db.close(), () => {}) }
175
+ close(): void {
176
+ this.#closing = true; this.#channel?.close()
177
+ if (!this.#flight && !this.#pending) this.#closeDatabase()
178
+ }
179
+ }
@@ -0,0 +1,178 @@
1
+ import { isRecord, validateDocument } from '@lincyaw/kernel'
2
+ import type { TeachingDocument } from '@lincyaw/kernel'
3
+ import { DocumentDraftStore } from './document-draft-store.ts'
4
+ import type { DraftStoreCommit } from './document-draft-store.ts'
5
+
6
+ export interface DocumentDraftReference { id: string, revision: number, token: string }
7
+ export interface DocumentDraft {
8
+ content: TeachingDocument
9
+ baseVersion: string
10
+ baseHash: string
11
+ input: number
12
+ pendingHash?: string
13
+ composing?: boolean
14
+ origins?: DocumentDraftReference[]
15
+ }
16
+ export interface StoredDocumentDraft extends DocumentDraft {
17
+ format: 2
18
+ identity: string
19
+ id: string
20
+ owner: string
21
+ revision: number
22
+ updatedAt: number
23
+ }
24
+ export interface RecoverableDocumentDraft extends StoredDocumentDraft { token: string, source: 'durable' | 'rescue', owned: boolean }
25
+ export interface DocumentDraftChoice extends DocumentDraftReference { updatedAt: number, baseVersion: string, source: 'durable' | 'rescue' }
26
+ export interface DocumentDraftCommit extends DraftStoreCommit { record: StoredDocumentDraft, clearing: boolean }
27
+
28
+ const PREFIX = 'presmium.document.v2:'
29
+ const SETTLED = 'presmium.document-settled.v1:'
30
+ const MAX_CHARS = 8 << 20
31
+ const prefix = (identity: string) => `${PREFIX}${JSON.stringify(identity)}:`
32
+ const markerPrefix = (identity: string) => `${SETTLED}[${JSON.stringify(identity)},`
33
+ const keyOf = (identity: string, id: string) => `${prefix(identity)}${id}`
34
+ const settledKey = (identity: string, value: DocumentDraftReference) => `${SETTLED}${JSON.stringify([identity, value.id, value.revision, value.token])}`
35
+ const idRevision = (value: unknown): value is Record<string, unknown> & { id: string, revision: number } => isRecord(value) && typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 200 && Number.isSafeInteger(value.revision) && (value.revision as number) > 0
36
+ const reference = (value: unknown): value is DocumentDraftReference => idRevision(value) && 'token' in value && typeof value.token === 'string' && /^[a-f0-9]{64}$/.test(value.token)
37
+ const draftShape = (value: unknown): value is DocumentDraft => isRecord(value) && typeof value.baseVersion === 'string' && value.baseVersion.length > 0 && value.baseVersion.length <= 200 && typeof value.baseHash === 'string' && value.baseHash.length > 0
38
+ && Number.isSafeInteger(value.input) && (value.input as number) >= 0 && (value.pendingHash === undefined || typeof value.pendingHash === 'string')
39
+ && (value.composing === undefined || typeof value.composing === 'boolean') && (value.origins === undefined || Array.isArray(value.origins) && value.origins.length <= 1024 && value.origins.every(reference))
40
+
41
+ function documentDraft(value: unknown, documentId: string): DocumentDraft | undefined {
42
+ if (!draftShape(value)) return
43
+ const checked = validateDocument(value.content)
44
+ if (!checked.document || checked.document.id !== documentId || checked.issues.some(issue => issue.severity === 'error')) return
45
+ return { content: checked.document, baseVersion: value.baseVersion, baseHash: value.baseHash, input: value.input,
46
+ pendingHash: value.pendingHash, composing: value.composing, origins: value.origins }
47
+ }
48
+ async function tokenOf(raw: string): Promise<string> {
49
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw))
50
+ return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, '0')).join('')
51
+ }
52
+
53
+ /** Strictly committed document backups, with old localStorage copies identified as unconfirmed rescue material. */
54
+ export class DocumentDrafts {
55
+ readonly #owner = crypto.randomUUID()
56
+ readonly #owned = new Map<string, { identity: string, revision: number, raw: string }>()
57
+ #store?: DocumentDraftStore
58
+ #identity?: string
59
+ #closed = false
60
+ readonly #changed?: () => void
61
+ constructor(changed?: () => void) { this.#changed = changed }
62
+
63
+ #storage(identity: string): DocumentDraftStore | undefined {
64
+ if (this.#closed || this.#identity !== undefined && this.#identity !== identity) return
65
+ this.#identity = identity
66
+ return this.#store ??= new DocumentDraftStore(identity, this.#changed)
67
+ }
68
+ matches(identity: string, key: string | null): boolean { return key === null || key.startsWith(prefix(identity)) || key.startsWith(markerPrefix(identity)) }
69
+
70
+ async read(identity: string, documentId: string): Promise<{ records: RecoverableDocumentDraft[], failed: boolean }> {
71
+ const store = this.#storage(identity), values: { key: string, raw: unknown, source: 'durable' | 'rescue' }[] = []
72
+ let failed = false, durable = new Map<string, unknown>()
73
+ if (!store) return { records: [], failed: true }
74
+ try {
75
+ durable = await store.read([prefix(identity), markerPrefix(identity)])
76
+ for (const [key, raw] of durable) if (key.startsWith(prefix(identity))) values.push({ key, raw, source: 'durable' })
77
+ } catch { failed = true }
78
+ // Existing localStorage input is never removed or silently treated as a strict commit.
79
+ try {
80
+ const keys: string[] = []
81
+ for (let index = 0; index < localStorage.length; index++) {
82
+ const key = localStorage.key(index)
83
+ if (key?.startsWith(prefix(identity))) keys.push(key)
84
+ }
85
+ for (const key of keys) {
86
+ const raw = localStorage.getItem(key)
87
+ if (raw !== null) values.push({ key, raw, source: 'rescue' })
88
+ }
89
+ } catch { failed = true }
90
+ const records = new Map<string, RecoverableDocumentDraft>()
91
+ for (const { key, raw, source } of values) {
92
+ try {
93
+ if (typeof raw !== 'string' || raw.length > MAX_CHARS) throw new Error('document-draft-size')
94
+ const value: unknown = JSON.parse(raw)
95
+ if (!isRecord(value) || value.format !== 2 || value.identity !== identity || !idRevision(value) || key !== keyOf(identity, value.id)
96
+ || typeof value.owner !== 'string' || !value.owner || value.owner.length > 200 || typeof value.updatedAt !== 'number' || !Number.isSafeInteger(value.updatedAt) || value.updatedAt < 0 || Number.isNaN(new Date(value.updatedAt).getTime())) throw new Error('document-draft-data')
97
+ const draft = documentDraft(value, documentId)
98
+ if (!draft) throw new Error('document-draft-document')
99
+ const token = await tokenOf(raw), marker = settledKey(identity, { id: value.id, revision: value.revision, token })
100
+ if (durable.get(marker) === token) continue
101
+ if (source === 'rescue' && localStorage.getItem(marker) === token) continue
102
+ const id = JSON.stringify([value.id, value.revision, token])
103
+ if (!records.has(id)) records.set(id, { ...draft, format: 2, identity, id: value.id, owner: value.owner, revision: value.revision, updatedAt: value.updatedAt, token, source,
104
+ owned: source === 'durable' && value.owner === this.#owner && (this.#owned.get(value.id)?.raw === raw || store.owns(key, raw)) })
105
+ } catch { failed = true }
106
+ }
107
+ return { records: [...records.values()], failed }
108
+ }
109
+
110
+ #record(identity: string, draft: DocumentDraft, previous?: StoredDocumentDraft): { record: StoredDocumentDraft, raw: string } | undefined {
111
+ const store = this.#storage(identity)
112
+ if (!store || !draftShape(draft)) return
113
+ const owned = previous && this.#owned.get(previous.id)
114
+ if (previous && (!owned || owned.identity !== identity || previous.owner !== this.#owner || previous.revision !== owned.revision)) return
115
+ const changed = previous && store.conflicted(keyOf(identity, previous.id))
116
+ const record: StoredDocumentDraft = { ...draft, format: 2, identity, id: !changed && previous ? previous.id : crypto.randomUUID(), owner: this.#owner, revision: changed ? 1 : (owned?.revision ?? 0) + 1, updatedAt: Date.now() }
117
+ if (!Number.isSafeInteger(record.revision)) return
118
+ try { const raw = JSON.stringify(record); return raw.length <= MAX_CHARS ? { record, raw } : undefined }
119
+ catch { return }
120
+ }
121
+
122
+ /** Admission is immediate; only done='committed' confirms a durable backup. */
123
+ write(identity: string, draft: DocumentDraft, previous?: StoredDocumentDraft): DocumentDraftCommit | undefined {
124
+ const next = this.#record(identity, draft, previous)
125
+ if (!next) return
126
+ const commit = this.#store!.put(keyOf(identity, next.record.id), next.raw)
127
+ if (!commit) return
128
+ this.#owned.set(next.record.id, { identity, revision: next.record.revision, raw: next.raw })
129
+ if (previous && previous.id !== next.record.id) this.archive(previous)
130
+ return { ...commit, record: next.record, clearing: false }
131
+ }
132
+
133
+ async fork(identity: string, draft: DocumentDraft): Promise<StoredDocumentDraft | undefined> {
134
+ const next = this.#record(identity, draft)
135
+ if (!next || !await this.#store!.create(keyOf(identity, next.record.id), next.raw)) return
136
+ this.#owned.set(next.record.id, { identity, revision: next.record.revision, raw: next.raw })
137
+ return next.record
138
+ }
139
+
140
+ /** Immutable, revision-specific markers never delete another tab's newer record. */
141
+ async settle(identity: string, values: readonly DocumentDraftReference[]): Promise<boolean> {
142
+ const store = this.#storage(identity)
143
+ return Boolean(store && await store.mark(values.map(value => [settledKey(identity, value), value.token])))
144
+ }
145
+
146
+ remove(identity: string, record: StoredDocumentDraft, origins: readonly DocumentDraftReference[]): DocumentDraftCommit | undefined {
147
+ const owned = this.#owned.get(record.id), store = this.#storage(identity)
148
+ if (!store || !owned || owned.identity !== identity || record.owner !== this.#owner || owned.revision !== record.revision) return
149
+ const commit = store.remove(keyOf(identity, record.id), origins.map(value => [settledKey(identity, value), value.token]))
150
+ if (!commit) return
151
+ void commit.done.then(result => { if (result === 'committed' && this.#owned.get(record.id)?.revision === record.revision) this.#owned.delete(record.id) })
152
+ return { ...commit, record, clearing: true }
153
+ }
154
+
155
+ /** The old tab-local snapshot is removed only after a validated strict copy exists. */
156
+ async migrate(identity: string, documentId: string, legacyKey: string): Promise<'read' | 'write' | undefined> {
157
+ let raw: string | null, draft: DocumentDraft | undefined
158
+ try {
159
+ raw = sessionStorage.getItem(legacyKey)
160
+ if (raw === null) return
161
+ if (raw.length > MAX_CHARS) return 'read'
162
+ draft = documentDraft(JSON.parse(raw), documentId)
163
+ if (!draft) return 'read'
164
+ } catch { return 'read' }
165
+ try {
166
+ const found = (await this.read(identity, documentId)).records.find(record => record.source === 'durable' && record.baseVersion === draft.baseVersion && record.baseHash === draft.baseHash
167
+ && record.input === draft.input && record.pendingHash === draft.pendingHash && Boolean(record.composing) === Boolean(draft.composing) && JSON.stringify(record.content) === JSON.stringify(draft.content))
168
+ const latest = sessionStorage.getItem(legacyKey)
169
+ if (latest !== raw) return latest === null ? undefined : 'read'
170
+ if (!found && !await this.fork(identity, draft)) return 'write'
171
+ } catch { return 'read' }
172
+ try { if (sessionStorage.getItem(legacyKey) === raw) sessionStorage.removeItem(legacyKey) }
173
+ catch { /* The strict copy exists; keep the old bytes if cleanup is unavailable. */ }
174
+ }
175
+
176
+ close(): void { this.#closed = true; this.#store?.close() }
177
+ archive(record: StoredDocumentDraft): void { this.#owned.delete(record.id); this.#store?.release(keyOf(record.identity, record.id)) }
178
+ }