diff --git a/TESTING.md b/TESTING.md index 3512e465..81ebfcb2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2490,6 +2490,33 @@ files to the system trash. **No code path unlinks or overwrites a media file.** everywhere. Tests use `tests/fake_trash.py`; the browser test server installs it, so no test touches a real trash. +- **D162** (#498, maintainer ruling Q1 of #480; the concept has no screen for removed images, so this follows D152's + Show archived prompts): **Show removed images (N)** in the Images heading actions, before Import, a toggle + (`aria-pressed`) shown only while N > 0 or the view is on. N is `removed_total`, every removed image in the selected + source(s) whatever the filters (D152 counts the matching ones). The view (`GET /images/output?mode=workspace& + removed=1`, refused with `favorites`, `collection`, `category`, `tags` or `sort=rating`; a cursor never crosses + views) lists only files a removal mark hides, each card with a "Removed" `.chip` and without the star or Tag action; + the caption reads "N removed images · files on disk, hidden from Images", the empty view "No removed images." / + "No removed images match.". Entering clears Category and Tags. A card's image and Info both inspect (the viewer is + not opened: its Collections… and Delete… would write); the panel shows Return to library in Delete…'s place and + no Collections… or tag-filter chips. The bar reads "N selected", Return to library, Clear. The view is never saved + or restored: leaving the page, Browse or a saved view ends it, and a return that leaves N = 0 ends it too. + - **Return to library** (no review: nothing is lost or moved, as D152's Restore) posts `{items | filter}` to + `POST /prompt_manager/images/removed/return` (only this route takes `filter.removed`; every other bulk route + refuses it). 202 `{job, returned, not_removed}` (400 `no-images` when none is hidden now, `too-many-images` over + 500; 409 `busy` beside a scan, restore or delete, nothing changed). The job is a `scan`, "Return images to the + library": its first step deletes, in one transaction, the marks that still hide their file (same size and + `mtime_ns`; the marks generation moves again after the commit), then it scans exactly those files with the scan's + pipeline and groups. Toasts: "Returning N images to the library…", then "Returned N images to the library; K + linked to their prompts. Tags, categories, collections and favorites do not come back." When it ends, the + sidebar's Needs review, Library and Images counts are read again (`JOB_END_COUNTS.scan`). + - What comes back: the file in Images; its prompt, links, pairs and Needs review items from its embedded metadata + (a file without usable metadata comes back unlinked); previews on demand. Not: image tags, auto-tag marks, own + category, collection memberships, favorite (only a safety-backup restore has them). If the server stops between + the mark delete and the scan, the files are back unlinked and a "new files" scan links them. + Tests: `tests/test_image_return.py`, `tests/js/workspace-images-return.test.js`, + `tests/browser/workspace-images-removed.spec.mjs`. A Python change: ComfyUI needs a restart. + ### Prompt library cards and recorded deviations (#249 PR E) The shelf uses the shared concept toolbar and caption (E1) and the concept's diff --git a/database/image_removal.py b/database/image_removal.py index 58099e03..2a565603 100644 --- a/database/image_removal.py +++ b/database/image_removal.py @@ -43,6 +43,7 @@ from .image_categories import DELETE_SQL as DELETE_CATEGORY_SQL from .image_removal_schema import ( META_TABLE, TABLE, + bump_generation, clear_marks, has_marks_table, mark_of, @@ -319,4 +320,29 @@ class ImageRemovalOperations: if not locators: return 0 with self._pair_transaction() as conn: - return clear_marks(conn, locators) + removed = clear_marks(conn, locators) + # #498: again after the commit, so a reader that cached the marks + # between the in-transaction bump and the commit reloads them. + bump_generation() + return removed + + def clear_hiding_marks(self, files) -> set: + """Delete the marks that still hide their file (#498, Return to library). + + *files* are ``(locator, path)`` pairs. In one ``BEGIN IMMEDIATE`` + transaction each mark is read again and deleted only while the file at + *path* still has its size and ``mtime_ns``. Returns the locators whose + marks went. + """ + cleared = set() + files = list(files) + if not files: + return cleared + with self._pair_transaction() as conn: + for locator, path in files: + mark = mark_of(conn, locator) + if mark is not None and mark_hides({locator: mark}, locator, path): + cleared.add(locator) + clear_marks(conn, sorted(cleared)) + bump_generation() + return cleared diff --git a/database/image_removal_schema.py b/database/image_removal_schema.py index 8be0c321..d9be9ac0 100644 --- a/database/image_removal_schema.py +++ b/database/image_removal_schema.py @@ -155,6 +155,11 @@ def _bump() -> None: _generation += 1 +def bump_generation() -> None: + """Move the generation after a commit (a cached read made before it reloads).""" + _bump() + + def has_marks_table(conn: sqlite3.Connection) -> bool: return ( conn.execute( diff --git a/py/api/__init__.py b/py/api/__init__.py index f15cce0d..4b9cb43a 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -76,6 +76,7 @@ from .lora_integration import LoraIntegrationMixin from .lora_import_routes import LoraImportRoutesMixin from .image_categories import ImageCategoryRoutesMixin from .image_delete import ImageDeleteMixin +from .image_return import ImageReturnMixin from .source_rescope import SourceRescopeMixin try: @@ -346,6 +347,7 @@ class PromptManagerAPI( LoraImportRoutesMixin, ImageCategoryRoutesMixin, ImageDeleteMixin, + ImageReturnMixin, SourceRescopeMixin, ): """REST API handler for PromptManager operations and web interface. @@ -724,6 +726,7 @@ class PromptManagerAPI( self._register_lora_import_routes(routes) self._register_image_category_routes(routes) self._register_image_delete_routes(routes) + self._register_image_return_routes(routes) self._register_rescope_routes(routes) # Register gzip compression middleware (once) diff --git a/py/api/admin.py b/py/api/admin.py index a9f820e2..4637afee 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -241,7 +241,12 @@ SCAN_FINAL_STATES = { "error": "failed", } # The scope as the scan log's first line names it (the review dialog's words). -SCAN_SCOPE_LOG = {"new": "new and unresolved files", "all": "all files"} +SCAN_SCOPE_LOG = { + "new": "new and unresolved files", + "all": "all files", + "returned": "images returned to the library", +} +RETURN_JOB_TITLE = "Return images to the library" # #498 MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches # Log line prefix per outcome group (spec: + added, = known, ? review, ! error, # - no usable metadata). @@ -1381,13 +1386,17 @@ class AdminRoutesMixin: } ) - def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None): + def _start_scan_job( + self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None, returned=None + ): """Register the scan job synchronously; its runner starts on the loop. ``files`` is the scope (``SCAN_SCOPES``). ``roots`` selects sources by their index in ``output_dirs`` (the effective sources the caller resolved); None scans every source, resolved when the job starts. - Raises JobBusy while another record-changing job runs. + Raises JobBusy while another record-changing job runs. ``returned`` + (#498, ``[(path, locator)]``) makes it "Return images to the library": + those files' removal marks go first, then only they are scanned. """ if files not in SCAN_SCOPES: raise ValueError(f"Unknown scan scope: {files!r}") @@ -1395,20 +1404,25 @@ class AdminRoutesMixin: output_dirs is None or not roots or max(roots) >= len(output_dirs) ): raise ValueError("A scoped scan needs the sources its roots index") + scope = {"files": files, "roots": None if roots is None else list(roots)} + if returned is not None: + scope = {"files": "returned", "roots": None, "images": len(returned)} job = self._jobs.start( SCAN_JOB_KIND, - SCAN_JOB_TITLE, + SCAN_JOB_TITLE if returned is None else RETURN_JOB_TITLE, record_changing=True, - scope={"files": files, "roots": None if roots is None else list(roots)}, - runner=functools.partial(self._run_scan_job, output_dirs=output_dirs), + scope=scope, + runner=functools.partial( + self._run_scan_job, output_dirs=output_dirs, returned=returned + ), groups=SCAN_GROUP_NAMES, ) self._scan_job = job return job - async def _run_scan_job(self, job, output_dirs=None): + async def _run_scan_job(self, job, output_dirs=None, returned=None): final = None - async for payload in self._scan_images_events(job, output_dirs): + async for payload in self._scan_images_events(job, output_dirs, returned): job.publish(payload) final = payload.get("type") state = SCAN_FINAL_STATES.get(final, "finished") @@ -1446,7 +1460,7 @@ class AdminRoutesMixin: ) return response - async def _scan_images_events(self, job, output_dirs=None): + async def _scan_images_events(self, job, output_dirs=None, returned=None): """Yield progress payloads while scanning the output directories. Every effective source is walked, or only those ``job.scope["roots"]`` @@ -1484,9 +1498,16 @@ class AdminRoutesMixin: 0, f"Scanning {len(walked)} directory(ies) for media files..." ) - media_files = await self._run_in_executor( - self._collect_output_media_sync, walked - ) + if returned is None: + media_files = await self._run_in_executor( + self._collect_output_media_sync, walked + ) + else: + # #498: only the files whose marks this job deleted. + media_files = await self._run_in_executor( + self._clear_returned_marks, returned + ) + job.log_line(f"Returned {_plural(len(media_files), 'image')}") counts = dict.fromkeys(SCAN_COUNT_KEYS, 0) job.update( total=len(media_files), diff --git a/py/api/image_categories.py b/py/api/image_categories.py index ff37ecd4..91a98558 100644 --- a/py/api/image_categories.py +++ b/py/api/image_categories.py @@ -232,15 +232,17 @@ class ImageCategoryRoutesMixin: return None, _coded(400, "too-many-images", TOO_MANY) return tuple(selection.targets), None - async def _category_request(self, request, fields: frozenset): + async def _category_request(self, request, fields: frozenset, data=None): """``(data, targets, roots, None)`` or ``(None, None, None, error)``. The images are ``items`` (1 to 500 targets) or ``filter`` (all images - an Images listing filter admits), besides *fields*. + an Images listing filter admits), besides *fields*. *data* is the body + when the caller has already read it (a body is read once). """ - data, error = await _json_body_or_error(request) - if error is not None: - return None, None, None, error + if data is None: + data, error = await _json_body_or_error(request) + if error is not None: + return None, None, None, error names = set(data) - fields if set(data) & fields != fields or names not in ({"items"}, {"filter"}): return ( diff --git a/py/api/image_return.py b/py/api/image_return.py new file mode 100644 index 00000000..09032a4e --- /dev/null +++ b/py/api/image_return.py @@ -0,0 +1,148 @@ +"""Return removed images to the library (#498). + +``POST /prompt_manager/images/removed/return {items | filter}`` names files a +removal mark hides (#480, "Remove from the library only"): ``items`` are 1 to +500 listing identities, ``filter`` the removed view's listing query +(``removed: "1"`` plus the view's other parameters, at most 500 images). Only +this route takes ``removed``: every other bulk route refuses it, because an +explicit write there would show a file again without re-reading it. + +The route answers 202 ``{job, returned, not_removed}`` after starting a +``scan`` job titled "Return images to the library" (admission ``records``: +409 ``busy`` beside a scan, restore or delete, nothing changed). The job +first deletes the marks that still hide their file (same size and +``mtime_ns``), in one transaction, then scans exactly those files with the +scan's own pipeline: prompts, links, pairs and Needs review items come back +from the files' embedded metadata. Image tags, auto-tag marks, the own +category, collection memberships and the favorite were deleted by the remove +and do not come back. No file is moved, written or deleted. +""" + +import os +from pathlib import Path + +from aiohttp import web + +from .image_categories import TOO_MANY, _coded +from .images import ( + ListingSourceChanged, + _favorite_relative_key, + _json_body_or_error, + parse_listing_filter, +) +from .image_tag_jobs import MAX_JOB_TARGETS +from .jobs import JobBusy +from .job_routes import busy_response + +try: + from ...database.image_removal import mark_hides +except ImportError: + from database.image_removal import mark_hides + +NONE_REMOVED = "None of the selected images are removed from the library." +SOURCE_CHANGED = "The image sources changed. Reload Images and try again." + + +def _bad(message): + return web.json_response({"success": False, "error": message}, status=400) + + +class ImageReturnMixin: + """Return to library (#498): clear the marks, then scan those files.""" + + def _register_image_return_routes(self, routes): + # A fixed path beside the dynamic /images/{image_id} routes. + @routes.post("/prompt_manager/images/removed/return") + async def return_removed_images_route(request): + return await self.return_removed_images(request) + + async def return_removed_images(self, request): + data, error = await _json_body_or_error(request) + if error is not None: + return error + if set(data) == {"filter"}: + targets, roots, error = await self._removed_filter_targets(data["filter"]) + else: + _data, targets, roots, error = await self._category_request( + request, frozenset(), data=data + ) + if error is not None: + return error + files = {} + for index in dict.fromkeys(t[0] for t in targets): + files[index] = await self._get_gallery_files(roots[index]) + returned, not_removed = await self._run_in_executor( + self._hidden_targets, roots, targets, files + ) + if not returned: + return _coded(400, "no-images", NONE_REMOVED) + try: + job = self._start_scan_job( + files="all", output_dirs=list(roots), returned=returned + ) + except JobBusy as busy: + return busy_response(busy) + return web.json_response( + { + "success": True, + "job": job.summary(), + "returned": len(returned), + "not_removed": not_removed, + }, + status=202, + ) + + async def _removed_filter_targets(self, raw): + """``(targets, roots, None)`` for the removed view's filter, else an error.""" + if not isinstance(raw, dict) or raw.get("removed") != "1": + return None, None, _bad('filter must hold removed: "1"') + rest = {key: value for key, value in raw.items() if key != "removed"} + try: + parse_listing_filter(rest) + selection = await self.resolve_listing_filter( + {**rest, "removed": "1"}, MAX_JOB_TARGETS + ) + except ListingSourceChanged: + return None, None, _coded(409, "source-changed", SOURCE_CHANGED) + except ValueError as exc: + return None, None, _bad(str(exc)) + if selection.truncated: + return None, None, _coded(400, "too-many-images", TOO_MANY) + if not selection.targets: + return None, None, _coded(400, "no-images", NONE_REMOVED) + return tuple(selection.targets), list(self._get_all_output_dirs()), None + + def _hidden_targets(self, roots, targets, files): + """``([(path, locator)], not_removed)``: the targets a mark hides now. + + Each target is matched to its listed spelling (the walk's). One + physical file named twice (nested roots) is returned once; a target + that is not listed or not hidden counts in ``not_removed``. + """ + marks = self._removal_marks() + listings, seen, returned, not_removed = {}, set(), [], 0 + for index, _scope, relative in targets: + root = roots[index] + if index not in listings: + listings[index] = self._favorite_listing(root, files[index]) + spelled = listings[index].get(_favorite_relative_key(relative)) + if spelled is None: + not_removed += 1 + continue + path = os.path.join(str(root), *spelled.split("/")) + locator = os.path.normcase(os.path.realpath(path)) + if locator in seen: + continue + if not mark_hides(marks, locator, path): + not_removed += 1 + continue + seen.add(locator) + returned.append((Path(path), locator)) + return returned, not_removed + + def _clear_returned_marks(self, returned) -> list: + """The paths whose marks this deleted (blocking; one transaction).""" + cleared = self.db.clear_hiding_marks( + [(locator, str(path)) for path, locator in returned] + ) + return [path for path, locator in returned if locator in cleared] diff --git a/py/api/images.py b/py/api/images.py index eb5636d9..45daa952 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -1160,7 +1160,10 @@ def _workspace_filters(query: Mapping) -> dict: the linked positive, and ``favorites=1`` keeps only favorites (the Favorites page, #249 D51). ``collection=`` (1 to 9 digits) keeps only that collection's members (#331); it wins over ``favorites`` (a page never - sets both). Raises ValueError for any unknown or oversized value. + sets both). ``removed=1`` lists only the files a removal mark hides (#498, + Show removed images); such a file has no records, so it never combines + with ``favorites``, ``collection``, ``category``, ``tags`` or the rating + sort. Raises ValueError for any unknown or oversized value. """ text = query.get("q", "").strip() folded = text.casefold() @@ -1175,6 +1178,12 @@ def _workspace_filters(query: Mapping) -> dict: ) favorites = query.get("favorites", "0") collection = query.get("collection", "") + removed = query.get("removed", "0") + if removed not in ("0", "1") or ( + removed == "1" + and (favorites == "1" or collection or category or tags or order == "rating") + ): + raise ValueError("Invalid gallery filters") if ( favorites not in ("0", "1") or (collection != "" and _COLLECTION_ID.fullmatch(collection) is None) @@ -1202,6 +1211,7 @@ def _workspace_filters(query: Mapping) -> dict: "tags": tags, "favorites": favorites == "1" and not collection, "collection": int(collection) if collection else None, + "removed": removed == "1", } @@ -2073,6 +2083,7 @@ class ImageRoutesMixin: filters["tags"], filters["favorites"], filters["collection"], + filters["removed"], ] ).encode() ).hexdigest() @@ -2102,6 +2113,11 @@ class ImageRoutesMixin: self._select_workspace_page, listings, filters, after, limit, scope ) ) + # #498: Show removed images (N), every removed file in the selected + # source(s) whatever the filters (the hidden set is cached work). + removed_total = len( + await self._run_in_executor(self._hidden_entries, listings) + ) except CollectionMissing: return collection_not_found() except Exception: @@ -2128,6 +2144,7 @@ class ImageRoutesMixin: "limit": limit, "has_more": has_more, "next_cursor": next_cursor, + "removed_total": removed_total, } if positive_count is _COUNT_OMITTED: del result["positive_count"] @@ -2451,9 +2468,12 @@ class ImageRoutesMixin: def key(entry): return _workspace_key(entry, order) - # #480: files removed from the library stay on disk, hidden here. + # #480: files removed from the library stay on disk, hidden here; the + # removed view (#498) lists only them. hidden = self._hidden_entries(listings) - if hidden: + if filters.get("removed"): + entries = [entry for entry in entries if entry.normalized in hidden] + elif hidden: entries = [entry for entry in entries if entry.normalized not in hidden] return ( entries, diff --git a/tests/browser/images-removed-concept.spec.mjs b/tests/browser/images-removed-concept.spec.mjs new file mode 100644 index 00000000..06c7e2ae --- /dev/null +++ b/tests/browser/images-removed-concept.spec.mjs @@ -0,0 +1,65 @@ +import {test,expect} from './fixtures.mjs'; +import {seedConceptAt,openConcept,openProduction} from './concept-inventory.mjs'; + +// #498 (D162): the concept has no Show removed images screen, so production builds it from the concept's own parts: +// the heading action button (as the concept's Images heading button), the card's `.chip` (as a concept card chip) and +// the selection bar's buttons (as the concept bar's plain buttons). Each is compared with its concept counterpart. +test.use({locale:'en-US',timezoneId:'UTC'}); + +const CARDS='.gallery-body > .image-grid > .media-card'; +const KEYS=['color','backgroundColor','borderTopColor','borderTopWidth','fontSize','fontWeight','paddingTop','paddingLeft', + 'borderTopLeftRadius','height']; +const CHIP_KEYS=['color','backgroundColor','borderTopColor','fontSize','fontWeight','paddingTop','paddingLeft', + 'borderTopLeftRadius']; + +/** Runs in the page: the computed style of the first node `selector` matches. */ +function styleOf([selector,keys]) { + const node=document.querySelector(selector); + return node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null; +} + +async function measure(page,selectors) { + const found={}; + for (const [name,selector,keys] of selectors) found[name]=await page.evaluate(styleOf,[selector,keys]); + return found; +} + +test.beforeEach(async({page,request})=>{await seedConceptAt(page,request);}); + +for (const theme of ['dark','light']) { + test(`Show removed images uses the concept's heading button, chip and bar buttons (${theme})`,async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + await openConcept(page,'images',theme); + await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check(); + await page.mouse.move(0,0); + const reference=await measure(page,[ + ['heading','#main .heading-actions > button:not(.ca-primary)',KEYS], + ['chip','#main .chip',CHIP_KEYS], + ['bar','#main #bulkbar > .bulkbar > button:not(.danger)',KEYS], + ]); + + await openProduction(page,'images',theme); + await expect(page.locator(CARDS).first()).toBeVisible(); + await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check(); + await page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Delete…',exact:true}).click(); + const dialog=page.locator('dialog#modal[open]'); + await dialog.getByRole('radio',{name:/^Remove from the library only/}).check(); + await dialog.locator('.dialog-actions button.danger').click(); + const toggle=page.locator('.heading-actions').getByRole('button',{name:/^Show removed images \(1\)$/}); + await expect(toggle).toBeVisible(); + await page.mouse.move(0,0); + const heading=await measure(page,[['heading','#workspace-main .heading-actions > button[aria-pressed]',KEYS]]); + await toggle.click(); + await expect(page.locator(`${CARDS} .chip`,{hasText:'Removed'})).toBeVisible(); + await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check(); + await expect(page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Return to library'})).toBeVisible(); + await page.mouse.move(0,0); + const actual={...heading,...await measure(page,[ + ['chip',`${CARDS} .media-info .chip`,CHIP_KEYS], + ['bar','#workspace-main #bulkbar > .bulkbar > button',KEYS], + ])}; + // Every part was found on both sides: two missing nodes must never compare equal. + expect(Object.values(reference).every(Boolean)).toBe(true); + expect(actual).toEqual(reference); + }); +} diff --git a/tests/browser/workspace-images-removed.spec.mjs b/tests/browser/workspace-images-removed.spec.mjs new file mode 100644 index 00000000..5fd204f3 --- /dev/null +++ b/tests/browser/workspace-images-removed.spec.mjs @@ -0,0 +1,131 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; +import { seedConceptAt, openProduction } from './concept-inventory.mjs'; + +// #498 (D162): Images › Show removed images (N) lists the files "Remove from the library only" hid; Return to library +// (bar or inspector) clears their marks and scans them again. Nothing is deleted or moved. +test.use({ locale: 'en-US', timezoneId: 'UTC' }); + +const CARDS = '#workspace-main article[data-image-key]'; +const bar = page => page.locator('#workspace-main #bulkbar'); +const toast = page => page.locator('#workspace-toast'); +const toggle = page => page.locator('.heading-actions').getByRole('button', { name: /^Show removed images/ }); +const imagesBadge = page => page.locator('#primary-nav a[data-route="images"] .nav-count'); + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + await seedConceptAt(page, request); + page.removedErrors = []; + page.on('pageerror', error => page.removedErrors.push(error.message)); + page.on('dialog', dialog => { page.removedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); }); +}); + +test.afterEach(async ({ page, browserName }) => { + await saveCoverage(page, browserName); + expect(page.removedErrors).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; +} + +/** Remove the first `count` cards from the library (library mode), then wait for the cards to leave. */ +async function removeFromLibrary(page, count) { + const keys = []; + for (let index = 0; index < count; index += 1) keys.push(await check(page, index)); + await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: /^Delete \d+ images?$/ }); + await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check(); + await dialog.locator('.dialog-actions button.danger').click(); + await expect(toast(page)).toContainText(`Removed ${count} image${count === 1 ? '' : 's'} from the library.`); + for (const key of keys) await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0); + return keys; +} + +test('without removed images there is no toggle', async ({ page }) => { + await openImages(page); + await expect(page.locator('#result-count')).toContainText(/images?/); + await expect(toggle(page)).toHaveCount(0); +}); + +test('Show removed images lists the removed files; Return to library brings them back', async ({ page }) => { + const before = await openImages(page); + const keys = await removeFromLibrary(page, 2); + await expect(toggle(page)).toHaveText('Show removed images (2)'); + await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false'); + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator(CARDS)).toHaveCount(2); + await expect(page.locator('#result-count')).toHaveText('2 removed images · files on disk, hidden from Images'); + for (const key of keys) { + const card = page.locator(`${CARDS}[data-image-key="${key}"]`); + await expect(card.locator('.chip', { hasText: 'Removed' })).toBeVisible(); + // A removed file has no records: no star and no Tag action, which would write and show it again. + await expect(card.locator('.star-button')).toHaveCount(0); + await expect(card.locator('.image-card-actions button')).toHaveCount(1); + } + await check(page, 0); + await check(page, 1); + const actions = bar(page).locator('button'); + await expect(actions).toHaveText(['Return to library', 'Clear']); + await bar(page).getByRole('button', { name: 'Return to library' }).click(); + await expect(toast(page)).toContainText('Returned 2 images to the library'); + await expect(toast(page)).toContainText('Tags, categories, collections and favorites do not come back.'); + // The view emptied: it turns off and Images lists every file again. + await expect(toggle(page)).toHaveCount(0); + await expect(page.locator(CARDS)).toHaveCount(before); + for (const key of keys) await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(1); + await expect(bar(page)).toBeEmpty(); +}); + +test('Return to library from the inspector', async ({ page }) => { + await openImages(page); + const [key] = await removeFromLibrary(page, 1); + await toggle(page).click(); + const card = page.locator(`${CARDS}[data-image-key="${key}"]`); + await card.getByRole('button', { name: /^Inspect / }).click(); + const panel = page.locator('#image-inspector'); + await expect(panel.getByRole('button', { name: 'Return to library' })).toBeVisible(); + await expect(panel.getByRole('button', { name: 'Delete…' })).toHaveCount(0); + await expect(panel.locator('button[data-collections]')).toHaveCount(0); + await panel.getByRole('button', { name: 'Return to library' }).click(); + await expect(toast(page)).toContainText('Returned 1 image to the library'); + await expect(toggle(page)).toHaveCount(0); + await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(1); +}); + +test('the sidebar Images count follows a remove and a return', async ({ page }) => { + await openImages(page); + await expect(imagesBadge(page)).toHaveText(/^\d[\d,]*$/); + const start = Number((await imagesBadge(page).textContent()).replace(/,/g, '')); + await removeFromLibrary(page, 1); + await expect(imagesBadge(page)).toHaveText((start - 1).toLocaleString('en-US')); + await toggle(page).click(); + await check(page, 0); + await bar(page).getByRole('button', { name: 'Return to library' }).click(); + await expect(toast(page)).toContainText('Returned 1 image to the library'); + await expect(imagesBadge(page)).toHaveText(start.toLocaleString('en-US')); +}); + +test('the removed view is never saved and ends when the page is left', async ({ page }) => { + await openImages(page); + await removeFromLibrary(page, 1); + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true'); + await page.locator('#primary-nav a[data-route="library"]').click(); + await expect(page).toHaveURL(/#\/library/); + await page.locator('#primary-nav a[data-route="images"]').click(); + await expect(page.locator(CARDS).first()).toBeVisible(); + await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('#result-count')).not.toContainText('removed'); +}); diff --git a/tests/js/workspace-images-return.test.js b/tests/js/workspace-images-return.test.js new file mode 100644 index 00000000..bc3a8dad --- /dev/null +++ b/tests/js/workspace-images-return.test.js @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// #498 (D162): Images › Show removed images › Return to library: the words, the request and the toasts. +const loaded = await import('../../web/js/workspace/images-return.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-return.mjs is not implemented'); return loaded; }; +const pageScope = () => { + const controller = new AbortController(); + return { signal: controller.signal, cleanup: () => {}, abort: () => controller.abort() }; +}; +const image = (path, root_index = 0) => ({ path, root_index, root_scope: 's0', filename: path }); + +test('the toggle, the caption and the empty view', () => { + const { toggleLabel, removedCaption, removedEmpty, RETURN_LABEL } = module(); + assert.equal(toggleLabel(null), 'Show removed images'); + assert.equal(toggleLabel(1234), 'Show removed images (1,234)'); + assert.equal(removedCaption(1), '1 removed image · files on disk, hidden from Images'); + assert.equal(removedCaption(3), '3 removed images · files on disk, hidden from Images'); + assert.equal(removedEmpty(false), 'No removed images.'); + assert.equal(removedEmpty(true), 'No removed images match.'); + assert.equal(RETURN_LABEL, 'Return to library'); +}); + +test('the finished toast says what came back and what did not', () => { + const { finishedToast, startToast } = module(); + assert.equal(startToast(2), 'Returning 2 images to the library…'); + assert.equal(finishedToast({ state: 'finished', counters: { added: 1 } }, 2), + 'Returned 2 images to the library; 1 image linked to its prompt. Tags, categories, collections and favorites do not come back.'); + assert.equal(finishedToast({ state: 'finished', counters: { added: 3 } }, 3), + 'Returned 3 images to the library; 3 images linked to their prompts. Tags, categories, collections and favorites do not come back.'); + assert.equal(finishedToast({ state: 'finished', counters: {} }, 1), + 'Returned 1 image to the library. Tags, categories, collections and favorites do not come back.'); + assert.match(finishedToast({ state: 'failed', status_line: 'Disk error' }, 1), /^Returning images stopped: Disk error\.$/); + assert.match(finishedToast({ state: 'cancelled' }, 1), /cancelled/); +}); + +test('refusals name the cause', () => { + const { returnFailure, NONE_REMOVED, TOO_MANY, SOURCE_CHANGED, RETURN_FAILED } = module(); + assert.equal(returnFailure({ code: 'no-images' }), NONE_REMOVED); + assert.equal(returnFailure({ code: 'too-many-images' }), TOO_MANY); + assert.equal(returnFailure({ code: 'source-changed' }), SOURCE_CHANGED); + assert.equal(returnFailure({ code: 'busy', message: 'Scan for metadata is running.' }), 'Scan for metadata is running.'); + assert.equal(returnFailure(new Error('x')), RETURN_FAILED); +}); + +test('over 500 refuses with the toast before any request', async () => { + const { returnImages, TOO_MANY } = module(); + const toasts = [], posts = []; + const api = { post: async (...args) => { posts.push(args); return {}; } }; + await returnImages({ api, scope: pageScope(), filter: { q: '' }, count: 501, notify: text => toasts.push(text) }); + assert.deepEqual(toasts, [TOO_MANY]); + assert.deepEqual(posts, []); +}); + +test('items post their identities; a filter posts the removed view; the job is tracked and followed', async () => { + const { returnImages, RETURN_URL } = module(); + const toasts = [], posts = [], ended = []; + let listener = null; + const job = { id: 'j1', title: 'Return images to the library', state: 'running', counters: {} }; + const store = { + track: started => started, + find: () => job, + subscribe: fn => { listener = fn; return () => { listener = null; }; }, + }; + const api = { post: async (url, body) => { posts.push([url, body]); return { job, returned: 2, not_removed: 0 }; } }; + const started = await returnImages({ api, scope: pageScope(), store, images: [image('a.png'), image('b.png', 1)], + notify: text => toasts.push(text), onReturned: done => ended.push(done) }); + assert.equal(started, job); + assert.deepEqual(posts[0], [RETURN_URL, { items: [ + { root_index: 0, root_scope: 's0', path: 'a.png' }, { root_index: 1, root_scope: 's0', path: 'b.png' }] }]); + assert.deepEqual(toasts, ['Returning 2 images to the library…']); + job.state = 'finished'; job.counters = { added: 2 }; + listener(); + assert.deepEqual(ended, [job]); + assert.match(toasts[1], /^Returned 2 images to the library; 2 images linked/); + await returnImages({ api, scope: pageScope(), filter: { q: 'x' }, count: 2, notify: () => {} }); + assert.deepEqual(posts[1][1], { filter: { q: 'x', removed: '1' } }); +}); + +test('a refused return toasts, releases the trigger and changes nothing', async () => { + const { returnImages, NONE_REMOVED } = module(); + const toasts = []; + const attributes = new Map(); + const trigger = { getAttribute: name => attributes.get(name) ?? null, setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: name => attributes.delete(name) }; + const api = { post: async () => { throw Object.assign(new Error('no'), { code: 'no-images' }); } }; + const result = await returnImages({ api, scope: pageScope(), images: [image('a.png')], trigger, notify: text => toasts.push(text) }); + assert.equal(result, null); + assert.deepEqual(toasts, [NONE_REMOVED]); + assert.equal(trigger.getAttribute('aria-disabled'), null); +}); diff --git a/tests/test_image_return.py b/tests/test_image_return.py new file mode 100644 index 00000000..5c8ae2c3 --- /dev/null +++ b/tests/test_image_return.py @@ -0,0 +1,244 @@ +"""Show removed images and Return to library (#498). + +``GET /prompt_manager/images/output?mode=workspace&removed=1`` lists only the +files a removal mark hides; every workspace page carries ``removed_total``. +``POST /prompt_manager/images/removed/return {items | filter}`` starts a +``scan`` job titled "Return images to the library": it deletes the marks of +the files that are still hidden, then scans exactly those files, so their +prompt links come back from their metadata. Image tags, category, +collections and favorites do not come back. +""" + +import asyncio +import hashlib +import os +import sqlite3 +from unittest.mock import patch + +try: + from tests.disk_waits import DISK_BOUND_SECONDS + from tests.test_api_admin import _write_png + from tests.test_image_delete_routes import LISTING, ImageDeleteTestCase +except ImportError: # discovered with tests/ as the top-level directory + from disk_waits import DISK_BOUND_SECONDS + from test_api_admin import _write_png + from test_image_delete_routes import LISTING, ImageDeleteTestCase + +RETURN = "/prompt_manager/images/removed/return" + + +class _ReturnCase(ImageDeleteTestCase): + async def page(self, status=200, **query): + params = {"mode": "workspace", **query} + response = await self.client.get(LISTING, params=params) + self.assertEqual(response.status, status, await response.text()) + return await response.json() + + async def removed_paths(self): + page = await self.page(removed="1") + return [image["path"] for image in page["images"]] + + async def give_back(self, body, status=202): + answer = await self.post(RETURN, body, status) + if status != 202: + return answer + job = self.api._jobs.get(answer["job"]["id"]) + await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS) + job.answer = answer + return job + + def png(self, name, text): + path = self.output_dir / name + _write_png(path, text) + return path + + def marks(self): + conn = sqlite3.connect(self.api.db.model.db_path) + try: + return conn.execute("SELECT COUNT(*) FROM image_removals").fetchone()[0] + finally: + conn.close() + + +class TestRemovedView(_ReturnCase): + async def test_the_view_lists_only_hidden_files_and_both_views_count_them(self): + self.write("a.png", b"aaa", mtime=1_000_000) + self.write("b.png", b"bbb", mtime=2_000_000) + self.write("c.png", b"ccc", mtime=3_000_000) + await self.delete(["a.png", "b.png"], "library") + shown = await self.page() + self.assertEqual([i["path"] for i in shown["images"]], ["c.png"]) + self.assertEqual(shown["removed_total"], 2) + removed = await self.page(removed="1") + self.assertEqual( + sorted(i["path"] for i in removed["images"]), ["a.png", "b.png"] + ) + self.assertEqual(removed["total"], 2) + self.assertEqual(removed["removed_total"], 2) + # A search applies inside the view. + self.assertEqual( + [i["path"] for i in (await self.page(removed="1", q="b."))["images"]], + ["b.png"], + ) + + async def test_no_marks_count_zero(self): + self.write("a.png", b"aaa") + self.assertEqual((await self.page())["removed_total"], 0) + self.assertEqual((await self.page(removed="1"))["images"], []) + + async def test_refused_combinations(self): + for query in ( + {"removed": "2"}, + {"removed": "1", "favorites": "1"}, + {"removed": "1", "collection": "1"}, + {"removed": "1", "category": "x"}, + {"removed": "1", "tags": "x"}, + {"removed": "1", "sort": "rating"}, + ): + with self.subTest(query=query): + await self.page(status=400, **query) + + +class TestReturn(_ReturnCase): + async def test_return_shows_the_file_and_rescans_its_links_but_not_its_tags(self): + path = self.png("linked.png", "a returned prompt") + prompt_id = self.save("a returned prompt") + self.api.db.link_image_to_prompt(str(prompt_id), str(path)) + target = self.target("linked.png") + info = os.stat(path) + self.api.db.add_image_tags( + locator=os.path.normcase(os.path.realpath(path)), + sha256=hashlib.sha256(path.read_bytes()).hexdigest(), + size=info.st_size, + mtime_ns=info.st_mtime_ns, + expected_epoch=self.api.db.get_execution_epoch(), + tags=["kept-before"], + ) + self.assertEqual(self.count("SELECT COUNT(*) FROM media_image_tags"), 1) + await self.delete(["linked.png"], "library") + self.assertEqual(await self.removed_paths(), ["linked.png"]) + before = os.stat(path) + job = await self.give_back({"items": [target]}) + self.assertEqual(job.state, "finished", job.summary()) + self.assertEqual(self.marks(), 0) + self.assertEqual(await self.listed(), ["linked.png"]) + self.assertEqual(await self.removed_paths(), []) + # The links come back from the file's metadata; the tags do not. + self.assertEqual( + self.count( + "SELECT COUNT(*) FROM generated_images WHERE prompt_id IS NOT NULL" + ), + 1, + ) + self.assertEqual(self.count("SELECT COUNT(*) FROM media_image_tags"), 0) + after = os.stat(path) + self.assertEqual( + (before.st_size, before.st_mtime_ns), (after.st_size, after.st_mtime_ns) + ) + self.assertEqual(self.fake.moved, []) + self.assertEqual(job.kind, "scan") + self.assertEqual(job.title, "Return images to the library") + self.assertEqual(job.answer["returned"], 1) + + async def test_a_file_that_is_not_hidden_is_not_changed(self): + self.write("shown.png", b"shown") + self.write("hidden.png", b"hidden", mtime=1_000_000) + await self.delete(["hidden.png"], "library") + job = await self.give_back( + {"items": [self.target("shown.png"), self.target("hidden.png")]} + ) + self.assertEqual(job.state, "finished", job.summary()) + self.assertEqual(job.answer["returned"], 1) + self.assertEqual(job.answer["not_removed"], 1) + self.assertEqual(self.marks(), 0) + + async def test_nothing_hidden_is_refused(self): + self.write("shown.png", b"shown") + body = await self.give_back({"items": [self.target("shown.png")]}, 400) + self.assertEqual(body["code"], "no-images") + + async def test_return_after_a_cancelled_scan_links_the_file(self): + path = self.png("again.png", "linked again") + await self.delete(["again.png"], "library") + cancelled = self.api._start_scan_job() + self.api._jobs.cancel(cancelled.id) + await asyncio.wait_for(asyncio.shield(cancelled.task), DISK_BOUND_SECONDS) + self.assertTrue(cancelled.cancelled) + job = await self.give_back({"items": [self.target("again.png")]}) + self.assertEqual(job.state, "finished", job.summary()) + self.assertEqual( + self.count( + "SELECT COUNT(*) FROM generated_images " + "WHERE image_path LIKE '%again.png'" + ), + 1, + ) + del path + + async def test_other_bulk_routes_refuse_the_removed_filter(self): + self.write("hidden.png", b"hidden", mtime=1_000_000) + await self.delete(["hidden.png"], "library") + await self.post( + "/prompt_manager/images/delete/preview", {"filter": {"removed": "1"}}, 400 + ) + self.assertEqual(self.marks(), 1) + + async def test_select_all_matching_in_the_view(self): + for n in range(3): + self.write(f"{n}.png", f"bytes {n}".encode(), mtime=1_000_000 + n) + await self.delete(["0.png", "1.png"], "library") + job = await self.give_back({"filter": {"removed": "1"}}) + self.assertEqual(job.state, "finished", job.summary()) + self.assertEqual(sorted(await self.listed()), ["0.png", "1.png", "2.png"]) + + async def test_busy_beside_a_scan_changes_nothing(self): + self.write("hidden.png", b"hidden", mtime=1_000_000) + await self.delete(["hidden.png"], "library") + gate = asyncio.Event() + + async def held(job): + await gate.wait() + job.finish("finished") + + running = self.api._jobs.start( + "scan", "Scan for metadata", record_changing=True, runner=held + ) + try: + body = await self.give_back({"items": [self.target("hidden.png")]}, 409) + self.assertEqual(body["code"], "busy") + self.assertEqual(self.marks(), 1) + finally: + gate.set() + await asyncio.wait_for(asyncio.shield(running.task), DISK_BOUND_SECONDS) + + async def test_body_shapes(self): + await self.post(RETURN, {}, 400) + await self.post(RETURN, {"items": []}, 400) + too_many = [self.target(f"{n}.png") for n in range(501)] + await self.post(RETURN, {"items": too_many}, 400) + + +class TestMarksCacheRace(_ReturnCase): + async def test_a_listing_read_during_the_clear_does_not_keep_the_file_hidden(self): + from database import image_removal, image_removal_schema + + self.write("raced.png", b"raced", mtime=1_000_000) + await self.delete(["raced.png"], "library") + real_clear = image_removal_schema.clear_marks + stale = dict(self.api.db.removal_marks()) + + def clear_then_read(conn, locators): + removed = real_clear(conn, locators) + # Another thread's connection reads the marks before this + # transaction commits: it still sees them, under the new generation. + key = ( + self.api._database_identity(self.api.db), + image_removal_schema.generation(), + ) + self.api._marks_cache = (key, stale) + return removed + + with patch.object(image_removal, "clear_marks", side_effect=clear_then_read): + job = await self.give_back({"items": [self.target("raced.png")]}) + self.assertEqual(job.state, "finished", job.summary()) + self.assertEqual(await self.listed(), ["raced.png"]) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 7c18907f..a32acab2 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -287,6 +287,12 @@ "functions": 80, "statements": 80 }, + "workspace/images-return.mjs": { + "lines": 80, + "branches": 70, + "functions": 80, + "statements": 80 + }, "workspace/images-export.mjs": { "lines": 98, "branches": 98, diff --git a/web/js/workspace/activity-kinds.mjs b/web/js/workspace/activity-kinds.mjs index 8e7d0793..18690c73 100644 --- a/web/js/workspace/activity-kinds.mjs +++ b/web/js/workspace/activity-kinds.mjs @@ -129,8 +129,9 @@ export const KINDS = Object.freeze({ scan: Object.freeze({ ...GENERIC, pillVerb: 'Scanning', - startToast: () => 'Scan started.', - finishToast: namedFinish(() => 'Scan', addedOf, scanLine), + // #498: "Return images to the library" is a scan of the returned files; its toasts carry its own title. + startToast: job => (job.scope?.files === 'returned' ? `${job.title} started.` : 'Scan started.'), + finishToast: namedFinish(job => (job.scope?.files === 'returned' ? job.title : 'Scan'), addedOf, scanLine), line: scanLine, kept: addedOf, tiles: tiles([['added', 'Added to Library', 'added'], ['known', 'Already known', ''], diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 5771d5bd..b294ec96 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, onDelete = null}) { + collections = null, onCollectionsChanged = null, onDelete = null, onReturn = null}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -342,6 +342,13 @@ export function mountFileInspector(container, {api, sender, parentScope, variant // #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)); + // #498 (D162): a removed file (Show removed images) offers Return to library in Delete…'s place. + const returnable = onReturn ? revealTarget(record.identity) : null; + if (returnable) { + const button = createButton('Return to library'); + contentScope.listen(button, 'click', () => onReturn(returnable, button)); + footer.append(button); + } if (!full && footer.childNodes.length) container.append(footer); } /** diff --git a/web/js/workspace/image-card.mjs b/web/js/workspace/image-card.mjs index 53a7663f..e42bb9bc 100644 --- a/web/js/workspace/image-card.mjs +++ b/web/js/workspace/image-card.mjs @@ -53,7 +53,7 @@ export function cardMedia(image, lazy = false) { * cannot be served, and `showInfo: false` hides the title and file line (D97). A `linked_only` image (LoRA media in * LoraManager's folders, #355) has no Tag action. */ -export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true } = {}) { +export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true, removed = false } = {}) { const card = el('article', undefined, 'media-card'); card.dataset.imageKey = imageKey(image); card.setAttribute('role', 'listitem'); card.style.setProperty('--media-ratio', mediaRatio(image)); @@ -71,7 +71,7 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m label.append(box); overlays.append(label); } - if (typeof image.favorite === 'boolean') { + if (typeof image.favorite === 'boolean' && !removed) { const star = createButton('', 'star-button'); star.setAttribute('aria-label', `Favorite ${image.filename}`); paintStar(star, image.favorite, favoriteBusy); @@ -82,6 +82,8 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m const text = el('div'); const title = el('h3', cardTitle(image)); if (image.path) title.title = image.path; text.append(title, el('p', cardSubtitle(image))); + // #498 (D162): a card in Show removed images carries the concept's `.chip`. + if (removed) text.append(el('span', 'Removed', 'chip')); // D97: `showInfo` false (System › Appearance) hides the title and file line; Info and Tag stay. if (!showInfo) text.hidden = true; const actions = el('div', undefined, 'image-card-actions'); @@ -90,7 +92,7 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`); actions.append(inspectButton); // #355: LoRA-linked media lives in LoraManager's folders; tags are kept for output files only. - if (image.linked_only !== true) actions.append(createTagButton(image)); + if (image.linked_only !== true && !removed) actions.append(createTagButton(image)); info.append(text, actions); card.append(art, info); return card; diff --git a/web/js/workspace/images-return.mjs b/web/js/workspace/images-return.mjs new file mode 100644 index 00000000..f2578643 --- /dev/null +++ b/web/js/workspace/images-return.mjs @@ -0,0 +1,97 @@ +/** + * Images › Show removed images › Return to library (#498, D162). + * + * A removed image is a file "Remove from the library only" (#480) hid: it stays on disk without records. Return to + * library posts the checked images (or "all N matching" in the removed view) to `POST /images/removed/return`, which + * starts a scan job, "Return images to the library": the marks go, then those files are scanned, so their prompt + * links come back from their metadata. Tags, categories, collections and favorites do not come back. Nothing is + * deleted or moved, so there is no review (as the Library's Restore, D152). + */ +import { followJobEnd } from './images-tagging-bulk.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { countOf } from './plural.mjs'; + +export const RETURN_URL = '/prompt_manager/images/removed/return'; +export const MAX_RETURN = 500; +export const TOO_MANY = 'Select at most 500 images.'; +export const RETURN_LABEL = 'Return to library'; +export const SHOW_REMOVED = 'Show removed images'; +export const NOT_BACK = 'Tags, categories, collections and favorites do not come back.'; +export const RETURN_FAILED = 'The images could not be returned. Nothing was changed.'; +export const NONE_REMOVED = 'None of the selected images are removed from the library.'; +export const SOURCE_CHANGED = 'The sources changed. Refresh the images and try again.'; + +/** The heading toggle's text: "Show removed images (N)", without the count while unknown. */ +export function toggleLabel(total) { + return Number.isSafeInteger(total) && total >= 0 ? `${SHOW_REMOVED} (${total.toLocaleString('en-US')})` : SHOW_REMOVED; +} + +/** The removed view's caption: "N removed images · files on disk, hidden from Images". */ +export function removedCaption(total) { + return `${countOf(total, 'removed image')} · files on disk, hidden from Images`; +} + +/** The removed view's empty text. */ +export function removedEmpty(filtered) { + return filtered ? 'No removed images match.' : 'No removed images.'; +} + +export function startToast(count) { + return `Returning ${countOf(count, 'image')} to the library…`; +} + +/** The toast when the return job ends: how many came back and how many the scan linked. */ +export function finishedToast(job, returned) { + if (job?.state === 'failed') return `Returning images stopped: ${job.status_line || 'see Activity'}.`; + if (job?.state === 'cancelled') return 'Returning images was cancelled. The images already returned stay in the library.'; + const linked = Number(job?.counters?.added ?? 0) || 0; + const back = `Returned ${countOf(returned, 'image')} to the library`; + const links = linked ? `; ${countOf(linked, 'image')} linked to ${linked === 1 ? 'its prompt' : 'their prompts'}` : ''; + return `${back}${links}. ${NOT_BACK}`; +} + +export function returnFailure(error) { + if (error?.code === 'no-images') return NONE_REMOVED; + if (error?.code === 'too-many-images') return TOO_MANY; + if (error?.code === 'source-changed') return SOURCE_CHANGED; + if (error?.code === 'busy' && error.message) return error.message; + return RETURN_FAILED; +} + +/** + * Return `images` (the checked ones, or the inspector's one) or `filter` ("all N matching" in the removed view, `count` + * of them). `trigger` is aria-disabled until the server answers; a second press is ignored. `onReturned(job)` runs + * when the job ends (at once without an Activity store). + */ +export async function returnImages({api, scope, store, images = [], filter = null, count = images.length, trigger = null, + onReturned = () => {}, notify = showWorkspaceToast}) { + if (count > MAX_RETURN) { notify(TOO_MANY); return null; } + if (!filter && !images.length) return null; + if (trigger?.getAttribute('aria-disabled') === 'true') return null; + trigger?.setAttribute('aria-disabled', 'true'); + let answer; + try { + const body = filter ? {filter: {...filter, removed: '1'}} + : {items: images.map(({root_index, root_scope, path}) => ({root_index, root_scope, path}))}; + answer = await api.post(RETURN_URL, body, {signal: scope.signal}); + } catch (error) { + if (!scope.signal.aborted) notify(returnFailure(error)); + return null; + } finally { + trigger?.removeAttribute('aria-disabled'); + } + if (scope.signal.aborted) return null; + const returned = Number(answer?.returned ?? count) || 0; + notify(startToast(returned)); + let job; + try { + job = store ? store.track(answer.job) : answer.job; + } catch { + store?.load?.(); + onReturned(null); + return null; + } + if (store) followJobEnd({store, scope, id: job.id, onEnd: ended => { notify(finishedToast(ended, returned)); onReturned(ended); }}); + else onReturned(job); + return job; +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index 85303048..1bba487a 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -51,7 +51,8 @@ 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, onDeleted = null }) { + collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null, removedView = () => false, + onReturn = 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. @@ -103,10 +104,29 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o bulk.append(deleteButton); } bulk.append(clearButton); + // #498 (D162): in Show removed images the bar holds only Return to library and Clear; every other action writes + // records, which would show a removed file again without reading it. + const normalActions = [...bulk.children].filter(node => node !== count && node !== clearButton); + const returnButton = onReturn ? createButton('Return to library') : null; + if (returnButton) { + scope.listen(returnButton, 'click', () => { + const target = matchingTarget(); + onReturn(checkedImages(), target, returnButton); + }); + } + let shownRemoved = false; + function paintMode() { + const removed = Boolean(returnButton && removedView()); + if (removed === shownRemoved) return; + shownRemoved = removed; + for (const node of [...normalActions, returnButton]) node?.remove(); + clearButton.before(...(removed ? [returnButton] : normalActions)); + } /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ function render() { if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; } + paintMode(); const listing = getListing(); const label = allMatching ? matchingLabel(listing.total) : selectionLabel(checked.size); if (count.textContent !== label) count.textContent = label; diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 6fc5f46b..98f8b081 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -62,6 +62,14 @@ function queryFor(filters, batchSize) { const media = filters.media ?? 'all'; if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter'); if (media !== 'all') query.set('media', media); + // #498 (D162): Show removed images lists only the files a removal mark hides; never with a category, tags, + // favorites or a collection (the server refuses them: such a file has no records). + const removed = filters.removed ?? false; + if (typeof removed !== 'boolean') throw new TypeError('Invalid removed filter'); + if (removed && (filters.category || (filters.tags ?? []).length || filters.favorites || (filters.collection ?? '') !== '')) { + throw new TypeError('Invalid removed filter'); + } + if (removed) query.set('removed', '1'); const sort = filters.sort ?? 'newest'; if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter'); query.set('sort', sort); @@ -107,6 +115,9 @@ function validatePage(page, cursor) { } } +/** A page's `removed_total` (#498), or null from an older server. */ +const removedOf = page => (isCount(page?.removed_total) ? page.removed_total : null); + /** `state` with the item named by `key` patched (both frozen); the same state when no item has that key. */ export function withItem(state, key, patch) { if (!patch || typeof patch !== 'object' || 'path' in patch || 'root_index' in patch) { @@ -128,8 +139,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re throw new TypeError('Invalid image batch size'); } // positiveCount: distinct linked positives over the whole matching set, or null while unknown (PR B contract). + // removedTotal (#498): the removed images in the selected source(s), from the first page; null while unknown. let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null, - hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null }); + hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null, removedTotal: null }); let query = queryFor({}, batchSize); let controller = null; let disposed = false; @@ -149,7 +161,8 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items : Object.freeze(saved.items.map(item => Object.freeze({ ...item }))); state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore, - loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null }); + loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null, + removedTotal: isCount(saved.removedTotal) ? saved.removedTotal : null }); } function publish(update) { @@ -188,8 +201,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re // first page's count, and a first page without one leaves it unknown. const positiveCount = isCount(page.positive_count) ? page.positive_count : cursor === null ? null : state.positiveCount; controller = null; + const removedTotal = cursor === null ? removedOf(page) : state.removedTotal; publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more, - loading: false, error: null, loaded: items.length, positiveCount }); + loading: false, error: null, loaded: items.length, positiveCount, removedTotal }); return incoming.length; } catch (error) { if (!disposed && request === controller) { @@ -224,7 +238,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re const request = refreshing = new AbortController(); const target = Math.max(state.loaded, 1); const items = [], keys = new Set(); - let cursor = null, total = 0, hasMore = true, positiveCount = null; + let cursor = null, total = 0, hasMore = true, positiveCount = null, removedTotal = null; try { while (items.length < target && hasMore) { const parameters = new URLSearchParams(query); @@ -239,7 +253,10 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re keys.add(key); items.push(Object.freeze({ ...item })); } - if (cursor === null) positiveCount = isCount(page.positive_count) ? page.positive_count : null; + if (cursor === null) { + positiveCount = isCount(page.positive_count) ? page.positive_count : null; + removedTotal = removedOf(page); + } ({total, next_cursor: cursor, has_more: hasMore} = page); } } catch (error) { @@ -251,7 +268,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re refreshing = null; seen = keys; state = Object.freeze({ ...state, items: Object.freeze(items), total, cursor, hasMore, loading: false, error: null, - loaded: items.length, positiveCount }); + loaded: items.length, positiveCount, removedTotal }); onChange(state, {replaced: true}); return items.length; } @@ -267,7 +284,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re query = nextQuery; seen = new Set(); publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true, - loading: false, error: null, loaded: 0, positiveCount: null }); + loading: false, error: null, loaded: 0, positiveCount: null, removedTotal: state.removedTotal }); return load ? loadMore() : 0; } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index a3bb4b96..a46da282 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -10,6 +10,7 @@ import { promptRoles } from './prompt-blocks.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { createMediaViewer } from './media-viewer.mjs'; import { openDeleteImages } from './images-delete.mjs'; +import { removedCaption, removedEmpty, returnImages, toggleLabel } from './images-return.mjs'; import { noun } from './plural.mjs'; import { pageHeading } from './heading.mjs'; import { mountToolbar } from './toolbar.mjs'; @@ -177,6 +178,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // A collection and favorites are never both the filter (DC10). favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection }; filters.tags = [...filters.tags]; + // #498 (D162): Show removed images belongs to this visit only: never restored, saved or handed on. + filters.removed = false; if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest'; // A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched. let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== ''); @@ -186,20 +189,28 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // A Set category write still in flight may change what the saved collection shows: the return reloads instead. // A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the // Images return state (#249 D51, and M2 for saved views). - if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null; + if (!embedded && !filters.favorites && !filters.collection && !saved && !filters.removed) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null; cancelAnimationFrame(restoreFrame); }); main.classList.add('images-main'); scope.cleanup(() => main.classList.remove('images-main')); // The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt. const heading = embedded ? null : pageHeading(main); - let importButton = null, importDialog = null; + let importButton = null, importDialog = null, showRemoved = null; if (heading) { heading.description.textContent = 'Your generations, their prompts, and the next idea.'; importButton = createButton(''); importButton.append(createWorkspaceIcon('upload'), el('span', 'Import')); heading.actions.prepend(importButton); scope.cleanup(() => importButton.remove()); + // #498 (D162): Show removed images (N), before Import, only on the Images page and only while there are any. + if (imagesPage) { + // Attached only while shown, so a library without removals keeps the concept's heading exactly. + showRemoved = createButton(toggleLabel(null)); + showRemoved.setAttribute('aria-pressed', 'false'); + scope.cleanup(() => showRemoved.remove()); + scope.listen(showRemoved, 'click', () => setRemovedView(!filters.removed)); + } scope.listen(importButton, 'click', () => { importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning, onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan, @@ -301,6 +312,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage 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, + // #498: the removed view's bar holds only Return to library and Clear. + removedView:() => filters.removed, + onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger}), // 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. @@ -339,10 +353,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage selection = imageKey(image); selectedImage = image; for (const video of details.querySelectorAll('video')) releaseVideo(video); + // #498: a removed file's panel offers Return to library and nothing that writes (Collections…, Delete…, tag chips). + const removedView = filters.removed; detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel', - onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection, - collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy, - onDelete:embedded ? null : (target, button) => deleteOne(target, button)}); + onTagFilter:embedded || removedView ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection, + collections:removedView ? null : inspectorCollections, onCollectionsChanged:collectionsChangedBy, + onDelete:embedded || removedView ? null : (target, button) => deleteOne(target, button), + onReturn:removedView ? (target, button) => returnSome({images:[target], trigger:button}) : null}); detailInspector.setState(inspection.state); inspection.select(image); for (const [key, card] of rendered) markSelected(card, key === selection); @@ -360,7 +377,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const key = imageKey(image); const card = createImageCard(image, {checked:bar ? bar.isChecked(key) : null, favoriteBusy:favoriteBusy(key), // D97: "Show image details on cards" off hides the title and file line; Info and Tag stay. - showInfo:preferences?.showImageInfo !== false}); + showInfo:preferences?.showImageInfo !== false, + // #498: a removed file has no records; its star and Tag would write and show it again. + removed:filters.removed}); markSelected(card, key === selection); return card; } @@ -378,6 +397,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const image = state.items.find(item => imageKey(item) === card.dataset.imageKey); if (!image) return; const action = event.target.closest('.image-card-actions button'); + // #498: in the removed view a card only inspects (the viewer's writes would show the file again). + if (filters.removed) { if (event.target.closest('.image-open, .image-card-actions button')) inspect(image); return; } if (event.target.closest('.star-button')) toggleFavorite(api, image); else if (event.target.closest('.image-open')) previewImage(image); else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover, @@ -553,7 +574,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage state = next; bar?.listingChanged(); if (!embedded) { - setText(count, countLine(next)); + setText(count, filters.removed ? removedCaption(next.total) : countLine(next)); + paintRemovedToggle(next); setText(captionDay, captionDate(next.items, filters.sort)); if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent; } @@ -576,7 +598,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); } if (showsEmpty) { - setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES); + setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText + : filters.removed ? removedEmpty(Boolean(filters.q || filters.subfolder || filters.media !== 'all')) : NO_MATCHES); empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0); } if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next); @@ -656,6 +679,32 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage loadCategories(); reset(); } + /** #498 (D162): enter or leave Show removed images; entering clears the Category and Tags it never combines with. */ + function setRemovedView(on) { + if (filters.removed === on) return; + filters.removed = on; + if (on && (filters.category || filters.tags.length)) { + filters.category = ''; filters.tags = []; + toolbar?.setValue({q:filters.q, category:'', sort:filters.sort}); + toolbar?.setActiveFilters(activeRefinements(filters)); + } + reset(); + } + function paintRemovedToggle(next) { + if (!showRemoved) return; + const total = next.removedTotal; + // A return that leaves nothing removed ends the view: Images lists every file again. + if (filters.removed && total === 0 && !next.loading) { queueMicrotask(() => setRemovedView(false)); } + setText(showRemoved, toggleLabel(total)); + setAttr(showRemoved, 'aria-pressed', String(filters.removed)); + const shown = filters.removed || total > 0; + if (!shown) showRemoved.remove(); + else if (!showRemoved.isConnected) heading.actions.prepend(showRemoved); + } + /** #498: Return to library for the checked images, "all N matching" (`filter`) or the inspector's one. */ + function returnSome({images = [], filter = null, count = images.length, trigger = null}) { + return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) 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(); }}); diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index a2b056c9..8c48eee6 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -129,9 +129,12 @@ export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']); export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']); /** The count a review decision or a finished metadata scan can change. */ export const REVIEW_WRITE_COUNTS = Object.freeze(['review']); -/** Job kind -> the counts re-read once when such a job ends (a scan feeds Needs review; #501: a delete hides images). */ +/** + * Job kind -> the counts re-read once when such a job ends: a scan feeds Needs review and can add prompts, and since + * #498 "Return images to the library" (a scan) shows removed images again; #501: a delete hides images. + */ export const JOB_END_COUNTS = Object.freeze({ - scan: REVIEW_WRITE_COUNTS, + scan: Object.freeze(['review', 'library', 'images']), image_delete: Object.freeze(['images', 'favorites', 'review']), }); // Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).