Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87166539e8 | ||
|
|
23c01455fa | ||
|
|
a6d6129ec6 | ||
|
|
e8d96a0f55 | ||
|
|
d0134b2d13 | ||
|
|
9af3271dbb |
+73
-3
@@ -1402,6 +1402,13 @@ seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
|
||||
Tag) and asserts that every row reserves the tallest footer and that their own footer
|
||||
is shorter than the row it reserves.
|
||||
|
||||
### Overview counts (#547)
|
||||
|
||||
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
|
||||
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
|
||||
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
|
||||
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
|
||||
|
||||
### Images page controls and recorded deviations (#249 PR C)
|
||||
|
||||
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
|
||||
@@ -2487,9 +2494,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 +2526,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
|
||||
@@ -2901,6 +2935,42 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete
|
||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
|
||||
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
|
||||
|
||||
### Prompt page heading on one row (#524)
|
||||
|
||||
**D164** (beyond the concept, which never has four heading actions beside a long title): from 951px, the concept's
|
||||
sidebar breakpoint, the prompt page's heading actions keep one row and the title takes the remaining width, clamped to
|
||||
two lines with its full text in the DOM and in `title`. Below 951px the concept's wrapping and stacked heading is
|
||||
unchanged. A short title lays out as the concept's, so the history concept comparison is unaffected. A deleted prompt's
|
||||
page has no "Prompt history · …" description: it is emptied and hidden while the gone message shows.
|
||||
|
||||
### Delete review and prompt-page polish (#512)
|
||||
|
||||
Found in the live test of #505/#507/#509. **D163** (beyond the concept, maintainer ruling 2026-10-29: delete was hard to
|
||||
find on a prompt's page): the prompt page heading ends with a **"⋯" More menu** button after Send to ComfyUI
|
||||
(`more-menu.mjs`: `aria-haspopup="menu"`, `aria-expanded`; Enter, Space or ArrowDown opens it on its first item, ArrowUp
|
||||
on its last; arrows, Home and End move; Escape closes it and returns focus to ⋯; Tab, an outside press or a choice
|
||||
closes it). It holds one danger item, "Delete prompt…", which opens the same review as Edit metadata's Delete prompt…
|
||||
(D161, kept) and returns focus to ⋯. The menu uses the concept's button, surface, border and toast shadow, opens
|
||||
right-aligned under ⋯, and opens rightwards where the heading wraps it to the left edge (390px). It is held with Edit
|
||||
metadata while the page's review or write runs, and disabled on a gone prompt's page.
|
||||
|
||||
Also:
|
||||
- The Delete… review leaves out rows with nothing in them ("Delete permanently: 0 …", "Archive: 0 …"), close-only
|
||||
reviews included.
|
||||
- A review opened from a prompt's page is titled "Delete this prompt" or "Archive this prompt" (following the choice),
|
||||
and its close-only sentence reads "This prompt cannot be deleted or archived."
|
||||
- An archived prompt that is another prompt's negative is named so: "1 prompt used as another prompt's negative:
|
||||
hidden, restorable" (or "N prompts in a saved pair (M used as another prompt's negative)"), with the sentence that its
|
||||
variant stays.
|
||||
- A prompt page whose prompt no longer exists (404, after a delete or from a stale link) reads "This prompt no longer
|
||||
exists. It may have been deleted." with "Open the Prompt library", no Retry; Edit metadata and ⋯ are disabled.
|
||||
- Item 5 of #512 does not fail on the code after #505: the archived shelf's button reads "← Prompt library", and under a
|
||||
remembered search the heading's count equals what the archived shelf lists. A regression test pins both.
|
||||
|
||||
Tests: `tests/js/workspace-library-delete.test.js` (#512) and `tests/browser/workspace-library-delete.spec.mjs` (#512
|
||||
cases: the menu by mouse, keyboard and at 390px, the gone page, the titles, the count under a search). JS only: no
|
||||
restart.
|
||||
|
||||
### No 1–5 prompt rating in the workspace (#447)
|
||||
|
||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -421,7 +421,8 @@ export function measureInventory(scope) {
|
||||
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
||||
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
||||
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
||||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
// D163: a closed menu's items are not heading actions.
|
||||
buttons:[...actions.querySelectorAll('button')].filter(node=>!node.closest('[role="menu"]')).map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||||
}
|
||||
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
||||
|
||||
@@ -26,7 +26,8 @@ const SECTIONS=['heading','history','noPageScroll'];
|
||||
*/
|
||||
export function mapHistoryConcept(reference) {
|
||||
const {history}=reference.oracle;
|
||||
return {...reference,history:{...history,
|
||||
// D163 (#512): the heading as laid out with the More menu button (the oracle's heading).
|
||||
return {...reference,heading:reference.oracle.heading,history:{...history,
|
||||
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
|
||||
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
|
||||
summary:history.summary,
|
||||
@@ -120,11 +121,14 @@ async function conceptAsRecorded(page) {
|
||||
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
|
||||
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
|
||||
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
|
||||
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
|
||||
// D163 (#512): the heading ends with the "⋯" More menu button, drawn as the concept's Edit metadata button.
|
||||
const edit=[...document.querySelectorAll('#main > .heading .heading-actions > button')].find(node=>node.textContent.trim()==='Edit metadata');
|
||||
const moreButton=edit.cloneNode(false);moreButton.removeAttribute('data-action');moreButton.textContent='⋯';edit.parentElement.append(moreButton);
|
||||
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived,moreButton};
|
||||
},SHOW_ARCHIVED);
|
||||
await settle(page);
|
||||
return ()=>page.evaluate(()=>{
|
||||
const {timeline,order,texts,archived}=window.__historyOracle;
|
||||
const {timeline,order,texts,archived,moreButton}=window.__historyOracle;moreButton.remove();
|
||||
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
||||
for(const [span,text] of texts)span.textContent=text;
|
||||
archived.remove();delete window.__historyOracle;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -602,7 +602,8 @@ async function staleSourceRecovery(page, request, roots) {
|
||||
const pages = [], inspections = [], options = [], mutations = [];
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.endsWith('/images/output')) pages.push(url);
|
||||
// limit=1 is a sidebar count read (Overview re-reads it on a visit, #547), not a page of images.
|
||||
if (url.pathname.endsWith('/images/output') && url.searchParams.get('limit') !== '1') pages.push(url);
|
||||
if (url.pathname.includes('/images/inspect/')) inspections.push(url);
|
||||
if (request.method() !== 'GET') mutations.push(url.pathname);
|
||||
});
|
||||
|
||||
@@ -50,7 +50,8 @@ test('Delete… reviews the selection, deletes the unpaired, archives the paired
|
||||
await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts');
|
||||
const grid=review(page).locator('dl.meta-grid > div');
|
||||
await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']);
|
||||
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt in a saved pair: hidden, restorable',
|
||||
// #512: Cat portrait is also another prompt's negative in the fixture, so the review says so.
|
||||
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair',"1 prompt used as another prompt's negative: hidden, restorable",
|
||||
'1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']);
|
||||
await expect(review(page).locator('p.small')).toHaveCount(3);
|
||||
await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/);
|
||||
@@ -81,7 +82,7 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await openReview(page);
|
||||
await expect(review(page)).toHaveAccessibleName('Archive selected prompts');
|
||||
// The archive reason is the saved pair, for one or many prompts (live note on #470): never negative variants.
|
||||
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText('2 prompts in a saved pair: hidden, restorable');
|
||||
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText("2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable");
|
||||
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 2 prompts.');
|
||||
await expect(cards(page)).toHaveCount(before.length-2);
|
||||
@@ -300,7 +301,8 @@ test('the page\'s delete holds one review at a time, Escape returns focus, and a
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(page.locator('.library-archived-notice')).toBeVisible();
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
|
||||
// #512: the page's review names its one prompt.
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('This prompt cannot be deleted or archived.');
|
||||
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -459,3 +461,148 @@ test('a failed review keeps the selection, and a selection nothing can change op
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
expect(byText(await items(request),'Library extra 0000')).toBeDefined();
|
||||
});
|
||||
|
||||
// #512 (live test of #505/#507/#509): the prompt page's "⋯" More menu (D163), a gone prompt's page, single-prompt titles.
|
||||
const more=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'More actions for this prompt',exact:true});
|
||||
const moreMenu=page=>page.getByRole('menu',{name:'More actions for this prompt'});
|
||||
test('the prompt page\'s More menu opens Delete prompt…, by mouse and keyboard, and closes on Escape or outside (#512 D163)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
const heading=page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button');
|
||||
await expect(heading).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
|
||||
await expect(more(page)).toHaveAttribute('aria-haspopup','menu');await expect(more(page)).toHaveAttribute('aria-expanded','false');
|
||||
await expect(moreMenu(page)).toBeHidden();
|
||||
// Keyboard: Enter opens on the first item; Escape closes and returns focus.
|
||||
await more(page).focus();await page.keyboard.press('Enter');
|
||||
await expect(more(page)).toHaveAttribute('aria-expanded','true');
|
||||
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
|
||||
await expect(item).toBeFocused();await expect(item).toHaveClass(/\bdanger\b/);
|
||||
await page.keyboard.press('ArrowDown');await expect(item).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toBeFocused();await expect(more(page)).toHaveAttribute('aria-expanded','false');
|
||||
// ArrowUp opens on the last item; ArrowUp, Home and End move within it (one item here); Tab closes it.
|
||||
await page.keyboard.press('ArrowUp');await expect(item).toBeFocused();
|
||||
for(const key of ['ArrowUp','Home','End']) {await page.keyboard.press(key);await expect(item).toBeFocused();}
|
||||
await page.keyboard.press('Tab');await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toHaveAttribute('aria-expanded','false');
|
||||
// Space opens too; an outside press closes it.
|
||||
await more(page).focus();await page.keyboard.press(' ');await expect(item).toBeFocused();
|
||||
await page.locator('#workspace-main h1').click();await expect(moreMenu(page)).toBeHidden();
|
||||
// Choosing it opens the page's review, titled for this prompt; Cancel returns focus to ⋯.
|
||||
await more(page).click();await item.click();
|
||||
await expect(moreMenu(page)).toBeHidden();
|
||||
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(review(page).locator('#modal-title')).toHaveText('Delete this prompt');
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(review(page)).toHaveCount(0);await expect(more(page)).toBeFocused();
|
||||
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
|
||||
});
|
||||
|
||||
test('the More menu fits a 390px viewport and works by touch (#512 D163)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await more(page).click();
|
||||
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
|
||||
await expect(item).toBeVisible();
|
||||
const box=await moreMenu(page).boundingBox();
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390);
|
||||
await item.click();
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 1 prompt.');
|
||||
await expect(page.locator('.library-archived-notice')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a deleted prompt\'s page says it no longer exists, with the way back and no Retry (#512)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await deleteFromPage(page,dual.id);
|
||||
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.');
|
||||
await expect(page.getByRole('button',{name:'Retry prompt history'})).toHaveCount(0);
|
||||
await expect(editMetadata(page)).toBeDisabled();await expect(more(page)).toBeDisabled();
|
||||
await page.getByRole('button',{name:'Open the Prompt library',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
});
|
||||
|
||||
// #524 (live test of #522): a long title clamps beside the heading actions, which stay on one row at desktop widths.
|
||||
const LONG_TITLE='masterpiece, high quality, very_awa, newest, absurdres, highres, depth_of_field, detailed eyes, realistic eyes, fake eyelashes, black eyes, white_nails, 1girl, solo, black bob_cut, upper body';
|
||||
async function openLongTitle(page,request,width) {
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await page.route(new RegExp(`/prompt_manager/library/${dual.id}\\?`),async route=>{
|
||||
const response=await route.fetch(),json=await response.json();
|
||||
await route.fulfill({response,json:{...json,positive:{...json.positive,title:LONG_TITLE}}});
|
||||
});
|
||||
await page.setViewportSize({width,height:900});
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(LONG_TITLE);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth)).toBe(true);
|
||||
return page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button');
|
||||
}
|
||||
for(const width of [1440,951]) test(`a long title clamps and the heading actions stay on one row at ${width}px (#524)`,async({page,request})=>{
|
||||
const actions=await openLongTitle(page,request,width);
|
||||
await expect(actions).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
|
||||
const tops=await actions.evaluateAll(nodes=>nodes.map(node=>Math.round(node.getBoundingClientRect().top)));
|
||||
expect(new Set(tops).size).toBe(1);
|
||||
const h1=page.locator('#workspace-main h1');
|
||||
const shape=await h1.evaluate(node=>({scroll:node.scrollHeight,client:node.clientHeight,line:parseFloat(getComputedStyle(node).lineHeight),title:node.title}));
|
||||
expect(shape.scroll).toBeGreaterThan(shape.client);
|
||||
expect(shape.client).toBeLessThanOrEqual(Math.ceil(shape.line*2));
|
||||
// The clamp hides pixels only: the whole title stays the text and shows on hover.
|
||||
expect(shape.title).toBe(LONG_TITLE);
|
||||
// The buttons do not shrink: no label is cut or broken onto a second line.
|
||||
const broken=await actions.evaluateAll(nodes=>nodes.filter(node=>node.scrollWidth>node.clientWidth||node.getBoundingClientRect().height>44).length);
|
||||
expect(broken).toBe(0);
|
||||
const [h1Box,firstBox]=[await h1.boundingBox(),await actions.first().boundingBox()];
|
||||
expect(h1Box.x+h1Box.width).toBeLessThanOrEqual(firstBox.x);
|
||||
});
|
||||
|
||||
test('a long title at 390px keeps the concept\'s stacked heading, not clamped, inside the viewport (#524)',async({page,request})=>{
|
||||
const actions=await openLongTitle(page,request,390);
|
||||
const h1=await page.locator('#workspace-main h1').evaluate(node=>({scroll:node.scrollHeight,client:node.clientHeight}));
|
||||
expect(h1.scroll).toBeLessThanOrEqual(h1.client);
|
||||
const right=await actions.evaluateAll(nodes=>Math.max(...nodes.map(node=>node.getBoundingClientRect().right)));
|
||||
expect(right).toBeLessThanOrEqual(390);
|
||||
});
|
||||
|
||||
test('a deleted prompt\'s page has no history subtitle, and a page that loads shows it (#524)',async({page,request})=>{
|
||||
const list=await items(request),dual=byText(list,'Dual role portrait'),dog=byText(list,'Dog landscape');
|
||||
const description=page.locator('#workspace-main > .heading p.muted');
|
||||
await deleteFromPage(page,dual.id);
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.');
|
||||
await expect(description).toBeHidden();await expect(description).toHaveText('');
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dog.id}`);
|
||||
await expect(description).toHaveText('Prompt history · one positive, one shared gallery');await expect(description).toBeVisible();
|
||||
});
|
||||
|
||||
test('the archived shelf\'s heading count and its list agree under a remembered search (#512 item 5)',async({page,request})=>{
|
||||
const all=await items(request);
|
||||
const dual=byText(all,'Dual role portrait'),dog=byText(all,'Dog landscape');
|
||||
await archiveByApi(request,[dual.id]);
|
||||
await page.goto(route);
|
||||
// A search that matches a shelf prompt but not the archived one.
|
||||
await page.getByRole('searchbox').fill('landscape');
|
||||
await expect(card(page,dog.id)).toHaveCount(1);await expect(card(page,dual.id)).toHaveCount(0);
|
||||
const matching=(await(await request.get('/prompt_manager/library?limit=100&shelf=archived&text=landscape')).json()).items.length;
|
||||
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
|
||||
// Leave and come back: the remembered search is shown and still counts the same.
|
||||
await page.goto('/prompt_manager/workspace#/overview');await page.goto(route);
|
||||
await expect(page.getByRole('searchbox')).toHaveValue('landscape');
|
||||
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
|
||||
await showArchived(page).click();
|
||||
await expect(backToLibrary(page)).toBeVisible();
|
||||
if(matching) await expect(cards(page)).toHaveCount(matching);
|
||||
else await expect(page.locator('.library-status')).toHaveText('No archived prompts match.');
|
||||
await page.getByRole('searchbox').fill('');
|
||||
await expect(card(page,dual.id)).toHaveCount(1);
|
||||
await backToLibrary(page).click();
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
|
||||
});
|
||||
|
||||
@@ -540,8 +540,9 @@ test('new from Images cancel preserves browse',async({page})=>{
|
||||
|
||||
test('history heading follows the concept and sends the selected saved pair',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
|
||||
const actions=headingActions(page).locator('button:not([role="menuitem"])');
|
||||
// D163 (#512): the More menu button follows Send to ComfyUI.
|
||||
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
|
||||
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
|
||||
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
|
||||
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
|
||||
|
||||
@@ -176,7 +176,7 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
|
||||
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
|
||||
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&url.searchParams.get('limit')!=='1')sorts.push(url.searchParams.get('sort'));});// limit=1 is a sidebar count read, not the shelf
|
||||
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
|
||||
@@ -60,6 +60,10 @@ test.afterEach(async ({ page, browserName }) => {
|
||||
|
||||
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
|
||||
const before = await (await request.get('/prompt_manager/stats')).json();
|
||||
const total = async path => (await (await request.get(`/prompt_manager/${path}`)).json()).total;
|
||||
const counts = [['Prompts', await total('library?limit=1')],
|
||||
['Images', await total('images/output?mode=workspace&limit=1')],
|
||||
['Tags', await total('vocabulary/tags?limit=1')]];
|
||||
const mutations = [];
|
||||
page.on('request', request => {
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
|
||||
@@ -69,10 +73,11 @@ test('workspace starts with real persisted statistics without mutating data', as
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
|
||||
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
|
||||
for (const [label, count] of counts) await expect(statistic(page, label)).toHaveText(String(count));
|
||||
// #547: a card and the sidebar badge of the page it names show one number.
|
||||
for (const [route, count] of [['library', counts[0][1]], ['images', counts[1][1]], ['tags', counts[2][1]]]) {
|
||||
await expect(page.locator(`aside.sidebar nav [data-route="${route}"] .nav-count`)).toHaveText(String(count));
|
||||
}
|
||||
expect(before.stats.total_prompts).toBe(2);
|
||||
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
|
||||
expect(mutations).toEqual([]);
|
||||
await expect(page.locator('iframe')).toHaveCount(0);
|
||||
@@ -287,25 +292,65 @@ test('unavailable host palette falls back to the system theme and tracks system
|
||||
});
|
||||
|
||||
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
|
||||
const sources = ['**/prompt_manager/library?limit=1', '**/prompt_manager/images/output?mode=workspace&limit=1',
|
||||
'**/prompt_manager/vocabulary/tags?limit=1'];
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/stats', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
for (const source of sources) {
|
||||
await page.route(source, async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
}
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
|
||||
await page.unroute('**/prompt_manager/stats');
|
||||
await page.route('**/prompt_manager/stats', route => route.fulfill({
|
||||
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
|
||||
}));
|
||||
for (const source of sources) {
|
||||
await page.unroute(source);
|
||||
await page.route(source, route => route.fulfill({ json: { success: true, total: 0, items: [], images: [], tags: [] } }));
|
||||
}
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
|
||||
for (const label of ['Prompts', 'Images', 'Tags']) {
|
||||
await expect(statistic(page, label)).toHaveText('0');
|
||||
}
|
||||
await expect(page.locator('#workspace-main .empty-state')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a slow or failed Images read leaves the other Overview cards and retries only itself', async ({ page }) => {
|
||||
const images = '**/prompt_manager/images/output?mode=workspace&limit=1';
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route(images, async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
await expect(statistic(page, 'Images')).toHaveText('–');
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
await page.unroute(images);
|
||||
let libraryReads = 0;
|
||||
page.on('request', request => { if (new URL(request.url()).pathname === '/prompt_manager/library') libraryReads++; });
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
|
||||
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
|
||||
expect(libraryReads).toBe(0);
|
||||
});
|
||||
|
||||
test('opening Overview reads each count once beside the sidebar', async ({ page }) => {
|
||||
let reads = 0;
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === null) reads++;
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
|
||||
@@ -324,11 +369,9 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
let completed;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
const finished = new Promise(resolve => { completed = resolve; });
|
||||
await page.route('**/prompt_manager/stats', async route => {
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ json: {
|
||||
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
|
||||
} }).catch(() => {});
|
||||
await route.fulfill({ json: { success: true, total: 999, items: [] } }).catch(() => {});
|
||||
completed();
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
@@ -351,7 +394,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
|
||||
let reads = 0;
|
||||
page.on('request', request => {
|
||||
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
|
||||
if (new URL(request.url()).pathname === '/prompt_manager/vocabulary/tags') reads++;
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
|
||||
@@ -8,7 +8,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
|
||||
} });
|
||||
expect(saved.ok()).toBe(true);
|
||||
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
|
||||
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
|
||||
const workspace = await context.newPage();
|
||||
const errors = [];
|
||||
workspace.on('pageerror', error => errors.push(error.message));
|
||||
@@ -16,7 +16,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
await workspace.goto('/prompt_manager/workspace#/overview');
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
|
||||
.toHaveText(stats.total_prompts.toLocaleString());
|
||||
.toHaveText(library.total.toLocaleString());
|
||||
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
const imageResponse = workspace.waitForResponse(response => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -42,8 +42,13 @@ test('the grid shows each row only when it applies, with protected titles in sel
|
||||
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
// #512: a row with nothing in it is left out.
|
||||
const archiveOnly = reviewOf(preview({archive: [2]}));
|
||||
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']);
|
||||
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Archive']);
|
||||
const deleteOnly = reviewOf(preview({delete: [1]}));
|
||||
assert.deepEqual(reviewGrid(deleteOnly).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
|
||||
const keptOnly = reviewOf(preview({protected: [7]}));
|
||||
assert.deepEqual(reviewGrid(keptOnly, id => names[id]), [['Protected (kept)', '1: Seven']]);
|
||||
const off = reviewOf(preview({delete: [1], backup: 'off'}));
|
||||
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
|
||||
});
|
||||
@@ -136,7 +141,7 @@ test('reviewOf keeps only well-formed ids and counts from an answer', async () =
|
||||
not_found: 'x', image_links: 2, backup: 'maybe', not_archived: -3});
|
||||
assert.deepEqual(review, {shelf: 'active', notArchived: 0, epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
|
||||
imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {},
|
||||
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'});
|
||||
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive', archiveOnly: []});
|
||||
});
|
||||
|
||||
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
|
||||
@@ -291,3 +296,32 @@ test('the outcome names prompts restored since the review', async () => {
|
||||
assert.equal(none.toast, 'Nothing was deleted. 1 was restored since the review and kept. 1 changed since the review and ' +
|
||||
'was not deleted. The Library was reloaded.');
|
||||
});
|
||||
|
||||
// #512 (live test of #505/#507/#509): the review names a single prompt, and a negative selected without its positive.
|
||||
test('a review from a prompt\'s own page names that prompt', async () => {
|
||||
const {reviewOf, reviewTitle, blockedReason} = await source;
|
||||
const single = review => ({...review, single: true});
|
||||
assert.equal(reviewTitle(single(reviewOf(preview({delete: [1]})))), 'Delete this prompt');
|
||||
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2]})))), 'Archive this prompt');
|
||||
const kept = single(reviewOf(preview({protected: [3]})));
|
||||
assert.equal(reviewTitle(kept), 'Delete this prompt');
|
||||
assert.equal(blockedReason(kept), 'This prompt cannot be deleted or archived.');
|
||||
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2], deletable: [2], review_digests: {2: 'c'.repeat(64)}}),
|
||||
'archived'))), 'Delete this prompt');
|
||||
});
|
||||
|
||||
test('a negative used by another prompt is named as such when it is archived', async () => {
|
||||
const {reviewOf, reviewGrid, reviewSentences} = await source;
|
||||
const negative = reviewOf(preview({archive: [5], deletable: []}));
|
||||
assert.deepEqual(reviewGrid(negative), [['Archive', "1 prompt used as another prompt's negative: hidden, restorable"]]);
|
||||
assert.deepEqual(reviewSentences(negative), ["Prompts used as another prompt's negative are archived instead of deleted, so " +
|
||||
"that prompt's variant stays. To bring them back, press Show archived prompts, select them and press Restore."]);
|
||||
const mixed = reviewOf(preview({archive: [2, 5], deletable: [2], review_digests: {2: 'c'.repeat(64)}}));
|
||||
assert.deepEqual(reviewGrid(mixed)[0], ['Archive', "2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable"]);
|
||||
// A paired prompt whose review digest is missing is not called another prompt's negative (review M1).
|
||||
const noDigest = reviewOf(preview({archive: [2], deletable: [2], review_digests: {}}));
|
||||
assert.deepEqual(reviewGrid(noDigest), [['Archive', '1 prompt in a saved pair: hidden, restorable']]);
|
||||
const sentences = reviewSentences(mixed);
|
||||
assert.match(sentences[0], /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted/);
|
||||
assert.match(sentences[1], /^Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant stays\.$/);
|
||||
});
|
||||
|
||||
@@ -105,17 +105,29 @@ test('a job of the Activity page is its own deep link', async () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
|
||||
const { summarizeStats } = await source;
|
||||
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
|
||||
['Prompts', 0], ['Images', 12], ['Tags', 3],
|
||||
]);
|
||||
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
|
||||
{total_prompts:'2', total_images:0, total_tags:0},
|
||||
{total_prompts:Infinity, total_images:0, total_tags:0},
|
||||
{total_prompts:0, total_images:1.5, total_tags:0}]) {
|
||||
assert.throws(() => summarizeStats(stats), /statistics/i);
|
||||
}
|
||||
test('Overview cards show the count of the page they name', async () => {
|
||||
const { summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES } = await source;
|
||||
assert.deepEqual(OVERVIEW_COUNT_ROUTES, ['library', 'images', 'tags']);
|
||||
const counts = new Map([['library', 64], ['images', 122], ['tags', 306], ['favorites', 8]]);
|
||||
assert.deepEqual(summarizeOverviewCounts(counts), {
|
||||
cards: [['Prompts', 64], ['Images', 122], ['Tags', 306]], pending: false, failed: false, empty: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('Overview tells loading, failed and really empty counts apart', async () => {
|
||||
const { summarizeOverviewCounts } = await source;
|
||||
const nothing = summarizeOverviewCounts(new Map());
|
||||
assert.deepEqual([nothing.pending, nothing.failed, nothing.empty], [true, false, false]);
|
||||
assert.deepEqual(nothing.cards, [['Prompts', null], ['Images', null], ['Tags', null]]);
|
||||
// A slow Images read does not hold back the cards already read.
|
||||
const partial = summarizeOverviewCounts(new Map([['library', 0], ['tags', 0]]));
|
||||
assert.deepEqual(partial.cards, [['Prompts', 0], ['Images', null], ['Tags', 0]]);
|
||||
assert.deepEqual([partial.pending, partial.failed, partial.empty], [true, false, false]);
|
||||
const failed = summarizeOverviewCounts(new Map([['library', 2], ['images', null], ['tags', 3]]));
|
||||
assert.deepEqual([failed.pending, failed.failed, failed.empty], [false, true, false]);
|
||||
assert.deepEqual(failed.cards[1], ['Images', null]);
|
||||
const empty = summarizeOverviewCounts(new Map([['library', 0], ['images', 0], ['tags', 0]]));
|
||||
assert.deepEqual([empty.pending, empty.failed, empty.empty], [false, false, true]);
|
||||
});
|
||||
|
||||
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
|
||||
|
||||
@@ -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"])
|
||||
+12
@@ -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,
|
||||
@@ -485,6 +491,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/more-menu.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
},
|
||||
"workspace/plural.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). */
|
||||
|
||||
+37
-21
@@ -1,6 +1,6 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||||
import { routes, mountRouter, summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS,
|
||||
JOB_END_COUNTS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
@@ -149,9 +149,13 @@ function mountWorkspace() {
|
||||
countRequests.get(id)?.abort();
|
||||
const request = new AbortController();
|
||||
countRequests.set(id, request);
|
||||
// A failed count reads as loading again while it is retried; a known one stays until replaced.
|
||||
if (knownCounts.get(id) === null) { knownCounts.delete(id); notifyCounts(); }
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (!request.signal.aborted) showCount(id, count);
|
||||
if (request.signal.aborted) return;
|
||||
countRequests.delete(id);
|
||||
showCount(id, count);
|
||||
}, [id]);
|
||||
}
|
||||
}
|
||||
@@ -159,7 +163,15 @@ function mountWorkspace() {
|
||||
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
knownCounts.set(id, count);
|
||||
notifyCounts();
|
||||
}
|
||||
/** #547: Overview shows these same counts, so a card and its badge cannot differ, and no read runs twice. */
|
||||
const knownCounts = new Map();
|
||||
const countListeners = new Set();
|
||||
function notifyCounts() { for (const listener of countListeners) listener(); }
|
||||
/** Re-read the given counts unless a read is already in flight. */
|
||||
function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); }
|
||||
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
|
||||
function setCount(id, count) {
|
||||
countRequests.get(id)?.abort();
|
||||
@@ -494,34 +506,38 @@ function mountWorkspace() {
|
||||
}
|
||||
tools.append(cards);
|
||||
main.append(tools);
|
||||
async function loadStats() {
|
||||
const status = element('p', 'Loading library statistics…');
|
||||
status.setAttribute('role', 'status');
|
||||
stats.replaceChildren(status);
|
||||
try {
|
||||
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
|
||||
if (view.signal.aborted) return;
|
||||
const values = summarizeStats(result.stats);
|
||||
function showStats() {
|
||||
const { cards, pending, failed, empty } = summarizeOverviewCounts(knownCounts);
|
||||
const parts = [];
|
||||
if (cards.some(([, count]) => count !== null)) {
|
||||
const list = element('dl', undefined, 'stat-grid');
|
||||
for (const [label, count] of values) {
|
||||
for (const [label, count] of cards) {
|
||||
const stat = element('div', undefined, 'stat');
|
||||
stat.append(element('dt', label), element('dd', count.toLocaleString()));
|
||||
stat.append(element('dt', label), element('dd', count === null ? '–' : count.toLocaleString()));
|
||||
list.append(stat);
|
||||
}
|
||||
stats.replaceChildren(list);
|
||||
if (values.every(([, count]) => count === 0)) {
|
||||
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (view.signal.aborted) return;
|
||||
parts.push(list);
|
||||
}
|
||||
if (pending) {
|
||||
const status = element('p', 'Loading library statistics…');
|
||||
status.setAttribute('role', 'status');
|
||||
parts.push(status);
|
||||
} else if (failed) {
|
||||
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
|
||||
alert.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
view.listen(retry, 'click', loadStats);
|
||||
stats.replaceChildren(alert, retry);
|
||||
view.listen(retry, 'click', () => refreshCounts(OVERVIEW_COUNT_ROUTES.filter(id => knownCounts.get(id) === null)));
|
||||
parts.push(alert, retry);
|
||||
} else if (empty) {
|
||||
parts.push(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
|
||||
}
|
||||
stats.replaceChildren(...parts);
|
||||
}
|
||||
loadStats();
|
||||
countListeners.add(showStats);
|
||||
view.cleanup(() => countListeners.delete(showStats));
|
||||
showStats();
|
||||
// A visit reads them again, as a generation since the last read changes the sidebar's badges too.
|
||||
freshenCounts(OVERVIEW_COUNT_ROUTES);
|
||||
}
|
||||
scope.cleanup(mountRouter(window, render));
|
||||
scope.listen(window, 'pagehide', () => scope.dispose());
|
||||
|
||||
@@ -26,6 +26,7 @@ export const COPY = Object.freeze({
|
||||
nothing: `${NOTHING} The Library was reloaded.`,
|
||||
none: 'None of the selected prompts can be deleted or archived.',
|
||||
noneArchived: 'None of the selected prompts can be deleted.',
|
||||
noneSingle: 'This prompt cannot be deleted or archived.',
|
||||
restoreStorage: 'The library was restored or moved since this page loaded. No prompt was returned to the Library.',
|
||||
restoreUnconfirmed: 'Returning the prompts could not be confirmed. The Library was reloaded.',
|
||||
restoreFailed: 'The prompts could not be returned to the Library. Try again.',
|
||||
@@ -65,6 +66,10 @@ function pairedOf(preview) {
|
||||
pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs),
|
||||
variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) },
|
||||
pairedImageLinks: count(preview?.paired_image_links), choice: 'archive',
|
||||
// #512: archive ids the server says cannot be deleted (another prompt's negative); none from an older answer.
|
||||
// The server's own list, not the digest-checked one: a deletable prompt with a bad digest is still paired.
|
||||
archiveOnly: Array.isArray(preview?.deletable)
|
||||
? [...archive].filter(id => !ids(preview.deletable).includes(id)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +92,8 @@ function plan(review) {
|
||||
|
||||
export function reviewTitle(review) {
|
||||
const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length;
|
||||
// #512: a review opened from a prompt's own page names that prompt.
|
||||
if (review.single) return deletes || !archive.length ? 'Delete this prompt' : 'Archive this prompt';
|
||||
if (onArchivedShelf(review)) return 'Delete archived prompts';
|
||||
if (!archive.length) return 'Delete selected prompts';
|
||||
if (!deletes) return 'Archive selected prompts';
|
||||
@@ -112,16 +119,23 @@ export function archivedNoticeText(variantCount) {
|
||||
/** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */
|
||||
export function reviewGrid(review, titleOf = () => null) {
|
||||
if (permanent(review)) return permanentGrid(review, titleOf);
|
||||
const rows = [
|
||||
['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`],
|
||||
// The archive reason is the saved pair (a pair's positive or negative), with or without negative variants.
|
||||
['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: hidden, restorable`],
|
||||
];
|
||||
// #512: rows with nothing in them are left out.
|
||||
const rows = [];
|
||||
if (review.delete.length) rows.push(['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`]);
|
||||
if (review.archive.length) rows.push(['Archive', archiveValue(review)]);
|
||||
keptRows(rows, review, titleOf);
|
||||
if (review.delete.length) deleteRows(rows, review, review.imageLinks);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The archive reason is the saved pair (a pair's positive or negative); #512 names another prompt's negative. */
|
||||
function archiveValue(review) {
|
||||
const total = review.archive.length, negatives = review.archiveOnly.length;
|
||||
if (negatives === total) return `${countOf(total, 'prompt')} used as another prompt's negative: hidden, restorable`;
|
||||
const detail = negatives ? ` (${negatives.toLocaleString('en-US')} used as another prompt's negative)` : '';
|
||||
return `${countOf(total, 'prompt')} in a saved pair${detail}: hidden, restorable`;
|
||||
}
|
||||
|
||||
function keptRows(rows, review, titleOf) {
|
||||
if (review.protected.length) {
|
||||
const names = protectedNames(review, titleOf);
|
||||
@@ -163,12 +177,17 @@ function permanentGrid(review, titleOf) {
|
||||
/** The review's sentences, in order, each when it applies. */
|
||||
export function reviewSentences(review) {
|
||||
if (permanent(review)) return permanentSentences(review);
|
||||
const sentences = [];
|
||||
if (review.archive.length) {
|
||||
const sentences = [], negatives = review.archiveOnly.length;
|
||||
if (review.archive.length > negatives) {
|
||||
sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' +
|
||||
'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them ' +
|
||||
'and press Delete… or Restore.');
|
||||
}
|
||||
// #512: a negative selected without its positive stays the other prompt's variant.
|
||||
if (negatives) {
|
||||
sentences.push("Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant " +
|
||||
`stays.${negatives === review.archive.length ? ' To bring them back, press Show archived prompts, select them and press Restore.' : ''}`);
|
||||
}
|
||||
if (review.delete.length) sentences.push(removeSentence(review));
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
return sentences;
|
||||
@@ -210,8 +229,9 @@ export function primaryLabel(review) {
|
||||
|
||||
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
|
||||
export function blockedReason(review) {
|
||||
if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return COPY.noneArchived;
|
||||
if (!review.delete.length && !review.archive.length) return COPY.none;
|
||||
const none = review.single ? COPY.noneSingle : null;
|
||||
if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return none ?? COPY.noneArchived;
|
||||
if (!review.delete.length && !review.archive.length) return none ?? COPY.none;
|
||||
const deletes = review.delete.length + plan(review).paired.length;
|
||||
if (deletes && review.disk && review.disk.sufficient === false) {
|
||||
return typeof review.disk.message === 'string' && review.disk.message
|
||||
@@ -372,7 +392,8 @@ export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds,
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; }
|
||||
const review = { ...reviewOf(preview), epoch };
|
||||
// #512: the review names this one prompt.
|
||||
const review = { ...reviewOf(preview), epoch, single: true };
|
||||
openDeleteReview({ scope, trigger, review, titleOf, onDismiss,
|
||||
onStart: async chosen => {
|
||||
let result = null, error = null;
|
||||
|
||||
@@ -18,8 +18,12 @@ import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
import {mountLibrarySelection} from './library-selection.mjs';
|
||||
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
|
||||
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs';
|
||||
import {createMoreMenu} from './more-menu.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// #512: what a prompt page says when its prompt no longer exists (404).
|
||||
const PROMPT_GONE='This prompt no longer exists. It may have been deleted.';
|
||||
const HISTORY_DESCRIPTION='Prompt history · one positive, one shared gallery';
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null;
|
||||
// The pair one reconciled save names, taken once by the history it opens (the concept selects it after a save, app.js 986).
|
||||
@@ -59,8 +63,9 @@ function headingActions(main,fallback) {
|
||||
}
|
||||
/** The shell heading's description (concept `p.muted`); a view mounted without one shows it at the top of `fallback`. */
|
||||
function headingDescription(main,fallback,text) {
|
||||
const description=pageHeading(main)?.description;
|
||||
if(description) description.textContent=text;else fallback.prepend(el('p',text,'muted'));
|
||||
let description=pageHeading(main)?.description;
|
||||
if(description) description.textContent=text;else {description=el('p',text,'muted');fallback.prepend(description);}
|
||||
return description;
|
||||
}
|
||||
|
||||
/** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */
|
||||
@@ -485,10 +490,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
|
||||
const body=el('div');history.append(body);
|
||||
// Concept heading (app.js a591–597): ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
|
||||
headingDescription(main,history,'Prompt history · one positive, one shared gallery');
|
||||
const description=headingDescription(main,history,HISTORY_DESCRIPTION);
|
||||
// #524 D164: at desktop widths a long title clamps beside the actions.
|
||||
pageHeading(main)?.heading.classList.add('prompt-page-heading');
|
||||
const actions=headingActions(main,history);
|
||||
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;
|
||||
// D163 (#512, maintainer 2026-10-29): a "⋯" More menu after Send to ComfyUI holds Delete prompt…, so delete is
|
||||
// findable on the page; Edit metadata keeps its own Delete prompt… (D161).
|
||||
const moreMenu=createMoreMenu({scope,label:'More actions for this prompt',items:[{label:'Delete prompt…',danger:true,onSelect:()=>deletePrompt(moreMenu.button)}]});
|
||||
actions.append(back,edit,send,moreMenu.element);
|
||||
let blocks,galleryScope,detail,contentScope,updateBranches,updateParent,current=null;
|
||||
scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();});
|
||||
// Every visit opens on the first negative, as the concept's navigate() does (app.js 1149: state.variant = 0);
|
||||
@@ -504,14 +515,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// can race the delete; aria-disabled keeps it focusable for the return.
|
||||
function holdEdit(held) {
|
||||
deleting=held;
|
||||
if(held) edit.setAttribute('aria-disabled','true');else edit.removeAttribute('aria-disabled');
|
||||
for(const button of [edit,moreMenu.button]) {
|
||||
if(held) button.setAttribute('aria-disabled','true');else button.removeAttribute('aria-disabled');
|
||||
}
|
||||
}
|
||||
function deletePrompt() {
|
||||
function deletePrompt(trigger=edit) {
|
||||
const shown=detail;if(deleting || !shown?.positive) return;
|
||||
// One review at a time, as the selection bar holds itself (a second press during the preview starts nothing).
|
||||
holdEdit(true);
|
||||
reviewPromptDelete({api,scope,trigger:edit,ids:[shown.positive.id],epoch:shown.library_epoch,
|
||||
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(edit.isConnected) edit.focus({preventScroll:true});},
|
||||
reviewPromptDelete({api,scope,trigger,ids:[shown.positive.id],epoch:shown.library_epoch,
|
||||
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(trigger.isConnected) trigger.focus({preventScroll:true});},
|
||||
onSettled:(outcome,result,failure)=>{
|
||||
holdEdit(false);
|
||||
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-delete',receipt:result ?? {phase:'unconfirmed'}});
|
||||
@@ -571,6 +584,19 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateParent();updateBranches(state);return;}
|
||||
contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null;updateParent=null;current=null;send.disabled=true;
|
||||
blocks?.dispose();blocks=null;galleryScope?.dispose();galleryScope=null;body.replaceChildren();
|
||||
// #512: a prompt that no longer exists (deleted, or a stale link) says so, with the way back; no Retry.
|
||||
// Edit metadata and ⋯ act on a loaded prompt only (review M2).
|
||||
const hadFocus=[edit,moreMenu.button].includes(document.activeElement);
|
||||
const loaded=!state.loading && !state.error;edit.disabled=!loaded;moreMenu.setDisabled(!loaded);
|
||||
// #524: a prompt that no longer exists has no history to describe.
|
||||
const gone=state.error?.status===404;description.textContent=gone?'':HISTORY_DESCRIPTION;description.hidden=gone;
|
||||
if(gone) {
|
||||
const message=el('p',PROMPT_GONE,'library-status');message.setAttribute('role','status');body.append(message);
|
||||
const open=createButton('Open the Prompt library');contentScope.listen(open,'click',()=>{window.location.hash='#/library';});body.append(open);
|
||||
// A focused control that was just disabled hands focus to the way back (review M3).
|
||||
if(hadFocus) open.focus({preventScroll:true});
|
||||
return;
|
||||
}
|
||||
if(state.loading || state.error) {
|
||||
const message=el('p',state.loading?'Loading prompt history…':'Prompt history could not be loaded.','library-status');message.setAttribute('role',state.error?'alert':'status');body.append(message);
|
||||
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
|
||||
@@ -721,7 +747,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');};
|
||||
updateParent=()=>{
|
||||
const name=displayTitle(detail.positive);
|
||||
main.querySelector('h1').textContent=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta();
|
||||
const h1=main.querySelector('h1');h1.textContent=name;h1.title=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta();
|
||||
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${name}`);blocks?.setTitle(name);
|
||||
};
|
||||
let activePairId;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
|
||||
/**
|
||||
* A "⋯" (More) menu button for a page heading (D163, #512; the concept has no menu, so it uses the concept's button
|
||||
* and surface styles). The button carries `aria-haspopup="menu"` and `aria-expanded`; Enter, Space or ArrowDown opens
|
||||
* the menu on its first item, ArrowUp on its last; ArrowUp/ArrowDown/Home/End move between items; Escape closes it and
|
||||
* returns focus to the button; Tab, an outside pointer press or choosing an item closes it. A held button
|
||||
* (`aria-disabled`) or a disabled one opens nothing. Listeners live on `scope`.
|
||||
*
|
||||
* `items`: `[{ label, danger, onSelect }]`. Returns `{ element, button, setDisabled(disabled) }`.
|
||||
*/
|
||||
let menus = 0;
|
||||
export function createMoreMenu({ scope, label, items }) {
|
||||
const id = `more-menu-${++menus}`;
|
||||
const element = document.createElement('div');
|
||||
element.className = 'more-menu';
|
||||
const button = createButton('⋯');
|
||||
button.classList.add('more-menu-button');
|
||||
button.setAttribute('aria-label', label);
|
||||
button.setAttribute('aria-haspopup', 'menu');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.setAttribute('aria-controls', id);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'more-menu-list';
|
||||
menu.id = id;
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', label);
|
||||
menu.hidden = true;
|
||||
const entries = items.map(item => {
|
||||
const entry = createButton(item.label, item.danger ? 'danger' : '');
|
||||
entry.setAttribute('role', 'menuitem');
|
||||
entry.tabIndex = -1;
|
||||
scope.listen(entry, 'click', () => { close(false); item.onSelect(); });
|
||||
return entry;
|
||||
});
|
||||
menu.append(...entries);
|
||||
element.append(button, menu);
|
||||
|
||||
const isOpen = () => !menu.hidden;
|
||||
const held = () => button.disabled || button.getAttribute('aria-disabled') === 'true';
|
||||
function open(index) {
|
||||
if (held()) return;
|
||||
menu.hidden = false;
|
||||
// Right-aligned under the button; where the heading wraps it to the left edge (390px), it opens rightwards.
|
||||
menu.style.left = menu.style.right = '';
|
||||
if (menu.getBoundingClientRect().left < 0) Object.assign(menu.style, { left: '0', right: 'auto' });
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
entries.at(index)?.focus();
|
||||
}
|
||||
function close(refocus) {
|
||||
if (!isOpen()) return;
|
||||
menu.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
if (refocus) button.focus();
|
||||
}
|
||||
function move(step) {
|
||||
const at = entries.indexOf(document.activeElement);
|
||||
entries[(at + step + entries.length) % entries.length].focus();
|
||||
}
|
||||
scope.listen(button, 'click', () => (isOpen() ? close(true) : open(0)));
|
||||
scope.listen(button, 'keydown', event => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
open(event.key === 'ArrowDown' ? 0 : -1);
|
||||
});
|
||||
scope.listen(menu, 'keydown', event => {
|
||||
const keys = { ArrowDown: () => move(1), ArrowUp: () => move(-1), Home: () => entries[0].focus(),
|
||||
End: () => entries.at(-1).focus(), Escape: () => close(true) };
|
||||
if (event.key === 'Tab') { close(false); return; }
|
||||
if (!keys[event.key]) return;
|
||||
event.preventDefault();
|
||||
keys[event.key]();
|
||||
});
|
||||
// Focus leaving the menu (a screen reader's cursor, a script) closes it too.
|
||||
scope.listen(element, 'focusout', event => { if (event.relatedTarget && !element.contains(event.relatedTarget)) close(false); });
|
||||
// An outside press closes it; capture, so a press that a page control stops still counts.
|
||||
const outside = event => { if (isOpen() && !element.contains(event.target)) close(false); };
|
||||
document.addEventListener('pointerdown', outside, true);
|
||||
scope.cleanup(() => document.removeEventListener('pointerdown', outside, true));
|
||||
return {
|
||||
element,
|
||||
button,
|
||||
setDisabled(disabled) { button.disabled = disabled; if (disabled) close(false); },
|
||||
};
|
||||
}
|
||||
+17
-11
@@ -87,15 +87,6 @@ export function resolveRoute(hash) {
|
||||
return routes.find(route => hash === `#/${route.id}`) || routes[0];
|
||||
}
|
||||
|
||||
export function summarizeStats(stats) {
|
||||
return [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]
|
||||
.map(([label, key]) => {
|
||||
const count = stats?.[key];
|
||||
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid library statistics');
|
||||
return [label, count];
|
||||
});
|
||||
}
|
||||
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
|
||||
// Favorites page's own listing, so its count is what that page lists.
|
||||
const NAV_COUNT_SOURCES = Object.freeze([
|
||||
@@ -129,9 +120,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).
|
||||
@@ -147,6 +141,18 @@ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_C
|
||||
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
|
||||
}
|
||||
|
||||
// #547: each Overview card is the count of the page it names, the same number as that page's sidebar badge.
|
||||
const OVERVIEW_CARDS = Object.freeze([['Prompts', 'library'], ['Images', 'images'], ['Tags', 'tags']]);
|
||||
export const OVERVIEW_COUNT_ROUTES = Object.freeze(OVERVIEW_CARDS.map(([, id]) => id));
|
||||
|
||||
/** Overview's state from the shell's counts: no entry is a read not settled yet, null a read that failed. */
|
||||
export function summarizeOverviewCounts(counts) {
|
||||
const cards = OVERVIEW_CARDS.map(([label, id]) => [label, counts.get(id) ?? null]);
|
||||
const pending = OVERVIEW_COUNT_ROUTES.some(id => !counts.has(id));
|
||||
const failed = OVERVIEW_COUNT_ROUTES.some(id => counts.get(id) === null);
|
||||
return { cards, pending, failed, empty: !pending && cards.every(([, count]) => count === 0) };
|
||||
}
|
||||
|
||||
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
|
||||
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
|
||||
|
||||
|
||||
@@ -482,3 +482,26 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.tagging-modal .tag-action-notice { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ca-text); font-size: 12px; /* D147 */ }
|
||||
.tagging-modal .tag-action-notice > span { flex: 1 1 16em; }
|
||||
.tag-image-preview.tag-preview-empty { display: grid; place-items: center; padding: 8px; text-align: center; font-size: 12px; color: var(--ca-muted); background: var(--ca-field); box-sizing: border-box; /* D147 */ }
|
||||
|
||||
/* D163 (#512): the prompt page's "⋯" More menu. The concept has no menu; it uses the concept's button, surface, border
|
||||
and toast shadow, opens under its button, right-aligned so it stays inside a 390px viewport. */
|
||||
.more-menu { position: relative; display: inline-flex; }
|
||||
.more-menu-list {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; display: flex; flex-direction: column; gap: 4px;
|
||||
min-width: 160px; padding: 6px; background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 6px;
|
||||
box-shadow: 0 8px 35px #0004;
|
||||
}
|
||||
.more-menu-list[hidden] { display: none; }
|
||||
.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }
|
||||
|
||||
/* D164 (#524): the prompt page has four heading actions and titles of any length, which the concept never shows. From
|
||||
the concept's sidebar breakpoint up the actions keep one row and the title takes what is left, clamped to two lines
|
||||
(its full text stays in the DOM and in `title`). A short title lays out exactly as the concept's. */
|
||||
@media (min-width: 951px) {
|
||||
.heading.prompt-page-heading > div:first-child { min-width: 0; }
|
||||
.heading.prompt-page-heading > .heading-actions { flex: 0 0 auto; }
|
||||
.heading.prompt-page-heading h1 {
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
|
||||
overflow: hidden; overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user