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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user