Compare commits

...
Author SHA1 Message Date
Vito Sansevero 1e3a3d4a9c Merge remote-tracking branch 'origin/main' into feat/498-show-removed 2026-10-29 20:13:15 -07:00
Vito Sansevero af22543309 Merge remote-tracking branch 'origin/main' into feat/498-show-removed 2026-10-29 19:34:52 -07:00
Vito Sansevero e42248afa4 fix(images): a category or tags chosen in Show removed images ends the view (#498 review)
The toolbar's Category and Refine's Tags stayed usable in the removed
view, and the listing then refused the combination with a page error.
Choosing one now leaves the view, as a removed file has neither.
2026-10-29 17:44:37 -07:00
Vito Sansevero be1fea17f0 fix(images): no default removedView function in the selection bar (#498)
The default arrow was never called, so images-selection.mjs fell under
its function coverage floor (97.29 < 98).
2026-10-29 17:15:01 -07:00
Vito Sansevero dcd09dac0b Merge remote-tracking branch 'origin/main' into feat/498-show-removed
# Conflicts:
#	TESTING.md
#	tests/test_image_removal_gaps.py
#	utils/image_monitor.py
#	web/js/workspace/state.mjs
2026-10-29 16:34:22 -07:00
Vito Sansevero 4dd996387f fix(monitor): skip trash folders only below the watched folder (#501 review)
A source named .Trash-1000 (or another trash name) was ignored by the
image monitor while every walker still listed it; the monitor now tests
the folders below the longest watched folder holding the new file.
2026-10-29 16:20:59 -07:00
Vito Sansevero 13aaced3e2 Merge branch 'fix/501-removal-mark-gaps' into feat/498-show-removed 2026-10-29 14:58:31 -07:00
Vito Sansevero 907daa5fad test(browser): wait for the answers two specs raced in the merge queue (#501)
- System diagnostics "level filter": wait for each level's own log
  answer, and poll the shown levels; the CRITICAL line is in the
  previous level's answer too, so it proved nothing (firefox).
- Library editor "restored storage after a confirmed save": wait for
  the PATCH answer, which waits for a whole restore in the test's
  route, instead of the 5 s expect default (webkit).
2026-10-29 14:46:09 -07:00
Vito Sansevero f71d64de85 Merge remote-tracking branch 'origin/main' into fix/501-removal-mark-gaps 2026-10-29 14:43:47 -07:00
Vito Sansevero 5917f2df6d test(images): record the return route in the legacy contract snapshot; removed_total in the workspace keys (#498)
The scan runner passes returned only when set, so a stand-in events
function without that keyword still runs.
2026-10-29 14:24:57 -07:00
Vito Sansevero 5ee76fc3a2 Merge branch 'fix/501-removal-mark-gaps' into feat/498-show-removed 2026-10-29 14:11:53 -07:00
Vito Sansevero ed9574febd feat(images): Show removed images and Return to library (#498)
- GET /images/output?mode=workspace&removed=1 lists only files a removal
  mark hides; every workspace page carries removed_total.
- POST /images/removed/return {items | filter} starts a scan job,
  "Return images to the library": the marks that still hide their file
  go in one transaction, then exactly those files are scanned, so their
  prompt links come back from their metadata. Tags, categories,
  collections and favorites do not come back.
- The marks generation moves again after the commit, so a reader that
  cached the marks mid-transaction reloads them.
- Images: Show removed images (N) heading toggle (D162), Removed chips,
  a bar with only Return to library and Clear, Return to library in the
  side panel; the view is never saved or restored.
2026-10-29 14:11:51 -07:00
Vito Sansevero 77b5eef422 Merge remote-tracking branch 'origin/main' into fix/501-removal-mark-gaps
# Conflicts:
#	web/js/workspace/index.mjs
2026-10-29 13:51:19 -07:00
Vito Sansevero 245d0cdc6a fix(images): background jobs skip files hidden by the removal mark; walkers skip trash folders inside output roots (#501)
- Thumbnail jobs and the legacy scan_output_dir listing leave
  library-removed files out (one mark check, mark_hides).
- Missing-file links also forgets removal marks that hide nothing any
  more (file gone from a folder that exists, or changed), only under a
  readable, non-empty source, re-checked at apply.
- The sidebar Images, Favorites and Needs review counts and the
  collection counts are read again when an image_delete job ends.
- utils/media_folders.py: one skip for thumbnails and trash folders
  (.Trash, .Trash-<uid>, .Trashes, $RECYCLE.BIN) used by every walker,
  the image monitor and the delete review.
2026-10-29 13:25:23 -07:00
25 changed files with 1085 additions and 50 deletions
+30 -3
View File
@@ -2487,9 +2487,8 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
`$RECYCLE.BIN` (any case), as none descends `thumbnails`: the workspace and legacy
listings (`_walk_media`, `_settled_entry`), the metadata scan and duplicate find
(`_collect_media_files`), the legacy auto-tag listing (`_collect_scan_files`) and the
delete review (`trash-folder`). The image monitor tests every folder of the new file's
absolute path, so it also ignores outputs written into a source that itself lies
inside such a folder. Tests: `tests/test_media_folders.py`,
delete review (`trash-folder`), and the image monitor, which tests the folders below the
watched folder holding the new file (a source named like a trash folder is still watched). Tests: `tests/test_media_folders.py`,
`tests/test_image_removal_gaps.py`.
- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the
freedesktop Trash specification (home trash on the same mount, else the mount's
@@ -2520,6 +2519,34 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
a read-only image and an unwritable folder (design and probe:
`docs/egm/499-macos-trash/`, local).
- **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; choosing a category or tags while it is on
(toolbar, Refine this view) ends the view. 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
+27 -1
View File
@@ -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
+5
View File
@@ -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(
+3
View File
@@ -78,6 +78,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:
@@ -349,6 +350,7 @@ class PromptManagerAPI(
LoraImportRoutesMixin,
ImageCategoryRoutesMixin,
ImageDeleteMixin,
ImageReturnMixin,
SourceRescopeMixin,
):
"""REST API handler for PromptManager operations and web interface.
@@ -728,6 +730,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)
+34 -12
View File
@@ -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,26 @@ 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):
extra = {} if returned is None else {"returned": returned}
async for payload in self._scan_images_events(job, output_dirs, **extra):
job.publish(payload)
final = payload.get("type")
state = SCAN_FINAL_STATES.get(final, "finished")
@@ -1446,7 +1461,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 +1499,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),
+7 -5
View File
@@ -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 (
+148
View File
@@ -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]
+23 -3
View File
@@ -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=<id>`` (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,
@@ -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);
});
}
@@ -0,0 +1,143 @@
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');
});
test('choosing a category in the removed view ends the view and lists the category', async ({ page }) => {
await openImages(page);
await removeFromLibrary(page, 1);
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
// A removed file has no category: the view ends instead of refusing the listing.
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(page.locator('#result-count')).not.toContainText('removed');
});
+1
View File
@@ -122,6 +122,7 @@
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
"POST /prompt_manager/images/link",
"POST /prompt_manager/images/removed/return",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/jobs/autotag",
+91
View File
@@ -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);
});
+2
View File
@@ -3020,6 +3020,8 @@ class TestSharedListing(ImageAPITestCase):
"limit",
"has_more",
"next_cursor",
# #498: Show removed images (N).
"removed_total",
},
)
_, folders = await self._json("GET", "/prompt_manager/gallery/subfolders")
+10
View File
@@ -239,6 +239,16 @@ class TestWalkersSkipTrashFolders(_GapCase):
monitor.is_image_file(os.path.join(base, folder, "files", "a.png"))
)
def test_the_image_monitor_watches_a_root_named_like_a_trash_folder(self):
monitor = ImageGenerationHandler.__new__(ImageGenerationHandler)
monitor.supported_extensions = (".png",)
root = os.path.join(str(self.output_dir), ".Trash-1000")
monitor.watched_roots = (root,)
self.assertTrue(monitor.is_image_file(os.path.join(root, "sub", "a.png")))
self.assertFalse(
monitor.is_image_file(os.path.join(root, "sub", ".Trashes", "a.png"))
)
async def test_the_workspace_listing(self):
self.seed()
self.assertEqual(await self.listed(), ["kept.png"])
+244
View File
@@ -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"])
+6
View File
@@ -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,
+24 -2
View File
@@ -147,6 +147,10 @@ class ImageGenerationHandler(FileSystemEventHandler):
# Settle check: the file size must be unchanged across two reads
self.settle_interval = SETTLE_INTERVAL_SECONDS
# The folders this handler is scheduled on (#501): the folder skip only
# tests the folders below them, so a source named like a trash folder
# is still watched.
self.watched_roots = ()
self.settle_attempts = SETTLE_ATTEMPTS
# Images scheduled but not yet processed: path -> (prompt snapshot, due time).
@@ -433,11 +437,28 @@ class ImageGenerationHandler(FileSystemEventHandler):
"""
# Previews are derivatives, and a trash folder inside an output
# folder holds thrown-away files (#501): neither is a new output.
folders = filepath.replace("\\", "/").split("/")[:-1]
if in_skipped_folder(folders):
if in_skipped_folder(self._folders_below_root(filepath)):
return False
return filepath.lower().endswith(self.supported_extensions)
def _folders_below_root(self, filepath: str) -> list:
"""The folder names of *filepath* below the longest watched root.
Without a watched root holding it (a handler used on its own), every
folder of the path counts.
"""
text = os.path.abspath(filepath)
holding = [
root
for root in getattr(self, "watched_roots", ())
if os.path.normcase(text).startswith(
os.path.normcase(os.path.join(root, ""))
)
]
if holding:
text = text[len(os.path.join(max(holding, key=len), "")) :]
return text.replace("\\", "/").split("/")[:-1]
def process_new_image(self, image_path: str, prompt_snapshot=None):
"""Process a newly created image file for gallery integration.
@@ -1238,6 +1259,7 @@ class ImageMonitor:
handler.review_recorder = self.review_recorder
handler.thumbnail_queue = self.thumbnail_queue
handler.output_listener = self.output_listener
handler.watched_roots = tuple(os.path.abspath(d) for d in valid_directories)
observer = Observer()
scheduled = []
for output_dir in valid_directories:
+3 -2
View File
@@ -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', ''],
+8 -1
View File
@@ -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);
}
/**
+5 -3
View File
@@ -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;
+97
View File
@@ -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;
}
+21 -1
View File
@@ -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 = null,
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;
+24 -7
View File
@@ -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;
}
+59 -8
View File
@@ -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(); }});
@@ -673,6 +722,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (savedScopePending && !discoverySettled) { updateScope(); return; }
settleSavedScope();
updateScope();
// #498: a removed file has no category or tags, so choosing one (toolbar, Refine, a tag chip) ends the view.
if (filters.removed && (filters.category || filters.tags.length)) filters.removed = false;
stream.reset(filters, {load:!recovering});
}
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
+5 -2
View File
@@ -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).