feat(images): Delete… in the Images selection bar, inspector and viewer (#480)
The concept's Remove links… place, D159: one review per selection that asks each time (nothing preselected) whether to remove the files from the library only or also move them to the trash. The browser test server uses a fake trash.
This commit is contained in:
@@ -31,13 +31,15 @@ function captionDay(seconds,now=new Date()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to
|
||||
* collection compares in place (D138).
|
||||
* D8 (amended by #249 PR D and #331): the selection bar's real actions only. Add to collection compares in place
|
||||
* (D138). #480 D159: Remove links… returns as Delete…; OMITTED_BULK names the concept button it replaces.
|
||||
*/
|
||||
const OMITTED_BULK=Object.freeze(['Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
|
||||
const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
|
||||
/** #480 D159: Delete… takes the place of Remove links… (the concept's `danger` button, under another text). */
|
||||
const DELETE_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-remove"]',text:'Delete…'}]);
|
||||
|
||||
/**
|
||||
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
|
||||
@@ -452,10 +454,12 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const removed=(await inventoryOf(page,'#main')).bulkbar;
|
||||
await restore();
|
||||
// The oracle for production's bar: the concept page itself without the omitted actions and with D85's button.
|
||||
const restoreProduction=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS],clone:AUTO_TAG_CLONE});
|
||||
// The oracle for production's bar: the concept page itself with D85's button and D159's Delete… in place of
|
||||
// Remove links….
|
||||
const restoreClones=await mutate(page,{clone:[...AUTO_TAG_CLONE,...DELETE_CLONE]});
|
||||
const restoreRemoved=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const oracle=await inventoryOf(page,'#main');
|
||||
await restoreProduction();
|
||||
await restoreRemoved();await restoreClones();
|
||||
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const emptyActual=await inventoryOf(page,'#workspace-main');
|
||||
@@ -468,7 +472,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
|
||||
'Set category','Add to collection','Export metadata','Clear']);
|
||||
'Set category','Add to collection','Export metadata','Delete…','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// The bar comparison notices a change to the production bar.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
|
||||
|
||||
// #480 (D159): the Images selection bar's Delete… against the concept's "Remove links…" (app.js a432, `danger`) and
|
||||
// its review against the concept's `maintenancePlan` (a953–957): the bar button's place and style, the review's
|
||||
// `dl.meta-grid` rows and its actions, on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
|
||||
// Paired screenshots are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
|
||||
/** Runs in the page: the selection bar's danger button and where it sits among the bar's buttons. */
|
||||
function measureBar() {
|
||||
const bar=document.querySelector('#bulkbar > .bulkbar');
|
||||
const buttons=[...bar.querySelectorAll(':scope > button')];
|
||||
const danger=bar.querySelector(':scope > button.danger');
|
||||
const css=(node,...keys)=>Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]]));
|
||||
return {
|
||||
// The danger action sits right before Clear, the last button.
|
||||
dangerBeforeClear:buttons.indexOf(danger)===buttons.length-2,
|
||||
lastText:buttons.at(-1).textContent.trim(),
|
||||
danger:css(danger,'color','backgroundColor','borderTopColor','fontSize','fontWeight','paddingTop','paddingLeft',
|
||||
'borderTopLeftRadius','height'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: the open review's grid and actions (the parts production shares with the concept). */
|
||||
function measureReview() {
|
||||
const dialog=document.querySelector('dialog#modal[open]');
|
||||
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
|
||||
const grid=dialog.querySelector('#modal-body dl.meta-grid');
|
||||
const entry=grid.querySelector(':scope > div');
|
||||
const actions=dialog.querySelector('#modal-body > .dialog-actions');
|
||||
const [cancel]=actions.children;
|
||||
return {
|
||||
grid:css(grid,'display','gridTemplateColumns','columnGap','rowGap'),
|
||||
dt:css(entry.querySelector('dt'),'fontSize','color','textTransform','letterSpacing'),
|
||||
dd:css(entry.querySelector('dd'),'fontSize','color','marginLeft'),
|
||||
terms:[...grid.querySelectorAll('dt')].map(dt=>dt.textContent.trim()),
|
||||
actions:css(actions,'display','justifyContent','columnGap','marginTop'),
|
||||
cancel:{text:cancel.textContent.trim(),...css(cancel,'backgroundColor','color','fontSize','paddingTop','paddingLeft')},
|
||||
};
|
||||
}
|
||||
|
||||
async function checkFirst(page) {
|
||||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
}
|
||||
|
||||
async function concept(page,theme) {
|
||||
await openConcept(page,'images',theme);
|
||||
await checkFirst(page);
|
||||
const bar=await page.evaluate(measureBar);
|
||||
await page.locator('#main #bulkbar').getByRole('button',{name:'Remove links…',exact:true}).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
|
||||
await page.mouse.move(0,0);
|
||||
return {bar,review:await page.evaluate(measureReview)};
|
||||
}
|
||||
|
||||
async function production(page,theme) {
|
||||
await openProduction(page,'images',theme);
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
await checkFirst(page);
|
||||
const bar=await page.evaluate(measureBar);
|
||||
await page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Delete…',exact:true}).click();
|
||||
const dialog=page.locator('dialog#modal[open]');
|
||||
await expect(dialog.locator('#modal-title')).toHaveText('Delete 1 image');
|
||||
await dialog.getByRole('radio',{name:/^Remove from the library only/}).check();
|
||||
await page.mouse.move(0,0);
|
||||
return {bar,review:await page.evaluate(measureReview)};
|
||||
}
|
||||
|
||||
/**
|
||||
* D159: production's rows follow the chosen mode (Scope, then what the files lose, Backup and Media on disk; the
|
||||
* concept's sample has Scope, Protected, Backup and Media on disk). The geometry, tokens and the Cancel button match.
|
||||
*/
|
||||
function mapConcept(reference,actual) {
|
||||
return {...reference,review:{...reference.review,terms:actual.review.terms}};
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
|
||||
|
||||
for(const theme of THEMES)for(const [width,height] of SIZES) {
|
||||
test(`delete matches the concept's remove links ${theme} ${width}x${height}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const reference=await concept(page,theme);
|
||||
await page.screenshot({path:testInfo.outputPath(`concept-remove-links-${theme}-${width}.png`)});
|
||||
const actual=await production(page,theme);
|
||||
await page.screenshot({path:testInfo.outputPath(`production-delete-${theme}-${width}.png`)});
|
||||
expect(reference.review.terms).toEqual(['Scope','Protected','Backup','Media on disk']);
|
||||
expect(actual.review.terms[0]).toBe('Scope');
|
||||
expect(actual.review.terms.slice(-2)).toEqual(['Backup','Media on disk']);
|
||||
expect(diffInventory(mapConcept(reference,actual),actual)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('delete comparison detects mutations',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const reference=await concept(page,'dark');
|
||||
const actual=await production(page,'dark');
|
||||
const expected=mapConcept(reference,actual);
|
||||
expect(diffInventory(expected,actual)).toEqual([]);
|
||||
const undo=await mutate(page,{css:'dialog#modal dl.meta-grid { column-gap: 31px; } dialog#modal .dialog-actions { gap: 29px; }'});
|
||||
const paths=diffInventory(expected,{...actual,review:await page.evaluate(measureReview)}).map(item=>item.path);
|
||||
expect(paths).toEqual(expect.arrayContaining(['review.grid.columnGap','review.actions.columnGap']));
|
||||
await undo();
|
||||
});
|
||||
@@ -56,7 +56,8 @@ function withCategoryScope(inventory) {
|
||||
* equal production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
|
||||
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
|
||||
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details.
|
||||
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details; #480's
|
||||
* D159 "Delete…" follows it in the panel footer.
|
||||
*/
|
||||
export function mapInspectorConcept(reference,{viewport}) {
|
||||
const {panel,viewerPlain,fileControls}=reference;
|
||||
@@ -125,6 +126,13 @@ async function withCollectionsButton(page,selector) {
|
||||
for(const key of Object.keys(button.dataset))delete button.dataset[key];
|
||||
button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…');
|
||||
history.after(button);(window.__collectionsButtons??=[]).push(button);
|
||||
// #480 D159: Delete… ends the panel's actions, drawn as the concept's own `danger` button (its Remove links…).
|
||||
if(selector.startsWith('#image-inspector')) {
|
||||
const remove=history.cloneNode(false);
|
||||
for(const key of Object.keys(remove.dataset))delete remove.dataset[key];
|
||||
remove.className='danger';remove.textContent='Delete…';
|
||||
button.after(remove);window.__collectionsButtons.push(remove);
|
||||
}
|
||||
},selector);
|
||||
await settle(page);
|
||||
return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});};
|
||||
@@ -373,7 +381,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
|
||||
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(tags.locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…','Delete…']);
|
||||
});
|
||||
|
||||
// #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the
|
||||
|
||||
@@ -220,6 +220,12 @@ def create_app(root: Path) -> web.Application:
|
||||
PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")]
|
||||
|
||||
# #480: Delete… never reaches a real system trash from a page test; the fake
|
||||
# renames into this server's root, which goes when the server exits.
|
||||
from tests import fake_trash
|
||||
|
||||
fake_trash.install(root / "fake-trash")
|
||||
|
||||
from py.api import PromptManagerAPI
|
||||
from py import autotag
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
@@ -527,7 +527,7 @@ test('Add to collection creates a collection and adds to it ignoring case (CUJ 1
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file);
|
||||
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Export metadata', 'Clear']);
|
||||
'Export metadata', 'Delete…', 'Clear']);
|
||||
await barButton(page, 'Add to collection').click();
|
||||
// The concept's simpleInput with Add (D138) and the existing names as suggestions.
|
||||
await expect(addDialog(page).locator('label')).toHaveText('Collection name');
|
||||
@@ -578,7 +578,7 @@ test('Remove from collection takes the checked images off the open collection (C
|
||||
await check(page, 'landscape_01.png');
|
||||
await check(page, 'landscape_02.png');
|
||||
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Remove from collection', 'Export metadata', 'Clear']);
|
||||
'Remove from collection', 'Export metadata', 'Delete…', 'Clear']);
|
||||
await barButton(page, 'Remove from collection').click();
|
||||
await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.');
|
||||
await expect(imageCards(page)).toHaveCount(10);
|
||||
@@ -715,7 +715,8 @@ test('Collections… in the side panel adds, removes and creates for one image (
|
||||
// The newest image, landscape_01, is the panel's.
|
||||
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png');
|
||||
const open = panel(page).getByRole('button', { name: 'Collections…', exact: true });
|
||||
await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…');
|
||||
// #480 D159: Delete… follows Collections… at the end of the panel's actions.
|
||||
await expect(panel(page).locator('.detail-footer > button').nth(-2)).toHaveText('Collections…');
|
||||
await open.click();
|
||||
await expect(collectionsDialog(page)).toBeVisible();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
// #480 (D159): Images › Delete…: the review asks every time (nothing preselected), "Remove from the library only"
|
||||
// hides the file and keeps it on disk, "Also move the files to the trash" moves it (the server's fake trash here).
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
const CARDS = '#workspace-main article[data-image-key]';
|
||||
const bar = page => page.locator('#workspace-main #bulkbar');
|
||||
const review = page => page.getByRole('dialog', { name: /^Delete \d+ images?$/ });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConceptAt(page, request);
|
||||
page.deleteErrors = [];
|
||||
page.on('pageerror', error => page.deleteErrors.push(error.message));
|
||||
page.on('dialog', dialog => { page.deleteErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.deleteErrors).toEqual([]);
|
||||
});
|
||||
|
||||
async function openImages(page) {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
return page.locator(CARDS).count();
|
||||
}
|
||||
|
||||
async function check(page, index) {
|
||||
const card = page.locator(CARDS).nth(index);
|
||||
const key = await card.getAttribute('data-image-key');
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
return key;
|
||||
}
|
||||
|
||||
async function deleteChecked(page, mode) {
|
||||
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const dialog = review(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
const primary = dialog.locator('.dialog-actions button.danger');
|
||||
// Ask every time: no mode is preselected and the primary waits for one.
|
||||
await expect(dialog.getByRole('radio')).toHaveCount(2);
|
||||
for (const radio of await dialog.getByRole('radio').all()) await expect(radio).not.toBeChecked();
|
||||
await expect(primary).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(dialog.locator('dl.meta-grid')).toBeHidden();
|
||||
const label = mode === 'trash' ? 'Also move the files to the trash' : 'Remove from the library only';
|
||||
await dialog.getByRole('radio', { name: new RegExp(`^${label}`) }).check();
|
||||
await expect(dialog.locator('dl.meta-grid')).toBeVisible();
|
||||
await expect(primary).not.toHaveAttribute('aria-disabled', 'true');
|
||||
return { dialog, primary };
|
||||
}
|
||||
|
||||
for (const mode of ['library', 'trash']) {
|
||||
test(`${mode}: the deleted image leaves Images and stays gone after a reload`, async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
const key = await check(page, 0);
|
||||
const { dialog, primary } = await deleteChecked(page, mode);
|
||||
await expect(dialog.locator('dt', { hasText: 'Media on disk' })).toBeVisible();
|
||||
await expect(primary).toHaveText(mode === 'trash' ? 'Move 1 image to the trash' : 'Remove 1 image from the library');
|
||||
await primary.click();
|
||||
await expect(toast(page)).toContainText(mode === 'trash' ? 'Moved 1 image to the trash.' : 'Removed 1 image from the library.');
|
||||
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
|
||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||
await expect(bar(page)).toBeEmpty();
|
||||
await page.reload();
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('Cancel changes nothing', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
await check(page, 0);
|
||||
const { dialog } = await deleteChecked(page, 'library');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.locator(CARDS)).toHaveCount(before);
|
||||
});
|
||||
|
||||
test('switching modes redraws the rows from one review', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await check(page, 0);
|
||||
let previews = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/images/delete/preview')) previews += 1; });
|
||||
const { dialog, primary } = await deleteChecked(page, 'library');
|
||||
await expect(dialog.locator('dd', { hasText: 'Files stay on disk, hidden from Images.' })).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /^Also move the files to the trash/ }).check();
|
||||
await expect(dialog.locator('dd', { hasText: 'Nothing is deleted permanently' })).toBeVisible();
|
||||
await expect(primary).toHaveText('Move 1 image to the trash');
|
||||
expect(previews).toBe(1);
|
||||
});
|
||||
|
||||
test('Delete… in the inspector reviews that one image', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
const card = page.locator(CARDS).first();
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
const button = page.locator('#workspace-main .file-inspector button.danger', { hasText: 'Delete…' });
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
const dialog = review(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
|
||||
await dialog.locator('.dialog-actions button.danger').click();
|
||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||
});
|
||||
|
||||
test('Delete… in the viewer closes it after the delete', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
await page.locator(CARDS).first().getByRole('button', { name: /^Open / }).click();
|
||||
const viewer = page.locator('dialog#viewer-dialog[open]');
|
||||
await expect(viewer).toBeVisible();
|
||||
await viewer.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const dialog = review(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
|
||||
await dialog.locator('.dialog-actions button.danger').click();
|
||||
await expect(viewer).toBeHidden();
|
||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||
});
|
||||
@@ -1794,7 +1794,7 @@ test('card checkboxes select images into the concept bar and Clear empties it',
|
||||
await checkbox(first, 'same.png').check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
|
||||
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
|
||||
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Delete…', 'Clear']);
|
||||
// Toggling neither opens the viewer, nor inspects, nor moves focus.
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(checkbox(first, 'same.png')).toBeFocused();
|
||||
|
||||
@@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' &&
|
||||
|
||||
test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
|
||||
await select(page, FIRST);
|
||||
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
|
||||
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Delete…', 'Clear']);
|
||||
});
|
||||
|
||||
test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// #480 (D159): Images › Delete…: the review's rows, sentences, labels and outcome toasts.
|
||||
const loaded = await import('../../web/js/workspace/images-delete.mjs').catch(() => null);
|
||||
const module = () => { assert.ok(loaded, 'images-delete.mjs is not implemented'); return loaded; };
|
||||
const pageScope = () => {
|
||||
const controller = new AbortController();
|
||||
return { signal: controller.signal, cleanup: () => {}, abort: () => controller.abort() };
|
||||
};
|
||||
const plan = (over = {}) => ({
|
||||
token: 't', library: { count: 3 }, trash: { count: 2 },
|
||||
refused: { count: 1, counts: { 'not-listed': 1 }, rows: [{ path: 'sub/gone.png', code: 'not-listed', detail: 'No longer listed in Images.' }] },
|
||||
trash_refused: { count: 1, counts: { 'no-trash': 1 }, rows: [{ path: 'b.png', code: 'no-trash', detail: 'This drive has no trash.' }] },
|
||||
records: { prompt_links: 2, pair_links: 0, image_tags: 1, auto_tagged: 0, categories: 1, collections: 2, favorites: 0, review_items: 0 },
|
||||
orphaned_prompts: { count: 1, titles: ['Red fox'] },
|
||||
backup: { policy: 'on' }, disk: null, ...over,
|
||||
});
|
||||
|
||||
test('the words of the review and its routes', () => {
|
||||
const { PREVIEW_URL, MAX_DELETE, TOO_MANY, QUESTION, MODES } = module();
|
||||
assert.equal(PREVIEW_URL, '/prompt_manager/images/delete/preview');
|
||||
assert.equal(MAX_DELETE, 500);
|
||||
assert.equal(TOO_MANY, 'Select at most 500 images.');
|
||||
assert.equal(QUESTION, 'What should happen to the files?');
|
||||
assert.deepEqual(MODES.map(mode => mode.label), ['Remove from the library only', 'Also move the files to the trash']);
|
||||
});
|
||||
|
||||
test('library rows: scope, refusals, links, details, backup and media', () => {
|
||||
const { deleteRows } = module();
|
||||
const rows = Object.fromEntries(deleteRows(plan(), 'library'));
|
||||
assert.equal(rows.Scope, '3 images');
|
||||
assert.equal(rows['Not changed'], '1: gone.png. No longer listed in Images.');
|
||||
assert.equal(rows['Prompt links'], '2 links. 1 prompt loses its last image: Red fox. The prompt stays on the Library page.');
|
||||
assert.equal(rows['Image details'], '1 with image tags, 1 category, 2 collection memberships');
|
||||
assert.equal(rows.Backup, 'Create and verify before changes (the database, not the files)');
|
||||
assert.equal(rows['Media on disk'], 'Files stay on disk, hidden from Images.');
|
||||
});
|
||||
|
||||
test('trash rows add the trash refusals and the trash media line', () => {
|
||||
const { deleteRows } = module();
|
||||
const rows = Object.fromEntries(deleteRows(plan({ backup: { policy: 'off' } }), 'trash'));
|
||||
assert.equal(rows.Scope, '2 images');
|
||||
assert.match(rows['Not changed'], /^2: gone\.png, b\.png\. No longer listed in Images\. This drive has no trash\.$/);
|
||||
assert.equal(rows.Backup, 'Off: no safety backup (System › Backup & export › Recovery policy)');
|
||||
assert.match(rows['Media on disk'], /Nothing is deleted permanently/);
|
||||
});
|
||||
|
||||
test('rows leave out what is zero', () => {
|
||||
const { deleteRows } = module();
|
||||
const bare = plan({ refused: { count: 0, rows: [] }, trash_refused: { count: 0, rows: [] },
|
||||
records: { prompt_links: 0 }, orphaned_prompts: { count: 0, titles: [] } });
|
||||
assert.deepEqual(deleteRows(bare, 'library').map(([term]) => term), ['Scope', 'Backup', 'Media on disk']);
|
||||
const many = plan({ orphaned_prompts: { count: 5, titles: ['A', 'B', 'C'] } });
|
||||
assert.match(Object.fromEntries(deleteRows(many, 'library'))['Prompt links'],
|
||||
/5 prompts lose their last image: A, B, C and 2 more\. The prompts stay on the Library page\./);
|
||||
assert.equal(module().refusedValue({ count: 5, rows: [{ path: 'a.png' }] }), '5: a.png and 4 more');
|
||||
assert.equal(module().refusedValue({ count: 0 }), null);
|
||||
});
|
||||
|
||||
test('sentences follow the mode and the backup policy', () => {
|
||||
const { deleteSentences, SENTENCES, USE_LIBRARY_ONLY, NO_TRASH_HERE } = module();
|
||||
assert.deepEqual(deleteSentences(plan(), 'library'), [SENTENCES.libraryBackup]);
|
||||
assert.deepEqual(deleteSentences(plan({ backup: { policy: 'off' } }), 'library'), [SENTENCES.libraryNoBackup]);
|
||||
assert.deepEqual(deleteSentences(plan(), 'trash'), [SENTENCES.trash, USE_LIBRARY_ONLY]);
|
||||
const nowhere = plan({ trash: { count: 0 }, trash_refused: { count: 3, rows: [] } });
|
||||
assert.deepEqual(deleteSentences(nowhere, 'trash'), [SENTENCES.trash, NO_TRASH_HERE, USE_LIBRARY_ONLY]);
|
||||
assert.deepEqual(deleteSentences(plan({ trash_refused: { count: 0 } }), 'trash'), [SENTENCES.trash]);
|
||||
});
|
||||
|
||||
test('primary labels and the close-only reasons', () => {
|
||||
const { primaryLabel, closeOnlyReason, NONE_DELETABLE } = module();
|
||||
assert.equal(primaryLabel('library', 1), 'Remove 1 image from the library');
|
||||
assert.equal(primaryLabel('trash', 4), 'Move 4 images to the trash');
|
||||
assert.equal(closeOnlyReason(plan()), null);
|
||||
assert.equal(closeOnlyReason(plan({ library: { count: 0 }, trash: { count: 0 } })), NONE_DELETABLE);
|
||||
assert.equal(closeOnlyReason(plan({ disk: { sufficient: false, required_bytes: 2048, free_bytes: 1024 } })),
|
||||
'There is not enough free space for the safety backup (needs 2,048 bytes, has 1,024).');
|
||||
});
|
||||
|
||||
test('outcome toasts per mode and failure', () => {
|
||||
const { outcomeToast } = module();
|
||||
assert.equal(outcomeToast({ scope: { mode: 'library' }, counters: { removed: 3, not_changed: 0 }, total: 3 }),
|
||||
'Removed 3 images from the library.');
|
||||
assert.equal(outcomeToast({ scope: { mode: 'trash' }, counters: { trashed: 2, not_changed: 1 }, total: 3 }),
|
||||
'Moved 2 images to the trash. 1 was not changed (see Activity).');
|
||||
assert.equal(outcomeToast({ scope: { mode: 'trash' }, counters: { trashed: 0, not_changed: 2 }, total: 2 }),
|
||||
'Moved 0 images to the trash. 2 were not changed (see Activity).');
|
||||
assert.equal(outcomeToast({ scope: { mode: 'library', failure: 'records-failed' }, counters: { removed: 1 }, total: 4 }),
|
||||
'Stopped after 1 of 4 images; the rest were not changed. See Activity.');
|
||||
assert.equal(outcomeToast({ scope: { failure: 'backup-failed' } }), 'The safety backup failed; nothing was changed.');
|
||||
assert.equal(outcomeToast({ scope: { failure: 'sources-changed' } }), 'The sources changed; nothing was changed.');
|
||||
});
|
||||
|
||||
test('review failures name the cause', () => {
|
||||
const { reviewFailure, SOURCE_CHANGED, STORAGE_CHANGED, TOO_MANY, REVIEW_FAILED } = module();
|
||||
assert.equal(reviewFailure({ code: 'source-changed' }), SOURCE_CHANGED);
|
||||
assert.equal(reviewFailure({ code: 'storage-changed' }), STORAGE_CHANGED);
|
||||
assert.equal(reviewFailure({ code: 'too-many-images' }), TOO_MANY);
|
||||
assert.equal(reviewFailure({ code: 'busy', message: 'Scan running' }), 'Scan running');
|
||||
assert.equal(reviewFailure(new Error('boom')), REVIEW_FAILED);
|
||||
});
|
||||
|
||||
test('over 500 refuses with the toast before any request', async () => {
|
||||
const { openDeleteImages, TOO_MANY } = module();
|
||||
const toasts = [];
|
||||
const api = { post: () => assert.fail('no request') };
|
||||
await openDeleteImages({ api, scope: pageScope(), images: [], filter: {}, count: 501, notify: text => toasts.push(text) });
|
||||
assert.deepEqual(toasts, [TOO_MANY]);
|
||||
await openDeleteImages({ api, scope: pageScope(), images: [], notify: text => toasts.push(text) });
|
||||
assert.deepEqual(toasts, [TOO_MANY]);
|
||||
});
|
||||
|
||||
test('a failed review toasts and releases its trigger', async () => {
|
||||
const { openDeleteImages, SOURCE_CHANGED } = module();
|
||||
const toasts = [], sent = [];
|
||||
const attributes = new Map();
|
||||
const trigger = { textContent: 'Delete…', getAttribute: name => attributes.get(name) ?? null,
|
||||
setAttribute: (name, value) => attributes.set(name, value), removeAttribute: name => attributes.delete(name) };
|
||||
const api = { post: async (url, body) => { sent.push([url, body]); throw Object.assign(new Error('x'), { code: 'source-changed' }); } };
|
||||
await openDeleteImages({ api, scope: pageScope(), trigger, notify: text => toasts.push(text),
|
||||
images: [{ root_index: 0, root_scope: 'a'.repeat(64), path: 'a.png', filename: 'a.png' }] });
|
||||
assert.deepEqual(sent, [['/prompt_manager/images/delete/preview', { items: [{ root_index: 0, root_scope: 'a'.repeat(64), path: 'a.png' }] }]]);
|
||||
assert.deepEqual(toasts, [SOURCE_CHANGED]);
|
||||
assert.equal(trigger.textContent, 'Delete…');
|
||||
assert.equal(trigger.getAttribute('aria-disabled'), null);
|
||||
});
|
||||
|
||||
test('apply sends the token and the mode, and toasts that the job started', async () => {
|
||||
const { applyDelete } = module();
|
||||
const sent = [], toasts = [];
|
||||
const api = { post: async (url, body) => { sent.push([url, body]); return { job: { id: 'j', title: 'Delete images (move to the trash)' } }; } };
|
||||
const store = { track: job => job, load: () => {} };
|
||||
const job = await applyDelete({ api, store, plan: plan(), mode: 'trash', notify: text => toasts.push(text) });
|
||||
assert.equal(job.id, 'j');
|
||||
assert.deepEqual(sent, [['/prompt_manager/maintenance/apply', { token: 't', mode: 'trash' }]]);
|
||||
assert.deepEqual(toasts, ['Delete images (move to the trash) started.']);
|
||||
const refused = await applyDelete({ api: { post: async () => { throw Object.assign(new Error('Busy'), { code: 'busy', running: null }); } },
|
||||
store, plan: plan(), mode: 'library', notify: text => toasts.push(text) });
|
||||
assert.equal(refused, null);
|
||||
});
|
||||
@@ -234,7 +234,7 @@ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
|
||||
* collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes.
|
||||
*/
|
||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback,
|
||||
collections = null, onCollectionsChanged = null}) {
|
||||
collections = null, onCollectionsChanged = null, onDelete = null}) {
|
||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
let contentScope;
|
||||
@@ -339,6 +339,9 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
onChanged:onCollectionsChanged, focusFallback:fallback}));
|
||||
footer.append(button);
|
||||
}
|
||||
// #480: Delete… for a listed Images file only, last in the actions.
|
||||
const removable = onDelete ? revealTarget(record.identity) : null;
|
||||
if (removable) footer.append(deleteButton(removable));
|
||||
if (!full && footer.childNodes.length) container.append(footer);
|
||||
}
|
||||
/**
|
||||
@@ -456,6 +459,12 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
});
|
||||
return button;
|
||||
}
|
||||
/** #480: "Delete…" opens the Images delete review for this one file. */
|
||||
function deleteButton(target) {
|
||||
const button = createButton('Delete…', 'danger');
|
||||
contentScope.listen(button, 'click', () => onDelete(target, button));
|
||||
return button;
|
||||
}
|
||||
/** "Workflow & raw metadata": each supported record (`pre[tabindex=0] > code`), then the two downloads. */
|
||||
function rawMetadata(record) {
|
||||
const {metadata} = record, section = disclosure('Workflow & raw metadata');
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Images › Delete… (#480, D159): the review dialog for the checked images (or "all N matching", or the one image of
|
||||
* the inspector or viewer) and the apply.
|
||||
*
|
||||
* - The concept's selection bar has "Remove links…" (`danger`) opening its `maintenancePlan` review (`dl.meta-grid`
|
||||
* Scope / Protected / Backup / Media on disk, Cancel and a primary). Production names it "Delete…" and asks each
|
||||
* time (maintainer ruling): a radio group "What should happen to the files?" with nothing checked, "Remove from the
|
||||
* library only" (records go, the file stays on disk, hidden from Images) or "Also move the files to the trash".
|
||||
* - One preview (`POST /images/delete/preview`) answers both modes; choosing a mode only redraws the rows. The primary
|
||||
* (`danger`) is enabled once a mode with images to act on is chosen. Apply sends `{token, mode}` to the reviewed-plan
|
||||
* apply; the job is followed in the Activity store and `onDeleted(job)` runs when it ends.
|
||||
* - Nothing here deletes a file: the server never unlinks one (it renames into the system trash or leaves it).
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { closeWorkspaceDialog, openCloseOnlyReview, openContentDialog } from './workspace-dialogs.mjs';
|
||||
import { followJobEnd } from './images-tagging-bulk.mjs';
|
||||
import { applyRefusal, APPLY_URL } from './maintenance-review.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { countOf, noun } from './plural.mjs';
|
||||
|
||||
export const PREVIEW_URL = '/prompt_manager/images/delete/preview';
|
||||
export const MAX_DELETE = 500;
|
||||
export const TOO_MANY = 'Select at most 500 images.';
|
||||
export const QUESTION = 'What should happen to the files?';
|
||||
export const MODES = Object.freeze([
|
||||
Object.freeze({value: 'library', label: 'Remove from the library only', hint: 'Files stay on disk, hidden from Images.'}),
|
||||
Object.freeze({value: 'trash', label: 'Also move the files to the trash',
|
||||
hint: 'You can restore them from the trash until it is emptied.'}),
|
||||
]);
|
||||
export const NONE_DELETABLE = 'None of the selected images can be deleted.';
|
||||
export const REVIEW_FAILED = 'The review failed. Nothing was changed.';
|
||||
export const SOURCE_CHANGED = 'The sources changed. Refresh the images and try again.';
|
||||
export const STORAGE_CHANGED = 'Prompt storage was restored. Reopen Delete.';
|
||||
export const USE_LIBRARY_ONLY = 'To remove these files from the library anyway, choose “Remove from the library only”.';
|
||||
export const NO_TRASH_HERE = 'Moving to the trash is not available on this system yet.';
|
||||
const SENTENCES = Object.freeze({
|
||||
libraryBackup: 'Removed images stay hidden unless the file is modified or replaced. Their tags, category, collections '
|
||||
+ 'and favorite can only be recovered by restoring the safety backup, which also undoes every later change.',
|
||||
libraryNoBackup: 'Removed images stay hidden unless the file is modified or replaced. Their tags, category, '
|
||||
+ 'collections and favorite cannot be recovered.',
|
||||
trash: 'A file restored from the trash shows again without its tags, category, collections or favorite; a scan '
|
||||
+ 'links it to its prompt again.',
|
||||
});
|
||||
export { SENTENCES };
|
||||
|
||||
const RECORD_WORDS = [
|
||||
['image_tags', 'with image tags', 'with image tags'],
|
||||
['auto_tagged', 'auto-tagged', 'auto-tagged'],
|
||||
['categories', 'category', 'categories'],
|
||||
['collections', 'collection membership', 'collection memberships'],
|
||||
['favorites', 'favorite', 'favorites'],
|
||||
['review_items', 'Needs review item', 'Needs review items'],
|
||||
];
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
return Number(value ?? 0).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/** The images a mode acts on. */
|
||||
export function modeCount(plan, mode) {
|
||||
return Number(plan?.[mode]?.count ?? 0);
|
||||
}
|
||||
|
||||
/** "Name, name, name and K more" for up to three refused rows, grouped by their sentence. */
|
||||
export function refusedValue(summary) {
|
||||
const count = Number(summary?.count ?? 0);
|
||||
if (!count) return null;
|
||||
const rows = Array.isArray(summary.rows) ? summary.rows : [];
|
||||
const names = rows.slice(0, 3).map(row => String(row.path).split('/').at(-1));
|
||||
const more = count - names.length;
|
||||
const reasons = [...new Set(rows.map(row => row.detail).filter(Boolean))];
|
||||
return `${number(count)}: ${names.join(', ')}${more > 0 ? ` and ${number(more)} more` : ''}`
|
||||
+ (reasons.length ? `. ${reasons.join(' ')}` : '');
|
||||
}
|
||||
|
||||
/** The `dl.meta-grid` rows for `mode` (design §9.3). */
|
||||
export function deleteRows(plan, mode) {
|
||||
const rows = [['Scope', countOf(modeCount(plan, mode), 'image')]];
|
||||
const refused = {count: 0, rows: []};
|
||||
for (const part of mode === 'trash' ? [plan.refused, plan.trash_refused] : [plan.refused]) {
|
||||
refused.count += Number(part?.count ?? 0);
|
||||
refused.rows.push(...(part?.rows ?? []));
|
||||
}
|
||||
const notChanged = refusedValue(refused);
|
||||
if (notChanged) rows.push(['Not changed', notChanged]);
|
||||
const records = plan.records ?? {};
|
||||
const links = Number(records.prompt_links ?? 0);
|
||||
if (links) {
|
||||
const orphans = plan.orphaned_prompts ?? {count: 0, titles: []};
|
||||
let text = `${countOf(links, 'link')}.`;
|
||||
if (orphans.count > 0) {
|
||||
const titles = (orphans.titles ?? []).filter(Boolean);
|
||||
const more = orphans.count - titles.length;
|
||||
text += ` ${countOf(orphans.count, 'prompt')} ${orphans.count === 1 ? 'loses its' : 'lose their'} last image`
|
||||
+ (titles.length ? `: ${titles.join(', ')}${more > 0 ? ` and ${number(more)} more` : ''}` : '')
|
||||
+ `. The ${noun(orphans.count, 'prompt')} ${orphans.count === 1 ? 'stays' : 'stay'} on the Library page.`;
|
||||
}
|
||||
rows.push(['Prompt links', text]);
|
||||
}
|
||||
const details = RECORD_WORDS.filter(([key]) => Number(records[key] ?? 0) > 0)
|
||||
.map(([key, one, many]) => `${number(records[key])} ${Number(records[key]) === 1 ? one : many}`);
|
||||
if (details.length) rows.push(['Image details', details.join(', ')]);
|
||||
rows.push(['Backup', backupOn(plan) ? 'Create and verify before changes (the database, not the files)'
|
||||
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
|
||||
rows.push(['Media on disk', mode === 'trash'
|
||||
? 'Files move to the trash. Nothing is deleted permanently. Disk space is freed when you empty the trash.'
|
||||
: 'Files stay on disk, hidden from Images.']);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function backupOn(plan) {
|
||||
return plan?.backup?.policy !== 'off';
|
||||
}
|
||||
|
||||
/** The `p.small` sentences for `mode`. */
|
||||
export function deleteSentences(plan, mode) {
|
||||
if (mode === 'library') return [backupOn(plan) ? SENTENCES.libraryBackup : SENTENCES.libraryNoBackup];
|
||||
const sentences = [SENTENCES.trash];
|
||||
const trashRefused = Number(plan?.trash_refused?.count ?? 0);
|
||||
if (trashRefused && trashRefused >= modeCount(plan, 'library')) sentences.push(NO_TRASH_HERE);
|
||||
if (trashRefused) sentences.push(USE_LIBRARY_ONLY);
|
||||
return sentences;
|
||||
}
|
||||
|
||||
/** The primary's label: "Remove N images from the library" or "Move N images to the trash". */
|
||||
export function primaryLabel(mode, count) {
|
||||
return mode === 'trash' ? `Move ${countOf(count, 'image')} to the trash` : `Remove ${countOf(count, 'image')} from the library`;
|
||||
}
|
||||
|
||||
/** Why the review cannot start at all, or null. */
|
||||
export function closeOnlyReason(plan) {
|
||||
if (modeCount(plan, 'library') === 0 && modeCount(plan, 'trash') === 0) return NONE_DELETABLE;
|
||||
if (plan?.disk && plan.disk.sufficient === false) {
|
||||
return `There is not enough free space for the safety backup (needs ${number(plan.disk.required_bytes)} bytes, `
|
||||
+ `has ${number(plan.disk.free_bytes)}).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The toast for a review that could not be read. */
|
||||
export function reviewFailure(error) {
|
||||
if (error?.code === 'source-changed') return SOURCE_CHANGED;
|
||||
if (error?.code === 'storage-changed') return STORAGE_CHANGED;
|
||||
if (error?.code === 'too-many-images') return TOO_MANY;
|
||||
if (error?.code === 'busy' && error.message) return error.message;
|
||||
return REVIEW_FAILED;
|
||||
}
|
||||
|
||||
/** The toast when the job ends (design §9.4). */
|
||||
export function outcomeToast(job) {
|
||||
const counters = job?.counters ?? {};
|
||||
const mode = job?.scope?.mode;
|
||||
if (job?.scope?.failure === 'backup-failed') return 'The safety backup failed; nothing was changed.';
|
||||
if (job?.scope?.failure === 'sources-changed') return 'The sources changed; nothing was changed.';
|
||||
const done = Number(mode === 'trash' ? counters.trashed : counters.removed) || 0;
|
||||
const total = Number(job?.total ?? 0);
|
||||
if (job?.scope?.failure === 'records-failed') {
|
||||
return `Stopped after ${number(done)} of ${countOf(total, 'image')}; the rest were not changed. See Activity.`;
|
||||
}
|
||||
const head = mode === 'trash' ? `Moved ${countOf(done, 'image')} to the trash.` : `Removed ${countOf(done, 'image')} from the library.`;
|
||||
const unchanged = Number(counters.not_changed ?? 0);
|
||||
return unchanged ? `${head} ${number(unchanged)} ${unchanged === 1 ? 'was' : 'were'} not changed (see Activity).` : head;
|
||||
}
|
||||
|
||||
function modeGroup(onChange) {
|
||||
const group = el('div', undefined, 'field');
|
||||
group.setAttribute('role', 'radiogroup');
|
||||
const caption = el('span', QUESTION);
|
||||
caption.id = 'image-delete-mode-label';
|
||||
group.setAttribute('aria-labelledby', caption.id);
|
||||
group.append(caption);
|
||||
for (const choice of MODES) {
|
||||
const label = el('label');
|
||||
const input = el('input');
|
||||
Object.assign(input, {type: 'radio', name: 'image-delete-mode', value: choice.value, checked: false});
|
||||
input.addEventListener('change', () => { if (input.checked) onChange(choice.value); });
|
||||
label.append(input, ` ${choice.label}`, el('span', ` ${choice.hint}`, 'small muted'));
|
||||
group.append(label);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** The review for an answered preview `plan`; `onApply(mode)` runs on the primary. */
|
||||
export function showDeleteReview(plan, {scope, count, trigger = null, onApply}) {
|
||||
const title = `Delete ${countOf(count, 'image')}`;
|
||||
const reason = closeOnlyReason(plan);
|
||||
if (reason) {
|
||||
openCloseOnlyReview({scope, title, grid: deleteRows(plan, 'library'), sentence: reason, trigger});
|
||||
return null;
|
||||
}
|
||||
let mode = null;
|
||||
const list = el('dl', undefined, 'meta-grid');
|
||||
list.hidden = true;
|
||||
const notes = el('div', undefined, 'image-delete-notes');
|
||||
const cancel = createButton('Cancel');
|
||||
const start = createButton(primaryLabel('library', modeCount(plan, 'library')), 'danger');
|
||||
start.setAttribute('aria-disabled', 'true');
|
||||
function render() {
|
||||
list.replaceChildren();
|
||||
notes.replaceChildren();
|
||||
if (!mode) return;
|
||||
for (const [term, value] of deleteRows(plan, mode)) {
|
||||
const entry = el('div');
|
||||
entry.append(el('dt', term), el('dd', value));
|
||||
list.append(entry);
|
||||
}
|
||||
list.hidden = false;
|
||||
for (const sentence of deleteSentences(plan, mode)) notes.append(el('p', sentence, 'small'));
|
||||
const acting = modeCount(plan, mode);
|
||||
start.textContent = primaryLabel(mode, acting);
|
||||
start.setAttribute('aria-disabled', String(acting === 0));
|
||||
}
|
||||
const group = modeGroup(value => { mode = value; render(); });
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
start.addEventListener('click', () => {
|
||||
if (!mode || start.getAttribute('aria-disabled') === 'true') return;
|
||||
const chosen = mode;
|
||||
closeWorkspaceDialog();
|
||||
onApply(chosen);
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, start);
|
||||
openContentDialog({scope, title, nodes: [group, list, notes, actions], trigger});
|
||||
return {group, list, start};
|
||||
}
|
||||
|
||||
/** Apply `plan` in `mode`; the started job, or null (a toast said why). */
|
||||
export async function applyDelete({api, store, plan, mode, notify = showWorkspaceToast}) {
|
||||
let result;
|
||||
try {
|
||||
result = await api.post(APPLY_URL, {token: plan.token, mode});
|
||||
} catch (error) {
|
||||
const refusal = applyRefusal(error);
|
||||
notify(refusal.toast);
|
||||
if (refusal.lost) store?.load?.();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const job = store ? store.track(result?.job) : result?.job;
|
||||
notify(`${job.title} started.`);
|
||||
return job;
|
||||
} catch {
|
||||
store?.load?.();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole flow for `images` (the checked ones) or `filter` ("all N matching", `count` of them): the preview, the
|
||||
* review and the apply. `trigger` reads "Reviewing…" and is aria-disabled until the review opens; a second press is
|
||||
* ignored. `onDeleted(job)` runs when the started job ends.
|
||||
*/
|
||||
export async function openDeleteImages({api, scope, store, images = [], filter = null, count = images.length, trigger = null,
|
||||
onDeleted = () => {}, notify = showWorkspaceToast}) {
|
||||
if (count > MAX_DELETE) { notify(TOO_MANY); return; }
|
||||
if (!filter && !images.length) return;
|
||||
if (trigger?.getAttribute('aria-disabled') === 'true') return;
|
||||
const label = trigger?.textContent;
|
||||
trigger?.setAttribute('aria-disabled', 'true');
|
||||
if (trigger) trigger.textContent = 'Reviewing…';
|
||||
let plan;
|
||||
try {
|
||||
const body = filter ? {filter} : {items: images.map(({root_index, root_scope, path}) => ({root_index, root_scope, path}))};
|
||||
plan = await api.post(PREVIEW_URL, body, {signal: scope.signal});
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(reviewFailure(error));
|
||||
return;
|
||||
} finally {
|
||||
if (trigger) { trigger.textContent = label; trigger.removeAttribute('aria-disabled'); }
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
showDeleteReview(plan, {scope, count, trigger, onApply: async mode => {
|
||||
const job = await applyDelete({api, store, plan, mode, notify});
|
||||
if (!job) return;
|
||||
if (store) followJobEnd({store, scope, id: job.id, onEnd: ended => { notify(outcomeToast(ended)); onDeleted(ended); }});
|
||||
else onDeleted(job);
|
||||
}});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-taggin
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
|
||||
import { openAddToCollection, removeFromCollection } from './images-collections.mjs';
|
||||
import { openDeleteImages } from './images-delete.mjs';
|
||||
|
||||
const CHECKBOX = '.image-overlays input[type="checkbox"]';
|
||||
const CHECKBOX_LABEL = '.image-overlays > label';
|
||||
@@ -50,7 +51,7 @@ function el(tag, text, className) {
|
||||
*/
|
||||
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
|
||||
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
|
||||
collection = null, onCollectionsChanged = null, onSourceChanged = null }) {
|
||||
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null }) {
|
||||
let checked = initial;
|
||||
let exporting = false, categorizing = false;
|
||||
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
|
||||
@@ -93,7 +94,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
|
||||
scope.listen(removeButton, 'click', () => removeChecked(removeButton));
|
||||
bulk.append(removeButton);
|
||||
}
|
||||
bulk.append(exportMetadata, clearButton);
|
||||
bulk.append(exportMetadata);
|
||||
// #480 (D159): the concept's "Remove links…" (`danger`) place, after Export metadata and before Clear.
|
||||
if (onDeleted) {
|
||||
const deleteButton = createButton('Delete…', 'danger');
|
||||
scope.listen(deleteButton, 'click', () => openDeleteImages({ api, scope, store: activity, images: checkedImages(),
|
||||
...matchingTarget(), trigger: deleteButton, onDeleted }));
|
||||
bulk.append(deleteButton);
|
||||
}
|
||||
bulk.append(clearButton);
|
||||
|
||||
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
|
||||
function render() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { promptRoles } from './prompt-blocks.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { openDeleteImages } from './images-delete.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { mountToolbar } from './toolbar.mjs';
|
||||
@@ -299,6 +300,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
|
||||
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
|
||||
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
|
||||
onDeleted:deleted,
|
||||
// The Set category field suggests the toolbar's categories (#332 MC13).
|
||||
getCategories:() => categoryNames,
|
||||
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
|
||||
@@ -321,6 +323,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
};
|
||||
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy,
|
||||
onDelete:embedded ? null : (item,button) => deleteOne(item,button,() => mediaViewer.close()),
|
||||
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
|
||||
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
|
||||
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
|
||||
@@ -338,7 +341,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
|
||||
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy});
|
||||
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy,
|
||||
onDelete:embedded ? null : (target, button) => deleteOne(target, button)});
|
||||
detailInspector.setState(inspection.state);
|
||||
inspection.select(image);
|
||||
for (const [key, card] of rendered) markSelected(card, key === selection);
|
||||
@@ -647,6 +651,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
clearChecks:() => bar?.clear(), listsNow:() => !recovering,
|
||||
current:started => !scope.signal.aborted && started === generation,
|
||||
rediscover:() => loadOptions().then(found => { if (found) renderLoadStatus(); })})});
|
||||
/** #480: a finished delete job reloads the listing (the deleted files leave it, and the selection clears). */
|
||||
function deleted() {
|
||||
loadCategories();
|
||||
reset();
|
||||
}
|
||||
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
|
||||
function deleteOne(target, trigger, after = null) {
|
||||
openDeleteImages({api, scope, store:activity, images:[target], trigger, onDeleted:() => { after?.(); deleted(); }});
|
||||
}
|
||||
function reset() {
|
||||
generation += 1;
|
||||
mediaViewer.stopSlideshow();
|
||||
|
||||
@@ -25,7 +25,7 @@ function element(tag, className, text) {
|
||||
* reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open.
|
||||
*/
|
||||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter,
|
||||
collections = null,onCollectionsChanged = null}) {
|
||||
collections = null,onCollectionsChanged = null,onDelete = null}) {
|
||||
const scope = createScope();
|
||||
let active = null, retryVersion = 0;
|
||||
const dispose = () => scope.dispose();
|
||||
@@ -82,6 +82,16 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
const download = element('a','media-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
|
||||
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
|
||||
controls.append(download);
|
||||
// #480: Delete… (the Images viewer only) pauses the media, then opens the delete review for this file.
|
||||
if (onDelete) {
|
||||
const remove = createButton('Delete…','danger');
|
||||
local.listen(remove,'click',() => {
|
||||
session.stop();
|
||||
for (const video of dialog.querySelectorAll('video')) video.pause();
|
||||
if (entry) onDelete(entry,remove);
|
||||
});
|
||||
controls.append(remove);
|
||||
}
|
||||
const next = button('Next media','Next →',() => session.navigate(1));
|
||||
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155
|
||||
// (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. D156 (#450):
|
||||
|
||||
Reference in New Issue
Block a user