phases 4-6: article system, stem note fields, edit-state cleanup
Phase 4 — remove edit-state flags from domain objects: - ast-parser.js: drop isDirty from buildInstrument, buildBar, buildStemNote - store.js: add resetEditState() - PaneCP.js: use resetEditState() on import; drop flags arg from patchScore - PaneFO.js: isDirty -> _modified for linked instruments; no per-node dirty tracking - PaneSubObjects.js: _spliceNode helper; deletion via splice; _isNew on new bars - exporter.js: on-demand traversal; absence from model means deleted Phase 5 — article parser and exporter: - ast-parser.js: _buildArticleEntry replaces _mergeArticleEntry; stage.article and voice.article dispatch; article properties carry scope and overwritten flag - exporter.js: _buildArticlesBlock emits -important: list for overwrites scope Phase 6 — stem note dead fields: - ast-parser.js: parse weight and articulatory on stem notes; flatten article.definite properties onto stem note model sibling to pitch/chain
This commit is contained in:
@@ -109,14 +109,24 @@ export function buildModel(rawTree) {
|
||||
case 'stage.cone':
|
||||
score.stageCone = { type: 'stage_cone', ...node.props };
|
||||
break;
|
||||
case 'stage.voice':
|
||||
score.stageVoices.push({
|
||||
case 'stage.article':
|
||||
score.articles.push(_buildArticleEntry(node));
|
||||
break;
|
||||
case 'stage.voice': {
|
||||
const sv = {
|
||||
type: 'stage_voice',
|
||||
name: node.positionals[0],
|
||||
direction: node.props.direction,
|
||||
distance: node.props.distance,
|
||||
});
|
||||
articles: [],
|
||||
};
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot === 'voice' && child.slot === 'article')
|
||||
sv.articles.push(_buildArticleEntry(child));
|
||||
}
|
||||
score.stageVoices.push(sv);
|
||||
break;
|
||||
}
|
||||
case 'instrument':
|
||||
score.instruments.push(_buildInstrument(node));
|
||||
break;
|
||||
@@ -124,31 +134,27 @@ export function buildModel(rawTree) {
|
||||
score.bars.push(_buildBar(node));
|
||||
break;
|
||||
default:
|
||||
if (node.parentSlot === 'articles') {
|
||||
_mergeArticleEntry(score, node);
|
||||
} else {
|
||||
score[node.slot] = _buildGeneric(node);
|
||||
}
|
||||
score[node.slot] = _buildGeneric(node);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// Articles are keyed by label. The AST emits one line per (label, subtype)
|
||||
// — e.g. `articles.defaults 'f'` and (future) `articles.overwrites 'f'`.
|
||||
// We merge them into one entry per label whose properties carry the scope
|
||||
// of their originating subtype, so the UI can toggle scope per property.
|
||||
function _mergeArticleEntry(score, node) {
|
||||
const name = node.positionals[0];
|
||||
let entry = score.articles.find(a => a.name === name);
|
||||
if (!entry) {
|
||||
entry = { type: 'article', name, properties: [] };
|
||||
score.articles.push(entry);
|
||||
}
|
||||
const scope = node.slot; // 'defaults' | 'overwrites' | future subtype
|
||||
for (const [key, value] of Object.entries(node.props)) {
|
||||
entry.properties.push({ name: key, value, scope });
|
||||
function _buildArticleEntry(node) {
|
||||
const entry = { type: 'article', name: node.positionals[0], properties: [] };
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot !== 'article') continue;
|
||||
if (child.slot === 'defaults' || child.slot === 'definite') {
|
||||
const raw = child.props.constant ?? child.props.stacked ?? child.props.static;
|
||||
const shapeChild = child.children.find(c => c.slot === 'shape');
|
||||
const value = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null);
|
||||
entry.properties.push({ name: child.positionals[0], value, scope: child.slot, overwritten: child.props.overwritten ?? false });
|
||||
} else if (child.slot === 'overwrites') {
|
||||
for (const propName of child.positionals) {
|
||||
entry.properties.push({ name: propName, value: null, scope: 'overwrites' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@@ -176,7 +182,6 @@ function _buildInstrument(node) {
|
||||
name: node.positionals[0],
|
||||
notChangedSince: node.props.NOT_CHANGED_SINCE ?? null,
|
||||
isLinked: (node.positionals[0] ?? '').includes('/'),
|
||||
isDirty: false,
|
||||
variations: [],
|
||||
basicProperties: null,
|
||||
volumes: null,
|
||||
@@ -339,7 +344,6 @@ function _buildBar(node) {
|
||||
const bar = {
|
||||
type: 'bar',
|
||||
id: node.positionals[0] ?? '',
|
||||
isDirty: false,
|
||||
stressor: null,
|
||||
tempoShape: null,
|
||||
tempoLevels: null,
|
||||
@@ -413,7 +417,7 @@ function _buildVoice(node) {
|
||||
voice.offsets.push(_buildOffset(child));
|
||||
break;
|
||||
case 'voice.article':
|
||||
voice.articles.push(child.positionals[0]);
|
||||
voice.articles.push(_buildArticleEntry(child));
|
||||
break;
|
||||
case 'voice.motif':
|
||||
voice.motifs.push(_buildMotif(child));
|
||||
@@ -444,9 +448,17 @@ function _buildOffset(node) {
|
||||
}
|
||||
|
||||
function _buildStemNote(node) {
|
||||
const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent']);
|
||||
const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent', 'weight', 'articulatory']);
|
||||
const chainNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'chain');
|
||||
const writeToNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'write_to');
|
||||
const definiteProps = {};
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot === 'article' && child.slot === 'definite') {
|
||||
const raw = child.props.constant ?? child.props.stacked ?? child.props.static;
|
||||
const shapeChild = child.children.find(c => c.slot === 'shape');
|
||||
definiteProps[child.positionals[0]] = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'stem_note',
|
||||
pitch: node.props.pitch,
|
||||
@@ -454,13 +466,13 @@ function _buildStemNote(node) {
|
||||
adjacent: node.props.adjacent ?? null,
|
||||
adjStress: node.props.adj_stress ?? null,
|
||||
length: null,
|
||||
weight: null,
|
||||
weight: node.props.weight ?? null,
|
||||
chainText: chainNode?.props._tmp_string ?? '',
|
||||
writeToName: writeToNode?.positionals[0] ?? null,
|
||||
clauses: (chainNode?.children ?? [])
|
||||
.filter(c => c.parentSlot === 'chain' && c.slot === 'clause')
|
||||
.map(_buildClause),
|
||||
isDirty: false,
|
||||
...definiteProps,
|
||||
unknownProps: _collectUnknownProps(node.props, KNOWN),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const PaneCP = {
|
||||
try {
|
||||
const text = await fetchAstLog();
|
||||
props.store.scoreModel = buildModel(parseAstLog(text));
|
||||
props.store.exportLog = [];
|
||||
props.store.resetEditState();
|
||||
} catch (e) {
|
||||
importError.value = e.message;
|
||||
} finally {
|
||||
@@ -49,9 +49,7 @@ export const PaneCP = {
|
||||
const model = props.store.scoreModel;
|
||||
const { text: patched, log } = patchScore(
|
||||
raw, model.instruments, model.bars, model.info, model.articles ?? [],
|
||||
{ articlesModified: !!model.articlesModified },
|
||||
);
|
||||
model.articlesModified = false;
|
||||
props.store.exportLog = log;
|
||||
await putScoreText(patched);
|
||||
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
||||
|
||||
@@ -61,10 +61,10 @@ export const PaneFO = {
|
||||
// Guard: first edit to a linked instrument triggers embed-or-discard before committing.
|
||||
function makeChangeHandler(instr) {
|
||||
return (info) => {
|
||||
if (instr.isLinked && !instr.isDirty) {
|
||||
if (instr.isLinked && !instr._modified) {
|
||||
pendingEdit.value = { instr, undo: info?.undo };
|
||||
} else {
|
||||
instr.isDirty = true;
|
||||
instr._modified = true;
|
||||
props.store.markDirty();
|
||||
}
|
||||
};
|
||||
@@ -73,7 +73,7 @@ export const PaneFO = {
|
||||
function embedInstrument(instr) {
|
||||
instr.name = instr.name.split('/').pop();
|
||||
instr.isLinked = false;
|
||||
instr.isDirty = true;
|
||||
instr._modified = true;
|
||||
pendingEdit.value = null;
|
||||
props.store.markDirty();
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export const PaneFO = {
|
||||
: null,
|
||||
);
|
||||
} else if (node.type === 'article') {
|
||||
const markDirty = () => { node.isDirty = true; props.store.markDirty(); };
|
||||
const markDirty = () => { 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}`),
|
||||
@@ -209,12 +209,12 @@ export const PaneFO = {
|
||||
]),
|
||||
);
|
||||
} else if (node.type === 'bar') {
|
||||
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };
|
||||
const markBarDirty = () => { props.store.markDirty(); };
|
||||
children.push(
|
||||
h('h4', H4, `Bar: ${node.id}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'id', value: node.id, editable: node.isNew ?? false },
|
||||
{ key: 'id', value: node.id, editable: node._isNew ?? false },
|
||||
{ key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' },
|
||||
{ key: 'stress_pattern', value: stressorToString(node.stressor), editable: true },
|
||||
],
|
||||
@@ -234,7 +234,7 @@ export const PaneFO = {
|
||||
h('h4', H4, `Voice: ${node.name}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
|
||||
{ key: 'articles', value: node.articles.map(a => a.name).join(', ') || '—', editable: false },
|
||||
{ key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false },
|
||||
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
||||
],
|
||||
@@ -273,10 +273,7 @@ export const PaneFO = {
|
||||
}),
|
||||
);
|
||||
} else if (node.type === 'stem_note') {
|
||||
const bar = props.store.focusPath.find(n => n.type === 'bar') ?? null;
|
||||
const markDirty = () => {
|
||||
node.isDirty = true;
|
||||
if (bar) bar.isDirty = true;
|
||||
props.store.markDirty();
|
||||
};
|
||||
children.push(
|
||||
|
||||
@@ -23,38 +23,39 @@ export const PaneSubObjects = {
|
||||
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
||||
}
|
||||
|
||||
function _spliceNode(arr, node) {
|
||||
const idx = arr.indexOf(node);
|
||||
if (idx !== -1) { arr.splice(idx, 1); props.store.markDirty(); }
|
||||
}
|
||||
|
||||
function deleteItem(kind, node) {
|
||||
const store = props.store;
|
||||
const model = store.scoreModel;
|
||||
if (!model) return;
|
||||
|
||||
if (kind === 'instrument') {
|
||||
const idx = model.instruments.indexOf(node);
|
||||
if (idx !== -1) { model.instruments.splice(idx, 1); store.markDirty(); }
|
||||
_spliceNode(model.instruments, node);
|
||||
} else if (kind === 'articles') {
|
||||
const idx = (model.articles ?? []).indexOf(node);
|
||||
if (idx !== -1) { model.articles.splice(idx, 1); model.articlesModified = true; store.markDirty(); }
|
||||
_spliceNode(model.articles, node);
|
||||
} else if (kind === 'bar') {
|
||||
node.deleted = true;
|
||||
node.isDirty = true;
|
||||
store.markDirty();
|
||||
_spliceNode(model.bars, node);
|
||||
} else if (kind === 'variation') {
|
||||
for (const instr of model.instruments) {
|
||||
const idx = instr.variations.indexOf(node);
|
||||
if (idx !== -1) { instr.variations.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; }
|
||||
if (idx !== -1) { instr.variations.splice(idx, 1); instr._modified = true; store.markDirty(); return; }
|
||||
for (const v of instr.variations) {
|
||||
const sidx = v.subvariations.indexOf(node);
|
||||
if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr.isDirty = true; store.markDirty(); return; }
|
||||
if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr._modified = true; store.markDirty(); return; }
|
||||
}
|
||||
}
|
||||
} else if (kind === 'label_spec') {
|
||||
for (const instr of model.instruments) {
|
||||
for (const v of instr.variations) {
|
||||
const idx = v.labelSpecs.indexOf(node);
|
||||
if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; }
|
||||
if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr._modified = true; store.markDirty(); return; }
|
||||
for (const sv of v.subvariations) {
|
||||
const idx2 = sv.labelSpecs.indexOf(node);
|
||||
if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr.isDirty = true; store.markDirty(); return; }
|
||||
if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr._modified = true; store.markDirty(); return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,11 +70,11 @@ export const PaneSubObjects = {
|
||||
if (afterId) {
|
||||
newId = _incrementId(afterId);
|
||||
} else {
|
||||
const liveBars = (model.bars ?? []).filter(b => !b.deleted);
|
||||
const liveBars = model.bars ?? [];
|
||||
newId = liveBars.length ? _incrementId(liveBars[liveBars.length - 1].id) : 'bar001';
|
||||
}
|
||||
const newBar = {
|
||||
type: 'bar', id: newId, isDirty: true, isNew: true,
|
||||
type: 'bar', id: newId, _isNew: true,
|
||||
stressor: null, tempoLevels: null,
|
||||
upperStressBound: null, lowerStressBound: null, tempoShape: null,
|
||||
voices: {},
|
||||
@@ -95,11 +96,9 @@ export const PaneSubObjects = {
|
||||
if (!items.length && props.kind !== 'bar') return h('div', null, h('em', null, 'No sub-objects'));
|
||||
|
||||
if (props.kind === 'bar') {
|
||||
const visibleItems = items.filter(item => !item.node.deleted);
|
||||
|
||||
const barGroups = [];
|
||||
const seen = new Map();
|
||||
for (const item of visibleItems) {
|
||||
for (const item of items) {
|
||||
const key = _barGroupKey(item.label);
|
||||
if (!seen.has(key)) {
|
||||
const g = { key, items: [] };
|
||||
|
||||
@@ -133,23 +133,30 @@ function _serializeArticleValue(v) {
|
||||
|
||||
function _buildArticlesBlock(articles) {
|
||||
const body = articles.flatMap(art => {
|
||||
const props = (art.properties ?? []).filter(p => p.name);
|
||||
if (!props.length) return [];
|
||||
return [` ${art.name}:`, ...props.map(p => ` ${p.name}: ${_serializeArticleValue(p.value)}`)];
|
||||
const defaults = (art.properties ?? []).filter(p => p.scope !== 'overwrites' && p.name);
|
||||
const overwrites = (art.properties ?? []).filter(p => p.scope === 'overwrites' && p.name);
|
||||
if (!defaults.length && !overwrites.length) return [];
|
||||
const lines = [` ${art.name}:`];
|
||||
defaults.forEach(p => lines.push(` ${p.name}: ${_serializeArticleValue(p.value)}`));
|
||||
if (overwrites.length) {
|
||||
lines.push(` -important:`);
|
||||
overwrites.forEach(p => lines.push(` - ${p.name}`));
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
return body.length ? ['articles:', ...body].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 {
|
||||
if (newBlock) lines.splice(artIdx, end - artIdx, newBlock);
|
||||
else lines.splice(artIdx, end - artIdx);
|
||||
} else if (newBlock) {
|
||||
const instrIdx = lines.findIndex(l => /^instrument\s/.test(l));
|
||||
const insertAt = instrIdx !== -1 ? instrIdx : lines.length;
|
||||
lines.splice(insertAt, 0, newBlock, '');
|
||||
@@ -196,17 +203,18 @@ function _patchInstrumentHeader(text, instruments) {
|
||||
if (m) {
|
||||
const rawName = m[1].replace(/^'|'$/g, '');
|
||||
const instr = instrMap[rawName];
|
||||
if (instr && instr.deleted) {
|
||||
if (!instr) {
|
||||
// not in model → deleted; skip block
|
||||
i++;
|
||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||
} else if (instr && instr.isDirty) {
|
||||
} else if (instr.isLinked && !instr._modified) {
|
||||
result.push(line);
|
||||
i++;
|
||||
} else {
|
||||
i++;
|
||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||
result.push(exportInstrument(instr));
|
||||
result.push('');
|
||||
} else {
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
result.push(line);
|
||||
@@ -263,42 +271,37 @@ function _buildNewBarDoc(bar) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = [], flags = {}) {
|
||||
export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = []) {
|
||||
const log = [];
|
||||
const SEP = '\n---\n';
|
||||
const [header, ...barDocs] = rawScoreText.split(SEP);
|
||||
|
||||
let patchedHeader = _patchMetadata(header, info);
|
||||
|
||||
const dirtyArticles = articles.filter(a => a.isDirty);
|
||||
if (dirtyArticles.length || flags.articlesModified) {
|
||||
if (articles.length) {
|
||||
patchedHeader = _patchArticles(patchedHeader, articles);
|
||||
dirtyArticles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` }));
|
||||
articles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` }));
|
||||
}
|
||||
|
||||
patchedHeader = _patchInstrumentHeader(patchedHeader, instruments);
|
||||
instruments.forEach(i => {
|
||||
if (i.deleted) log.push({ level: 'changed', path: `instrument / ${i.name} (deleted)` });
|
||||
else if (i.isDirty) log.push({ level: 'changed', path: `instrument / ${i.name}` });
|
||||
if (!(i.isLinked && !i._modified)) log.push({ level: 'changed', path: `instrument / ${i.name}` });
|
||||
});
|
||||
|
||||
const barMap = Object.fromEntries(bars.map(b => [b.id, b]));
|
||||
const newBars = bars.filter(b => b.isNew && !b.deleted);
|
||||
const newBars = bars.filter(b => b._isNew);
|
||||
|
||||
if (!barDocs.length) {
|
||||
newBars.forEach(b => log.push({ level: 'changed', path: `bar / ${b.id}` }));
|
||||
return { text: [patchedHeader, ...newBars.map(_buildNewBarDoc)].join(SEP), log };
|
||||
}
|
||||
|
||||
let passedThrough = 0;
|
||||
const patchedBarDocs = [];
|
||||
barDocs.forEach(doc => {
|
||||
const m = doc.match(/^_id:\s*(\S+)/m);
|
||||
if (!m) { passedThrough++; patchedBarDocs.push(doc); return; }
|
||||
if (!m) { patchedBarDocs.push(doc); return; }
|
||||
const bar = barMap[m[1]];
|
||||
if (!bar) { passedThrough++; patchedBarDocs.push(doc); return; }
|
||||
if (bar.deleted) return;
|
||||
if (!bar.isDirty) { passedThrough++; patchedBarDocs.push(doc); return; }
|
||||
if (!bar) return; // not in model → deleted
|
||||
log.push({ level: 'changed', path: `bar / ${bar.id}` });
|
||||
patchedBarDocs.push(_patchBarMeta(doc, bar));
|
||||
});
|
||||
@@ -308,9 +311,5 @@ export function patchScore(rawScoreText, instruments, bars = [], info = null, ar
|
||||
log.push({ level: 'changed', path: `bar / ${bar.id}` });
|
||||
});
|
||||
|
||||
if (passedThrough > 0) {
|
||||
log.push({ level: 'info', message: `${passedThrough} bar document${passedThrough !== 1 ? 's' : ''} passed through unchanged` });
|
||||
}
|
||||
|
||||
return { text: [patchedHeader, ...patchedBarDocs].join(SEP), log };
|
||||
}
|
||||
|
||||
@@ -25,4 +25,10 @@ export const store = reactive({
|
||||
markDirty() {
|
||||
this.isDirty = true;
|
||||
},
|
||||
|
||||
resetEditState() {
|
||||
this.isDirty = false;
|
||||
this.exportLog = [];
|
||||
this.focusPath = [];
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user