Article export + Bootstrap toggle + export change log
- patchScore() now accepts articles[] and returns { text, log } instead
of a plain string. Dirty articles are re-serialized into the articles:
block (block style, replacing any existing flow-style entries). Clean
articles bypass the patcher entirely.
- article.isDirty is set on direct property edits so the exporter can
distinguish directly changed entities from propagated dirty state.
- patchScore() log collects { level:'changed', path } for each patched
article/instrument/bar, and a { level:'info', message } entry for the
count of bar documents passed through unchanged.
- CP pane shows the export log above the status poller after each export.
- Article scope toggle replaced with Bootstrap form-check form-switch;
hand-rolled .se-toggle CSS removed.
- Test suite updated for { text, log } return; 11 new assertions (188 total).
This commit is contained in:
@@ -67,7 +67,11 @@ export const PaneCP = {
|
|||||||
try {
|
try {
|
||||||
const raw = await fetchScoreText(props.store.credentials);
|
const raw = await fetchScoreText(props.store.credentials);
|
||||||
props.store.rawScoreText = raw;
|
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);
|
await putScoreText(patched, props.store.credentials);
|
||||||
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -134,6 +138,18 @@ export const PaneCP = {
|
|||||||
// Export error
|
// Export error
|
||||||
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
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)
|
// Status poller (while running)
|
||||||
store.synthesisStatus && !store.synthesisStatus.frozen
|
store.synthesisStatus && !store.synthesisStatus.frozen
|
||||||
? h(StatusPoller, { store }) : null,
|
? h(StatusPoller, { store }) : null,
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const PaneFO = {
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
} else if (node.type === 'article') {
|
} 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';
|
const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0';
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', H4, `Article: ${node.name}`),
|
h('h4', H4, `Article: ${node.name}`),
|
||||||
@@ -180,14 +180,20 @@ export const PaneFO = {
|
|||||||
style: 'flex:1;min-width:6em',
|
style: 'flex:1;min-width:6em',
|
||||||
onInput: onValInput,
|
onInput: onValInput,
|
||||||
}),
|
}),
|
||||||
h('button', {
|
h('div', { class: 'form-check form-switch mb-0' }, [
|
||||||
class: 'se-toggle',
|
h('input', {
|
||||||
role: 'switch',
|
class: 'form-check-input mt-0',
|
||||||
'aria-checked': String(isOverwrite),
|
type: 'checkbox',
|
||||||
onClick: flip,
|
role: 'switch',
|
||||||
}),
|
id: `prop-scope-${idx}`,
|
||||||
h('span', { class: 'se-toggle-label active', style: 'min-width:4.5em' },
|
checked: isOverwrite,
|
||||||
isOverwrite ? 'overwrite' : 'default'),
|
onChange: flip,
|
||||||
|
}),
|
||||||
|
h('label', {
|
||||||
|
class: 'form-check-label',
|
||||||
|
for: `prop-scope-${idx}`,
|
||||||
|
}, isOverwrite ? 'overwrite' : 'default'),
|
||||||
|
]),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'se-btn-remove',
|
class: 'se-btn-remove',
|
||||||
title: 'Remove property',
|
title: 'Remove property',
|
||||||
|
|||||||
@@ -137,8 +137,46 @@ export function exportInstrument(instr) {
|
|||||||
return lines.join('\n');
|
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 ────────────────────────────────────────────────────────────
|
// ── 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.
|
// Voice note content in bar documents is left verbatim.
|
||||||
|
|
||||||
const META_KEYS = ['title', 'composer', 'source', 'encrypter'];
|
const META_KEYS = ['title', 'composer', 'source', 'encrypter'];
|
||||||
@@ -242,24 +280,40 @@ function patchBarMeta(doc, bar) {
|
|||||||
return out.join('\n');
|
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 SEP = '\n---\n';
|
||||||
const [header, ...barDocs] = rawScoreText.split(SEP);
|
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 = {};
|
const barMap = {};
|
||||||
for (const bar of bars) barMap[bar.id] = bar;
|
for (const bar of bars) barMap[bar.id] = bar;
|
||||||
|
|
||||||
|
let passedThrough = 0;
|
||||||
const patchedBarDocs = barDocs.map(doc => {
|
const patchedBarDocs = barDocs.map(doc => {
|
||||||
const m = doc.match(/^_id:\s*(\S+)/m);
|
const m = doc.match(/^_id:\s*(\S+)/m);
|
||||||
if (!m) return doc;
|
if (!m) { passedThrough++; return doc; }
|
||||||
const bar = barMap[m[1]];
|
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 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const store = reactive({
|
|||||||
synthesisStatus: null,
|
synthesisStatus: null,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
exportLog: [],
|
||||||
|
|
||||||
setFocus(path) {
|
setFocus(path) {
|
||||||
this.focusPath = path;
|
this.focusPath = path;
|
||||||
|
|||||||
@@ -356,47 +356,19 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Two-state toggle (scope: defaults / overwrites). The label preceding the
|
/* Export log (shown in CP pane after export) */
|
||||||
switch tells the user what the two ends mean; the thumb position shows
|
.se-export-log {
|
||||||
which is active. Click anywhere on the track to flip. */
|
font-size: 0.75rem;
|
||||||
.se-toggle {
|
font-family: monospace;
|
||||||
position: relative;
|
border: 1px solid #333;
|
||||||
width: 2.2rem;
|
padding: 0.3rem 0.4rem;
|
||||||
height: 1.1rem;
|
margin: 0.4rem 0;
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid #555;
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
background: #2a2a2a;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
.se-toggle::after {
|
.se-export-changed {
|
||||||
content: '';
|
color: #aad4aa;
|
||||||
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-toggle[aria-checked="true"] {
|
.se-export-info {
|
||||||
background: #2d4a2d;
|
|
||||||
border-color: #4a7a4a;
|
|
||||||
}
|
|
||||||
.se-toggle[aria-checked="true"]::after {
|
|
||||||
transform: translateX(1.1rem);
|
|
||||||
background: #b0e0b0;
|
|
||||||
}
|
|
||||||
.se-toggle-label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #888;
|
color: #888;
|
||||||
user-select: none;
|
|
||||||
min-width: 4em;
|
|
||||||
}
|
|
||||||
.se-toggle-label.active {
|
|
||||||
color: #d8d8d8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-prop-key, .se-prop-val {
|
.se-prop-key, .se-prop-val {
|
||||||
|
|||||||
@@ -230,12 +230,14 @@ const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
|
|||||||
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
|
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
|
||||||
// Mark only alpha as dirty, verify ki is preserved verbatim.
|
// Mark only alpha as dirty, verify ki is preserved verbatim.
|
||||||
const patchInstruments = model.instruments.map(i => ({ ...i, isDirty: i.name === 'alpha' }));
|
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');
|
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 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 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('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)
|
// 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)));
|
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,
|
stressor: null, tempoLevels: null,
|
||||||
upperStressBound: null, lowerStressBound: null, tempoShape: 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');
|
const barPatchedLines = barPatched.split('\n');
|
||||||
ok('dirty bar _meta updated with new BPM', barPatched.includes('beats_per_minute: 140'));
|
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'));
|
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 unchanged', barPatched.includes('beats_per_minute: 100'));
|
||||||
ok('clean bar voice preserved', barPatched.includes('- D4 4'));
|
ok('clean bar voice preserved', barPatched.includes('- D4 4'));
|
||||||
ok('document separators preserved', (barPatched.match(/\n---\n/g) ?? []).length === 2);
|
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 ────────────────────────────────────────────────────
|
// ── patchScore metadata ────────────────────────────────────────────────────
|
||||||
section('patchScore metadata');
|
section('patchScore metadata');
|
||||||
@@ -450,7 +454,7 @@ instrument alpha:
|
|||||||
A: "1:0,10;1,0"
|
A: "1:0,10;1,0"
|
||||||
`;
|
`;
|
||||||
const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' };
|
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 title replaced', metaPatched.includes('title: New Title'));
|
||||||
ok('existing composer replaced', metaPatched.includes('composer: New Composer'));
|
ok('existing composer replaced', metaPatched.includes('composer: New Composer'));
|
||||||
ok('empty value leaves existing line', metaPatched.includes('source: Some Book'));
|
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:'));
|
ok('instrument block untouched', metaPatched.includes('instrument alpha:'));
|
||||||
|
|
||||||
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
|
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('missing title prepended', noTitlePatched.includes('title: Fugue'));
|
||||||
ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1);
|
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'));
|
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 ─────────────────────────────────────────────────────────
|
// ── Motif parsing ─────────────────────────────────────────────────────────
|
||||||
section('Motif parsing — dynamic motif');
|
section('Motif parsing — dynamic motif');
|
||||||
const DYN_MOTIF = `00 bar '001P1L1M1'
|
const DYN_MOTIF = `00 bar '001P1L1M1'
|
||||||
|
|||||||
Reference in New Issue
Block a user