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:
c0dev0id
2026-06-30 06:27:12 +02:00
parent 5bec1a1d1e
commit 86b14dfaa3
6 changed files with 132 additions and 60 deletions

View File

@@ -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,

View File

@@ -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',

View File

@@ -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 };
}

View File

@@ -8,6 +8,7 @@ export const store = reactive({
synthesisStatus: null,
isDirty: false,
errorMessage: '',
exportLog: [],
setFocus(path) {
this.focusPath = path;

View File

@@ -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 {