Add brief editing and character record updates
This commit is contained in:
59
prototype/static/app.js
vendored
59
prototype/static/app.js
vendored
@@ -43,7 +43,7 @@ function paragraphTail(text, targetWords) {
|
||||
function loadStory() {
|
||||
const selectedSummaries = [...byId('story-summaries').querySelectorAll('input:checked')]
|
||||
.map((input) => sceneByUuid(input.value)).filter(Boolean);
|
||||
byId('story').value = selectedSummaries.map((scene) => `${scene.title}\n${scene.summary}`).join('\n\n');
|
||||
byId('story').value = selectedSummaries.map((scene) => scene.summary).join('\n\n');
|
||||
byId('new-thread').checked = selectedSummaries.length === 0;
|
||||
}
|
||||
|
||||
@@ -85,9 +85,7 @@ function addRecall() {
|
||||
if (!source) return;
|
||||
const value = byId('recall-mode').value === 'full' ? source.text : source.summary;
|
||||
if (!value) { byId('error').textContent = `${source.title} has no approved dense summary yet; choose Full canonical scene instead.`; return; }
|
||||
const form = byId('recall-mode').value === 'full' ? 'full canonical scene' : 'approved dense summary';
|
||||
const block = `${source.title} (${form})\n${value}`;
|
||||
byId('recall').value = byId('recall').value.trim() ? `${byId('recall').value.trim()}\n\n${block}` : block;
|
||||
byId('recall').value = byId('recall').value.trim() ? `${byId('recall').value.trim()}\n\n${value}` : value;
|
||||
}
|
||||
|
||||
function populateContinuity(defaults = false) {
|
||||
@@ -117,8 +115,12 @@ function escapeHtml(value) {
|
||||
|
||||
function updateSceneState() {
|
||||
const scene = project.scenes.find((candidate) => candidate.uuid === byId('scene').value);
|
||||
byId('scene-state').textContent = scene ? `${scene.chapter} · ${scene.state} · ${scene.brief ? 'BRIEF paired' : 'BRIEF missing'}` : '';
|
||||
byId('brief').textContent = scene && scene.brief ? scene.brief : 'No paired BRIEF exists for this scene.';
|
||||
const binding = scene?.binding_state === 'stable' ? 'stable UUID binding' : scene?.binding_state === 'legacy-title-match' ? 'legacy title match' : 'BRIEF missing';
|
||||
byId('scene-state').textContent = scene ? `${scene.chapter} · ${scene.state} · ${binding}` : '';
|
||||
byId('brief').value = scene?.brief || '';
|
||||
byId('brief').disabled = project.locked;
|
||||
byId('save-brief').disabled = project.locked || !scene;
|
||||
byId('brief-status').textContent = project.locked ? 'Close Scrivener before saving a BRIEF.' : scene?.brief_uuid ? 'Editing the paired BRIEF.' : 'Saving will create and stably bind a new BRIEF for this scene.';
|
||||
byId('propose-summary').disabled = !(scene && scene.state === 'canonical');
|
||||
byId('propose-updates').disabled = !(scene && scene.state === 'canonical');
|
||||
}
|
||||
@@ -146,8 +148,10 @@ function render(data) {
|
||||
byId('status').className = `status ${data.locked ? 'warning' : 'ok'}`;
|
||||
byId('project-check').innerHTML = [
|
||||
['Scenes', data.scenes.length], ['Briefs without scene', data.unmatched_briefs.length],
|
||||
['Scenes without brief', data.unmatched_scenes.length], ['Characters', data.characters.length], ['Places', data.places.length],
|
||||
['Scenes without brief', data.unmatched_scenes.length], ['Stable scene bindings', data.binding_counts.stable], ['Legacy title matches', data.binding_counts.legacy], ['Characters', data.characters.length], ['Places', data.places.length],
|
||||
].map(([label, value]) => `<dt>${label}</dt><dd>${value}</dd>`).join('');
|
||||
byId('reconcile-bindings').disabled = data.locked;
|
||||
byId('binding-status').textContent = data.locked ? 'Close Scrivener before creating or updating bindings.' : data.binding_counts.legacy ? 'Create stable bindings before renaming or moving scenes.' : 'Scene/BRIEF bindings are stored by UUID.';
|
||||
byId('scene').innerHTML = data.scenes.map((scene) => `<option value="${scene.uuid}">${escapeHtml(scene.title)} — ${escapeHtml(scene.chapter)}</option>`).join('');
|
||||
if ([...byId('scene').options].some((option) => option.value === priorSceneUuid)) byId('scene').value = priorSceneUuid;
|
||||
checks(byId('characters'), data.characters);
|
||||
@@ -175,6 +179,24 @@ async function request(path, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function reconcileBindings() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/reconcile-bindings', {});
|
||||
byId('binding-status').textContent = `${data.binding_counts.stable} stable binding${data.binding_counts.stable === 1 ? '' : 's'} saved.`;
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function saveBrief() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/save-brief', { scene_uuid: byId('scene').value, text: byId('brief').value });
|
||||
byId('brief-status').textContent = data.created ? 'New paired BRIEF created and bound by UUID.' : 'BRIEF saved.';
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function build() {
|
||||
byId('error').textContent = ''; byId('warnings').textContent = '';
|
||||
try {
|
||||
@@ -266,6 +288,7 @@ async function saveSummary() {
|
||||
function resetUpdates() {
|
||||
updatesSceneUuid = '';
|
||||
byId('character-updates').innerHTML = '';
|
||||
byId('new-character-updates').innerHTML = '';
|
||||
byId('lexicon-additions').value = ''; byId('lexicon-additions').disabled = true;
|
||||
byId('world-note-additions').value = ''; byId('world-note-additions').disabled = true;
|
||||
byId('updates-confirmation').checked = false; byId('save-updates').disabled = true;
|
||||
@@ -275,10 +298,20 @@ function resetUpdates() {
|
||||
function renderCharacterUpdates(updates) {
|
||||
byId('character-updates').innerHTML = updates.map((update) => {
|
||||
const character = project.characters.find((candidate) => candidate.uuid === update.uuid);
|
||||
return `<label class="proposal"><input type="checkbox" data-uuid="${update.uuid}" checked> <span>${escapeHtml(character ? character.title : 'Unknown character')}</span><textarea rows="3">${escapeHtml(update.text)}</textarea></label>`;
|
||||
const updateHeading = `Continuity update from ${sceneByUuid(updatesSceneUuid).title}:`;
|
||||
const completeRecord = `${character ? character.text.trim() : ''}\n\n${updateHeading}\n${update.text}`.trim();
|
||||
return `<label class="proposal"><input type="checkbox" data-uuid="${update.uuid}" checked> <span>${escapeHtml(character ? character.title : 'Unknown character')} — complete editable record</span><textarea rows="8">${escapeHtml(completeRecord)}</textarea></label>`;
|
||||
}).join('') || '<p class="muted">No character updates proposed.</p>';
|
||||
}
|
||||
|
||||
function renderNewCharacterUpdates(characters) {
|
||||
byId('new-character-updates').innerHTML = characters.map((character, index) => `
|
||||
<label class="proposal"><input type="checkbox" data-new-character="${index}" checked> <span>New Character</span>
|
||||
<input class="new-character-name" value="${escapeHtml(character.name)}" aria-label="New character name">
|
||||
<textarea class="new-character-text" rows="3">${escapeHtml(character.text)}</textarea>
|
||||
</label>`).join('') || '<p class="muted">No new Characters proposed.</p>';
|
||||
}
|
||||
|
||||
async function proposeUpdates() {
|
||||
byId('error').textContent = '';
|
||||
const scene = sceneByUuid(byId('scene').value);
|
||||
@@ -286,27 +319,31 @@ async function proposeUpdates() {
|
||||
const data = await request('/api/propose-continuity-updates', { scene_uuid: scene.uuid, text: scene.text, send_confirmation: byId('updates-send-confirmation').checked });
|
||||
updatesSceneUuid = scene.uuid;
|
||||
renderCharacterUpdates(data.character_updates || []);
|
||||
renderNewCharacterUpdates(data.new_characters || []);
|
||||
byId('lexicon-additions').value = data.lexicon_additions || '';
|
||||
byId('lexicon-additions').disabled = false;
|
||||
byId('world-note-additions').value = data.world_note_additions || '';
|
||||
byId('world-note-additions').disabled = false;
|
||||
byId('save-updates').disabled = false;
|
||||
byId('updates-status').textContent = 'Review each proposal. Uncheck a character update to exclude it.';
|
||||
byId('updates-status').textContent = 'Each selected Character field is a complete replacement record: trim or revise it before saving. Uncheck to exclude it.';
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function saveUpdates() {
|
||||
byId('error').textContent = '';
|
||||
const characterUpdates = [...byId('character-updates').querySelectorAll('input:checked')].map((input) => ({ uuid: input.dataset.uuid, text: input.parentElement.querySelector('textarea').value }));
|
||||
const newCharacters = [...byId('new-character-updates').querySelectorAll('input:checked')].map((input) => ({ name: input.parentElement.querySelector('.new-character-name').value, text: input.parentElement.querySelector('.new-character-text').value }));
|
||||
try {
|
||||
const data = await request('/api/save-continuity-updates', { scene_uuid: updatesSceneUuid, character_updates: characterUpdates, lexicon_additions: byId('lexicon-additions').value, world_note_additions: byId('world-note-additions').value, updates_confirmation: byId('updates-confirmation').checked });
|
||||
const saved = [...data.characters, data.lexicon ? 'Master Lexicon' : '', data.world_notes ? 'World Notes' : ''].filter(Boolean);
|
||||
const data = await request('/api/save-continuity-updates', { scene_uuid: updatesSceneUuid, character_updates: characterUpdates, new_characters: newCharacters, lexicon_additions: byId('lexicon-additions').value, world_note_additions: byId('world-note-additions').value, updates_confirmation: byId('updates-confirmation').checked });
|
||||
const saved = [...data.characters, ...(data.new_characters || []).map((name) => `new Character: ${name}`), data.lexicon ? 'Master Lexicon' : '', data.world_notes ? 'World Notes' : ''].filter(Boolean);
|
||||
byId('updates-status').textContent = saved.length ? `Saved updates to: ${saved.join(', ')}.` : 'No new continuity additions were saved.';
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
byId('scene').addEventListener('change', changeScene);
|
||||
byId('reconcile-bindings').addEventListener('click', reconcileBindings);
|
||||
byId('save-brief').addEventListener('click', saveBrief);
|
||||
byId('load-story').addEventListener('click', loadStory);
|
||||
byId('load-previous').addEventListener('click', loadPrevious);
|
||||
byId('add-recall').addEventListener('click', addRecall);
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<aside>
|
||||
<h2>Project check</h2>
|
||||
<dl id="project-check"></dl>
|
||||
<button id="reconcile-bindings" type="button">Create/update stable scene bindings</button>
|
||||
<p id="binding-status" class="muted"></p>
|
||||
<h2>Scene</h2>
|
||||
<label for="scene">Paired Manuscript scene</label>
|
||||
<select id="scene"></select>
|
||||
@@ -29,8 +31,10 @@
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<section class="brief-panel">
|
||||
<h2>Author BRIEF <span class="read-only">read-only</span></h2>
|
||||
<pre id="brief">Select a scene to view its paired BRIEF.</pre>
|
||||
<h2>Author BRIEF <span class="read-only">editable</span></h2>
|
||||
<textarea id="brief" rows="10" placeholder="Write the BRIEF that drives this scene."></textarea>
|
||||
<button id="save-brief" type="button">Save BRIEF</button>
|
||||
<p id="brief-status" class="muted"></p>
|
||||
</section>
|
||||
<section class="brief-panel">
|
||||
<h2>World Notes <span class="read-only">read-only reference</span></h2>
|
||||
@@ -124,6 +128,7 @@
|
||||
<label class="checkbox"><input id="updates-send-confirmation" type="checkbox"> I understand this sends the canonical scene and current continuity records to OpenRouter.</label>
|
||||
<button id="propose-updates" disabled>Propose continuity updates</button>
|
||||
<div id="character-updates" class="proposal-list"></div>
|
||||
<div id="new-character-updates" class="proposal-list"></div>
|
||||
<label>LEXICON additions <span class="read-only">editable</span>
|
||||
<textarea id="lexicon-additions" rows="4" disabled></textarea>
|
||||
</label>
|
||||
|
||||
@@ -11,9 +11,9 @@ button { margin: 1rem 0; border: 0; border-radius: 4px; padding: .65rem 1rem; ba
|
||||
button:disabled { cursor: not-allowed; opacity: .5; } .draft-actions, .final-actions { margin-top: .5rem; padding: .75rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .draft-actions button, .final-actions button { margin: .6rem .5rem 0 0; }
|
||||
.continuity-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #eef4f0; } .continuity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } .continuity-panel button { margin: .6rem 0 0; } .carry-actions { margin-top: 1rem; padding-top: .25rem; border-top: 1px solid #cbd8d0; } .carry-actions button { margin-right: .5rem; } .context-list { min-height: 6rem; max-height: 12rem; overflow: auto; margin-top: .35rem; padding: .45rem; border: 1px solid #bab2a5; background: #fffefb; } .context-list label { padding: .22rem 0; font-weight: 400; }
|
||||
#working-draft { min-height: 28rem; max-height: 65vh; resize: vertical; background: #fffefb; color: #17212b; font: .9rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.summary-panel, .updates-panel { margin-top: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .proposal-list { margin: .75rem 0; } .proposal { margin: .6rem 0; padding: .6rem; border: 1px solid #d8d2c5; background: #fffefb; font-weight: 650; } .proposal textarea { margin-top: .55rem; font-weight: 400; }
|
||||
.summary-panel, .updates-panel { margin-top: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .proposal-list { margin: .75rem 0; } .proposal { margin: .6rem 0; padding: .6rem; border: 1px solid #d8d2c5; background: #fffefb; font-weight: 650; } .proposal textarea, .proposal input:not([type]) { margin-top: .55rem; font-weight: 400; }
|
||||
pre { min-height: 28rem; max-height: 65vh; overflow: auto; margin: 0; padding: 1rem; border: 1px solid #d8d2c5; background: #202525; color: #e5f3ed; white-space: pre-wrap; font: .82rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.brief-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .brief-panel h2 { margin-top: 1rem; } .brief-panel pre { min-height: 0; max-height: 16rem; background: #fffefb; color: #17212b; font-family: inherit; font-size: .95rem; } .read-only { font-size: .68rem; font-weight: 600; color: #665f54; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.brief-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .brief-panel h2 { margin-top: 1rem; } .brief-panel pre { min-height: 0; max-height: 16rem; background: #fffefb; color: #17212b; font-family: inherit; font-size: .95rem; } .brief-panel textarea { min-height: 12rem; font: .95rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } .read-only { font-size: .68rem; font-weight: 600; color: #665f54; text-transform: uppercase; letter-spacing: .08em; }
|
||||
dl { display: grid; grid-template-columns: 1fr auto; margin: 0; font-size: .9rem; } dt, dd { margin: 0; padding: .25rem 0; border-bottom: 1px solid #ece6da; } dd { font-weight: 700; } .status { border-radius: 999px; padding: .45rem .75rem; font-size: .78rem; } .ok { background: #d9eee4; color: #164d38; } .warning { background: #ffe2d6; color: #7f2c19; } .muted { color: #665f54; font-size: .8rem; } .error { color: #aa2717; font-weight: 700; } .checkbox { margin-top: 1rem; font-weight: 400; }
|
||||
.warnings { color: #805215; font-weight: 650; }
|
||||
@media (max-width: 800px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #d8d2c5; } .grid, .continuity-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user