diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 67088ec8..8851c1f1 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -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. diff --git a/tests/browser/images-delete-concept.spec.mjs b/tests/browser/images-delete-concept.spec.mjs new file mode 100644 index 00000000..c5b8264c --- /dev/null +++ b/tests/browser/images-delete-concept.spec.mjs @@ -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(); +}); diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index d2bcd606..2b1467ef 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -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 diff --git a/tests/browser/server.py b/tests/browser/server.py index 45c83c8e..bd05edc9 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -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 diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index 9decc16d..0ab181af 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -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(); diff --git a/tests/browser/workspace-images-delete.spec.mjs b/tests/browser/workspace-images-delete.spec.mjs new file mode 100644 index 00000000..6984c2cd --- /dev/null +++ b/tests/browser/workspace-images-delete.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 87db1b57..f40e0afc 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -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(); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index 245bfc13..14b8c9a5 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -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 }) => { diff --git a/tests/js/workspace-images-delete.test.js b/tests/js/workspace-images-delete.test.js new file mode 100644 index 00000000..9e98f50a --- /dev/null +++ b/tests/js/workspace-images-delete.test.js @@ -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); +}); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index e35551a2..5771d5bd 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -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'); diff --git a/web/js/workspace/images-delete.mjs b/web/js/workspace/images-delete.mjs new file mode 100644 index 00000000..baa0b286 --- /dev/null +++ b/web/js/workspace/images-delete.mjs @@ -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); + }}); +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index 217e32a2..85303048 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -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() { diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 16e22c6e..a3bb4b96 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -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(); diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index cda26c2c..cba49ad7 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -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):