From dfa8059842d19f4562c9ffc506f706ac7d41a1d7 Mon Sep 17 00:00:00 2001 From: Jack Terwilliger Date: Thu, 9 Jul 2026 09:50:42 -0700 Subject: [PATCH 1/2] multimedia loading saving offsets fixed --- README.md | 31 ++ package.json | 3 + .../media-player/src/LinkedMediaDlg.svelte | 95 ++++-- packages/media-player/src/index.ts | 1 + .../media-player/src/linked-media-types.ts | 3 + packages/mumo/src/App.svelte | 313 ++++++++++++++---- packages/mumo/src/builtins.ts | 168 ---------- packages/mumo/src/formats.ts | 6 + packages/serialization/src/eaf-emit.ts | 24 +- packages/serialization/src/eaf-parse.ts | 8 +- packages/serialization/src/types.ts | 1 + .../serialization/tests/eaf-scenarios.test.ts | 21 ++ 12 files changed, 399 insertions(+), 275 deletions(-) create mode 100644 packages/media-player/src/linked-media-types.ts delete mode 100644 packages/mumo/src/builtins.ts diff --git a/README.md b/README.md index a37af64..281a638 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,37 @@ tools for doing multimodal interaction research ## Install Download an installer or binary from [releases](https://github.com/jackft/mumo/releases). Runs on Linux Mac Windows. +## Development + +From a fresh clone, install workspace dependencies before running dev or build +commands: + +```sh +nvm install +nvm use +corepack enable +pnpm install +pnpm run dev +``` + +The repo requires Node `>=22.13.0`; `.nvmrc` and `.node-version` both pin +`22.13.0` for common version managers. + +If Corepack fails with `Cannot find matching keyid`, update Corepack first and +activate the pinned pnpm version: + +```sh +nvm use +npm install -g corepack@latest +corepack enable +corepack prepare pnpm@10.33.0 --activate +pnpm install +``` + +This project uses the pnpm version declared in `package.json`. `pnpm install` +also runs the prepare step that copies large runtime assets into +`packages/mumo/public/`. + ## Builds The browser app build is relocatable by default. You can copy `packages/mumo/dist/` diff --git a/package.json b/package.json index 9b4e430..c37e060 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.1", "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=22.13.0" + }, "scripts": { "clean": "rm -rf packages/*/dist packages/*/dist-lib packages/*/out packages/audio-analysis-wasm/pkg", "build": "pnpm -r --if-present run build", diff --git a/packages/media-player/src/LinkedMediaDlg.svelte b/packages/media-player/src/LinkedMediaDlg.svelte index 3e0dd5d..e244cec 100644 --- a/packages/media-player/src/LinkedMediaDlg.svelte +++ b/packages/media-player/src/LinkedMediaDlg.svelte @@ -1,18 +1,20 @@ @@ -4507,7 +4659,7 @@ let patternSchemaDlgOpen = $state(false) else undoManager.redo() } } - if (matchKey(e, 'save')) { e.preventDefault(); void (fc.currentFilename ? saveMumo() : saveMumoAs()) } + if (matchKey(e, 'save')) { e.preventDefault(); void (filecontroller.currentFilename ? saveMumo() : saveMumoAs()) } if (matchKey(e, 'speed_up')) { e.preventDefault(); stepSpeedUp() } if (matchKey(e, 'speed_down')) { e.preventDefault(); stepSpeedDown() } if (matchKey(e, 'play_pause_global')) { e.preventDefault(); togglePlay(); return } @@ -4674,7 +4826,7 @@ let patternSchemaDlgOpen = $state(false) {/if} - +
@@ -4712,8 +4864,7 @@ let patternSchemaDlgOpen = $state(false)

- - +
{#if _showFileOpen}
@@ -4974,16 +5125,42 @@ let patternSchemaDlgOpen = $state(false) linkedMediaOpen = false} - onLink={linkMediaFile} - onRemove={(id) => multiPlayer.removeTrack(id)} + onLink={() => void linkMediaFile()} + onLoad={async (url) => { + const stored = eafPassthrough?.media.find(d => d.mediaUrl === url) + const offsetSec = (stored?.timeOrigin ?? 0) / 1000 + const result = await platform.openBinaryFile(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'], 'Media files') + if (!result) return + const player = await multiPlayer.addTrack(result.file, result.path, offsetSec) + eafSlotAssignments = new Map(eafSlotAssignments).set(url, player.id) + }} + onRemove={(id) => { + if (multiPlayer.players.some(p => p.id === id)) { + multiPlayer.removeTrack(id) + const updated = new Map(eafSlotAssignments) + for (const [url, pid] of updated) if (pid === id) updated.delete(url) + eafSlotAssignments = updated + } else if (eafPassthrough) { + eafPassthrough = { ...eafPassthrough, media: eafPassthrough.media.filter(d => d.mediaUrl !== id) } + } + }} onOffsetChange={(id, offsetSec) => { - multiPlayer.updateTrackOffset(id, offsetSec) - mediaSignals = mediaSignals.map(s => - s.id.startsWith(id + ':') ? { ...s, timeOffset: offsetSec } : s - ) - _flushSignals() + if (multiPlayer.players.some(p => p.id === id)) { + multiPlayer.updateTrackOffset(id, offsetSec) + mediaSignals = mediaSignals.map(s => + s.id.startsWith(id + ':') ? { ...s, timeOffset: offsetSec } : s + ) + _flushSignals() + } else if (eafPassthrough) { + eafPassthrough = { + ...eafPassthrough, + media: eafPassthrough.media.map(d => + d.mediaUrl === id ? { ...d, timeOrigin: Math.round(offsetSec * 1000) } : d + ), + } + } }} /> {#if showTranscript} @@ -5191,7 +5368,7 @@ let patternSchemaDlgOpen = $state(false) if (eApi?.collectionSetsAddItem && activeCollectionId !== null) { void eApi.collectionSetsAddItem(activeCollectionId, { kind: 'bookmark', - docPath: fc.currentFilename ?? '', + docPath: filecontroller.currentFilename ?? '', refId: bm.id, startS: bm.startSeconds, endS: bm.endSeconds, diff --git a/packages/mumo/src/builtins.ts b/packages/mumo/src/builtins.ts deleted file mode 100644 index 15bd927..0000000 --- a/packages/mumo/src/builtins.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { AnnotationStore, PatternSchema, ControlledVocabulary } from '@mumo/core' - -// Built-in vocabularies - -const troubleTypeVocab: ControlledVocabulary = { - id: 'builtin:vocab:trouble-type', - name: 'Trouble type', - entries: [ - { id: 'tt-hearability', value: 'hearability', description: 'Could not hear / too quiet' }, - { id: 'tt-reference', value: 'reference', description: 'Referent unclear or unknown' }, - { id: 'tt-grammar', value: 'grammar/syntax', description: 'Grammatical or syntactic issue' }, - { id: 'tt-factual', value: 'factual', description: 'Factual accuracy questioned' }, - { id: 'tt-other', value: 'other' }, - ], -} - -const initiationTypeVocab: ControlledVocabulary = { - id: 'builtin:vocab:initiation-type', - name: 'Initiation type', - entries: [ - { id: 'it-open-class', value: 'open class', description: 'huh?, what?, pardon?' }, - { id: 'it-partial', value: 'partial repeat', description: 'Repeat of trouble source fragment' }, - { id: 'it-wh', value: 'wh-word', description: 'who?, which?, where? etc.' }, - { id: 'it-candidate', value: 'candidate understanding', description: 'Proposed understanding for confirmation' }, - { id: 'it-other', value: 'other' }, - ], -} - -const solutionTypeVocab: ControlledVocabulary = { - id: 'builtin:vocab:solution-type', - name: 'Solution type', - entries: [ - { id: 'st-repeat', value: 'repeat', description: 'Repeated with emphasis or clarification' }, - { id: 'st-correction', value: 'correction', description: 'Replaced trouble source with correct form' }, - { id: 'st-reformulation',value: 'reformulation', description: 'Rephrased or restructured' }, - { id: 'st-abandoned', value: 'abandoned', description: 'Repair attempt abandoned' }, - { id: 'st-other', value: 'other' }, - ], -} - -const fppTypeVocab: ControlledVocabulary = { - id: 'builtin:vocab:fpp-type', - name: 'FPP type', - entries: [ - { id: 'fpp-question', value: 'question' }, - { id: 'fpp-request', value: 'request' }, - { id: 'fpp-invitation', value: 'invitation' }, - { id: 'fpp-offer', value: 'offer' }, - { id: 'fpp-assessment', value: 'assessment' }, - { id: 'fpp-greeting', value: 'greeting' }, - { id: 'fpp-complaint', value: 'complaint' }, - { id: 'fpp-announcement', value: 'announcement' }, - { id: 'fpp-other', value: 'other' }, - ], -} - -const sppTypeVocab: ControlledVocabulary = { - id: 'builtin:vocab:spp-type', - name: 'SPP type', - entries: [ - { id: 'spp-answer', value: 'answer' }, - { id: 'spp-acceptance', value: 'acceptance' }, - { id: 'spp-rejection', value: 'rejection' }, - { id: 'spp-counter', value: 'counter-assessment' }, - { id: 'spp-grant', value: 'grant/comply' }, - { id: 'spp-greeting', value: 'return greeting' }, - { id: 'spp-other', value: 'other' }, - ], -} - -// Built-in pattern schemas - -const repairSchema: PatternSchema = { - id: 'builtin:repair', - name: 'Repair', - description: 'Other-initiated self-repair (Schegloff et al. 1977)', - slots: [ - { - id: 'trouble', - name: 'trouble', - label: 'Trouble source', - anchorKind: 'span', - required: true, - metrics: [ - { id: 'trouble-type', name: 'Trouble type', type: 'categorical', - vocabularyId: 'builtin:vocab:trouble-type' }, - ], - }, - { - id: 'initiation', - name: 'initiation', - label: 'Repair initiation', - anchorKind: 'span', - required: true, - metrics: [ - { id: 'initiation-type', name: 'Initiation type', type: 'categorical', - vocabularyId: 'builtin:vocab:initiation-type' }, - ], - }, - { - id: 'solution', - name: 'solution', - label: 'Repair proper', - anchorKind: 'span', - required: false, - metrics: [ - { id: 'is-solved', name: 'Resolved?', type: 'boolean' }, - { id: 'solution-type', name: 'Solution type', type: 'categorical', - vocabularyId: 'builtin:vocab:solution-type' }, - ], - }, - ], -} - -const adjPairSchema: PatternSchema = { - id: 'builtin:adj-pair', - name: 'Adjacency Pair', - description: 'First and second pair parts (Schegloff & Sacks 1973)', - slots: [ - { - id: 'fpp', - name: 'fpp', - label: 'First Pair Part', - anchorKind: 'utterance', - required: true, - metrics: [ - { id: 'fpp-type', name: 'FPP type', type: 'categorical', - vocabularyId: 'builtin:vocab:fpp-type' }, - ], - }, - { - id: 'spp', - name: 'spp', - label: 'Second Pair Part', - anchorKind: 'utterance', - required: true, - metrics: [ - { id: 'spp-type', name: 'SPP type', type: 'categorical', - vocabularyId: 'builtin:vocab:spp-type' }, - { id: 'preferred', name: 'Preferred?', type: 'boolean' }, - ], - }, - ], -} - -// Seed function — loads built-ins only if no pattern schemas exist yet - -const BUILTIN_FRAME_SCHEMAS = [repairSchema, adjPairSchema] - -const BUILTIN_VOCABS = [ - troubleTypeVocab, - initiationTypeVocab, - solutionTypeVocab, - fppTypeVocab, - sppTypeVocab, -] - -export function seedBuiltins(store: AnnotationStore): void { - if (store.allPatternSchemas().length > 0) return - for (const vocab of BUILTIN_VOCABS) { - if (!store.getVocabulary(vocab.id)) { - store.addVocabulary(vocab.name, vocab.entries, vocab.id) - } - } - for (const schema of BUILTIN_FRAME_SCHEMAS) { - store.addPatternSchema(schema, schema.id) - } -} diff --git a/packages/mumo/src/formats.ts b/packages/mumo/src/formats.ts index a413499..db8a3e5 100644 --- a/packages/mumo/src/formats.ts +++ b/packages/mumo/src/formats.ts @@ -27,7 +27,13 @@ export type ImportResult = { export type ExportOpts = { language?: string mediaUrl?: string + mimeType?: string + mediaHash?: string timeOriginMs?: number + /** Additional media descriptors beyond the primary. */ + additionalMedia?: Array<{ mediaUrl: string; mimeType?: string; mediaHash?: string; timeOriginMs?: number }> + /** Passthrough PROPERTY elements from an original EAF header (mumo:mediaHash:* excluded — regenerated). */ + headerProperties?: Array<{ name: string; value: string }> /** Emit word-level token tiers (tokens:) in EAF export. */ includeTokenTiers?: boolean } diff --git a/packages/serialization/src/eaf-emit.ts b/packages/serialization/src/eaf-emit.ts index dffbae0..68c7b21 100644 --- a/packages/serialization/src/eaf-emit.ts +++ b/packages/serialization/src/eaf-emit.ts @@ -47,7 +47,8 @@ function isoDate(): string { export interface EmitEAFOptions { includeWords?: boolean // emit word-level token tiers (default: false) tokenStore?: TokenStore // required when includeWords is true - mediaUrl?: string // MEDIA_DESCRIPTOR URL + mediaUrl?: string // MEDIA_DESCRIPTOR URL (raw path or http URL) + mediaHash?: string // content hash stored as a PROPERTY element relativeMediaUrl?: string // RELATIVE_MEDIA_URL (relative path for ELAN portability) extractedFrom?: string // EXTRACTED_FROM attribute on MEDIA_DESCRIPTOR mimeType?: string @@ -55,7 +56,9 @@ export interface EmitEAFOptions { isTemplate?: boolean // emit ETF (no annotations, no time slots) author?: string language?: string // BCP47 tag for document language (e.g. 'en', 'fr') - additionalMedia?: Array<{ mediaUrl: string; mimeType?: string; timeOrigin?: number; relativeMediaUrl?: string }> + additionalMedia?: Array<{ mediaUrl: string; mediaHash?: string; mimeType?: string; timeOrigin?: number; relativeMediaUrl?: string }> + /** Passthrough PROPERTY elements (e.g. from an original EAF). mumo:mediaHash:* are always regenerated and must not be included here. */ + headerProperties?: Array<{ name: string; value: string }> } // buildEAFDocumentObject — returns the raw JS object before XMLBuilder @@ -71,6 +74,7 @@ export function buildEAFDocumentObject( includeWords = false, tokenStore, mediaUrl = '', + mediaHash, relativeMediaUrl = '', extractedFrom = '', mimeType = '', @@ -79,6 +83,7 @@ export function buildEAFDocumentObject( author = 'mumo', language, additionalMedia, + headerProperties, } = opts const pool = new TimeSlotPool() @@ -217,6 +222,21 @@ export function buildEAFDocumentObject( if (descriptors.length === 1) headerObj['MEDIA_DESCRIPTOR'] = descriptors[0] else if (descriptors.length > 1) headerObj['MEDIA_DESCRIPTOR'] = descriptors + // PROPERTY elements: passthrough first (excl. mumo:mediaHash:* which are regenerated), then content hashes + const passthroughProps = (headerProperties ?? []).filter(p => !p.name.startsWith('mumo:mediaHash:')) + const allHashes: Array<{ index: number; hash: string }> = [] + if (mediaHash) allHashes.push({ index: 0, hash: mediaHash }) + for (const [i, m] of (additionalMedia ?? []).entries()) { + if (m.mediaHash) allHashes.push({ index: i + 1, hash: m.mediaHash }) + } + const hashProps = allHashes.map(h => ({ '@_NAME': `mumo:mediaHash:${h.index}`, '#text': h.hash })) + const allProps = [ + ...passthroughProps.map(p => ({ '@_NAME': p.name, '#text': p.value })), + ...hashProps, + ] + if (allProps.length === 1) headerObj['PROPERTY'] = allProps[0] + else if (allProps.length > 1) headerObj['PROPERTY'] = allProps + // TIER elements (TIME_ORDER is built afterwards — tier emission registers slots) const tierElems: Record[] = [] diff --git a/packages/serialization/src/eaf-parse.ts b/packages/serialization/src/eaf-parse.ts index ed82792..4161838 100644 --- a/packages/serialization/src/eaf-parse.ts +++ b/packages/serialization/src/eaf-parse.ts @@ -22,7 +22,7 @@ import { buildTimeMap } from './time-slots.js' const EAF_ARRAY_TAGS = new Set([ 'TIME_SLOT', 'TIER', 'ANNOTATION', 'LINGUISTIC_TYPE', 'CONTROLLED_VOCABULARY', - 'CV_ENTRY_ML', 'CVE_VALUE', 'DESCRIPTION', 'MEDIA_DESCRIPTOR', 'EXTERNAL_REF', 'LANGUAGE', 'CONSTRAINT', + 'CV_ENTRY_ML', 'CVE_VALUE', 'DESCRIPTION', 'MEDIA_DESCRIPTOR', 'EXTERNAL_REF', 'LANGUAGE', 'CONSTRAINT', 'PROPERTY', ]) const eafXmlParser = new XMLParser({ @@ -172,7 +172,11 @@ export function parseXML(xml: string): EAFDocument { } }) - return { timeSlots, tiers, linguisticTypes, vocabularies, media, externalRefs, languages, ...(author ? { author } : {}) } + const properties: Array<{ name: string; value: string }> = ((hdrEl['PROPERTY'] ?? []) as Record[]) + .map(el => ({ name: (ga(el, 'NAME') ?? '').trim(), value: (gt(el) ?? '').trim() })) + .filter(p => p.name) + + return { timeSlots, tiers, linguisticTypes, vocabularies, media, externalRefs, languages, properties, ...(author ? { author } : {}) } } function normalizeConstraint(s: string | null): EAFLinguisticType['constraint'] { diff --git a/packages/serialization/src/types.ts b/packages/serialization/src/types.ts index f9dda59..cb10bee 100644 --- a/packages/serialization/src/types.ts +++ b/packages/serialization/src/types.ts @@ -90,6 +90,7 @@ export interface EAFDocument { media: EAFMediaDescriptor[] externalRefs: EAFExternalRef[] languages: EAFLanguage[] + properties: Array<{ name: string; value: string }> author?: string } diff --git a/packages/serialization/tests/eaf-scenarios.test.ts b/packages/serialization/tests/eaf-scenarios.test.ts index 561f931..a26bb1c 100644 --- a/packages/serialization/tests/eaf-scenarios.test.ts +++ b/packages/serialization/tests/eaf-scenarios.test.ts @@ -262,4 +262,25 @@ describe('media descriptor', () => { const xml = emitEAF(emptyDoc(), new AnnotationStore()) expect(xml).not.toContain(' { + const xml = emitEAF(emptyDoc(), new AnnotationStore(), { + mediaUrl: 'file:///primary.mp4', + additionalMedia: [ + { mediaUrl: 'file:///secondary.wav', mimeType: 'audio/x-wav', timeOrigin: 500 }, + ], + }) + expect(xml).toContain('MEDIA_URL="file:///primary.mp4"') + expect(xml).toContain('MEDIA_URL="file:///secondary.wav"') + expect(xml).toContain('MIME_TYPE="audio/x-wav"') + expect(xml).toContain('TIME_ORIGIN="500"') + }) + + it('omits TIME_ORIGIN when timeOrigin is zero', () => { + const xml = emitEAF(emptyDoc(), new AnnotationStore(), { + mediaUrl: 'file:///video.mp4', + timeOrigin: 0, + }) + expect(xml).not.toContain('TIME_ORIGIN') + }) }) From 4264cad552797b1aa081c399923aa2acfad8241b Mon Sep 17 00:00:00 2001 From: Jack Terwilliger Date: Thu, 9 Jul 2026 10:15:02 -0700 Subject: [PATCH 2/2] apply templates --- packages/mumo/src/App.svelte | 33 ++- packages/mumo/src/apply-template-types.ts | 17 ++ .../mumo/src/dialogs/ApplyTemplateDlg.svelte | 257 ++++++++++++++++++ packages/mumo/src/template-merge.ts | 241 ++++++++++++++++ 4 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 packages/mumo/src/apply-template-types.ts create mode 100644 packages/mumo/src/dialogs/ApplyTemplateDlg.svelte create mode 100644 packages/mumo/src/template-merge.ts diff --git a/packages/mumo/src/App.svelte b/packages/mumo/src/App.svelte index 62bedbd..2e33847 100644 --- a/packages/mumo/src/App.svelte +++ b/packages/mumo/src/App.svelte @@ -10,7 +10,7 @@ import type { DocumentJSON, ImageProvenance, PMNode, TrackSet, ControllerMeta, SymbolDef } from '@mumo/core' import { CollabManager } from './collab.js' import type { CollabMode, CollabStatus, CollabIdentity, AwarenessLike, PeerPatternSel } from './collab.js' - import { parseXML, eafTomumo, emitEAF, emitETF, parseMMEAF, parseMMETF, emitMMEAF, emitMMETF, emitVTT, emitTXT, emitCSV, packMumo, unpackMumo } from '@mumo/serialization' + import { parseXML, eafTomumo, emitEAF, emitETF, parseETF, parseMMEAF, parseMMETF, emitMMEAF, emitMMETF, emitVTT, emitTXT, emitCSV, packMumo, unpackMumo } from '@mumo/serialization' import { MediaResolver } from './media-resolver.js' import type { MediaResolveResult } from './media-resolver.js' import appIconUrl from './assets/mumo.svg' @@ -61,6 +61,8 @@ import PreferencesDlg from './dialogs/PreferencesDlg.svelte' import EditAnnPopover from './dialogs/EditAnnPopover.svelte' import EafImportDlg from './dialogs/EafImportDlg.svelte' + import ApplyTemplateDlg from './dialogs/ApplyTemplateDlg.svelte' + import { buildTemplateMerge } from './template-merge.js' import EditUttTierDlg from './dialogs/EditUttTierDlg.svelte' import EditTierDlg from './dialogs/EditTierDlg.svelte' import UttTiersDlg from './dialogs/UttTiersDlg.svelte' @@ -4307,6 +4309,25 @@ let patternSchemaDlgOpen = $state(false) if (filecontroller.currentFormat === 'eaf') saveETF() else saveMMETF() } + + let applyTemplateDlgOpen = $state(false) + let applyTemplateDlgData = $state | null>(null) + + async function applyTemplate() { + const file = await platform.openBinaryFile(['mmetf', 'etf'], 'Template files') + if (!file) return + const text = await file.file.text() + const ext = (file.path ?? file.file.name).split('.').pop()?.toLowerCase() + let tmpl: Parameters[0] + try { + tmpl = ext === 'mmetf' ? parseMMETF(text) : parseETF(text) + } catch { + alert('Could not parse template file.') + return + } + applyTemplateDlgData = buildTemplateMerge(tmpl, store) + applyTemplateDlgOpen = true + } @@ -4626,6 +4647,15 @@ let patternSchemaDlgOpen = $state(false) /> {/if} +{#if applyTemplateDlgOpen && applyTemplateDlgData} + applyTemplateDlgData!.applyFn(store)} + onclose={() => { applyTemplateDlgOpen = false }} + /> +{/if} + {#if eafImportDlg.open && eafImportDlg.eaf} { openMenu = null; saveMMETF() }}>Save MMETF
+

diff --git a/packages/mumo/src/apply-template-types.ts b/packages/mumo/src/apply-template-types.ts new file mode 100644 index 0000000..a060ab7 --- /dev/null +++ b/packages/mumo/src/apply-template-types.ts @@ -0,0 +1,17 @@ +export type ConflictEntry = { + category: string + name: string + reason: string +} + +export type PreviewItem = { + action: 'add' | 'merge' | 'skip' + name: string + detail?: string + additions?: string[] +} + +export type PreviewSection = { + category: string + items: PreviewItem[] +} diff --git a/packages/mumo/src/dialogs/ApplyTemplateDlg.svelte b/packages/mumo/src/dialogs/ApplyTemplateDlg.svelte new file mode 100644 index 0000000..d79c8aa --- /dev/null +++ b/packages/mumo/src/dialogs/ApplyTemplateDlg.svelte @@ -0,0 +1,257 @@ + + + + + + diff --git a/packages/mumo/src/template-merge.ts b/packages/mumo/src/template-merge.ts new file mode 100644 index 0000000..4d76a89 --- /dev/null +++ b/packages/mumo/src/template-merge.ts @@ -0,0 +1,241 @@ +import type { AnnotationStore, TierDefJSON, VocabularyJSON, LinguisticTypeJSON, PatternSchemaJSON } from '@mumo/core' +import { newId } from '@mumo/core' +import type { ConflictEntry, PreviewSection } from './apply-template-types.js' + +export type TemplateMergeInput = { + tiers: TierDefJSON[] + vocabularies: VocabularyJSON[] + linguisticTypes: LinguisticTypeJSON[] + patternSchemas?: PatternSchemaJSON[] +} + +export type TemplateMergeResult = { + conflicts: ConflictEntry[] + preview: PreviewSection[] + applyFn: (store: AnnotationStore) => void +} + +export function buildTemplateMerge(tmpl: TemplateMergeInput, store: AnnotationStore): TemplateMergeResult { + const tmplVocabs = tmpl.vocabularies ?? [] + const tmplLTs = tmpl.linguisticTypes ?? [] + const tmplTiers = tmpl.tiers ?? [] + const tmplSchemas = tmpl.patternSchemas ?? [] + + const exVocabs = new Map(store.allVocabularies().map(v => [v.name, v])) + const exLTs = new Map(store.allLinguisticTypes().map(lt => [lt.name, lt])) + const exTiers = new Map(store.allTiers().map(t => [t.name, t])) + const exSchemas = new Map(store.allPatternSchemas().map(s => [s.name, s])) + + const tmplLTById = new Map(tmplLTs.map(lt => [lt.id, lt])) + const tmplVocabById = new Map(tmplVocabs.map(v => [v.id, v])) + const tmplTierById = new Map(tmplTiers.map(t => [t.id, t])) + + const conflicts: ConflictEntry[] = [] + + for (const v of tmplVocabs) { + const ex = exVocabs.get(v.name) + if (!ex) continue + const tmplVals = new Set(v.entries.map(e => e.value)) + const missing = ex.entries.map(e => e.value).filter(val => !tmplVals.has(val)) + if (missing.length > 0) + conflicts.push({ category: 'Vocabulary', name: v.name, reason: `existing has entries not in template: ${missing.join(', ')}` }) + } + + for (const lt of tmplLTs) { + const ex = exLTs.get(lt.name) + if (!ex) continue + if (ex.constraint !== lt.constraint) + conflicts.push({ category: 'Linguistic type', name: lt.name, reason: `constraint mismatch — existing: ${ex.constraint ?? 'none'}, template: ${lt.constraint ?? 'none'}` }) + const exVocabName = ex.vocabularyId ? [...exVocabs.values()].find(v => v.id === ex.vocabularyId)?.name : undefined + const tmplVocabName = lt.vocabularyId ? tmplVocabById.get(lt.vocabularyId)?.name : undefined + if (exVocabName !== tmplVocabName) + conflicts.push({ category: 'Linguistic type', name: lt.name, reason: `vocabulary mismatch — existing: ${exVocabName ?? 'none'}, template: ${tmplVocabName ?? 'none'}` }) + } + + const allStoreTiers = store.allTiers() + for (const tier of tmplTiers) { + const ex = exTiers.get(tier.name) + if (!ex) continue + const exLTName = ex.linguisticTypeId ? [...exLTs.values()].find(lt => lt.id === ex.linguisticTypeId)?.name : undefined + const tmplLTName = tier.linguisticTypeId ? tmplLTById.get(tier.linguisticTypeId)?.name : undefined + if (exLTName !== tmplLTName) + conflicts.push({ category: 'Tier', name: tier.name, reason: `linguistic type mismatch — existing: ${exLTName ?? 'none'}, template: ${tmplLTName ?? 'none'}` }) + const exParentName = ex.parentTierId ? allStoreTiers.find(t => t.id === ex.parentTierId)?.name : undefined + const tmplParentName = tier.parentTierId ? tmplTierById.get(tier.parentTierId)?.name : undefined + if (exParentName !== tmplParentName) + conflicts.push({ category: 'Tier', name: tier.name, reason: `parent mismatch — existing: ${exParentName ?? 'none'}, template: ${tmplParentName ?? 'none'}` }) + } + + for (const schema of tmplSchemas) { + const ex = exSchemas.get(schema.name) + if (!ex) continue + for (const exSlot of ex.slots) { + const tmplSlot = schema.slots.find(s => s.name === exSlot.name) + if (!tmplSlot) { + conflicts.push({ category: 'Pattern schema', name: schema.name, reason: `existing has slot not in template: "${exSlot.name}"` }) + continue + } + const tmplMetricNames = new Set(tmplSlot.metrics.map(m => m.name)) + const missingMetrics = exSlot.metrics.map(m => m.name).filter(n => !tmplMetricNames.has(n)) + if (missingMetrics.length > 0) + conflicts.push({ category: 'Pattern schema', name: schema.name, reason: `slot "${exSlot.name}" missing metrics in template: ${missingMetrics.join(', ')}` }) + } + } + + // Preview + const preview: PreviewSection[] = [] + + const vocabItems = tmplVocabs.map(v => { + const ex = exVocabs.get(v.name) + if (!ex) return { action: 'add' as const, name: v.name, detail: `${v.entries.length} entries` } + const exVals = new Set(ex.entries.map(e => e.value)) + const newEntries = v.entries.filter(e => !exVals.has(e.value)) + if (newEntries.length === 0) return { action: 'skip' as const, name: v.name } + return { action: 'merge' as const, name: v.name, additions: newEntries.map(e => e.value) } + }) + if (vocabItems.length > 0) preview.push({ category: 'Vocabularies', items: vocabItems }) + + const ltItems = tmplLTs.map(lt => { + if (exLTs.has(lt.name)) return { action: 'skip' as const, name: lt.name } + const vocabName = lt.vocabularyId ? tmplVocabById.get(lt.vocabularyId)?.name : undefined + const parts = [ + lt.constraint ? `constraint: ${lt.constraint}` : '', + vocabName ? `vocab: ${vocabName}` : '', + ].filter(Boolean) + return { action: 'add' as const, name: lt.name, ...(parts.length ? { detail: parts.join(', ') } : {}) } + }) + if (ltItems.length > 0) preview.push({ category: 'Linguistic types', items: ltItems }) + + const orderedTmplTiers = orderTiersByDependency(tmplTiers) + + const tierItems = orderedTmplTiers.map(tier => { + if (exTiers.has(tier.name)) return { action: 'skip' as const, name: tier.name } + const ltName = tier.linguisticTypeId ? tmplLTById.get(tier.linguisticTypeId)?.name : undefined + const parentName = tier.parentTierId ? tmplTierById.get(tier.parentTierId)?.name : undefined + const parts = [ + ltName ? `LT: ${ltName}` : '', + parentName ? `parent: ${parentName}` : '', + ].filter(Boolean) + return { action: 'add' as const, name: tier.name, ...(parts.length ? { detail: parts.join(', ') } : {}) } + }) + if (tierItems.length > 0) preview.push({ category: 'Tiers', items: tierItems }) + + const schemaItems = tmplSchemas.map(schema => { + const ex = exSchemas.get(schema.name) + if (!ex) return { action: 'add' as const, name: schema.name, detail: `${schema.slots.length} slot${schema.slots.length === 1 ? '' : 's'}` } + const additions: string[] = [] + for (const tmplSlot of schema.slots) { + const exSlot = ex.slots.find(s => s.name === tmplSlot.name) + if (!exSlot) { additions.push(`slot: ${tmplSlot.name}`); continue } + const exMetricNames = new Set(exSlot.metrics.map(m => m.name)) + for (const m of tmplSlot.metrics) { + if (!exMetricNames.has(m.name)) additions.push(`${tmplSlot.name} › ${m.name}`) + } + } + if (additions.length === 0) return { action: 'skip' as const, name: schema.name } + return { action: 'merge' as const, name: schema.name, additions } + }) + if (schemaItems.length > 0) preview.push({ category: 'Pattern schemas', items: schemaItems }) + + const applyFn = (s: AnnotationStore) => { + s.transact(() => { + const vocabIdMap = new Map() + for (const v of tmplVocabs) { + const ex = exVocabs.get(v.name) + if (ex) { + vocabIdMap.set(v.id, ex.id) + const exVals = new Set(ex.entries.map(e => e.value)) + const newEntries = v.entries.filter(e => !exVals.has(e.value)) + if (newEntries.length > 0) + s.updateVocabulary(ex.id, { entries: [...ex.entries, ...newEntries.map(e => ({ id: newId(), value: e.value, ...(e.description ? { description: e.description } : {}) }))] }) + continue + } + const added = s.addVocabulary(v.name, v.entries.map(e => ({ id: newId(), value: e.value, ...(e.description ? { description: e.description } : {}) }))) + vocabIdMap.set(v.id, added.id) + } + + const ltIdMap = new Map() + for (const lt of tmplLTs) { + const ex = exLTs.get(lt.name) + if (ex) { ltIdMap.set(lt.id, ex.id); continue } + const vocabStoreId = lt.vocabularyId ? vocabIdMap.get(lt.vocabularyId) : undefined + const added = s.addLinguisticType(lt.name, { + ...(lt.constraint ? { constraint: lt.constraint } : {}), + ...(vocabStoreId ? { vocabularyId: vocabStoreId } : {}), + }) + ltIdMap.set(lt.id, added.id) + } + + const tierIdMap = new Map() + for (const tier of orderedTmplTiers) { + const ex = exTiers.get(tier.name) + if (ex) { tierIdMap.set(tier.id, ex.id); continue } + const ltStoreId = tier.linguisticTypeId ? ltIdMap.get(tier.linguisticTypeId) : undefined + const parentStoreId = tier.parentTierId ? tierIdMap.get(tier.parentTierId) : undefined + const added = s.addTier(tier.name, { + ...(ltStoreId ? { linguisticTypeId: ltStoreId } : {}), + ...(parentStoreId ? { parentTierId: parentStoreId } : {}), + ...(tier.participant ? { participant: tier.participant } : {}), + ...(tier.annotator ? { annotator: tier.annotator } : {}), + ...(tier.defaultLocale ? { defaultLocale: tier.defaultLocale } : {}), + ...(tier.inlineGloss ? { inlineGloss: tier.inlineGloss } : {}), + }) + tierIdMap.set(tier.id, added.id) + } + + for (const schema of tmplSchemas) { + const ex = exSchemas.get(schema.name) + if (!ex) { + s.addPatternSchema({ + name: schema.name, + slots: schema.slots.map(slot => ({ + ...slot, + id: newId(), + metrics: slot.metrics.map(m => ({ + ...m, + id: newId(), + ...(m.vocabularyId ? { vocabularyId: vocabIdMap.get(m.vocabularyId) ?? m.vocabularyId } : {}), + })), + })), + ...(schema.description ? { description: schema.description } : {}), + ...(schema.color !== undefined ? { color: schema.color } : {}), + ...(schema.hotkey ? { hotkey: schema.hotkey } : {}), + }) + continue + } + const mergedSlots = [...ex.slots] + for (const tmplSlot of schema.slots) { + const exSlotIdx = mergedSlots.findIndex(s => s.name === tmplSlot.name) + if (exSlotIdx === -1) { + mergedSlots.push({ ...tmplSlot, id: newId(), metrics: tmplSlot.metrics.map(m => ({ ...m, id: newId(), ...(m.vocabularyId ? { vocabularyId: vocabIdMap.get(m.vocabularyId) ?? m.vocabularyId } : {}) })) } as typeof tmplSlot) + } else { + const exSlot = mergedSlots[exSlotIdx]! + const exMetricNames = new Set(exSlot.metrics.map(m => m.name)) + const newMetrics = tmplSlot.metrics + .filter(m => !exMetricNames.has(m.name)) + .map(m => ({ ...m, id: newId(), ...(m.vocabularyId ? { vocabularyId: vocabIdMap.get(m.vocabularyId) ?? m.vocabularyId } : {}) })) + if (newMetrics.length > 0) + mergedSlots[exSlotIdx] = { ...exSlot, metrics: [...exSlot.metrics, ...newMetrics] } + } + } + s.updatePatternSchema(ex.id, { slots: mergedSlots }) + } + }) + } + + return { conflicts, preview, applyFn } +} + +function orderTiersByDependency(tiers: TierDefJSON[]): TierDefJSON[] { + const byId = new Map(tiers.map(t => [t.id, t])) + const result: TierDefJSON[] = [] + const visited = new Set() + function visit(t: TierDefJSON) { + if (visited.has(t.id)) return + if (t.parentTierId) { const p = byId.get(t.parentTierId); if (p) visit(p) } + visited.add(t.id) + result.push(t) + } + tiers.forEach(visit) + return result +}