Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3a3d4a9c | ||
|
|
af22543309 | ||
|
|
e42248afa4 | ||
|
|
be1fea17f0 | ||
|
|
dcd09dac0b | ||
|
|
4dd996387f | ||
|
|
13aaced3e2 | ||
|
|
907daa5fad | ||
|
|
f71d64de85 | ||
|
|
5917f2df6d | ||
|
|
5ee76fc3a2 | ||
|
|
ed9574febd | ||
|
|
77b5eef422 | ||
|
|
245d0cdc6a |
+30
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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', ''],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user