@lincyaw/workbench 0.1.0 → 0.2.0-rc.0
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/README.md +40 -8
- package/package.json +27 -22
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +547 -90
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +33 -43
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +751 -203
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +456 -44
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +757 -361
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- package/src/store.ts +0 -599
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CaptionTimingSource, TeachingDocument } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export interface CaptionTimingRow { id: string, from: number, to: number, start: string, end: string }
|
|
4
|
+
export interface CaptionTimingDraft { version: string, text: string, basis: CaptionTimingSource, rows: CaptionTimingRow[] }
|
|
5
|
+
type Scope = { identity: string, memberId: string }
|
|
6
|
+
interface Entry { values: Map<string, CaptionTimingDraft>, failed: boolean, unreadable: boolean }
|
|
7
|
+
const MAX_CHARS = 1 << 20
|
|
8
|
+
const MAX_TARGETS = 512
|
|
9
|
+
const valid = (value: unknown): value is CaptionTimingDraft => {
|
|
10
|
+
if (!value || typeof value !== 'object') return false
|
|
11
|
+
const d = value as CaptionTimingDraft, b = d.basis
|
|
12
|
+
if (typeof d.version !== 'string' || !d.version || d.version.length > 256 || typeof d.text !== 'string' || d.text.length > MAX_CHARS
|
|
13
|
+
|| !b || typeof b !== 'object' || typeof b.available !== 'boolean' || typeof b.sourceFingerprint !== 'string'
|
|
14
|
+
|| b.sourceFingerprint.length > 256 || b.playbackFingerprint !== undefined && (typeof b.playbackFingerprint !== 'string' || b.playbackFingerprint.length > 256)
|
|
15
|
+
|| b.durationMs !== undefined && (!Number.isSafeInteger(b.durationMs) || b.durationMs < 0)
|
|
16
|
+
|| b.reason !== undefined && (typeof b.reason !== 'string' || b.reason.length > 256)
|
|
17
|
+
|| !Array.isArray(b.narrationIds) || b.narrationIds.length > MAX_TARGETS || b.narrationIds.some(id => typeof id !== 'string' || id.length > 256)
|
|
18
|
+
|| !Array.isArray(d.rows) || d.rows.length > MAX_TARGETS) return false
|
|
19
|
+
return d.rows.every(r => r && typeof r.id === 'string' && r.id.length <= 256 && Number.isSafeInteger(r.from) && r.from >= 0
|
|
20
|
+
&& Number.isSafeInteger(r.to) && r.to >= r.from && r.to <= d.text.length && typeof r.start === 'string' && r.start.length <= 32 && typeof r.end === 'string' && r.end.length <= 32)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Unapplied author inputs are per-tab recovery data, never the canonical production timing. */
|
|
24
|
+
export class CaptionTimingDrafts {
|
|
25
|
+
readonly #entries = new Map<string, Entry>()
|
|
26
|
+
readonly #leave = (event: BeforeUnloadEvent) => { if ([...this.#entries.values()].some(entry => entry.values.size || entry.failed)) event.preventDefault() }
|
|
27
|
+
constructor() { if (typeof window !== 'undefined') window.addEventListener('beforeunload', this.#leave) }
|
|
28
|
+
#key(scope: Scope): string { return `presmium.caption-timing.v1:${JSON.stringify([scope.identity, scope.memberId])}` }
|
|
29
|
+
#entry(scope: Scope): Entry {
|
|
30
|
+
const key = this.#key(scope), existing = this.#entries.get(key)
|
|
31
|
+
if (existing) return existing
|
|
32
|
+
const result: Entry = { values: new Map(), failed: false, unreadable: false }; this.#entries.set(key, result)
|
|
33
|
+
try {
|
|
34
|
+
const raw = sessionStorage.getItem(key)
|
|
35
|
+
if (raw === null) return result
|
|
36
|
+
if (raw.length > MAX_CHARS) throw new Error('caption-draft-limit')
|
|
37
|
+
const data: unknown = JSON.parse(raw)
|
|
38
|
+
if (!Array.isArray(data) || data.length > MAX_TARGETS) throw new Error('caption-draft-data')
|
|
39
|
+
for (const pair of data) {
|
|
40
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string' || !pair[0] || pair[0].length > 256 || !valid(pair[1]) || result.values.has(pair[0])) throw new Error('caption-draft-data')
|
|
41
|
+
result.values.set(pair[0], pair[1])
|
|
42
|
+
}
|
|
43
|
+
} catch { result.values.clear(); result.failed = true; result.unreadable = true }
|
|
44
|
+
return result
|
|
45
|
+
}
|
|
46
|
+
get(scope: Scope, stepId: string): CaptionTimingDraft | undefined { return this.#entry(scope).values.get(stepId) }
|
|
47
|
+
steps(scope: Scope, document: TeachingDocument): string[] { return [...this.#entry(scope).values.keys()].filter(id => Object.hasOwn(document.steps, id)) }
|
|
48
|
+
failed(scope: Scope): boolean { return this.#entry(scope).failed }
|
|
49
|
+
set(scope: Scope, stepId: string, value: CaptionTimingDraft | undefined): void {
|
|
50
|
+
const entry = this.#entry(scope)
|
|
51
|
+
if (value) entry.values.set(stepId, value); else entry.values.delete(stepId)
|
|
52
|
+
if (entry.unreadable) { entry.failed = true; return }
|
|
53
|
+
try {
|
|
54
|
+
const key = this.#key(scope), raw = JSON.stringify([...entry.values])
|
|
55
|
+
if (raw.length > MAX_CHARS || entry.values.size > MAX_TARGETS || [...entry.values.values()].some(draft => !valid(draft))) throw new Error('caption-draft-limit')
|
|
56
|
+
if (entry.values.size) sessionStorage.setItem(key, raw); else sessionStorage.removeItem(key)
|
|
57
|
+
entry.failed = false
|
|
58
|
+
} catch { entry.failed = true }
|
|
59
|
+
}
|
|
60
|
+
close(): void { if (typeof window !== 'undefined') window.removeEventListener('beforeunload', this.#leave) }
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const CAPTION_TIMING_MESSAGES = {
|
|
2
|
+
'zh-CN': {
|
|
3
|
+
title: '手动字幕时间', open: '设置字幕时间', cancel: '取消', done: '完成', apply: '应用', automatic: '使用自动时间', split: '按换行分段',
|
|
4
|
+
start: '开始(秒)', end: '结束(秒)', step: '本步骤 · 时间从步骤起点计算', invalid: '时间须递增且不超出本步骤。',
|
|
5
|
+
loading: '正在加载字幕时间…', prepare: '请先准备当前步骤。', unavailable: '当前步骤的字幕时间不可用。', changed: '讲稿或音频已变化。', recheck: '再次检查', saved: '字幕时间已保存到当前步骤。',
|
|
6
|
+
failed: '无法确认字幕时间已保存。输入已保留,请重试。', storageFailed: '无法备份字幕时间,请保持此页面打开。', discard: '放弃修改', limit: '本次最多设置 32 段。', segment: (n: number) => `字幕 ${n}`,
|
|
7
|
+
},
|
|
8
|
+
en: {
|
|
9
|
+
title: 'Manual Caption Timing', open: 'Set Caption Times', cancel: 'Cancel', done: 'Done', apply: 'Apply', automatic: 'Use Automatic Times', split: 'Split at Line Breaks',
|
|
10
|
+
start: 'Start (seconds)', end: 'End (seconds)', step: 'Current step · Times start at the step boundary', invalid: 'Times must increase and fit inside this step.',
|
|
11
|
+
loading: 'Loading caption times…', prepare: 'Prepare the current step first.', unavailable: 'Caption timing is unavailable for this step.', changed: 'The script or audio has changed.', recheck: 'Check Again', saved: 'Caption times saved to this step.',
|
|
12
|
+
failed: 'Couldn’t confirm that caption times were saved. Your input is kept; try again.', storageFailed: 'Couldn’t back up caption times. Keep this page open.', discard: 'Discard Changes', limit: 'Set no more than 32 segments at once.', segment: (n: number) => `Caption ${n}`,
|
|
13
|
+
},
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.pmw-caption-times {
|
|
2
|
+
display: grid;
|
|
3
|
+
gap: var(--pm-space-3);
|
|
4
|
+
min-width: 0;
|
|
5
|
+
}
|
|
6
|
+
.pmw-caption-times__text {
|
|
7
|
+
margin: 0;
|
|
8
|
+
white-space: pre-wrap;
|
|
9
|
+
overflow-wrap: anywhere;
|
|
10
|
+
}
|
|
11
|
+
.pmw-caption-times__range {
|
|
12
|
+
display: grid;
|
|
13
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
14
|
+
gap: var(--pm-space-2);
|
|
15
|
+
}
|
|
16
|
+
.pmw-caption-times__actions {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
gap: var(--pm-space-2);
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { applyDocumentCaptionTiming, captionSourceFingerprint } from '@lincyaw/kernel'
|
|
2
|
+
import type { DocumentAnswer, DocumentCaptionTimingAction, TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
5
|
+
/** The source actor adopts only this caption slot; later native body input remains owned by the editor. */
|
|
6
|
+
export function reconcileCaptionTiming(action: DocumentCaptionTimingAction, before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument): TeachingDocument | undefined {
|
|
7
|
+
try {
|
|
8
|
+
if (before.id !== answer.content.id || before.id !== current.id || !same(applyDocumentCaptionTiming(before, action).content, answer.content)) return
|
|
9
|
+
if (!current.steps[action.stepId]) return current
|
|
10
|
+
if (!same(before.production.captionTimings?.[action.stepId], current.production.captionTimings?.[action.stepId])
|
|
11
|
+
|| captionSourceFingerprint(before, action.stepId) !== captionSourceFingerprint(current, action.stepId)) return
|
|
12
|
+
return applyDocumentCaptionTiming(current, action).content
|
|
13
|
+
} catch { return }
|
|
14
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AuthoringClientAction, AuthoringClientActionResult, CanonicalSource, ClientComponentDraftReference } from '@lincyaw/kernel'
|
|
2
|
+
import { draftContentHash } from './authoring-draft-fragment.ts'
|
|
3
|
+
import { componentDraftMetadata, componentDraftReference } from './component-draft-inspection.ts'
|
|
4
|
+
import type { ComponentPropertyDraft, ComponentPropertyDrafts } from './component-property-drafts.ts'
|
|
5
|
+
|
|
6
|
+
export const sameComponentDraftReference = (a: ClientComponentDraftReference | null, b: ClientComponentDraftReference | null): boolean => a === null || b === null ? a === b
|
|
7
|
+
: a.id === b.id && a.revision === b.revision && a.token === b.token && a.blockId === b.blockId && a.stepId === b.stepId
|
|
8
|
+
|
|
9
|
+
/** Explicit form actions reuse the human bank; they never apply parameters to lecture.json. */
|
|
10
|
+
export async function mutateComponentDrafts(bank: ComponentPropertyDrafts, context: { identity: string, memberId: string, source: CanonicalSource }, action: Extract<AuthoringClientAction, { type: 'component-drafts.restore' | 'component-drafts.discard' }>, signal: AbortSignal, owned: (draft?: ComponentPropertyDraft) => void, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
|
|
11
|
+
const read = await bank.readForAgent(context, signal, action.draft)
|
|
12
|
+
owned()
|
|
13
|
+
const candidate = read.candidates[0]!, target = candidate.record.target, previous = bank.get(target)
|
|
14
|
+
owned(candidate.record)
|
|
15
|
+
const unchanged = () => { signal.throwIfAborted(); owned(candidate.record); if (bank.get(target) !== previous) throw new Error('client-component-drafts-changed') }
|
|
16
|
+
if (action.type === 'component-drafts.discard') {
|
|
17
|
+
if (candidate.current && (!previous || previous.id !== candidate.record.id || previous.revision !== candidate.record.revision)) throw new Error('client-component-drafts-changed')
|
|
18
|
+
if (candidate.current !== action.current) throw new Error('client-component-drafts-changed')
|
|
19
|
+
if (candidate.current && (candidate.storage !== 'durable' || candidate.status !== 'available' || candidate.record.composing || bank.status(target) !== 'available')) throw new Error('client-component-draft-needs-human')
|
|
20
|
+
const result: AuthoringClientActionResult = { type: action.type, family: 'component', draft: action.draft, current: action.current, discarded: true }
|
|
21
|
+
if (!fits(result)) throw new Error('client-component-result-too-large')
|
|
22
|
+
unchanged()
|
|
23
|
+
const discarded = candidate.current ? await bank.clear(previous!, { preserveOrigins: true }) : await bank.discard(candidate, signal, unchanged)
|
|
24
|
+
signal.throwIfAborted(); owned()
|
|
25
|
+
if (!discarded) throw new Error('client-component-drafts-changed')
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
if (candidate.current || candidate.record.composing) throw new Error('client-component-draft-needs-human')
|
|
29
|
+
if (previous) {
|
|
30
|
+
if (!action.expectedCurrent || action.expectedCurrent.blockId !== target.blockId || action.expectedCurrent.stepId !== target.stepId) throw new Error('client-component-drafts-changed')
|
|
31
|
+
const current = await bank.readForAgent(context, signal, action.expectedCurrent)
|
|
32
|
+
unchanged()
|
|
33
|
+
const value = current.candidates[0]!
|
|
34
|
+
if (!value.current || !sameComponentDraftReference(componentDraftReference(value), action.expectedCurrent)) throw new Error('client-component-drafts-changed')
|
|
35
|
+
if (value.storage !== 'durable' || value.status !== 'available' || value.record.composing || bank.status(target) !== 'available') throw new Error('client-component-draft-needs-human')
|
|
36
|
+
} else if (action.expectedCurrent !== null) throw new Error('client-component-drafts-changed')
|
|
37
|
+
unchanged()
|
|
38
|
+
if (!bank.canKeep(target)) throw new Error('client-component-drafts-limit')
|
|
39
|
+
const projected = { ...candidate.record, id: '0'.repeat(36), revision: 1, composing: false, updatedAt: new Date().toISOString() }
|
|
40
|
+
if (!fits({ type: action.type, family: 'component', draft: action.draft,
|
|
41
|
+
restored: componentDraftMetadata({ ...candidate, record: projected, token: '0'.repeat(64), current: true, storage: 'durable', status: 'available' }, context.source), preservedCurrent: action.expectedCurrent })) throw new Error('client-component-result-too-large')
|
|
42
|
+
const restored = await bank.recover(candidate, previous, signal, unchanged)
|
|
43
|
+
signal.throwIfAborted(); owned()
|
|
44
|
+
if (!restored) throw new Error('client-component-drafts-changed')
|
|
45
|
+
const record = bank.get(target)
|
|
46
|
+
if (!record || record.id === candidate.record.id || record.id === previous?.id || record.composing || bank.status(target) !== 'available') throw new Error('client-component-drafts-changed')
|
|
47
|
+
const token = await draftContentHash(JSON.stringify(record))
|
|
48
|
+
signal.throwIfAborted(); owned()
|
|
49
|
+
if (bank.get(target) !== record) throw new Error('client-component-drafts-changed')
|
|
50
|
+
const result: AuthoringClientActionResult = { type: action.type, family: 'component', draft: action.draft,
|
|
51
|
+
restored: componentDraftMetadata({ record, token, key: candidate.key, current: true, storage: 'durable', status: 'available' }, context.source), preservedCurrent: action.expectedCurrent }
|
|
52
|
+
if (!fits(result)) throw new Error('client-component-result-too-large')
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AuthoringClientAction, AuthoringClientActionResult, CanonicalSource, ClientComponentDraftMetadata } from '@lincyaw/kernel'
|
|
2
|
+
import { authoringDraftFragment, draftContentHash } from './authoring-draft-fragment.ts'
|
|
3
|
+
import type { ComponentPropertyAgentCandidate, ComponentPropertyDrafts } from './component-property-drafts.ts'
|
|
4
|
+
|
|
5
|
+
export const componentDraftReference = ({ record, token }: ComponentPropertyAgentCandidate) => ({ id: record.id, revision: record.revision, token, blockId: record.target.blockId, ...record.target.stepId !== undefined ? { stepId: record.target.stepId } : {} })
|
|
6
|
+
/** Metadata is an explicit whitelist: neither baseline/value nor trusted scope identities are spread. */
|
|
7
|
+
export function componentDraftMetadata(value: ComponentPropertyAgentCandidate, source: CanonicalSource): ClientComponentDraftMetadata {
|
|
8
|
+
const { record } = value
|
|
9
|
+
return { draft: componentDraftReference(value), current: value.current, definitionId: record.definitionId, storage: value.storage, status: value.status, updatedAt: Date.parse(record.updatedAt), baseVersion: record.source.version,
|
|
10
|
+
sourceCurrent: record.source.documentId === source.documentId && record.source.version === source.version && record.source.fingerprint === source.fingerprint, composing: record.composing, reset: record.reset }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function inspectComponentDrafts(drafts: ComponentPropertyDrafts, context: { identity: string, memberId: string, source: CanonicalSource }, action: Extract<AuthoringClientAction, { type: 'component-drafts.inspect' | 'component-drafts.read' }>, signal: AbortSignal, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
|
|
14
|
+
const read = await drafts.readForAgent(context, signal, action.type === 'component-drafts.read' ? action.draft : undefined)
|
|
15
|
+
const current = () => { signal.throwIfAborted(); if (!drafts.readable(context.identity, context.memberId) || drafts.revision(context.identity, context.memberId) !== read.revision) throw new Error('client-component-drafts-changed') }
|
|
16
|
+
if (action.type === 'component-drafts.inspect') {
|
|
17
|
+
const values = read.candidates.map(value => componentDraftMetadata(value, context.source)), token = await draftContentHash(JSON.stringify({ values, coverage: read.coverage, skippedInvalid: read.skippedInvalid }))
|
|
18
|
+
current()
|
|
19
|
+
if (action.cursor && action.cursor.token !== token) throw new Error('client-component-drafts-changed')
|
|
20
|
+
const start = action.cursor ? values.findIndex(value => value.draft.token === action.cursor!.after) + 1 : 0
|
|
21
|
+
if (action.cursor && start === 0) throw new Error('client-component-drafts-changed')
|
|
22
|
+
const result: Extract<AuthoringClientActionResult, { type: 'component-drafts.inspect' }> = { type: action.type, family: 'component', coverage: read.coverage, skippedInvalid: read.skippedInvalid, token, drafts: [] }
|
|
23
|
+
for (const value of values.slice(start, start + (action.limit ?? 10))) {
|
|
24
|
+
const next = { ...result, drafts: [...result.drafts, value], ...start + result.drafts.length + 1 < values.length ? { next: { after: value.draft.token, token } } : {} }
|
|
25
|
+
if (!fits(next)) break
|
|
26
|
+
result.drafts.push(value)
|
|
27
|
+
}
|
|
28
|
+
if (start < values.length && !result.drafts.length) throw new Error('client-component-metadata-too-large')
|
|
29
|
+
if (start + result.drafts.length < values.length) result.next = { after: result.drafts.at(-1)!.draft.token, token }
|
|
30
|
+
if (!fits(result)) throw new Error('client-component-metadata-too-large')
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
const value = read.candidates[0]!
|
|
34
|
+
return authoringDraftFragment({ text: JSON.stringify(value.record.value), base: { type: action.type, family: 'component', draft: componentDraftReference(value), storage: value.storage, format: 'presmium-component-parameters-json-v1' }, offset: action.offset, maxChars: action.maxChars }, current, fits)
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.component-properties__content,
|
|
2
|
+
.component-properties__backups,
|
|
3
|
+
.component-properties__references {
|
|
4
|
+
display: grid;
|
|
5
|
+
gap: var(--pm-space-3);
|
|
6
|
+
min-width: 0;
|
|
7
|
+
}
|
|
8
|
+
.component-properties__actions {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-wrap: wrap;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: var(--pm-space-2);
|
|
13
|
+
}
|
|
14
|
+
.component-properties__reference {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
gap: var(--pm-space-2);
|
|
19
|
+
min-width: 0;
|
|
20
|
+
}
|
|
21
|
+
.component-properties__reference > span {
|
|
22
|
+
min-width: 0;
|
|
23
|
+
overflow-wrap: anywhere;
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { applyDocumentComponentAction, createDocumentParameterChanges, effectiveComponentParameters } from '@lincyaw/kernel'
|
|
2
|
+
import type { CanonicalSource, DocumentAnswer, DocumentComponentAction, JsonObject, TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
import type { ComponentPropertyDraft } from './component-property-drafts.ts'
|
|
4
|
+
|
|
5
|
+
export const sameComponentSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
|
|
6
|
+
export const componentSource = (answer: Pick<DocumentAnswer, 'content' | 'version' | 'fingerprint'>): CanonicalSource => ({ documentId: answer.content.id, version: answer.version, fingerprint: answer.fingerprint })
|
|
7
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
8
|
+
|
|
9
|
+
export function componentPropertyAction(draft: ComponentPropertyDraft): DocumentComponentAction | undefined {
|
|
10
|
+
const { blockId, stepId } = draft.target
|
|
11
|
+
if (stepId && draft.reset) return { type: 'component.step-parameters.reset', blockId, stepId }
|
|
12
|
+
const changes = createDocumentParameterChanges(draft.baseline, draft.value)
|
|
13
|
+
if (!changes.length) return
|
|
14
|
+
return stepId ? { type: 'component.step-parameters.patch', blockId, stepId, changes } : { type: 'component.parameters.patch', blockId, changes }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A checked rebase borrows the same reducer as the service, preserving unrelated current data. */
|
|
18
|
+
export function rebaseComponentPropertyDraft(document: TeachingDocument, draft: ComponentPropertyDraft): JsonObject {
|
|
19
|
+
const block = document.blocks[draft.target.blockId]
|
|
20
|
+
if (block?.type !== 'component' || block.definitionId !== draft.definitionId) throw new Error('component-definition-changed')
|
|
21
|
+
const action = componentPropertyAction(draft)
|
|
22
|
+
const content = action ? applyDocumentComponentAction(document, action).content : document
|
|
23
|
+
return effectiveComponentParameters(content, block.id, draft.target.stepId).parameters
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An external acknowledgement owns its parameter paths, never a later body tree or sibling form. */
|
|
27
|
+
export function reconcileComponentAction(action: DocumentComponentAction, before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument): TeachingDocument | undefined {
|
|
28
|
+
try {
|
|
29
|
+
if (answer.content.id !== before.id || current.id !== before.id) return
|
|
30
|
+
const expected = applyDocumentComponentAction(before, action).content
|
|
31
|
+
if (!same(expected, answer.content)) return
|
|
32
|
+
const original = before.blocks[action.blockId], actual = current.blocks[action.blockId]
|
|
33
|
+
if (original?.type !== 'component') return
|
|
34
|
+
// A later native deletion stays deleted. Its history is updated separately from the verified canonical reply.
|
|
35
|
+
if (!actual) return current
|
|
36
|
+
if (actual.type !== 'component' || actual.definitionId !== original.definitionId || !same(actual.parameters, original.parameters)
|
|
37
|
+
|| !same(before.definitions[original.definitionId], current.definitions[actual.definitionId])) return
|
|
38
|
+
if ('stepId' in action) {
|
|
39
|
+
const oldStep = before.steps[action.stepId], newStep = current.steps[action.stepId]
|
|
40
|
+
if (!oldStep || !newStep || oldStep.sectionId !== newStep.sectionId || oldStep.inherits !== newStep.inherits
|
|
41
|
+
|| !same(effectiveComponentParameters(before, action.blockId, action.stepId), effectiveComponentParameters(current, action.blockId, action.stepId))) return
|
|
42
|
+
}
|
|
43
|
+
return applyDocumentComponentAction(current, action).content
|
|
44
|
+
} catch { return }
|
|
45
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { CanonicalSource, ClientComponentDraftReference, JsonObject } from '@lincyaw/kernel'
|
|
3
|
+
import { DocumentDraftStore } from './document-draft-store.ts'
|
|
4
|
+
import type { DraftStoreCommit } from './document-draft-store.ts'
|
|
5
|
+
|
|
6
|
+
export interface ComponentPropertyTarget { identity: string, memberId: string, blockId: string, stepId?: string }
|
|
7
|
+
export interface ComponentPropertyDraft {
|
|
8
|
+
format: 1
|
|
9
|
+
id: string
|
|
10
|
+
revision: number
|
|
11
|
+
target: ComponentPropertyTarget
|
|
12
|
+
source: CanonicalSource
|
|
13
|
+
definitionId: string
|
|
14
|
+
baseline: JsonObject
|
|
15
|
+
value: JsonObject
|
|
16
|
+
reset: boolean
|
|
17
|
+
composing: boolean
|
|
18
|
+
updatedAt: string
|
|
19
|
+
}
|
|
20
|
+
export interface ComponentPropertyBackup { record: ComponentPropertyDraft, token: string, key: string }
|
|
21
|
+
export interface ComponentPropertyBackupRead { candidates: ComponentPropertyBackup[], unreadable: number }
|
|
22
|
+
export interface ComponentPropertyAgentCandidate extends ComponentPropertyBackup { current: boolean, storage: 'durable' | 'session', status: 'available' | 'pending' | 'session-only' }
|
|
23
|
+
interface Entry { record: ComponentPropertyDraft, store: DocumentDraftStore, operation?: DraftStoreCommit, failed: boolean, origins: ComponentPropertyBackup[] }
|
|
24
|
+
const PREFIX = 'presmium.component-properties.v1:'
|
|
25
|
+
const SETTLED = 'presmium.component-properties-settled.v1:'
|
|
26
|
+
const MAX_CHARS = 1 << 20
|
|
27
|
+
const scope = (target: Pick<ComponentPropertyTarget, 'identity' | 'memberId'>) => JSON.stringify([target.identity, target.memberId])
|
|
28
|
+
const targetKey = (target: ComponentPropertyTarget) => JSON.stringify([target.identity, target.memberId, target.blockId, target.stepId ?? null])
|
|
29
|
+
const prefix = (target: ComponentPropertyTarget) => `${PREFIX}${targetKey(target)}:`
|
|
30
|
+
const markerPrefix = (target: ComponentPropertyTarget) => `${SETTLED}${targetKey(target)}:`
|
|
31
|
+
const scopedPrefix = (kind: string, identity: string, memberId: string) => `${kind}${JSON.stringify([identity, memberId]).slice(0, -1)},`
|
|
32
|
+
const markerOf = (target: ComponentPropertyTarget, reference: { id: string, revision: number, token: string }) => `${markerPrefix(target)}${JSON.stringify([reference.id, reference.revision, reference.token])}`
|
|
33
|
+
const marker = (backup: ComponentPropertyBackup) => markerOf(backup.record.target, { id: backup.record.id, revision: backup.record.revision, token: backup.token })
|
|
34
|
+
const keyOf = (record: ComponentPropertyDraft) => `${prefix(record.target)}${record.id}`
|
|
35
|
+
const tokenOf = async (raw: string) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw)))].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
36
|
+
|
|
37
|
+
function valid(value: unknown, target: ComponentPropertyTarget): value is ComponentPropertyDraft {
|
|
38
|
+
return isRecord(value) && value.format === 1 && typeof value.id === 'string' && value.id.length > 0
|
|
39
|
+
&& Number.isSafeInteger(value.revision) && (value.revision as number) > 0 && isRecord(value.target)
|
|
40
|
+
&& value.target.identity === target.identity && value.target.memberId === target.memberId && value.target.blockId === target.blockId && value.target.stepId === target.stepId
|
|
41
|
+
&& isRecord(value.source) && typeof value.source.documentId === 'string' && typeof value.source.version === 'string' && typeof value.source.fingerprint === 'string'
|
|
42
|
+
&& typeof value.definitionId === 'string' && isRecord(value.baseline) && isRecord(value.value)
|
|
43
|
+
&& typeof value.reset === 'boolean' && typeof value.composing === 'boolean' && typeof value.updatedAt === 'string' && Number.isFinite(Date.parse(value.updatedAt))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Unapplied form input borrows the existing strict backup actor; only lecture.json is authoring content. */
|
|
47
|
+
export class ComponentPropertyDrafts {
|
|
48
|
+
readonly #entries = new Map<string, Entry>()
|
|
49
|
+
readonly #listeners = new Set<() => void>()
|
|
50
|
+
readonly #revisions = new Map<string, number>()
|
|
51
|
+
readonly #settling = new Map<string, number>()
|
|
52
|
+
#agentReader?: { identity: string, memberId: string, store: DocumentDraftStore }
|
|
53
|
+
readonly #writerOwner = crypto.randomUUID()
|
|
54
|
+
#revision = 0
|
|
55
|
+
#closed = false
|
|
56
|
+
readonly #leave = (event: BeforeUnloadEvent) => {
|
|
57
|
+
if ([...this.#entries.values()].some(entry => entry.failed || entry.operation || entry.record.composing)) event.preventDefault()
|
|
58
|
+
}
|
|
59
|
+
constructor() { if (typeof window !== 'undefined') window.addEventListener('beforeunload', this.#leave) }
|
|
60
|
+
subscribe = (listener: () => void): (() => void) => { this.#listeners.add(listener); return () => this.#listeners.delete(listener) }
|
|
61
|
+
snapshot = (): number => this.#revision
|
|
62
|
+
revision(identity: string, memberId: string): number { return this.#revisions.get(JSON.stringify([identity, memberId])) ?? 0 }
|
|
63
|
+
readable(identity: string, memberId: string): boolean { return !this.#closed && !this.#settling.get(JSON.stringify([identity, memberId])) }
|
|
64
|
+
#settle(target: ComponentPropertyTarget): () => void {
|
|
65
|
+
const key = scope(target)
|
|
66
|
+
this.#settling.set(key, (this.#settling.get(key) ?? 0) + 1); this.#touch(target)
|
|
67
|
+
return () => {
|
|
68
|
+
const remaining = (this.#settling.get(key) ?? 1) - 1
|
|
69
|
+
if (remaining) this.#settling.set(key, remaining); else this.#settling.delete(key)
|
|
70
|
+
this.#touch(target)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
#touch(target: Pick<ComponentPropertyTarget, 'identity' | 'memberId'>): void {
|
|
74
|
+
if (this.#closed) return
|
|
75
|
+
this.#revision++
|
|
76
|
+
this.#revisions.set(scope(target), (this.#revisions.get(scope(target)) ?? 0) + 1)
|
|
77
|
+
for (const listener of this.#listeners) listener()
|
|
78
|
+
}
|
|
79
|
+
get(target: ComponentPropertyTarget): ComponentPropertyDraft | undefined { return this.#entries.get(targetKey(target))?.record }
|
|
80
|
+
canKeep(target: ComponentPropertyTarget): boolean { return !this.#closed && (this.#entries.has(targetKey(target)) || this.#entries.size < 512) }
|
|
81
|
+
status(target: ComponentPropertyTarget): 'available' | 'pending' | 'session-only' {
|
|
82
|
+
const entry = this.#entries.get(targetKey(target))
|
|
83
|
+
return entry?.failed ? 'session-only' : entry?.operation ? 'pending' : 'available'
|
|
84
|
+
}
|
|
85
|
+
has(identity: string, memberId: string): boolean { return [...this.#entries.values()].some(entry => entry.record.target.identity === identity && entry.record.target.memberId === memberId) }
|
|
86
|
+
stopAgentRead(): void {
|
|
87
|
+
const reader = this.#agentReader
|
|
88
|
+
this.#agentReader = undefined
|
|
89
|
+
reader?.store.close()
|
|
90
|
+
if (reader) this.#touch(reader)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Explicit reads never settle backups; current input shadows older disk bytes even after admission failure. */
|
|
94
|
+
async readForAgent(context: { identity: string, memberId: string, source: CanonicalSource }, signal: AbortSignal, reference?: ClientComponentDraftReference): Promise<{ revision: number, candidates: ComponentPropertyAgentCandidate[], coverage: 'stored-and-session' | 'session-only', skippedInvalid: number }> {
|
|
95
|
+
const { identity, memberId, source } = context
|
|
96
|
+
signal.throwIfAborted()
|
|
97
|
+
if (!this.readable(identity, memberId)) throw new Error('client-component-drafts-unavailable')
|
|
98
|
+
if (this.#agentReader && (this.#agentReader.identity !== identity || this.#agentReader.memberId !== memberId)) this.stopAgentRead()
|
|
99
|
+
if (!this.#agentReader) {
|
|
100
|
+
const store = new DocumentDraftStore(JSON.stringify([identity, memberId]), () => { if (this.#agentReader?.store === store) this.#touch({ identity, memberId }) }, { owner: this.#writerOwner, family: 'component' })
|
|
101
|
+
this.#agentReader = { identity, memberId, store }
|
|
102
|
+
}
|
|
103
|
+
const reader = this.#agentReader, revision = this.revision(identity, memberId), writing = [...this.#entries.values()].filter(entry => entry.record.target.identity === identity && entry.record.target.memberId === memberId)
|
|
104
|
+
const target = reference ? { identity, memberId, blockId: reference.blockId, ...reference.stepId !== undefined ? { stepId: reference.stepId } : {} } : undefined
|
|
105
|
+
const key = target && reference ? `${prefix(target)}${reference.id}` : undefined
|
|
106
|
+
const known = reference && writing.find(entry => keyOf(entry.record) === key)
|
|
107
|
+
if (known && known.record.revision !== reference!.revision) throw new Error('client-component-draft-changed')
|
|
108
|
+
const current = () => { signal.throwIfAborted(); if (this.#agentReader !== reader || !this.readable(identity, memberId) || revision !== this.revision(identity, memberId)) throw new Error('client-component-drafts-changed') }
|
|
109
|
+
let stored: Map<string, unknown>, coverage: 'stored-and-session' | 'session-only' = 'stored-and-session'
|
|
110
|
+
try { stored = await reader.store.read(key && target && reference ? [key, markerOf(target, reference)] : [scopedPrefix(PREFIX, identity, memberId), scopedPrefix(SETTLED, identity, memberId)], signal, 2048) }
|
|
111
|
+
catch (error) {
|
|
112
|
+
current()
|
|
113
|
+
if (error instanceof Error && error.message === 'document-draft-read-limit') throw new Error('client-component-drafts-limit')
|
|
114
|
+
if (reference ? !known : !writing.length) throw new Error('client-component-drafts-unavailable')
|
|
115
|
+
stored = new Map(); coverage = 'session-only'
|
|
116
|
+
}
|
|
117
|
+
current()
|
|
118
|
+
const entries = new Map<string, { raw: unknown, storage: 'durable' | 'session', status: ComponentPropertyAgentCandidate['status'], current: boolean }>()
|
|
119
|
+
for (const [name, raw] of stored) if (name.startsWith(scopedPrefix(PREFIX, identity, memberId)) && (!key || name === key)) entries.set(name, { raw, storage: 'durable', status: 'available', current: false })
|
|
120
|
+
for (const entry of writing) if (!key || keyOf(entry.record) === key) {
|
|
121
|
+
const raw = JSON.stringify(entry.record), name = keyOf(entry.record)
|
|
122
|
+
entries.set(name, { raw, storage: stored.get(name) === raw ? 'durable' : 'session', status: entry.failed ? 'session-only' : entry.operation ? 'pending' : 'available', current: true })
|
|
123
|
+
}
|
|
124
|
+
const candidates: ComponentPropertyAgentCandidate[] = []; let skippedInvalid = 0
|
|
125
|
+
for (const [name, entry] of entries) {
|
|
126
|
+
try {
|
|
127
|
+
if (typeof entry.raw !== 'string' || entry.storage === 'durable' && entry.raw.length > MAX_CHARS) throw new Error('component-draft-data')
|
|
128
|
+
const value: unknown = JSON.parse(entry.raw)
|
|
129
|
+
if (!isRecord(value) || !isRecord(value.target) || typeof value.target.blockId !== 'string' || !value.target.blockId || value.target.blockId.length > 200
|
|
130
|
+
|| value.target.stepId !== undefined && (typeof value.target.stepId !== 'string' || !value.target.stepId || value.target.stepId.length > 200)) throw new Error('component-draft-data')
|
|
131
|
+
const owned = { identity, memberId, blockId: value.target.blockId, ...value.target.stepId !== undefined ? { stepId: value.target.stepId as string } : {} }
|
|
132
|
+
if (!valid(value, owned) || name !== keyOf(value) || value.id.length > 200 || value.definitionId.length > 200 || !value.definitionId
|
|
133
|
+
|| value.source.documentId !== source.documentId || !value.source.version || value.source.version.length > 200 || !value.source.fingerprint || value.source.fingerprint.length > 200) throw new Error('component-draft-data')
|
|
134
|
+
const token = await tokenOf(entry.raw); current()
|
|
135
|
+
const marked = stored.get(marker({ key: name, token, record: value })) === token
|
|
136
|
+
if (marked && !entry.current) continue
|
|
137
|
+
// Discarding a backup in another tab does not discard this tab's visible input.
|
|
138
|
+
const candidate: ComponentPropertyAgentCandidate = { key: name, token, record: value, current: entry.current, storage: marked ? 'session' : entry.storage, status: marked ? 'session-only' : entry.status }
|
|
139
|
+
if (reference && (value.id !== reference.id || value.revision !== reference.revision || token !== reference.token)) throw new Error('client-component-draft-changed')
|
|
140
|
+
candidates.push(candidate)
|
|
141
|
+
} catch { current(); if (reference) throw new Error('client-component-draft-changed'); skippedInvalid++ }
|
|
142
|
+
}
|
|
143
|
+
current()
|
|
144
|
+
if (reference && candidates.length !== 1) throw new Error('client-component-draft-changed')
|
|
145
|
+
return { revision, candidates: candidates.sort((a, b) => a.record.id.localeCompare(b.record.id)), coverage, skippedInvalid }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
keep(target: ComponentPropertyTarget, value: { source: CanonicalSource, definitionId: string, baseline: JsonObject, value: JsonObject, reset?: boolean, composing?: boolean }, origins: ComponentPropertyBackup[] = []): ComponentPropertyDraft {
|
|
149
|
+
if (this.#closed) throw new Error('component-draft-closed')
|
|
150
|
+
const key = targetKey(target), previous = this.#entries.get(key)
|
|
151
|
+
if (!previous && this.#entries.size >= 512) throw new Error('component-draft-limit')
|
|
152
|
+
const record: ComponentPropertyDraft = { format: 1, id: previous?.record.id ?? crypto.randomUUID(), revision: (previous?.record.revision ?? 0) + 1,
|
|
153
|
+
target: structuredClone(target), source: structuredClone(value.source), definitionId: value.definitionId, baseline: structuredClone(value.baseline), value: structuredClone(value.value),
|
|
154
|
+
reset: Boolean(value.reset), composing: Boolean(value.composing), updatedAt: new Date().toISOString() }
|
|
155
|
+
const entry: Entry = previous ?? { record, store: new DocumentDraftStore(scope(target), undefined, { owner: this.#writerOwner, family: 'component' }), failed: false, origins: [] }
|
|
156
|
+
entry.record = record
|
|
157
|
+
for (const origin of origins) if (!entry.origins.some(candidate => candidate.key === origin.key && candidate.token === origin.token)) entry.origins.push(origin)
|
|
158
|
+
this.#entries.set(key, entry)
|
|
159
|
+
const raw = JSON.stringify(record)
|
|
160
|
+
const operation = raw.length <= MAX_CHARS ? entry.store.put(keyOf(record), raw) : undefined
|
|
161
|
+
entry.operation = operation
|
|
162
|
+
entry.failed = !operation
|
|
163
|
+
this.#touch(target)
|
|
164
|
+
if (operation) void operation.done.then(result => {
|
|
165
|
+
if (this.#closed || this.#entries.get(key) !== entry || entry.operation !== operation) return
|
|
166
|
+
entry.operation = undefined; entry.failed = result !== 'committed'; this.#touch(target)
|
|
167
|
+
})
|
|
168
|
+
return record
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Reads never settle a foreign backup or replace current input. Recovery is an explicit caller action. */
|
|
172
|
+
async read(target: ComponentPropertyTarget, signal?: AbortSignal): Promise<ComponentPropertyBackupRead> {
|
|
173
|
+
if (this.#closed) throw new Error('component-draft-closed')
|
|
174
|
+
const reader = new DocumentDraftStore(scope(target), undefined, { owner: this.#writerOwner, family: 'component' })
|
|
175
|
+
try {
|
|
176
|
+
const stored = await reader.read([prefix(target), markerPrefix(target)], signal), result: ComponentPropertyBackup[] = []
|
|
177
|
+
if (stored.size > 1024) throw new Error('component-draft-limit')
|
|
178
|
+
let unreadable = 0
|
|
179
|
+
for (const [key, raw] of stored) {
|
|
180
|
+
signal?.throwIfAborted()
|
|
181
|
+
if (!key.startsWith(prefix(target))) continue
|
|
182
|
+
if (typeof raw !== 'string' || raw.length > MAX_CHARS) { unreadable++; continue }
|
|
183
|
+
let record: unknown
|
|
184
|
+
try { record = JSON.parse(raw) } catch { unreadable++; continue }
|
|
185
|
+
if (!valid(record, target) || key !== keyOf(record)) { unreadable++; continue }
|
|
186
|
+
const token = await tokenOf(raw), candidate = { record, token, key }
|
|
187
|
+
signal?.throwIfAborted()
|
|
188
|
+
if (stored.get(marker(candidate)) === token) continue
|
|
189
|
+
const current = this.#entries.get(targetKey(target))
|
|
190
|
+
if (current?.record.id === record.id || current?.origins.some(origin => origin.key === key && origin.token === token)) continue
|
|
191
|
+
result.push(candidate)
|
|
192
|
+
}
|
|
193
|
+
return { candidates: result.sort((a, b) => b.record.updatedAt.localeCompare(a.record.updatedAt)), unreadable }
|
|
194
|
+
} finally { reader.close() }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Recovery creates a confirmed branch before replacing the current in-memory draft. */
|
|
198
|
+
async recover(backup: ComponentPropertyBackup, expected: ComponentPropertyDraft | undefined, signal?: AbortSignal, guard?: () => void): Promise<boolean> {
|
|
199
|
+
const target = backup.record.target, key = targetKey(target), previous = this.#entries.get(key)
|
|
200
|
+
const owns = () => !this.#closed && this.#entries.get(key) === previous && previous?.record === expected
|
|
201
|
+
&& this.status(target) === 'available' && this.canKeep(target)
|
|
202
|
+
signal?.throwIfAborted(); guard?.()
|
|
203
|
+
if (!owns()) return false
|
|
204
|
+
const settled = this.#settle(target)
|
|
205
|
+
const store = new DocumentDraftStore(scope(target), undefined, { owner: this.#writerOwner, family: 'component' })
|
|
206
|
+
let adopted = false
|
|
207
|
+
try {
|
|
208
|
+
const values = await store.read([backup.key, marker(backup)], signal), raw = values.get(backup.key)
|
|
209
|
+
if (typeof raw !== 'string' || raw.length > MAX_CHARS || await tokenOf(raw) !== backup.token || values.get(marker(backup)) === backup.token) return false
|
|
210
|
+
signal?.throwIfAborted()
|
|
211
|
+
if (!owns()) return false
|
|
212
|
+
let value: unknown
|
|
213
|
+
try { value = JSON.parse(raw) } catch { return false }
|
|
214
|
+
if (!valid(value, target) || backup.key !== keyOf(value)) return false
|
|
215
|
+
guard?.()
|
|
216
|
+
const record: ComponentPropertyDraft = { ...structuredClone(value), id: crypto.randomUUID(), revision: 1, composing: false, updatedAt: new Date().toISOString() }
|
|
217
|
+
const fork = JSON.stringify(record)
|
|
218
|
+
if (fork.length > MAX_CHARS) return false
|
|
219
|
+
const operation = store.put(keyOf(record), fork)
|
|
220
|
+
if (!operation || await operation.done !== 'committed') return false
|
|
221
|
+
signal?.throwIfAborted()
|
|
222
|
+
// A later keystroke retains ownership; the committed branch remains recoverable.
|
|
223
|
+
guard?.()
|
|
224
|
+
if (!owns()) return false
|
|
225
|
+
this.#entries.set(key, { record, store, failed: false, origins: [backup] })
|
|
226
|
+
previous?.store.close()
|
|
227
|
+
adopted = true; this.#touch(target)
|
|
228
|
+
return true
|
|
229
|
+
} finally { if (!adopted) store.close(); settled() }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Both the in-memory revision and strict storage owner must still match the acknowledged input. */
|
|
233
|
+
async clear(expected: ComponentPropertyDraft, options?: { preserveOrigins?: boolean }): Promise<boolean> {
|
|
234
|
+
const key = targetKey(expected.target), entry = this.#entries.get(key)
|
|
235
|
+
if (this.#closed || !entry || entry.record !== expected) return false
|
|
236
|
+
const settled = this.#settle(expected.target)
|
|
237
|
+
try {
|
|
238
|
+
const operation = entry.store.remove(keyOf(expected), options?.preserveOrigins ? [] : entry.origins.map(origin => [marker(origin), origin.token] as [string, string]))
|
|
239
|
+
if (!operation) { entry.failed = true; this.#touch(expected.target); return false }
|
|
240
|
+
entry.operation = operation; this.#touch(expected.target)
|
|
241
|
+
const result = await operation.done
|
|
242
|
+
if (this.#closed || this.#entries.get(key) !== entry) return false
|
|
243
|
+
if (entry.operation === operation) { entry.operation = undefined; entry.failed = result !== 'committed' }
|
|
244
|
+
if (result === 'committed' && entry.record === expected) { this.#entries.delete(key); entry.store.close(); this.#touch(expected.target); return true }
|
|
245
|
+
this.#touch(expected.target)
|
|
246
|
+
return false
|
|
247
|
+
} finally { settled() }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A discarded foreign revision is marked exactly; newer input under that key remains recoverable. */
|
|
251
|
+
async discard(backup: ComponentPropertyBackup, signal?: AbortSignal, guard?: () => void): Promise<boolean> {
|
|
252
|
+
signal?.throwIfAborted(); guard?.()
|
|
253
|
+
if (this.#closed) return false
|
|
254
|
+
const settled = this.#settle(backup.record.target)
|
|
255
|
+
const reader = new DocumentDraftStore(scope(backup.record.target), undefined, { owner: this.#writerOwner, family: 'component' })
|
|
256
|
+
try {
|
|
257
|
+
const values = await reader.read([backup.key], signal), raw = values.get(backup.key)
|
|
258
|
+
if (typeof raw !== 'string' || await tokenOf(raw) !== backup.token) return false
|
|
259
|
+
signal?.throwIfAborted(); guard?.()
|
|
260
|
+
const result = await reader.mark([[marker(backup), backup.token]])
|
|
261
|
+
this.#touch(backup.record.target)
|
|
262
|
+
return result
|
|
263
|
+
} finally { reader.close(); settled() }
|
|
264
|
+
}
|
|
265
|
+
close(): void {
|
|
266
|
+
if (this.#closed) return
|
|
267
|
+
this.#closed = true
|
|
268
|
+
this.stopAgentRead()
|
|
269
|
+
if (typeof window !== 'undefined') window.removeEventListener('beforeunload', this.#leave)
|
|
270
|
+
for (const entry of this.#entries.values()) entry.store.close()
|
|
271
|
+
this.#listeners.clear()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const COMPONENT_PROPERTY_MESSAGES = {
|
|
2
|
+
'zh-CN': {
|
|
3
|
+
properties: '属性', scope: '参数范围', base: '基础参数', step: '当前步骤', materials: '素材与引用', back: '返回参数',
|
|
4
|
+
apply: '应用', adopt: '采用', cancel: '取消', done: '完成', undo: '撤消', reset: '使用继承参数', resetPending: '将使用继承参数', edit: '继续编辑',
|
|
5
|
+
loading: '正在加载参数…', empty: '此内容没有公开参数。', changed: '文稿已更新,请重新检查。', recheck: '再次检查',
|
|
6
|
+
invalid: '请检查参数。', failed: '无法应用参数。输入已保留,请重试。', denied: '没有修改权限。', missing: '此内容已移除。',
|
|
7
|
+
storage: '输入仅在本次会话中保留。', storagePending: '正在备份输入…', storageRead: '无法读取备份。', limit: '无法再保留参数草稿。',
|
|
8
|
+
retry: '重试', backup: '参数备份', recover: '恢复草稿', discard: '放弃', discardChanges: '放弃更改', unreadable: '部分备份无法读取。',
|
|
9
|
+
backupChanged: '备份或输入已变化,请重新加载。', definitionChanged: '素材已改变,请核对保留的输入。',
|
|
10
|
+
preserved: '保留已有步骤覆盖', impact: (count: number) => `影响 ${count} 个步骤`,
|
|
11
|
+
search: '搜索素材', definitions: '已有素材', noDefinitions: '没有兼容素材。', results: (count: number) => `${count} 项`,
|
|
12
|
+
current: '当前素材', incompatible: '不适用于当前内容', module: '暂不支持此素材', references: '引用位置', locate: '定位',
|
|
13
|
+
actions: '素材操作', copy: '创建独立副本', remove: '删除', unavailable: '尚未提供独立副本或删除能力。', copyUnavailable: '尚未提供独立副本。', inUse: '此素材正在被引用。',
|
|
14
|
+
saved: '参数已应用到此内容。', resetReason: '之后将使用继承的参数。',
|
|
15
|
+
},
|
|
16
|
+
en: {
|
|
17
|
+
properties: 'Properties', scope: 'Parameter scope', base: 'Base parameters', step: 'Current step', materials: 'Assets and References', back: 'Back to Parameters',
|
|
18
|
+
apply: 'Apply', adopt: 'Use', cancel: 'Cancel', done: 'Done', undo: 'Undo', reset: 'Use Inherited Parameters', resetPending: 'Inherited parameters will be used', edit: 'Keep Editing',
|
|
19
|
+
loading: 'Loading parameters…', empty: 'No public parameters.', changed: 'The document changed. Review again.', recheck: 'Check Again',
|
|
20
|
+
invalid: 'Check the parameters.', failed: 'Couldn’t apply the parameters. Your input is kept; try again.', denied: 'Editing is not permitted.', missing: 'This content was removed.',
|
|
21
|
+
storage: 'Input is kept only in this session.', storagePending: 'Backing up input…', storageRead: 'Couldn’t read the backups.', limit: 'No more parameter drafts can be kept.',
|
|
22
|
+
retry: 'Retry', backup: 'Parameter backup', recover: 'Recover Draft', discard: 'Discard', discardChanges: 'Discard Changes', unreadable: 'Some backups could not be read.',
|
|
23
|
+
backupChanged: 'The backup or input changed. Reload it.', definitionChanged: 'The asset changed. Review the input that was kept.',
|
|
24
|
+
preserved: 'Keep existing step overrides', impact: (count: number) => `Affects ${count} ${count === 1 ? 'step' : 'steps'}`,
|
|
25
|
+
search: 'Search assets', definitions: 'Existing assets', noDefinitions: 'No compatible assets.', results: (count: number) => `${count} ${count === 1 ? 'item' : 'items'}`,
|
|
26
|
+
current: 'Current asset', incompatible: 'Not compatible with this content', module: 'This asset is not supported yet', references: 'References', locate: 'Locate',
|
|
27
|
+
actions: 'Asset Actions', copy: 'Make an Independent Copy', remove: 'Delete', unavailable: 'Independent copies and deletion are not available yet.', copyUnavailable: 'Independent copies are not available yet.', inUse: 'This asset is in use.',
|
|
28
|
+
saved: 'Parameters applied to this content.', resetReason: 'The inherited parameters will be used.',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
export type ComponentPropertyMessages = typeof COMPONENT_PROPERTY_MESSAGES['en']
|