diff --git a/static/components/PaneCP.js b/static/components/PaneCP.js index 248bd83..b26726f 100644 --- a/static/components/PaneCP.js +++ b/static/components/PaneCP.js @@ -67,7 +67,11 @@ export const PaneCP = { try { const raw = await fetchScoreText(props.store.credentials); props.store.rawScoreText = raw; - const patched = patchScore(raw, props.store.scoreModel.instruments, props.store.scoreModel.bars, props.store.scoreModel.info); + const model = props.store.scoreModel; + const { text: patched, log } = patchScore( + raw, model.instruments, model.bars, model.info, model.articles ?? [], + ); + props.store.exportLog = log; await putScoreText(patched, props.store.credentials); props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 }; } catch (e) { @@ -134,6 +138,18 @@ export const PaneCP = { // Export error exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null, + // Export log (shown after export, cleared on next import) + store.exportLog?.length + ? h('div', { class: 'se-export-log' }, + store.exportLog.map((entry, i) => + h('div', { + key: i, + class: entry.level === 'changed' ? 'se-export-changed' : 'se-export-info', + }, entry.path ? `→ ${entry.path}` : entry.message) + ) + ) + : null, + // Status poller (while running) store.synthesisStatus && !store.synthesisStatus.frozen ? h(StatusPoller, { store }) : null, diff --git a/static/components/PaneFO.js b/static/components/PaneFO.js index 6d4b6c9..5ea9a00 100644 --- a/static/components/PaneFO.js +++ b/static/components/PaneFO.js @@ -155,7 +155,7 @@ export const PaneFO = { : null, ); } else if (node.type === 'article') { - const markDirty = () => props.store.markDirty(); + const markDirty = () => { node.isDirty = true; props.store.markDirty(); }; const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0'; children.push( h('h4', H4, `Article: ${node.name}`), @@ -180,14 +180,20 @@ export const PaneFO = { style: 'flex:1;min-width:6em', onInput: onValInput, }), - h('button', { - class: 'se-toggle', - role: 'switch', - 'aria-checked': String(isOverwrite), - onClick: flip, - }), - h('span', { class: 'se-toggle-label active', style: 'min-width:4.5em' }, - isOverwrite ? 'overwrite' : 'default'), + h('div', { class: 'form-check form-switch mb-0' }, [ + h('input', { + class: 'form-check-input mt-0', + type: 'checkbox', + role: 'switch', + id: `prop-scope-${idx}`, + checked: isOverwrite, + onChange: flip, + }), + h('label', { + class: 'form-check-label', + for: `prop-scope-${idx}`, + }, isOverwrite ? 'overwrite' : 'default'), + ]), h('button', { class: 'se-btn-remove', title: 'Remove property', diff --git a/static/exporter.js b/static/exporter.js index 2e2bee6..d8a8bca 100644 --- a/static/exporter.js +++ b/static/exporter.js @@ -137,8 +137,46 @@ export function exportInstrument(instr) { return lines.join('\n'); } +// ── Articles ─────────────────────────────────────────────────────────────── +// RFC §4.3: articles: MAPPING { LABEL: { ATTR: VALUE ... } ... } + +function serializeArticleValue(v) { + if (typeof v === 'boolean') return String(v); + if (typeof v === 'number') return String(v); + const s = String(v); + return /[:#\[\]{}&*!,|>'"%@`]/.test(s) ? JSON.stringify(s) : s; +} + +function buildArticlesBlock(articles) { + const lines = ['articles:']; + for (const art of articles) { + const props = (art.properties ?? []).filter(p => p.name); + if (!props.length) continue; + lines.push(` ${art.name}:`); + for (const p of props) lines.push(` ${p.name}: ${serializeArticleValue(p.value)}`); + } + return lines.length > 1 ? lines.join('\n') : null; +} + +function patchArticles(text, articles) { + const newBlock = buildArticlesBlock(articles); + if (!newBlock) return text; + const lines = text.split('\n'); + const artIdx = lines.findIndex(l => /^articles\s*:/.test(l)); + if (artIdx !== -1) { + let end = artIdx + 1; + while (end < lines.length && (lines[end] === '' || lines[end].startsWith(' ') || lines[end].startsWith('\t'))) end++; + lines.splice(artIdx, end - artIdx, newBlock); + } else { + const instrIdx = lines.findIndex(l => /^instrument\s/.test(l)); + const insertAt = instrIdx !== -1 ? instrIdx : lines.length; + lines.splice(insertAt, 0, newBlock, ''); + } + return lines.join('\n'); +} + // ── Score patch ──────────────────────────────────────────────────────────── -// Replace dirty instrument blocks and dirty bar _meta blocks. +// Replace dirty article, instrument, and bar _meta blocks. // Voice note content in bar documents is left verbatim. const META_KEYS = ['title', 'composer', 'source', 'encrypter']; @@ -242,24 +280,40 @@ function patchBarMeta(doc, bar) { return out.join('\n'); } -export function patchScore(rawScoreText, instruments, bars = [], info = null) { +export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = []) { + const log = []; const SEP = '\n---\n'; const [header, ...barDocs] = rawScoreText.split(SEP); - const patchedHeader = patchInstrumentHeader(patchMetadata(header, info), instruments); + let patchedHeader = patchMetadata(header, info); - if (!barDocs.length) return patchedHeader; + const dirtyArticles = articles.filter(a => a.isDirty); + if (dirtyArticles.length) { + patchedHeader = patchArticles(patchedHeader, articles); + for (const a of dirtyArticles) log.push({ level: 'changed', path: `articles / ${a.name}` }); + } + + patchedHeader = patchInstrumentHeader(patchedHeader, instruments); + for (const i of instruments.filter(i => i.isDirty)) log.push({ level: 'changed', path: `instrument / ${i.name}` }); + + if (!barDocs.length) return { text: patchedHeader, log }; const barMap = {}; for (const bar of bars) barMap[bar.id] = bar; + let passedThrough = 0; const patchedBarDocs = barDocs.map(doc => { const m = doc.match(/^_id:\s*(\S+)/m); - if (!m) return doc; + if (!m) { passedThrough++; return doc; } const bar = barMap[m[1]]; - if (!bar?.isDirty) return doc; + if (!bar?.isDirty) { passedThrough++; return doc; } + log.push({ level: 'changed', path: `bar / ${bar.id}` }); return patchBarMeta(doc, bar); }); - return [patchedHeader, ...patchedBarDocs].join(SEP); + if (passedThrough > 0) { + log.push({ level: 'info', message: `${passedThrough} bar document${passedThrough !== 1 ? 's' : ''} passed through unchanged` }); + } + + return { text: [patchedHeader, ...patchedBarDocs].join(SEP), log }; } diff --git a/static/store.js b/static/store.js index 6160830..9b7522d 100644 --- a/static/store.js +++ b/static/store.js @@ -8,6 +8,7 @@ export const store = reactive({ synthesisStatus: null, isDirty: false, errorMessage: '', + exportLog: [], setFocus(path) { this.focusPath = path; diff --git a/static/style.css b/static/style.css index 7eaab61..ef4e44d 100644 --- a/static/style.css +++ b/static/style.css @@ -356,47 +356,19 @@ font-family: monospace; } -/* Two-state toggle (scope: defaults / overwrites). The label preceding the - switch tells the user what the two ends mean; the thumb position shows - which is active. Click anywhere on the track to flip. */ -.se-toggle { - position: relative; - width: 2.2rem; - height: 1.1rem; - flex-shrink: 0; - border: 1px solid #555; - border-radius: 0.55rem; - background: #2a2a2a; - cursor: pointer; - padding: 0; +/* Export log (shown in CP pane after export) */ +.se-export-log { + font-size: 0.75rem; + font-family: monospace; + border: 1px solid #333; + padding: 0.3rem 0.4rem; + margin: 0.4rem 0; } -.se-toggle::after { - content: ''; - position: absolute; - top: 1px; - left: 1px; - width: 0.85rem; - height: 0.85rem; - border-radius: 50%; - background: #999; - transition: transform 0.12s ease, background 0.12s ease; +.se-export-changed { + color: #aad4aa; } -.se-toggle[aria-checked="true"] { - background: #2d4a2d; - border-color: #4a7a4a; -} -.se-toggle[aria-checked="true"]::after { - transform: translateX(1.1rem); - background: #b0e0b0; -} -.se-toggle-label { - font-size: 0.7rem; +.se-export-info { color: #888; - user-select: none; - min-width: 4em; -} -.se-toggle-label.active { - color: #d8d8d8; } .se-prop-key, .se-prop-val { diff --git a/test-parser.mjs b/test-parser.mjs index 55fcb8d..71fc1ba 100644 --- a/test-parser.mjs +++ b/test-parser.mjs @@ -230,12 +230,14 @@ const rawScore = readFileSync(SCORE_FIXTURE, 'utf8'); // pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded. // Mark only alpha as dirty, verify ki is preserved verbatim. const patchInstruments = model.instruments.map(i => ({ ...i, isDirty: i.name === 'alpha' })); -const patched = patchScore(rawScore, patchInstruments); +const { text: patched, log: patchLog } = patchScore(rawScore, patchInstruments); const patchedLines = patched.split('\n'); ok('patched score still has instrument alpha:', patchedLines.some(l => /^instrument\s+alpha\s*:/.test(l))); ok('patched score still has instrument ki:', patchedLines.some(l => /^instrument\s+ki\s*:/.test(l))); ok('patched score alpha block contains character:', patchedLines.some(l => l.trim() === 'character:')); +ok('patchScore log records alpha as changed', patchLog.some(e => e.level === 'changed' && e.path === 'instrument / alpha')); +ok('patchScore log does not record ki (not dirty)', !patchLog.some(e => e.path?.includes('ki'))); // Ki is clean — its block must appear verbatim (check a unique line from the original) const kiOrigLines = rawScore.split('\n').filter(l => l.startsWith('instrument ki:') || (l.startsWith(' ') && rawScore.indexOf('instrument ki:') < rawScore.indexOf(l))); @@ -430,7 +432,7 @@ const cleanBar = { stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null, }; -const barPatched = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]); +const { text: barPatched, log: barLog } = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]); const barPatchedLines = barPatched.split('\n'); ok('dirty bar _meta updated with new BPM', barPatched.includes('beats_per_minute: 140')); ok('dirty bar stress_pattern updated', barPatched.includes('stress_pattern: 2,3;1')); @@ -438,6 +440,8 @@ ok('dirty bar voice content preserved', barPatched.includes('- C4 4')); ok('clean bar unchanged', barPatched.includes('beats_per_minute: 100')); ok('clean bar voice preserved', barPatched.includes('- D4 4')); ok('document separators preserved', (barPatched.match(/\n---\n/g) ?? []).length === 2); +ok('bar log records dirty bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M1')); +ok('bar log has pass-through info entry', barLog.some(e => e.level === 'info' && e.message?.includes('passed through'))); // ── patchScore metadata ──────────────────────────────────────────────────── section('patchScore metadata'); @@ -450,7 +454,7 @@ instrument alpha: A: "1:0,10;1,0" `; const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' }; -const metaPatched = patchScore(META_SCORE, [], [], updatedInfo); +const { text: metaPatched } = patchScore(META_SCORE, [], [], updatedInfo); ok('existing title replaced', metaPatched.includes('title: New Title')); ok('existing composer replaced', metaPatched.includes('composer: New Composer')); ok('empty value leaves existing line', metaPatched.includes('source: Some Book')); @@ -458,13 +462,32 @@ ok('new key prepended', metaPatched.includes('encrypter: Me')); ok('instrument block untouched', metaPatched.includes('instrument alpha:')); const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`; -const noTitlePatched = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' }); +const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' }); ok('missing title prepended', noTitlePatched.includes('title: Fugue')); ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1); -const nullInfoPatched = patchScore(META_SCORE, [], [], null); +const { text: nullInfoPatched } = patchScore(META_SCORE, [], [], null); ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title')); +// ── patchScore articles ──────────────────────────────────────────────────── +section('patchScore articles'); +const ART_SCORE = `articles:\n f: { add_stress: 3 }\n\ninstrument alpha:\n character:\n`; +const artModel = [{ name: 'f', isDirty: true, properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }]; +const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [], [], null, artModel); +ok('article block replaced', artPatched.includes('articles:')); +ok('updated value written', artPatched.includes('add_stress: 4')); +ok('old flow-style entry removed', !artPatched.includes('{ add_stress: 3 }')); +ok('instrument line still present', artPatched.includes('instrument alpha:')); +ok('article change logged', artLog.some(e => e.level === 'changed' && e.path === 'articles / f')); + +const ART_SCORE_NO_ART = `instrument alpha:\n character:\n`; +const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [], [], null, artModel); +ok('article block inserted before instrument when missing', artInserted.indexOf('articles:') < artInserted.indexOf('instrument alpha:')); + +const artClean = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 3, scope: 'defaults' }] }]; +const { text: artUntouched } = patchScore(ART_SCORE, [], [], null, artClean); +ok('clean articles section left verbatim', artUntouched.includes('{ add_stress: 3 }')); + // ── Motif parsing ───────────────────────────────────────────────────────── section('Motif parsing — dynamic motif'); const DYN_MOTIF = `00 bar '001P1L1M1'