fix(library): editor follow-ups after storage restore and history selection parity (#243)
* fix(library): close recipe editor follow-ups from the native editor slice - After restored storage, Create text revision and Archive are disabled so they cannot re-read an old numeric ID; only Reload Library remains. - The storage-changed exit in Edit, revision and Add negative states that Reload Library closes the dialog and discards the entries (the concept has no discard confirmation, so the copy stays inline). - When Save becomes disabled after an unconfirmed write or a conflict, focus moves to the recovery action instead of falling to the body; the archive confirmation does the same. - A reuse toast only says the entered parent metadata was not applied when the controller found it differs from the stored metadata. - Prompt history opens on its first negative variant, as the concept does, so the heading Send starts enabled. - "1 linked image" is singular on cards, variant lanes and the embedded gallery count. * fix(library): treat a blank category as equal to a stored uncategorized one A reuse that left the category blank sent "Uncategorized" and compared it with the stored NULL, so the toast claimed the entered metadata was not applied. Both sides now normalize blank and "Uncategorized" the same way before comparing; a genuinely different category still discloses it. * test(coverage): raise frontend coverage floors after editor follow-ups * fix(library): singular counts for one item and honest discard copy - One shared countOf/noun helper (web/js/workspace/plural.mjs) now words linked images, saved negative variants (card chip and history count), distinct recorded runs and the Images toolbar count in the singular for exactly one; every other count keeps its existing wording. - The restored-storage status says "Leaving this dialog discards your entries." because Cancel, the close button and Escape discard the draft too, not only Reload Library. - The new helper gets its coverage floor; no other floor changes. * fix(library): open every prompt history visit on its first negative The concept resets to the first negative variant on every navigation to a prompt (navigate: state.variant = 0) and selects another pair only right after a save names it. The history no longer remembers a clicked variant or an explicit All images choice across visits: the per-visit memory is replaced by a one-shot saved selection that the next history mount (or the same visit's reconciliation) consumes, and a verified pair outside the loaded page is kept only for that saved selection. Within a visit, clicks and All images behave as before.
This commit is contained in:
@@ -9,6 +9,8 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
|
||||
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
|
||||
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
|
||||
// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery.
|
||||
const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');};
|
||||
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
|
||||
const exact=async locator=>locator.evaluate(node=>node.textContent);
|
||||
|
||||
@@ -91,7 +93,7 @@ test('same-path replacement is current content and a during-inspection change is
|
||||
|
||||
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
|
||||
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
|
||||
await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
|
||||
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
|
||||
try {
|
||||
@@ -395,7 +397,7 @@ test('history, run and output failures retry their own reads and route exit igno
|
||||
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
|
||||
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
|
||||
await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
|
||||
@@ -520,7 +522,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run
|
||||
});
|
||||
|
||||
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
@@ -531,7 +533,7 @@ test('history epoch recovery reports saved-parent read failures and retries with
|
||||
});
|
||||
|
||||
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
|
||||
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
|
||||
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
|
||||
@@ -179,6 +179,22 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog
|
||||
expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true);
|
||||
});
|
||||
|
||||
test('an unconfirmed archive moves focus to Retry instead of dropping it out of the dialog',async({page,request})=>{
|
||||
const f=page.fixture;await mountEditor(page);let dropped=false;
|
||||
await page.route(`**/prompt_manager/pairs/${f.pair_id}`,async route=>{
|
||||
if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;}
|
||||
await route.continue();
|
||||
});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.');
|
||||
await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
await page.keyboard.press('Enter');await expect(dialog).toHaveCount(0);
|
||||
expect(page.writes).toHaveLength(2);expect((await pairRead(request,f.pair_id)).archived).toBe(true);
|
||||
});
|
||||
|
||||
test('an archive conflict offers an explicit Reload that re-reads and never replays the archive',async({page,request})=>{
|
||||
const f=page.fixture;await mountEditor(page);
|
||||
let conflicted=false;
|
||||
@@ -262,7 +278,7 @@ test('recovery states render inline and never replay a write',async({page,reques
|
||||
dialog=await openEditor(page,{mode:'new'});await ready(dialog);
|
||||
await field(dialog,'Title').fill('Lost response');await field(dialog,'Positive prompt').fill('Lost response prompt');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry sends the same save once.');
|
||||
await expect(button(dialog,'Save')).toBeDisabled();
|
||||
await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
||||
await page.unroute('**/prompt_manager/library');
|
||||
await page.route('**/prompt_manager/library/*?variant_limit=40',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
|
||||
await button(dialog,'Retry').click();
|
||||
@@ -343,8 +359,11 @@ test('an unconfirmed Edit save says Retry reloads for review and resends nothing
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Notes').fill('Unconfirmed notes');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.');
|
||||
await button(dialog,'Retry').click();
|
||||
// Save is disabled now; focus moves to the one recovery action instead of falling out of the modal.
|
||||
await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.');
|
||||
expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true);
|
||||
await expect(field(dialog,'Notes')).toHaveValue('Unconfirmed notes');expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -426,12 +445,19 @@ async function openHistory(page,id) {
|
||||
async function selectPair(page,name) {
|
||||
const dot=variantsPanel(page).getByRole('button',{name,exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
|
||||
}
|
||||
/** In-app navigation away and back, as the concept's hash router does (app.js navigate: state.variant = 0). */
|
||||
async function leaveAndReturn(page,id) {
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
|
||||
}
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
|
||||
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
|
||||
// One linked image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
@@ -443,6 +469,10 @@ test('library heading offers Export then + New prompt, and New opens the saved p
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Harbor at dawn');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(toast(page)).toHaveText('Saved.');
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('fog, noise');
|
||||
// Exactly one saved negative is counted in the singular, in history and on the shelf card.
|
||||
await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
|
||||
});
|
||||
|
||||
@@ -468,7 +498,8 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
|
||||
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
await expect(send).toBeDisabled();
|
||||
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
|
||||
await selectPair(page,'Select negative variant 2');await expect(send).toBeEnabled();
|
||||
await expect(savedTexts(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
|
||||
await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
||||
@@ -477,6 +508,18 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
||||
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
|
||||
await leaveAndReturn(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
|
||||
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('add negative re-renders history with the saved or reused pair selected',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
const add=variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true});
|
||||
@@ -488,8 +531,10 @@ test('add negative re-renders history with the saved or reused pair selected',as
|
||||
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
|
||||
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(laneLabel(page,'· 1 linked images')).toHaveCount(1);
|
||||
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
|
||||
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
expect(page.writes.map(write=>write.url)).toEqual([`/prompt_manager/library/${f.positive_id}/variants`,`/prompt_manager/library/${f.positive_id}/variants`]);
|
||||
});
|
||||
|
||||
@@ -550,6 +595,7 @@ test('restored storage after a confirmed save or before an archive offers an exp
|
||||
await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
||||
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
|
||||
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
|
||||
@@ -563,6 +609,7 @@ test('restored storage after a confirmed save or before an archive offers an exp
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
|
||||
});
|
||||
|
||||
const DISCARDS='Leaving this dialog discards your entries.';
|
||||
test('restored storage before an Edit or Add negative save offers only the Library reload exit',async({page,request})=>{
|
||||
const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body();
|
||||
const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}});
|
||||
@@ -570,9 +617,12 @@ test('restored storage before an Edit or Add negative save offers only the Libra
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(await restore()).toBeOK();
|
||||
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
|
||||
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
||||
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
|
||||
// Revise and archive would re-read the old numeric IDs, which may now name another prompt.
|
||||
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await expect(button(dialog,'Reload Library')).toBeFocused();
|
||||
expect(page.writes).toHaveLength(1);// the refused save
|
||||
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
|
||||
expect(page.writes).toHaveLength(1);expect((await detail(request,f.positive_id)).positive.notes).toBe(f.metadata.notes);
|
||||
@@ -580,7 +630,7 @@ test('restored storage before an Edit or Add negative save offers only the Libra
|
||||
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(await restore()).toBeOK();
|
||||
await field(dialog,'Negative prompt').fill('typed before the restore');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
|
||||
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Save']);
|
||||
await expect(button(dialog,'Reload Library state')).toHaveCount(0);expect(page.writes).toHaveLength(2);
|
||||
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
|
||||
@@ -623,12 +673,13 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
|
||||
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
||||
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
|
||||
const lane=archived.filter({hasText:'· 1 linked images · Archived'});await expect(lane).toHaveCount(1);
|
||||
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
|
||||
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
||||
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
|
||||
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
|
||||
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
|
||||
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
|
||||
const writes=page.writes.length;
|
||||
@@ -713,6 +764,37 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra
|
||||
expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
|
||||
const f=page.fixture;await page.goto(`${workspace}#/library`);
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
||||
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reuse of an uncategorized prompt with the category left blank is not unapplied metadata, a different category is',async({page,request})=>{
|
||||
const f=page.fixture;const current=await detail(request,f.positive_id,'active');
|
||||
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{category:''}}})).toBeOK();
|
||||
expect((await detail(request,f.positive_id)).positive.category).toBeNull();
|
||||
const reuse=async(category,negative)=>{
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
|
||||
await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
||||
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
};
|
||||
await reuse('','blank category negative');
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
||||
await reuse('Landscapes','different category negative');
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
|
||||
});
|
||||
|
||||
test('turning Show archived on again after a hidden archive shows the newly archived pair',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/});
|
||||
@@ -723,7 +805,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
|
||||
await expect(laneLabel(page,'· 1 linked images · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
});
|
||||
|
||||
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
|
||||
|
||||
@@ -37,7 +37,11 @@ test('library groups reusable negatives under one positive without exposing nega
|
||||
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
await page.goto(route);await openHistory(page);await expect(media(page)).toHaveCount(3);
|
||||
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
|
||||
await page.goto(route);await openHistory(page);
|
||||
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
|
||||
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
|
||||
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
|
||||
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
|
||||
|
||||
@@ -612,6 +612,8 @@ test('embedded history gallery keeps the history heading and disposes its layout
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('h1')).not.toHaveText('Prompt library');
|
||||
const title=await page.locator('h1').textContent();expect(title).not.toBe('Images');
|
||||
// History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery.
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
for (const layout of ['List','Masonry','Grid']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
|
||||
@@ -788,3 +788,22 @@ test('a refresh failed by restored storage cannot recover through retryReconcili
|
||||
assert.equal(server.writes().length, 1);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('a blank category reused against an uncategorized owner is not unapplied metadata; a different one is', async () => {
|
||||
const { createLibraryEditorController } = await source;
|
||||
for (const [stored, entered, expected] of [[null, '', false], ['', ' ', false], ['Uncategorized', '', false], [null, 'Uncategorized', false],
|
||||
[null, 'Landscapes', true], ['Studio', '', true]]) {
|
||||
const owner = positive({ id: 9, text: 'a portrait', title: 'Portrait', category: stored, tags: [], rating: null, notes: '' });
|
||||
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false, positive: owner });
|
||||
const server = harness(created(() => response(200, reused)));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
||||
await controller.initialize();
|
||||
controller.setField('title', 'Portrait');
|
||||
controller.setField('positive_text', 'a portrait');
|
||||
controller.setField('category', entered);
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
assert.equal(controller.state.outcome.metadataNotApplied, expected, `stored ${stored} entered ${JSON.stringify(entered)}`);
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/plural.mjs');
|
||||
|
||||
test('countOf uses the singular noun only for exactly one', async () => {
|
||||
const { countOf, noun } = await source;
|
||||
assert.equal(countOf(1, 'linked image'), '1 linked image');
|
||||
assert.equal(countOf(0, 'linked image'), '0 linked images');
|
||||
assert.equal(countOf(2, 'saved negative variant'), '2 saved negative variants');
|
||||
assert.equal(countOf(1, 'distinct recorded run'), '1 distinct recorded run');
|
||||
assert.equal(countOf(149, 'distinct recorded run'), '149 distinct recorded runs');
|
||||
assert.equal(noun(1, 'image'), 'image');
|
||||
assert.equal(noun(202, 'image'), 'images');
|
||||
assert.equal(noun(2, 'child', 'children'), 'children');
|
||||
});
|
||||
+14
-8
@@ -170,14 +170,14 @@
|
||||
"workspace/history-state.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 91,
|
||||
"functions": 96,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/history.mjs": {
|
||||
"lines": 96,
|
||||
"lines": 97,
|
||||
"branches": 87,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/icons.mjs": {
|
||||
"lines": 98,
|
||||
@@ -217,13 +217,13 @@
|
||||
},
|
||||
"workspace/library-editor-state.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 91,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/library-editor.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 88,
|
||||
"branches": 93,
|
||||
"functions": 92,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -235,14 +235,14 @@
|
||||
},
|
||||
"workspace/library-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 84,
|
||||
"functions": 94,
|
||||
"functions": 96,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/media-viewer.mjs": {
|
||||
@@ -251,6 +251,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/plural.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-blocks.mjs": {
|
||||
"lines": 89,
|
||||
"branches": 89,
|
||||
@@ -272,7 +278,7 @@
|
||||
"workspace/recorded-run.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 83,
|
||||
"functions": 71,
|
||||
"functions": 77,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/state.mjs": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {createHistoryController, visibleHistoryColumns} from './history-state.mjs';
|
||||
import {mountRecordedRun} from './recorded-run.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -160,7 +161,7 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
|
||||
if (index !== undefined) timeline.scrollLeft = Math.max(0, 38 + index * 64 - anchor.x);
|
||||
}
|
||||
}
|
||||
count.textContent = window.group ? `${window.recorded_run_count} distinct recorded runs as of this read · ${window.items.length} loaded positions` : '';
|
||||
count.textContent = window.group ? `${countOf(window.recorded_run_count, 'distinct recorded run')} as of this read · ${window.items.length} loaded positions` : '';
|
||||
const group = window.group;
|
||||
scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by exact frozen positive raw text, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.';
|
||||
status.setAttribute('role', window.error || next.run.error ? 'alert' : 'status');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
@@ -322,7 +323,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
|
||||
}
|
||||
state = next;
|
||||
count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} linked images` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`;
|
||||
count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} ${noun(next.total, 'linked image')}` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} ${noun(next.total, 'image')}`;
|
||||
if (changed) updateLayout();
|
||||
message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
|
||||
if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
|
||||
|
||||
@@ -144,8 +144,10 @@ function createMetadata(draft) {
|
||||
return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags),
|
||||
rating: draft.rating, notes: draft.notes};
|
||||
}
|
||||
// A blank category is stored as NULL; "Uncategorized" is only the UI's label for it.
|
||||
const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value));
|
||||
function sameMetadata(sent, owner) {
|
||||
return sent.title === stored(owner.title) && sent.category === stored(owner.category) && sameTags(sent.tags, owner.tags) &&
|
||||
return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) &&
|
||||
sent.rating === owner.rating && sent.notes === (owner.notes ?? '');
|
||||
}
|
||||
function draftProblem(mode, draft) {
|
||||
|
||||
@@ -21,6 +21,9 @@ const REVIEW = 'Loaded the latest saved values. Review your entries, then save.'
|
||||
const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.';
|
||||
const TOAST_MS = 6500;
|
||||
const REFRESH = ['Reload Library', 'refresh'];
|
||||
// The concept has no discard confirmation (its Cancel and × simply close), so the state says it inline:
|
||||
// every exit (Reload Library, Cancel, × or Escape) drops the draft.
|
||||
const DISCARDS = 'Leaving this dialog discards your entries.';
|
||||
let toastTimer;
|
||||
|
||||
/** Show one outcome in the shared workspace toast (role="status"); it clears itself like the concept's. */
|
||||
@@ -42,7 +45,8 @@ function el(tag, text, className) {
|
||||
/**
|
||||
* Inline state copy and its one recovery action; `archive` selects the pair-archive
|
||||
* wording. After a restore only New prompt may re-read: every other mode names an
|
||||
* owner by its old numeric ID, so it offers only the Library refresh exit.
|
||||
* owner by its old numeric ID, so it offers only the Library refresh exit, which
|
||||
* discards the draft.
|
||||
*/
|
||||
function statusOf(state, archive, mode = null) {
|
||||
const nothing = archive ? 'Nothing was archived.' : 'Nothing was saved.';
|
||||
@@ -54,7 +58,8 @@ function statusOf(state, archive, mode = null) {
|
||||
case 'write-error': return {text: `${archive ? 'Not archived' : 'Not saved'}: ${message} Your entries are kept.`};
|
||||
case 'conflict': return {text: archive ? 'This negative variant changed since it was loaded. Reload it and review it before archiving.'
|
||||
: 'This prompt changed since it was loaded. Reload it and review your entries before saving.', action: ['Reload', 'reload']};
|
||||
case 'storage-changed': return {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: mode === 'new' ? ['Reload Library state', 'reload'] : REFRESH};
|
||||
case 'storage-changed': return mode === 'new' ? {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: ['Reload Library state', 'reload']}
|
||||
: {text: `Prompt storage was restored since this dialog opened. ${nothing}${archive ? '' : ` ${DISCARDS}`}`, action: REFRESH};
|
||||
case 'unknown-outcome': return {text: archive ? 'Archive could not be confirmed. Retry sends the same archive once.'
|
||||
: mode === 'metadata' ? 'Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.'
|
||||
: 'Save could not be confirmed. Your entries are kept; Retry sends the same save once.', action: ['Retry', 'retryUnconfirmed']};
|
||||
@@ -86,12 +91,25 @@ function setChildren(parent, nodes) {
|
||||
if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
/** A button that held focus when saving began gets it back after a failed or conflicting write (disabling drops focus to the body). */
|
||||
function restoreFocus(button, state) {
|
||||
if (!button || state.phase === 'saving') return button;
|
||||
if (state.phase !== 'complete' && button.isConnected && !button.disabled && (!document.activeElement || document.activeElement === document.body)) button.focus();
|
||||
/** Saving or re-reading disables or removes the focused action; focus would drop to the body. */
|
||||
const SETTLING = new Set(['saving', 'initializing']);
|
||||
const restoredStorage = state => state.phase === 'storage-changed' || (state.phase === 'saved-refresh-error' && state.error?.code === 'storage-changed');
|
||||
/**
|
||||
* The action that held focus when a write or re-read began gets it back once that
|
||||
* settles; when it is gone or disabled (Save after an unconfirmed write), focus moves
|
||||
* to the first usable fallback, the recovery action first, and never to the body.
|
||||
*/
|
||||
function restoreFocus(button, state, fallbacks) {
|
||||
if (!button || SETTLING.has(state.phase)) return button;
|
||||
if (state.phase !== 'complete' && (!document.activeElement || document.activeElement === document.body)) {
|
||||
[button, ...fallbacks].find(node => node?.isConnected && !node.disabled)?.focus();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** Remember a focused dialog action while the phase settles. */
|
||||
function settlingFocus(actions, state, current) {
|
||||
return SETTLING.has(state.phase) && actions.contains(document.activeElement) ? document.activeElement : current;
|
||||
}
|
||||
|
||||
function field(label, control, id) {
|
||||
const wrapper = el('div', undefined, 'field');
|
||||
@@ -246,9 +264,11 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
categories.replaceChildren(...datalist('', state.categories).children);
|
||||
}
|
||||
const saving = state.phase === 'saving';
|
||||
if (saving && document.activeElement === save) refocus = save;
|
||||
refocus = settlingFocus(actions, state, refocus);
|
||||
save.disabled = !SUBMITTABLE.has(state.phase);
|
||||
cancel.disabled = close.disabled = revise.disabled = saving;
|
||||
cancel.disabled = close.disabled = saving;
|
||||
// After a restore the numeric IDs may name another prompt; only the Library reload exit stays.
|
||||
revise.disabled = saving || restoredStorage(state);
|
||||
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
|
||||
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
|
||||
const status = statusOf(state, false, mode);
|
||||
@@ -257,7 +277,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
else archive.removeAttribute('aria-describedby');
|
||||
const retry = recoveryButton(recovery, status?.action, views.signal);
|
||||
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
|
||||
refocus = restoreFocus(refocus, state);
|
||||
refocus = restoreFocus(refocus, state, [retry, save]);
|
||||
if (state.phase === 'complete') finish();
|
||||
}
|
||||
|
||||
@@ -322,11 +342,11 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
const retry = recoveryButton(recovery, status?.action, views.signal) ?? (readFailed ? reread : null);
|
||||
links.hidden = !counted;
|
||||
const saving = state.phase === 'saving';
|
||||
if (saving && document.activeElement === confirm) refocus = confirm;
|
||||
refocus = settlingFocus(actions, state, refocus);
|
||||
confirm.disabled = !counted || !SUBMITTABLE.has(state.phase);
|
||||
cancel.disabled = close.disabled = saving;
|
||||
setChildren(actions, [...(retry ? [retry] : []), cancel, confirm]);
|
||||
refocus = restoreFocus(refocus, state);
|
||||
refocus = restoreFocus(refocus, state, [retry, confirm]);
|
||||
if (state.phase === 'complete') finish();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ import {createLibraryEditorController} from './library-editor-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null;
|
||||
const variantSelections=new Map();
|
||||
// The pair one reconciled save names, taken once by the history it opens (the concept selects it after a save, app.js 986).
|
||||
const savedSelections=new Map();
|
||||
const historyVisits=new Map();
|
||||
// Exact-ID pair reads for selections outside the bounded variant page; never merged into it.
|
||||
const selectedPairs=new Map();
|
||||
@@ -39,32 +41,38 @@ export function invalidateLibraryAfterWrite(commit) {
|
||||
writeGeneration++;
|
||||
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
|
||||
}
|
||||
/** Remember the selection a reconciled save names, so the destination history opens on it. */
|
||||
/** Remember the selection a reconciled save names, so the destination history opens on it once. */
|
||||
export function rememberLibraryResult(result) {
|
||||
const positive=result?.detail?.positive, kind=result?.commit?.kind;
|
||||
if(!positive || !Number.isSafeInteger(positive.id) || !Array.isArray(result.detail.variants) || typeof kind!=='string') throw new TypeError('Invalid reconciled Library result');
|
||||
if(kind==='create') savedParentId=positive.id;
|
||||
if(!result.pair || kind==='archive-pair') return;
|
||||
variantSelections.set(positive.id,result.pair.id);
|
||||
savedSelections.set(positive.id,result.pair.id);
|
||||
if(result.detail.variants.some(variant=>variant.id===result.pair.id)) selectedPairs.delete(positive.id);
|
||||
else selectedPairs.set(positive.id,result.pair);
|
||||
if(result.pair.archived) archivedVisible.set(positive.id,true);
|
||||
}
|
||||
/**
|
||||
* Toast copy from the receipt: the concept's save toast for New, Revision and Edit and its
|
||||
* Add-negative line (app.js 989, 1028); a reuse discloses what was not saved.
|
||||
* Add-negative line (app.js 989, 1028); a reuse discloses what was not saved, and only
|
||||
* when the controller found the entered parent metadata differs from the stored one.
|
||||
*/
|
||||
function outcomeMessage(result) {
|
||||
function outcomeMessage(result,outcome) {
|
||||
const {kind,receipt}=result.commit;
|
||||
if(kind==='metadata') return 'Saved.';
|
||||
if(kind==='archive-pair') return ARCHIVED_MESSAGE;
|
||||
if(kind!=='create' && kind!=='add-negative') return null;
|
||||
if(receipt.positive_created) return 'Saved.';
|
||||
if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.'
|
||||
:'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.';
|
||||
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'
|
||||
:'Saved as a new negative variant of an existing prompt.';
|
||||
if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
|
||||
return 'This prompt pair already exists. Showing the saved version.';
|
||||
}
|
||||
/** Take the saved selection for one positive; it never outlives the visit that consumes it. */
|
||||
function takeSavedSelection(positiveId) {
|
||||
const pairId=savedSelections.get(positiveId);savedSelections.delete(positiveId);return pairId ?? null;
|
||||
}
|
||||
function rememberUnappliedMetadata(result,{outcome,draft}={}) {
|
||||
if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return;
|
||||
const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes});
|
||||
@@ -73,7 +81,7 @@ function rememberUnappliedMetadata(result,{outcome,draft}={}) {
|
||||
function createNewPromptEditor(api,scope,main) {
|
||||
return createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:(result,context)=>{
|
||||
rememberLibraryResult(result);rememberUnappliedMetadata(result,context);
|
||||
const message=outcomeMessage(result);if(message) showWorkspaceToast(message);
|
||||
const message=outcomeMessage(result,context?.outcome);if(message) showWorkspaceToast(message);
|
||||
const hash=`#/library/${result.detail.positive.id}`;
|
||||
if(window.location.hash===hash) focusHeading(main);else {focusHeadingOnMount=true;window.location.hash=hash;}
|
||||
}});
|
||||
@@ -172,7 +180,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
heading.append(h2,el('span',item.category || 'Uncategorized','library-chip'));
|
||||
const prompts=el('div',undefined,'library-card-prompts');
|
||||
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']});
|
||||
const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?`${item.variant_count} saved negative variants`:'No saved negatives','library-chip library-chip-negative'),el('span',`${item.image_count} linked images`));
|
||||
const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?countOf(item.variant_count,'saved negative variant'):'No saved negatives','library-chip library-chip-negative'),el('span',countOf(item.image_count,'linked image')));
|
||||
const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip'));
|
||||
content.append(heading,prompts,stats,vocabulary);
|
||||
const footer=el('footer',undefined,'library-card-footer');
|
||||
@@ -342,6 +350,10 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
|
||||
let blocks,galleryScope,detail,contentScope,updateBranches,updateParent,current=null;
|
||||
scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();});
|
||||
// Every visit opens on the first negative, as the concept's navigate() does (app.js 1149: state.variant = 0);
|
||||
// only the pair a save just named is selected instead. A verified pair outside the page belongs to that save.
|
||||
let pendingSelection=takeSavedSelection(positiveId);
|
||||
if(selectedPairs.get(positiveId)?.id!==pendingSelection) selectedPairs.delete(positiveId);
|
||||
const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose());
|
||||
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView});
|
||||
scope.listen(back,'click',()=>{window.location.hash='#/library';});
|
||||
@@ -352,16 +364,17 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
scope.listen(send,'click',()=>{const roles=current?.sendRoles();if(roles)sender.open(roles,send);});
|
||||
function reconciled(result,context) {
|
||||
rememberLibraryResult(result);rememberUnappliedMetadata(result,context);
|
||||
const message=outcomeMessage(result),id=result.detail.positive.id;
|
||||
const message=outcomeMessage(result,context?.outcome),id=result.detail.positive.id;
|
||||
if(message) showWorkspaceToast(message);
|
||||
if(id!==positiveId) {focusHeadingOnMount=true;window.location.hash=`#/library/${id}`;return;}
|
||||
pendingSelection=takeSavedSelection(positiveId);
|
||||
if(current) current.apply(result);else loader.load(positiveId);
|
||||
focusHeading(main);
|
||||
}
|
||||
/** Explicit exit after restored storage: dispose the recorded view, then remount with a fresh visit. */
|
||||
function reloadView() {
|
||||
writeGeneration++;shelfVisit=shelfVisit && {filters:shelfVisit.filters,anchor:null};
|
||||
variantSelections.delete(positiveId);selectedPairs.delete(positiveId);metadataDrafts.delete(positiveId);
|
||||
pendingSelection=null;savedSelections.delete(positiveId);selectedPairs.delete(positiveId);metadataDrafts.delete(positiveId);
|
||||
current=null;contentScope?.dispose();contentScope=null;historyVisits.delete(positiveId);
|
||||
loader.load(positiveId);focusHeading(main);
|
||||
}
|
||||
@@ -445,14 +458,14 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
lane.node.style.setProperty('--lane-offset',`${38+index*47}px`);
|
||||
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative variant ${entry.number}`);
|
||||
lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current';
|
||||
lane.text.textContent=`${name} · ${entry.variant.image_count} linked images${entry.kind==='archived'?' · Archived':''}`;
|
||||
lane.text.textContent=`${name} · ${countOf(entry.variant.image_count,'linked image')}${entry.kind==='archived'?' · Archived':''}`;
|
||||
if(lane.restore) {lane.restore.setAttribute('aria-label',`Restore ${name}`);lane.restore.disabled=savedReadState!=='current' || restoringPairs.has(entry.variant.id);}
|
||||
if(previous.nextSibling!==lane.node) previous.after(lane.node);
|
||||
previous=lane.node;
|
||||
}
|
||||
if(active?.isConnected && document.activeElement!==active) active.focus({preventScroll:true});
|
||||
emptyNote.hidden=entries.length>0;
|
||||
variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`;
|
||||
variantCount.textContent=`${detail.variants.length} of ${countOf(detail.variant_total,'saved negative variant')}`;
|
||||
const archivedState=archivedLoader.state,target=moreTarget();
|
||||
const loadError=state.variantsError || (archivedShown && (archivedState.error || archivedState.variantsError));
|
||||
more.hidden=!target || !!loadError;more.setAttribute('aria-disabled',String(state.loadingVariants || archivedState.loading || archivedState.loadingVariants || savedReadState!=='current'));retry.hidden=!loadError;
|
||||
@@ -510,7 +523,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
reloadSaved.hidden=savedReadState==='current';
|
||||
}
|
||||
}
|
||||
updateBranches(loader.state);blocks?.setState({prompts:rolesFor(variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});updateSend();
|
||||
updateBranches(loader.state);blocks?.setState({prompts:rolesFor(activePairId ?? null),actionsEnabled:savedReadState==='current'});updateSend();
|
||||
}
|
||||
async function retrySavedParent() {
|
||||
parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true});
|
||||
@@ -521,7 +534,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
setParentState({detail:fresh});
|
||||
} catch(error) {if(!content.signal.aborted && generation===parentGeneration)setParentState({error});}
|
||||
}
|
||||
content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);});
|
||||
content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{pendingSelection=null;loader.load(positiveId);});
|
||||
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
|
||||
mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
|
||||
@@ -531,7 +544,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
function select(pairId,{force=false}={}) {
|
||||
if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return;
|
||||
if(pairId!==null && !recordFor(pairId)) pairId=null;
|
||||
activePairId=pairId;variantSelections.set(positiveId,pairId);blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true});
|
||||
activePairId=pairId;blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true});
|
||||
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
|
||||
const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId);
|
||||
const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId);
|
||||
@@ -563,7 +576,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(restoringPairs.has(pairId)) return;
|
||||
restoringPairs.add(pairId);showRestoreProblem('');button.disabled=true;
|
||||
const controller=createLibraryEditorController({api,mode:'metadata',positiveId,pairId,onCommitted:invalidateLibraryAfterWrite,onReconciled:result=>{
|
||||
rememberLibraryResult(result);apply(result);
|
||||
rememberLibraryResult(result);pendingSelection=takeSavedSelection(positiveId);apply(result);
|
||||
const dot=[...lanes.values()].find(lane=>lane.entry.kind!=='archived' && lane.entry.variant.id===pairId)?.button;dot?.focus();
|
||||
}});
|
||||
content.cleanup(()=>controller.dispose());
|
||||
@@ -604,12 +617,13 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply};
|
||||
content.listen(all,'click',()=>select(null));
|
||||
const remembered=variantSelections.get(positiveId),outside=selectedPairs.get(positiveId);
|
||||
const remembered=pendingSelection,outside=selectedPairs.get(positiveId);pendingSelection=null;
|
||||
if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);}
|
||||
if(archivedShown) archivedLoader.load(positiveId);
|
||||
let initial=null;
|
||||
// Open on the first negative variant (concept navigate: state.variant = 0) unless a save just named a pair.
|
||||
let initial=detail.variants[0]?.id ?? null;
|
||||
if(detail.variants.some(variant=>variant.id===remembered)) initial=remembered;
|
||||
else if(outside?.id===remembered) {try {libraryPromptRoles(detail,remembered,outside);initial=remembered;} catch {selectedPairs.delete(positiveId);}}
|
||||
else if(outside && outside.id===remembered) {try {libraryPromptRoles(detail,remembered,outside);initial=remembered;} catch {selectedPairs.delete(positiveId);}}
|
||||
updateBranches(state);select(initial);
|
||||
}
|
||||
loader.load(positiveId);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Count wording shared by Library and history views: the singular noun only for exactly one. */
|
||||
export function noun(count, singular, plural = `${singular}s`) {
|
||||
return count === 1 ? singular : plural;
|
||||
}
|
||||
|
||||
/** `1 linked image`, `2 linked images`. */
|
||||
export function countOf(count, singular, plural) {
|
||||
return `${count} ${noun(count, singular, plural)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user