Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a782c9830d | ||
|
|
aa125d82e3 | ||
|
|
b12a9a14ba | ||
|
|
704db4be8a | ||
|
|
bb744550f4 | ||
|
|
2650621a1b | ||
|
|
d6371b7fad | ||
|
|
75cd9c6e13 | ||
|
|
77f08782f4 | ||
|
|
50989a7bd6 | ||
|
|
62815c9e3e | ||
|
|
a1ec65442d | ||
|
|
e52cc39cd5 | ||
|
|
eff6ece6a4 | ||
|
|
fbaf02f827 | ||
|
|
0faf2bea50 | ||
|
|
b968730cc4 | ||
|
|
d02a911748 | ||
|
|
f87f2c43d0 | ||
|
|
d0ffcccc23 | ||
|
|
bc3f09d844 | ||
|
|
e0b86712a8 | ||
|
|
6fdcbacfc1 | ||
|
|
bbc8c418de | ||
|
|
fd20968bc8 | ||
|
|
96c2a293a4 | ||
|
|
66ca988d2a | ||
|
|
da136415da | ||
|
|
e47f05e6be | ||
|
|
20dbc2df21 | ||
|
|
dd247e3b6f | ||
|
|
c416c84347 | ||
|
|
3e2339fe96 | ||
|
|
599dd828e6 | ||
|
|
52531a8be4 | ||
|
|
7ff785cb5a | ||
|
|
775e85b73a | ||
|
|
c1b5fd3fc0 | ||
|
|
e9d01c917b | ||
|
|
0d59de637e | ||
|
|
917aa838cb | ||
|
|
8e9750226d | ||
|
|
78993dcb4d | ||
|
|
c36ea7a0fb | ||
|
|
1153de26fc | ||
|
|
e14ec03945 | ||
|
|
990ebee79b | ||
|
|
d44cd198c4 | ||
|
|
7ae30212b9 | ||
|
|
94355a6311 | ||
|
|
4a7875c71d | ||
|
|
d9988f246a | ||
|
|
4186c9a053 | ||
|
|
07c16dca58 | ||
|
|
cd82cdcd1b | ||
|
|
a6927489a8 | ||
|
|
323f2617d8 | ||
|
|
53cfd8b035 | ||
|
|
f30db7713d | ||
|
|
567b06ac48 | ||
|
|
a543ecff6f | ||
|
|
4888427a1e | ||
|
|
9f55f0cc79 | ||
|
|
4022298446 | ||
|
|
6372bc7c63 | ||
|
|
d4d99b7e18 | ||
|
|
ebd43a8140 | ||
|
|
72df65113a |
@@ -21,15 +21,19 @@ env:
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
|
||||
# Time limit rule as in test.yml: ceil(1.5 x the slowest of three recent green runs on main) + 3
|
||||
# minutes, never under 5. Measured 2026-10-26 in test.yml on runs 40257217681 (3be0ada),
|
||||
# 40240706568 (fb9134f), 40228167328 (f9835aa), before these legs moved here in #420:
|
||||
# slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
|
||||
# (macos 6m14s-8m30s, windows 11m42s-15m54s)
|
||||
# minutes, never under 5, timed as whole-job wall time. One matrix job, so the slowest leg of each
|
||||
# run counts (tests/vendor/ci-workflow.test.mjs recomputes it, #493).
|
||||
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
|
||||
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
|
||||
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
|
||||
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
|
||||
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
|
||||
# after three green runs.
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 27
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -51,5 +55,52 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms
|
||||
# and a test database's create-to-delete about 111 ms; on the D: work
|
||||
# disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their
|
||||
# databases under TEMP, so it moves to a folder on D: (measured in #487,
|
||||
# run 40915866444). The runner's TEMP was an 8.3 short name
|
||||
# (C:\Users\RUNNER~1\...), so the new one is spelled by its short name too
|
||||
# and paths still have to be resolved. D: makes no short names by default
|
||||
# (run 40922502209), so they are switched on for it first.
|
||||
- name: Keep test files on the work disk (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
fsutil 8dot3name set D: 0
|
||||
$folder = New-Item -ItemType Directory -Force -Path (Join-Path $env:RUNNER_TEMP "PromptManagerTests")
|
||||
$short = (New-Object -ComObject Scripting.FileSystemObject).GetFolder($folder.FullName).ShortPath
|
||||
Write-Output "test TEMP: $short (long name $($folder.FullName))"
|
||||
if ($short -eq $folder.FullName) { Write-Output "::warning::D: gave the test TEMP no 8.3 short name" }
|
||||
"TEMP=$short" >> $env:GITHUB_ENV
|
||||
"TMP=$short" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
PM_TEST_SLOWEST: "40"
|
||||
run: python -m tests.run_suite -v
|
||||
|
||||
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
|
||||
# suite never skips the native proof.
|
||||
native-trash:
|
||||
name: native trash on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 6
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Native trash proof
|
||||
timeout-minutes: 4
|
||||
env:
|
||||
PM_NATIVE_TRASH: macos
|
||||
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
|
||||
|
||||
@@ -28,8 +28,8 @@ env:
|
||||
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
|
||||
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
|
||||
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
|
||||
# Windows and macOS moved to portability.yml (#420), which keeps this 27-minute limit; this
|
||||
# Linux-only matrix keeps it too until it is re-measured.
|
||||
# Windows and macOS moved to portability.yml (#420), which has its own measured limit (#493);
|
||||
# this Linux-only matrix keeps 27 minutes until it is re-measured.
|
||||
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
|
||||
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
|
||||
# perf budget 30s / 29s / 27s, black and flake8 39s / 37s / 39s,
|
||||
|
||||
+287
-8
@@ -53,6 +53,14 @@ stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing i
|
||||
temp root, when that pipe closes, so a worker killed without teardown leaves no
|
||||
server behind. A test that fails gets the stderr its server wrote during the test
|
||||
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
|
||||
Each worker also records its browser's own log (#513, `tools/vendor/browser-logs.mjs`):
|
||||
Playwright's `pw:browser` channel, kept as a 300-line tail without printing it
|
||||
unless `DEBUG` asks for it. A browser that exits abnormally writes one
|
||||
`pm-browser-exit| browser pid=<n> exited: exitCode=<c>, signal=<s>` line to the job
|
||||
log, and a test that fails on a closed or crashed target ("Target page, context or
|
||||
browser has been closed") gets the tail attached as `browser-stderr.txt`, which the
|
||||
uploaded test-results carry. `tests/browser/browser-logs.spec.mjs` kills a browser of
|
||||
its own on purpose, so each browser job logs that line once for it.
|
||||
A `/__test__/.../reset` still running after 2 s writes a report to the job log,
|
||||
passing test or not: every thread's stack taken while it waits, the default
|
||||
executor's threads and queue, the open connections and WAL size, then the total
|
||||
@@ -251,12 +259,29 @@ the process exits non-zero.
|
||||
stacks.
|
||||
- When the watchdog fires under coverage, that run's coverage data is lost; the
|
||||
job fails anyway and the dump is what matters.
|
||||
- `PM_TEST_SLOWEST=N` (off by default) lists the N slowest tests after the
|
||||
summary, each timed from its `start:` line to the next one like the hang
|
||||
limit. The Windows and macOS jobs (`portability.yml`) set it to 40, so every
|
||||
run shows where its time went (#487). The list prints only when the run
|
||||
ends: a job GitHub cancels or a watchdog exit prints none, so read the
|
||||
timestamps between `start:` lines instead.
|
||||
- On the Windows runners `TEMP` sits on the OS disk C:, where a flush took about
|
||||
6 ms and a test database's create-to-delete about 111 ms (D: 0.5 ms and 23 ms,
|
||||
macOS 19 ms). `portability.yml` points `TEMP`/`TMP` at a folder under
|
||||
`RUNNER_TEMP` on D: before the suite. It switches 8.3 short names on for D:
|
||||
and spells the folder by its short name, as the runner's own TEMP was
|
||||
(`C:\Users\RUNNER~1\...`), logs it, and warns when there is none.
|
||||
A test that waits on a fixed busy timeout, a network name (`\\server\share`:
|
||||
use `\\localhost\<missing share>`) or a default TLS context (it reads the
|
||||
Windows certificate store) costs seconds there; the slowest list shows it.
|
||||
|
||||
Every job in `test.yml`, `code-quality.yml` and `viewerjs-check.yml` has
|
||||
`timeout-minutes` (`tests/vendor/ci-workflow.test.mjs` checks it). The rule is
|
||||
ceil(1.5 x the slowest of three recent green runs on `main`) + 3 minutes, never
|
||||
under 5; the three run ids, their times and the resulting limits are written
|
||||
in a comment above `jobs:`. The 3 minutes leave room for one watchdog firing, so
|
||||
Every job in `test.yml`, `code-quality.yml`, `viewerjs-check.yml` and
|
||||
`portability.yml` has `timeout-minutes` (`tests/vendor/ci-workflow.test.mjs`
|
||||
checks it). The rule is ceil(1.5 x the slowest of three recent green runs on
|
||||
`main`) + 3 minutes, never under 5, timed as whole-job wall time; the three run
|
||||
ids, their times and the resulting limits are written in a comment above
|
||||
`jobs:`. For `portability.yml` the tooling test recomputes the limit from that
|
||||
comment (#493); its one matrix job takes the slowest Windows or macOS leg. The 3 minutes leave room for one watchdog firing, so
|
||||
the dump prints before GitHub cancels the job. A job that hits its limit, or a
|
||||
watchdog dump, is a failure like any other: read the dump, find the blocking
|
||||
call, fix it. Never raise a limit or rerun to get green; a limit changes only
|
||||
@@ -2399,6 +2424,102 @@ npm run --prefix tools/vendor test:workflows -- workspace-collections.spec.mjs c
|
||||
images-concept.spec.mjs inspector-concept.spec.mjs shell-concept.spec.mjs --project chromium --project touch
|
||||
```
|
||||
|
||||
### Deleting images (#480)
|
||||
|
||||
Images › Delete… removes the checked images (or "Select all N matching", at most 500, or
|
||||
the one file of the side panel or the viewer) from the library, and on request moves the
|
||||
files to the system trash. **No code path unlinks or overwrites a media file.**
|
||||
|
||||
- **D159** (#480, maintainer request): the concept's "Remove links…" (`danger`, app.js
|
||||
432) returns as **Delete…** in the same place (after Export metadata, before Clear), and
|
||||
as the last action of the side panel and after Download in the Images viewer. Its
|
||||
review is the concept's `maintenancePlan` (`dl.meta-grid`, Cancel and a primary) with a
|
||||
radio group first, "What should happen to the files?", nothing checked (the ruling: ask
|
||||
every time): "Remove from the library only" or "Also move the files to the trash". The
|
||||
rows follow the chosen mode (Scope, Not changed, Prompt links, Image details, Backup,
|
||||
Media on disk) and the primary is `danger`: "Remove N images from the library" / "Move
|
||||
N images to the trash". `tests/browser/images-delete-concept.spec.mjs` compares the bar
|
||||
button and the review's grid and actions with the concept.
|
||||
- **Routes.** `POST /prompt_manager/images/delete/preview {items | filter}` reviews both
|
||||
modes at once under the maintenance preview lease and answers a reviewed plan
|
||||
(`_plan_response`, operation `delete_images`): `library.count`, `trash.count`, `refused`
|
||||
and `trash_refused` (`{count, counts, rows ≤ 50}` with coded reasons), `records`,
|
||||
`orphaned_prompts` and the backup. Each target is matched to its entry in the current
|
||||
listing (`not-listed` otherwise). `POST /prompt_manager/maintenance/apply {token, mode}`
|
||||
(`mode` required for this plan, refused for any other) starts job kind `image_delete`
|
||||
(admission `records`, not cancellable; groups `trashed`, `removed`, `not_changed`,
|
||||
`errors`): sources unchanged, the promised safety backup, then per file a recheck
|
||||
(containment and fingerprint) and
|
||||
- library mode: one transaction deletes the file's records and writes its removal mark;
|
||||
- trash mode: the file moves first (`utils/trash.py`); a refused move changes nothing;
|
||||
then its records go. A records failure stops the job (`records-failed`); later files
|
||||
are "Not attempted".
|
||||
- **What goes** (`database/image_removal.py`): image links under every spelling (pair
|
||||
links by cascade, workflow documents released), image tags, auto-tag marks, pending
|
||||
undecided Needs review items, the own category, collection memberships and the
|
||||
favorite. A decided or revised review item keeps its decision and the annotation rows it
|
||||
cites. Prompts, recorded runs and the vocabulary stay. Previews are removed.
|
||||
- **Removal marks** (`image_removals`, `image_removals_meta`): a library-only removal
|
||||
hides exactly that file (locator, size, `mtime_ns`) from the workspace listing, the
|
||||
output scan, the duplicate find, Tag output images and linking. A modified or replaced
|
||||
file shows again. An explicit tag, category, favorite or collection write clears the
|
||||
mark. Older builds ignore the tables (removed files show there); restoring an older
|
||||
backup shows every file again; restoring a pre-delete backup after trash mode brings
|
||||
back records of files that are in the trash (Missing-file links cleans them up). The
|
||||
legacy gallery lists every file on disk, removed or not. Hard links have one mark per
|
||||
path, and on FAT/exFAT/SMB a same-size replacement can stay hidden.
|
||||
Since #501 the thumbnail jobs (System › Thumbnails' counts and its missing, rebuild and
|
||||
convert runs, and the previews after a scan) and the legacy `GET /scan_output_dir`
|
||||
listing skip hidden files too; an on-demand preview the legacy gallery asks for is still
|
||||
made, and a one-image auto-tag (`/autotag/single` + `/autotag/apply`) is an explicit
|
||||
write that shows the file again. System › Maintenance **Missing-file links** also
|
||||
forgets the marks that hide nothing any more (the file is gone from a folder that still
|
||||
exists, or its bytes changed), counted in its scope ("K removed images whose files are
|
||||
gone or changed") and job line, judged only under a source that is readable and lists
|
||||
something (an unplugged drive's empty mount point keeps its marks), never for a source
|
||||
no longer configured, and re-checked at apply (`plan-changed`). The sidebar's Images,
|
||||
Favorites and Needs review counts and the collection counts are read again when an
|
||||
`image_delete` job ends in the tab that followed it (`JOB_END_COUNTS`, `state.mjs`).
|
||||
Tests: `tests/test_image_removal_gaps.py`, the sidebar case in
|
||||
`tests/browser/workspace-images-delete.spec.mjs`.
|
||||
- **Trash folders inside an output root** (#501, `utils/media_folders.py`): no walker
|
||||
descends a folder below a root named `.Trash`, `.Trash-<digits>`, `.Trashes` or
|
||||
`$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`,
|
||||
`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
|
||||
`.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with
|
||||
`renameat2(RENAME_NOREPLACE)`; without it, across devices, on a read-only mount or with
|
||||
a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). macOS
|
||||
(#499, `utils/trash_macos.py`) calls `-[NSFileManager trashItemAtURL:resultingItemURL:error:]`
|
||||
through `ctypes` (ten fixed `objc_msgSend` prototypes, nil checks before every send, one
|
||||
autorelease pool per call, loaded on first use). It answers in the review dialog, without
|
||||
writing: `network` (not a local volume), `no-trash` (read-only, or no trash folder
|
||||
macOS would use), `cloud-sync` (iCloud Drive, `~/Library/CloudStorage`, or ubiquitous),
|
||||
`trash-in-root` (the trash folder is inside an output root, compared by `(st_dev,
|
||||
st_ino)` of every ancestor) and `no-permission` (folder not writable, an immutable or
|
||||
append flag, a foreign file in a sticky folder). A fresh volume's `.Trashes/<uid>` is
|
||||
predicted (`URLForDirectory` answers 3328/`ENOENT` until macOS makes it). A refused move
|
||||
keeps the file and logs the `NSError`; a move macOS reports done but whose place cannot be
|
||||
shown (no result URL, or one inside an output root) goes under `errors` as
|
||||
`trash-unconfirmed`, keeps its records and stops the job. Windows
|
||||
never uses the Recycle Bin (#500, maintainer ruling (a)): files larger than the bin's
|
||||
capacity are deleted permanently and IFileOperation's `PreDeleteItem` guard cannot detect
|
||||
it (native probe runs 40951037575, 40953347904), so every file answers `windows-recycle`
|
||||
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
|
||||
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
|
||||
browser test server installs it, so no test touches a real trash. The native proof,
|
||||
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
|
||||
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
|
||||
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
|
||||
a read-only image and an unwritable folder (design and probe:
|
||||
`docs/egm/499-macos-trash/`, local).
|
||||
|
||||
### Prompt library cards and recorded deviations (#249 PR E)
|
||||
|
||||
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
@@ -2524,7 +2645,8 @@ Category". It is built only from the concept's Images parts:
|
||||
input[type=checkbox]`); Home, End and the arrow keys still move between titles.
|
||||
- `div#library-bulkbar` sits between the toolbar and the caption, empty until something
|
||||
is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear (D151
|
||||
adds Add tags before Set category, in the Images bar's order). The
|
||||
adds Add tags before Set category, in the Images bar's order; D152 adds Delete… before
|
||||
Clear, and Restore alone on the archived shelf). The
|
||||
selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their
|
||||
state, Load more keeps it, and any shelf reload (search, category, sort, tags, a
|
||||
write) and leaving the page clear it. Clicking a checkbox toggles it and sets the
|
||||
@@ -2605,6 +2727,157 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no
|
||||
Add tags); units `tests/js/workspace-library-selection.test.js`; Python
|
||||
`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart.
|
||||
|
||||
### Deleting and archiving selected prompts (#443)
|
||||
|
||||
**D152** (beyond the concept, on D148's bar; the concept archives a prompt from its recipe
|
||||
editor's "Archive…", `app.js` 976, reviewed in `maintenancePlan`, 953–958, and maps the legacy
|
||||
delete to a "reference-safe archive", 783): the Prompt library deletes or archives the
|
||||
selected prompts in one reviewed step, replacing the legacy admin page's bulk and single
|
||||
Delete, which refuses a whole batch when any prompt belongs to a saved pair (maintainer
|
||||
rulings of 2026-10-28):
|
||||
|
||||
- The bar reads "N selected", [Select all N loaded], Add tags, Set category, **Delete…**
|
||||
(the concept's `danger` class, as its Images bar's "Remove links…"), Clear.
|
||||
- Delete… posts `{prompt_ids, expected_epoch}` to `POST
|
||||
/prompt_manager/library/delete/preview`, which classifies each id in this order: not
|
||||
found (missing or not a Library parent), protected (tagged `__protected__`), already
|
||||
archived, archive (the positive or negative of any saved pair, archived pair or not),
|
||||
delete (everything else). From the press until the review opens, every bar action, Clear
|
||||
too, is `aria-disabled` and Delete… reads "Reviewing…" (a double press sends one preview).
|
||||
- The review is the shared record-changing review (`openReviewDialog`), titled "Delete
|
||||
selected prompts", "Archive selected prompts" or "Delete or archive selected prompts", its
|
||||
`dl.meta-grid` rows Delete permanently ("N prompts not in a saved pair") and Archive ("N prompts in a saved
|
||||
pair: hidden, restorable", never negative variants: a pair's prompt with none is archived too) (always), Protected (kept) with up to
|
||||
three titles, Already archived, No longer exist, and, when something is deleted, Image
|
||||
links removed and Backup ("Create and verify before deleting", or "Off: no safety backup
|
||||
(System › Backup & export › Recovery policy)"); then `p.small` sentences: why paired
|
||||
prompts are archived and how to restore them, what a delete removes and that it cannot be
|
||||
undone except by restoring the safety backup (which returns the whole library to that
|
||||
moment; kept among the last 10), that a later scan, new image or node run with the same
|
||||
text adds a deleted prompt again, and how to unprotect. The primary is `danger`: "Delete D
|
||||
prompts", "Archive A prompts" or "Delete D and archive A prompts". When nothing can change,
|
||||
or the disk has no room for the promised backup, the review is close-only with the reason.
|
||||
The concept's "Review maintenance plan" title, its demo note, Scope/Protected/Media on
|
||||
disk rows and `ca-primary` start are replaced by these.
|
||||
- Start posts the reviewed lists `{archive_ids, delete_ids, expected_epoch, backup}` to `POST
|
||||
/prompt_manager/library/delete` while Delete… reads "Deleting…" (or "Archiving…"). The
|
||||
server takes a `records` hold ("Delete prompts (Library)", refused 409 `busy` beside a scan,
|
||||
restore, maintenance, move or Optimize), checks the disk (507 `insufficient-space`) and,
|
||||
when a delete remains and the reviewed `backup` was "on" or the policy is on now, takes a
|
||||
verified safety backup (500 `backup-failed`, nothing changed). One `BEGIN IMMEDIATE`
|
||||
transaction then deletes only reviewed deletes still unpaired whose prompt row, tags,
|
||||
title and image links are unchanged since the backup (exactly the legacy rows: image
|
||||
link rows, cascaded tags, Library state and pair links of images, released workflow
|
||||
documents; files never), archives the paired ones and any reviewed delete now paired, and
|
||||
counts the rest (`already_archived`, `not_found`, `changed`). Archiving writes a
|
||||
`prompt_archive` row, the prompt's `updated_at` and, when its stored hash is stale or
|
||||
missing and no other row holds the canonical one, the canonical hash, so scans, the
|
||||
monitor and node saves find the archived row instead of adding a copy. A changed epoch
|
||||
answers 409 `storage-changed`; nothing is written.
|
||||
- Outcomes: "Deleted D prompts." "Archived A prompts." with already archived, no longer
|
||||
exist, changed and kept-protected sentences and "Safety backup saved: NAME (System ›
|
||||
Backup & export)."; the shelf, categories, cached Library views and sidebar counts reload.
|
||||
Storage restored: "The library was restored or moved since this page loaded. Nothing was
|
||||
deleted or archived."; busy: "NAME is running. Nothing was changed; try again when it
|
||||
finishes."; a failed backup or a short disk: the server's sentence; no answer, 502, 503 or
|
||||
504: "The server did not confirm the change. The Library was reloaded: check the shelf and
|
||||
Show archived prompts."; other refusals: "The selected prompts could not be changed. Try
|
||||
again.", the selection kept.
|
||||
- **Show archived prompts (N)** (heading actions, before Export, a plain button; N the
|
||||
archived prompts matching the filters, `archived_total`; the shelf counterpart of #235's
|
||||
Show archived) lists only archived prompts (`GET /prompt_manager/library?shelf=archived`,
|
||||
a cursor never crosses shelves), each card with an "Archived" `.chip`, the caption "N
|
||||
archived prompts · M negative variants hidden with them", the bar "N selected", Restore,
|
||||
Clear, and "No archived prompts." / "No archived prompts match." when empty. On the archived
|
||||
shelf the button reads the concept's own way back, "← Prompt library" (app.js 594; #482), with
|
||||
no `aria-pressed` (its label names where it goes). The shelf stays with the shelf visit, never
|
||||
in a saved view; Browse and a saved view open the active shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
|
||||
/prompt_manager/library/restore` (every requested archived id, protected or not): "Restored
|
||||
N prompts to the Prompt library."
|
||||
- An archived prompt's page shows the concept's `.notice` ("Archived" / "This prompt is archived
|
||||
because it is in a saved pair. It and its N negative variants are hidden from the Prompt library; nothing was
|
||||
removed.", or "It is hidden from…" when it has none) with Restore;
|
||||
everything else on the page works. New prompt or Add negative on an archived prompt's
|
||||
text keeps it archived and toasts "This text is an archived prompt. Restore it to show it
|
||||
in the Prompt library again."
|
||||
- Archived prompts: hidden from the shelf, its search, `total`, the sidebar count, Tags &
|
||||
categories' prompt counts (a value only archived prompts hold stays listed with 0) and
|
||||
System › Overview and the restore review's positive counts; still counted by rename and
|
||||
delete impacts and usage statistics; still shown as another prompt's negative variant, on
|
||||
recorded runs and Images, by the legacy pages and the ComfyUI node's search; still reached
|
||||
by run counting, scans and the monitor; never pruned by maintenance (a new protection,
|
||||
"archived in the Library"); exported with `archived: true`. Older builds ignore the table
|
||||
and show them again; the rows stay.
|
||||
- Deviation from legacy: a selected `__protected__` prompt is never deleted or archived (the
|
||||
legacy delete removed it); it is listed in the review and the toast.
|
||||
|
||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (mixed review and result on the
|
||||
server, Cancel, Show archived prompts and Restore from the bar and the notice, double press,
|
||||
failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-concept.spec.mjs`
|
||||
(the review against the concept's `maintenancePlan`, the bar's Delete… against the concept's
|
||||
"Remove links…"); units `tests/js/workspace-library-delete.test.js`; Python
|
||||
`tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`,
|
||||
`tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart.
|
||||
|
||||
### Deleting a prompt in a saved pair permanently (#482)
|
||||
|
||||
**D160** (beyond legacy, maintainer ruling 2026-10-29; follow-up to D152): when the Delete… selection
|
||||
holds prompts in a saved pair that no other positive uses as its negative ("deletable"), the review
|
||||
asks, in a D90 radio row "Prompts in a saved pair" after the grid: **Archive (hidden, restorable)**
|
||||
(checked on every open, never remembered, so the review is D152's) or **Delete permanently, with
|
||||
their saved pairs**. Choosing it rewrites the review in place (`openReviewDialog` `setRows`,
|
||||
`setPrimary`):
|
||||
- Delete permanently "D prompts: X not in a saved pair, Y in a saved pair" (or "Y prompts in a saved
|
||||
pair"), Saved pairs removed "S saved pairs (V with a negative prompt, A archived)", Negatives kept
|
||||
"K negative prompts are kept", Archive "R prompts used as another prompt's negative: hidden,
|
||||
restorable", then D152's protected, already archived, no longer exist, Image links removed (images
|
||||
of every deleted prompt) and Backup rows.
|
||||
- Sentences: the pairs go with their titles, categories, ratings, notes, tags, run counts and image
|
||||
links; their negative prompts stay; recorded runs stay in History and Activity by their text; D152's
|
||||
irreversibility (per backup policy) and re-creation sentences, plus "A pair node run with the same
|
||||
text saves a new pair."; and, when R > 0, why those are archived.
|
||||
- Primary (`danger`): "Delete D prompts" / "Delete D and archive R prompts"; a short disk keeps it
|
||||
unavailable (`aria-disabled`) with the disk sentence, while Archive still starts.
|
||||
|
||||
The preview adds `deletable` (a subset of `archive`, which keeps every archive id for D152 clients),
|
||||
`review_digests` (sha256 of each deletable prompt's row and its pairs: negative, archived flag,
|
||||
metadata, usage, tags, image-pair links), `paired_counts` and `paired_image_links`, and plans the
|
||||
disk whenever anything could be deleted. The write takes optional `delete_paired_ids` with their
|
||||
`paired_digests` (both or neither; ids must be reviewed archive ids). In one transaction, after the
|
||||
archives, each chosen prompt whose digest is unchanged loses its image-pair links, then its own saved
|
||||
pairs (pair tags cascade; review decisions keep their rows with `pair_id` cleared), then itself exactly
|
||||
as D152 deletes. Any drift (pair added or edited, new link, pairs gone, now another prompt's negative)
|
||||
archives it and counts it `changed`. A paired delete is backed up and disk-checked as a delete. The
|
||||
toast adds "Removed S saved pairs."; "N changed since the review and was not deleted." replaces "…was
|
||||
kept.". Negatives are never deleted: one no remaining pair uses is listed in the Library afterwards (as
|
||||
after the legacy pair delete). Legacy still refuses paired prompts (409).
|
||||
|
||||
Tests: `tests/test_bulk_library_delete.py` `TestPairedDelete` (classes and counts, the 7896 empty
|
||||
negative, the row diff against the legacy pair deletes then prompt delete, archive-only, a positive
|
||||
with its own negative, a self-pair, each drift, validation); `tests/test_library_delete_routes.py`
|
||||
`TestPairedDeleteRoutes` (preview, paired-only backup, a D152 body, validation, short disk); units
|
||||
`tests/js/workspace-library-delete.test.js`, `tests/js/workspace-toolbar.test.js` (`setRows`,
|
||||
`setPrimary`); browser `tests/browser/workspace-library-delete.spec.mjs` (the choice, its grid and
|
||||
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
||||
change: ComfyUI needs a restart.
|
||||
|
||||
### Delete on a prompt's own page (#481)
|
||||
|
||||
**D161** (beyond the concept, coordinator ruling (A) 2026-10-29, open to the maintainer at the live test): the recipe
|
||||
editor (Edit metadata, the concept's app.js 976 row "Create text revision", "Archive…", Cancel, Save) reads "Create
|
||||
text revision", **"Archive variant…"** (the selected negative variant's archive, #235, which used the concept's
|
||||
"Archive…" label) and **"Delete prompt…"** (danger), then Cancel and Save. "Delete prompt…" closes the editor and runs
|
||||
D152/D160's Delete… review for that one prompt (`reviewPromptDelete`: the same preview, review with the saved-pair
|
||||
choice, backup and write, fenced by the page's `library_epoch`). Cancel changes nothing and returns focus to Edit
|
||||
metadata. A delete toasts and opens the Prompt library; an archive (or a drift) toasts and reads the page again, so an
|
||||
archived prompt shows its Archived notice. The button is disabled while the editor saves or after a restore.
|
||||
|
||||
The concept comparison applies D161 in `mapConcept` (`tests/browser/library-editor-canonical-comparison.mjs`): the
|
||||
concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete prompt…" with the same geometry and tokens.
|
||||
|
||||
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.
|
||||
|
||||
### No 1–5 prompt rating in the workspace (#447)
|
||||
|
||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||
@@ -2869,6 +3142,12 @@ mapped differences"):
|
||||
change within the workspace waits the same way: the shell renders the next route once a
|
||||
video still starting on Inspect settles (`leaveGuard`; the latest route wins). Route
|
||||
changes at `loadstart` + 0–7 ms hung 4–5 of 120 pages before and 0 of 120 after.
|
||||
The shared viewer's own video gets the same guard (#483): a new selection while the
|
||||
viewer D155 opened is still starting its video leaves the viewer open, untouched, until
|
||||
that video reports metadata or an error (or 15 s), then closes it; the URL is revoked
|
||||
after that, and a viewer asked for meanwhile (the new file's D155 open) opens then. The
|
||||
merge queue hung once without it (run 40903238387: the PNG selection 12 ms after the
|
||||
inline video showed, with the viewer's new video still starting).
|
||||
A real page leave cannot wait. With no page code releasing anything, navigating
|
||||
away hung 8 of 120 pages and reload 22 of 120 (3 and 8 of 120 with the page's own
|
||||
release), so that WebKit teardown stays a known limitation; dispose still releases at
|
||||
@@ -3823,8 +4102,8 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
- **D104** (Backup & export, S9, Q4): Recovery policy keeps the concept's "Proposed" chip.
|
||||
"Create a safety backup before maintenance" is the server's `safety_backup`
|
||||
(`maintenance.safety_backup`) policy. Its line says what it does ("Keep a verified copy
|
||||
of the database on disk before maintenance, cleanups and LoRA imports change records. A
|
||||
restore always keeps one."); the concept's "Preview a verified backup before applying
|
||||
of the database on disk before maintenance, cleanups, LoRA imports and prompt deletes
|
||||
change records. A restore always keeps one."); the concept's "Preview a verified backup before applying
|
||||
operations that change records." describes nothing the server does. It is drafted behind the Save row and saved with
|
||||
`expected_revision` (a configuration changed elsewhere is the conflict status); it is
|
||||
disabled with "Server settings could not be read." when the read failed. "Scheduled
|
||||
|
||||
@@ -40,6 +40,7 @@ from .annotation_schema import (
|
||||
METADATA_TABLE,
|
||||
)
|
||||
from .autotag_schema import AUTOTAG_TABLE
|
||||
from .image_removal_schema import clear_marks
|
||||
from .library_commands import _UUID, LibraryCommandError, _validate_epoch
|
||||
from .models import utc_now_iso
|
||||
from .vocabulary import validate_vocabulary_tag_name
|
||||
@@ -236,6 +237,9 @@ class AnnotationOperations:
|
||||
|
||||
@staticmethod
|
||||
def _annotation_owner(conn: sqlite3.Connection, locator: str, now: str) -> str:
|
||||
# A file with image tags or Needs review state is in the library: a
|
||||
# removal mark never hides it (#480).
|
||||
clear_marks(conn, [locator])
|
||||
row = conn.execute(_OWNER, (locator,)).fetchone()
|
||||
if row is not None:
|
||||
return row[0]
|
||||
|
||||
@@ -221,6 +221,7 @@ class ImageCategoryOperations:
|
||||
sources. Returns ``{updated, unchanged}``; a file that already had
|
||||
exactly this value is ``unchanged`` and keeps its ``updated_at``.
|
||||
"""
|
||||
from .image_removal_schema import clear_marks_of_keys
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
keys = list(dict.fromkeys(tuple(key) for key in file_keys))
|
||||
@@ -234,6 +235,7 @@ class ImageCategoryOperations:
|
||||
continue
|
||||
conn.execute(UPSERT_SQL, (*key, category, now))
|
||||
counts["updated"] += 1
|
||||
clear_marks_of_keys(conn, keys) # #480: a categorized file is listed
|
||||
return counts
|
||||
|
||||
def clear_image_categories(self, file_keys) -> int:
|
||||
|
||||
@@ -402,6 +402,7 @@ class ImageCollectionOperations:
|
||||
is created only when at least one item is inserted. Returns
|
||||
``{collection, created, added, already}``.
|
||||
"""
|
||||
from .image_removal_schema import clear_marks_of_keys
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
if (collection_id is None) == (name is None):
|
||||
@@ -438,6 +439,7 @@ class ImageCollectionOperations:
|
||||
).rowcount
|
||||
for root, path in pairs
|
||||
)
|
||||
clear_marks_of_keys(conn, pairs) # #480: a collected file is listed
|
||||
return {
|
||||
"collection": collection,
|
||||
"created": created,
|
||||
|
||||
@@ -104,11 +104,14 @@ class ImageFavoriteOperations:
|
||||
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
from .image_removal_schema import clear_marks_of_keys
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
|
||||
(root_key, relative_path, utc_now_iso()),
|
||||
)
|
||||
clear_marks_of_keys(conn, [(root_key, relative_path)]) # #480
|
||||
return True
|
||||
|
||||
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Remove a file's records from the library, and its removal mark (#480).
|
||||
|
||||
A *removal* describes one physical file the way the API resolved it from the
|
||||
walked listing (``py/api/image_delete.py``):
|
||||
|
||||
- ``spellings``: every path string records may name it by
|
||||
(``duplicate_spellings.Spellings.of``);
|
||||
- ``locator``: its private canonical locator;
|
||||
- ``favorites``: ``(root key, relative key)`` under every listed root holding
|
||||
it, the keys of its favorite and its collection memberships;
|
||||
- ``category``: its own category's file key, or None.
|
||||
|
||||
``remove_image_records`` deletes, in one ``BEGIN IMMEDIATE`` transaction:
|
||||
|
||||
- its image records (``generated_images``) under every spelling, plus legacy
|
||||
rows matched by ``image_path``; their saved-pair links go by cascade and the
|
||||
workflow documents they held are released;
|
||||
- the image tags and auto-tag marks of its byte records. The annotation file
|
||||
and byte rows go too, unless a Needs review item still cites them
|
||||
(``media_review_items.byte_id`` is ``ON DELETE RESTRICT``): a decided or
|
||||
revised item keeps its decision;
|
||||
- its pending, undecided Needs review items (settled as duplicate removal
|
||||
settles them);
|
||||
- its own category, favorite and collection memberships.
|
||||
|
||||
With ``mark`` it also writes the file's removal mark (library-only removal):
|
||||
the file stays on disk, hidden until its bytes change. Prompts, recorded runs
|
||||
and the media file itself are never touched here.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from .annotation_schema import BYTES_TABLE, FILES_TABLE
|
||||
from .annotations import _BUMP_INDEX
|
||||
from .autotag_schema import AUTOTAG_TABLE
|
||||
from .duplicate_copies import (
|
||||
DELETE_FAVORITE_SQL,
|
||||
DELETE_LINK_SQL,
|
||||
DELETE_MEMBERSHIPS_SQL,
|
||||
_file_link_rows,
|
||||
)
|
||||
from .image_categories import DELETE_SQL as DELETE_CATEGORY_SQL
|
||||
from .image_removal_schema import (
|
||||
META_TABLE,
|
||||
TABLE,
|
||||
clear_marks,
|
||||
has_marks_table,
|
||||
mark_of,
|
||||
write_mark as _write_mark,
|
||||
)
|
||||
from .library import display_title
|
||||
from .models import utc_now_iso
|
||||
from .review import _BUMP_REVIEW_INDEX, _PENDING_OF_LOCATOR, _SETTLE_ITEM
|
||||
from .review_changes import notify_review_change
|
||||
from .workflow_documents import documents_of_rows, release_documents
|
||||
|
||||
BYTES_OF_LOCATOR_SQL = (
|
||||
f"SELECT b.id FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b " # nosec B608
|
||||
"ON b.file_id=f.id WHERE f.locator=?"
|
||||
)
|
||||
TAG_COUNT_SQL = "SELECT COUNT(*) FROM media_image_tags WHERE byte_id=?"
|
||||
AUTOTAG_COUNT_SQL = (
|
||||
f"SELECT COUNT(*) FROM {AUTOTAG_TABLE} WHERE byte_id=?" # nosec B608
|
||||
)
|
||||
CLEAR_TAGS_SQL = "DELETE FROM media_image_tags WHERE byte_id=?"
|
||||
CLEAR_AUTOTAG_SQL = f"DELETE FROM {AUTOTAG_TABLE} WHERE byte_id=?" # nosec B608
|
||||
BUMP_TAG_REVISION_SQL = (
|
||||
f"UPDATE {BYTES_TABLE} SET tag_revision=tag_revision+1 WHERE id=?" # nosec B608
|
||||
)
|
||||
REVIEW_REFS_SQL = (
|
||||
f"SELECT COUNT(*) FROM {FILES_TABLE} f " # nosec B608
|
||||
f"JOIN {BYTES_TABLE} b ON b.file_id=f.id "
|
||||
"JOIN media_review_items i ON i.byte_id=b.id WHERE f.locator=?"
|
||||
)
|
||||
DELETE_FILE_SQL = f"DELETE FROM {FILES_TABLE} WHERE locator=?" # nosec B608
|
||||
FILE_EXISTS_SQL = f"SELECT 1 FROM {FILES_TABLE} WHERE locator=?" # nosec B608
|
||||
FAVORITE_EXISTS_SQL = (
|
||||
"SELECT COUNT(*) FROM image_favorites WHERE root_key=? AND relative_path=?"
|
||||
)
|
||||
MEMBERSHIP_COUNT_SQL = (
|
||||
"SELECT COUNT(*) FROM image_collection_items WHERE root_key=? AND relative_path=?"
|
||||
)
|
||||
CATEGORY_EXISTS_SQL = (
|
||||
"SELECT COUNT(*) FROM image_categories WHERE root_key=? AND relative_path=?"
|
||||
)
|
||||
PAIR_LINK_COUNT_SQL = "SELECT COUNT(*) FROM image_prompt_pairs WHERE image_id=?"
|
||||
PROMPT_LINKS_SQL = "SELECT id FROM generated_images WHERE prompt_id=?"
|
||||
PROMPT_TITLE_SQL = (
|
||||
"SELECT p.text, s.title FROM prompts p "
|
||||
"LEFT JOIN prompt_library_state s ON s.prompt_id=p.id WHERE p.id=?"
|
||||
)
|
||||
MARKS_SQL = f"SELECT locator, size, mtime_ns FROM {TABLE}" # nosec B608
|
||||
REVISION_SQL = f"SELECT revision FROM {META_TABLE} WHERE singleton_id=1" # nosec B608
|
||||
MAX_ORPHAN_TITLES = 3
|
||||
RECORD_KINDS = (
|
||||
"prompt_links",
|
||||
"pair_links",
|
||||
"image_tags",
|
||||
"auto_tagged",
|
||||
"categories",
|
||||
"collections",
|
||||
"favorites",
|
||||
"review_items",
|
||||
)
|
||||
|
||||
|
||||
def _link_ids(conn, removal):
|
||||
"""``{image id: prompt id}`` of the records naming the file, any spelling."""
|
||||
found = {}
|
||||
for spelled in dict.fromkeys(removal["spellings"]):
|
||||
for image_id, prompt_id in _file_link_rows(conn, spelled):
|
||||
found[image_id] = prompt_id
|
||||
return found
|
||||
|
||||
|
||||
def _byte_ids(conn, locator):
|
||||
return [row[0] for row in conn.execute(BYTES_OF_LOCATOR_SQL, (locator,))]
|
||||
|
||||
|
||||
def file_locator(path) -> str:
|
||||
"""The locator of an absolute path (``normcase(realpath)``)."""
|
||||
return os.path.normcase(os.path.realpath(str(path)))
|
||||
|
||||
|
||||
def removed_from_library(conn, path) -> bool:
|
||||
"""Whether *path* is hidden by a removal mark whose bytes still match.
|
||||
|
||||
A mark whose file changed (other size or ``mtime_ns``) or is gone is
|
||||
stale: it is deleted on *conn* (the caller's write transaction), and the
|
||||
file is not hidden.
|
||||
"""
|
||||
locator = file_locator(path)
|
||||
mark = mark_of(conn, locator)
|
||||
if mark is None:
|
||||
return False
|
||||
try:
|
||||
info = os.lstat(path)
|
||||
except OSError:
|
||||
clear_marks(conn, [locator])
|
||||
return False
|
||||
if (info.st_size, info.st_mtime_ns) == tuple(mark):
|
||||
return True
|
||||
clear_marks(conn, [locator])
|
||||
return False
|
||||
|
||||
|
||||
def mark_hides(marks: dict, locator: str, path) -> bool:
|
||||
"""Whether a removal mark in *marks* hides the file at *path* (blocking).
|
||||
|
||||
*marks* is ``{locator: (size, mtime_ns)}``. The mark of *locator* hides
|
||||
the file while ``os.lstat(path)`` still has the size and ``mtime_ns`` it
|
||||
was removed with; a file that cannot be looked at is not hidden.
|
||||
"""
|
||||
mark = marks.get(locator)
|
||||
if mark is None:
|
||||
return False
|
||||
try:
|
||||
info = os.lstat(path)
|
||||
except OSError:
|
||||
return False
|
||||
return (info.st_size, info.st_mtime_ns) == tuple(mark)
|
||||
|
||||
|
||||
def write_mark(conn, locator, size, mtime_ns):
|
||||
_write_mark(conn, locator, size, mtime_ns, utc_now_iso())
|
||||
|
||||
|
||||
class ImageRemovalOperations:
|
||||
"""PromptDatabase mixin: remove a file's records; removal marks."""
|
||||
|
||||
def count_removal_records(self, removals) -> dict:
|
||||
"""Counts of what removing *removals* deletes, in one read snapshot.
|
||||
|
||||
``{records: {kind: n}, orphaned_prompts: {count, titles}}``: prompts
|
||||
whose every image record is among these files lose their last image
|
||||
(they stay in the Library).
|
||||
"""
|
||||
from .maintenance import _read_snapshot
|
||||
|
||||
def read(conn):
|
||||
totals = dict.fromkeys(RECORD_KINDS, 0)
|
||||
links = {}
|
||||
for removal in removals:
|
||||
found = _link_ids(conn, removal)
|
||||
links.update(found)
|
||||
for image_id in found:
|
||||
totals["pair_links"] += conn.execute(
|
||||
PAIR_LINK_COUNT_SQL, (image_id,)
|
||||
).fetchone()[0]
|
||||
tagged = autotagged = False
|
||||
for byte_id in _byte_ids(conn, removal["locator"]):
|
||||
tagged |= bool(
|
||||
conn.execute(TAG_COUNT_SQL, (byte_id,)).fetchone()[0]
|
||||
)
|
||||
autotagged |= bool(
|
||||
conn.execute(AUTOTAG_COUNT_SQL, (byte_id,)).fetchone()[0]
|
||||
)
|
||||
totals["image_tags"] += int(tagged)
|
||||
totals["auto_tagged"] += int(autotagged)
|
||||
totals["review_items"] += len(
|
||||
conn.execute(_PENDING_OF_LOCATOR, (removal["locator"],)).fetchall()
|
||||
)
|
||||
totals["favorites"] += int(
|
||||
any(
|
||||
conn.execute(FAVORITE_EXISTS_SQL, key).fetchone()[0]
|
||||
for key in removal["favorites"]
|
||||
)
|
||||
)
|
||||
totals["collections"] += sum(
|
||||
conn.execute(MEMBERSHIP_COUNT_SQL, key).fetchone()[0]
|
||||
for key in removal["favorites"]
|
||||
)
|
||||
if removal.get("category"):
|
||||
totals["categories"] += conn.execute(
|
||||
CATEGORY_EXISTS_SQL, removal["category"]
|
||||
).fetchone()[0]
|
||||
totals["prompt_links"] = len(links)
|
||||
return {
|
||||
"records": totals,
|
||||
"orphaned_prompts": self._orphaned_prompts(conn, links),
|
||||
}
|
||||
|
||||
return _read_snapshot(self.model.db_path, read)
|
||||
|
||||
@staticmethod
|
||||
def _orphaned_prompts(conn, links):
|
||||
removed = set(links)
|
||||
orphans = []
|
||||
for prompt_id in dict.fromkeys(links.values()):
|
||||
remaining = [
|
||||
row[0]
|
||||
for row in conn.execute(PROMPT_LINKS_SQL, (prompt_id,))
|
||||
if row[0] not in removed
|
||||
]
|
||||
if not remaining:
|
||||
orphans.append(prompt_id)
|
||||
titles = []
|
||||
for prompt_id in orphans[:MAX_ORPHAN_TITLES]:
|
||||
row = conn.execute(PROMPT_TITLE_SQL, (prompt_id,)).fetchone()
|
||||
if row is not None:
|
||||
titles.append(display_title((row[1],), row[0]) or "")
|
||||
return {"count": len(orphans), "titles": titles}
|
||||
|
||||
def remove_image_records(self, removal, *, mark=None) -> dict:
|
||||
"""Delete every record of one file (one transaction); see the module.
|
||||
|
||||
``mark``: ``(size, mtime_ns)`` to write the file's removal mark, or
|
||||
None (the file was moved to the trash). Returns the counts deleted.
|
||||
"""
|
||||
counts = dict.fromkeys(RECORD_KINDS, 0)
|
||||
locator = removal["locator"]
|
||||
with self._pair_transaction() as conn:
|
||||
links = _link_ids(conn, removal)
|
||||
if links:
|
||||
documents = documents_of_rows(conn, "id", list(links))
|
||||
for image_id in links:
|
||||
counts["pair_links"] += conn.execute(
|
||||
PAIR_LINK_COUNT_SQL, (image_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(DELETE_LINK_SQL, (image_id,))
|
||||
release_documents(conn, documents)
|
||||
counts["prompt_links"] = len(links)
|
||||
counts.update(self._remove_annotations(conn, locator))
|
||||
for key in removal["favorites"]:
|
||||
counts["favorites"] += conn.execute(DELETE_FAVORITE_SQL, key).rowcount
|
||||
counts["collections"] += conn.execute(
|
||||
DELETE_MEMBERSHIPS_SQL, key
|
||||
).rowcount
|
||||
if removal.get("category"):
|
||||
counts["categories"] += conn.execute(
|
||||
DELETE_CATEGORY_SQL, tuple(removal["category"])
|
||||
).rowcount
|
||||
if mark is not None:
|
||||
write_mark(conn, locator, *mark)
|
||||
if counts["review_items"]:
|
||||
notify_review_change(self.model.db_path)
|
||||
return counts
|
||||
|
||||
def _remove_annotations(self, conn, locator) -> dict:
|
||||
"""Tags, auto-tag marks, pending review items and, when free, the rows."""
|
||||
counts = {"image_tags": 0, "auto_tagged": 0, "review_items": 0}
|
||||
bytes_ = _byte_ids(conn, locator)
|
||||
for byte_id in bytes_:
|
||||
tags = conn.execute(CLEAR_TAGS_SQL, (byte_id,)).rowcount
|
||||
autotags = conn.execute(CLEAR_AUTOTAG_SQL, (byte_id,)).rowcount
|
||||
if tags:
|
||||
conn.execute(BUMP_TAG_REVISION_SQL, (byte_id,))
|
||||
counts["image_tags"] |= bool(tags)
|
||||
counts["auto_tagged"] |= bool(autotags)
|
||||
for (item_id,) in conn.execute(_PENDING_OF_LOCATOR, (locator,)).fetchall():
|
||||
counts["review_items"] += conn.execute(_SETTLE_ITEM, (item_id,)).rowcount
|
||||
if counts["review_items"]:
|
||||
conn.execute(_BUMP_REVIEW_INDEX)
|
||||
if conn.execute(FILE_EXISTS_SQL, (locator,)).fetchone():
|
||||
if not conn.execute(REVIEW_REFS_SQL, (locator,)).fetchone()[0]:
|
||||
conn.execute(DELETE_FILE_SQL, (locator,))
|
||||
conn.execute(_BUMP_INDEX)
|
||||
counts["image_tags"] = int(counts["image_tags"])
|
||||
counts["auto_tagged"] = int(counts["auto_tagged"])
|
||||
return counts
|
||||
|
||||
def removal_marks(self) -> dict:
|
||||
"""``{locator: (size, mtime_ns)}`` of every removal mark (one read)."""
|
||||
conn = self.model.get_connection()
|
||||
if not has_marks_table(conn):
|
||||
return {}
|
||||
return {row[0]: (row[1], row[2]) for row in conn.execute(MARKS_SQL)}
|
||||
|
||||
def removal_revision(self) -> int:
|
||||
conn = self.model.get_connection()
|
||||
if not has_marks_table(conn):
|
||||
return 0
|
||||
row = conn.execute(REVISION_SQL).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def clear_removal_marks(self, locators) -> int:
|
||||
"""Show these files again (their marks go); the count removed."""
|
||||
locators = list(locators)
|
||||
if not locators:
|
||||
return 0
|
||||
with self._pair_transaction() as conn:
|
||||
return clear_marks(conn, locators)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Removal marks (#480): files removed from the library that stay on disk.
|
||||
|
||||
``image_removals`` holds one row per hidden file: its private canonical
|
||||
locator (the ``media_annotation_files.locator`` form) and the ``size`` and
|
||||
``mtime_ns`` it had when it was removed. A file at that locator with other
|
||||
bytes is a new file and shows again. ``image_removals_meta`` is one row, the
|
||||
marks revision, bumped in the same transaction as every mark change so the
|
||||
workspace listing caches can tell marks changed.
|
||||
|
||||
The group installs like ``image_favorites``: optional in old backups,
|
||||
required once a database is current. Older builds ignore it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from .annotation_schema import MAX_LOCATOR_LENGTH
|
||||
|
||||
TABLE = "image_removals"
|
||||
META_TABLE = "image_removals_meta"
|
||||
TABLES = (TABLE, META_TABLE)
|
||||
_TABLE_DDL = {
|
||||
TABLE: (
|
||||
f"CREATE TABLE {TABLE} (locator TEXT PRIMARY KEY, "
|
||||
"size INTEGER NOT NULL CHECK(size>=0), mtime_ns INTEGER NOT NULL, "
|
||||
"removed_at TEXT NOT NULL) WITHOUT ROWID"
|
||||
),
|
||||
META_TABLE: (
|
||||
f"CREATE TABLE {META_TABLE} (singleton_id INTEGER PRIMARY KEY "
|
||||
"CHECK(singleton_id=1), revision INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK(revision>=0))"
|
||||
),
|
||||
}
|
||||
_SEED_META = (
|
||||
f"INSERT OR IGNORE INTO {META_TABLE} (singleton_id, revision) VALUES (1, 0)"
|
||||
)
|
||||
_COLUMNS = {
|
||||
TABLE: (
|
||||
("locator", "TEXT", 1, 1),
|
||||
("size", "INTEGER", 1, 0),
|
||||
("mtime_ns", "INTEGER", 1, 0),
|
||||
("removed_at", "TEXT", 1, 0),
|
||||
),
|
||||
META_TABLE: (
|
||||
("singleton_id", "INTEGER", 0, 1),
|
||||
("revision", "INTEGER", 1, 0),
|
||||
),
|
||||
}
|
||||
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql or "").lower()
|
||||
|
||||
|
||||
def _structure_error(conn: sqlite3.Connection, allow_missing: bool) -> str | None:
|
||||
rows = conn.execute(
|
||||
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name IN (?,?) "
|
||||
"OR tbl_name IN (?,?)",
|
||||
TABLES + TABLES,
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return None if allow_missing else "Missing image removal marks schema"
|
||||
tables = {name: sql for kind, name, _tbl, sql in rows if kind == "table"}
|
||||
others = {(kind, name) for kind, name, _tbl, _sql in rows if kind != "table"}
|
||||
if set(tables) != set(TABLES) or others - {("index", _AUTOINDEX)}:
|
||||
return "Image removal marks name is reserved by an unsupported schema object"
|
||||
for table in TABLES:
|
||||
if _compact(tables[table]) != _compact(_TABLE_DDL[table]):
|
||||
return "Image removal marks have an invalid table definition"
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({table})")
|
||||
)
|
||||
if columns != _COLUMNS[table]:
|
||||
return "Image removal marks have invalid columns"
|
||||
return None
|
||||
|
||||
|
||||
def _values_error(conn: sqlite3.Connection) -> str | None:
|
||||
limit = MAX_LOCATOR_LENGTH * 4
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {TABLE} WHERE typeof(locator) != 'text' " # nosec B608
|
||||
f"OR length(CAST(locator AS BLOB)) > {limit} OR locator = '' "
|
||||
"OR typeof(size) != 'integer' OR typeof(mtime_ns) != 'integer' "
|
||||
"OR typeof(removed_at) != 'text' OR length(CAST(removed_at AS BLOB)) > 64 "
|
||||
"LIMIT 1"
|
||||
).fetchone():
|
||||
return "Image removal marks contain invalid values"
|
||||
meta = conn.execute(
|
||||
f"SELECT singleton_id, typeof(revision) FROM {META_TABLE}" # nosec B608
|
||||
).fetchall()
|
||||
if meta != [(1, "integer")]:
|
||||
return "Image removal marks have an invalid revision"
|
||||
return None
|
||||
|
||||
|
||||
def image_removals_schema_error(
|
||||
conn: sqlite3.Connection, *, allow_missing: bool = True
|
||||
) -> str | None:
|
||||
"""Reject unsupported schemas and invalid persisted values (reads only)."""
|
||||
error = _structure_error(conn, allow_missing)
|
||||
if error:
|
||||
return error
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
|
||||
).fetchone()
|
||||
return _values_error(conn) if exists else None
|
||||
|
||||
|
||||
def migrate_image_removals(conn: sqlite3.Connection) -> None:
|
||||
"""Install both tables atomically; validate before and after."""
|
||||
conn.execute("SAVEPOINT image_removals_schema")
|
||||
try:
|
||||
error = image_removals_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
for sql in _TABLE_DDL.values():
|
||||
conn.execute(sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
conn.execute(_SEED_META)
|
||||
error = image_removals_schema_error(conn, allow_missing=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE image_removals_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO image_removals_schema")
|
||||
conn.execute("RELEASE image_removals_schema")
|
||||
raise
|
||||
|
||||
|
||||
_UNMARK_SQL = f"DELETE FROM {TABLE} WHERE locator=?" # nosec B608
|
||||
_MARK_SQL = (
|
||||
f"INSERT OR REPLACE INTO {TABLE} " # nosec B608
|
||||
"(locator, size, mtime_ns, removed_at) VALUES (?, ?, ?, ?)"
|
||||
)
|
||||
_MARK_OF_SQL = f"SELECT size, mtime_ns FROM {TABLE} WHERE locator=?" # nosec B608
|
||||
BUMP_MARKS_SQL = (
|
||||
f"UPDATE {META_TABLE} SET revision=revision+1 WHERE singleton_id=1" # nosec B608
|
||||
)
|
||||
|
||||
|
||||
# Bumped whenever this process writes or clears a mark (even in a transaction
|
||||
# that later rolls back): readers that cache the marks reload when it moves.
|
||||
_generation = 0
|
||||
|
||||
|
||||
def generation() -> int:
|
||||
"""The marks generation of this process (see ``_generation``)."""
|
||||
return _generation
|
||||
|
||||
|
||||
def _bump() -> None:
|
||||
global _generation
|
||||
_generation += 1
|
||||
|
||||
|
||||
def has_marks_table(conn: sqlite3.Connection) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def locator_of_key(root_key: str, relative_key: str) -> str:
|
||||
"""The locator of a favorite-style file key (root key + relative key)."""
|
||||
return os.path.normcase(os.path.join(root_key, *relative_key.split("/")))
|
||||
|
||||
|
||||
def mark_of(conn: sqlite3.Connection, locator: str):
|
||||
"""``(size, mtime_ns)`` of the file's removal mark, or None."""
|
||||
if not has_marks_table(conn):
|
||||
return None
|
||||
row = conn.execute(_MARK_OF_SQL, (locator,)).fetchone()
|
||||
return (row[0], row[1]) if row else None
|
||||
|
||||
|
||||
def write_mark(
|
||||
conn: sqlite3.Connection, locator: str, size: int, mtime_ns: int, now: str
|
||||
):
|
||||
conn.execute(_MARK_SQL, (locator, int(size), int(mtime_ns), now))
|
||||
conn.execute(BUMP_MARKS_SQL)
|
||||
_bump()
|
||||
|
||||
|
||||
def clear_marks(conn: sqlite3.Connection, locators) -> int:
|
||||
"""Delete the marks of *locators* on *conn* (no commit); the count removed.
|
||||
|
||||
Every record write for a file calls this (#480): a file with records is
|
||||
in the library, so it is never hidden.
|
||||
"""
|
||||
locators = list(dict.fromkeys(locators))
|
||||
if not locators or not has_marks_table(conn):
|
||||
return 0
|
||||
removed = 0
|
||||
for locator in locators:
|
||||
removed += conn.execute(_UNMARK_SQL, (locator,)).rowcount
|
||||
if removed:
|
||||
conn.execute(BUMP_MARKS_SQL)
|
||||
_bump()
|
||||
return removed
|
||||
|
||||
|
||||
def clear_marks_of_keys(conn: sqlite3.Connection, file_keys) -> int:
|
||||
"""``clear_marks`` for favorite-style ``(root key, relative key)`` pairs."""
|
||||
return clear_marks(conn, [locator_of_key(*key) for key in file_keys])
|
||||
+41
-12
@@ -7,6 +7,7 @@ from contextlib import contextmanager
|
||||
|
||||
from .library_folders import folder_condition, list_library_folders
|
||||
from .library_tokens import library_edit_token
|
||||
from .prompt_archive import ARCHIVED, SHELVED
|
||||
|
||||
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
|
||||
OR EXISTS(SELECT 1 FROM generated_images eg WHERE eg.prompt_id=p.id)
|
||||
@@ -106,6 +107,12 @@ ARCHIVE_SCOPES = {
|
||||
}
|
||||
|
||||
|
||||
# Shelves (#443): the parents on the shelf, or the archived ones.
|
||||
PARENT_SCOPES = {"active": SHELVED, "archived": ARCHIVED}
|
||||
# A positive pair owner that is on the shelf, as a condition on alias pp.
|
||||
SHELVED_PAIR = SHELVED.replace("p.id", "pp.positive_prompt_id")
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
@@ -270,6 +277,8 @@ class LibraryOperations:
|
||||
rows = conn.execute(
|
||||
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
|
||||
"p.run_count,p.last_used_at,"
|
||||
+ ARCHIVED
|
||||
+ " AS archived,"
|
||||
+ (f"{sort_expression} AS _sort," if sort_expression else "")
|
||||
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
|
||||
"AND v.archived=0) AS variant_count,"
|
||||
@@ -293,6 +302,7 @@ class LibraryOperations:
|
||||
# Usage is shown, never compared: the token hashes owner fields only.
|
||||
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
||||
row["last_used_at"] = utc_iso(row["last_used_at"])
|
||||
row["archived"] = bool(row["archived"])
|
||||
row["edit_token"] = library_edit_token(epoch, row)
|
||||
return result
|
||||
|
||||
@@ -310,10 +320,13 @@ class LibraryOperations:
|
||||
sort: str = "newest",
|
||||
limit: int = 40,
|
||||
after: dict | None = None,
|
||||
shelf: str = "active",
|
||||
) -> dict:
|
||||
_limit(limit)
|
||||
if sort not in _ORDERS:
|
||||
raise ValueError("Unknown library sort order")
|
||||
if not isinstance(shelf, str) or shelf not in PARENT_SCOPES:
|
||||
raise ValueError("shelf must be active or archived")
|
||||
where, params = _filter(
|
||||
text, category, [] if tags is None else tags, tag_mode, folder_prefixes
|
||||
)
|
||||
@@ -328,15 +341,18 @@ class LibraryOperations:
|
||||
ceiling = after["ceiling"]
|
||||
where += " AND p.id<=?"
|
||||
params.append(ceiling)
|
||||
# One SELECT counts the filtered parents and their active variants.
|
||||
total, variant_total = conn.execute(
|
||||
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
|
||||
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
|
||||
+ _FROM
|
||||
+ " WHERE "
|
||||
+ where,
|
||||
# One SELECT counts the filtered parents of this scope, their active
|
||||
# variants, and the filtered archived parents (Show archived, #443).
|
||||
scope = PARENT_SCOPES[shelf]
|
||||
total, variant_total, archived_total = conn.execute(
|
||||
f"SELECT COALESCE(SUM({scope}),0),"
|
||||
f"COALESCE(SUM(CASE WHEN {scope} THEN (SELECT COUNT(*) "
|
||||
"FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
|
||||
"AND v.archived=0) ELSE 0 END),0),"
|
||||
f"COALESCE(SUM({ARCHIVED}),0) FROM " + _FROM + " WHERE " + where,
|
||||
params,
|
||||
).fetchone()
|
||||
where += f" AND {scope}"
|
||||
expression, direction = _ORDERS[sort]
|
||||
if after is not None:
|
||||
operator = ">" if direction == "ASC" else "<"
|
||||
@@ -369,6 +385,7 @@ class LibraryOperations:
|
||||
"items": rows,
|
||||
"total": total,
|
||||
"variant_total": variant_total,
|
||||
"archived_total": archived_total,
|
||||
# Echoed so a client can tell this read applied its tag match.
|
||||
"tag_mode": tag_mode,
|
||||
"has_more": has_more,
|
||||
@@ -386,6 +403,7 @@ class LibraryOperations:
|
||||
sort: str = "newest",
|
||||
limit: int = 40,
|
||||
after: dict | None = None,
|
||||
shelf: str = "active",
|
||||
) -> dict:
|
||||
with self.library_snapshot(include_ceiling=True) as (conn, epoch, ceiling):
|
||||
return self._list_library(
|
||||
@@ -400,6 +418,7 @@ class LibraryOperations:
|
||||
sort=sort,
|
||||
limit=limit,
|
||||
after=after,
|
||||
shelf=shelf,
|
||||
)
|
||||
|
||||
def _is_library_positive(self, conn: sqlite3.Connection, positive_id: int) -> bool:
|
||||
@@ -644,7 +663,9 @@ class LibraryOperations:
|
||||
owners = {}
|
||||
order = []
|
||||
for row in conn.execute(
|
||||
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes FROM "
|
||||
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,"
|
||||
+ ARCHIVED
|
||||
+ " AS archived FROM "
|
||||
+ _FROM
|
||||
+ " WHERE "
|
||||
+ _ELIGIBLE
|
||||
@@ -657,6 +678,7 @@ class LibraryOperations:
|
||||
"tags": [],
|
||||
"rating": row["rating"],
|
||||
"notes": row["notes"],
|
||||
"archived": bool(row["archived"]),
|
||||
"variants": [],
|
||||
}
|
||||
order.append(row["id"])
|
||||
@@ -701,8 +723,9 @@ class LibraryOperations:
|
||||
"""System › Overview counts from one snapshot, writing nothing.
|
||||
|
||||
``positive_prompts`` and ``negative_variants`` are the unfiltered
|
||||
Library's ``total`` and ``variant_total``: eligible parents and active
|
||||
pairs (every pair's positive is eligible). ``images`` counts indexed
|
||||
Library's ``total`` and ``variant_total``: eligible parents on the
|
||||
shelf (not archived, #443) and their active pairs (every pair's
|
||||
positive is eligible). ``images`` counts indexed
|
||||
``generated_images`` rows, not files on disk. An image has no rating
|
||||
of its own (it shows its linked prompt's), so ``rated_images`` counts
|
||||
the indexed images whose linked prompt is rated. ``review_pending`` is
|
||||
@@ -710,8 +733,14 @@ class LibraryOperations:
|
||||
"""
|
||||
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
||||
row = conn.execute(
|
||||
"SELECT (SELECT COUNT(*) FROM prompts p WHERE " + _ELIGIBLE + "),"
|
||||
"(SELECT COUNT(*) FROM prompt_pairs WHERE archived=0),"
|
||||
"SELECT (SELECT COUNT(*) FROM prompts p WHERE "
|
||||
+ _ELIGIBLE
|
||||
+ " AND "
|
||||
+ SHELVED
|
||||
+ "),"
|
||||
"(SELECT COUNT(*) FROM prompt_pairs pp WHERE pp.archived=0 AND "
|
||||
+ SHELVED_PAIR
|
||||
+ "),"
|
||||
"(SELECT COUNT(*) FROM generated_images),"
|
||||
"(SELECT COUNT(*) FROM generated_images gi JOIN prompts r "
|
||||
"ON r.id=gi.prompt_id WHERE r.rating IS NOT NULL),"
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Delete selected Library prompts; archive the paired ones (#443, D152).
|
||||
|
||||
A selected Library parent is classified, in this order, as ``not_found``
|
||||
(missing, or not a Library parent), ``protected`` (tagged ``__protected__``;
|
||||
never changed), ``already_archived``, ``archive`` (the positive or negative of
|
||||
any saved pair, archived pair or not: archiving hides it from the shelf and
|
||||
removes nothing) or ``delete`` (in no pair: deleted exactly as the legacy
|
||||
delete does, through ``delete_prompt_rows``). Every command checks the
|
||||
storage epoch first and runs in one ``BEGIN IMMEDIATE`` transaction.
|
||||
|
||||
The write executes what the user reviewed: it is given the reviewed archive
|
||||
and delete lists, and deletes only reviewed deletes the route backed up
|
||||
(``candidates``) whose fingerprint is unchanged since that backup.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
from .library import _ELIGIBLE
|
||||
from .library_commands import _validate_bulk_ids, _validate_epoch
|
||||
from .models import utc_now_iso
|
||||
from .prompt_archive import ARCHIVED as _ARCHIVED
|
||||
from .prompt_archive import TABLE as ARCHIVE
|
||||
from .workflow_documents import documents_of_rows, release_documents
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
# Ids bound per statement: the pair check binds each id twice, which stays
|
||||
# under SQLite's historical 999-variable limit (maintenance.py ID_BATCH).
|
||||
ID_BATCH = 400
|
||||
_PROTECTED = (
|
||||
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.prompt_id=p.id AND t.name='__protected__')"
|
||||
)
|
||||
_PAIRED = (
|
||||
"EXISTS(SELECT 1 FROM prompt_pairs pp "
|
||||
"WHERE pp.positive_prompt_id=p.id OR pp.negative_prompt_id=p.id)"
|
||||
)
|
||||
# #482: used as the negative of another positive's pair (archived or not):
|
||||
# never deleted, so that positive's variant stays (archive only).
|
||||
_NEGATIVE_OF_OTHER = (
|
||||
"EXISTS(SELECT 1 FROM prompt_pairs op "
|
||||
"WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)"
|
||||
)
|
||||
# Classes in the order a prompt is tested; the first that holds wins.
|
||||
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _batches(ids: list) -> Iterable[list]:
|
||||
for start in range(0, len(ids), ID_BATCH):
|
||||
yield ids[start : start + ID_BATCH]
|
||||
|
||||
|
||||
def delete_prompt_rows(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
|
||||
"""Delete prompts and their image link rows; return the prompts deleted.
|
||||
|
||||
The legacy delete's statements (``delete_prompt``, ``bulk_delete_prompts``)
|
||||
in the caller's transaction: image link rows first (``image_prompt_pairs``
|
||||
cascades, review decisions keep a NULL image), then the prompts (tags and
|
||||
Library state cascade), then the workflow documents no row references.
|
||||
Image files are never touched. The caller checks pairs first.
|
||||
"""
|
||||
documents = documents_of_rows(conn, "prompt_id", prompt_ids)
|
||||
deleted = 0
|
||||
for chunk in _batches(list(prompt_ids)):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
conn.execute(
|
||||
f"DELETE FROM generated_images WHERE prompt_id IN ({marks})", # nosec
|
||||
chunk,
|
||||
)
|
||||
deleted += conn.execute(
|
||||
f"DELETE FROM prompts WHERE id IN ({marks})", chunk # nosec B608
|
||||
).rowcount
|
||||
release_documents(conn, documents)
|
||||
return deleted
|
||||
|
||||
|
||||
class LibraryArchiveOperations:
|
||||
"""PromptDatabase mixin: classify, archive or delete, and restore parents."""
|
||||
|
||||
def _classify_library_delete(
|
||||
self, conn: sqlite3.Connection, prompt_ids: list[int]
|
||||
) -> dict:
|
||||
found, deletable = {}, set()
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for row in conn.execute(
|
||||
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED}," # nosec B608
|
||||
f"{_NEGATIVE_OF_OTHER} "
|
||||
f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
|
||||
chunk,
|
||||
):
|
||||
protected, archived, paired, negative_of_other = row[1:5]
|
||||
if protected:
|
||||
found[row[0]] = "protected"
|
||||
elif archived:
|
||||
found[row[0]] = "already_archived"
|
||||
elif paired:
|
||||
found[row[0]] = "archive"
|
||||
if not negative_of_other:
|
||||
deletable.add(row[0])
|
||||
else:
|
||||
found[row[0]] = "delete"
|
||||
classes = {name: [] for name in CLASSES}
|
||||
for pid in prompt_ids:
|
||||
classes[found.get(pid, "not_found")].append(pid)
|
||||
# #482: the archive ids Delete permanently may delete with their pairs.
|
||||
classes["deletable"] = [pid for pid in classes["archive"] if pid in deletable]
|
||||
return classes
|
||||
|
||||
@staticmethod
|
||||
def _image_links(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
|
||||
total = 0
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
total += conn.execute(
|
||||
"SELECT COUNT(*) FROM generated_images " # nosec B608
|
||||
f"WHERE prompt_id IN ({marks})",
|
||||
chunk,
|
||||
).fetchone()[0]
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _fingerprints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
||||
"""What a safety backup holds of each prompt a delete would remove.
|
||||
|
||||
The prompt row (usage included), its tag ids, its Library title and
|
||||
its image link rows (count and newest id), as a JSON-safe list.
|
||||
"""
|
||||
prints = {}
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for row in conn.execute(
|
||||
"SELECT p.id,p.text,p.hash,p.category,p.rating,p.notes," # nosec
|
||||
"p.updated_at,p.run_count,p.last_used_at,"
|
||||
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM prompt_tags "
|
||||
"WHERE prompt_id=p.id ORDER BY tag_id)),"
|
||||
"(SELECT title FROM prompt_library_state WHERE prompt_id=p.id),"
|
||||
"(SELECT COUNT(*) FROM generated_images WHERE prompt_id=p.id),"
|
||||
"(SELECT MAX(id) FROM generated_images WHERE prompt_id=p.id) "
|
||||
f"FROM prompts p WHERE p.id IN ({marks})",
|
||||
chunk,
|
||||
):
|
||||
prints[row[0]] = [
|
||||
value if isinstance(value, (int, str, type(None))) else repr(value)
|
||||
for value in tuple(row)[1:]
|
||||
]
|
||||
return prints
|
||||
|
||||
@staticmethod
|
||||
def _distinct_images(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
|
||||
"""Image rows of ``prompt_ids`` (each image once; #482 deletes them)."""
|
||||
return LibraryArchiveOperations._image_links(conn, prompt_ids)
|
||||
|
||||
@staticmethod
|
||||
def _pair_prints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
||||
"""#482: each prompt's own saved pairs, as the review saw them.
|
||||
|
||||
Per pair (by id): its negative, archived flag, metadata, usage, ordered
|
||||
tag ids and its image-pair links (count and newest id).
|
||||
"""
|
||||
prints = {pid: [] for pid in prompt_ids}
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for row in conn.execute(
|
||||
"SELECT pp.positive_prompt_id,pp.id,pp.negative_prompt_id," # nosec
|
||||
"pp.archived,pp.title,pp.category,pp.rating,pp.notes,pp.run_count,"
|
||||
"pp.last_used_at,pp.updated_at,"
|
||||
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM "
|
||||
"prompt_pair_tags WHERE pair_id=pp.id ORDER BY tag_id)),"
|
||||
"(SELECT COUNT(*) FROM image_prompt_pairs WHERE pair_id=pp.id),"
|
||||
"(SELECT MAX(id) FROM image_prompt_pairs WHERE pair_id=pp.id) "
|
||||
f"FROM prompt_pairs pp WHERE pp.positive_prompt_id IN ({marks}) "
|
||||
"ORDER BY pp.id",
|
||||
chunk,
|
||||
):
|
||||
prints[row[0]].append(
|
||||
[
|
||||
v if isinstance(v, (int, str, type(None))) else repr(v)
|
||||
for v in tuple(row)[1:]
|
||||
]
|
||||
)
|
||||
return prints
|
||||
|
||||
@classmethod
|
||||
def _review_digests(cls, conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
||||
"""#482: one digest per deletable prompt of its row and its pairs."""
|
||||
prompts = cls._fingerprints(conn, prompt_ids)
|
||||
pairs = cls._pair_prints(conn, prompt_ids)
|
||||
return {
|
||||
pid: hashlib.sha256(
|
||||
json.dumps(
|
||||
[prompts.get(pid), pairs[pid]], separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
for pid in prompt_ids
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _paired_counts(
|
||||
conn: sqlite3.Connection, prompt_ids: list[int], selected: list[int]
|
||||
) -> dict:
|
||||
"""#482: what Delete permanently would remove with ``prompt_ids``."""
|
||||
pairs = archived = variants = 0
|
||||
negatives = set()
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for archived_flag, negative, positive in conn.execute(
|
||||
"SELECT archived,negative_prompt_id,positive_prompt_id " # nosec
|
||||
f"FROM prompt_pairs WHERE positive_prompt_id IN ({marks})",
|
||||
chunk,
|
||||
):
|
||||
pairs += 1
|
||||
archived += 1 if archived_flag else 0
|
||||
if negative is not None:
|
||||
variants += 1
|
||||
if negative != positive:
|
||||
negatives.add(negative)
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"archived_pairs": archived,
|
||||
"variants": variants,
|
||||
"kept_negatives": len(negatives - set(selected)),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]:
|
||||
_validate_epoch(expected_epoch)
|
||||
_validate_bulk_ids(prompt_ids)
|
||||
return list(dict.fromkeys(prompt_ids))
|
||||
|
||||
def classify_library_delete(
|
||||
self, prompt_ids: list[int], *, expected_epoch: str
|
||||
) -> dict:
|
||||
"""What a delete of ``prompt_ids`` would do now (reads only).
|
||||
|
||||
Returns the id lists per class, in request order, ``image_links``
|
||||
(the image link rows the ``delete`` class would remove) and
|
||||
``fingerprints`` of the ``delete`` class (``_fingerprints``).
|
||||
"""
|
||||
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
|
||||
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
deletable = classes["deletable"]
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
**classes,
|
||||
"image_links": self._image_links(conn, classes["delete"]),
|
||||
"fingerprints": self._fingerprints(conn, classes["delete"]),
|
||||
# #482: Delete permanently, with the deletable prompts' pairs.
|
||||
"review_digests": self._review_digests(conn, deletable),
|
||||
"paired_counts": self._paired_counts(conn, deletable, prompt_ids),
|
||||
"paired_image_links": self._distinct_images(conn, deletable),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _repair_hash(conn: sqlite3.Connection, prompt_id: int) -> None:
|
||||
"""Give an archived prompt its canonical hash, so ingestion finds it.
|
||||
|
||||
Scans, the monitor and node saves resolve prompts by hash; a stale or
|
||||
missing one would make them insert a visible copy (Q3). When another
|
||||
row already holds the canonical hash, lookups find that row and no new
|
||||
copy is made, so nothing is written.
|
||||
"""
|
||||
text, stored = conn.execute(
|
||||
"SELECT text,hash FROM prompts WHERE id=?", (prompt_id,)
|
||||
).fetchone()
|
||||
canonical = generate_prompt_hash(text) if isinstance(text, str) else None
|
||||
if canonical is None or stored == canonical:
|
||||
return
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM prompts WHERE hash=? AND id!=?", (canonical, prompt_id)
|
||||
).fetchone():
|
||||
return
|
||||
conn.execute("UPDATE prompts SET hash=? WHERE id=?", (canonical, prompt_id))
|
||||
|
||||
def archive_or_delete_library_prompts(
|
||||
self,
|
||||
archive_ids: list[int],
|
||||
delete_ids: list[int],
|
||||
*,
|
||||
expected_epoch: str,
|
||||
candidates: dict | None,
|
||||
delete_paired: dict | None = None,
|
||||
) -> dict:
|
||||
"""Archive and delete what the user reviewed, all or nothing.
|
||||
|
||||
``archive_ids`` and ``delete_ids`` are the reviewed classes.
|
||||
``candidates`` maps each reviewed delete the route backed up to its
|
||||
fingerprint then (None: every reviewed delete, unchecked; tests and
|
||||
callers without a backup). A reviewed delete is deleted only when it
|
||||
still classifies ``delete`` and its fingerprint is unchanged; one that
|
||||
is now paired, and a reviewed archive now unpaired, are archived (the
|
||||
restorable direction); anything else is kept and counted.
|
||||
|
||||
``delete_paired`` (#482) maps reviewed archive ids the user chose to
|
||||
Delete permanently to their review digest. Each is deleted with its
|
||||
own saved pairs only while it is still deletable and its digest is
|
||||
unchanged; otherwise it is archived and counted ``changed``.
|
||||
"""
|
||||
_validate_epoch(expected_epoch)
|
||||
for ids in (archive_ids, delete_ids):
|
||||
if not isinstance(ids, list) or any(
|
||||
type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in ids
|
||||
):
|
||||
raise ValueError("prompt ids must be lists of positive integers")
|
||||
reviewed_delete = set(delete_ids)
|
||||
delete_paired = {} if delete_paired is None else delete_paired
|
||||
if not isinstance(delete_paired, dict) or any(
|
||||
type(pid) is not int
|
||||
or not isinstance(digest, str)
|
||||
or not _DIGEST.match(digest)
|
||||
for pid, digest in delete_paired.items()
|
||||
):
|
||||
raise ValueError("delete_paired must map prompt ids to review digests")
|
||||
if not set(delete_paired) <= set(archive_ids):
|
||||
raise ValueError("A permanent delete must be a reviewed archive id")
|
||||
if not archive_ids and not delete_ids:
|
||||
raise ValueError("Nothing to delete or archive")
|
||||
if reviewed_delete & set(archive_ids):
|
||||
raise ValueError("A prompt cannot be both archived and deleted")
|
||||
prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids]))
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
now_prints = self._fingerprints(conn, classes["delete"])
|
||||
deletes, archives, changed = [], [], 0
|
||||
paired_now = set(classes["deletable"])
|
||||
chosen = [pid for pid in classes["archive"] if pid in delete_paired]
|
||||
digests = self._review_digests(
|
||||
conn, [pid for pid in chosen if pid in paired_now]
|
||||
)
|
||||
paired_deletes = []
|
||||
for pid in classes["archive"]:
|
||||
if pid not in delete_paired:
|
||||
archives.append(pid)
|
||||
elif digests.get(pid) == delete_paired[pid]:
|
||||
paired_deletes.append(pid)
|
||||
else:
|
||||
archives.append(pid) # changed since the review: restorable
|
||||
changed += 1
|
||||
for pid in classes["delete"]:
|
||||
if pid not in reviewed_delete:
|
||||
archives.append(pid) # reviewed as archive, now unpaired
|
||||
changed += 1 if pid in delete_paired else 0
|
||||
elif candidates is None or candidates.get(pid) == now_prints[pid]:
|
||||
deletes.append(pid)
|
||||
else:
|
||||
changed += 1
|
||||
changed += len(classes["protected"])
|
||||
image_links = self._image_links(conn, [*deletes, *paired_deletes])
|
||||
now = utc_now_iso()
|
||||
for pid in archives:
|
||||
self._repair_hash(conn, pid)
|
||||
conn.executemany(
|
||||
f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)",
|
||||
[(pid, now) for pid in archives],
|
||||
)
|
||||
# A changed row is the newest edit the restore fingerprint reads.
|
||||
conn.executemany(
|
||||
"UPDATE prompts SET updated_at=? WHERE id=?",
|
||||
[(now, pid) for pid in archives],
|
||||
)
|
||||
# #482: the image-pair links (RESTRICT) go first, then the pairs
|
||||
# (pair tags CASCADE, review decisions SET NULL), then the prompts.
|
||||
pairs_removed = 0
|
||||
for chunk in _batches(paired_deletes):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
pairs_of = (
|
||||
"SELECT id FROM prompt_pairs " # nosec B608
|
||||
f"WHERE positive_prompt_id IN ({marks})"
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM image_prompt_pairs WHERE pair_id IN ({pairs_of})",
|
||||
chunk,
|
||||
)
|
||||
pairs_removed += conn.execute(
|
||||
"DELETE FROM prompt_pairs " # nosec B608
|
||||
f"WHERE positive_prompt_id IN ({marks})",
|
||||
chunk,
|
||||
).rowcount
|
||||
deleted = delete_prompt_rows(conn, [*deletes, *paired_deletes])
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"deleted": deleted,
|
||||
"archived": len(archives),
|
||||
"already_archived": len(classes["already_archived"]),
|
||||
"not_found": len(classes["not_found"]),
|
||||
"changed": changed,
|
||||
"image_links": image_links,
|
||||
"pairs_removed": pairs_removed,
|
||||
}
|
||||
|
||||
def restore_library_prompts(
|
||||
self, prompt_ids: list[int], *, expected_epoch: str
|
||||
) -> dict:
|
||||
"""Put archived prompts back on the shelf, all or nothing.
|
||||
|
||||
Every requested id with an archive row is restored, whatever its
|
||||
protection or Library parent state: restoring only un-hides it.
|
||||
"""
|
||||
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
restored, existing = [], set()
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for pid, archived in conn.execute(
|
||||
f"SELECT p.id,{_ARCHIVED} FROM prompts p " # nosec B608
|
||||
f"WHERE p.id IN ({marks})",
|
||||
chunk,
|
||||
):
|
||||
existing.add(pid)
|
||||
if archived:
|
||||
restored.append(pid)
|
||||
now = utc_now_iso()
|
||||
conn.executemany(
|
||||
f"DELETE FROM {ARCHIVE} WHERE prompt_id=?", [(pid,) for pid in restored]
|
||||
)
|
||||
conn.executemany(
|
||||
"UPDATE prompts SET updated_at=? WHERE id=?",
|
||||
[(now, pid) for pid in restored],
|
||||
)
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"restored": len(restored),
|
||||
"unchanged": len(existing) - len(restored),
|
||||
"not_found": len(prompt_ids) - len(existing),
|
||||
}
|
||||
+157
-26
@@ -15,6 +15,8 @@ change:
|
||||
``cleanup_orphaned_images``), counted only under a source that can be read
|
||||
now, as the health check's ``missing_files`` judges them
|
||||
(``health.link_states``). It deletes database rows only, never a file.
|
||||
It also forgets the removal marks (#480) that hide nothing any more: the
|
||||
file is gone or changed, under a readable source (#501, ``_stale_marks``).
|
||||
|
||||
``preview_maintenance`` reads one snapshot on its own ``mode=ro`` connection
|
||||
(``health.readonly_uri``) and closes it in ``finally``; files are looked at
|
||||
@@ -41,6 +43,8 @@ from pathlib import Path
|
||||
|
||||
from .execution_contexts import LIVE_HINT_CTES
|
||||
from .health import BATCH_ROWS, link_states, readable_roots, readonly_uri
|
||||
from .image_removal_schema import TABLE as MARKS_TABLE
|
||||
from .image_removal_schema import clear_marks, has_marks_table
|
||||
from .pairs import PairDependencyError
|
||||
from .workflow_documents import documents_of_rows, release_documents
|
||||
|
||||
@@ -85,6 +89,12 @@ PROTECTIONS = (
|
||||
"with Library state (a title or an explicit parent)",
|
||||
"with Library state (a title or an explicit parent)",
|
||||
),
|
||||
(
|
||||
# #443: an archived prompt is restorable, never pruned.
|
||||
"EXISTS (SELECT 1 FROM prompt_archive pa WHERE pa.prompt_id = p.id)",
|
||||
"archived in the Library",
|
||||
"archived in the Library",
|
||||
),
|
||||
(
|
||||
"p.id IN (SELECT prompt_id FROM live_hint_prompts)",
|
||||
"used by recorded runs",
|
||||
@@ -93,7 +103,8 @@ PROTECTIONS = (
|
||||
)
|
||||
|
||||
# Exempt references: images, pairs, the __protected__ tag, Library parent
|
||||
# state, and live recorded-run hints (computed once, see LIVE_HINT_CTES).
|
||||
# state, the Library archive, and live recorded-run hints (computed once,
|
||||
# see LIVE_HINT_CTES).
|
||||
ORPHANED_PROMPTS_SQL = (
|
||||
"WITH "
|
||||
+ LIVE_HINT_CTES
|
||||
@@ -187,6 +198,8 @@ class MaintenanceDraft:
|
||||
protected: dict
|
||||
media: dict
|
||||
details: list = field(default_factory=list)
|
||||
# missing_links only: locators of removal marks whose file is gone (#501).
|
||||
marks: list = field(default_factory=list)
|
||||
|
||||
def public(self):
|
||||
"""The plan fields a response may carry (no fingerprint, no targets)."""
|
||||
@@ -289,6 +302,90 @@ def _delete_ids(conn, table, ids):
|
||||
return removed
|
||||
|
||||
|
||||
def _mark_rows(conn, only=None):
|
||||
"""``[(locator, size, mtime_ns)]`` of the removal marks (all, or those
|
||||
among *only*), sorted by locator."""
|
||||
if not has_marks_table(conn):
|
||||
return []
|
||||
sql = f"SELECT locator, size, mtime_ns FROM {MARKS_TABLE}" # nosec B608
|
||||
if only is None:
|
||||
return sorted(tuple(row) for row in conn.execute(sql))
|
||||
found = []
|
||||
for batch in _batches(sorted(only)):
|
||||
found.extend(
|
||||
tuple(row)
|
||||
for row in conn.execute(
|
||||
f"{sql} WHERE locator IN ({_marks(batch)})", batch # nosec B608
|
||||
)
|
||||
)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _lists_something(folder):
|
||||
"""Whether *folder* holds at least one entry (an unplugged drive's mount
|
||||
point, or an unbound volume, is an empty folder that reads fine)."""
|
||||
try:
|
||||
with os.scandir(folder) as entries:
|
||||
return next(entries, None) is not None
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _name_listed(path):
|
||||
"""Whether *path*'s folder lists its name, compared under ``normcase``.
|
||||
|
||||
A locator is ``normcase``d; on a case-sensitive NTFS folder ``lstat`` of
|
||||
the lower-cased spelling fails for a file that is there.
|
||||
"""
|
||||
folder, name = os.path.split(path)
|
||||
wanted = os.path.normcase(name)
|
||||
try:
|
||||
with os.scandir(folder) as entries:
|
||||
return any(os.path.normcase(entry.name) == wanted for entry in entries)
|
||||
except OSError:
|
||||
return True # cannot tell: keep the mark
|
||||
|
||||
|
||||
def _stale_mark(locator, size, mtime_ns):
|
||||
"""Whether the file of a mark is gone or has other bytes (blocking)."""
|
||||
try:
|
||||
info = os.lstat(locator)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return os.path.isdir(os.path.dirname(locator)) and not _name_listed(locator)
|
||||
except OSError:
|
||||
return False
|
||||
return (info.st_size, info.st_mtime_ns) != (size, mtime_ns)
|
||||
|
||||
|
||||
def _stale_marks(marks, roots):
|
||||
"""``[(locator, owner)]`` of the marks that hide nothing any more (#501).
|
||||
|
||||
*marks* are ``(locator, size, mtime_ns)`` rows. A mark is stale when its
|
||||
file was changed, or is gone from a folder that still exists. Only marks
|
||||
under a source that can be read now and lists something are judged (an
|
||||
unplugged drive's marks, or its empty mount point's, stay); any doubt
|
||||
keeps the mark. *owner* is the index of the longest source holding it.
|
||||
Locators are ``normcase(realpath)`` paths, so the sources are too.
|
||||
"""
|
||||
readable = readable_roots(roots)
|
||||
prefixes = [
|
||||
os.path.normcase(os.path.realpath(root)).rstrip("\\/") + os.sep
|
||||
for root in roots
|
||||
]
|
||||
judged = {}
|
||||
stale = []
|
||||
for locator, size, mtime_ns in marks:
|
||||
owners = [i for i, prefix in enumerate(prefixes) if locator.startswith(prefix)]
|
||||
if not owners:
|
||||
continue # under no source now (a source removed from the settings)
|
||||
owner = max(owners, key=lambda i: len(prefixes[i]))
|
||||
if owner not in judged:
|
||||
judged[owner] = readable[owner] and _lists_something(roots[owner])
|
||||
if judged[owner] and _stale_mark(locator, size, mtime_ns):
|
||||
stale.append((locator, owner))
|
||||
return stale
|
||||
|
||||
|
||||
def _read_snapshot(db_path, read):
|
||||
"""``read(conn)`` on one ``mode=ro`` snapshot; the connection always closes."""
|
||||
conn = sqlite3.connect(readonly_uri(db_path), uri=True)
|
||||
@@ -431,8 +528,11 @@ class MaintenanceOperations:
|
||||
if operation == "workflow_storage":
|
||||
return self._plan_workflow_storage()
|
||||
if operation == "missing_links":
|
||||
rows = _read_snapshot(self.model.db_path, self._link_rows)
|
||||
return self._plan_missing_links(rows, roots)
|
||||
rows, marks = _read_snapshot(
|
||||
self.model.db_path,
|
||||
lambda conn: (self._link_rows(conn), _mark_rows(conn)),
|
||||
)
|
||||
return self._plan_missing_links(rows, roots, _stale_marks(marks, roots))
|
||||
reader = (
|
||||
self._plan_duplicate_prompts
|
||||
if operation == "duplicate_prompts"
|
||||
@@ -563,13 +663,15 @@ class MaintenanceOperations:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _links_fingerprint(candidates):
|
||||
def _links_fingerprint(candidates, stale=()):
|
||||
fingerprint = _Fingerprint("missing_links")
|
||||
for row, owner in candidates:
|
||||
fingerprint.add([row[0], row[1], row[3], owner])
|
||||
for locator, owner in stale:
|
||||
fingerprint.add(["mark", locator, owner])
|
||||
return fingerprint.hexdigest()
|
||||
|
||||
def _plan_missing_links(self, rows, roots):
|
||||
def _plan_missing_links(self, rows, roots, stale=()):
|
||||
readable = readable_roots(roots)
|
||||
candidates, unavailable, outside = [], 0, 0
|
||||
for row, owner, state in link_states(rows, roots, readable):
|
||||
@@ -597,29 +699,47 @@ class MaintenanceOperations:
|
||||
)
|
||||
)
|
||||
count = len(candidates)
|
||||
parts = []
|
||||
if count:
|
||||
parts.append(
|
||||
_plural(count, "link to a missing file", "links to missing files")
|
||||
)
|
||||
if stale:
|
||||
parts.append(
|
||||
_plural(
|
||||
len(stale),
|
||||
"removed image whose file is gone or changed",
|
||||
"removed images whose files are gone or changed",
|
||||
)
|
||||
)
|
||||
details = [
|
||||
{
|
||||
"label": _relative(row[1], roots[owner]),
|
||||
"detail": f"{_source_name(roots[owner], owner)} · the file is missing",
|
||||
}
|
||||
for row, owner in candidates
|
||||
] + [
|
||||
{
|
||||
"label": _relative(locator, os.path.realpath(roots[owner])),
|
||||
"detail": (
|
||||
f"{_source_name(roots[owner], owner)} · removed image, "
|
||||
"the file is gone or changed"
|
||||
),
|
||||
}
|
||||
for locator, owner in stale
|
||||
]
|
||||
return MaintenanceDraft(
|
||||
operation="missing_links",
|
||||
fingerprint=self._links_fingerprint(candidates),
|
||||
fingerprint=self._links_fingerprint(candidates, stale),
|
||||
targets=[row[0] for row, _owner in candidates],
|
||||
scope={
|
||||
"count": count,
|
||||
"label": (
|
||||
_plural(count, "link to a missing file", "links to missing files")
|
||||
if count
|
||||
else "No links to missing files"
|
||||
),
|
||||
"count": count + len(stale),
|
||||
"label": "; ".join(parts) or "No links to missing files",
|
||||
},
|
||||
protected={"count": unavailable + outside, "reasons": reasons},
|
||||
media={"deletes_files": False, "label": LINKS_ONLY},
|
||||
details=[
|
||||
{
|
||||
"label": _relative(row[1], roots[owner]),
|
||||
"detail": (
|
||||
f"{_source_name(roots[owner], owner)} · the file is missing"
|
||||
),
|
||||
}
|
||||
for row, owner in candidates[:MAX_DETAILS]
|
||||
],
|
||||
details=details[:MAX_DETAILS],
|
||||
marks=[locator for locator, _owner in stale],
|
||||
)
|
||||
|
||||
def _plan_optimize(self):
|
||||
@@ -669,14 +789,18 @@ class MaintenanceOperations:
|
||||
|
||||
# ── apply ─────────────────────────────────────────────────────────
|
||||
|
||||
def apply_maintenance(self, operation, fingerprint, targets, roots=()) -> dict:
|
||||
def apply_maintenance(
|
||||
self, operation, fingerprint, targets, roots=(), marks=()
|
||||
) -> dict:
|
||||
"""Apply a reviewed plan exactly, or raise PlanChanged (blocking).
|
||||
|
||||
Inside ``BEGIN IMMEDIATE`` the fingerprint is computed again on the
|
||||
write connection; when it differs nothing is written. ``targets`` and
|
||||
``roots`` are the plan's. Returns ``{changed, rows}``: how many
|
||||
records changed and up to ``MAX_RESULT_ROWS`` descriptions of them
|
||||
(``{prompt_id[, kept_id]}`` or ``{image_path}``).
|
||||
(``{prompt_id[, kept_id]}`` or ``{image_path}``). ``marks`` are a
|
||||
missing-links plan's stale removal marks; that apply also returns
|
||||
``marks``, how many it forgot.
|
||||
"""
|
||||
if operation not in MAINTENANCE_OPERATIONS or operation == "workflow_storage":
|
||||
raise ValueError(f"Unknown maintenance operation {operation!r}")
|
||||
@@ -689,9 +813,12 @@ class MaintenanceOperations:
|
||||
appliers = {
|
||||
"duplicate_prompts": self._apply_duplicate_prompts,
|
||||
"unused_components": self._apply_unused_components,
|
||||
"missing_links": self._apply_missing_links,
|
||||
}
|
||||
with self._pair_transaction() as conn:
|
||||
if operation == "missing_links":
|
||||
return self._apply_missing_links(
|
||||
conn, fingerprint, targets, roots, list(marks)
|
||||
)
|
||||
return appliers[operation](conn, fingerprint, targets, roots)
|
||||
|
||||
def _apply_duplicate_prompts(self, conn, fingerprint, targets, _roots):
|
||||
@@ -720,7 +847,7 @@ class MaintenanceOperations:
|
||||
]
|
||||
return {"changed": changed, "rows": rows}
|
||||
|
||||
def _apply_missing_links(self, conn, fingerprint, targets, roots):
|
||||
def _apply_missing_links(self, conn, fingerprint, targets, roots, marks):
|
||||
rows = []
|
||||
for batch in _batches(sorted(targets)):
|
||||
rows.extend(
|
||||
@@ -737,10 +864,14 @@ class MaintenanceOperations:
|
||||
if state != "missing": # back, or its source went away
|
||||
raise PlanChanged("missing_links")
|
||||
candidates.append((row, owner))
|
||||
if self._links_fingerprint(candidates) != fingerprint:
|
||||
stale = _stale_marks(_mark_rows(conn, marks) if marks else [], roots)
|
||||
if [locator for locator, _owner in stale] != marks: # a mark went or is valid
|
||||
raise PlanChanged("missing_links")
|
||||
if self._links_fingerprint(candidates, stale) != fingerprint:
|
||||
raise PlanChanged("missing_links")
|
||||
changed = _delete_ids(conn, "generated_images", [row[0] for row in rows])
|
||||
return {
|
||||
"changed": changed,
|
||||
"marks": clear_marks(conn, marks),
|
||||
"rows": [{"image_path": row[1]} for row in rows[:MAX_RESULT_ROWS]],
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ try:
|
||||
except ImportError:
|
||||
from utils.hashing import register_prompt_functions
|
||||
from .image_categories import image_categories_schema_error, migrate_image_categories
|
||||
from .image_removal_schema import image_removals_schema_error, migrate_image_removals
|
||||
from .prompt_archive import migrate_prompt_archive, prompt_archive_schema_error
|
||||
from .workflow_documents import (
|
||||
DOCUMENT_ID_COLUMNS,
|
||||
migrate_workflow_documents,
|
||||
@@ -366,6 +368,10 @@ class PromptModel:
|
||||
migrate_autotag_state(conn)
|
||||
# Image categories (#332): independent of every table above.
|
||||
migrate_image_categories(conn)
|
||||
# The Library parent archive (#443): independent of every table above.
|
||||
migrate_prompt_archive(conn)
|
||||
# Removal marks (#480): independent of every table above.
|
||||
migrate_image_removals(conn)
|
||||
# Last: the older generated_images rebuilds run only before file_path.
|
||||
migrate_workflow_documents(conn)
|
||||
|
||||
@@ -1242,6 +1248,16 @@ class PromptModel:
|
||||
)
|
||||
if categories_error:
|
||||
return False, categories_error
|
||||
archive_error = prompt_archive_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if archive_error:
|
||||
return False, archive_error
|
||||
removals_error = image_removals_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if removals_error:
|
||||
return False, removals_error
|
||||
views_error = saved_views_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
|
||||
+11
-8
@@ -18,12 +18,14 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations # noqa: F401 (re-exported)
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .image_removal import ImageRemovalOperations, removed_from_library
|
||||
from .image_collections import ImageCollectionOperations
|
||||
from .annotations import AnnotationOperations
|
||||
from .review import ReviewOperations
|
||||
from .review_decisions import ReviewDecisionOperations
|
||||
from .saved_views import SavedViewOperations
|
||||
from .library_commands import LibraryCommandOperations
|
||||
from .library_archive import LibraryArchiveOperations, delete_prompt_rows
|
||||
from .vocabulary import VocabularyOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
from .execution_contexts import ExecutionContextOperations
|
||||
@@ -448,6 +450,7 @@ class PromptDatabase(
|
||||
PairOperations,
|
||||
LibraryOperations,
|
||||
LibraryCommandOperations,
|
||||
LibraryArchiveOperations,
|
||||
VocabularyOperations,
|
||||
ExecutionHistoryOperations,
|
||||
ExecutionContextOperations,
|
||||
@@ -465,6 +468,7 @@ class PromptDatabase(
|
||||
WorkflowStorageOperations,
|
||||
CategoryImportOperations,
|
||||
ImageCategoryOperations,
|
||||
ImageRemovalOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
@@ -1806,6 +1810,11 @@ class PromptDatabase(
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
if removed_from_library(conn, image_path):
|
||||
# #480: the user removed this exact file from the library; its
|
||||
# bytes are unchanged, so nothing links it again.
|
||||
self.logger.debug("Not linking a file removed from the library")
|
||||
return 0
|
||||
filename = os.path.basename(image_path)
|
||||
file_path = normalize_image_path(image_path)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
@@ -2344,16 +2353,10 @@ class PromptDatabase(
|
||||
Returns:
|
||||
Number of prompts actually deleted.
|
||||
"""
|
||||
count = 0
|
||||
with self._pair_transaction() as conn:
|
||||
self._ensure_prompts_unpaired(conn, prompt_ids)
|
||||
documents = documents_of_rows(conn, "prompt_id", prompt_ids)
|
||||
for pid in prompt_ids:
|
||||
conn.execute("DELETE FROM generated_images WHERE prompt_id = ?", (pid,))
|
||||
cursor = conn.execute("DELETE FROM prompts WHERE id = ?", (pid,))
|
||||
if cursor.rowcount > 0:
|
||||
count += 1
|
||||
release_documents(conn, documents)
|
||||
# The workspace Library's delete runs the same statements (#443).
|
||||
count = delete_prompt_rows(conn, list(dict.fromkeys(prompt_ids)))
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Library parent archive (#443, D152): prompts hidden from the shelf, restorable.
|
||||
|
||||
A row means the Library parent is archived; no row means it is on the shelf.
|
||||
Nothing else about the prompt changes: its text, metadata, tags, run count,
|
||||
image links, pairs and recorded runs stay. Restore deletes the row. Deleting
|
||||
the prompt (any path) drops the row by cascade.
|
||||
|
||||
A table of its own, not a column: ``prompt_library_state`` is validated by its
|
||||
exact definition, so a new column there would make older builds refuse the
|
||||
upgraded database, while older builds ignore an unknown table (they show the
|
||||
archived prompts again and keep the rows). Optional in old backups, required
|
||||
once a database is current.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
TABLE = "prompt_archive"
|
||||
MAX_STAMP_BYTES = 64
|
||||
# What ``utc_now_iso`` writes starts with a date; anything else was not written here.
|
||||
_STAMP_GLOB = "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*"
|
||||
_DDL = (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
|
||||
)
|
||||
_COLUMNS = (("prompt_id", "INTEGER", 0, 1), ("archived_at", "TEXT", 1, 0))
|
||||
_FOREIGN_KEYS = {("prompt_id", "prompts", "id", "CASCADE")}
|
||||
# A predicate on ``p`` (the prompts alias): the parent is not archived.
|
||||
SHELVED = f"NOT EXISTS(SELECT 1 FROM {TABLE} pa WHERE pa.prompt_id=p.id)"
|
||||
ARCHIVED = f"EXISTS(SELECT 1 FROM {TABLE} pa WHERE pa.prompt_id=p.id)"
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql or "").lower()
|
||||
|
||||
|
||||
def _structure_error(conn: sqlite3.Connection, allow_missing: bool) -> str | None:
|
||||
objects = conn.execute(
|
||||
"SELECT type,sql FROM sqlite_master WHERE name=? "
|
||||
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
||||
(TABLE, TABLE),
|
||||
).fetchall()
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing prompt archive schema"
|
||||
tables = [sql for kind, sql in objects if kind == "table"]
|
||||
if len(tables) != 1 or len(objects) != 1:
|
||||
return "Prompt archive name is reserved by an unsupported schema object"
|
||||
if _compact(tables[0]) != _compact(_DDL):
|
||||
return "Prompt archive has an invalid table definition"
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
||||
)
|
||||
if columns != _COLUMNS:
|
||||
return "Prompt archive has invalid columns"
|
||||
foreign_keys = {
|
||||
(row[3], row[2], row[4], row[6])
|
||||
for row in conn.execute(f"PRAGMA foreign_key_list({TABLE})")
|
||||
}
|
||||
if foreign_keys != _FOREIGN_KEYS:
|
||||
return "Prompt archive has invalid foreign key definitions"
|
||||
return None
|
||||
|
||||
|
||||
def prompt_archive_schema_error(
|
||||
conn: sqlite3.Connection, *, allow_missing: bool = True
|
||||
) -> str | None:
|
||||
"""Reject an unsupported archive schema or values; reads only.
|
||||
|
||||
A missing table is acceptable for backups that predate it unless
|
||||
``allow_missing`` is false.
|
||||
"""
|
||||
error = _structure_error(conn, allow_missing)
|
||||
if error is not None:
|
||||
return error
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
|
||||
).fetchone():
|
||||
return None
|
||||
if conn.execute(f"PRAGMA foreign_key_check({TABLE})").fetchone():
|
||||
return "Prompt archive contains foreign key violations"
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {TABLE} WHERE typeof(archived_at)!='text' " # nosec B608
|
||||
f"OR length(CAST(archived_at AS BLOB))>{MAX_STAMP_BYTES} "
|
||||
f"OR archived_at NOT GLOB '{_STAMP_GLOB}' LIMIT 1"
|
||||
).fetchone():
|
||||
return "Prompt archive contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def migrate_prompt_archive(conn: sqlite3.Connection) -> None:
|
||||
"""Install the table atomically; validate before and after (no backfill)."""
|
||||
conn.execute("SAVEPOINT prompt_archive_schema")
|
||||
try:
|
||||
error = _structure_error(conn, True)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
error = _structure_error(conn, False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE prompt_archive_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO prompt_archive_schema")
|
||||
conn.execute("RELEASE prompt_archive_schema")
|
||||
raise
|
||||
@@ -149,6 +149,7 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
|
||||
collections (#331).
|
||||
"""
|
||||
from .library import _ELIGIBLE
|
||||
from .prompt_archive import SHELVED, TABLE as ARCHIVE
|
||||
|
||||
has_state = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND "
|
||||
@@ -157,9 +158,15 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
|
||||
has_pairs = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='prompt_pairs'"
|
||||
).fetchone()
|
||||
has_archive = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (ARCHIVE,)
|
||||
).fetchone()
|
||||
if has_state and has_pairs:
|
||||
# Archived parents (#443) are off the shelf, as in library_counts.
|
||||
positives = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompts p WHERE " + _ELIGIBLE
|
||||
"SELECT COUNT(*) FROM prompts p WHERE "
|
||||
+ _ELIGIBLE
|
||||
+ (" AND " + SHELVED if has_archive else "")
|
||||
).fetchone()[0]
|
||||
else:
|
||||
positives = _count(conn, "prompts")
|
||||
|
||||
+29
-12
@@ -20,7 +20,8 @@ counted, hashed into the token and renamed with the prompts and pairs.
|
||||
|
||||
import sqlite3
|
||||
|
||||
from .library import _ELIGIBLE, UNTAGGED, _escape, _limit
|
||||
from .library import _ELIGIBLE, SHELVED_PAIR, UNTAGGED, _escape, _limit
|
||||
from .prompt_archive import SHELVED
|
||||
from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch
|
||||
from .library_tokens import canonical_token
|
||||
from .models import utc_now_iso
|
||||
@@ -40,17 +41,21 @@ _IMAGE_FILES = (
|
||||
|
||||
def _tag_rows(page: str, order: str = "page.id") -> str:
|
||||
"""Count rows for the registry rows selected by ``page`` (id, name)."""
|
||||
# Eligible parent components carrying a tag in the page, plus the
|
||||
# positive parent of every active pair carrying it (always eligible).
|
||||
# Eligible parent components on the shelf carrying a tag in the page,
|
||||
# plus the positive parent of every active pair carrying it (always
|
||||
# eligible) when that parent is on the shelf: archived parents (#443)
|
||||
# are hidden with their pairs, so Browse lists what was counted.
|
||||
return (
|
||||
"WITH page AS (" + page + "),"
|
||||
"owners(tag_id,pid) AS ("
|
||||
"SELECT pt.tag_id,p.id FROM prompts p "
|
||||
"JOIN prompt_tags pt ON pt.prompt_id=p.id "
|
||||
"WHERE pt.tag_id IN (SELECT id FROM page) AND " + _ELIGIBLE + " "
|
||||
"AND " + SHELVED + " "
|
||||
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
|
||||
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id "
|
||||
"WHERE pp.archived=0 AND ppt.tag_id IN (SELECT id FROM page)),"
|
||||
"WHERE pp.archived=0 AND " + SHELVED_PAIR + " "
|
||||
"AND ppt.tag_id IN (SELECT id FROM page)),"
|
||||
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
|
||||
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
|
||||
"SELECT page.id,page.name,COUNT(DISTINCT owners.pid) AS prompt_count,"
|
||||
@@ -79,8 +84,10 @@ _RANKED_TAG_PAGE = (
|
||||
"WITH owners(tag_id,pid) AS ("
|
||||
"SELECT pt.tag_id,p.id FROM prompts p "
|
||||
"JOIN prompt_tags pt ON pt.prompt_id=p.id WHERE " + _ELIGIBLE + " "
|
||||
"AND " + SHELVED + " "
|
||||
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
|
||||
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0),"
|
||||
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0 "
|
||||
"AND " + SHELVED_PAIR + "),"
|
||||
"counted AS (SELECT t.id,t.name,COUNT(DISTINCT o.pid) AS prompt_count "
|
||||
"FROM tags t LEFT JOIN owners o ON o.tag_id=t.id WHERE {search} "
|
||||
"GROUP BY t.id),"
|
||||
@@ -94,7 +101,13 @@ _RANKED_TAG_PAGE = (
|
||||
"ORDER BY page.prompt_count {direction},page.id"
|
||||
)
|
||||
_UNTAGGED_COUNT = (
|
||||
"SELECT COUNT(*) FROM prompts p WHERE (" + _ELIGIBLE + ") AND " + UNTAGGED
|
||||
"SELECT COUNT(*) FROM prompts p WHERE ("
|
||||
+ _ELIGIBLE
|
||||
+ ") AND "
|
||||
+ UNTAGGED
|
||||
# Untagged prompts opens the shelf, which hides archived parents (#443).
|
||||
+ " AND "
|
||||
+ SHELVED
|
||||
)
|
||||
_TAG_ONE = _tag_rows("SELECT id,name FROM tags WHERE id=?")
|
||||
# Every owner kind a tag can sit on, for the delete confirmation.
|
||||
@@ -134,7 +147,9 @@ def _validate_tag_id(tag_id: object) -> None:
|
||||
|
||||
|
||||
# Category values in Tags & categories (#332 §7): the prompt-backed values
|
||||
# (nonblank values on eligible parents or on active pairs, grouped exactly, so
|
||||
# (nonblank values on eligible parents or on active pairs; ``prompt_count``
|
||||
# counts only parents on the shelf, so a value only archived parents (#443)
|
||||
# hold stays listed with 0, as a tag does; grouped exactly, so
|
||||
# whitespace and case variants stay distinct; /library/options lists the same
|
||||
# prompt-backed set with its own query) plus the values only image rows hold.
|
||||
# A prompt-backed value is ordered by its oldest owning positive; an image-only
|
||||
@@ -143,15 +158,17 @@ def _validate_tag_id(tag_id: object) -> None:
|
||||
# files missing on disk. No FULL OUTER JOIN, so it runs on SQLite 3.31+.
|
||||
_IMAGE_ONLY_FIRST_PID = 2**63 - 1
|
||||
_CATEGORY_ROWS = (
|
||||
"WITH owners(value,pid) AS ("
|
||||
"SELECT p.category,p.id FROM prompts p WHERE p.category IS NOT NULL "
|
||||
"AND TRIM(p.category)!='' AND " + _ELIGIBLE + " "
|
||||
"UNION SELECT pp.category,pp.positive_prompt_id FROM prompt_pairs pp "
|
||||
"WITH owners(value,pid,shelved) AS ("
|
||||
"SELECT p.category,p.id," + SHELVED + " FROM prompts p "
|
||||
"WHERE p.category IS NOT NULL AND TRIM(p.category)!='' AND " + _ELIGIBLE + " "
|
||||
"UNION SELECT pp.category,pp.positive_prompt_id," + SHELVED_PAIR + " "
|
||||
"FROM prompt_pairs pp "
|
||||
"WHERE pp.archived=0 AND pp.category IS NOT NULL AND TRIM(pp.category)!=''),"
|
||||
"image_values(value,image_count) AS ("
|
||||
"SELECT category,COUNT(*) FROM image_categories GROUP BY category),"
|
||||
"grouped(value,first_pid,prompt_count) AS ("
|
||||
"SELECT value,MIN(pid),COUNT(DISTINCT pid) FROM owners GROUP BY value "
|
||||
"SELECT value,MIN(pid),COUNT(DISTINCT CASE WHEN shelved THEN pid END) "
|
||||
"FROM owners GROUP BY value "
|
||||
"UNION ALL SELECT value," + str(_IMAGE_ONLY_FIRST_PID) + ",0 FROM image_values "
|
||||
"WHERE value NOT IN (SELECT value FROM owners)),"
|
||||
"counted AS (SELECT g.value,g.first_pid,g.prompt_count,"
|
||||
|
||||
@@ -19,6 +19,7 @@ Split into domain-specific mixins for maintainability:
|
||||
- SystemRoutesMixin: System health check, Overview and usage statistics
|
||||
- LibraryLocationRoutesMixin: where the library lives, Move library, Keep it here
|
||||
- MaintenanceRoutesMixin: reviewed maintenance plans (preview, apply)
|
||||
- LibraryDeleteRoutesMixin: the Library's Delete… (archive paired) and Restore
|
||||
- WorkflowStorageRoutesMixin: store each workflow once, then Reclaim space
|
||||
- DuplicateFilesMixin: duplicate image files as find and removal jobs
|
||||
- LoraImportRoutesMixin: the reviewed, staged LoRA reference import job
|
||||
@@ -43,6 +44,7 @@ from .prompts import PromptRoutesMixin
|
||||
from .revalidation import revalidated_response
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .library_delete import LibraryDeleteRoutesMixin
|
||||
from .vocabulary import VocabularyRoutesMixin
|
||||
from .annotations import AnnotationRoutesMixin
|
||||
from .review import ReviewRoutesMixin
|
||||
@@ -75,6 +77,7 @@ from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
from .lora_import_routes import LoraImportRoutesMixin
|
||||
from .image_categories import ImageCategoryRoutesMixin
|
||||
from .image_delete import ImageDeleteMixin
|
||||
from .source_rescope import SourceRescopeMixin
|
||||
|
||||
try:
|
||||
@@ -314,6 +317,7 @@ async def _gzip_middleware(request, handler):
|
||||
class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
LibraryDeleteRoutesMixin,
|
||||
VocabularyRoutesMixin,
|
||||
AnnotationRoutesMixin,
|
||||
ReviewRoutesMixin,
|
||||
@@ -344,6 +348,7 @@ class PromptManagerAPI(
|
||||
LoraIntegrationMixin,
|
||||
LoraImportRoutesMixin,
|
||||
ImageCategoryRoutesMixin,
|
||||
ImageDeleteMixin,
|
||||
SourceRescopeMixin,
|
||||
):
|
||||
"""REST API handler for PromptManager operations and web interface.
|
||||
@@ -424,6 +429,12 @@ class PromptManagerAPI(
|
||||
# Reviewed maintenance plans under single-use tokens; a restart
|
||||
# forgets them (py/api/maintenance.py).
|
||||
self._maintenance_plans = PlanStore()
|
||||
# #480: the removal marks the workspace listing hides, read once now so
|
||||
# a listing page reads no table until a mark or the database changes.
|
||||
try:
|
||||
self._removal_marks()
|
||||
except Exception:
|
||||
self.logger.warning("Removal marks could not be read", exc_info=True)
|
||||
|
||||
# A safety backup interrupted by a crash leaves its temp copy behind.
|
||||
try:
|
||||
@@ -692,6 +703,7 @@ class PromptManagerAPI(
|
||||
|
||||
self._register_pair_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_library_delete_routes(routes)
|
||||
self._register_vocabulary_routes(routes)
|
||||
self._register_annotation_routes(routes)
|
||||
self._register_review_routes(routes)
|
||||
@@ -715,6 +727,7 @@ class PromptManagerAPI(
|
||||
self._register_lora_routes(routes)
|
||||
self._register_lora_import_routes(routes)
|
||||
self._register_image_category_routes(routes)
|
||||
self._register_image_delete_routes(routes)
|
||||
self._register_rescope_routes(routes)
|
||||
|
||||
# Register gzip compression middleware (once)
|
||||
|
||||
+22
-7
@@ -36,10 +36,13 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from ...utils import exit_signal
|
||||
from ...utils.media_folders import is_skipped_folder
|
||||
except ImportError:
|
||||
from utils import exit_signal
|
||||
from utils.media_folders import is_skipped_folder
|
||||
|
||||
try:
|
||||
from ...database.image_removal import mark_hides
|
||||
from ...database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from ...database.run_attribution import (
|
||||
RecordedOutput,
|
||||
@@ -60,6 +63,7 @@ try:
|
||||
decide_file_prompt,
|
||||
)
|
||||
except ImportError:
|
||||
from database.image_removal import mark_hides
|
||||
from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from database.run_attribution import (
|
||||
RecordedOutput,
|
||||
@@ -118,10 +122,11 @@ DUPLICATE_FIND_LEGACY_TITLE = "Find duplicate image files (gallery page)"
|
||||
def _collect_media_files(output_dirs, extensions):
|
||||
"""Media files under ``output_dirs`` (blocking), bounded and symlink-free.
|
||||
|
||||
Walks each root without following symlinks, skips ``thumbnails``
|
||||
directories and symlinked files, never descends more than MAX_SCAN_DEPTH
|
||||
levels and stops after MAX_SCAN_FILES files in total. Extensions match
|
||||
case-insensitively and each path is listed once.
|
||||
Walks each root without following symlinks, skips ``thumbnails`` and
|
||||
trash folders (``utils.media_folders``) and symlinked files, never
|
||||
descends more than MAX_SCAN_DEPTH levels and stops after MAX_SCAN_FILES
|
||||
files in total. Extensions match case-insensitively and each path is
|
||||
listed once.
|
||||
"""
|
||||
wanted = {ext.lower() for ext in extensions}
|
||||
found = []
|
||||
@@ -136,7 +141,7 @@ def _collect_media_files(output_dirs, extensions):
|
||||
dirnames[:] = sorted(
|
||||
d
|
||||
for d in dirnames
|
||||
if d != "thumbnails"
|
||||
if not is_skipped_folder(d)
|
||||
and depth < MAX_SCAN_DEPTH
|
||||
and not os.path.islink(os.path.join(current, d))
|
||||
)
|
||||
@@ -883,6 +888,8 @@ class AdminRoutesMixin:
|
||||
extensions = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS
|
||||
media_files = []
|
||||
seen = set()
|
||||
# #480: a file removed from the library is never a copy or a keeper.
|
||||
marks = self._removal_marks()
|
||||
for slot, root in enumerate(roots):
|
||||
for path in _collect_media_files([root], extensions):
|
||||
try:
|
||||
@@ -891,6 +898,9 @@ class AdminRoutesMixin:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
locator = os.path.normcase(os.path.realpath(str(path)))
|
||||
if locator in marks and mark_hides(marks, locator, path):
|
||||
continue
|
||||
key = _physical_file_key(path, stat)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
@@ -1551,8 +1561,13 @@ class AdminRoutesMixin:
|
||||
if job.cancelled:
|
||||
return
|
||||
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
|
||||
skipped = [f for f in batch if str(f) in known]
|
||||
to_read = [f for f in batch if str(f) not in known]
|
||||
# #480: a file removed from the library is not read or linked
|
||||
# again while its bytes are unchanged.
|
||||
removed = await self._run_in_executor(self._removed_paths, batch)
|
||||
skipped = [f for f in batch if str(f) in known or str(f) in removed]
|
||||
to_read = [
|
||||
f for f in batch if str(f) not in known and str(f) not in removed
|
||||
]
|
||||
outcomes = [_scan_outcome(f, roots, "skipped") for f in skipped]
|
||||
counts["processed"] += len(skipped)
|
||||
counts["unchanged"] += len(skipped)
|
||||
|
||||
@@ -23,6 +23,11 @@ from .prompts import thumbnail_url_for
|
||||
from . import thumbnail_settings
|
||||
from .. import model_gate
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import is_skipped_folder
|
||||
except ImportError:
|
||||
from utils.media_folders import is_skipped_folder
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
|
||||
@@ -1319,8 +1324,9 @@ class AutotagRoutesMixin:
|
||||
def _collect_scan_files(output_path: Path):
|
||||
"""Media files under ``output_path`` from a bounded, symlink-free walk.
|
||||
|
||||
Descends at most ``_SCAN_MAX_DEPTH`` levels, skips ``thumbnails``
|
||||
trees and symlinked files, and stops after ``_SCAN_MAX_FILES`` files.
|
||||
Descends at most ``_SCAN_MAX_DEPTH`` levels, skips ``thumbnails`` and
|
||||
trash folders (``utils.media_folders``) and symlinked files, and stops
|
||||
after ``_SCAN_MAX_FILES`` files.
|
||||
Returns ``(paths, truncated)``.
|
||||
"""
|
||||
found = []
|
||||
@@ -1330,7 +1336,7 @@ class AutotagRoutesMixin:
|
||||
dirnames[:] = sorted(
|
||||
name
|
||||
for name in dirnames
|
||||
if name != "thumbnails" and depth + 1 <= _SCAN_MAX_DEPTH
|
||||
if not is_skipped_folder(name) and depth + 1 <= _SCAN_MAX_DEPTH
|
||||
)
|
||||
for name in sorted(filenames):
|
||||
if Path(name).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
@@ -1357,6 +1363,10 @@ class AutotagRoutesMixin:
|
||||
files, truncated = await self._run_in_executor(
|
||||
self._collect_scan_files, output_path
|
||||
)
|
||||
# #501: a file removed from the library is not offered for tagging.
|
||||
files = await self._run_in_executor(
|
||||
self._without_removed, output_path, files
|
||||
)
|
||||
has_thumbnails = (output_path / "thumbnails").is_dir()
|
||||
|
||||
images = []
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
"""Delete selected images: remove them from the library, or trash them (#480).
|
||||
|
||||
``POST /prompt_manager/images/delete/preview {items | filter}`` reviews the
|
||||
selection for both modes at once and answers a reviewed plan (the System ›
|
||||
Maintenance contract, ``py/api/maintenance.py``): the token, the counts of
|
||||
the records each file loses, the refused files with a coded reason (both
|
||||
modes, and the trash-only ones), the prompts that lose their last image and
|
||||
the safety backup. It writes nothing. ``items`` are Images listing identities
|
||||
(at most 500); ``filter`` is "Select all N matching". Each target is matched
|
||||
to its entry in the current listing (``not-listed`` otherwise), so every
|
||||
later step uses the walked spelling, never the request's text.
|
||||
|
||||
``POST /prompt_manager/maintenance/apply {token, mode}`` runs the plan as the
|
||||
``image_delete`` job (admission ``records``, not cancellable): the sources
|
||||
are checked, the promised safety backup is taken, then each file is checked
|
||||
again (containment and fingerprint) and
|
||||
|
||||
- ``mode="library"``: its records are deleted and its removal mark written
|
||||
in one transaction; the file stays on disk, hidden;
|
||||
- ``mode="trash"``: it is moved to the trash first (``utils/trash.py``); a
|
||||
refused move leaves the file and its records alone; after a move its
|
||||
records are deleted.
|
||||
|
||||
No path here unlinks a media file. A failed records write stops the job; the
|
||||
files after it are not attempted.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat as stat_module
|
||||
from pathlib import Path
|
||||
|
||||
from .admin import DELETABLE_MEDIA_EXTENSIONS, _remove_thumbnail
|
||||
from .duplicate_review import (
|
||||
CHANGED_CONTENT,
|
||||
GONE,
|
||||
LINK,
|
||||
OUTSIDE_SOURCE,
|
||||
UNREADABLE,
|
||||
file_problem,
|
||||
)
|
||||
from .images import _favorite_relative_key, _favorite_root_key
|
||||
from .jobs import IMAGE_DELETE_GROUP_NAMES, IMAGE_DELETE_JOB_KIND, JobBusy
|
||||
from .job_routes import busy_response
|
||||
from .maintenance import MAINTENANCE_PREVIEW_LEASE, _fail_job, _failure, backup_note
|
||||
|
||||
try:
|
||||
from ...database import image_removal_schema
|
||||
from ...database.image_removal import mark_hides
|
||||
from ...database.annotation_schema import MAX_LOCATOR_LENGTH
|
||||
from ...utils import trash
|
||||
from ...utils.media_folders import is_trash_folder
|
||||
except ImportError:
|
||||
from database import image_removal_schema
|
||||
from database.image_removal import mark_hides
|
||||
from database.annotation_schema import MAX_LOCATOR_LENGTH
|
||||
from utils import trash
|
||||
from utils.media_folders import is_trash_folder
|
||||
|
||||
IMAGE_DELETE = "delete_images"
|
||||
MODES = ("library", "trash")
|
||||
TITLES = {
|
||||
"library": "Delete images (remove from the library)",
|
||||
"trash": "Delete images (move to the trash)",
|
||||
}
|
||||
MAX_DETAIL_ROWS = 50
|
||||
# The system moved the file but its place in a trash could not be shown (#499).
|
||||
TRASH_UNCONFIRMED = "trash-unconfirmed"
|
||||
RECORDS_FAILED_CODE = "records-failed"
|
||||
|
||||
# Reason codes (design §4.4) and the sentence each shows.
|
||||
REASONS = {
|
||||
"not-listed": "No longer listed in Images.",
|
||||
"outside": "Outside the output folders.",
|
||||
"link": "A link, not a file.",
|
||||
"gone": "The file no longer exists.",
|
||||
"unreadable": "The file can't be read.",
|
||||
"thumbnail": "A generated preview, not an image.",
|
||||
"not-media": "Not a media file.",
|
||||
"trash-folder": "Inside a trash folder.",
|
||||
"path-too-long": "The path is too long.",
|
||||
"changed": "The file changed after the review.",
|
||||
trash.NO_TRASH: "This drive has no trash.",
|
||||
trash.NETWORK: "Network drives can't use the trash.",
|
||||
trash.TRASH_IN_ROOT: "This drive's trash is inside an output folder.",
|
||||
trash.LOCKED: "The file is open in another program. Close it and try again.",
|
||||
trash.FAILED: "The file could not be moved to the trash.",
|
||||
trash.NO_PERMISSION: (
|
||||
"PromptManager isn't allowed to move this file (no permission, or it is "
|
||||
"locked in Finder)."
|
||||
),
|
||||
trash.CLOUD_SYNC: (
|
||||
"Files in iCloud Drive or a cloud-synced folder can't be moved to the "
|
||||
"trash here."
|
||||
),
|
||||
TRASH_UNCONFIRMED: (
|
||||
"Moved by macOS, but its place in the trash could not be confirmed."
|
||||
),
|
||||
trash.WINDOWS_RECYCLE: (
|
||||
"Moving files to the Recycle Bin isn't available on Windows: files larger "
|
||||
"than the bin's capacity would be deleted permanently."
|
||||
),
|
||||
"not-attempted": "Not attempted: the job stopped after an error.",
|
||||
}
|
||||
_PROBLEM_CODES = {
|
||||
OUTSIDE_SOURCE: "outside",
|
||||
LINK: "link",
|
||||
GONE: "gone",
|
||||
UNREADABLE: "unreadable",
|
||||
CHANGED_CONTENT: "changed",
|
||||
}
|
||||
SOURCES_CHANGED = "The sources changed since the review; nothing was changed."
|
||||
RECORDS_FAILED = "The image records could not be removed; the job stopped."
|
||||
|
||||
|
||||
def lexical_problem(relative: str) -> str | None:
|
||||
"""The refusal code a root-relative POSIX path earns before any file access."""
|
||||
parts = relative.split("/")
|
||||
if "thumbnails" in parts[:-1]:
|
||||
return "thumbnail"
|
||||
if any(is_trash_folder(part) for part in parts[:-1]):
|
||||
return "trash-folder"
|
||||
if os.path.splitext(parts[-1])[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||
return "not-media"
|
||||
return None
|
||||
|
||||
|
||||
def file_check(path, root, relative, *, expected=None):
|
||||
"""``(code, lstat)``: why *path* may not be deleted, or ``(None, lstat)``.
|
||||
|
||||
Text first (inside the root, not a preview or trash folder, a media
|
||||
file), then ``duplicate_review.file_problem``: a regular file, not a
|
||||
link, whose resolved path is still inside the resolved root. With
|
||||
*expected* (``{size, mtime_ns, inode}`` from the review) the file must
|
||||
still be those bytes. Every handle is closed when this returns.
|
||||
"""
|
||||
code = lexical_problem(relative)
|
||||
if code:
|
||||
return code, None
|
||||
try:
|
||||
info = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
return "gone", None
|
||||
except OSError:
|
||||
return "unreadable", None
|
||||
if getattr(info, "st_file_attributes", 0) & getattr(
|
||||
stat_module, "FILE_ATTRIBUTE_REPARSE_POINT", 0
|
||||
):
|
||||
return "link", info
|
||||
if expected is None:
|
||||
problem = file_problem(path, root, None, info.st_size, hash_check=False)
|
||||
else:
|
||||
problem = file_problem(
|
||||
path,
|
||||
root,
|
||||
None,
|
||||
expected["size"],
|
||||
hash_check=False,
|
||||
mtime_ns=expected["mtime_ns"],
|
||||
inode=expected["inode"],
|
||||
)
|
||||
if problem:
|
||||
return _PROBLEM_CODES.get(problem, "outside"), info
|
||||
if len(os.path.normcase(os.path.realpath(path))) > MAX_LOCATOR_LENGTH:
|
||||
return "path-too-long", info
|
||||
return None, info
|
||||
|
||||
|
||||
def _row(target, code):
|
||||
return {
|
||||
"path": target["rel"],
|
||||
"root_label": target["root_label"],
|
||||
"code": code,
|
||||
"detail": REASONS[code],
|
||||
}
|
||||
|
||||
|
||||
def _summary(rows):
|
||||
counts = {}
|
||||
for row in rows:
|
||||
counts[row["code"]] = counts.get(row["code"], 0) + 1
|
||||
return {"count": len(rows), "counts": counts, "rows": rows[:MAX_DETAIL_ROWS]}
|
||||
|
||||
|
||||
def _fingerprint(targets) -> str:
|
||||
material = [
|
||||
(t["root_index"], t["rel"], t["size"], t["mtime_ns"], t["inode"])
|
||||
for t in targets
|
||||
]
|
||||
return hashlib.sha256(json.dumps(material).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class ImageDeleteMixin:
|
||||
"""The Images Delete… review and its apply job (#480)."""
|
||||
|
||||
def _register_image_delete_routes(self, routes):
|
||||
# A fixed path beside the dynamic /images/{image_id} routes: aiohttp
|
||||
# (3.10+) looks fixed paths up first (see image_categories.py).
|
||||
@routes.post("/prompt_manager/images/delete/preview")
|
||||
async def preview_image_delete_route(request):
|
||||
return await self.preview_image_delete(request)
|
||||
|
||||
# ── review ────────────────────────────────────────────────────────
|
||||
|
||||
async def preview_image_delete(self, request):
|
||||
"""Review the selection under the maintenance preview lease."""
|
||||
try:
|
||||
async with self._jobs.lease(MAINTENANCE_PREVIEW_LEASE):
|
||||
return await self._preview_image_delete(request)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def _preview_image_delete(self, request):
|
||||
_data, targets, roots, error = await self._category_request(
|
||||
request, frozenset()
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
db = self.db
|
||||
identity = self._database_identity(db)
|
||||
try:
|
||||
files = {}
|
||||
for index in dict.fromkeys(t[0] for t in targets):
|
||||
files[index] = await self._get_gallery_files(roots[index])
|
||||
review = await self._run_in_executor(
|
||||
self._review_image_delete, db, roots, targets, files
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Image delete review failed")
|
||||
return _failure("The review failed. Check server logs for details.", 500)
|
||||
return await self._plan_response(
|
||||
db,
|
||||
identity,
|
||||
IMAGE_DELETE,
|
||||
_fingerprint(review["targets"]),
|
||||
review["public"],
|
||||
{"targets": review["targets"]},
|
||||
)
|
||||
|
||||
def _review_image_delete(self, db, roots, targets, files):
|
||||
"""Every target checked now (blocking); ``{targets, public}``."""
|
||||
listings, seen = {}, set()
|
||||
acted, refused, trash_refused = [], [], []
|
||||
spellings = self._source_spellings()
|
||||
root_texts = [str(root) for root in roots]
|
||||
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))
|
||||
target = {
|
||||
"root_index": index,
|
||||
"root_scope": _scope,
|
||||
"root": str(root),
|
||||
"root_label": self._public_path(root),
|
||||
"rel": spelled or relative,
|
||||
}
|
||||
if spelled is None:
|
||||
refused.append(_row(target, "not-listed"))
|
||||
continue
|
||||
absolute = os.path.join(str(root), *spelled.split("/"))
|
||||
code, info = file_check(absolute, str(root), spelled)
|
||||
if code:
|
||||
refused.append(_row(target, code))
|
||||
continue
|
||||
identity = (info.st_dev, info.st_ino)
|
||||
if identity in seen:
|
||||
continue # one physical file named twice (nested roots, hardlinks)
|
||||
seen.add(identity)
|
||||
target.update(self._removal_of(absolute, info, spellings))
|
||||
target["trash"] = trash.trash_available(absolute, root_texts)
|
||||
if target["trash"]:
|
||||
trash_refused.append(_row(target, target["trash"]))
|
||||
acted.append(target)
|
||||
counted = db.count_removal_records([t["removal"] for t in acted])
|
||||
library_count = len(acted)
|
||||
public = {
|
||||
"operation": IMAGE_DELETE,
|
||||
"scope": {"count": library_count, "label": "images"},
|
||||
"library": {"count": library_count},
|
||||
"trash": {"count": library_count - len(trash_refused)},
|
||||
"protected": {"count": len(refused), "reasons": []},
|
||||
"refused": _summary(refused),
|
||||
"trash_refused": _summary(trash_refused),
|
||||
"records": counted["records"],
|
||||
"orphaned_prompts": counted["orphaned_prompts"],
|
||||
"media": {"deletes_files": False, "label": "Chosen in the review"},
|
||||
"details": [],
|
||||
}
|
||||
return {"targets": acted, "public": public}
|
||||
|
||||
@staticmethod
|
||||
def _removal_of(absolute, info, spellings):
|
||||
favorites = [
|
||||
(_favorite_root_key(root), _favorite_relative_key(relative))
|
||||
for root, relative in spellings.favorites(absolute).items()
|
||||
]
|
||||
return {
|
||||
"abs": absolute,
|
||||
"size": info.st_size,
|
||||
"mtime_ns": info.st_mtime_ns,
|
||||
"inode": [info.st_dev, info.st_ino],
|
||||
"removal": {
|
||||
"spellings": list(spellings.of(absolute).values()),
|
||||
"locator": os.path.normcase(os.path.realpath(absolute)),
|
||||
"favorites": favorites,
|
||||
"category": spellings.category_key(absolute),
|
||||
},
|
||||
}
|
||||
|
||||
# ── apply ─────────────────────────────────────────────────────────
|
||||
|
||||
def _start_image_delete(self, db, plan, mode, backup_on):
|
||||
"""Register the delete job for a reviewed plan (on the loop)."""
|
||||
return self._jobs.start(
|
||||
IMAGE_DELETE_JOB_KIND,
|
||||
TITLES[mode],
|
||||
admission="records",
|
||||
cancellable=False,
|
||||
scope={"operation": IMAGE_DELETE, "mode": mode},
|
||||
groups=IMAGE_DELETE_GROUP_NAMES,
|
||||
runner=lambda job: self._run_image_delete(job, db, plan, mode, backup_on),
|
||||
)
|
||||
|
||||
async def _run_image_delete(self, job, db, plan, mode, backup_on):
|
||||
targets = plan.payload["targets"]
|
||||
unchanged = await self._run_in_executor(self._sources_unchanged, targets)
|
||||
if not unchanged:
|
||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
try:
|
||||
await self._delete_images(job, db, targets, mode, backup)
|
||||
finally:
|
||||
if backup:
|
||||
await self._prune_backup_pool(job, db)
|
||||
|
||||
async def _delete_images(self, job, db, targets, mode, backup):
|
||||
job.update(total=len(targets), status_line=TITLES[mode])
|
||||
roots = [str(root) for root in self._get_all_output_dirs()]
|
||||
done_group = "trashed" if mode == "trash" else "removed"
|
||||
changed, failed = 0, None
|
||||
for done, target in enumerate(targets, start=1):
|
||||
if failed:
|
||||
self._record_delete(job, "not_changed", target, "not-attempted")
|
||||
continue
|
||||
group, code, label = await self._run_in_executor(
|
||||
self._delete_one_image, db, target, mode, roots
|
||||
)
|
||||
if group == "errors":
|
||||
failed = code or RECORDS_FAILED_CODE
|
||||
text = REASONS[code] if code else RECORDS_FAILED
|
||||
job.record(
|
||||
"errors",
|
||||
{
|
||||
"path": target["rel"],
|
||||
"root": target["root_label"],
|
||||
"root_index": target["root_index"],
|
||||
"root_scope": target["root_scope"],
|
||||
"detail": text,
|
||||
},
|
||||
f"! {target['root_label']}/{target['rel']} · {text}",
|
||||
)
|
||||
elif group == done_group:
|
||||
changed += 1
|
||||
detail = (
|
||||
f"Moved to the {label}" if label else "Removed from the library"
|
||||
)
|
||||
self._record_delete(job, group, target, None, detail)
|
||||
else:
|
||||
self._record_delete(job, group, target, code)
|
||||
job.update(processed=done, progress=int(done / len(targets) * 100))
|
||||
if changed:
|
||||
self.invalidate_gallery_cache()
|
||||
verb = "Moved" if mode == "trash" else "Removed"
|
||||
where = "to the trash" if mode == "trash" else "from the library"
|
||||
line = f"{verb} {changed:,} of {len(targets):,} images {where}"
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
if failed:
|
||||
# "records-failed" names every stop at an errors row (the page's
|
||||
# "Stopped after N of M" toast); the row says which error it was.
|
||||
job.scope["failure"] = RECORDS_FAILED_CODE
|
||||
job.publish({"type": "error", "code": failed, "message": line})
|
||||
job.finish("failed")
|
||||
|
||||
@staticmethod
|
||||
def _record_delete(job, group, target, code, detail=None):
|
||||
text = detail or REASONS[code]
|
||||
row = {
|
||||
"path": target["rel"],
|
||||
"root": target["root_label"],
|
||||
"root_index": target["root_index"],
|
||||
"root_scope": target["root_scope"],
|
||||
"code": code,
|
||||
"detail": text,
|
||||
}
|
||||
mark = {"removed": "-", "trashed": "-"}.get(group, "=")
|
||||
job.record(
|
||||
group, row, f"{mark} {target['root_label']}/{target['rel']} · {text}"
|
||||
)
|
||||
|
||||
def _delete_one_image(self, db, target, mode, roots):
|
||||
"""``(group, code, trash label)`` for one reviewed file (blocking)."""
|
||||
code, _info = file_check(
|
||||
target["abs"],
|
||||
target["root"],
|
||||
target["rel"],
|
||||
expected={
|
||||
"size": target["size"],
|
||||
"mtime_ns": target["mtime_ns"],
|
||||
"inode": target["inode"],
|
||||
},
|
||||
)
|
||||
if code:
|
||||
return "not_changed", code, None
|
||||
label = None
|
||||
if mode == "trash":
|
||||
try:
|
||||
label = trash.move_to_trash(target["abs"], roots)
|
||||
except trash.TrashRefused as refused:
|
||||
code = refused.code if refused.code in REASONS else trash.FAILED
|
||||
self.logger.info(
|
||||
"Not moved to the trash (%s): %s %s",
|
||||
code,
|
||||
target["rel"],
|
||||
refused.detail,
|
||||
)
|
||||
return "not_changed", code, None
|
||||
except trash.TrashUnconfirmed as unconfirmed:
|
||||
# The file left its folder: keep its records, stop the job.
|
||||
self.logger.warning(
|
||||
"Trash move not confirmed: %s %s", target["rel"], unconfirmed.detail
|
||||
)
|
||||
return "errors", TRASH_UNCONFIRMED, None
|
||||
mark = None
|
||||
else:
|
||||
mark = (target["size"], target["mtime_ns"])
|
||||
try:
|
||||
db.remove_image_records(target["removal"], mark=mark)
|
||||
except Exception:
|
||||
self.logger.exception("Removing an image's records failed")
|
||||
return "errors", None, label
|
||||
_remove_thumbnail(Path(target["root"]), Path(target["rel"]), self.logger)
|
||||
return ("trashed" if mode == "trash" else "removed"), None, label
|
||||
|
||||
# ── the workspace listing ─────────────────────────────────────────
|
||||
|
||||
def _removal_marks(self) -> dict:
|
||||
"""The removal marks, cached until a mark changes or the database does.
|
||||
|
||||
A listing page reads no table while nothing changed: the cache follows
|
||||
this process's marks generation and the database identity (a restore).
|
||||
"""
|
||||
key = (self._database_identity(self.db), image_removal_schema.generation())
|
||||
cached = getattr(self, "_marks_cache", None)
|
||||
if cached is None or cached[0] != key:
|
||||
cached = (key, self.db.removal_marks())
|
||||
self._marks_cache = cached
|
||||
return cached[1]
|
||||
|
||||
def _hidden_entries(self, listings) -> set:
|
||||
"""Normalized paths of listed files a removal mark hides (blocking).
|
||||
|
||||
Only marked locators are looked at, and only those files are
|
||||
``lstat``'ed: a mark hides a file while its size and ``mtime_ns`` are
|
||||
still the ones it was removed with.
|
||||
"""
|
||||
marks = self._removal_marks()
|
||||
if not marks:
|
||||
return set()
|
||||
located = self._workspace_locators(listings)
|
||||
return {
|
||||
entry.normalized
|
||||
for locator, entry in ((locator, located.get(locator)) for locator in marks)
|
||||
if entry is not None and mark_hides(marks, locator, entry.path)
|
||||
}
|
||||
|
||||
def _removed_paths(self, paths) -> set:
|
||||
"""The absolute paths among *paths* a removal mark hides (blocking)."""
|
||||
marks = self._removal_marks()
|
||||
if not marks:
|
||||
return set()
|
||||
return {
|
||||
str(path)
|
||||
for path in paths
|
||||
if mark_hides(marks, os.path.normcase(os.path.realpath(str(path))), path)
|
||||
}
|
||||
|
||||
def _removal_check_under(self, root):
|
||||
"""``hidden(path) -> bool`` for files a walk of *root* found (blocking).
|
||||
|
||||
A walk never follows a link below *root*, so a file's locator is
|
||||
``normcase(join(realpath(root), relative))``: no ``realpath`` per
|
||||
file, and an ``lstat`` only for a marked one. Without marks it
|
||||
answers False at once.
|
||||
"""
|
||||
marks = self._removal_marks()
|
||||
if not marks:
|
||||
return lambda _path: False
|
||||
root = os.fspath(root)
|
||||
real = os.path.realpath(root)
|
||||
|
||||
def hidden(path):
|
||||
relative = os.path.relpath(os.fspath(path), root)
|
||||
locator = os.path.normcase(os.path.join(real, relative))
|
||||
return mark_hides(marks, locator, path)
|
||||
|
||||
return hidden
|
||||
|
||||
def _without_removed(self, root, paths) -> list:
|
||||
"""*paths* found by a walk of *root*, less the hidden ones (blocking)."""
|
||||
removed = self._removal_check_under(root)
|
||||
return [path for path in paths if not removed(path)]
|
||||
|
||||
|
||||
def parse_mode(value):
|
||||
"""The apply's ``mode``, or None when it is not one of MODES."""
|
||||
return value if isinstance(value, str) and value in MODES else None
|
||||
@@ -488,7 +488,11 @@ class ImageTagJobsMixin:
|
||||
_still_targets, self, index, root, files, seen
|
||||
)
|
||||
)
|
||||
return found
|
||||
# #480: a file removed from the library is never tagged by a batch.
|
||||
hidden = await self._run_in_executor(
|
||||
self._removed_paths, [target.path for target in found]
|
||||
)
|
||||
return [target for target in found if target.path not in hidden]
|
||||
|
||||
async def _start_autotag(
|
||||
self, *, model_type, existing, options, keep_in_memory, title, owner_kind
|
||||
|
||||
+16
-6
@@ -66,6 +66,7 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...database.run_attribution import recorded_output_lookup
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.json_strict import dumps_strict
|
||||
from ...utils.inspection_response import (
|
||||
@@ -91,6 +92,7 @@ except ImportError:
|
||||
from database.models import normalize_image_path
|
||||
from database.run_attribution import recorded_output_lookup
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from utils.parallel import map_parallel
|
||||
from utils.json_strict import dumps_strict
|
||||
from utils.inspection_response import (
|
||||
@@ -915,7 +917,8 @@ def _name_suffix(name):
|
||||
def _walk_media(output_path, max_files):
|
||||
"""Yield the ``os.DirEntry`` of each media file under *output_path*.
|
||||
|
||||
Symlinks are never followed, ``thumbnails/`` is skipped, descent stops
|
||||
Symlinks are never followed, ``thumbnails/`` and trash folders are
|
||||
skipped (``utils.media_folders``, #501), descent stops
|
||||
at MAX_SCAN_DEPTH and at most *max_files* entries are produced. The order
|
||||
is ``os.walk``'s (top-down, subfolders in ``os.scandir`` order, files by
|
||||
name), which the gallery's ties in mtime keep. Each entry's type comes
|
||||
@@ -927,8 +930,6 @@ def _walk_media(output_path, max_files):
|
||||
produced = 0
|
||||
while stack:
|
||||
top, rel_parts = stack.pop()
|
||||
if "thumbnails" in rel_parts:
|
||||
continue
|
||||
dirs, files = [], []
|
||||
try:
|
||||
with os.scandir(top) as entries:
|
||||
@@ -952,7 +953,7 @@ def _walk_media(output_path, max_files):
|
||||
if len(rel_parts) >= MAX_SCAN_DEPTH:
|
||||
continue
|
||||
for entry in reversed(dirs):
|
||||
if not entry.is_symlink():
|
||||
if not entry.is_symlink() and not is_skipped_folder(entry.name):
|
||||
stack.append((entry.path, rel_parts + (entry.name,)))
|
||||
|
||||
|
||||
@@ -976,7 +977,7 @@ def _settled_entry(root, relative):
|
||||
*folders, name = relative.split(os.sep)
|
||||
if _name_suffix(name).lower() not in MEDIA_EXTENSIONS:
|
||||
return None
|
||||
if "thumbnails" in folders or len(folders) > MAX_SCAN_DEPTH:
|
||||
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
|
||||
return None
|
||||
folder = os.fspath(root)
|
||||
for part in ("", *folders):
|
||||
@@ -2450,6 +2451,10 @@ class ImageRoutesMixin:
|
||||
def key(entry):
|
||||
return _workspace_key(entry, order)
|
||||
|
||||
# #480: files removed from the library stay on disk, hidden here.
|
||||
hidden = self._hidden_entries(listings)
|
||||
if hidden:
|
||||
entries = [entry for entry in entries if entry.normalized not in hidden]
|
||||
return (
|
||||
entries,
|
||||
key,
|
||||
@@ -3071,7 +3076,9 @@ class ImageRoutesMixin:
|
||||
no symlinks), each preview folder is listed once, and at most
|
||||
MAX_THUMBNAIL_FILES targets are queued per batch (a job plans again
|
||||
after each batch); ``remaining`` counts the targets beyond that cap.
|
||||
Sources in *exclude* (worked on by an earlier batch) are left out.
|
||||
Sources in *exclude* (worked on by an earlier batch) are left out, and
|
||||
so are files removed from the library (#501: a removal mark hides
|
||||
them, and their previews went with their records).
|
||||
``stop()`` true ends the walk before the next file.
|
||||
"""
|
||||
settings = writer_settings(settings)
|
||||
@@ -3080,11 +3087,14 @@ class ImageRoutesMixin:
|
||||
plan = {key: 0 for key in _PLAN_COUNTS}
|
||||
plan.update(targets=[], fresh_files=[])
|
||||
folders = {}
|
||||
removed = self._removal_check_under(output_path)
|
||||
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
|
||||
if stop is not None and stop():
|
||||
break
|
||||
if exclude and src in exclude:
|
||||
continue
|
||||
if removed(src):
|
||||
continue
|
||||
rel_no_ext = src.relative_to(output_path).with_suffix("")
|
||||
stem = f"{rel_no_ext.name}{thumbnail_settings.THUMB_SUFFIX}"
|
||||
dst = thumbnails_dir / rel_no_ext.parent / (stem + settings.extension)
|
||||
|
||||
@@ -123,6 +123,11 @@ MODEL_DOWNLOAD_JOB_KIND = "model_download"
|
||||
DUPLICATES_JOB_KIND = "duplicates"
|
||||
DUPLICATE_GROUP_NAMES = ("groups_found",)
|
||||
DUPLICATE_CLEANUP_GROUP_NAMES = ("removed", "kept", "errors")
|
||||
# The workspace Library's Delete… (#443, py/api/library_delete.py): a
|
||||
# ``records`` hold around its safety backup and write.
|
||||
PROMPT_DELETE_JOB_KIND = "prompt_delete"
|
||||
IMAGE_DELETE_JOB_KIND = "image_delete" # #480
|
||||
IMAGE_DELETE_GROUP_NAMES = ("trashed", "removed", "not_changed", "errors")
|
||||
# The System health check (py/api/system.py): a read-only ``read`` job with
|
||||
# no file outcomes, so it may run beside a scan.
|
||||
HEALTH_JOB_KIND = "health"
|
||||
|
||||
@@ -157,6 +157,13 @@ def _archive_scope(request: web.Request) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _shelf(request: web.Request) -> str:
|
||||
value = request.query.get("shelf", "active")
|
||||
if value not in ("active", "archived"):
|
||||
raise ValueError("shelf must be active or archived")
|
||||
return value
|
||||
|
||||
|
||||
def _missing() -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Positive prompt not found"}, status=404
|
||||
@@ -332,6 +339,9 @@ class LibraryRoutesMixin:
|
||||
"tags": sorted(set(request.query.getall("tag", []))),
|
||||
"tag_mode": request.query.get("tag_mode", "all"),
|
||||
"sort": request.query.get("sort", "newest"),
|
||||
# #443: which shelf; part of the cursor scope, so a cursor never
|
||||
# crosses shelves. Not the variant ``archive`` scope of /{id}.
|
||||
"shelf": _shelf(request),
|
||||
}
|
||||
limit = _page_limit(request)
|
||||
cursor = request.query.get("cursor")
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Delete or archive selected Library prompts, and restore archived ones (#443).
|
||||
|
||||
The workspace Prompt library's Delete… (D152). The legacy ``POST
|
||||
/prompt_manager/bulk/delete`` and ``DELETE /prompt_manager/delete/{id}`` are
|
||||
separate and unchanged.
|
||||
|
||||
- ``POST /prompt_manager/library/delete/preview {prompt_ids, expected_epoch}``
|
||||
classifies the selection (reads only, under a lease): the ids to ``delete``
|
||||
(in no saved pair), to ``archive`` (paired), the ``protected`` ones (tagged
|
||||
``__protected__``, kept), counts of ``already_archived`` and ``not_found``,
|
||||
the ``image_links`` a delete removes, the safety ``backup`` policy and,
|
||||
when a backup would be taken, the ``disk`` it needs.
|
||||
- ``POST /prompt_manager/library/delete {archive_ids, delete_ids,
|
||||
expected_epoch, backup}`` executes that review under a ``records`` hold
|
||||
(refused 409 ``busy`` beside a scan, restore, maintenance, move or
|
||||
Optimize). When a delete remains and a backup was promised (the reviewed
|
||||
``backup`` was "on" or the policy is on now) it checks the disk (507
|
||||
``insufficient-space``) and takes a verified safety backup (500
|
||||
``backup-failed``); then one transaction deletes the reviewed deletes whose
|
||||
fingerprint is unchanged since the backup and archives the paired ones.
|
||||
- ``POST /prompt_manager/library/restore {prompt_ids, expected_epoch}``
|
||||
removes the archive mark (under a lease).
|
||||
|
||||
A changed storage epoch answers 409 ``storage-changed``; nothing is written.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.library_commands import LibraryCommandError
|
||||
from ...database.safety_backup import SafetyBackupFailed
|
||||
except ImportError:
|
||||
from database.library_commands import LibraryCommandError
|
||||
from database.safety_backup import SafetyBackupFailed
|
||||
|
||||
from . import maintenance as _maintenance
|
||||
from .admin import _prune_safety_backups
|
||||
from .job_routes import busy_response
|
||||
from .jobs import PROMPT_DELETE_JOB_KIND, JobBusy
|
||||
from .prompts import parse_bulk_prompt_ids
|
||||
|
||||
PREVIEW_URL = "/prompt_manager/library/delete/preview"
|
||||
DELETE_URL = "/prompt_manager/library/delete"
|
||||
RESTORE_URL = "/prompt_manager/library/restore"
|
||||
DELETE_TITLE = "Delete prompts (Library)"
|
||||
_SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"})
|
||||
_DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"})
|
||||
# #482: Delete permanently, with its digests; both or neither (a #443 body).
|
||||
_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"})
|
||||
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
|
||||
|
||||
|
||||
def _failure(status, code, error, **extra):
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": error, **extra}, status=status
|
||||
)
|
||||
|
||||
|
||||
def _invalid(message):
|
||||
return _failure(400, "invalid-request", message)
|
||||
|
||||
|
||||
def _storage_changed(exc):
|
||||
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
|
||||
|
||||
|
||||
async def _body(request, keys, optional=frozenset()):
|
||||
"""The JSON object with exactly ``keys`` (plus all or none of ``optional``)."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
raise ValueError("Expected a JSON object") from None
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
extra = set(data) - keys
|
||||
if not keys <= set(data) or (extra and extra != optional):
|
||||
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
|
||||
return data
|
||||
|
||||
|
||||
def _paired_delete(data, archive_ids):
|
||||
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
|
||||
if "delete_paired_ids" not in data:
|
||||
return {}
|
||||
ids = _reviewed_ids(data["delete_paired_ids"], "delete_paired_ids")
|
||||
digests = data["paired_digests"]
|
||||
if not isinstance(digests, dict):
|
||||
raise ValueError("paired_digests must map prompt ids to digests")
|
||||
if not set(ids) <= set(archive_ids):
|
||||
raise ValueError("delete_paired_ids must be reviewed archive ids")
|
||||
chosen = {}
|
||||
for pid in ids:
|
||||
digest = digests.get(str(pid))
|
||||
if not isinstance(digest, str) or len(digest) != 64:
|
||||
raise ValueError("Every delete_paired_ids entry needs its review digest")
|
||||
chosen[pid] = digest
|
||||
return chosen
|
||||
|
||||
|
||||
def _reviewed_ids(value, name):
|
||||
"""A reviewed id list: possibly empty, else as ``parse_bulk_prompt_ids``."""
|
||||
if value == []:
|
||||
return []
|
||||
try:
|
||||
return parse_bulk_prompt_ids(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc).replace("prompt_ids", name)) from None
|
||||
|
||||
|
||||
class LibraryDeleteRoutesMixin:
|
||||
"""Mixin providing the workspace Library's delete, archive and restore."""
|
||||
|
||||
def _register_library_delete_routes(self, routes):
|
||||
routes.post(PREVIEW_URL)(self.preview_library_delete)
|
||||
routes.post(DELETE_URL)(self.delete_library_prompts)
|
||||
routes.post(RESTORE_URL)(self.restore_library_prompts)
|
||||
|
||||
async def preview_library_delete(self, request):
|
||||
try:
|
||||
data = await _body(request, _SELECTION_KEYS)
|
||||
ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
db = self.db
|
||||
policy = self._safety_backup_policy()
|
||||
try:
|
||||
async with self._jobs.lease("Library delete review"):
|
||||
review = await self._run_in_executor(
|
||||
db.classify_library_delete,
|
||||
ids,
|
||||
expected_epoch=data["expected_epoch"],
|
||||
)
|
||||
disk = None
|
||||
if (review["delete"] or review["deletable"]) and policy == "on":
|
||||
public, short, _size = await self._run_in_executor(
|
||||
_maintenance.disk_plan,
|
||||
db.model.db_path,
|
||||
backup=True,
|
||||
vacuum=False,
|
||||
)
|
||||
disk = {
|
||||
**public,
|
||||
"message": (
|
||||
None
|
||||
if short is None
|
||||
else _maintenance.short_space_message(short)
|
||||
),
|
||||
}
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
except LibraryCommandError as exc:
|
||||
return _storage_changed(exc)
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
self.logger.exception("Library delete review failed")
|
||||
return _failure(500, "review-failed", "The delete could not be reviewed")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"library_epoch": review["library_epoch"],
|
||||
"delete": review["delete"],
|
||||
"archive": review["archive"],
|
||||
"protected": review["protected"],
|
||||
"already_archived": len(review["already_archived"]),
|
||||
"not_found": len(review["not_found"]),
|
||||
"image_links": review["image_links"],
|
||||
# #482: the archive ids Delete permanently may delete with pairs.
|
||||
"deletable": review["deletable"],
|
||||
"review_digests": {
|
||||
str(pid): digest for pid, digest in review["review_digests"].items()
|
||||
},
|
||||
"paired_counts": review["paired_counts"],
|
||||
"paired_image_links": review["paired_image_links"],
|
||||
"backup": policy,
|
||||
"disk": disk,
|
||||
}
|
||||
)
|
||||
|
||||
async def delete_library_prompts(self, request):
|
||||
try:
|
||||
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS)
|
||||
archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids")
|
||||
delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids")
|
||||
if not archive_ids and not delete_ids:
|
||||
raise ValueError("Nothing to delete or archive")
|
||||
if len(archive_ids) + len(delete_ids) > 1000:
|
||||
raise ValueError("At most 1000 prompts can be changed at once")
|
||||
if set(archive_ids) & set(delete_ids):
|
||||
raise ValueError("A prompt cannot be both archived and deleted")
|
||||
if data["backup"] not in ("on", "off"):
|
||||
raise ValueError("backup must be on or off")
|
||||
paired = _paired_delete(data, archive_ids)
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
busy = self._jobs.blocker(PROMPT_DELETE_JOB_KIND, admission="records")
|
||||
if busy is not None:
|
||||
return busy_response(JobBusy(busy))
|
||||
try:
|
||||
async with self._jobs.hold(
|
||||
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
|
||||
) as job:
|
||||
response = await self._held_library_delete(
|
||||
self.db, data, archive_ids, delete_ids, paired
|
||||
)
|
||||
if response.status >= 400:
|
||||
job.finish("failed")
|
||||
return response
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired):
|
||||
epoch = data["expected_epoch"]
|
||||
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
|
||||
backup = None
|
||||
try:
|
||||
candidates = None
|
||||
if delete_ids or paired:
|
||||
review = await self._run_in_executor(
|
||||
db.classify_library_delete,
|
||||
[*delete_ids, *paired],
|
||||
expected_epoch=epoch,
|
||||
)
|
||||
candidates = review["fingerprints"]
|
||||
# #482: a permanent delete of a prompt in a saved pair is backed up too.
|
||||
if (candidates or review["deletable"]) and promised:
|
||||
_disk, short, _size = await self._run_in_executor(
|
||||
_maintenance.disk_plan,
|
||||
db.model.db_path,
|
||||
backup=True,
|
||||
vacuum=False,
|
||||
)
|
||||
if short is not None:
|
||||
return _failure(
|
||||
507,
|
||||
"insufficient-space",
|
||||
_maintenance.short_space_message(short),
|
||||
)
|
||||
backup = await self._run_in_executor(
|
||||
_maintenance.take_safety_backup, db.model
|
||||
)
|
||||
result = await self._run_in_executor(
|
||||
db.archive_or_delete_library_prompts,
|
||||
archive_ids,
|
||||
delete_ids,
|
||||
expected_epoch=epoch,
|
||||
candidates=candidates if candidates is not None else {},
|
||||
delete_paired=paired,
|
||||
)
|
||||
except LibraryCommandError as exc:
|
||||
return _storage_changed(exc)
|
||||
except SafetyBackupFailed as error:
|
||||
self.logger.error("Safety backup before a Library delete failed")
|
||||
return _failure(
|
||||
500, "backup-failed", _maintenance.backup_failed_message(error)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
self.logger.exception("Library delete failed")
|
||||
return _failure(500, "delete-failed", "The prompts could not be changed")
|
||||
finally:
|
||||
if backup is not None:
|
||||
await self._prune_after_delete(db)
|
||||
return web.json_response({"success": True, **result, "backup": backup})
|
||||
|
||||
async def _prune_after_delete(self, db):
|
||||
try:
|
||||
await self._run_in_executor(_prune_safety_backups, db.model.db_path)
|
||||
except Exception: # pruning old copies never fails the delete
|
||||
self.logger.exception("Could not prune old safety backups")
|
||||
|
||||
async def restore_library_prompts(self, request):
|
||||
try:
|
||||
data = await _body(request, _SELECTION_KEYS)
|
||||
ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
try:
|
||||
async with self._jobs.lease("Library restore"):
|
||||
result = await self._run_in_executor(
|
||||
self.db.restore_library_prompts,
|
||||
ids,
|
||||
expected_epoch=data["expected_epoch"],
|
||||
)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
except LibraryCommandError as exc:
|
||||
return _storage_changed(exc)
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
self.logger.exception("Library restore failed")
|
||||
return _failure(500, "restore-failed", "The prompts could not be restored")
|
||||
return web.json_response({"success": True, **result})
|
||||
+21
-4
@@ -281,7 +281,7 @@ def short_space_message(short):
|
||||
)
|
||||
|
||||
|
||||
def _changed_line(operation, count):
|
||||
def _changed_line(operation, count, marks=0):
|
||||
if operation == "optimize":
|
||||
return "Optimized the database"
|
||||
if operation == "duplicate_prompts":
|
||||
@@ -291,7 +291,11 @@ def _changed_line(operation, count):
|
||||
noun = "unused prompt component" if count == 1 else "unused prompt components"
|
||||
return f"Removed {count:,} {noun}"
|
||||
noun = "link to a missing file" if count == 1 else "links to missing files"
|
||||
return f"Removed {count:,} {noun}"
|
||||
line = f"Removed {count:,} {noun}"
|
||||
if marks:
|
||||
gone = "removed image" if marks == 1 else "removed images"
|
||||
line += f"; forgot {marks:,} {gone} whose files are gone or changed"
|
||||
return line
|
||||
|
||||
|
||||
def take_safety_backup(model, label="backup"):
|
||||
@@ -419,6 +423,7 @@ class MaintenanceRoutesMixin:
|
||||
draft.public(),
|
||||
{
|
||||
"targets": draft.targets,
|
||||
"marks": draft.marks,
|
||||
"roots": roots,
|
||||
"protected": draft.protected["count"],
|
||||
"rows": draft.scope["count"],
|
||||
@@ -488,7 +493,9 @@ class MaintenanceRoutesMixin:
|
||||
# ── apply ─────────────────────────────────────────────────────────
|
||||
|
||||
async def apply_maintenance_plan(self, request):
|
||||
data, error = await self._read_maintenance_body(request, ("token",))
|
||||
from .image_delete import IMAGE_DELETE, parse_mode
|
||||
|
||||
data, error = await self._read_maintenance_body(request, ("token", "mode"))
|
||||
if error is not None:
|
||||
return error
|
||||
token = data.get("token")
|
||||
@@ -497,6 +504,13 @@ class MaintenanceRoutesMixin:
|
||||
plan, refused = self._peek_plan(token)
|
||||
if refused is not None:
|
||||
return refused
|
||||
mode = None
|
||||
if plan.operation == IMAGE_DELETE:
|
||||
mode = parse_mode(data.get("mode"))
|
||||
if mode is None:
|
||||
return _invalid('mode must be "library" or "trash"')
|
||||
elif "mode" in data:
|
||||
return _invalid("Unknown field: mode")
|
||||
store = self._maintenance_plans
|
||||
if plan.operation == ROTATED_LOGS:
|
||||
# Log files only: no database identity, backup or disk check.
|
||||
@@ -544,6 +558,8 @@ class MaintenanceRoutesMixin:
|
||||
try:
|
||||
if plan.operation == DUPLICATE_FILES:
|
||||
job = self._start_duplicate_cleanup(db, plan, backup)
|
||||
elif plan.operation == IMAGE_DELETE:
|
||||
job = self._start_image_delete(db, plan, mode, backup)
|
||||
elif plan.operation == WORKFLOW_STORAGE:
|
||||
job = self._start_workflow_storage(db, backup)
|
||||
else:
|
||||
@@ -642,6 +658,7 @@ class MaintenanceRoutesMixin:
|
||||
plan.fingerprint,
|
||||
payload["targets"],
|
||||
payload["roots"],
|
||||
payload.get("marks", ()),
|
||||
)
|
||||
except PlanChanged:
|
||||
return _fail_job(job, "plan-changed", PLAN_CHANGED)
|
||||
@@ -653,7 +670,7 @@ class MaintenanceRoutesMixin:
|
||||
)
|
||||
job.record_counted("changed", result["changed"], rows)
|
||||
job.record_counted("protected", payload["protected"])
|
||||
line = _changed_line(plan.operation, result["changed"])
|
||||
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// #513: a worker records its browser's own log, so a browser that dies between two tests leaves its exit and stderr
|
||||
// behind instead of only "Target page, context or browser has been closed" on the next test.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { isTargetGone } from '../../tools/vendor/browser-logs.mjs';
|
||||
|
||||
test('the worker records the launch of the browser its tests use', async ({ browser, browserLogs }) => {
|
||||
expect(browser.isConnected()).toBe(true);
|
||||
expect(browserLogs.launches().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a killed browser leaves its signal and stderr in the worker log', async ({ playwright, browserName, browserLogs }) => {
|
||||
const before = browserLogs.launches();
|
||||
const victim = await playwright[browserName].launch();
|
||||
const pid = browserLogs.launches().find(item => !before.includes(item));
|
||||
expect(pid, 'the new browser\'s launch was recorded').toBeGreaterThan(0);
|
||||
const disconnected = new Promise(resolve => victim.once('disconnected', resolve));
|
||||
// Playwright starts a browser in a process group of its own; WebKit's launched pid is a wrapper script.
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
await disconnected;
|
||||
const error = await victim.newContext().then(() => null, item => item);
|
||||
expect(error, 'a dead browser refuses a new context').not.toBeNull();
|
||||
expect(isTargetGone(error.message)).toBe(true);
|
||||
// The pipe can close before the launcher has logged the exit.
|
||||
await expect.poll(() => browserLogs.exits()).toContainEqual({ pid, exitCode: null, signal: 'SIGKILL' });
|
||||
expect(browserLogs.tail()).toContain(`[pid=${pid}] <process did exit: exitCode=null, signal=SIGKILL>`);
|
||||
});
|
||||
@@ -4,13 +4,29 @@
|
||||
// points at that worker's server. A server that dies fails the running test
|
||||
// with its stderr tail, and the next test in the worker gets a fresh server.
|
||||
// A test that fails gets the stderr its server wrote during it attached as
|
||||
// `server-stderr`.
|
||||
// `server-stderr`. The worker also records its browser's own log (#513): a
|
||||
// browser that exits abnormally prints one `pm-browser-exit|` line, and a test
|
||||
// that fails on a closed or crashed target gets that log attached as
|
||||
// `browser-stderr`.
|
||||
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
|
||||
|
||||
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
|
||||
|
||||
export const test = base.extend({
|
||||
browserLogs: [async ({}, use) => {
|
||||
const recorder = createBrowserLogRecorder();
|
||||
try {
|
||||
await use(recorder);
|
||||
} finally {
|
||||
recorder.uninstall();
|
||||
}
|
||||
}, { scope: 'worker', auto: true }],
|
||||
browserLogsOnFailure: [async ({ browserLogs }, use, testInfo) => {
|
||||
await use();
|
||||
await attachBrowserLogs(testInfo, browserLogs);
|
||||
}, { auto: true }],
|
||||
testServer: [async ({}, use) => {
|
||||
const supervisor = createServerSupervisor({ start: () => startBrowserServer() });
|
||||
try {
|
||||
|
||||
@@ -149,8 +149,8 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
const footer=panel.locator('.detail-footer');
|
||||
// #331 D139: every listed file's footer ends with Collections… (folder icon).
|
||||
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
|
||||
// #331 D139: every listed file's footer has Collections… (folder icon); #480 D159: Delete… ends it.
|
||||
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…','Delete…']);
|
||||
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
|
||||
await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/);
|
||||
@@ -162,10 +162,10 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']);
|
||||
// Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139).
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…','Delete…']);
|
||||
// Unlinked: no badge, category, tags, pair or history; the footer holds Collections… (D139) and Delete… (D159).
|
||||
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…','Delete…']);
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
|
||||
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
@@ -266,7 +266,7 @@ test('pair send and history appear only with real roles and link',async({page,re
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…','Delete…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
|
||||
@@ -627,6 +627,10 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
|
||||
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
|
||||
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
|
||||
production.paths=production.paths.filter(path=>!modePaths.includes(path));
|
||||
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
|
||||
expect(production.toolbar[8].text).toBe('Delete…');
|
||||
production.toolbar.splice(8,1);
|
||||
production.paths=production.paths.filter(path=>!path.includes('button.danger'));
|
||||
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
|
||||
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
|
||||
const appended=production.toolbar.slice(9);
|
||||
|
||||
@@ -31,13 +31,15 @@ function captionDay(seconds,now=new Date()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to
|
||||
* collection compares in place (D138).
|
||||
* D8 (amended by #249 PR D and #331): the selection bar's real actions only. Add to collection compares in place
|
||||
* (D138). #480 D159: Remove links… returns as Delete…; OMITTED_BULK names the concept button it replaces.
|
||||
*/
|
||||
const OMITTED_BULK=Object.freeze(['Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
|
||||
const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
|
||||
/** #480 D159: Delete… takes the place of Remove links… (the concept's `danger` button, under another text). */
|
||||
const DELETE_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-remove"]',text:'Delete…'}]);
|
||||
|
||||
/**
|
||||
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
|
||||
@@ -452,10 +454,12 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const removed=(await inventoryOf(page,'#main')).bulkbar;
|
||||
await restore();
|
||||
// The oracle for production's bar: the concept page itself without the omitted actions and with D85's button.
|
||||
const restoreProduction=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS],clone:AUTO_TAG_CLONE});
|
||||
// The oracle for production's bar: the concept page itself with D85's button and D159's Delete… in place of
|
||||
// Remove links….
|
||||
const restoreClones=await mutate(page,{clone:[...AUTO_TAG_CLONE,...DELETE_CLONE]});
|
||||
const restoreRemoved=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const oracle=await inventoryOf(page,'#main');
|
||||
await restoreProduction();
|
||||
await restoreRemoved();await restoreClones();
|
||||
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const emptyActual=await inventoryOf(page,'#workspace-main');
|
||||
@@ -468,7 +472,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
|
||||
'Set category','Add to collection','Export metadata','Clear']);
|
||||
'Set category','Add to collection','Export metadata','Delete…','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// The bar comparison notices a change to the production bar.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
|
||||
|
||||
// #480 (D159): the Images selection bar's Delete… against the concept's "Remove links…" (app.js a432, `danger`) and
|
||||
// its review against the concept's `maintenancePlan` (a953–957): the bar button's place and style, the review's
|
||||
// `dl.meta-grid` rows and its actions, on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
|
||||
// Paired screenshots are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
|
||||
/** Runs in the page: the selection bar's danger button and where it sits among the bar's buttons. */
|
||||
function measureBar() {
|
||||
const bar=document.querySelector('#bulkbar > .bulkbar');
|
||||
const buttons=[...bar.querySelectorAll(':scope > button')];
|
||||
const danger=bar.querySelector(':scope > button.danger');
|
||||
const css=(node,...keys)=>Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]]));
|
||||
return {
|
||||
// The danger action sits right before Clear, the last button.
|
||||
dangerBeforeClear:buttons.indexOf(danger)===buttons.length-2,
|
||||
lastText:buttons.at(-1).textContent.trim(),
|
||||
danger:css(danger,'color','backgroundColor','borderTopColor','fontSize','fontWeight','paddingTop','paddingLeft',
|
||||
'borderTopLeftRadius','height'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: the open review's grid and actions (the parts production shares with the concept). */
|
||||
function measureReview() {
|
||||
const dialog=document.querySelector('dialog#modal[open]');
|
||||
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
|
||||
const grid=dialog.querySelector('#modal-body dl.meta-grid');
|
||||
const entry=grid.querySelector(':scope > div');
|
||||
const actions=dialog.querySelector('#modal-body > .dialog-actions');
|
||||
const [cancel]=actions.children;
|
||||
return {
|
||||
grid:css(grid,'display','gridTemplateColumns','columnGap','rowGap'),
|
||||
dt:css(entry.querySelector('dt'),'fontSize','color','textTransform','letterSpacing'),
|
||||
dd:css(entry.querySelector('dd'),'fontSize','color','marginLeft'),
|
||||
terms:[...grid.querySelectorAll('dt')].map(dt=>dt.textContent.trim()),
|
||||
actions:css(actions,'display','justifyContent','columnGap','marginTop'),
|
||||
cancel:{text:cancel.textContent.trim(),...css(cancel,'backgroundColor','color','fontSize','paddingTop','paddingLeft')},
|
||||
};
|
||||
}
|
||||
|
||||
async function checkFirst(page) {
|
||||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
}
|
||||
|
||||
async function concept(page,theme) {
|
||||
await openConcept(page,'images',theme);
|
||||
await checkFirst(page);
|
||||
const bar=await page.evaluate(measureBar);
|
||||
await page.locator('#main #bulkbar').getByRole('button',{name:'Remove links…',exact:true}).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
|
||||
await page.mouse.move(0,0);
|
||||
return {bar,review:await page.evaluate(measureReview)};
|
||||
}
|
||||
|
||||
async function production(page,theme) {
|
||||
await openProduction(page,'images',theme);
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
await checkFirst(page);
|
||||
const bar=await page.evaluate(measureBar);
|
||||
await page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Delete…',exact:true}).click();
|
||||
const dialog=page.locator('dialog#modal[open]');
|
||||
await expect(dialog.locator('#modal-title')).toHaveText('Delete 1 image');
|
||||
await dialog.getByRole('radio',{name:/^Remove from the library only/}).check();
|
||||
await page.mouse.move(0,0);
|
||||
return {bar,review:await page.evaluate(measureReview)};
|
||||
}
|
||||
|
||||
/**
|
||||
* D159: production's rows follow the chosen mode (Scope, then what the files lose, Backup and Media on disk; the
|
||||
* concept's sample has Scope, Protected, Backup and Media on disk). The geometry, tokens and the Cancel button match.
|
||||
*/
|
||||
function mapConcept(reference,actual) {
|
||||
return {...reference,review:{...reference.review,terms:actual.review.terms}};
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
|
||||
|
||||
for(const theme of THEMES)for(const [width,height] of SIZES) {
|
||||
test(`delete matches the concept's remove links ${theme} ${width}x${height}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const reference=await concept(page,theme);
|
||||
await page.screenshot({path:testInfo.outputPath(`concept-remove-links-${theme}-${width}.png`)});
|
||||
const actual=await production(page,theme);
|
||||
await page.screenshot({path:testInfo.outputPath(`production-delete-${theme}-${width}.png`)});
|
||||
expect(reference.review.terms).toEqual(['Scope','Protected','Backup','Media on disk']);
|
||||
expect(actual.review.terms[0]).toBe('Scope');
|
||||
expect(actual.review.terms.slice(-2)).toEqual(['Backup','Media on disk']);
|
||||
expect(diffInventory(mapConcept(reference,actual),actual)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('delete comparison detects mutations',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const reference=await concept(page,'dark');
|
||||
const actual=await production(page,'dark');
|
||||
const expected=mapConcept(reference,actual);
|
||||
expect(diffInventory(expected,actual)).toEqual([]);
|
||||
const undo=await mutate(page,{css:'dialog#modal dl.meta-grid { column-gap: 31px; } dialog#modal .dialog-actions { gap: 29px; }'});
|
||||
const paths=diffInventory(expected,{...actual,review:await page.evaluate(measureReview)}).map(item=>item.path);
|
||||
expect(paths).toEqual(expect.arrayContaining(['review.grid.columnGap','review.actions.columnGap']));
|
||||
await undo();
|
||||
});
|
||||
@@ -56,7 +56,8 @@ function withCategoryScope(inventory) {
|
||||
* equal production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
|
||||
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
|
||||
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details.
|
||||
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details; #480's
|
||||
* D159 "Delete…" follows it in the panel footer.
|
||||
*/
|
||||
export function mapInspectorConcept(reference,{viewport}) {
|
||||
const {panel,viewerPlain,fileControls}=reference;
|
||||
@@ -125,6 +126,13 @@ async function withCollectionsButton(page,selector) {
|
||||
for(const key of Object.keys(button.dataset))delete button.dataset[key];
|
||||
button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…');
|
||||
history.after(button);(window.__collectionsButtons??=[]).push(button);
|
||||
// #480 D159: Delete… ends the panel's actions, drawn as the concept's own `danger` button (its Remove links…).
|
||||
if(selector.startsWith('#image-inspector')) {
|
||||
const remove=history.cloneNode(false);
|
||||
for(const key of Object.keys(remove.dataset))delete remove.dataset[key];
|
||||
remove.className='danger';remove.textContent='Delete…';
|
||||
button.after(remove);window.__collectionsButtons.push(remove);
|
||||
}
|
||||
},selector);
|
||||
await settle(page);
|
||||
return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});};
|
||||
@@ -373,7 +381,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
|
||||
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(tags.locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…','Delete…']);
|
||||
});
|
||||
|
||||
// #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the
|
||||
|
||||
@@ -44,7 +44,12 @@ export function mapLibraryConcept(reference) {
|
||||
// "4 runs" and "Used today". The two-line clamp is
|
||||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||||
footer:card.footer}));
|
||||
return {...reference,library:{...library,toolbar,
|
||||
// D152 (#443): Show archived prompts (N) comes first in the heading actions, a plain button like Export (the concept
|
||||
// fixture has nothing archived, so N is 0).
|
||||
const actions=reference.heading.actions;
|
||||
const plain=actions.buttons.find(button=>!button.primary);
|
||||
const heading={...reference.heading,actions:{...actions,buttons:[{...plain,text:'Show archived prompts (0)'},...actions.buttons]}};
|
||||
return {...reference,heading,library:{...library,toolbar,
|
||||
// D35: the caption uses the singular for one.
|
||||
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
|
||||
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
|
||||
// #443 D152: the Library's Delete… review against the concept's own reviewed archive: the recipe editor's "Archive…"
|
||||
// (app.js 976), which opens `maintenancePlan("archive <title>")` (953–958, 1309–1310). The concept's shell, grid tokens,
|
||||
// `p.small` and actions are kept; its title, demo note, rows, note text and `ca-primary` start are replaced by D152's,
|
||||
// and the start takes the concept's `danger` class, measured on the editor's own "Archive…". The bar's Delete… is
|
||||
// compared with the concept Images bar's danger action, "Remove links…" (app.js 432).
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConcept(request);
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
/** Runs in the page: the open review dialog's title, body tree, grid, notes and actions with the concept's tokens. */
|
||||
function measureReview() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const body = dialog.querySelector('#modal-body');
|
||||
const grid = body.querySelector(':scope > dl.meta-grid');
|
||||
const notes = [...body.querySelectorAll(':scope > p.small')];
|
||||
const actions = body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title: text(dialog.querySelector('#modal-title')),
|
||||
tree: [...body.children].map(name).filter(item => item !== 'p.demo-note' && item !== 'h3'),
|
||||
grid: { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
|
||||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
|
||||
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
|
||||
notes: notes.map(note => ({ text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') })),
|
||||
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
|
||||
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: a button's class and tokens (the concept's danger button, or production's). */
|
||||
function measureButton(selector) {
|
||||
const node = document.querySelector(selector);
|
||||
const style = getComputedStyle(node);
|
||||
return { className: node.className, ...Object.fromEntries(['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop',
|
||||
'paddingLeft', 'borderTopColor', 'borderTopLeftRadius', 'height'].map(key => [key, key === 'height'
|
||||
? `${Math.round(node.getBoundingClientRect().height * 100) / 100}px` : style[key]])) };
|
||||
}
|
||||
|
||||
async function conceptReview(page, theme) {
|
||||
await openConcept(page, 'prompt/alpine', theme);
|
||||
await page.locator('#main .heading-actions').getByRole('button', { name: 'Edit metadata', exact: true }).click();
|
||||
const archive = page.locator('dialog#modal[open] .dialog-actions button', { hasText: 'Archive…' });
|
||||
await expect(archive).toBeVisible();await page.mouse.move(0, 0);
|
||||
const danger = await page.evaluate(measureButton, 'dialog#modal[open] .dialog-actions button.danger');
|
||||
await archive.click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
|
||||
await page.mouse.move(0, 0);
|
||||
return { danger, review: await page.evaluate(measureReview) };
|
||||
}
|
||||
|
||||
async function seedMixed(request) {
|
||||
// One unpaired prompt beside the concept's paired ones, so the review deletes one and archives one.
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'Unpaired study', category: 'Studies' } })).toBeOK();
|
||||
const items = (await (await request.get('/prompt_manager/library?limit=100')).json()).items;
|
||||
return { unpaired: items.find(item => item.text === 'Unpaired study'), paired: items.find(item => item.variant_count > 0) };
|
||||
}
|
||||
|
||||
async function productionReview(page, request, theme) {
|
||||
const { unpaired, paired } = await seedMixed(request);
|
||||
await openProduction(page, 'library', theme);
|
||||
for (const item of [unpaired, paired]) await page.locator(`article[data-prompt-id="${item.id}"] .image-overlays input`).check();
|
||||
await page.locator('#library-bulkbar .bulkbar').getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Delete or archive selected prompts');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureReview);
|
||||
}
|
||||
|
||||
/** D152's changes to the concept's review; everything else must equal the concept. */
|
||||
function mapDeleteConcept({ danger, review }) {
|
||||
const [cancel] = review.actions.buttons;
|
||||
const note = review.notes.at(-1);
|
||||
return {
|
||||
...review,
|
||||
title: 'Delete or archive selected prompts',
|
||||
// #482: the paired prompt is deletable, so the review asks Archive or Delete permanently (a D90 radio row).
|
||||
tree: ['dl.meta-grid', 'div.field', 'p.small', 'p.small', 'div.dialog-actions'],
|
||||
grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'],
|
||||
['Archive', '1 prompt in a saved pair: hidden, restorable'],
|
||||
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting']] },
|
||||
notes: [
|
||||
{ ...note, text: 'Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the '
|
||||
+ 'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, '
|
||||
+ 'select them and press Restore.' },
|
||||
// A second paragraph takes the concept's own `p + p` margin (style.css 93-95).
|
||||
{ ...note, marginTop: '10px', text: "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and "
|
||||
+ 'image links. Deleted prompts cannot be brought back one by one: the only way back is to restore the safety '
|
||||
+ 'backup taken first (System › Backup & export), which returns the whole library to that moment. It is kept '
|
||||
+ 'among the last 10 safety backups. Image files, favorites, collections and recorded runs stay. A later scan, a '
|
||||
+ 'new image or a node run with the same text adds it again as a new prompt.' },
|
||||
],
|
||||
actions: { ...review.actions, tree: [review.actions.tree[0], 'button.danger'], buttons: [cancel, { text: 'Delete 1 and archive 1 prompt', className: danger.className,
|
||||
backgroundColor: danger.backgroundColor, color: danger.color, fontSize: danger.fontSize, paddingTop: danger.paddingTop,
|
||||
paddingLeft: danger.paddingLeft, borderTopColor: danger.borderTopColor }] },
|
||||
};
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`the Delete… review matches the concept's reviewed archive ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const concept = await conceptReview(page, theme);
|
||||
expect(concept.review.tree).toEqual(['dl.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-archive-review-${theme}-${width}.png`) });
|
||||
const production = await productionReview(page, request, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-delete-review-${theme}-${width}.png`) });
|
||||
expect(diffInventory(mapDeleteConcept(concept), production)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('the Delete… review comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptReview(page, 'dark');
|
||||
await productionReview(page, request, 'dark');
|
||||
const expected = mapDeleteConcept(concept);
|
||||
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
|
||||
expect(await paths()).toEqual([]);
|
||||
await mutate(page, { css: '#modal-body .dialog-actions .danger { padding-left: 2px }' });
|
||||
expect(await paths()).toContain('actions.buttons.1.paddingLeft');
|
||||
});
|
||||
|
||||
test('the bar\'s Delete… matches the concept Images bar\'s danger action', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openConcept(page, 'images', 'dark');
|
||||
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
|
||||
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
|
||||
const reference = await page.evaluate(measureButton, '#bulkbar .bulkbar button.danger');
|
||||
const { paired } = await seedMixed(request);
|
||||
await openProduction(page, 'library', 'dark');
|
||||
await page.locator(`article[data-prompt-id="${paired.id}"] .image-overlays input`).check();
|
||||
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
|
||||
expect(await page.evaluate(measureButton, '#library-bulkbar .bulkbar button.danger')).toEqual(reference);
|
||||
});
|
||||
@@ -26,9 +26,15 @@ export function inventoryOf(dialog) {
|
||||
};
|
||||
}
|
||||
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
|
||||
// concept's "Rating" select is not drawn.
|
||||
// concept's "Rating" select is not drawn. D161 (#481): the concept's one danger "Archive…" becomes "Archive variant…" and
|
||||
// is followed by the prompt's own "Delete prompt…", drawn as the same danger button.
|
||||
function mapButton(item) {
|
||||
if(item.text==='Save in prototype') return [{...item,text:'Save'}];
|
||||
if(item.text==='Archive…') return [{...item,text:'Archive variant…'},{...item,text:'Delete prompt…'}];
|
||||
return [item];
|
||||
}
|
||||
export function mapConcept(reference) {
|
||||
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
|
||||
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.flatMap(mapButton)}};
|
||||
}
|
||||
|
||||
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
|
||||
|
||||
@@ -24,7 +24,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
|
||||
await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled();
|
||||
await editor(page).getByRole('button',{name:'Archive…',exact:true}).click();
|
||||
await editor(page).getByRole('button',{name:'Archive variant…',exact:true}).click();
|
||||
await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive');
|
||||
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Keep the browser test server's large frees going back to the OS (#516).
|
||||
|
||||
glibc serves an allocation of at least the mmap threshold (128 KiB at start) with its
|
||||
own mmap and unmaps it on free. Freeing such a block also raises the threshold, up to
|
||||
32 MiB, so the server's later image-sized buffers (PIL pixel data, PNG encoding,
|
||||
whole-file reads in the executor threads) come from per-thread malloc arenas instead.
|
||||
Those arenas fragment and keep their pages: a server grew from 13 MB to 0.6-1 GB RSS
|
||||
over one page-workflow shard while its Python heap stayed near 12 MB. Setting
|
||||
M_MMAP_THRESHOLD fixes the threshold at its start value and turns the raising off.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import sys
|
||||
|
||||
M_MMAP_THRESHOLD = -3
|
||||
MMAP_THRESHOLD_BYTES = 128 * 1024
|
||||
|
||||
|
||||
def pin_mmap_threshold(libc=None) -> bool:
|
||||
"""Fix glibc's mmap threshold; False where there is no glibc ``mallopt``."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return False
|
||||
if libc is None:
|
||||
try:
|
||||
libc = ctypes.CDLL(None)
|
||||
except OSError:
|
||||
return False
|
||||
mallopt = getattr(libc, "mallopt", None)
|
||||
if mallopt is None:
|
||||
return False
|
||||
return bool(mallopt(M_MMAP_THRESHOLD, MMAP_THRESHOLD_BYTES))
|
||||
@@ -20,6 +20,7 @@ from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.folder_trash import close_trash, replace_folder
|
||||
from tests.browser.malloc_tuning import pin_mmap_threshold
|
||||
from tests.browser.request_hold import register_request_holds
|
||||
from tests.browser import slow_reset_watch
|
||||
from tests.browser.fast_reset import SEED_ON_LIVE_ENV, ScratchSeeder
|
||||
@@ -220,6 +221,12 @@ def create_app(root: Path) -> web.Application:
|
||||
PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")]
|
||||
|
||||
# #480: Delete… never reaches a real system trash from a page test; the fake
|
||||
# renames into this server's root, which goes when the server exits.
|
||||
from tests import fake_trash
|
||||
|
||||
fake_trash.install(root / "fake-trash")
|
||||
|
||||
from py.api import PromptManagerAPI
|
||||
from py import autotag
|
||||
from utils.hashing import generate_prompt_hash
|
||||
@@ -749,6 +756,8 @@ def main(argv: list[str] | None = None) -> None:
|
||||
help="shut down when stdin closes (the launcher's parent-death guard)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
# Before any thread allocates (#516): large frees then return to the OS.
|
||||
pin_mmap_threshold()
|
||||
listener = bind_listener(args.port)
|
||||
# One machine-readable line tells the launcher which port this process holds.
|
||||
print(f"{PORT_ANNOUNCEMENT}{listener.getsockname()[1]}", flush=True)
|
||||
|
||||
@@ -87,8 +87,8 @@ function measureBackups(mainSelector) {
|
||||
}
|
||||
|
||||
const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
|
||||
'records. A restore always keeps one.';
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
|
||||
'prompt deletes change records. A restore always keeps one.';
|
||||
|
||||
/**
|
||||
* D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export
|
||||
|
||||
@@ -527,7 +527,7 @@ test('Add to collection creates a collection and adds to it ignoring case (CUJ 1
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file);
|
||||
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Export metadata', 'Clear']);
|
||||
'Export metadata', 'Delete…', 'Clear']);
|
||||
await barButton(page, 'Add to collection').click();
|
||||
// The concept's simpleInput with Add (D138) and the existing names as suggestions.
|
||||
await expect(addDialog(page).locator('label')).toHaveText('Collection name');
|
||||
@@ -578,7 +578,7 @@ test('Remove from collection takes the checked images off the open collection (C
|
||||
await check(page, 'landscape_01.png');
|
||||
await check(page, 'landscape_02.png');
|
||||
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Remove from collection', 'Export metadata', 'Clear']);
|
||||
'Remove from collection', 'Export metadata', 'Delete…', 'Clear']);
|
||||
await barButton(page, 'Remove from collection').click();
|
||||
await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.');
|
||||
await expect(imageCards(page)).toHaveCount(10);
|
||||
@@ -715,7 +715,8 @@ test('Collections… in the side panel adds, removes and creates for one image (
|
||||
// The newest image, landscape_01, is the panel's.
|
||||
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png');
|
||||
const open = panel(page).getByRole('button', { name: 'Collections…', exact: true });
|
||||
await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…');
|
||||
// #480 D159: Delete… follows Collections… at the end of the panel's actions.
|
||||
await expect(panel(page).locator('.detail-footer > button').nth(-2)).toHaveText('Collections…');
|
||||
await open.click();
|
||||
await expect(collectionsDialog(page)).toBeVisible();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
// #480 (D159): Images › Delete…: the review asks every time (nothing preselected), "Remove from the library only"
|
||||
// hides the file and keeps it on disk, "Also move the files to the trash" moves it (the server's fake trash here).
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
const CARDS = '#workspace-main article[data-image-key]';
|
||||
const bar = page => page.locator('#workspace-main #bulkbar');
|
||||
const review = page => page.getByRole('dialog', { name: /^Delete \d+ images?$/ });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConceptAt(page, request);
|
||||
page.deleteErrors = [];
|
||||
page.on('pageerror', error => page.deleteErrors.push(error.message));
|
||||
page.on('dialog', dialog => { page.deleteErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.deleteErrors).toEqual([]);
|
||||
});
|
||||
|
||||
async function openImages(page) {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
return page.locator(CARDS).count();
|
||||
}
|
||||
|
||||
async function check(page, index) {
|
||||
const card = page.locator(CARDS).nth(index);
|
||||
const key = await card.getAttribute('data-image-key');
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
return key;
|
||||
}
|
||||
|
||||
async function deleteChecked(page, mode) {
|
||||
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const dialog = review(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
const primary = dialog.locator('.dialog-actions button.danger');
|
||||
// Ask every time: no mode is preselected and the primary waits for one.
|
||||
await expect(dialog.getByRole('radio')).toHaveCount(2);
|
||||
for (const radio of await dialog.getByRole('radio').all()) await expect(radio).not.toBeChecked();
|
||||
await expect(primary).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(dialog.locator('dl.meta-grid')).toBeHidden();
|
||||
const label = mode === 'trash' ? 'Also move the files to the trash' : 'Remove from the library only';
|
||||
await dialog.getByRole('radio', { name: new RegExp(`^${label}`) }).check();
|
||||
await expect(dialog.locator('dl.meta-grid')).toBeVisible();
|
||||
await expect(primary).not.toHaveAttribute('aria-disabled', 'true');
|
||||
return { dialog, primary };
|
||||
}
|
||||
|
||||
for (const mode of ['library', 'trash']) {
|
||||
test(`${mode}: the deleted image leaves Images and stays gone after a reload`, async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
const key = await check(page, 0);
|
||||
const { dialog, primary } = await deleteChecked(page, mode);
|
||||
await expect(dialog.locator('dt', { hasText: 'Media on disk' })).toBeVisible();
|
||||
await expect(primary).toHaveText(mode === 'trash' ? 'Move 1 image to the trash' : 'Remove 1 image from the library');
|
||||
await primary.click();
|
||||
await expect(toast(page)).toContainText(mode === 'trash' ? 'Moved 1 image to the trash.' : 'Removed 1 image from the library.');
|
||||
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
|
||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||
await expect(bar(page)).toBeEmpty();
|
||||
await page.reload();
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
// #501: the sidebar's Images count (read once at mount) follows a finished delete without a reload.
|
||||
test('a finished delete re-reads the sidebar Images count', async ({ page }) => {
|
||||
await openImages(page);
|
||||
const badge = page.locator('#primary-nav a[data-route="images"] .nav-count');
|
||||
await expect(badge).toHaveText(/^\d[\d,]*$/);
|
||||
const before = Number((await badge.textContent()).replace(/,/g, ''));
|
||||
await check(page, 0);
|
||||
const { primary } = await deleteChecked(page, 'library');
|
||||
await primary.click();
|
||||
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
||||
await expect(badge).toHaveText((before - 1).toLocaleString('en-US'));
|
||||
});
|
||||
|
||||
test('Cancel changes nothing', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
await check(page, 0);
|
||||
const { dialog } = await deleteChecked(page, 'library');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.locator(CARDS)).toHaveCount(before);
|
||||
});
|
||||
|
||||
test('switching modes redraws the rows from one review', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await check(page, 0);
|
||||
let previews = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/images/delete/preview')) previews += 1; });
|
||||
const { dialog, primary } = await deleteChecked(page, 'library');
|
||||
await expect(dialog.locator('dd', { hasText: 'Files stay on disk, hidden from Images.' })).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /^Also move the files to the trash/ }).check();
|
||||
await expect(dialog.locator('dd', { hasText: 'Nothing is deleted permanently' })).toBeVisible();
|
||||
await expect(primary).toHaveText('Move 1 image to the trash');
|
||||
expect(previews).toBe(1);
|
||||
});
|
||||
|
||||
test('Delete… in the inspector reviews that one image', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
const card = page.locator(CARDS).first();
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
const button = page.locator('#workspace-main .file-inspector button.danger', { hasText: 'Delete…' });
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
const dialog = review(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
|
||||
await dialog.locator('.dialog-actions button.danger').click();
|
||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||
});
|
||||
|
||||
test('Delete… in the viewer closes it after the delete', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
await page.locator(CARDS).first().getByRole('button', { name: /^Open / }).click();
|
||||
const viewer = page.locator('dialog#viewer-dialog[open]');
|
||||
await expect(viewer).toBeVisible();
|
||||
await viewer.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const dialog = review(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
|
||||
await dialog.locator('.dialog-actions button.danger').click();
|
||||
await expect(viewer).toBeHidden();
|
||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||
});
|
||||
|
||||
// The review never starts a delete without an explicit choice (live test 2026-10-29): every activation of the
|
||||
// aria-disabled primary is ignored, the dialog has no form to submit, and a chosen primary starts once.
|
||||
test('without a chosen mode nothing can start a delete', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
await check(page, 0);
|
||||
const applies = [];
|
||||
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.postDataJSON()); });
|
||||
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const dialog = review(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
const primary = dialog.locator('.dialog-actions button.danger');
|
||||
await expect(primary).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(primary).toHaveText('Choose what happens to the files');
|
||||
// Visibly dimmed while no mode is chosen.
|
||||
await expect(primary).toHaveCSS('opacity', '0.55');
|
||||
await expect(primary).toHaveCSS('cursor', 'not-allowed');
|
||||
await primary.click({ force: true });
|
||||
await primary.dblclick({ force: true });
|
||||
await primary.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Space');
|
||||
await dialog.locator('#modal-title').click();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await dialog.locator('form').count()).toBe(0);
|
||||
await page.evaluate(() => document.querySelector('dialog#modal[open]')?.dispatchEvent(new Event('submit', { bubbles: true })));
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(applies).toEqual([]);
|
||||
for (const radio of await dialog.getByRole('radio').all()) await expect(radio).not.toBeChecked();
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator(CARDS)).toHaveCount(before);
|
||||
expect(applies).toEqual([]);
|
||||
});
|
||||
|
||||
test('a chosen mode starts exactly one delete, with that mode, however often it is pressed', async ({ page }) => {
|
||||
const before = await openImages(page);
|
||||
await check(page, 0);
|
||||
const applies = [];
|
||||
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.postDataJSON()); });
|
||||
const { primary } = await deleteChecked(page, 'library');
|
||||
await primary.dblclick();
|
||||
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||
expect(applies.length).toBe(1);
|
||||
expect(applies[0].mode).toBe('library');
|
||||
expect(Object.keys(applies[0]).sort()).toEqual(['mode', 'token']);
|
||||
});
|
||||
@@ -1376,6 +1376,61 @@ async function expectStar(button, favorite) {
|
||||
await expect(button).toHaveAttribute('aria-pressed', String(favorite));
|
||||
}
|
||||
|
||||
// #494: the Favorites count is re-read 300 ms after a star changes. A page that has begun to leave starts no count read
|
||||
// (WebKit fails it with "Fetch API cannot load … due to access control checks.", reported as a page error; queue run
|
||||
// 40934368716). A leave that is called off counts again on the next input.
|
||||
for (const leave of ['beforeunload', 'pagehide']) {
|
||||
test(`a star changed just before ${leave} starts no Favorites count read while the page leaves`, async ({ page }) => {
|
||||
await page.clock.install();
|
||||
const countReads = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === '1' && url.searchParams.get('limit') === '1') countReads.push(url.search);
|
||||
});
|
||||
await page.goto(workspace);
|
||||
const card = page.locator('article[data-image-key="0:shared/same.png"]');
|
||||
await expectStar(star(card, 'same.png'), false);
|
||||
await expect.poll(() => countReads.length).toBe(1);
|
||||
await star(card, 'same.png').click();
|
||||
await expectStar(star(card, 'same.png'), true);
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await page.evaluate(type => window.dispatchEvent(new Event(type)), leave);
|
||||
await page.clock.runFor(1000);
|
||||
// A read made after the clock ran is listed, so the listener saw every count read.
|
||||
await page.evaluate(() => fetch('/prompt_manager/images/output?mode=workspace&favorites=1&limit=1&probe=1'));
|
||||
await expect.poll(() => countReads.length).toBe(2);
|
||||
expect(countReads.at(-1)).toContain('probe=1');
|
||||
if (leave === 'beforeunload') {
|
||||
// The leave was called off (the page is still here): the next input counts again, once, and the badge shows it.
|
||||
await page.keyboard.press('Shift');
|
||||
await expect.poll(() => countReads.length).toBe(3);
|
||||
expect(countReads.at(-1)).not.toContain('probe=1');
|
||||
await expect(page.locator('nav [data-route="favorites"] .nav-count')).toHaveText('1');
|
||||
await page.clock.runFor(1000);
|
||||
expect(countReads).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a count read in flight when the page starts to leave is dropped, then read again if the page stays (#494)', async ({ page }) => {
|
||||
const held = [];
|
||||
// The page's first Library count read is held at the server boundary while the page starts to leave.
|
||||
await page.route('**/prompt_manager/library?limit=1', route => { held.push(route); });
|
||||
await page.goto(workspace);
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
const failed = page.waitForEvent('requestfailed', item => item.url().includes('/prompt_manager/library?limit=1'));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')));
|
||||
await failed;
|
||||
await page.unroute('**/prompt_manager/library?limit=1');
|
||||
await held[0].continue().catch(() => {});
|
||||
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveCount(0);
|
||||
// The page stayed: the next input reads the dropped count again.
|
||||
const reread = page.waitForRequest(item => item.url().includes('/prompt_manager/library?limit=1'));
|
||||
await page.keyboard.press('Shift');
|
||||
await reread;
|
||||
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveText(/^\d+$/);
|
||||
});
|
||||
|
||||
test('favorite persists across reload', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
|
||||
const sent = [];
|
||||
@@ -1794,7 +1849,7 @@ test('card checkboxes select images into the concept bar and Clear empties it',
|
||||
await checkbox(first, 'same.png').check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
|
||||
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
|
||||
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Delete…', 'Clear']);
|
||||
// Toggling neither opens the viewer, nor inspects, nor moves focus.
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(checkbox(first, 'same.png')).toBeFocused();
|
||||
|
||||
@@ -704,6 +704,147 @@ test('a video replaced while still starting is reset once it settles', async ({
|
||||
}
|
||||
});
|
||||
|
||||
test('a new selection while the viewer\'s own video is still starting leaves it until it settles (#483)', async ({ context, browserName }) => {
|
||||
test.setTimeout(180000);
|
||||
// D155 opens the viewer once the inline preview has settled; the viewer then starts a video of its own. A new
|
||||
// selection from that video's own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it, must not
|
||||
// reset it or take it off the page while it starts (WebKit, GStreamer: TESTING.md D62; merge-queue run
|
||||
// 40903238387). The viewer stays open until it reports metadata or an error, then closes; the URL goes last.
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await page.addInitScript(() => {
|
||||
const load = HTMLMediaElement.prototype.load;
|
||||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) { this.__resets = (this.__resets ?? 0) + 1; this.__resetReadyState ??= this.readyState; } return load.call(this); };
|
||||
const revoke = URL.revokeObjectURL;
|
||||
URL.revokeObjectURL = url => {
|
||||
const starting = window.__starting;
|
||||
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
|
||||
return revoke(url);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await page.evaluate(() => {
|
||||
const input = document.querySelector('#local-file');
|
||||
new MutationObserver((records, observer) => {
|
||||
const video = document.querySelector('#viewer-dialog video');
|
||||
if (!video) return;
|
||||
observer.disconnect();
|
||||
const replace = () => {
|
||||
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
|
||||
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), resets: video.__resets ?? 0 };
|
||||
};
|
||||
video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true });
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
const png = parityPng('f02.png');
|
||||
await page.evaluate(bytes => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
|
||||
window.__pngFiles = transfer.files;
|
||||
}, [...png.buffer]);
|
||||
await page.evaluate(delay => { window.__delay = delay; }, attempt % 5);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
await expect.poll(() => page.evaluate(() => window.__starting?.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 });
|
||||
const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced }));
|
||||
if (attempt % 5 === 0) expect(readyState).toBe(0);
|
||||
// Starting when replaced: still on the page with its URL, never reset; settled: released at once.
|
||||
expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, resets: 0 } : { connected: false, src: false, resets: 1 });
|
||||
expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1);
|
||||
// Its one reset came after it had metadata, never while it was starting.
|
||||
expect(await page.evaluate(() => window.__starting.node.__resetReadyState)).toBeGreaterThanOrEqual(1);
|
||||
// The new file's viewer opens after the old one closed, and the page still answers.
|
||||
await expect(viewer(page).getByRole('img', { name: 'f02.png', exact: true })).toBeVisible({ timeout: 8000 });
|
||||
await expect(viewer(page).locator('video')).toHaveCount(0);
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** #483: holds the viewer's video in HAVE_NOTHING (its `loadedmetadata` never fires) so a hold can be driven by hand. */
|
||||
async function stallViewerVideo(page) {
|
||||
await page.addInitScript(() => {
|
||||
const describe = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'readyState');
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'readyState', { configurable: true, get() {
|
||||
return this.closest?.('#viewer-dialog') && window.__stallViewer ? 0 : describe.get.call(this);
|
||||
} });
|
||||
window.addEventListener('loadedmetadata', event => {
|
||||
if (window.__stallViewer && event.target.closest?.('#viewer-dialog')) event.stopImmediatePropagation();
|
||||
}, true);
|
||||
});
|
||||
}
|
||||
|
||||
test('a held viewer closed by hand, or replaced twice, opens only the latest file\'s viewer at once (#483)', async ({ page }) => {
|
||||
await stallViewerVideo(page);
|
||||
await openInspect(page);
|
||||
await page.evaluate(() => { window.__stallViewer = true; });
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(viewer(page).locator('video')).toBeAttached();
|
||||
// Two selections while the viewer's video is (made to look) still starting: the viewer stays, untouched.
|
||||
await chooser(page).setInputFiles(parityPng('f02.png', 'first.png'));
|
||||
await chooser(page).setInputFiles(parityPng('f02.png', 'second.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(viewer(page).locator('video')).toBeAttached();
|
||||
// Open file metadata pressed meanwhile (the page behind the viewer is inert, so by script): the latest request.
|
||||
await action(page).evaluate(node => node.click());
|
||||
// Closing it by hand ends the hold at once: the latest file's viewer opens, once, well before D62's 15 s.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page).getByRole('img', { name: 'second.png', exact: true })).toBeVisible({ timeout: 5000 });
|
||||
await expect(viewer(page).getByRole('img', { name: 'first.png', exact: true })).toHaveCount(0);
|
||||
await expect(viewer(page).locator('video')).toHaveCount(0);
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a held viewer revokes its URL only once it is gone, drops a stale open, and ends on page leave (#483)', async ({ page }) => {
|
||||
await stallViewerVideo(page);
|
||||
await openInspect(page);
|
||||
await trackReleases(page);
|
||||
await page.evaluate(() => { window.__stallViewer = true; });
|
||||
const videoUrl = () => page.evaluate(() => document.querySelector('#viewer-dialog video')?.getAttribute('src') ?? null);
|
||||
const released = url => page.evaluate(url => window.__released.filter(item => item.url === url), url);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(viewer(page).locator('video')).toBeAttached();
|
||||
const first = await videoUrl();
|
||||
await chooser(page).setInputFiles(parityPng('f02.png', 'held.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
// The session asked to revoke the video's URL; it waits while the held viewer still plays it.
|
||||
expect(await released(first)).toEqual([]);
|
||||
// An Open asked for during the hold, then an AVI (which never opens the viewer by itself): the open for the earlier
|
||||
// file is stale and is dropped when the hold ends.
|
||||
await action(page).evaluate(node => node.click());
|
||||
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
|
||||
await chooser(page).setInputFiles(avi);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect.poll(() => released(first)).toEqual([{ url: first, live: 0 }]);
|
||||
// Page leave while held: the viewer goes and the deferred URL is revoked with nothing holding it.
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(viewer(page).locator('video')).toBeAttached();
|
||||
const second = await videoUrl();
|
||||
await chooser(page).setInputFiles(parityPng('f02.png', 'leave.png'));
|
||||
await expect(viewer(page).locator('video')).toBeAttached();
|
||||
expect(await released(second)).toEqual([]);
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect.poll(() => released(second)).toEqual([{ url: second, live: 0 }]);
|
||||
// Another route while held: the page's dispose ends the hold and revokes the URL with nothing holding it.
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(viewer(page).locator('video')).toBeAttached();
|
||||
const third = await videoUrl();
|
||||
await chooser(page).setInputFiles(parityPng('f02.png', 'route.png'));
|
||||
await expect(viewer(page).locator('video')).toBeAttached();
|
||||
await page.evaluate(() => { window.location.hash = '#/library'; });
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
|
||||
await expect.poll(() => released(third)).toEqual([{ url: third, live: 0 }]);
|
||||
});
|
||||
|
||||
test('leaving for another route while a video starts waits for it to settle', async ({ context, browserName }) => {
|
||||
test.setTimeout(180000);
|
||||
// A WebKit (GStreamer) video detached before its pipeline had started deadlocked the page (TESTING.md D62); a
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
|
||||
// #443 D152: Delete… on the Library bar deletes unpaired prompts, archives paired ones (restorable), keeps protected ones.
|
||||
const route='/prompt_manager/workspace#/library';
|
||||
const PREVIEW='/prompt_manager/library/delete/preview';
|
||||
const DELETE='/prompt_manager/library/delete';
|
||||
const cards=page=>page.locator('article[data-prompt-id]');
|
||||
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
|
||||
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
|
||||
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
|
||||
const toast=page=>page.locator('.toast');
|
||||
const review=page=>page.getByRole('dialog');
|
||||
const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/});
|
||||
// #482: on the archived shelf the same button reads the concept's way back (app.js 594 "← Prompt library").
|
||||
const backToLibrary=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'← Prompt library',exact:true});
|
||||
|
||||
async function items(request,shelf='active') {
|
||||
const query=shelf==='archived'?'&shelf=archived':'';
|
||||
return (await(await request.get(`/prompt_manager/library?limit=100${query}`)).json()).items;
|
||||
}
|
||||
const byText=(list,text)=>list.find(item=>item.text===text);
|
||||
function recordPosts(page,path) {
|
||||
const posts=[];
|
||||
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname===path)posts.push(item.postDataJSON());});
|
||||
return posts;
|
||||
}
|
||||
async function openReview(page) {
|
||||
await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
|
||||
await expect(review(page)).toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:2}})).toBeOK();
|
||||
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
|
||||
|
||||
test('Delete… reviews the selection, deletes the unpaired, archives the paired and keeps the protected',async({page,request})=>{
|
||||
const before=await items(request);
|
||||
const dog=byText(before,'Dog landscape'),cat=byText(before,'Cat portrait'),guarded=byText(before,'Library extra 0000');
|
||||
await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
|
||||
const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
for(const item of [dog,cat,guarded]) await box(page,item.id).check();
|
||||
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${before.length} loaded`,'Add tags','Set category','Delete…','Clear']);
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).toHaveClass(/danger/);
|
||||
await openReview(page);
|
||||
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',
|
||||
'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/);
|
||||
// Cancel writes nothing and releases the bar.
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(review(page)).toHaveCount(0);expect(writes).toEqual([]);
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
await openReview(page);
|
||||
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Archived 1 prompt\. 1 protected prompt was kept\. Safety backup saved: \S+\.backup_\S+ \(System › Backup & export\)\.$/);
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
await expect(card(page,dog.id)).toHaveCount(0);await expect(card(page,cat.id)).toHaveCount(0);await expect(card(page,guarded.id)).toHaveCount(1);
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
// One preview per review opened (Cancel, then Delete…).
|
||||
const reviewed={prompt_ids:[dog.id,cat.id,guarded.id],expected_epoch:epoch};
|
||||
expect(previews).toEqual([reviewed,reviewed]);
|
||||
expect(writes).toEqual([{archive_ids:[cat.id],delete_ids:[dog.id],expected_epoch:epoch,backup:'on'}]);
|
||||
const after=await items(request);
|
||||
expect(byText(after,'Dog landscape')).toBeUndefined();expect(byText(after,'Cat portrait')).toBeUndefined();
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
|
||||
});
|
||||
|
||||
test('Show archived prompts lists archived prompts, and Restore on its bar returns them',async({page,request})=>{
|
||||
const before=await items(request);const cat=byText(before,'Cat portrait'),dual=byText(before,'Dual role portrait');
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,cat.id).check();await box(page,dual.id).check();
|
||||
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 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);
|
||||
// #482: a plain button that names where it goes (no pressed state that its changing label would contradict).
|
||||
await expect(showArchived(page)).not.toHaveAttribute('aria-pressed');
|
||||
await showArchived(page).click();
|
||||
await expect(backToLibrary(page)).toBeVisible();await expect(backToLibrary(page)).not.toHaveAttribute('aria-pressed');
|
||||
await expect(showArchived(page)).toHaveCount(0);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/);
|
||||
await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1);
|
||||
await box(page,cat.id).check();
|
||||
await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Clear']);
|
||||
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await backToLibrary(page).click();
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
|
||||
await expect(cards(page)).toHaveCount(before.length-1);
|
||||
await expect(card(page,cat.id)).toHaveCount(1);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
|
||||
// An empty archived view says so.
|
||||
await showArchived(page).click();await box(page,dual.id).check();
|
||||
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
|
||||
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
|
||||
});
|
||||
|
||||
// #482 (D154): a prompt in a saved pair can be deleted permanently, with its own saved pairs; Archive stays the default.
|
||||
const choice=page=>review(page).getByRole('radiogroup',{name:'Prompts in a saved pair'});
|
||||
const row=(page,term)=>review(page).locator('dl.meta-grid > div').filter({has:page.locator('dt',{hasText:new RegExp(`^${term}$`)})}).locator('dd');
|
||||
test('Delete permanently removes a paired prompt with its own saved pair; its negative stays (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
|
||||
const writes=recordPosts(page,DELETE);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,dual.id).check();await openReview(page);
|
||||
// Archive is checked: the review is #443's.
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
// The D90 radio row (the auto-tag "How to apply"): each radio keeps its own size beside its label.
|
||||
expect(await choice(page).evaluate(group=>{const label=group.querySelector('label'),radio=label.querySelector('input');
|
||||
return {group:group.className,display:getComputedStyle(label).display,gap:getComputedStyle(label).columnGap,
|
||||
margin:getComputedStyle(radio).marginLeft,full:radio.getBoundingClientRect().width>=label.getBoundingClientRect().width/2};}))
|
||||
.toEqual({group:'field',display:'flex',gap:'8px',margin:'0px',full:false});
|
||||
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
|
||||
await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair');
|
||||
await expect(row(page,'Saved pairs removed')).toHaveText('1 saved pair (1 with a negative prompt)');
|
||||
await expect(row(page,'Negatives kept')).toHaveText('1 negative prompt is kept');
|
||||
await expect(row(page,'Backup')).toHaveText('Create and verify before deleting');
|
||||
await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/);
|
||||
// Back to Archive: the review is #443's again.
|
||||
await choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'}).check();
|
||||
await expect(row(page,'Saved pairs removed')).toHaveCount(0);
|
||||
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
|
||||
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(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
|
||||
await expect(card(page,dual.id)).toHaveCount(0);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0].delete_paired_ids).toEqual([dual.id]);
|
||||
expect(Object.keys(writes[0].paired_digests)).toEqual([String(dual.id)]);
|
||||
const after=await items(request);
|
||||
expect(byText(after,'Dual role portrait')).toBeUndefined();
|
||||
// Its negative (Cat portrait) is kept, with its own variants.
|
||||
expect(byText(after,'Cat portrait')?.id).toBe(cat.id);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a prompt used as another prompt\'s negative is archived even when Delete permanently is chosen (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,cat.id).check();await box(page,dual.id).check();await openReview(page);
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(row(page,'Archive')).toHaveText('1 prompt used as another prompt\'s negative: hidden, restorable');
|
||||
await expect(review(page).locator('p.small',{hasText:'archived instead of deleted'})).toHaveCount(1);
|
||||
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Archived 1 prompt\./);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
|
||||
});
|
||||
|
||||
test('the choice resets to Archive on every Delete…, and a selection with no paired prompt has none (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),dog=byText(before,'Dog landscape');
|
||||
await page.goto(route);await box(page,dual.id).check();await openReview(page);
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await openReview(page);
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
await expect(choice(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// #481 (D161): a prompt's own page deletes it from Edit metadata (the concept's recipe editor, app.js 976), through the
|
||||
// same review: "Delete prompt…" beside "Archive variant…" (the variant's archive, renamed so the two cannot be confused).
|
||||
const editMetadata=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'Edit metadata',exact:true});
|
||||
const editor=page=>page.locator('dialog.library-editor[open]');
|
||||
async function deleteFromPage(page,id){
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);
|
||||
await editMetadata(page).click();
|
||||
await expect(editor(page).locator('.dialog-actions button')).toHaveText(['Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
|
||||
await expect(editor(page).getByRole('button',{name:'Delete prompt…',exact:true})).toHaveClass(/\bdanger\b/);
|
||||
await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await expect(editor(page)).toHaveCount(0);await expect(review(page)).toBeVisible();
|
||||
}
|
||||
test('Delete prompt… on a prompt\'s page archives it by default, and deletes it permanently when chosen (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const previews=recordPosts(page,PREVIEW);
|
||||
await deleteFromPage(page,dual.id);
|
||||
expect(previews.map(body=>body.prompt_ids)).toEqual([[dual.id]]);
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 1 prompt.');
|
||||
// The page stays and shows its Archived notice.
|
||||
await expect(page.locator('.library-archived-notice h2')).toHaveText('Archived');
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await page.locator('.library-archived-notice').getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
// Delete permanently: back to the Prompt library, the prompt gone.
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
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(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(card(page,dual.id)).toHaveCount(0);
|
||||
expect(byText(await items(request),'Dual role portrait')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Cancel on the page\'s review changes nothing and returns focus to Edit metadata (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const writes=recordPosts(page,DELETE);
|
||||
await deleteFromPage(page,dual.id);
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
|
||||
expect(writes).toEqual([]);
|
||||
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
|
||||
});
|
||||
|
||||
test('the page\'s delete holds one review at a time, Escape returns focus, and an archived page\'s review is close-only (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const previews=recordPosts(page,PREVIEW);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
// While the preview is held, Edit metadata is held too: no second review and no save can race the delete.
|
||||
await expect(editMetadata(page)).toHaveAttribute('aria-disabled','true');
|
||||
await editMetadata(page).click({force:true});await expect(editor(page)).toHaveCount(0);
|
||||
release();
|
||||
await expect(review(page)).toBeVisible();
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0].expected_epoch).toMatch(/\S/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
|
||||
await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
|
||||
await page.unroute(`**${PREVIEW}`);
|
||||
// Archived: its review has nothing to change and only closes.
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
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.');
|
||||
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a failed review on the prompt\'s page says so, changes nothing and releases Edit metadata (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const writes=recordPosts(page,DELETE);
|
||||
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
|
||||
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
|
||||
expect(writes).toEqual([]);
|
||||
await editMetadata(page).click();await expect(editor(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('a delete answered after the page was left invalidates the shelf without a toast or a jump (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
let answered;const done=new Promise(resolve=>{answered=resolve;});
|
||||
await page.route(`**${DELETE}`,async item=>{await held;const response=await item.fetch();await item.fulfill({response});answered();});
|
||||
await deleteFromPage(page,dual.id);
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await page.evaluate(()=>{window.location.hash='#/overview';});
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// Every toast text shown from here on is recorded, so one shown and hidden again still counts.
|
||||
await page.evaluate(()=>{window.seenToasts=[];new MutationObserver(()=>{for(const node of document.querySelectorAll('.toast'))
|
||||
if(node.textContent.trim())window.seenToasts.push(node.textContent.trim());}).observe(document.body,{subtree:true,childList:true,characterData:true});});
|
||||
release();await done;
|
||||
// The answer lands on Overview: no navigation; the shelf reads again when opened; and no toast was ever shown.
|
||||
await page.evaluate(()=>{window.location.hash='#/library';});
|
||||
await expect(card(page,dual.id)).toHaveCount(0);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
|
||||
expect(await page.evaluate(()=>window.seenToasts)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a restore since the prompt\'s page loaded is refused and the page reads the Library again (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await armUnseenRestores(page);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
|
||||
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
|
||||
await expect(restore).toBeOK();
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{
|
||||
const cat=byText(await items(request),'Cat portrait');
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
await expect(await request.post(DELETE,{data:{archive_ids:[cat.id],delete_ids:[],expected_epoch:epoch,backup:'off'}})).toBeOK();
|
||||
await page.goto(`/prompt_manager/workspace#/library/${cat.id}`);
|
||||
const notice=page.locator('.library-archived-notice');
|
||||
await expect(notice.locator('h2')).toHaveText('Archived');
|
||||
// The notice says why (the saved pair) and counts the prompt's negative variants (live note on #470).
|
||||
expect(cat.variant_count).toBeGreaterThan(1);
|
||||
await expect(notice.locator('p').first()).toHaveText(`This prompt is archived because it is in a saved pair. It and its ${cat.variant_count} negative variants are hidden from the Prompt library; nothing was removed.`);
|
||||
await notice.getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
await expect(notice).toHaveCount(0);
|
||||
expect(byText(await items(request),'Cat portrait')?.id).toBe(cat.id);
|
||||
});
|
||||
|
||||
// #443: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for
|
||||
// seconds; a loaded page of prompts is shown without waiting for one, on first load and after a delete.
|
||||
async function holdAnimationFrames(page) {
|
||||
await page.addInitScript(()=>{
|
||||
const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
|
||||
const held=new Map(),real=new Map();let holding=true,next=0;
|
||||
window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;};
|
||||
window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);};
|
||||
window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();};
|
||||
});
|
||||
}
|
||||
test('a held animation frame does not hide the loaded prompts, on first load or after a delete',async({page,request})=>{
|
||||
const before=await items(request);const dog=byText(before,'Dog landscape');
|
||||
await holdAnimationFrames(page);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\./);
|
||||
await expect(cards(page)).toHaveCount(before.length-1);await expect(card(page,dog.id)).toHaveCount(0);
|
||||
await page.evaluate(()=>window.__releaseAnimationFrames());
|
||||
await expect(cards(page)).toHaveCount(before.length-1);
|
||||
});
|
||||
|
||||
test('a double press sends one preview, and the bar holds while it is reviewed',async({page,request})=>{
|
||||
const dog=byText(await items(request),'Dog landscape');
|
||||
const previews=recordPosts(page,PREVIEW);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
|
||||
await page.goto(route);await box(page,dog.id).check();
|
||||
const remove=bar(page).getByRole('button',{name:/^(Delete…|Reviewing…)$/});
|
||||
await remove.click();
|
||||
await expect(remove).toHaveText('Reviewing…');await expect(remove).toHaveAttribute('aria-disabled','true');
|
||||
await expect(bar(page).getByRole('button',{name:'Clear',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await remove.click({force:true});
|
||||
release();
|
||||
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
|
||||
expect(previews).toHaveLength(1);
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
});
|
||||
|
||||
test('a restore since the page loaded, a failed backup, a busy job and an unconfirmed answer each say what happened',async({page,request})=>{
|
||||
const before=await items(request);const dog=byText(before,'Dog landscape');
|
||||
await armUnseenRestores(page);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
// Failed backup: nothing changed, the selection stays.
|
||||
await page.route(`**${DELETE}`,item=>item.fulfill({status:500,contentType:'application/json',
|
||||
body:JSON.stringify({success:false,code:'backup-failed',error:'The safety backup could not be taken and verified; nothing was changed.'})}));
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The safety backup failed. The safety backup could not be taken and verified; nothing was changed.');
|
||||
await expect(box(page,dog.id)).toBeChecked();
|
||||
// Busy: names the job, nothing changed.
|
||||
await page.unroute(`**${DELETE}`);
|
||||
await page.route(`**${DELETE}`,item=>item.fulfill({status:409,contentType:'application/json',
|
||||
body:JSON.stringify({success:false,code:'busy',error:'busy',running:{id:'abc',title:'Scan'}})}));
|
||||
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Scan is running. Nothing was changed; try again when it finishes.');
|
||||
// Unconfirmed: the shelf reloads.
|
||||
await page.unroute(`**${DELETE}`);
|
||||
await page.route(`**${DELETE}`,item=>item.abort());
|
||||
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
await page.unroute(`**${DELETE}`);
|
||||
// A restore the page has not seen: the review opened before it is refused on the server.
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
|
||||
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
|
||||
await expect(restore).toBeOK();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
|
||||
expect(byText(await items(request),'Dog landscape')).toBeDefined();
|
||||
});
|
||||
|
||||
test('a failed review keeps the selection, and a selection nothing can change opens the close-only review',async({page,request})=>{
|
||||
const before=await items(request);const guarded=byText(before,'Library extra 0000');
|
||||
await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,guarded.id).check();
|
||||
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
|
||||
await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
|
||||
await expect(box(page,guarded.id)).toBeChecked();
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
await openReview(page);
|
||||
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
|
||||
await expect(review(page).locator('.dialog-actions > button')).toHaveText(['Close']);
|
||||
await review(page).getByRole('button',{name:'Close',exact:true}).click();
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
expect(byText(await items(request),'Library extra 0000')).toBeDefined();
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
|
||||
// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions.
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const workspace='/prompt_manager/workspace';
|
||||
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
|
||||
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive variant…','Delete prompt…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
|
||||
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
|
||||
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
|
||||
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
|
||||
@@ -27,13 +27,15 @@ async function mountEditor(page,theme) {
|
||||
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.evaluate(async()=>{
|
||||
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
|
||||
window.editorEvents={committed:[],reconciled:[]};
|
||||
window.editorEvents={committed:[],reconciled:[],deletePrompt:[]};
|
||||
try {
|
||||
const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs');
|
||||
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const scope=createScope();
|
||||
const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope,
|
||||
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)});
|
||||
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result),
|
||||
// As the prompt's page wires it (#481); the review itself is covered in workspace-library-delete.spec.mjs.
|
||||
onDeletePrompt:request=>window.editorEvents.deletePrompt.push(request.positiveId)});
|
||||
window.editorHarness={scope,editor,showWorkspaceToast};
|
||||
} catch(error) {window.editorHarness={missing:String(error)};}
|
||||
});
|
||||
@@ -52,7 +54,7 @@ const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pair
|
||||
|
||||
test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{
|
||||
const f=page.fixture;await mountEditor(page);
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
|
||||
await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible();
|
||||
expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true);
|
||||
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
|
||||
@@ -73,9 +75,14 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
|
||||
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
|
||||
await expect(dialog.locator('.demo-note')).toHaveCount(0);
|
||||
expect(await actionNames(dialog)).toEqual(ACTIONS.metadata);
|
||||
await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
|
||||
await expect(button(dialog,'Archive variant…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
|
||||
// D161 (#481): Delete prompt… closes the editor first and hands the prompt to the page's delete review.
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await expect(button(dialog,'Delete prompt…')).toHaveClass(/\bdanger\b/);await button(dialog,'Delete prompt…').click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.editorEvents.deletePrompt)).toEqual([f.positive_id]);expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('editor modes persist through reload with selected saved negative',async({page,request})=>{
|
||||
@@ -144,13 +151,13 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
|
||||
const f=page.fixture;await mountEditor(page);
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog);
|
||||
const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled();
|
||||
const archive=button(dialog,'Archive variant…');await expect(archive).toBeDisabled();
|
||||
await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.');
|
||||
await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.');
|
||||
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
|
||||
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive…').click();
|
||||
await button(dialog,'Archive variant…').click();
|
||||
await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible();
|
||||
await expect(button(dialog,'Close dialog')).toBeFocused();
|
||||
const block=dialog.getByRole('region',{name:'Negative prompt',exact:true});
|
||||
@@ -163,7 +170,7 @@ test('archive confirms the selected pair with fresh counts and writes only on co
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
|
||||
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]);
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
|
||||
@@ -178,7 +185,7 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog
|
||||
failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'});
|
||||
});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1);
|
||||
await expect(button(dialog,'Archive')).toBeFocused();
|
||||
@@ -192,7 +199,7 @@ test('an unconfirmed archive moves focus to Retry instead of dropping it out of
|
||||
await route.continue();
|
||||
});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.');
|
||||
await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
||||
@@ -209,7 +216,7 @@ test('an archive conflict offers an explicit Reload that re-reads and never repl
|
||||
conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'});
|
||||
});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.');
|
||||
await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1);
|
||||
@@ -237,7 +244,7 @@ test('cancel and busy submit have honest write counts',async({page})=>{
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.');
|
||||
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1);
|
||||
release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1);
|
||||
@@ -319,15 +326,15 @@ test('edge states keep drafts, explain disabled archive and clean up on dispose'
|
||||
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog);
|
||||
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative);
|
||||
await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
|
||||
await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.');
|
||||
await expect(button(dialog,'Archive variant…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
|
||||
await expect(button(dialog,'Archive variant…')).toHaveAccessibleDescription('This negative variant is already archived.');
|
||||
await button(dialog,'Cancel').click();
|
||||
|
||||
let failed=true;
|
||||
await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();});
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog);
|
||||
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue('');
|
||||
failed=false;await button(dialog,'Archive…').click();
|
||||
failed=false;await button(dialog,'Archive variant…').click();
|
||||
await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.');
|
||||
await expect(button(dialog,'Archive')).toBeDisabled();
|
||||
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
@@ -347,12 +354,12 @@ test('the archive confirmation does not inherit a failed or invalid Edit save me
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.');
|
||||
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required');
|
||||
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
@@ -493,13 +500,16 @@ test('a restore seen finishing keeps New prompt open and reloads the Library aft
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
|
||||
// D152 (#443): Show archived prompts (N) comes first, a plain button (#482: no pressed state; on the archived shelf it
|
||||
// reads "← Prompt library").
|
||||
expect(await visibleNames(actions)).toEqual(['Show archived prompts (0)','Export','+ New prompt']);
|
||||
await expect(actions.nth(0)).not.toHaveAttribute('aria-pressed');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
// One image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(2)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1,2])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
await actions.nth(2).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise');
|
||||
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0];
|
||||
@@ -633,16 +643,20 @@ test('restored storage after a confirmed save or before an archive offers an exp
|
||||
const response=await route.fetch();await expect(await restore()).toBeOK();return route.fulfill({response});
|
||||
});
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click();
|
||||
await field(dialog,'Notes').fill('Lost to a restore');
|
||||
// The save's answer waits for a whole restore in the route above; wait for that answer, not the 5 s expect
|
||||
// default (#501 merge queue: under load the restore outlasted it on WebKit).
|
||||
const saved=page.waitForResponse(item=>item.request().method()==='PATCH'&&new URL(item.url()).pathname===`/prompt_manager/library/${f.positive_id}`);
|
||||
await button(dialog,'Save').click();await saved;
|
||||
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
||||
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
|
||||
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
|
||||
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await expect(await restore()).toBeOK();await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.');
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1);
|
||||
@@ -659,10 +673,10 @@ test('restored storage before an Edit or Add negative save offers only the Libra
|
||||
await expect(await restore()).toBeOK();
|
||||
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
|
||||
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
|
||||
// Revise and archive would re-read the old numeric IDs, which may now name another prompt.
|
||||
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await expect(button(dialog,'Reload Library')).toBeFocused();
|
||||
expect(page.writes).toHaveLength(1);// the refused save
|
||||
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
|
||||
@@ -729,7 +743,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
const before=await evidence();expect(before.counts).toEqual({images:1,runs:2});
|
||||
await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
|
||||
@@ -872,7 +886,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
|
||||
await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
|
||||
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
|
||||
@@ -62,7 +62,8 @@ test('Set category writes the selected prompts on the server, reloads the shelf
|
||||
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
|
||||
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
|
||||
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
|
||||
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']);
|
||||
// D152 (#443) adds Delete… before Clear.
|
||||
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Delete…','Clear']);
|
||||
await setCategory(page,' Studio set ');
|
||||
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
@@ -287,7 +288,7 @@ test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed on
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
|
||||
await addTags(page,'everyone');
|
||||
for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
for(const name of ['Add tags','Set category','Delete…','Clear',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
release();
|
||||
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
|
||||
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
|
||||
|
||||
@@ -221,6 +221,33 @@ test('tag image from review saves tags and the item remains pending', async ({ p
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('#515: tags typed before the image observation answers are saved', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await open(page);
|
||||
// Hold the dialog's observation so the typing lands while it is pending (a slow host or WebKit in CI).
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const matcher = '**/prompt_manager/image-annotations/observe';
|
||||
let reachedObserve;
|
||||
const reached = new Promise(resolve => { reachedObserve = resolve; });
|
||||
await page.route(matcher, async route => { reachedObserve(); await held; await route.continue(); });
|
||||
await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
await reached;
|
||||
const field = dialog.getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
await field.fill('dunes');
|
||||
const save = dialog.getByRole('button', { name: 'Save manual tags', exact: true });
|
||||
await expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
const answered = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
release();
|
||||
await answered;
|
||||
await expect(save).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(field).toHaveValue('dunes');
|
||||
await save.click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for review_0000.png. Prompt association still awaits review.');
|
||||
await page.unroute(matcher);
|
||||
});
|
||||
|
||||
test('an empty queue shows the empty notice only', async ({ page, request }) => {
|
||||
await seed(request, 0);
|
||||
await page.goto(review);
|
||||
|
||||
@@ -37,6 +37,15 @@ test.afterEach(async ({ page, request, browserName }) => {
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
});
|
||||
|
||||
async function openSettled(page, url) {
|
||||
// The page reads its selected file's details as soon as it lists. A source change before that read is answered
|
||||
// makes the page find the file again on its own, so the test's old-numbering locators are gone before they are
|
||||
// used (#484): every test changes sources only after the read was answered.
|
||||
const inspected = page.waitForResponse(item => new URL(item.url()).pathname.startsWith('/prompt_manager/images/inspect/') &&
|
||||
item.request().method() === 'GET');
|
||||
await page.goto(url);
|
||||
expect((await inspected).status()).toBe(200);
|
||||
}
|
||||
async function openTag(page, path = FIRST, root = 0) {
|
||||
const observed = answered(page, '/image-annotations/observe');
|
||||
await tagButton(page, path, root).click();
|
||||
@@ -53,7 +62,7 @@ async function savedTags(request, path, root) {
|
||||
}
|
||||
|
||||
test('renumbered sources under an open Tag dialog: it finds the file again, keeps the draft and Generate works', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await openSettled(page, images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
expect((await openTag(page)).status()).toBe(200);
|
||||
await manual(page).fill('kept_draft');
|
||||
@@ -91,7 +100,7 @@ test('renumbered sources under an open Tag dialog: it finds the file again, keep
|
||||
});
|
||||
|
||||
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await openSettled(page, images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
|
||||
await expect(page.locator('.bulkbar')).toBeVisible();
|
||||
@@ -113,7 +122,7 @@ test('the inspector finds a renumbered file again; checked files from the old nu
|
||||
});
|
||||
|
||||
test('a scope that cannot be mapped: Generate and Save are visibly disabled, the reason and Refresh sit beside them', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await openSettled(page, images);
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
@@ -157,7 +166,7 @@ test('a scope that cannot be mapped: Generate and Save are visibly disabled, the
|
||||
});
|
||||
|
||||
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await openSettled(page, images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
@@ -173,7 +182,7 @@ test('an unmappable inspector read keeps its visible Refresh, and Refresh recove
|
||||
|
||||
test('a Load more cursor the server cannot verify offers Refresh instead of a dead Retry', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 60 } })).toBeOK();
|
||||
await page.goto(images);
|
||||
await openSettled(page, images);
|
||||
await expect(cards(page).first()).toBeVisible();
|
||||
await expect(page.locator('#gallery-continuation')).toContainText('more load as you scroll');
|
||||
await expect(await request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
@@ -191,7 +200,7 @@ test('a Load more cursor the server cannot verify offers Refresh instead of a de
|
||||
});
|
||||
|
||||
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await openSettled(page, images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
@@ -217,7 +226,7 @@ test('a collection page finds a renumbered member again and stays on its members
|
||||
await expect(added).toBeOK();
|
||||
const collections = (await (await request.get('/prompt_manager/collections')).json()).collections;
|
||||
const { id } = collections.find(item => item.name === name);
|
||||
await page.goto(`/prompt_manager/workspace#/collection/${id}`);
|
||||
await openSettled(page, `/prompt_manager/workspace#/collection/${id}`);
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
|
||||
@@ -11,8 +11,8 @@ const STATUS_URL = '/prompt_manager/backup/status';
|
||||
const PREVIEW_URL = '/prompt_manager/restore/preview';
|
||||
const APPLY_URL = '/prompt_manager/restore/apply';
|
||||
const WHEN = { dateStyle: 'medium', timeStyle: 'short' };
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
|
||||
'records. A restore always keeps one.';
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
|
||||
'prompt deletes change records. A restore always keeps one.';
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
|
||||
@@ -274,11 +274,18 @@ test('level filter is minimum-level over all six choices', async ({ page, reques
|
||||
for (const line of (await lines(page).textContent()).split('\n')) expect(line).toMatch(/^\d\d:\d\d:\d\d [A-Z]+ +\S/);
|
||||
expect(urls.at(-1)).toBe('?limit=100');
|
||||
for (const minimum of LEVELS) {
|
||||
// Wait for this level's answer: the previous level's lines also hold the CRITICAL line, so seeing it proved
|
||||
// nothing, and under load the filtered answer painted after the check (#501 merge queue, firefox).
|
||||
const answered = page.waitForResponse(item => new URL(item.url()).pathname === LOGS_URL
|
||||
&& new URL(item.url()).search === `?limit=100&level=${minimum}`);
|
||||
await level.selectOption(minimum);
|
||||
await answered;
|
||||
await expect.poll(() => urls.at(-1)).toBe(`?limit=100&level=${minimum}`);
|
||||
await expect(lines(page)).toContainText('CRITICAL The database could not be opened');
|
||||
const shown = await shownLevels(page);
|
||||
expect(shown.every(name => LEVELS.indexOf(name) >= LEVELS.indexOf(minimum)), `${minimum}: ${shown}`).toBe(true);
|
||||
await expect.poll(async () => {
|
||||
const shown = await shownLevels(page);
|
||||
return shown.every(name => LEVELS.indexOf(name) >= LEVELS.indexOf(minimum)) || `${minimum}: ${shown}`;
|
||||
}).toBe(true);
|
||||
if (minimum === 'DEBUG') await expect(lines(page)).toContainText('DEBUG Thumbnail cache lookup');
|
||||
if (minimum === 'WARNING') {
|
||||
await expect(lines(page)).not.toContainText('Database backup verified');
|
||||
|
||||
@@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' &&
|
||||
|
||||
test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
|
||||
await select(page, FIRST);
|
||||
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
|
||||
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Delete…', 'Clear']);
|
||||
});
|
||||
|
||||
test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""A fake system trash for tests (#480): renames into a folder outside every root.
|
||||
|
||||
``install(folder)`` replaces ``utils.trash.BACKEND`` and returns the fake;
|
||||
``uninstall`` puts the real one back. ``refuse[path] = code`` refuses one
|
||||
file with that code. The fake never copies or unlinks; it records each call.
|
||||
The disposable browser server installs it when ``PM_TEST_FAKE_TRASH=1``, so
|
||||
no test touches a real trash.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from utils import trash
|
||||
|
||||
|
||||
class FakeTrash:
|
||||
def __init__(self, folder):
|
||||
self.folder = str(folder)
|
||||
os.makedirs(self.folder, exist_ok=True)
|
||||
self.refuse = {}
|
||||
self.moved = [] # (source, destination)
|
||||
self.calls = []
|
||||
|
||||
def available(self, path, roots):
|
||||
self.calls.append(("available", str(path)))
|
||||
return self.refuse.get(str(path))
|
||||
|
||||
def move(self, path, roots):
|
||||
self.calls.append(("move", str(path)))
|
||||
code = self.refuse.get(str(path))
|
||||
if code:
|
||||
raise trash.TrashRefused(code)
|
||||
destination = os.path.join(
|
||||
self.folder, f"{len(self.moved)}-{os.path.basename(path)}"
|
||||
)
|
||||
if os.path.lexists(destination):
|
||||
raise trash.TrashRefused(trash.FAILED)
|
||||
os.rename(path, destination)
|
||||
self.moved.append((str(path), destination))
|
||||
return "fake trash"
|
||||
|
||||
|
||||
_ORIGINAL = trash.BACKEND
|
||||
|
||||
|
||||
def install(folder):
|
||||
fake = FakeTrash(folder)
|
||||
trash.BACKEND = fake
|
||||
return fake
|
||||
|
||||
|
||||
def uninstall():
|
||||
trash.BACKEND = _ORIGINAL
|
||||
@@ -118,6 +118,7 @@
|
||||
"POST /prompt_manager/image-annotations/observe",
|
||||
"POST /prompt_manager/image-annotations/suggest",
|
||||
"POST /prompt_manager/images/clear-thumbnails",
|
||||
"POST /prompt_manager/images/delete/preview",
|
||||
"POST /prompt_manager/images/generate-thumbnails",
|
||||
"POST /prompt_manager/images/generate-thumbnails/progress",
|
||||
"POST /prompt_manager/images/link",
|
||||
@@ -131,6 +132,9 @@
|
||||
"POST /prompt_manager/jobs/thumbnails",
|
||||
"POST /prompt_manager/jobs/{job_id}/cancel",
|
||||
"POST /prompt_manager/library",
|
||||
"POST /prompt_manager/library/delete",
|
||||
"POST /prompt_manager/library/delete/preview",
|
||||
"POST /prompt_manager/library/restore",
|
||||
"POST /prompt_manager/library/{positive_id}/variants",
|
||||
"POST /prompt_manager/logs/config",
|
||||
"POST /prompt_manager/logs/truncate",
|
||||
|
||||
@@ -876,3 +876,63 @@ test('retry after a changed storage, file or expired capture observes again and
|
||||
await settle();
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
});
|
||||
|
||||
test('#515: text typed while the first observation is pending is kept, after the saved tags', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const answers = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise }));
|
||||
// An untagged file: the typed text is the whole draft and Save sends it.
|
||||
const bare = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' });
|
||||
const first = bare.open();
|
||||
bare.setManual('dunes');
|
||||
answers[0].resolve(response(200, observed({ tags: [], tag_revision: 0 })));
|
||||
await first;
|
||||
await settle();
|
||||
assert.equal(bare.state.phase, 'ready');
|
||||
assert.equal(bare.state.manual, 'dunes');
|
||||
assert.equal(bare.state.statusText, '', 'nothing changed elsewhere');
|
||||
await bare.save();
|
||||
await settle();
|
||||
assert.deepEqual(h.of('PUT', TAGS).at(-1).body.tags, ['dunes']);
|
||||
// A tagged file: its saved tags lead, so a Save never drops tags the user had not seen yet.
|
||||
const tagged = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
const second = tagged.open();
|
||||
tagged.setManual('dunes, minimal');
|
||||
answers[1].resolve(response(200, observed()));
|
||||
await second;
|
||||
await settle();
|
||||
assert.equal(tagged.state.manual, 'Minimal, warm_light, dunes');
|
||||
assert.equal(tagged.state.statusText, '');
|
||||
});
|
||||
|
||||
test('#515: a kept draft takes the text typed while the reopen observes', async () => {
|
||||
const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(409, 'file-changed', 'The file changed.') });
|
||||
c.setManual('kept draft');
|
||||
await c.save();
|
||||
await settle();
|
||||
const reopened = c.retry();
|
||||
c.setManual('kept draft, later');
|
||||
await reopened;
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'kept draft, later');
|
||||
});
|
||||
|
||||
test('#515: text typed before Retry after a failed first observation follows the saved tags', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
let n = 0;
|
||||
const h = harness(defaultRoutes({
|
||||
[`POST ${OBSERVE}`]: () => (n++ === 0 ? fail(500, 'internal', 'boom') : response(200, observed())),
|
||||
}));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
c.setManual('dunes');
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light, dunes');
|
||||
assert.equal(c.state.statusText, '');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// #480 (D159): Images › Delete…: the review's rows, sentences, labels and outcome toasts.
|
||||
const loaded = await import('../../web/js/workspace/images-delete.mjs').catch(() => null);
|
||||
const module = () => { assert.ok(loaded, 'images-delete.mjs is not implemented'); return loaded; };
|
||||
const pageScope = () => {
|
||||
const controller = new AbortController();
|
||||
return { signal: controller.signal, cleanup: () => {}, abort: () => controller.abort() };
|
||||
};
|
||||
const plan = (over = {}) => ({
|
||||
token: 't', library: { count: 3 }, trash: { count: 2 },
|
||||
refused: { count: 1, counts: { 'not-listed': 1 }, rows: [{ path: 'sub/gone.png', code: 'not-listed', detail: 'No longer listed in Images.' }] },
|
||||
trash_refused: { count: 1, counts: { 'no-trash': 1 }, rows: [{ path: 'b.png', code: 'no-trash', detail: 'This drive has no trash.' }] },
|
||||
records: { prompt_links: 2, pair_links: 0, image_tags: 1, auto_tagged: 0, categories: 1, collections: 2, favorites: 0, review_items: 0 },
|
||||
orphaned_prompts: { count: 1, titles: ['Red fox'] },
|
||||
backup: { policy: 'on' }, disk: null, ...over,
|
||||
});
|
||||
|
||||
test('the words of the review and its routes', () => {
|
||||
const { PREVIEW_URL, MAX_DELETE, TOO_MANY, QUESTION, MODES } = module();
|
||||
assert.equal(PREVIEW_URL, '/prompt_manager/images/delete/preview');
|
||||
assert.equal(MAX_DELETE, 500);
|
||||
assert.equal(TOO_MANY, 'Select at most 500 images.');
|
||||
assert.equal(QUESTION, 'What should happen to the files?');
|
||||
assert.deepEqual(MODES.map(mode => mode.label), ['Remove from the library only', 'Also move the files to the trash']);
|
||||
});
|
||||
|
||||
test('library rows: scope, refusals, links, details, backup and media', () => {
|
||||
const { deleteRows } = module();
|
||||
const rows = Object.fromEntries(deleteRows(plan(), 'library'));
|
||||
assert.equal(rows.Scope, '3 images');
|
||||
assert.equal(rows['Not changed'], '1: gone.png. No longer listed in Images.');
|
||||
assert.equal(rows['Prompt links'], '2 links. 1 prompt loses its last image: Red fox. The prompt stays on the Library page.');
|
||||
assert.equal(rows['Image details'], '1 with image tags, 1 category, 2 collection memberships');
|
||||
assert.equal(rows.Backup, 'Create and verify before changes (the database, not the files)');
|
||||
assert.equal(rows['Media on disk'], 'Files stay on disk, hidden from Images.');
|
||||
});
|
||||
|
||||
test('trash rows add the trash refusals and the trash media line', () => {
|
||||
const { deleteRows } = module();
|
||||
const rows = Object.fromEntries(deleteRows(plan({ backup: { policy: 'off' } }), 'trash'));
|
||||
assert.equal(rows.Scope, '2 images');
|
||||
assert.match(rows['Not changed'], /^2: gone\.png, b\.png\. No longer listed in Images\. This drive has no trash\.$/);
|
||||
assert.equal(rows.Backup, 'Off: no safety backup (System › Backup & export › Recovery policy)');
|
||||
assert.match(rows['Media on disk'], /Nothing is deleted permanently/);
|
||||
});
|
||||
|
||||
test('rows leave out what is zero', () => {
|
||||
const { deleteRows } = module();
|
||||
const bare = plan({ refused: { count: 0, rows: [] }, trash_refused: { count: 0, rows: [] },
|
||||
records: { prompt_links: 0 }, orphaned_prompts: { count: 0, titles: [] } });
|
||||
assert.deepEqual(deleteRows(bare, 'library').map(([term]) => term), ['Scope', 'Backup', 'Media on disk']);
|
||||
const many = plan({ orphaned_prompts: { count: 5, titles: ['A', 'B', 'C'] } });
|
||||
assert.match(Object.fromEntries(deleteRows(many, 'library'))['Prompt links'],
|
||||
/5 prompts lose their last image: A, B, C and 2 more\. The prompts stay on the Library page\./);
|
||||
assert.equal(module().refusedValue({ count: 5, rows: [{ path: 'a.png' }] }), '5: a.png and 4 more');
|
||||
assert.equal(module().refusedValue({ count: 0 }), null);
|
||||
});
|
||||
|
||||
test('sentences follow the mode and the backup policy', () => {
|
||||
const { deleteSentences, SENTENCES, USE_LIBRARY_ONLY, NO_TRASH_HERE } = module();
|
||||
assert.deepEqual(deleteSentences(plan(), 'library'), [SENTENCES.libraryBackup]);
|
||||
assert.deepEqual(deleteSentences(plan({ backup: { policy: 'off' } }), 'library'), [SENTENCES.libraryNoBackup]);
|
||||
assert.deepEqual(deleteSentences(plan(), 'trash'), [SENTENCES.trash, USE_LIBRARY_ONLY]);
|
||||
const nowhere = plan({ trash: { count: 0 }, trash_refused: { count: 3, rows: [] } });
|
||||
assert.deepEqual(deleteSentences(nowhere, 'trash'), [SENTENCES.trash, NO_TRASH_HERE, USE_LIBRARY_ONLY]);
|
||||
// #500: no "yet" (Windows is unavailable by design); each file's reason says why.
|
||||
assert.equal(NO_TRASH_HERE, 'Moving to the trash is not available for these files; each one lists why.');
|
||||
assert.deepEqual(deleteSentences(plan({ trash_refused: { count: 0 } }), 'trash'), [SENTENCES.trash]);
|
||||
});
|
||||
|
||||
test('primary labels and the close-only reasons', () => {
|
||||
const { primaryLabel, closeOnlyReason, NONE_DELETABLE } = module();
|
||||
assert.equal(primaryLabel('library', 1), 'Remove 1 image from the library');
|
||||
assert.equal(primaryLabel('trash', 4), 'Move 4 images to the trash');
|
||||
assert.equal(closeOnlyReason(plan()), null);
|
||||
assert.equal(closeOnlyReason(plan({ library: { count: 0 }, trash: { count: 0 } })), NONE_DELETABLE);
|
||||
assert.equal(closeOnlyReason(plan({ disk: { sufficient: false, required_bytes: 2048, free_bytes: 1024 } })),
|
||||
'There is not enough free space for the safety backup (needs 2,048 bytes, has 1,024).');
|
||||
});
|
||||
|
||||
test('outcome toasts per mode and failure', () => {
|
||||
const { outcomeToast } = module();
|
||||
assert.equal(outcomeToast({ scope: { mode: 'library' }, counters: { removed: 3, not_changed: 0 }, total: 3 }),
|
||||
'Removed 3 images from the library.');
|
||||
assert.equal(outcomeToast({ scope: { mode: 'trash' }, counters: { trashed: 2, not_changed: 1 }, total: 3 }),
|
||||
'Moved 2 images to the trash. 1 was not changed (see Activity).');
|
||||
assert.equal(outcomeToast({ scope: { mode: 'trash' }, counters: { trashed: 0, not_changed: 2 }, total: 2 }),
|
||||
'Moved 0 images to the trash. 2 were not changed (see Activity).');
|
||||
assert.equal(outcomeToast({ scope: { mode: 'library', failure: 'records-failed' }, counters: { removed: 1 }, total: 4 }),
|
||||
'Stopped after 1 of 4 images; the rest were not changed. See Activity.');
|
||||
assert.equal(outcomeToast({ scope: { failure: 'backup-failed' } }), 'The safety backup failed; nothing was changed.');
|
||||
assert.equal(outcomeToast({ scope: { failure: 'sources-changed' } }), 'The sources changed; nothing was changed.');
|
||||
});
|
||||
|
||||
test('review failures name the cause', () => {
|
||||
const { reviewFailure, SOURCE_CHANGED, STORAGE_CHANGED, TOO_MANY, REVIEW_FAILED } = module();
|
||||
assert.equal(reviewFailure({ code: 'source-changed' }), SOURCE_CHANGED);
|
||||
assert.equal(reviewFailure({ code: 'storage-changed' }), STORAGE_CHANGED);
|
||||
assert.equal(reviewFailure({ code: 'too-many-images' }), TOO_MANY);
|
||||
assert.equal(reviewFailure({ code: 'busy', message: 'Scan running' }), 'Scan running');
|
||||
assert.equal(reviewFailure(new Error('boom')), REVIEW_FAILED);
|
||||
});
|
||||
|
||||
test('over 500 refuses with the toast before any request', async () => {
|
||||
const { openDeleteImages, TOO_MANY } = module();
|
||||
const toasts = [];
|
||||
const api = { post: () => assert.fail('no request') };
|
||||
await openDeleteImages({ api, scope: pageScope(), images: [], filter: {}, count: 501, notify: text => toasts.push(text) });
|
||||
assert.deepEqual(toasts, [TOO_MANY]);
|
||||
await openDeleteImages({ api, scope: pageScope(), images: [], notify: text => toasts.push(text) });
|
||||
assert.deepEqual(toasts, [TOO_MANY]);
|
||||
});
|
||||
|
||||
test('a failed review toasts and releases its trigger', async () => {
|
||||
const { openDeleteImages, SOURCE_CHANGED } = module();
|
||||
const toasts = [], sent = [];
|
||||
const attributes = new Map();
|
||||
const trigger = { textContent: 'Delete…', getAttribute: name => attributes.get(name) ?? null,
|
||||
setAttribute: (name, value) => attributes.set(name, value), removeAttribute: name => attributes.delete(name) };
|
||||
const api = { post: async (url, body) => { sent.push([url, body]); throw Object.assign(new Error('x'), { code: 'source-changed' }); } };
|
||||
await openDeleteImages({ api, scope: pageScope(), trigger, notify: text => toasts.push(text),
|
||||
images: [{ root_index: 0, root_scope: 'a'.repeat(64), path: 'a.png', filename: 'a.png' }] });
|
||||
assert.deepEqual(sent, [['/prompt_manager/images/delete/preview', { items: [{ root_index: 0, root_scope: 'a'.repeat(64), path: 'a.png' }] }]]);
|
||||
assert.deepEqual(toasts, [SOURCE_CHANGED]);
|
||||
assert.equal(trigger.textContent, 'Delete…');
|
||||
assert.equal(trigger.getAttribute('aria-disabled'), null);
|
||||
});
|
||||
|
||||
test('apply sends the token and the mode, and toasts that the job started', async () => {
|
||||
const { applyDelete } = module();
|
||||
const sent = [], toasts = [];
|
||||
const api = { post: async (url, body) => { sent.push([url, body]); return { job: { id: 'j', title: 'Delete images (move to the trash)' } }; } };
|
||||
const store = { track: job => job, load: () => {} };
|
||||
const job = await applyDelete({ api, store, plan: plan(), mode: 'trash', notify: text => toasts.push(text) });
|
||||
assert.equal(job.id, 'j');
|
||||
assert.deepEqual(sent, [['/prompt_manager/maintenance/apply', { token: 't', mode: 'trash' }]]);
|
||||
assert.deepEqual(toasts, ['Delete images (move to the trash) started.']);
|
||||
const refused = await applyDelete({ api: { post: async () => { throw Object.assign(new Error('Busy'), { code: 'busy', running: null }); } },
|
||||
store, plan: plan(), mode: 'library', notify: text => toasts.push(text) });
|
||||
assert.equal(refused, null);
|
||||
});
|
||||
|
||||
test('apply refuses to send a delete without an explicit mode', async () => {
|
||||
const { applyDelete } = module();
|
||||
const api = { post: () => assert.fail('no request without a mode') };
|
||||
for (const mode of [undefined, null, '', 'TRASH', 'delete']) {
|
||||
await assert.rejects(applyDelete({ api, store: null, plan: plan(), mode, notify: () => {} }), TypeError);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// #443 D152: the Prompt library's Delete… review (delete unpaired, archive paired, keep protected) and Restore.
|
||||
const source = import('../../web/js/workspace/library-delete.mjs');
|
||||
|
||||
const preview = (patch = {}) => ({success: true, library_epoch: 'e', delete: [], archive: [], protected: [],
|
||||
already_archived: 0, not_found: 0, image_links: 0, backup: 'on', disk: null, ...patch});
|
||||
|
||||
test('the routes are the workspace ones, never the legacy bulk delete', async () => {
|
||||
const {PREVIEW_URL, DELETE_URL, RESTORE_URL} = await source;
|
||||
assert.equal(PREVIEW_URL, '/prompt_manager/library/delete/preview');
|
||||
assert.equal(DELETE_URL, '/prompt_manager/library/delete');
|
||||
assert.equal(RESTORE_URL, '/prompt_manager/library/restore');
|
||||
});
|
||||
|
||||
test('the title, primary and body follow what the review will do', async () => {
|
||||
const {reviewOf, reviewTitle, primaryLabel, deleteBody} = await source;
|
||||
const both = reviewOf(preview({delete: [1, 2], archive: [3]}));
|
||||
assert.equal(reviewTitle(both), 'Delete or archive selected prompts');
|
||||
assert.equal(primaryLabel(both), 'Delete 2 and archive 1 prompt');
|
||||
assert.deepEqual(deleteBody(both), {archive_ids: [3], delete_ids: [1, 2], expected_epoch: 'e', backup: 'on'});
|
||||
const onlyDelete = reviewOf(preview({delete: [1]}));
|
||||
assert.equal(reviewTitle(onlyDelete), 'Delete selected prompts');
|
||||
assert.equal(primaryLabel(onlyDelete), 'Delete 1 prompt');
|
||||
const onlyArchive = reviewOf(preview({archive: [4, 5]}));
|
||||
assert.equal(reviewTitle(onlyArchive), 'Archive selected prompts');
|
||||
assert.equal(primaryLabel(onlyArchive), 'Archive 2 prompts');
|
||||
});
|
||||
|
||||
test('the grid shows each row only when it applies, with protected titles in selection order', async () => {
|
||||
const {reviewOf, reviewGrid} = await source;
|
||||
const names = {7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten'};
|
||||
const review = reviewOf(preview({delete: [1], archive: [2], protected: [7, 8, 9, 10], already_archived: 1,
|
||||
not_found: 2, image_links: 3}));
|
||||
assert.deepEqual(reviewGrid(review, id => names[id]), [
|
||||
['Delete permanently', '1 prompt not in a saved pair'],
|
||||
['Archive', '1 prompt in a saved pair: hidden, restorable'],
|
||||
['Protected (kept)', '4: Seven, Eight, Nine and 1 more'],
|
||||
['Already archived', '1'],
|
||||
['No longer exist', '2'],
|
||||
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
const archiveOnly = reviewOf(preview({archive: [2]}));
|
||||
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']);
|
||||
const off = reviewOf(preview({delete: [1], backup: 'off'}));
|
||||
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
|
||||
});
|
||||
|
||||
test('the Archive row names the saved pair, never negative variants, for one or many prompts', async () => {
|
||||
const {reviewOf, reviewGrid} = await source;
|
||||
// A saved-pair prompt with 0 negative variants (live prompt 7901) is archive-only for its pair, not for variants.
|
||||
const one = reviewGrid(reviewOf(preview({archive: [2]}))).find(([term]) => term === 'Archive');
|
||||
assert.deepEqual(one, ['Archive', '1 prompt in a saved pair: hidden, restorable']);
|
||||
const many = reviewGrid(reviewOf(preview({archive: [2, 3, 4]}))).find(([term]) => term === 'Archive');
|
||||
assert.deepEqual(many, ['Archive', '3 prompts in a saved pair: hidden, restorable']);
|
||||
for (const [, value] of [one, many]) assert.doesNotMatch(value, /variant/);
|
||||
});
|
||||
|
||||
test('the sentences explain archive, irreversibility per backup policy, re-creation and protection', async () => {
|
||||
const {reviewOf, reviewSentences} = await source;
|
||||
const [archive, remove, guarded] = reviewSentences(reviewOf(preview({delete: [1], archive: [2], protected: [3]})));
|
||||
// Live test (#470): the archive reason is the saved pair, which a prompt with 0 negative variants has too.
|
||||
assert.match(archive, /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the pair and its runs stay intact\./);
|
||||
assert.doesNotMatch(archive, /variant/);
|
||||
assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/);
|
||||
assert.match(remove, /the only way back is to restore the safety backup taken first \(System › Backup & export\), which returns the whole library to that moment\. It is kept among the last 10 safety backups\./);
|
||||
assert.match(remove, /A later scan, a new image or a node run with the same text adds it again as a new prompt\.$/);
|
||||
assert.equal(guarded, 'Protected prompts are tagged __protected__; remove the tag to delete them.');
|
||||
const [off] = reviewSentences(reviewOf(preview({delete: [1], backup: 'off'})));
|
||||
assert.match(off, /Deleted prompts cannot be brought back\. Image files/);
|
||||
assert.doesNotMatch(off, /safety backup/);
|
||||
});
|
||||
|
||||
test('a review that cannot start says why: nothing to change, or no room for the backup', async () => {
|
||||
const {reviewOf, blockedReason, COPY} = await source;
|
||||
assert.equal(blockedReason(reviewOf(preview({protected: [1]}))), COPY.none);
|
||||
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false, message: 'Not enough. Nothing was changed.'}}))),
|
||||
'Not enough. Nothing was changed.');
|
||||
assert.match(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false}}))), /not enough free space/);
|
||||
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: true}}))), null);
|
||||
assert.equal(blockedReason(reviewOf(preview({archive: [1]}))), null);
|
||||
});
|
||||
|
||||
test('a delete outcome counts each part, singular and plural, and names the backup', async () => {
|
||||
const {reviewOf, deleteOutcome} = await source;
|
||||
const review = reviewOf(preview({delete: [1, 2], archive: [3], protected: [9]}));
|
||||
assert.deepEqual(deleteOutcome({success: true, deleted: 2, archived: 3, already_archived: 0, not_found: 0, changed: 0,
|
||||
backup: 'prompts.db.backup_x'}, null, review), {committed: true, reload: true,
|
||||
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
|
||||
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
|
||||
backup: null}, null, reviewOf(preview())).toast,
|
||||
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was not deleted.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2,
|
||||
backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast,
|
||||
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were not deleted. 2 protected prompts were kept. The Library was reloaded.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
|
||||
});
|
||||
|
||||
test('a failed delete says nothing changed, or that it may have, and keeps or reloads the selection', async () => {
|
||||
const {deleteOutcome, COPY} = await source;
|
||||
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
|
||||
assert.equal(COPY.storage, 'The library was restored or moved since this page loaded. Nothing was deleted or archived.');
|
||||
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'busy', running: {title: 'Scan'}}),
|
||||
{toast: 'Scan is running. Nothing was changed; try again when it finishes.', committed: false, reload: false});
|
||||
assert.deepEqual(deleteOutcome(null, {status: 500, code: 'backup-failed', message: 'The safety backup could not be taken and verified; nothing was changed.'}),
|
||||
{toast: 'The safety backup failed. The safety backup could not be taken and verified; nothing was changed.', committed: false, reload: false});
|
||||
assert.deepEqual(deleteOutcome(null, {status: 507, code: 'insufficient-space', message: 'Not enough free disk space. Nothing was changed.'}),
|
||||
{toast: 'Not enough free disk space. Nothing was changed.', committed: false, reload: false});
|
||||
for (const failure of [new TypeError('Failed to fetch'), {status: 502}, {status: 503}, {status: 504}]) {
|
||||
assert.deepEqual(deleteOutcome(null, failure), {toast: COPY.unconfirmed, committed: true, reload: true});
|
||||
}
|
||||
assert.deepEqual(deleteOutcome(null, {status: 400}), {toast: COPY.failed, committed: false, reload: false});
|
||||
});
|
||||
|
||||
test('a failed preview reloads only after a restore; Restore outcomes have their own words', async () => {
|
||||
const {previewOutcome, restoreOutcome, COPY} = await source;
|
||||
assert.deepEqual(previewOutcome({status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
|
||||
assert.deepEqual(previewOutcome({status: 500}), {toast: COPY.reviewFailed, committed: false, reload: false});
|
||||
assert.equal(previewOutcome({status: 409, code: 'busy', running: {title: 'Optimize'}}).toast,
|
||||
'Optimize is running. Nothing was changed; try again when it finishes.');
|
||||
assert.deepEqual(restoreOutcome({success: true, restored: 2, unchanged: 1, not_found: 1}, null),
|
||||
{toast: 'Restored 2 prompts to the Prompt library. 1 was not archived. 1 no longer exists.', committed: true, reload: true});
|
||||
assert.deepEqual(restoreOutcome({success: true, restored: 0, unchanged: 2, not_found: 0}, null),
|
||||
{toast: 'No prompt was restored. 2 were not archived.', committed: false, reload: true});
|
||||
assert.deepEqual(restoreOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.restoreStorage, committed: false, reload: true});
|
||||
assert.deepEqual(restoreOutcome(null, {status: 503}), {toast: COPY.restoreUnconfirmed, committed: true, reload: true});
|
||||
assert.deepEqual(restoreOutcome(null, {status: 400}), {toast: COPY.restoreFailed, committed: false, reload: false});
|
||||
});
|
||||
|
||||
test('reviewOf keeps only well-formed ids and counts from an answer', async () => {
|
||||
const {reviewOf} = await source;
|
||||
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
|
||||
not_found: 'x', image_links: 2, backup: 'maybe'});
|
||||
assert.deepEqual(review, {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'});
|
||||
});
|
||||
|
||||
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
|
||||
const {archivedNoticeText} = await source;
|
||||
assert.equal(archivedNoticeText(0), 'This prompt is archived because it is in a saved pair. It is hidden from the Prompt ' +
|
||||
'library; nothing was removed.');
|
||||
assert.equal(archivedNoticeText(1), 'This prompt is archived because it is in a saved pair. It and its 1 negative variant ' +
|
||||
'are hidden from the Prompt library; nothing was removed.');
|
||||
assert.equal(archivedNoticeText(3), 'This prompt is archived because it is in a saved pair. It and its 3 negative variants ' +
|
||||
'are hidden from the Prompt library; nothing was removed.');
|
||||
// An unknown count (an older server) never claims variants.
|
||||
assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
|
||||
});
|
||||
|
||||
// #482 (D160): prompts in a saved pair can be deleted permanently, with their own saved pairs.
|
||||
const D1 = 'a'.repeat(64), D2 = 'b'.repeat(64);
|
||||
const paired = (patch = {}) => preview({archive: [3, 4, 5], deletable: [3, 4], review_digests: {3: D1, 4: D2},
|
||||
paired_counts: {pairs: 3, archived_pairs: 1, variants: 2, kept_negatives: 2}, paired_image_links: 4, ...patch});
|
||||
|
||||
test('reviewOf reads the deletable ids, their digests and the pair counts; the choice starts at Archive', async () => {
|
||||
const {reviewOf, hasPairedChoice} = await source;
|
||||
const review = reviewOf(paired({deletable: [3, 4, 99], review_digests: {3: D1, 4: 'short', 99: D1}}));
|
||||
// Only reviewed archive ids with a well-formed digest can be deleted permanently.
|
||||
assert.deepEqual(review.deletable, [3]);
|
||||
assert.deepEqual(review.digests, {3: D1});
|
||||
assert.deepEqual(review.pairedCounts, {pairs: 3, archivedPairs: 1, variants: 2, keptNegatives: 2});
|
||||
assert.equal(review.pairedImageLinks, 4);
|
||||
assert.equal(review.choice, 'archive');
|
||||
assert.equal(hasPairedChoice(review), true);
|
||||
assert.equal(hasPairedChoice(reviewOf(preview({archive: [5]}))), false);
|
||||
});
|
||||
|
||||
test('Archive chosen keeps the #443 review; Delete permanently names the pairs, the kept negatives and archive-only', async () => {
|
||||
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel, deleteBody} = await source;
|
||||
const review = reviewOf(paired({delete: [1], image_links: 2}));
|
||||
assert.deepEqual(reviewGrid(review).map(([term]) => term), ['Delete permanently', 'Archive', 'Image links removed', 'Backup']);
|
||||
assert.equal(primaryLabel(review), 'Delete 1 and archive 3 prompts');
|
||||
assert.deepEqual(deleteBody(review), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on'});
|
||||
const permanent = withChoice(review, 'delete');
|
||||
assert.notEqual(permanent, review);
|
||||
assert.equal(review.choice, 'archive');
|
||||
assert.deepEqual(reviewGrid(permanent), [
|
||||
['Delete permanently', '3 prompts: 1 not in a saved pair, 2 in a saved pair'],
|
||||
['Saved pairs removed', '3 saved pairs (2 with a negative prompt, 1 archived)'],
|
||||
['Negatives kept', '2 negative prompts are kept'],
|
||||
['Archive', '1 prompt used as another prompt\'s negative: hidden, restorable'],
|
||||
['Image links removed', '6 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
assert.equal(reviewTitle(permanent), 'Delete or archive selected prompts');
|
||||
assert.equal(primaryLabel(permanent), 'Delete 3 and archive 1 prompt');
|
||||
assert.deepEqual(deleteBody(permanent), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on',
|
||||
delete_paired_ids: [3, 4], paired_digests: {3: D1, 4: D2}});
|
||||
});
|
||||
|
||||
test('edge counts: one prompt, no negative, nothing archive-only', async () => {
|
||||
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel} = await source;
|
||||
// Live prompt 7896: one pair with an explicitly empty negative.
|
||||
const one = withChoice(reviewOf(preview({archive: [7], deletable: [7], review_digests: {7: D1},
|
||||
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}, paired_image_links: 0})), 'delete');
|
||||
assert.deepEqual(reviewGrid(one), [
|
||||
['Delete permanently', '1 prompt in a saved pair'],
|
||||
['Saved pairs removed', '1 saved pair'],
|
||||
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
assert.equal(reviewTitle(one), 'Delete selected prompts');
|
||||
assert.equal(primaryLabel(one), 'Delete 1 prompt');
|
||||
});
|
||||
|
||||
test('Delete permanently sentences: what goes with the pairs, what stays, and how it comes back', async () => {
|
||||
const {reviewOf, withChoice, reviewSentences} = await source;
|
||||
const [pairs, remove, archive] = reviewSentences(withChoice(reviewOf(paired()), 'delete'));
|
||||
assert.equal(pairs, 'Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
|
||||
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
|
||||
'and Activity, listed by their text.');
|
||||
assert.match(remove, /the only way back is to restore the safety backup/);
|
||||
assert.match(remove, /A pair node run with the same text saves a new pair\.$/);
|
||||
assert.equal(archive, 'Prompts used as another prompt\'s negative are archived instead of deleted, so that ' +
|
||||
'prompt\'s variant stays.');
|
||||
const [, off] = reviewSentences(withChoice(reviewOf(paired({backup: 'off'})), 'delete'));
|
||||
assert.match(off, /Deleted prompts cannot be brought back\./);
|
||||
});
|
||||
|
||||
test('a short disk blocks only the permanent delete; the outcome names removed pairs', async () => {
|
||||
const {reviewOf, withChoice, blockedReason, deleteOutcome} = await source;
|
||||
const short = reviewOf(paired({disk: {sufficient: false, message: 'Not enough space. Nothing was changed.'}}));
|
||||
assert.equal(blockedReason(short), null);
|
||||
assert.equal(blockedReason(withChoice(short, 'delete')), 'Not enough space. Nothing was changed.');
|
||||
const {toast} = deleteOutcome({success: true, deleted: 2, archived: 1, pairs_removed: 3, changed: 1}, null, short);
|
||||
assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' +
|
||||
'was not deleted.');
|
||||
});
|
||||
@@ -859,6 +859,42 @@ test('backupNotice is the concept .notice with its copy and one Back up now acti
|
||||
}
|
||||
});
|
||||
|
||||
test('openReviewDialog replaces its rows and its primary in place (#482)', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const started = [];
|
||||
const review = openReviewDialog({ scope, title: 'Delete or archive', grid: [['Archive', '2 prompts']],
|
||||
cancelLabel: 'Cancel', primary: { label: 'Archive 2 prompts', danger: true }, onStart: () => started.push('start') });
|
||||
review.setRows([['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
|
||||
const grid = byId(dom, 'modal-body').children[0];
|
||||
assert.deepEqual(grid.children.map(entry => [entry.children[0].textContent, entry.children[1].textContent]),
|
||||
[['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
|
||||
// setValue reaches the new rows only.
|
||||
review.setValue('Saved pairs removed', '4 saved pairs');
|
||||
review.setValue('Archive', 'x');
|
||||
assert.equal(grid.children[1].children[1].textContent, '4 saved pairs');
|
||||
const start = button(byId(dom, 'modal-body'), 'Archive 2 prompts');
|
||||
review.setPrimary({ label: 'Delete 2 prompts', disabled: true });
|
||||
assert.equal(start.textContent, 'Delete 2 prompts');
|
||||
assert.equal(start.getAttribute('aria-disabled'), 'true');
|
||||
start.click();
|
||||
assert.deepEqual(started, []);
|
||||
assert.equal(modal(dom).open, true);
|
||||
review.setPrimary({ label: 'Delete 2 prompts', disabled: false });
|
||||
assert.equal(start.getAttribute('aria-disabled'), 'false');
|
||||
start.click();
|
||||
assert.deepEqual(started, ['start']);
|
||||
assert.throws(() => review.setRows([['only a term']]), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('openReviewDialog lays out heading, grid, options, sentence, backup box and actions', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
|
||||
+44
-2
@@ -28,6 +28,10 @@ stack shows which). A dump after the summary is a hang at interpreter exit
|
||||
|
||||
A limit is a measurement, not a dial: change it only with new measurements
|
||||
written next to it (TESTING.md "Time limits and hangs").
|
||||
|
||||
PM_TEST_SLOWEST=N (opt-in, #487) lists the N slowest tests after the summary.
|
||||
A test's time runs from its ``start:`` line to the next one, so it includes
|
||||
the class and module fixtures in between, like the hang limit.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -36,6 +40,7 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -63,23 +68,57 @@ def read_limit(name, default):
|
||||
return int(text)
|
||||
|
||||
|
||||
def make_result_class(stream, per_test_limit, shutdown_limit):
|
||||
class TestClock:
|
||||
"""Seconds from each test's start to the next start (or the run's end)."""
|
||||
|
||||
def __init__(self, clock=time.perf_counter):
|
||||
self._clock = clock
|
||||
self._current = None
|
||||
self._started = 0.0
|
||||
self.durations = []
|
||||
|
||||
def start(self, test_id):
|
||||
self.stop()
|
||||
self._current = test_id
|
||||
self._started = self._clock()
|
||||
|
||||
def stop(self):
|
||||
if self._current is not None:
|
||||
self.durations.append((self._clock() - self._started, self._current))
|
||||
self._current = None
|
||||
|
||||
def report(self, count):
|
||||
"""Return the lines that list the ``count`` slowest tests."""
|
||||
slowest = sorted(self.durations, key=lambda item: item[0], reverse=True)
|
||||
lines = [f"slowest {min(count, len(slowest))} of {len(slowest)} tests:"]
|
||||
lines += [f"{seconds:8.2f}s {test_id}" for seconds, test_id in slowest[:count]]
|
||||
return lines
|
||||
|
||||
|
||||
def make_result_class(stream, per_test_limit, shutdown_limit, slowest=0):
|
||||
"""Build a TextTestResult that names each test and re-arms the watchdog."""
|
||||
|
||||
def arm(seconds):
|
||||
faulthandler.dump_traceback_later(seconds, exit=True, file=stream)
|
||||
|
||||
clock = TestClock()
|
||||
|
||||
class WatchdogResult(unittest.TextTestResult):
|
||||
def startTest(self, test):
|
||||
# At default verbosity the progress dots share a line; start ours.
|
||||
prefix = "\n" if self.dots else ""
|
||||
stream.write(f"{prefix}start: {test.id()}\n")
|
||||
stream.flush()
|
||||
clock.start(test.id())
|
||||
super().startTest(test)
|
||||
arm(per_test_limit)
|
||||
|
||||
def stopTestRun(self):
|
||||
clock.stop()
|
||||
super().stopTestRun()
|
||||
if slowest:
|
||||
stream.write("\n".join(["", *clock.report(slowest), ""]))
|
||||
stream.flush()
|
||||
arm(shutdown_limit)
|
||||
|
||||
return WatchdogResult
|
||||
@@ -138,6 +177,7 @@ def main(argv=None):
|
||||
try:
|
||||
per_test_limit = read_limit("PM_TEST_HANG_LIMIT", DEFAULT_PER_TEST_LIMIT)
|
||||
shutdown_limit = read_limit("PM_TEST_SHUTDOWN_LIMIT", DEFAULT_SHUTDOWN_LIMIT)
|
||||
slowest = read_limit("PM_TEST_SLOWEST", 0)
|
||||
except LimitError as error:
|
||||
stream.write(f"{error}\n")
|
||||
return USAGE_EXIT
|
||||
@@ -152,7 +192,9 @@ def main(argv=None):
|
||||
verbosity=args.verbosity,
|
||||
# As unittest.main does: show warnings unless -W/PYTHONWARNINGS set them.
|
||||
warnings=None if sys.warnoptions else "default",
|
||||
resultclass=make_result_class(stream, per_test_limit, shutdown_limit),
|
||||
resultclass=make_result_class(
|
||||
stream, per_test_limit, shutdown_limit, slowest
|
||||
),
|
||||
)
|
||||
result = runner.run(suite)
|
||||
finally:
|
||||
|
||||
@@ -45,7 +45,9 @@ SEEDED_PATHS = (
|
||||
"/srv/someone/renders",
|
||||
"C:\\Users\\someone\\Documents\\renders",
|
||||
"D:/Users/someone/prompts.db",
|
||||
"\\\\nas\\share\\someone\\renders",
|
||||
# A share on this machine: on Windows the report resolves every source,
|
||||
# and an unknown server name made that wait about 2.9 s (#487).
|
||||
"\\\\localhost\\pm-no-such-share\\someone\\renders",
|
||||
)
|
||||
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
TOP_KEYS = {
|
||||
|
||||
@@ -25,6 +25,7 @@ try:
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase
|
||||
|
||||
from database import restore_stage
|
||||
from database.operations import PromptDatabase
|
||||
from py.api.library import _encode, _scope
|
||||
from tests.test_execution_context_database import bundle, snapshot
|
||||
@@ -43,6 +44,21 @@ OTHER_SESSION = "aaaaaaaa-3333-4333-8333-333333333333"
|
||||
MISSING_RUN = "bbbbbbbb-4444-4444-8444-444444444444"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def no_wal_truncate_wait():
|
||||
"""Restore with the restore notification held back, as these tests need.
|
||||
|
||||
The read under test keeps its snapshot open across the restore, so the
|
||||
publish's WAL truncate can never succeed; without the short wait it would
|
||||
sit out the full CHECKPOINT_BUSY_MS (30 s) each time (#487).
|
||||
"""
|
||||
with (
|
||||
patch("database.execution_history.notify_history_restore"),
|
||||
patch.object(restore_stage, "CHECKPOINT_BUSY_MS", 50),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class HistoryAPITests(ImageAPITestCase):
|
||||
async def tearDownAsync(self):
|
||||
if hasattr(self.api, "close_history_reads"):
|
||||
@@ -346,7 +362,7 @@ class HistoryAPITests(ImageAPITestCase):
|
||||
|
||||
def during_snapshot(*args, **kwargs):
|
||||
result = original(*args, **kwargs)
|
||||
with patch("database.execution_history.notify_history_restore"):
|
||||
with no_wal_truncate_wait():
|
||||
writer.model.restore_from_file(source)
|
||||
return result
|
||||
|
||||
@@ -890,7 +906,7 @@ class HistoryAPITests(ImageAPITestCase):
|
||||
|
||||
def during_snapshot(*args, **kwargs):
|
||||
result = original(*args, **kwargs)
|
||||
with patch("database.execution_history.notify_history_restore"):
|
||||
with no_wal_truncate_wait():
|
||||
writer.model.restore_from_file(source)
|
||||
return result
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import contextlib
|
||||
import csv
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
@@ -15,7 +16,18 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
# Run in a child interpreter: import py.autotag with a stub ComfyUI ``server``
|
||||
# and print every ML backend that the import pulled in.
|
||||
_IMPORT_AUTOTAG_REPORT_BACKENDS = """
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
sys.modules["server"] = MagicMock()
|
||||
import py.autotag
|
||||
print(*sorted({"onnxruntime", "llama_cpp"} & set(sys.modules)))
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -142,17 +154,26 @@ class TestModelInjection(EngineTestCase):
|
||||
self.assertEqual(service._model_loader, service._load_model)
|
||||
|
||||
def test_importing_autotag_does_not_import_ml_backends(self):
|
||||
for heavy in ("onnxruntime", "llama_cpp"):
|
||||
self.assertNotIn(heavy, sys.modules)
|
||||
# A fresh interpreter: earlier tests in this process may have imported
|
||||
# an installed backend, so only a clean import can prove this (#472).
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _IMPORT_AUTOTAG_REPORT_BACKENDS],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.split(), [])
|
||||
|
||||
def test_default_wd14_loader_reports_missing_onnxruntime(self):
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
if "onnxruntime" in sys.modules:
|
||||
self.skipTest("onnxruntime is installed in this environment")
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
service.load_model("wd14-vit")
|
||||
# A None entry makes onnxruntime unimportable even where it is installed.
|
||||
with _patch_modules({"onnxruntime": None}):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
service.load_model("wd14-vit")
|
||||
|
||||
self.assertIn("onnxruntime", str(ctx.exception))
|
||||
self.assertFalse(service.is_model_loaded())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""#424: each tagging backend reports the Python packages it cannot import.
|
||||
|
||||
``find_spec`` is patched throughout: no ML backend is installed or imported.
|
||||
``find_spec`` is patched throughout, and a backend an earlier test imported is
|
||||
hidden where it matters, so these pass with or without the ML packages (#472).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -75,8 +76,13 @@ class TestMissingPackages(unittest.TestCase):
|
||||
def test_find_spec_errors_count_as_missing(self):
|
||||
for error in (ValueError("no spec"), ImportError("broken"), OSError("io")):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
with patch.object(
|
||||
autotag.importlib.util, "find_spec", side_effect=error
|
||||
# Hide an onnxruntime an earlier test imported, so the check
|
||||
# reaches find_spec (#472).
|
||||
with (
|
||||
_patch_modules({"onnxruntime": None}),
|
||||
patch.object(
|
||||
autotag.importlib.util, "find_spec", side_effect=error
|
||||
),
|
||||
):
|
||||
self.assertEqual(missing_packages("wd14-vit"), ["onnxruntime"])
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""#516: the browser test server pins glibc's mmap threshold.
|
||||
|
||||
Large frees then go back to the OS instead of fragmenting per-thread arenas.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from tests.browser import malloc_tuning
|
||||
|
||||
|
||||
class FakeLibc:
|
||||
def __init__(self, result=1):
|
||||
self.calls = []
|
||||
self.result = result
|
||||
|
||||
def mallopt(self, option, value):
|
||||
self.calls.append((option, value))
|
||||
return self.result
|
||||
|
||||
|
||||
class PinMmapThresholdTests(unittest.TestCase):
|
||||
def test_sets_a_fixed_threshold_through_mallopt(self):
|
||||
libc = FakeLibc()
|
||||
with mock.patch.object(sys, "platform", "linux"):
|
||||
self.assertTrue(malloc_tuning.pin_mmap_threshold(libc))
|
||||
# M_MMAP_THRESHOLD is -3; setting it also turns off glibc's dynamic
|
||||
# raising of the threshold.
|
||||
self.assertEqual(libc.calls, [(-3, 128 * 1024)])
|
||||
|
||||
def test_reports_a_refused_setting(self):
|
||||
with mock.patch.object(sys, "platform", "linux"):
|
||||
self.assertFalse(malloc_tuning.pin_mmap_threshold(FakeLibc(result=0)))
|
||||
|
||||
def test_skips_a_libc_without_mallopt(self):
|
||||
with mock.patch.object(sys, "platform", "linux"):
|
||||
self.assertFalse(malloc_tuning.pin_mmap_threshold(object()))
|
||||
|
||||
def test_skips_other_platforms_without_loading_a_libc(self):
|
||||
for name in ("win32", "darwin"):
|
||||
with (
|
||||
self.subTest(name),
|
||||
mock.patch.object(sys, "platform", name),
|
||||
mock.patch.object(
|
||||
ctypes, "CDLL", side_effect=AssertionError("no libc load")
|
||||
),
|
||||
):
|
||||
self.assertFalse(malloc_tuning.pin_mmap_threshold())
|
||||
|
||||
def test_skips_when_the_process_libc_cannot_be_loaded(self):
|
||||
with (
|
||||
mock.patch.object(sys, "platform", "linux"),
|
||||
mock.patch.object(ctypes, "CDLL", side_effect=OSError("no libc")),
|
||||
):
|
||||
self.assertFalse(malloc_tuning.pin_mmap_threshold())
|
||||
|
||||
@unittest.skipUnless(
|
||||
sys.platform.startswith("linux")
|
||||
and hasattr(ctypes.CDLL(None), "gnu_get_libc_version"),
|
||||
"glibc only",
|
||||
)
|
||||
def test_glibc_accepts_the_setting(self):
|
||||
# This pins the threshold for the rest of this test process too; the
|
||||
# test server does the same at start, so nothing here depends on it.
|
||||
self.assertTrue(malloc_tuning.pin_mmap_threshold())
|
||||
|
||||
|
||||
class ServerMainTests(unittest.TestCase):
|
||||
def test_main_pins_the_threshold_before_it_starts_serving(self):
|
||||
from tests.browser import server
|
||||
|
||||
order = []
|
||||
|
||||
def bind(port):
|
||||
order.append("bind")
|
||||
raise RuntimeError("stop before serving")
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
server, "pin_mmap_threshold", side_effect=lambda: order.append("pin")
|
||||
),
|
||||
mock.patch.object(server, "bind_listener", side_effect=bind),
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
server.main(["--port", "0"])
|
||||
self.assertEqual(order, ["pin", "bind"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,728 @@
|
||||
"""#443 (D152): delete selected Library prompts; paired ones are archived.
|
||||
|
||||
The DB commands, and what every reader does with an archived parent.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
from database.library_commands import LibraryCommandError
|
||||
from database.library_folders import folder_prefixes
|
||||
from database.operations import PromptDatabase
|
||||
from database.prompt_archive import TABLE as ARCHIVE
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def save(db, text, **metadata):
|
||||
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
|
||||
|
||||
|
||||
def rows(db, sql, params=()):
|
||||
with db.model.get_connection() as conn:
|
||||
return [tuple(row) for row in conn.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def archived_ids(db):
|
||||
return sorted(row[0] for row in rows(db, f"SELECT prompt_id FROM {ARCHIVE}"))
|
||||
|
||||
|
||||
def prompt_ids(db):
|
||||
return sorted(row[0] for row in rows(db, "SELECT id FROM prompts"))
|
||||
|
||||
|
||||
_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")
|
||||
|
||||
|
||||
def _stable(value):
|
||||
return "<time>" if isinstance(value, str) and _STAMP.match(value) else value
|
||||
|
||||
|
||||
def table_dump(db):
|
||||
"""Every row of every table (times masked), to compare two databases."""
|
||||
tables = [
|
||||
row[0]
|
||||
for row in rows(
|
||||
db,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name NOT LIKE 'sqlite_%' AND name!='execution_history_metadata' "
|
||||
"ORDER BY name",
|
||||
)
|
||||
]
|
||||
dump = {}
|
||||
for table in tables:
|
||||
dump[table] = sorted(
|
||||
(tuple(map(_stable, row)) for row in rows(db, f"SELECT * FROM {table}")),
|
||||
key=repr,
|
||||
)
|
||||
return dump
|
||||
|
||||
|
||||
class LibraryDeleteCase(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
# Cleanups run last-in first-out and after every cleanup a test adds,
|
||||
# so a database a test opens (and closes via addCleanup) is closed
|
||||
# before the folder goes; Windows cannot delete an open file (#510).
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.db = PromptDatabase(str(self.root / "delete.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.epoch = self.db.get_library_options()["library_epoch"]
|
||||
|
||||
def paired(self, positive, negative="blur"):
|
||||
pair = self.db.save_prompt_pair(positive, negative)
|
||||
found = self.db.get_prompt_pair(pair)
|
||||
return found["positive_prompt_id"], found["negative_prompt_id"], pair
|
||||
|
||||
def image(self, prompt_id, name):
|
||||
path = self.root / name
|
||||
path.write_bytes(b"png")
|
||||
return self.db.link_image_to_prompt(prompt_id, str(path))
|
||||
|
||||
def apply(self, ids, epoch=None, review=None):
|
||||
"""Review ``ids`` (or use ``review``), then write what was reviewed."""
|
||||
review = review or self.classify(ids)
|
||||
return self.db.archive_or_delete_library_prompts(
|
||||
review["archive"],
|
||||
review["delete"],
|
||||
expected_epoch=epoch or self.epoch,
|
||||
candidates=review["fingerprints"],
|
||||
)
|
||||
|
||||
def classify(self, ids, epoch=None):
|
||||
return self.db.classify_library_delete(ids, expected_epoch=epoch or self.epoch)
|
||||
|
||||
def restore(self, ids, epoch=None):
|
||||
return self.db.restore_library_prompts(ids, expected_epoch=epoch or self.epoch)
|
||||
|
||||
|
||||
class TestClassifyAndApply(LibraryDeleteCase):
|
||||
def test_each_class_in_order(self):
|
||||
plain = save(self.db, "Plain")
|
||||
protected = save(self.db, "Guarded", tags=["__protected__"])
|
||||
protected_paired, _neg, _pair = self.paired("Guarded pair")
|
||||
self.db.bulk_add_tags([protected_paired], ["__protected__"])
|
||||
positive, negative_only, _pair = self.paired("Cat", "only negative")
|
||||
both = save(self.db, "Both roles")
|
||||
self.image(both, "both.png") # a parent through its image
|
||||
self.db.save_prompt_pair("Other", "Both roles")
|
||||
archived_pair_positive, _n, old_pair = self.paired("Old")
|
||||
self.db.archive_prompt_pair(old_pair, True)
|
||||
already, _n2, _p2 = self.paired("Already")
|
||||
self.apply([already])
|
||||
self.image(plain, "plain.png")
|
||||
ids = [
|
||||
plain,
|
||||
protected,
|
||||
protected_paired,
|
||||
positive,
|
||||
negative_only,
|
||||
both,
|
||||
archived_pair_positive,
|
||||
already,
|
||||
99999,
|
||||
]
|
||||
result = self.classify(ids)
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"delete": [plain],
|
||||
"archive": [positive, both, archived_pair_positive],
|
||||
"protected": [protected, protected_paired],
|
||||
"already_archived": [already],
|
||||
"not_found": [negative_only, 99999],
|
||||
"image_links": 1,
|
||||
"fingerprints": result["fingerprints"],
|
||||
# #482: the archive ids Delete permanently could remove (with pairs).
|
||||
"deletable": [positive, archived_pair_positive],
|
||||
"review_digests": result["review_digests"],
|
||||
"paired_counts": {
|
||||
"pairs": 2,
|
||||
"archived_pairs": 1,
|
||||
"variants": 2,
|
||||
# "only negative" is itself selected; only "blur" is kept.
|
||||
"kept_negatives": 1,
|
||||
},
|
||||
"paired_image_links": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(list(result["fingerprints"]), [plain])
|
||||
self.assertEqual(
|
||||
list(result["review_digests"]), [positive, archived_pair_positive]
|
||||
)
|
||||
|
||||
def test_apply_archives_paired_deletes_unpaired_and_skips_protected(self):
|
||||
plain = save(self.db, "Plain", tags=["red"])
|
||||
protected = save(self.db, "Guarded", tags=["__protected__"])
|
||||
positive, negative, pair = self.paired("Cat")
|
||||
self.image(plain, "a.png")
|
||||
self.image(positive, "b.png")
|
||||
before = rows(self.db, "SELECT updated_at FROM prompts WHERE id=?", (positive,))
|
||||
result = self.apply([plain, protected, positive, 99999])
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"deleted": 1,
|
||||
"archived": 1,
|
||||
"already_archived": 0,
|
||||
"not_found": 0,
|
||||
"changed": 0,
|
||||
"image_links": 1,
|
||||
"pairs_removed": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(prompt_ids(self.db), sorted([protected, positive, negative]))
|
||||
self.assertEqual(archived_ids(self.db), [positive])
|
||||
# Archive keeps everything: the pair, its image link and the prompt row.
|
||||
self.assertEqual(
|
||||
len(rows(self.db, "SELECT 1 FROM prompt_pairs WHERE id=?", (pair,))), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
len(
|
||||
rows(
|
||||
self.db,
|
||||
"SELECT 1 FROM generated_images WHERE prompt_id=?",
|
||||
(positive,),
|
||||
)
|
||||
),
|
||||
1,
|
||||
)
|
||||
after = rows(self.db, "SELECT updated_at FROM prompts WHERE id=?", (positive,))
|
||||
self.assertNotEqual(before, after)
|
||||
# The image file stays on disk.
|
||||
self.assertTrue((self.root / "a.png").exists())
|
||||
|
||||
def test_delete_has_exactly_the_legacy_row_effects(self):
|
||||
twin = PromptDatabase(str(self.root / "twin.db"))
|
||||
self.addCleanup(twin.close_all)
|
||||
twin_epoch = twin.get_library_options()["library_epoch"]
|
||||
for db in (self.db, twin):
|
||||
a = save(db, "Alpha", tags=["x", "y"], category="c")
|
||||
save(db, "Beta")
|
||||
path = self.root / "shared.png"
|
||||
path.write_bytes(b"png")
|
||||
db.link_image_to_prompt(a, str(path), {"workflow": {"nodes": []}})
|
||||
self.db.bulk_delete_prompts([a])
|
||||
twin.archive_or_delete_library_prompts(
|
||||
[], [a], expected_epoch=twin_epoch, candidates=None
|
||||
)
|
||||
self.assertEqual(table_dump(self.db), table_dump(twin))
|
||||
|
||||
def test_already_archived_and_restore(self):
|
||||
positive, _neg, _pair = self.paired("Cat")
|
||||
self.apply([positive])
|
||||
again = self.classify([positive])
|
||||
self.assertEqual(
|
||||
(again["archive"], again["already_archived"]), ([], [positive])
|
||||
)
|
||||
# Archived between the review and the write: counted, not archived twice.
|
||||
other, _n, _p = self.paired("Dog")
|
||||
review = self.classify([other])
|
||||
self.apply([other])
|
||||
late = self.apply([], review=review)
|
||||
self.assertEqual((late["archived"], late["already_archived"]), (0, 1))
|
||||
plain = save(self.db, "Plain")
|
||||
result = self.restore([positive, plain, 99999])
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"restored": 1,
|
||||
"unchanged": 1,
|
||||
"not_found": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(archived_ids(self.db), [other])
|
||||
|
||||
def test_the_write_executes_only_what_was_reviewed(self):
|
||||
plain = save(self.db, "Plain")
|
||||
guarded = save(self.db, "Guarded later")
|
||||
touched = save(self.db, "Touched later")
|
||||
review = self.classify([plain, guarded, touched])
|
||||
# Between the review and the write: one becomes protected, one is used.
|
||||
self.db.bulk_add_tags([guarded], ["__protected__"])
|
||||
self.db.record_prompt_use(touched)
|
||||
result = self.apply([], review=review)
|
||||
self.assertEqual((result["deleted"], result["changed"]), (1, 2))
|
||||
self.assertEqual(prompt_ids(self.db), sorted([guarded, touched]))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_a_new_image_link_after_the_review_keeps_the_prompt(self):
|
||||
plain = save(self.db, "Plain")
|
||||
review = self.classify([plain])
|
||||
self.image(plain, "late.png")
|
||||
self.assertEqual(self.apply([], review=review)["changed"], 1)
|
||||
self.assertIn(plain, prompt_ids(self.db))
|
||||
|
||||
def test_reviewed_deletes_without_a_backup_are_kept(self):
|
||||
plain = save(self.db, "Plain")
|
||||
review = self.classify([plain])
|
||||
result = self.db.archive_or_delete_library_prompts(
|
||||
[], [plain], expected_epoch=self.epoch, candidates={}
|
||||
)
|
||||
self.assertEqual((result["deleted"], result["changed"]), (0, 1))
|
||||
self.assertEqual(review["delete"], [plain])
|
||||
|
||||
def test_a_reviewed_delete_that_became_paired_is_archived(self):
|
||||
plain = save(self.db, "Plain")
|
||||
review = self.classify([plain])
|
||||
self.db.save_prompt_pair("Plain", "late negative")
|
||||
result = self.apply([], review=review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
|
||||
|
||||
def test_a_reviewed_archive_that_lost_its_pair_is_archived_not_deleted(self):
|
||||
positive, _negative, pair = self.paired("Cat")
|
||||
review = self.classify([positive])
|
||||
self.db.delete_prompt_pair(pair)
|
||||
result = self.apply([], review=review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
|
||||
self.assertEqual(archived_ids(self.db), [positive])
|
||||
|
||||
def test_one_id_in_both_lists_or_no_ids_is_refused(self):
|
||||
plain = save(self.db, "Plain")
|
||||
for archive, delete in (([plain], [plain]), ([], [])):
|
||||
with self.subTest(archive=archive, delete=delete):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
archive, delete, expected_epoch=self.epoch, candidates=None
|
||||
)
|
||||
|
||||
def test_archiving_repairs_a_missing_or_stale_hash(self):
|
||||
missing, _n1, _p1 = self.paired("Hashless")
|
||||
stale, _n2, _p2 = self.paired("Stale hash")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET hash=NULL WHERE id=?", (missing,))
|
||||
conn.execute("UPDATE prompts SET hash='stale' WHERE id=?", (stale,))
|
||||
self.apply([missing, stale])
|
||||
for pid, text in ((missing, "Hashless"), (stale, "Stale hash")):
|
||||
self.assertEqual(
|
||||
rows(self.db, "SELECT hash FROM prompts WHERE id=?", (pid,)),
|
||||
[(generate_prompt_hash(text),)],
|
||||
)
|
||||
# So every ingestion lookup finds the archived row.
|
||||
self.assertEqual(save(self.db, text), pid)
|
||||
|
||||
def test_a_canonical_hash_another_row_holds_is_left_alone(self):
|
||||
stale, _n, _p = self.paired("Twin text")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET hash='stale' WHERE id=?", (stale,))
|
||||
twin = save(self.db, "twin text")
|
||||
self.apply([stale])
|
||||
self.assertEqual(
|
||||
rows(self.db, "SELECT hash FROM prompts WHERE id=?", (stale,)), [("stale",)]
|
||||
)
|
||||
self.assertEqual(save(self.db, "Twin text"), twin)
|
||||
|
||||
def test_restore_unhides_protected_and_non_parent_archived_prompts(self):
|
||||
guarded, _n, _p = self.paired("Guarded")
|
||||
self.apply([guarded])
|
||||
self.db.bulk_add_tags([guarded], ["__protected__"])
|
||||
negative_only = save(self.db, "Only a negative")
|
||||
self.db.save_prompt_pair("Owner", "Only a negative")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {ARCHIVE} VALUES (?, '2026-10-28T00:00:00+00:00')",
|
||||
(negative_only,),
|
||||
)
|
||||
result = self.restore([guarded, negative_only])
|
||||
self.assertEqual(result["restored"], 2)
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_restored_storage_changes_nothing(self):
|
||||
plain = save(self.db, "Plain")
|
||||
positive, _neg, _pair = self.paired("Cat")
|
||||
rotate_history_epoch(self.db.model.get_connection())
|
||||
for call in (
|
||||
lambda: self.apply([plain, positive]),
|
||||
lambda: self.classify([plain]),
|
||||
lambda: self.restore([positive]),
|
||||
):
|
||||
with self.assertRaises(LibraryCommandError) as caught:
|
||||
call()
|
||||
self.assertEqual(caught.exception.code, "storage-changed")
|
||||
self.assertIn(plain, prompt_ids(self.db))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_a_failure_mid_write_rolls_everything_back(self):
|
||||
plain = save(self.db, "Plain")
|
||||
positive, _neg, _pair = self.paired("Cat")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"CREATE TRIGGER fail_delete BEFORE DELETE ON prompts "
|
||||
"BEGIN SELECT RAISE(ABORT, 'boom'); END"
|
||||
)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.apply([positive, plain])
|
||||
self.assertIn(plain, prompt_ids(self.db))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_many_ids_are_handled_in_batches(self):
|
||||
ids = [save(self.db, f"Many {i}") for i in range(1000)]
|
||||
result = self.apply(ids)
|
||||
self.assertEqual(result["deleted"], 1000)
|
||||
|
||||
def test_rejects_invalid_arguments(self):
|
||||
plain = save(self.db, "Plain")
|
||||
for ids, epoch in (([], self.epoch), ([True], self.epoch), ([plain], "x")):
|
||||
for call in (self.apply, self.classify, self.restore):
|
||||
with self.subTest(ids=ids, epoch=epoch, call=call.__name__):
|
||||
with self.assertRaises(ValueError):
|
||||
call(ids, epoch)
|
||||
|
||||
|
||||
class TestArchivedReaders(LibraryDeleteCase):
|
||||
"""What each reader does with an archived parent (design §4.4)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.kept, _kn, _kp = self.paired("Kept cat", "kept blur")
|
||||
self.gone, _gn, _gp = self.paired("Gone cat", "gone blur")
|
||||
for pid, category, tag in ((self.kept, "keep", "k"), (self.gone, "gone", "g")):
|
||||
self.db.update_prompt_metadata(pid, category=category, tags=[tag])
|
||||
self.apply([self.gone])
|
||||
|
||||
def ids_of(self, page):
|
||||
return [item["id"] for item in page["items"]]
|
||||
|
||||
def test_listing_search_and_totals(self):
|
||||
active = self.db.list_library()
|
||||
self.assertEqual(self.ids_of(active), [self.kept])
|
||||
self.assertEqual(
|
||||
(active["total"], active["variant_total"], active["archived_total"]),
|
||||
(1, 1, 1),
|
||||
)
|
||||
archived = self.db.list_library(shelf="archived")
|
||||
self.assertEqual(self.ids_of(archived), [self.gone])
|
||||
self.assertEqual((archived["total"], archived["variant_total"]), (1, 1))
|
||||
self.assertEqual(self.ids_of(self.db.list_library(text="Gone")), [])
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(text="Gone", shelf="archived")),
|
||||
[self.gone],
|
||||
)
|
||||
self.assertEqual(self.ids_of(self.db.list_library(tags=["g"])), [])
|
||||
self.assertEqual(self.db.list_library(category="gone")["archived_total"], 1)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.list_library(shelf="all")
|
||||
|
||||
def test_the_444_filters_and_tag_counts_hide_it(self):
|
||||
# #444's folder filter, tag match and tag ranking keep #443's shelves.
|
||||
bare, _bn, _bp = self.paired("Bare cat", "bare blur")
|
||||
self.apply([bare])
|
||||
folder = self.root / "out" / "cats"
|
||||
folder.mkdir(parents=True)
|
||||
for pid, name in ((self.kept, "kept.png"), (self.gone, "gone.png")):
|
||||
self.image(pid, str(Path("out") / "cats" / name))
|
||||
prefixes = folder_prefixes([str(self.root / "out")], "cats")
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(folder_prefixes=prefixes)), [self.kept]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.ids_of(
|
||||
self.db.list_library(folder_prefixes=prefixes, shelf="archived")
|
||||
),
|
||||
[self.gone],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(tags=["k", "g"], tag_mode="any")),
|
||||
[self.kept],
|
||||
)
|
||||
self.assertEqual(self.ids_of(self.db.list_library(tag_mode="untagged")), [])
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(tag_mode="untagged", shelf="archived")),
|
||||
[bare],
|
||||
)
|
||||
tags = self.db.list_vocabulary_tags(sort="prompts")
|
||||
self.assertEqual(
|
||||
{row["name"]: row["prompt_count"] for row in tags["tags"]},
|
||||
{"k": 1, "g": 0},
|
||||
)
|
||||
self.assertEqual(tags["untagged_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.list_vocabulary_tags(search="g", sort="prompts")["tags"][0][
|
||||
"prompt_count"
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
def test_detail_and_options(self):
|
||||
self.assertTrue(self.db.get_library_positive(self.gone)["positive"]["archived"])
|
||||
self.assertFalse(
|
||||
self.db.get_library_positive(self.kept)["positive"]["archived"]
|
||||
)
|
||||
# Suggestions keep archived parents' categories: the archived view
|
||||
# filters by them.
|
||||
self.assertEqual(self.db.get_library_options()["categories"], ["gone", "keep"])
|
||||
|
||||
def test_counts(self):
|
||||
self.assertEqual(self.db.library_counts()["positive_prompts"], 1)
|
||||
self.assertEqual(self.db.library_counts()["negative_variants"], 1)
|
||||
tags = {
|
||||
row["name"]: row["prompt_count"]
|
||||
for row in self.db.list_vocabulary_tags()["tags"]
|
||||
}
|
||||
self.assertEqual((tags["k"], tags["g"]), (1, 0))
|
||||
categories = {
|
||||
row["value"]: row["prompt_count"]
|
||||
for row in self.db.list_vocabulary_categories()["categories"]
|
||||
}
|
||||
# A value only archived parents hold stays listed with 0, as a tag does.
|
||||
self.assertEqual(categories, {"keep": 1, "gone": 0})
|
||||
|
||||
def test_restore_summary_and_export(self):
|
||||
from database.restore_stage import library_summary
|
||||
|
||||
summary = library_summary(self.db.model.get_connection())
|
||||
self.assertEqual(summary["positives"], 1)
|
||||
exported = {
|
||||
item["positive_text"]: item["archived"]
|
||||
for item in self.db.export_library()["positives"]
|
||||
}
|
||||
self.assertEqual(exported, {"Kept cat": False, "Gone cat": True})
|
||||
|
||||
def test_usage_statistics_keep_archived_runs(self):
|
||||
self.db.record_prompt_use(self.gone, 3)
|
||||
runs = {row["category"]: row["runs"] for row in self.db.usage_by_category()}
|
||||
self.assertEqual(runs["gone"], 3)
|
||||
|
||||
def test_runs_node_saves_and_new_prompt_find_the_archived_row(self):
|
||||
count = len(prompt_ids(self.db))
|
||||
self.assertTrue(self.db.record_prompt_use(self.gone, 2))
|
||||
existing = self.db.get_prompt_by_hash(generate_prompt_hash("Gone cat"))
|
||||
self.assertEqual(existing["id"], self.gone)
|
||||
self.assertEqual(save(self.db, "Gone cat"), self.gone)
|
||||
self.db.save_prompt_pair("Gone cat", "new blur")
|
||||
self.assertEqual(len(prompt_ids(self.db)), count + 1) # only the new negative
|
||||
self.assertEqual(archived_ids(self.db), [self.gone])
|
||||
|
||||
def test_legacy_reads_still_list_it_and_legacy_delete_still_refuses(self):
|
||||
from database.pairs import PairDependencyError
|
||||
|
||||
texts = [p["text"] for p in self.db.get_recent_prompts(limit=50)["prompts"]]
|
||||
self.assertIn("Gone cat", texts)
|
||||
with self.assertRaises(PairDependencyError):
|
||||
self.db.bulk_delete_prompts([self.gone])
|
||||
|
||||
def test_as_another_parents_negative_it_stays_a_visible_variant(self):
|
||||
owner, _neg, pair = self.paired("Owner cat", "Gone cat")
|
||||
detail = self.db.get_library_positive(owner)
|
||||
self.assertEqual([v["id"] for v in detail["variants"]], [pair])
|
||||
self.assertEqual(self.ids_of(self.db.list_library(text="Gone")), [owner])
|
||||
|
||||
def test_maintenance_never_prunes_an_archived_prompt(self):
|
||||
lonely = save(self.db, "Lonely")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {ARCHIVE} VALUES (?, '2026-10-28T00:00:00+00:00')",
|
||||
(lonely,),
|
||||
)
|
||||
orphaned = [row[0] for row in conn.execute(self.orphaned_sql()).fetchall()]
|
||||
self.assertNotIn(lonely, orphaned)
|
||||
|
||||
@staticmethod
|
||||
def orphaned_sql():
|
||||
from database.maintenance import ORPHANED_PROMPTS_SQL
|
||||
|
||||
return ORPHANED_PROMPTS_SQL
|
||||
|
||||
def test_bulk_metadata_writes_still_reach_an_archived_parent(self):
|
||||
result = self.db.bulk_set_library_category(
|
||||
[self.gone], "moved", expected_epoch=self.epoch
|
||||
)
|
||||
self.assertEqual(result["updated"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestPairedDelete(LibraryDeleteCase):
|
||||
"""#482 (D160): Delete permanently also removes a prompt's own saved pairs."""
|
||||
|
||||
def pair_rows(self, positive):
|
||||
return rows(
|
||||
self.db,
|
||||
"SELECT id FROM prompt_pairs WHERE positive_prompt_id=? ORDER BY id",
|
||||
(positive,),
|
||||
)
|
||||
|
||||
def pair_link(self, pair, image):
|
||||
return self.db.link_image_to_pair(pair, image, {"resolver_version": 1})
|
||||
|
||||
def delete_paired(self, ids, review, archive=None, epoch=None):
|
||||
"""Write ``ids`` as Delete permanently, with the digests ``review`` gave."""
|
||||
archive = review["archive"] if archive is None else archive
|
||||
return self.db.archive_or_delete_library_prompts(
|
||||
archive,
|
||||
review["delete"],
|
||||
expected_epoch=epoch or self.epoch,
|
||||
candidates=review["fingerprints"],
|
||||
delete_paired={pid: review["review_digests"][pid] for pid in ids},
|
||||
)
|
||||
|
||||
def test_classify_marks_deletable_and_archive_only_with_pair_counts(self):
|
||||
cat, cat_negative, _pair = self.paired("Cat", "blur")
|
||||
self.db.save_prompt_pair("Cat", "grain")
|
||||
archived_pair = self.db.save_prompt_pair("Cat", "noise")
|
||||
self.db.archive_prompt_pair(archived_pair, True)
|
||||
shared = save(self.db, "Shared")
|
||||
self.image(shared, "shared.png")
|
||||
self.db.save_prompt_pair("Dog", "Shared") # used as another positive's negative
|
||||
review = self.classify([cat, shared])
|
||||
self.assertEqual(review["archive"], [cat, shared])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
self.assertEqual(list(review["review_digests"]), [cat])
|
||||
self.assertRegex(review["review_digests"][cat], r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 3, "archived_pairs": 1, "variants": 3, "kept_negatives": 3},
|
||||
)
|
||||
self.assertEqual(review["paired_image_links"], 0)
|
||||
|
||||
def test_an_empty_negative_pair_is_deleted_with_its_prompt(self):
|
||||
# Live prompt 7896: an auto-created pair with an explicitly empty negative.
|
||||
pair = self.db.save_prompt_pair("été", "")
|
||||
prompt = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
review = self.classify([prompt])
|
||||
self.assertEqual(review["deletable"], [prompt])
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 1, "archived_pairs": 0, "variants": 0, "kept_negatives": 0},
|
||||
)
|
||||
result = self.delete_paired([prompt], review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (1, 0))
|
||||
self.assertEqual(result["pairs_removed"], 1)
|
||||
self.assertNotIn(prompt, prompt_ids(self.db))
|
||||
self.assertEqual(self.pair_rows(prompt), [])
|
||||
self.assertEqual(rows(self.db, "PRAGMA foreign_key_check"), [])
|
||||
|
||||
def test_rows_removed_are_the_legacy_pair_deletes_then_the_prompt_delete(self):
|
||||
twin = PromptDatabase(str(self.root / "twin.db"))
|
||||
self.addCleanup(twin.close_all)
|
||||
twin_epoch = twin.get_library_options()["library_epoch"]
|
||||
made = {}
|
||||
for db in (self.db, twin):
|
||||
plain = db.save_prompt_pair("Owl", "", tags=["night"], title="Owl")
|
||||
with_negative = db.save_prompt_pair("Owl", "dark", rating=4)
|
||||
hidden = db.save_prompt_pair("Owl", "fog")
|
||||
db.archive_prompt_pair(hidden, True)
|
||||
owl = db.get_prompt_pair(plain)["positive_prompt_id"]
|
||||
path = self.root / "owl.png"
|
||||
path.write_bytes(b"png")
|
||||
image = db.link_image_to_prompt(owl, str(path), {"workflow": {"nodes": []}})
|
||||
db.link_image_to_pair(with_negative, image, {"resolver_version": 1})
|
||||
save(db, "Bystander", tags=["night"])
|
||||
made[id(db)] = (owl, [plain, with_negative, hidden])
|
||||
owl, pairs = made[id(self.db)]
|
||||
review = self.classify([owl])
|
||||
self.delete_paired([owl], review)
|
||||
twin_owl, twin_pairs = made[id(twin)]
|
||||
with twin.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM image_prompt_pairs WHERE pair_id IN (SELECT id FROM "
|
||||
"prompt_pairs WHERE positive_prompt_id=?)",
|
||||
(twin_owl,),
|
||||
)
|
||||
conn.commit()
|
||||
for pair in twin_pairs:
|
||||
self.assertTrue(twin.delete_prompt_pair(pair))
|
||||
twin.archive_or_delete_library_prompts(
|
||||
[], [twin_owl], expected_epoch=twin_epoch, candidates=None
|
||||
)
|
||||
self.assertEqual(table_dump(self.db), table_dump(twin))
|
||||
# The negatives stay, and the image file stays on disk.
|
||||
self.assertTrue((self.root / "owl.png").exists())
|
||||
texts = [row[0] for row in rows(self.db, "SELECT text FROM prompts")]
|
||||
self.assertTrue({"dark", "fog", "Bystander"} <= set(texts))
|
||||
|
||||
def test_a_negative_used_by_another_positive_is_archived_never_deleted(self):
|
||||
shared = save(self.db, "Shared")
|
||||
self.image(shared, "s.png")
|
||||
self.db.save_prompt_pair("Dog", "Shared")
|
||||
review = self.classify([shared])
|
||||
self.assertEqual(review["deletable"], [])
|
||||
result = self.db.archive_or_delete_library_prompts(
|
||||
[shared],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={shared: "0" * 64},
|
||||
)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
|
||||
self.assertEqual(result["changed"], 1)
|
||||
self.assertEqual(archived_ids(self.db), [shared])
|
||||
|
||||
def test_a_positive_and_its_own_negative_selected_together(self):
|
||||
cat, blur, _pair = self.paired("Cat", "blur")
|
||||
self.image(blur, "blur.png") # the negative is a Library prompt too
|
||||
review = self.classify([cat, blur])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
result = self.delete_paired([cat], review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (1, 1))
|
||||
self.assertEqual(archived_ids(self.db), [blur])
|
||||
self.assertNotIn(cat, prompt_ids(self.db))
|
||||
|
||||
def test_a_self_pair_is_deleted(self):
|
||||
pair = self.db.save_prompt_pair("Same", "Same")
|
||||
same = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
review = self.classify([same])
|
||||
self.assertEqual(review["deletable"], [same])
|
||||
self.assertEqual(review["paired_counts"]["kept_negatives"], 0)
|
||||
self.assertEqual(self.delete_paired([same], review)["deleted"], 1)
|
||||
self.assertNotIn(same, prompt_ids(self.db))
|
||||
|
||||
def test_drift_after_the_review_archives_and_counts_changed(self):
|
||||
cases = {
|
||||
"pair added": lambda p: self.db.save_prompt_pair("Fox", "new"),
|
||||
"pair edited": lambda p: self.db.update_prompt_pair_metadata(
|
||||
self.pair_rows(p)[0][0], title="Renamed"
|
||||
),
|
||||
"pairs gone": lambda p: self.db.delete_prompt_pair(self.pair_rows(p)[0][0]),
|
||||
"now another's negative": lambda p: self.db.save_prompt_pair("Hen", "Fox"),
|
||||
}
|
||||
for name, change in cases.items():
|
||||
with self.subTest(name):
|
||||
fox, _neg, _pair = self.paired("Fox", "mist " + name)
|
||||
review = self.classify([fox])
|
||||
self.assertEqual(review["deletable"], [fox])
|
||||
change(fox)
|
||||
result = self.delete_paired([fox], review)
|
||||
self.assertEqual(
|
||||
(result["deleted"], result["archived"], result["changed"]),
|
||||
(0, 1, 1),
|
||||
)
|
||||
self.assertIn(fox, archived_ids(self.db))
|
||||
self.restore([fox])
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM prompt_pairs WHERE positive_prompt_id IN "
|
||||
"(SELECT id FROM prompts WHERE text IN ('Hen','Fox'))"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def test_delete_paired_must_name_reviewed_archive_ids(self):
|
||||
fox, _neg, _pair = self.paired("Fox")
|
||||
review = self.classify([fox])
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
[],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={fox: review["review_digests"][fox]},
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
[fox],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={fox: "not a digest"},
|
||||
)
|
||||
@@ -7,6 +7,7 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from database import restore_stage
|
||||
from database.execution_history import HistoryEpochChanged, notify_history_restore
|
||||
from tests import test_execution_history_database as history_tests
|
||||
|
||||
@@ -41,7 +42,12 @@ class HistoryReadFenceTests(unittest.TestCase):
|
||||
generation = fence.begin()
|
||||
with reader.recorded_history_snapshot() as epoch:
|
||||
original = reader.get_history_run(start.run_id)
|
||||
with patch("database.execution_history.notify_history_restore"):
|
||||
# The open snapshot keeps the WAL from being truncated; do not
|
||||
# sit out the full 30 s checkpoint wait for it (#487).
|
||||
with (
|
||||
patch("database.execution_history.notify_history_restore"),
|
||||
patch.object(restore_stage, "CHECKPOINT_BUSY_MS", 50),
|
||||
):
|
||||
writer.model.restore_from_file(source)
|
||||
self.assertEqual(reader.get_execution_epoch(), epoch)
|
||||
self.assertEqual(original["run"]["storage_epoch"], epoch)
|
||||
|
||||
@@ -400,6 +400,32 @@ class HistoryReadTests(unittest.TestCase):
|
||||
pass
|
||||
self.assertFalse(conn.in_transaction)
|
||||
|
||||
def test_a_deleted_negative_is_unavailable_and_a_negative_only_one_is_not(self):
|
||||
# #443: a Library delete can remove an unpaired prompt a run used as
|
||||
# its negative. The run's prompt ids are live hints, so a deleted one
|
||||
# reads unavailable; a negative-only prompt stays available.
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
self.saved(db, "negative", 2)
|
||||
start = self.capture(db)
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertTrue(context["navigation"]["negative_available"])
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(9,1,2)"
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertTrue(context["navigation"]["negative_available"])
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM prompt_pairs WHERE id=9")
|
||||
conn.execute("DELETE FROM prompts WHERE id=2")
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertFalse(context["navigation"]["negative_available"])
|
||||
|
||||
def test_detail_preserves_full_text_snapshots_hints_and_archive(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Delete selected images: the review and the apply job (#480).
|
||||
|
||||
``POST /prompt_manager/images/delete/preview`` reviews both modes;
|
||||
``POST /prompt_manager/maintenance/apply {token, mode}`` removes the files'
|
||||
records (library mode, with a removal mark) or moves the files to the trash
|
||||
first (trash mode, the fake trash here). No path unlinks a media file.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests import fake_trash
|
||||
from tests.disk_waits import DISK_BOUND_SECONDS
|
||||
from tests.test_api_duplicate_files import DuplicateFilesTestCase
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
import fake_trash
|
||||
from disk_waits import DISK_BOUND_SECONDS
|
||||
from test_api_duplicate_files import DuplicateFilesTestCase
|
||||
|
||||
from py.api import image_delete
|
||||
from py.api.image_delete import file_check, lexical_problem
|
||||
|
||||
PREVIEW = "/prompt_manager/images/delete/preview"
|
||||
APPLY = "/prompt_manager/maintenance/apply"
|
||||
LISTING = "/prompt_manager/images/output"
|
||||
|
||||
|
||||
class ImageDeleteTestCase(DuplicateFilesTestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
self.fake = fake_trash.install(Path(self.tmpdir) / "fake-trash")
|
||||
self.addCleanup(fake_trash.uninstall)
|
||||
return app
|
||||
|
||||
def target(self, path, index=0):
|
||||
roots = self.api._get_all_output_dirs()
|
||||
return {
|
||||
"root_index": index,
|
||||
"root_scope": self.api._gallery_root_scope(roots[index], index),
|
||||
"path": path,
|
||||
}
|
||||
|
||||
async def review(self, paths, status=200):
|
||||
items = [p if isinstance(p, dict) else self.target(p) for p in paths]
|
||||
return await self.post(PREVIEW, {"items": items}, status)
|
||||
|
||||
async def delete(self, paths, mode):
|
||||
plan = await self.review(paths)
|
||||
body = await self.post(APPLY, {"token": plan["token"], "mode": mode}, 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
|
||||
return plan, job
|
||||
|
||||
async def listed(self):
|
||||
response = await self.client.get(LISTING, params={"mode": "workspace"})
|
||||
page = await response.json()
|
||||
return [image["path"] for image in page["images"]]
|
||||
|
||||
def link(self, name, text="a prompt"):
|
||||
prompt_id = self.save(text)
|
||||
return self.api.db.link_image_to_prompt(
|
||||
str(prompt_id), str(self.output_dir / name)
|
||||
)
|
||||
|
||||
def count(self, sql):
|
||||
conn = sqlite3.connect(self.api.db.model.db_path)
|
||||
try:
|
||||
return conn.execute(sql).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def details(job, group):
|
||||
return [row["detail"] for row in job.detail()["outcomes"][group]]
|
||||
|
||||
|
||||
class TestReview(ImageDeleteTestCase):
|
||||
async def test_review_counts_records_and_refuses_what_it_cannot_delete(self):
|
||||
self.write("a.png", b"aaa", mtime=1_000_000)
|
||||
self.write("b.png", b"bbb", mtime=2_000_000)
|
||||
self.link("a.png", "only a")
|
||||
self.fake.refuse[str(self.output_dir / "b.png")] = "no-trash"
|
||||
plan = await self.review(["a.png", "b.png", "missing.png"])
|
||||
self.assertEqual(plan["library"], {"count": 2})
|
||||
self.assertEqual(plan["trash"], {"count": 1})
|
||||
self.assertEqual(plan["refused"]["counts"], {"not-listed": 1})
|
||||
self.assertEqual(plan["trash_refused"]["counts"], {"no-trash": 1})
|
||||
self.assertEqual(plan["records"]["prompt_links"], 1)
|
||||
self.assertEqual(plan["orphaned_prompts"]["count"], 1)
|
||||
self.assertIn(plan["backup"]["policy"], ("on", "off"))
|
||||
self.assertNotIn(self.tmpdir, str(plan))
|
||||
# The review wrote nothing.
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
|
||||
self.assertEqual(self.fake.moved, [])
|
||||
|
||||
async def test_body_shapes_and_the_cap(self):
|
||||
await self.post(PREVIEW, {}, 400)
|
||||
await self.post(PREVIEW, {"items": [], "mode": "trash"}, 400)
|
||||
too_many = [self.target(f"{n}.png") for n in range(501)]
|
||||
await self.post(PREVIEW, {"items": too_many}, 400)
|
||||
|
||||
async def test_mode_is_required_and_only_for_this_plan(self):
|
||||
self.write("a.png", b"aaa")
|
||||
self.link("a.png")
|
||||
plan = await self.review(["a.png"])
|
||||
# No explicit, exact mode: refused, never a default (least of all trash).
|
||||
for body in (
|
||||
{"token": plan["token"]},
|
||||
{"token": plan["token"], "mode": None},
|
||||
{"token": plan["token"], "mode": ""},
|
||||
{"token": plan["token"], "mode": "TRASH"},
|
||||
{"token": plan["token"], "mode": "delete"},
|
||||
{"token": plan["token"], "mode": ["trash"]},
|
||||
{"token": plan["token"], "mode": True},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
await self.post(APPLY, body, 400)
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
self.assertEqual(self.fake.moved, [])
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
|
||||
# The refusals left the token usable: an explicit mode still applies.
|
||||
body = await self.post(APPLY, {"token": plan["token"], "mode": "library"}, 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
|
||||
self.assertEqual(job.scope["mode"], "library")
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
self.assertEqual(self.fake.moved, [])
|
||||
|
||||
async def test_mode_on_another_plan_is_refused(self):
|
||||
plan = await self.post(
|
||||
"/prompt_manager/maintenance/preview",
|
||||
{"operation": "missing_links", "options": {}},
|
||||
200,
|
||||
)
|
||||
await self.post(APPLY, {"token": plan["token"], "mode": "trash"}, 400)
|
||||
|
||||
|
||||
class TestLibraryMode(ImageDeleteTestCase):
|
||||
async def test_records_go_and_the_file_stays_hidden(self):
|
||||
self.write("a.png", b"aaa", mtime=1_000_000)
|
||||
self.write("b.png", b"bbb", mtime=2_000_000)
|
||||
self.link("a.png")
|
||||
self.assertEqual(sorted(await self.listed()), ["a.png", "b.png"])
|
||||
before = os.stat(self.output_dir / "a.png")
|
||||
_plan, job = await self.delete(["a.png"], "library")
|
||||
self.assertEqual(job.state, "finished", job.summary())
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 0)
|
||||
after = os.stat(self.output_dir / "a.png")
|
||||
self.assertEqual(
|
||||
(before.st_size, before.st_mtime_ns), (after.st_size, after.st_mtime_ns)
|
||||
)
|
||||
self.assertEqual(await self.listed(), ["b.png"])
|
||||
# A later scan or link never brings it back.
|
||||
self.assertEqual(self.link("a.png", "rescanned"), 0)
|
||||
self.assertEqual(
|
||||
self.api._removed_paths([self.output_dir / "a.png"]),
|
||||
{str(self.output_dir / "a.png")},
|
||||
)
|
||||
self.assertEqual(self.fake.moved, [])
|
||||
|
||||
async def test_a_modified_file_shows_again(self):
|
||||
self.write("a.png", b"aaa", mtime=1_000_000)
|
||||
await self.delete(["a.png"], "library")
|
||||
self.assertEqual(await self.listed(), [])
|
||||
self.write("a.png", b"new and longer", mtime=3_000_000)
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertEqual(await self.listed(), ["a.png"])
|
||||
|
||||
async def test_a_safety_backup_is_taken_first(self):
|
||||
self.write("a.png", b"aaa")
|
||||
with patch.object(self.api, "_safety_backup_policy", return_value="on"):
|
||||
await self.delete(["a.png"], "library")
|
||||
self.assertEqual(len(self.backups()), 1)
|
||||
|
||||
|
||||
class TestTrashMode(ImageDeleteTestCase):
|
||||
async def test_the_file_moves_first_then_its_records_go(self):
|
||||
self.write("a.png", b"aaa")
|
||||
self.link("a.png")
|
||||
seen = {}
|
||||
original = self.fake.move
|
||||
|
||||
def move(path, roots):
|
||||
# The records still exist while the file moves.
|
||||
seen["links"] = self.count("SELECT COUNT(*) FROM generated_images")
|
||||
return original(path, roots)
|
||||
|
||||
self.fake.move = move
|
||||
_plan, job = await self.delete(["a.png"], "trash")
|
||||
self.assertEqual(job.state, "finished", job.summary())
|
||||
self.assertEqual(seen["links"], 1)
|
||||
self.assertFalse((self.output_dir / "a.png").exists())
|
||||
[(source, destination)] = self.fake.moved
|
||||
self.assertTrue(os.path.exists(destination))
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 0)
|
||||
self.assertEqual(self.api.db.removal_marks(), {})
|
||||
|
||||
async def test_a_refused_move_changes_nothing(self):
|
||||
self.write("a.png", b"aaa")
|
||||
self.link("a.png")
|
||||
plan = await self.review(["a.png"])
|
||||
self.fake.refuse[str(self.output_dir / "a.png")] = "locked"
|
||||
body = await self.post(APPLY, {"token": plan["token"], "mode": "trash"}, 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
|
||||
self.assertEqual(self.api.db.removal_marks(), {})
|
||||
self.assertEqual(job.counters["not_changed"], 1)
|
||||
self.assertEqual(
|
||||
self.details(job, "not_changed"), [image_delete.REASONS["locked"]]
|
||||
)
|
||||
|
||||
async def test_a_file_changed_after_the_review_is_not_changed(self):
|
||||
self.write("a.png", b"aaa", mtime=1_000_000)
|
||||
plan = await self.review(["a.png"])
|
||||
self.write("a.png", b"changed bytes", mtime=2_000_000)
|
||||
body = await self.post(APPLY, {"token": plan["token"], "mode": "trash"}, 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
self.assertEqual(self.fake.moved, [])
|
||||
self.assertEqual(
|
||||
self.details(job, "not_changed"), [image_delete.REASONS["changed"]]
|
||||
)
|
||||
|
||||
async def test_a_records_failure_stops_the_job(self):
|
||||
self.write("a.png", b"aaa", mtime=1_000_000)
|
||||
self.write("b.png", b"bbb", mtime=2_000_000)
|
||||
with patch.object(
|
||||
type(self.api.db),
|
||||
"remove_image_records",
|
||||
side_effect=sqlite3.OperationalError("locked"),
|
||||
):
|
||||
_plan, job = await self.delete(["a.png", "b.png"], "library")
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(job.scope.get("failure"), "records-failed")
|
||||
self.assertEqual(job.counters["errors"], 1)
|
||||
self.assertEqual(
|
||||
self.details(job, "not_changed"), [image_delete.REASONS["not-attempted"]]
|
||||
)
|
||||
|
||||
async def test_nothing_is_ever_unlinked(self):
|
||||
self.write("a.png", b"aaa")
|
||||
self.write("b.png", b"bbb")
|
||||
media = str(self.output_dir)
|
||||
real_remove, real_unlink = os.remove, os.unlink
|
||||
|
||||
def guard(real):
|
||||
def call(path, *args, **kwargs):
|
||||
text = os.path.realpath(str(path))
|
||||
if (
|
||||
text.startswith(os.path.realpath(media))
|
||||
and "thumbnails" not in text
|
||||
):
|
||||
raise AssertionError(f"unlinked a media file: {path}")
|
||||
return real(path, *args, **kwargs)
|
||||
|
||||
return call
|
||||
|
||||
with (
|
||||
patch("os.remove", guard(real_remove)),
|
||||
patch("os.unlink", guard(real_unlink)),
|
||||
):
|
||||
await self.delete(["a.png"], "trash")
|
||||
await self.delete(["b.png"], "library")
|
||||
self.assertTrue((self.output_dir / "b.png").exists())
|
||||
|
||||
|
||||
class TestFileCheck(ImageDeleteTestCase):
|
||||
def test_lexical_codes(self):
|
||||
self.assertEqual(lexical_problem("thumbnails/a.png"), "thumbnail")
|
||||
self.assertEqual(lexical_problem(".Trash-1000/files/a.png"), "trash-folder")
|
||||
self.assertEqual(lexical_problem("x/$RECYCLE.BIN/a.png"), "trash-folder")
|
||||
self.assertEqual(lexical_problem("a.txt"), "not-media")
|
||||
self.assertIsNone(lexical_problem(".Trashy/a.png"))
|
||||
self.assertIsNone(lexical_problem("sub/a.png"))
|
||||
|
||||
def test_links_and_folders_leading_outside_are_refused(self):
|
||||
root = str(self.output_dir)
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
(outside / "x.png").write_bytes(b"x")
|
||||
try:
|
||||
os.symlink(outside / "x.png", self.output_dir / "link.png")
|
||||
os.symlink(outside, self.output_dir / "linked", target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest("symlinks are not permitted here")
|
||||
self.assertEqual(
|
||||
file_check(str(self.output_dir / "link.png"), root, "link.png")[0], "link"
|
||||
)
|
||||
self.assertEqual(
|
||||
file_check(str(self.output_dir / "linked/x.png"), root, "linked/x.png")[0],
|
||||
"outside",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_check(str(self.output_dir / "gone.png"), root, "gone.png")[0], "gone"
|
||||
)
|
||||
self.write("ok.png", b"ok")
|
||||
self.assertIsNone(
|
||||
file_check(str(self.output_dir / "ok.png"), root, "ok.png")[0]
|
||||
)
|
||||
|
||||
def test_windows_reason_says_why_plainly(self):
|
||||
self.assertEqual(
|
||||
image_delete.REASONS["windows-recycle"],
|
||||
"Moving files to the Recycle Bin isn't available on Windows: files "
|
||||
"larger than the bin's capacity would be deleted permanently.",
|
||||
)
|
||||
|
||||
def test_reasons_cover_every_code(self):
|
||||
from utils import trash
|
||||
|
||||
for code in trash.CODES:
|
||||
self.assertIn(code, image_delete.REASONS)
|
||||
|
||||
|
||||
class TestMacOSOutcomes(ImageDeleteTestCase):
|
||||
"""The codes and the unconfirmed outcome the macOS backend adds (#499)."""
|
||||
|
||||
async def test_permission_and_cloud_refusals_show_their_sentences(self):
|
||||
self.write("a.png", b"aaa")
|
||||
self.write("b.png", b"bbb")
|
||||
self.fake.refuse[str(self.output_dir / "a.png")] = "no-permission"
|
||||
self.fake.refuse[str(self.output_dir / "b.png")] = "cloud-sync"
|
||||
plan = await self.review(["a.png", "b.png"])
|
||||
self.assertEqual(plan["trash"], {"count": 0})
|
||||
self.assertEqual(
|
||||
plan["trash_refused"]["counts"], {"no-permission": 1, "cloud-sync": 1}
|
||||
)
|
||||
details = {row["detail"] for row in plan["trash_refused"]["rows"]}
|
||||
self.assertEqual(
|
||||
details,
|
||||
{image_delete.REASONS["no-permission"], image_delete.REASONS["cloud-sync"]},
|
||||
)
|
||||
|
||||
async def test_an_unconfirmed_move_keeps_records_and_stops_the_job(self):
|
||||
from utils import trash
|
||||
|
||||
self.write("a.png", b"aaa", mtime=1_000_000)
|
||||
self.write("b.png", b"bbb", mtime=2_000_000)
|
||||
self.link("a.png")
|
||||
original = self.fake.move
|
||||
|
||||
def move(path, roots):
|
||||
if path.endswith("a.png"):
|
||||
original(path, roots) # the file did leave its folder
|
||||
raise trash.TrashUnconfirmed("resultingItemURL=None")
|
||||
return original(path, roots)
|
||||
|
||||
self.fake.move = move
|
||||
_plan, job = await self.delete(["a.png", "b.png"], "trash")
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(job.scope.get("failure"), "records-failed")
|
||||
self.assertEqual(
|
||||
self.details(job, "errors"), [image_delete.REASONS["trash-unconfirmed"]]
|
||||
)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
|
||||
self.assertEqual(
|
||||
self.details(job, "not_changed"), [image_delete.REASONS["not-attempted"]]
|
||||
)
|
||||
self.assertTrue((self.output_dir / "b.png").exists())
|
||||
|
||||
async def test_a_refusal_detail_is_logged(self):
|
||||
from utils import trash
|
||||
|
||||
self.write("a.png", b"aaa")
|
||||
|
||||
def move(path, roots):
|
||||
raise trash.TrashRefused("no-permission", "NSCocoaErrorDomain 513")
|
||||
|
||||
self.fake.move = move
|
||||
with self.assertLogs("prompt_manager.api", level="INFO") as logs:
|
||||
_plan, job = await self.delete(["a.png"], "trash")
|
||||
self.assertEqual(
|
||||
self.details(job, "not_changed"), [image_delete.REASONS["no-permission"]]
|
||||
)
|
||||
self.assertTrue(any("NSCocoaErrorDomain 513" in line for line in logs.output))
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
|
||||
|
||||
class TestNativeBackend(ImageDeleteTestCase):
|
||||
"""The real backend of this OS: Windows has no trash yet (#500).
|
||||
|
||||
Only library mode is applied, so even on macOS and Linux no file reaches
|
||||
a real trash.
|
||||
"""
|
||||
|
||||
async def test_library_only_works_and_the_trash_is_unavailable_here(self):
|
||||
fake_trash.uninstall()
|
||||
self.write("a.png", b"aaa")
|
||||
self.link("a.png")
|
||||
plan = await self.review(["a.png"])
|
||||
native_trash = sys.platform != "win32"
|
||||
if not native_trash:
|
||||
self.assertEqual(plan["trash"], {"count": 0})
|
||||
self.assertEqual(plan["trash_refused"]["counts"], {"windows-recycle": 1})
|
||||
body = await self.post(APPLY, {"token": plan["token"], "mode": "library"}, 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
|
||||
self.assertEqual(job.counters["removed"], 1, job.summary())
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 0)
|
||||
self.assertEqual(await self.listed(), [])
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Removing one file's records from the library, and removal marks (#480)."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.image_removal_schema import (
|
||||
image_removals_schema_error,
|
||||
migrate_image_removals,
|
||||
)
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
|
||||
def _locator(path):
|
||||
return os.path.normcase(os.path.realpath(str(path)))
|
||||
|
||||
|
||||
class _RemovalCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
base = Path(self.temp.name)
|
||||
self.root = base / "output"
|
||||
self.root.mkdir()
|
||||
# A second spelling of the folder (a symlink); Windows without the
|
||||
# privilege has none, and the records then carry one spelling.
|
||||
self.alias = base / "alias"
|
||||
try:
|
||||
self.alias.symlink_to(self.root, target_is_directory=True)
|
||||
except OSError:
|
||||
self.alias = None
|
||||
self.db = PromptDatabase(str(base / "prompts.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.epoch = self.db.get_execution_epoch()
|
||||
|
||||
def file(self, name, data=b"image-bytes"):
|
||||
path = self.root / name
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
def removal(self, path):
|
||||
root_key = os.path.normcase(os.path.realpath(str(self.root)))
|
||||
return {
|
||||
"spellings": [str(path)]
|
||||
+ ([str(self.alias / path.name)] if self.alias else []),
|
||||
"locator": _locator(path),
|
||||
"favorites": [(root_key, path.name.lower())],
|
||||
"category": (root_key, path.name.lower()),
|
||||
}
|
||||
|
||||
def link(self, path, text="a prompt", spelled=None):
|
||||
prompt_id = self.db.save_prompt(text)
|
||||
image_id = self.db.link_image_to_prompt(str(prompt_id), str(spelled or path))
|
||||
self.assertTrue(image_id)
|
||||
return prompt_id, image_id
|
||||
|
||||
def tag(self, path, tags):
|
||||
data = path.read_bytes()
|
||||
info = os.stat(path)
|
||||
return self.db.replace_image_tags(
|
||||
locator=_locator(path),
|
||||
sha256=hashlib.sha256(data).hexdigest(),
|
||||
size=info.st_size,
|
||||
mtime_ns=info.st_mtime_ns,
|
||||
expected_epoch=self.epoch,
|
||||
expected_tag_revision=0,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def queue(self, path):
|
||||
data = path.read_bytes()
|
||||
info = os.stat(path)
|
||||
return self.db.record_review_item(
|
||||
locator=_locator(path),
|
||||
spelled_path=os.path.realpath(str(path)),
|
||||
sha256=hashlib.sha256(data).hexdigest(),
|
||||
size=info.st_size,
|
||||
mtime_ns=info.st_mtime_ns,
|
||||
epoch=self.epoch,
|
||||
resolver_version=1,
|
||||
evidence={"candidates": []},
|
||||
candidate_count=0,
|
||||
limited=True,
|
||||
)
|
||||
|
||||
def count(self, sql, *params):
|
||||
conn = sqlite3.connect(self.db.model.db_path)
|
||||
try:
|
||||
return conn.execute(sql, params).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def decorate(self, path):
|
||||
"""Give *path* one record of every kind."""
|
||||
removal = self.removal(path)
|
||||
root_key, relative = removal["favorites"][0]
|
||||
self.tag(path, ["portrait"])
|
||||
self.db.set_image_favorite(root_key, relative)
|
||||
self.db.set_image_categories([removal["category"]], "keepers")
|
||||
self.db.add_to_collection(items=[(root_key, relative)], name="Set")
|
||||
return removal
|
||||
|
||||
|
||||
class ImageRemovalRecordsTests(_RemovalCase):
|
||||
def test_counts_and_removes_every_record_under_every_spelling(self):
|
||||
path = self.file("a.png")
|
||||
other = self.file("b.png", b"other")
|
||||
orphan, _ = self.link(path, "only this image")
|
||||
shared, _ = self.link(path, "two images")
|
||||
self.db.link_image_to_prompt(str(shared), str(other))
|
||||
# A record under the symlinked folder spelling (or a third prompt).
|
||||
self.link(path, "aliased", spelled=(self.alias or self.root) / "a.png")
|
||||
removal = self.decorate(path)
|
||||
self.assertTrue(self.queue(path))
|
||||
|
||||
counted = self.db.count_removal_records([removal])
|
||||
self.assertEqual(
|
||||
counted["records"],
|
||||
{
|
||||
"prompt_links": 3,
|
||||
"pair_links": 0,
|
||||
"image_tags": 1,
|
||||
"auto_tagged": 0,
|
||||
"categories": 1,
|
||||
"collections": 1,
|
||||
"favorites": 1,
|
||||
"review_items": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(counted["orphaned_prompts"]["count"], 2)
|
||||
|
||||
removed = self.db.remove_image_records(removal)
|
||||
self.assertEqual(removed["prompt_links"], 3)
|
||||
self.assertEqual(removed["review_items"], 1)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
|
||||
for table in (
|
||||
"media_image_tags",
|
||||
"media_annotation_files",
|
||||
"media_review_items",
|
||||
"image_favorites",
|
||||
"image_categories",
|
||||
"image_collection_items",
|
||||
):
|
||||
with self.subTest(table=table):
|
||||
self.assertEqual(self.count(f"SELECT COUNT(*) FROM {table}"), 0)
|
||||
# Prompts and the collection itself stay.
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM prompts"), 3)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM image_collections"), 1)
|
||||
self.assertTrue(path.exists())
|
||||
self.assertIsNotNone(self.db.get_prompt_by_id(orphan))
|
||||
|
||||
def test_a_kept_review_item_keeps_the_annotation_rows(self):
|
||||
path = self.file("a.png")
|
||||
removal = self.decorate(path)
|
||||
self.assertTrue(self.queue(path))
|
||||
conn = sqlite3.connect(self.db.model.db_path)
|
||||
try:
|
||||
# A revised item is not settled; it still cites the bytes, whose
|
||||
# foreign key is ON DELETE RESTRICT.
|
||||
conn.execute("UPDATE media_review_items SET revision=1")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
removed = self.db.remove_image_records(removal)
|
||||
self.assertEqual(removed["review_items"], 0)
|
||||
self.assertEqual(removed["image_tags"], 1)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM media_image_tags"), 0)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM media_review_items"), 1)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM media_annotation_files"), 1)
|
||||
|
||||
def test_mark_is_written_with_the_records_and_bumps_the_revision(self):
|
||||
path = self.file("a.png")
|
||||
self.link(path)
|
||||
before = self.db.removal_revision()
|
||||
info = os.stat(path)
|
||||
self.db.remove_image_records(
|
||||
self.removal(path), mark=(info.st_size, info.st_mtime_ns)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.removal_marks(),
|
||||
{_locator(path): (info.st_size, info.st_mtime_ns)},
|
||||
)
|
||||
self.assertEqual(self.db.removal_revision(), before + 1)
|
||||
self.assertEqual(self.db.clear_removal_marks([_locator(path)]), 1)
|
||||
self.assertEqual(self.db.removal_marks(), {})
|
||||
self.assertEqual(self.db.removal_revision(), before + 2)
|
||||
self.assertEqual(self.db.clear_removal_marks([_locator(path)]), 0)
|
||||
|
||||
def test_a_failure_rolls_back_everything(self):
|
||||
path = self.file("a.png")
|
||||
self.link(path)
|
||||
removal = self.decorate(path)
|
||||
broken = {**removal, "category": ("only-one-value",)}
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
self.db.remove_image_records(broken, mark=(1, 1))
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM media_image_tags"), 1)
|
||||
self.assertEqual(self.db.removal_marks(), {})
|
||||
|
||||
def test_parity_with_the_legacy_single_delete(self):
|
||||
path = self.file("a.png")
|
||||
_prompt, image_id = self.link(path)
|
||||
twin = PromptDatabase(str(Path(self.temp.name) / "twin.db"))
|
||||
try:
|
||||
twin_prompt = twin.save_prompt("a prompt")
|
||||
twin_image = twin.link_image_to_prompt(str(twin_prompt), str(path))
|
||||
self.assertTrue(twin.delete_image(twin_image))
|
||||
self.db.remove_image_records(self.removal(path))
|
||||
for table in (
|
||||
"generated_images",
|
||||
"image_prompt_pairs",
|
||||
"workflow_documents",
|
||||
):
|
||||
with self.subTest(table=table):
|
||||
conn = sqlite3.connect(twin.model.db_path)
|
||||
try:
|
||||
legacy = conn.execute(
|
||||
f"SELECT COUNT(*) FROM {table}"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(
|
||||
self.count(f"SELECT COUNT(*) FROM {table}"), legacy
|
||||
)
|
||||
finally:
|
||||
twin.close_all()
|
||||
self.assertIsNotNone(image_id)
|
||||
|
||||
|
||||
class RemovalMarkGateTests(_RemovalCase):
|
||||
"""A marked file is never linked again; any record write shows it again."""
|
||||
|
||||
def hide(self, path):
|
||||
info = os.stat(path)
|
||||
self.db.remove_image_records(
|
||||
self.removal(path), mark=(info.st_size, info.st_mtime_ns)
|
||||
)
|
||||
|
||||
def test_a_marked_file_is_not_linked_again(self):
|
||||
path = self.file("a.png")
|
||||
self.hide(path)
|
||||
prompt_id = self.db.save_prompt("scanned again")
|
||||
self.assertEqual(self.db.link_image_to_prompt(str(prompt_id), str(path)), 0)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 0)
|
||||
self.assertIn(_locator(path), self.db.removal_marks())
|
||||
|
||||
def test_a_changed_file_drops_its_stale_mark_and_links(self):
|
||||
path = self.file("a.png")
|
||||
self.hide(path)
|
||||
path.write_bytes(b"new bytes, new size")
|
||||
prompt_id = self.db.save_prompt("a new image at the same path")
|
||||
self.assertTrue(self.db.link_image_to_prompt(str(prompt_id), str(path)))
|
||||
self.assertEqual(self.db.removal_marks(), {})
|
||||
|
||||
def test_explicit_record_writes_clear_the_mark(self):
|
||||
writers = {
|
||||
"favorite": lambda path, key: self.db.set_image_favorite(*key),
|
||||
"category": lambda path, key: self.db.set_image_categories([key], "c"),
|
||||
"collection": lambda path, key: self.db.add_to_collection(
|
||||
items=[key], name="Set"
|
||||
),
|
||||
"tags": lambda path, key: self.tag(path, ["t"]),
|
||||
}
|
||||
for kind, write in writers.items():
|
||||
with self.subTest(kind=kind):
|
||||
path = self.file(f"{kind}.png", kind.encode())
|
||||
self.hide(path)
|
||||
self.assertIn(_locator(path), self.db.removal_marks())
|
||||
write(path, self.removal(path)["favorites"][0])
|
||||
self.assertNotIn(_locator(path), self.db.removal_marks())
|
||||
|
||||
|
||||
class ImageRemovalSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.addCleanup(self.conn.close)
|
||||
|
||||
def test_missing_is_allowed_only_for_old_backups(self):
|
||||
self.assertIsNone(image_removals_schema_error(self.conn))
|
||||
self.assertIsNotNone(
|
||||
image_removals_schema_error(self.conn, allow_missing=False)
|
||||
)
|
||||
|
||||
def test_install_is_valid_and_idempotent(self):
|
||||
migrate_image_removals(self.conn)
|
||||
migrate_image_removals(self.conn)
|
||||
self.assertIsNone(image_removals_schema_error(self.conn, allow_missing=False))
|
||||
self.assertEqual(
|
||||
self.conn.execute("SELECT revision FROM image_removals_meta").fetchall(),
|
||||
[(0,)],
|
||||
)
|
||||
|
||||
def test_invalid_values_and_objects_are_refused(self):
|
||||
migrate_image_removals(self.conn)
|
||||
self.conn.execute("INSERT INTO image_removals VALUES ('', 1, 1, 'now')")
|
||||
self.assertIn("invalid values", image_removals_schema_error(self.conn))
|
||||
self.conn.execute("DELETE FROM image_removals")
|
||||
self.conn.execute("CREATE INDEX extra ON image_removals(size)")
|
||||
self.assertIn("unsupported", image_removals_schema_error(self.conn))
|
||||
|
||||
def test_a_foreign_table_definition_is_refused(self):
|
||||
self.conn.execute("CREATE TABLE image_removals (locator TEXT)")
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_image_removals(self.conn)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Removal-mark gaps after #480, and trash folders inside output roots (#501).
|
||||
|
||||
- Thumbnail jobs and the legacy auto-tag listing skip a library-removed file.
|
||||
- Missing-file links also forgets the marks of files that are gone (only under
|
||||
a source that can be read now).
|
||||
- Every walker skips a trash folder that sits inside an output root.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.disk_waits import DISK_BOUND_SECONDS
|
||||
from tests.test_image_delete_routes import ImageDeleteTestCase
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from disk_waits import DISK_BOUND_SECONDS
|
||||
from test_image_delete_routes import ImageDeleteTestCase
|
||||
|
||||
from py.api import images as images_module
|
||||
from py.api.admin import _collect_media_files
|
||||
from py.api.autotag_routes import AutotagRoutesMixin
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
MAINTENANCE_PREVIEW = "/prompt_manager/maintenance/preview"
|
||||
MAINTENANCE_APPLY = "/prompt_manager/maintenance/apply"
|
||||
|
||||
|
||||
class _GapCase(ImageDeleteTestCase):
|
||||
def marks(self):
|
||||
conn = sqlite3.connect(self.api.db.model.db_path)
|
||||
try:
|
||||
return [
|
||||
row[0] for row in conn.execute("SELECT locator FROM image_removals")
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def revision(self):
|
||||
conn = sqlite3.connect(self.api.db.model.db_path)
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT revision FROM image_removals_meta WHERE singleton_id=1"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestThumbnailJobsSkipHiddenFiles(_GapCase):
|
||||
async def test_missing_and_rebuild_plans_leave_a_removed_file_out(self):
|
||||
self.write("hidden.png", b"hidden bytes", mtime=1_000_000)
|
||||
self.write("shown.png", b"shown bytes", mtime=2_000_000)
|
||||
await self.delete(["hidden.png"], "library")
|
||||
thumbs = self.output_dir / "thumbnails"
|
||||
for mode in ("missing", "rebuild"):
|
||||
with self.subTest(mode=mode):
|
||||
plan = self.api._plan_thumbnails(self.output_dir, thumbs, mode=mode)
|
||||
sources = [src.name for src, _dst, _video in plan["targets"]]
|
||||
self.assertEqual(sources, ["shown.png"])
|
||||
self.assertEqual(plan["missing"], 1)
|
||||
|
||||
async def test_a_changed_file_gets_its_preview_again(self):
|
||||
self.write("hidden.png", b"hidden bytes", mtime=1_000_000)
|
||||
await self.delete(["hidden.png"], "library")
|
||||
self.write("hidden.png", b"new and longer bytes", mtime=3_000_000)
|
||||
plan = self.api._plan_thumbnails(
|
||||
self.output_dir, self.output_dir / "thumbnails"
|
||||
)
|
||||
self.assertEqual([t[0].name for t in plan["targets"]], ["hidden.png"])
|
||||
|
||||
|
||||
class TestLegacyAutotagListing(_GapCase):
|
||||
async def test_scan_output_dir_leaves_a_removed_file_out(self):
|
||||
self.write("hidden.png", b"hidden bytes", mtime=1_000_000)
|
||||
self.write("shown.png", b"shown bytes", mtime=2_000_000)
|
||||
await self.delete(["hidden.png"], "library")
|
||||
with patch.object(
|
||||
self.api, "_find_comfyui_output_dir", return_value=str(self.output_dir)
|
||||
):
|
||||
response = await self.client.get("/prompt_manager/scan_output_dir")
|
||||
body = await response.json()
|
||||
self.assertEqual([image["filename"] for image in body["images"]], ["shown.png"])
|
||||
|
||||
|
||||
class TestMissingFileLinksPrunesMarks(_GapCase):
|
||||
async def links_review(self, status=200):
|
||||
return await self.post(
|
||||
MAINTENANCE_PREVIEW, {"operation": "missing_links", "options": {}}, status
|
||||
)
|
||||
|
||||
async def apply(self, token, status=202):
|
||||
body = await self.post(MAINTENANCE_APPLY, {"token": token}, status)
|
||||
if status != 202:
|
||||
return body
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
|
||||
return job
|
||||
|
||||
async def removed(self, name):
|
||||
self.write("other.png", b"another output") # the source lists something
|
||||
self.write(name, f"bytes of {name}".encode(), mtime=1_000_000)
|
||||
await self.delete([name], "library")
|
||||
self.assertEqual(len(self.marks()), 1)
|
||||
|
||||
async def test_the_mark_of_a_gone_file_is_counted_and_forgotten(self):
|
||||
await self.removed("gone.png")
|
||||
# Not an unlink by PromptManager: the user deleted it elsewhere.
|
||||
os.remove(self.output_dir / "gone.png")
|
||||
plan = await self.links_review()
|
||||
self.assertEqual(plan["scope"]["count"], 1)
|
||||
self.assertEqual(
|
||||
plan["scope"]["label"], "1 removed image whose file is gone or changed"
|
||||
)
|
||||
before = self.revision()
|
||||
job = await self.apply(plan["token"])
|
||||
self.assertEqual(job.state, "finished", job.summary())
|
||||
self.assertEqual(self.marks(), [])
|
||||
self.assertGreater(self.revision(), before)
|
||||
|
||||
async def test_the_mark_of_a_present_file_stays(self):
|
||||
await self.removed("kept.png")
|
||||
plan = await self.links_review()
|
||||
self.assertEqual(plan["scope"]["count"], 0)
|
||||
self.assertEqual(plan["scope"]["label"], "No links to missing files")
|
||||
self.assertEqual(len(self.marks()), 1)
|
||||
|
||||
async def test_a_mark_under_an_unreadable_source_stays(self):
|
||||
await self.removed("away.png")
|
||||
os.remove(self.output_dir / "away.png")
|
||||
with patch(
|
||||
"database.maintenance.readable_roots",
|
||||
side_effect=lambda r: [False] * len(r),
|
||||
):
|
||||
plan = await self.links_review()
|
||||
self.assertEqual(plan["scope"]["count"], 0)
|
||||
self.assertEqual(len(self.marks()), 1)
|
||||
|
||||
async def test_the_mark_of_a_changed_file_is_forgotten(self):
|
||||
await self.removed("changed.png")
|
||||
self.write("changed.png", b"other and longer bytes", mtime=4_000_000)
|
||||
plan = await self.links_review()
|
||||
self.assertEqual(plan["scope"]["count"], 1)
|
||||
job = await self.apply(plan["token"])
|
||||
self.assertEqual(job.state, "finished", job.summary())
|
||||
self.assertEqual(self.marks(), [])
|
||||
|
||||
async def test_marks_under_an_empty_source_stay(self):
|
||||
# An unplugged drive leaves its mount point: an empty, readable folder.
|
||||
await self.removed("mounted.png")
|
||||
for child in list(self.output_dir.iterdir()):
|
||||
if child.is_dir():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
os.remove(child)
|
||||
plan = await self.links_review()
|
||||
self.assertEqual(plan["scope"]["count"], 0)
|
||||
self.assertEqual(len(self.marks()), 1)
|
||||
|
||||
async def test_a_name_the_folder_still_lists_is_not_gone(self):
|
||||
# A case-sensitive NTFS folder: lstat of the normcased locator fails.
|
||||
await self.removed("Listed.png")
|
||||
real_lstat = os.lstat
|
||||
locator = self.marks()[0]
|
||||
|
||||
def lstat(path, *args, **kwargs):
|
||||
if os.fspath(path) == locator:
|
||||
raise FileNotFoundError(path)
|
||||
return real_lstat(path, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("database.maintenance.os.lstat", side_effect=lstat),
|
||||
patch("database.maintenance.os.path.normcase", side_effect=str.lower),
|
||||
):
|
||||
from database.maintenance import _stale_marks
|
||||
|
||||
stale = _stale_marks([(locator, 1, 1)], [str(self.output_dir)])
|
||||
self.assertEqual(stale, [])
|
||||
|
||||
async def test_a_file_back_before_the_apply_changes_the_plan(self):
|
||||
await self.removed("back.png")
|
||||
path = self.output_dir / "back.png"
|
||||
saved = Path(self.tmpdir) / "saved.png"
|
||||
shutil.copy2(path, saved)
|
||||
os.remove(path)
|
||||
plan = await self.links_review()
|
||||
shutil.copy2(saved, path)
|
||||
job = await self.apply(plan["token"])
|
||||
self.assertEqual(job.state, "failed", job.summary())
|
||||
self.assertEqual(len(self.marks()), 1)
|
||||
|
||||
|
||||
class TestWalkersSkipTrashFolders(_GapCase):
|
||||
def seed(self):
|
||||
self.write("kept.png", b"kept")
|
||||
self.write(
|
||||
"x.png", b"trashed x", folder=self.output_dir / ".Trash-1000" / "files"
|
||||
)
|
||||
self.write(
|
||||
"y.png", b"trashed y", folder=self.output_dir / "sub" / "$RECYCLE.BIN"
|
||||
)
|
||||
self.write("z.png", b"trashed z", folder=self.output_dir / ".Trashes")
|
||||
|
||||
def test_walk_media_and_the_settled_entry(self):
|
||||
self.seed()
|
||||
walked = sorted(
|
||||
Path(entry.path).name
|
||||
for entry in images_module._walk_media(self.output_dir, 100)
|
||||
)
|
||||
self.assertEqual(walked, ["kept.png"])
|
||||
for relative in (
|
||||
os.path.join(".Trash-1000", "files", "x.png"),
|
||||
os.path.join("sub", "$RECYCLE.BIN", "y.png"),
|
||||
):
|
||||
with self.subTest(relative=relative):
|
||||
self.assertIsNone(
|
||||
images_module._settled_entry(self.output_dir, relative)
|
||||
)
|
||||
|
||||
def test_scan_duplicate_and_autotag_walkers(self):
|
||||
self.seed()
|
||||
collected = [
|
||||
Path(p).name for p in _collect_media_files([self.output_dir], [".png"])
|
||||
]
|
||||
self.assertEqual(collected, ["kept.png"])
|
||||
found, _truncated = AutotagRoutesMixin._collect_scan_files(self.output_dir)
|
||||
self.assertEqual([p.name for p in found], ["kept.png"])
|
||||
|
||||
def test_the_image_monitor(self):
|
||||
monitor = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
monitor.supported_extensions = (".png",)
|
||||
base = str(self.output_dir)
|
||||
self.assertTrue(monitor.is_image_file(os.path.join(base, "a.png")))
|
||||
for folder in (".Trash-1000", "$Recycle.Bin", ".Trashes", ".trash"):
|
||||
with self.subTest(folder=folder):
|
||||
self.assertFalse(
|
||||
monitor.is_image_file(os.path.join(base, folder, "files", "a.png"))
|
||||
)
|
||||
|
||||
async def test_the_workspace_listing(self):
|
||||
self.seed()
|
||||
self.assertEqual(await self.listed(), ["kept.png"])
|
||||
|
||||
def test_a_root_named_like_a_trash_folder_still_lists(self):
|
||||
root = Path(self.tmpdir) / ".Trash-1000"
|
||||
(root / "inner").mkdir(parents=True)
|
||||
(root / "inner" / "a.png").write_bytes(b"a")
|
||||
walked = [Path(e.path).name for e in images_module._walk_media(root, 10)]
|
||||
self.assertEqual(walked, ["a.png"])
|
||||
@@ -345,7 +345,8 @@ class TestLibrary(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
(parent["title"], parent["notes"]), ("Portrait", "keep\nnotes")
|
||||
)
|
||||
self.assertNotIn("archived", parent)
|
||||
# The parent's own archive state (#443), never a pair's.
|
||||
self.assertIs(parent["archived"], False)
|
||||
self.assertRegex(parent["edit_token"], r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(page["items"][1]["title"], " \t\u00a0\u3000")
|
||||
self.assertEqual(self.db.list_library(text="Portrait")["total"], 1)
|
||||
@@ -1520,6 +1521,7 @@ class TestLibrary(unittest.TestCase):
|
||||
"tags",
|
||||
"rating",
|
||||
"notes",
|
||||
"archived",
|
||||
"variants",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""#443 (D152): the Library delete, archive and restore routes, and ingestion after."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.test_api_admin import AdminAPITestCase # first: it stubs ComfyUI
|
||||
from database.execution_history_schema import rotate_history_epoch # noqa: E402
|
||||
from database.prompt_archive import TABLE as ARCHIVE # noqa: E402
|
||||
from database.safety_backup import SafetyBackupFailed # noqa: E402
|
||||
from py.config import PromptManagerConfig # noqa: E402
|
||||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||||
|
||||
PREVIEW = "/prompt_manager/library/delete/preview"
|
||||
DELETE = "/prompt_manager/library/delete"
|
||||
RESTORE = "/prompt_manager/library/restore"
|
||||
|
||||
|
||||
def graph(positive, negative="blur"):
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LibraryDeleteRouteCase(AdminAPITestCase):
|
||||
def epoch(self):
|
||||
return self.api.db.get_library_options()["library_epoch"]
|
||||
|
||||
def save(self, text, **metadata):
|
||||
return self.api.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **metadata
|
||||
)
|
||||
|
||||
def paired(self, positive, negative="blur"):
|
||||
pair = self.api.db.save_prompt_pair(positive, negative)
|
||||
return self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
|
||||
def rows(self, sql, params=()):
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
return [tuple(row) for row in conn.execute(sql, params).fetchall()]
|
||||
|
||||
def ids(self):
|
||||
return sorted(row[0] for row in self.rows("SELECT id FROM prompts"))
|
||||
|
||||
def archived(self):
|
||||
return sorted(row[0] for row in self.rows(f"SELECT prompt_id FROM {ARCHIVE}"))
|
||||
|
||||
def backups(self):
|
||||
folder = os.path.dirname(self.api.db.model.db_path)
|
||||
return sorted(name for name in os.listdir(folder) if ".backup_" in name)
|
||||
|
||||
async def post(self, url, body):
|
||||
response = await self.client.request("POST", url, json=body)
|
||||
return response.status, await response.json()
|
||||
|
||||
async def review(self, ids):
|
||||
status, body = await self.post(
|
||||
PREVIEW, {"prompt_ids": ids, "expected_epoch": self.epoch()}
|
||||
)
|
||||
self.assertEqual(status, 200, body)
|
||||
return body
|
||||
|
||||
async def confirm(self, review, backup=None):
|
||||
return await self.post(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": backup or review["backup"],
|
||||
},
|
||||
)
|
||||
|
||||
def policy(self, on):
|
||||
previous = PromptManagerConfig.SAFETY_BACKUP
|
||||
PromptManagerConfig.SAFETY_BACKUP = on
|
||||
self.addCleanup(setattr, PromptManagerConfig, "SAFETY_BACKUP", previous)
|
||||
|
||||
|
||||
class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
|
||||
async def test_preview_then_delete_archives_deletes_and_backs_up(self):
|
||||
self.policy(True)
|
||||
plain = self.save("Plain")
|
||||
guarded = self.save("Guarded", tags=["__protected__"])
|
||||
positive = self.paired("Cat")
|
||||
review = await self.review([plain, guarded, positive, 99999])
|
||||
self.assertEqual(
|
||||
{
|
||||
key: review[key]
|
||||
for key in ("delete", "archive", "protected", "not_found")
|
||||
},
|
||||
{
|
||||
"delete": [plain],
|
||||
"archive": [positive],
|
||||
"protected": [guarded],
|
||||
"not_found": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(review["backup"], "on")
|
||||
self.assertTrue(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
(body["deleted"], body["archived"], body["changed"]), (1, 1, 0)
|
||||
)
|
||||
self.assertEqual(self.backups(), [body["backup"]])
|
||||
self.assertNotIn(os.sep, body["backup"])
|
||||
self.assertNotIn(plain, self.ids())
|
||||
self.assertEqual(self.archived(), [positive])
|
||||
# The backup still holds the deleted prompt.
|
||||
import sqlite3
|
||||
|
||||
path = os.path.join(os.path.dirname(self.api.db.model.db_path), body["backup"])
|
||||
with sqlite3.connect(path) as copy:
|
||||
self.assertEqual(
|
||||
copy.execute(
|
||||
"SELECT COUNT(*) FROM prompts WHERE id=?", (plain,)
|
||||
).fetchone()[0],
|
||||
1,
|
||||
)
|
||||
copy.close()
|
||||
# The hold ended: nothing is listed as running.
|
||||
self.assertEqual(self.api._jobs.running(), [])
|
||||
|
||||
async def test_archive_only_takes_no_backup(self):
|
||||
self.policy(True)
|
||||
positive = self.paired("Cat")
|
||||
shared = self.save("Shared")
|
||||
self.api.db.save_prompt_pair("Dog", "Shared") # archive-only (#482)
|
||||
self.api.db.link_image_to_prompt(shared, "/tmp/shared-443.png")
|
||||
# Nothing could be deleted: no disk plan. (#482: a deletable prompt has one.)
|
||||
self.assertIsNone((await self.review([shared]))["disk"])
|
||||
review = await self.review([positive])
|
||||
self.assertIsNotNone(review["disk"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["archived"], body["backup"]), (200, 1, None))
|
||||
self.assertEqual(self.backups(), [])
|
||||
|
||||
async def test_policy_off_takes_none_unless_the_review_promised_one(self):
|
||||
plain = self.save("Plain")
|
||||
other = self.save("Other")
|
||||
self.policy(False)
|
||||
review = await self.review([plain])
|
||||
self.assertEqual((review["backup"], review["disk"]), ("off", None))
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["deleted"], body["backup"]), (200, 1, None))
|
||||
# Reviewed with the policy on, turned off before the confirm: kept.
|
||||
review = await self.review([other])
|
||||
status, body = await self.confirm(review, backup="on")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsNotNone(body["backup"])
|
||||
|
||||
async def test_a_failed_backup_changes_nothing(self):
|
||||
self.policy(True)
|
||||
plain = self.save("Plain")
|
||||
positive = self.paired("Cat")
|
||||
review = await self.review([plain, positive])
|
||||
with patch(
|
||||
"py.api.maintenance.take_safety_backup",
|
||||
side_effect=SafetyBackupFailed("x"),
|
||||
):
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (500, "backup-failed"))
|
||||
self.assertIn("nothing was changed", body["error"])
|
||||
self.assertIn(plain, self.ids())
|
||||
self.assertEqual(self.archived(), [])
|
||||
|
||||
async def test_a_short_disk_refuses_before_any_change(self):
|
||||
self.policy(True)
|
||||
plain = self.save("Plain")
|
||||
from py.api import maintenance
|
||||
|
||||
with patch.object(maintenance, "_free_bytes", return_value=0):
|
||||
review = await self.review([plain])
|
||||
self.assertFalse(review["disk"]["sufficient"])
|
||||
self.assertIn("Nothing was changed", review["disk"]["message"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (507, "insufficient-space"))
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_busy_beside_a_record_changing_job(self):
|
||||
plain = self.save("Plain")
|
||||
review = await self.review([plain])
|
||||
async with self.api._jobs.hold("scan", "Scan", admission="records"):
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (409, "busy"))
|
||||
self.assertEqual(body["running"]["title"], "Scan")
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_restored_storage_answers_409_on_every_route(self):
|
||||
plain = self.save("Plain")
|
||||
review = await self.review([plain])
|
||||
rotate_history_epoch(self.api.db.model.get_connection())
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (409, "storage-changed"))
|
||||
for url in (PREVIEW, RESTORE):
|
||||
status, body = await self.post(
|
||||
url, {"prompt_ids": [plain], "expected_epoch": review["library_epoch"]}
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "storage-changed"))
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_restore_route(self):
|
||||
positive = self.paired("Cat")
|
||||
await self.confirm(await self.review([positive]))
|
||||
status, body = await self.post(
|
||||
RESTORE, {"prompt_ids": [positive, 99999], "expected_epoch": self.epoch()}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual((body["restored"], body["not_found"]), (1, 1))
|
||||
self.assertEqual(self.archived(), [])
|
||||
|
||||
async def test_bodies_are_validated(self):
|
||||
plain = self.save("Plain")
|
||||
epoch = self.epoch()
|
||||
selection = {"prompt_ids": [plain], "expected_epoch": epoch}
|
||||
for url, body in (
|
||||
(PREVIEW, {**selection, "extra": 1}),
|
||||
(PREVIEW, {"prompt_ids": [], "expected_epoch": epoch}),
|
||||
(PREVIEW, {"prompt_ids": [True], "expected_epoch": epoch}),
|
||||
(PREVIEW, {"prompt_ids": list(range(1, 1002)), "expected_epoch": epoch}),
|
||||
(PREVIEW, {"prompt_ids": [plain], "expected_epoch": "nope"}),
|
||||
(RESTORE, {"prompt_ids": [plain]}),
|
||||
(DELETE, {**selection}),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": [],
|
||||
"delete_ids": [],
|
||||
"expected_epoch": epoch,
|
||||
"backup": "on",
|
||||
},
|
||||
),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": [plain],
|
||||
"delete_ids": [plain],
|
||||
"expected_epoch": epoch,
|
||||
"backup": "on",
|
||||
},
|
||||
),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": [],
|
||||
"delete_ids": [plain],
|
||||
"expected_epoch": epoch,
|
||||
"backup": "maybe",
|
||||
},
|
||||
),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": list(range(1, 600)),
|
||||
"delete_ids": list(range(600, 1100)),
|
||||
"expected_epoch": epoch,
|
||||
"backup": "off",
|
||||
},
|
||||
),
|
||||
):
|
||||
with self.subTest(url=url, body=body):
|
||||
status, answer = await self.post(url, body)
|
||||
self.assertEqual(status, 400, answer)
|
||||
self.assertFalse(answer["success"])
|
||||
for url in (PREVIEW, DELETE, RESTORE):
|
||||
response = await self.client.request(
|
||||
"POST", url, data="[1]", headers={"Content-Type": "application/json"}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_the_legacy_bulk_delete_is_unchanged(self):
|
||||
plain = self.save("Plain")
|
||||
positive = self.paired("Cat")
|
||||
response = await self.client.request(
|
||||
"POST", "/prompt_manager/bulk/delete", json={"prompt_ids": [positive]}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
response = await self.client.request(
|
||||
"POST", "/prompt_manager/bulk/delete", json={"prompt_ids": [plain]}
|
||||
)
|
||||
self.assertEqual((await response.json())["deleted_count"], 1)
|
||||
self.assertEqual(self.archived(), [])
|
||||
|
||||
async def test_shelf_parameter_and_cursor_scope(self):
|
||||
kept = [self.paired(f"Kept {i}") for i in range(3)]
|
||||
gone = [self.paired(f"Gone {i}") for i in range(3)]
|
||||
await self.confirm(await self.review(gone))
|
||||
response = await self.client.request(
|
||||
"GET", "/prompt_manager/library?limit=2&shelf=active"
|
||||
)
|
||||
page = await response.json()
|
||||
self.assertEqual(page["archived_total"], 3)
|
||||
self.assertTrue(set(item["id"] for item in page["items"]) <= set(kept))
|
||||
response = await self.client.request(
|
||||
"GET",
|
||||
"/prompt_manager/library?limit=2&shelf=archived"
|
||||
f"&cursor={page['next_cursor']}",
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.request(
|
||||
"GET", "/prompt_manager/library?shelf=archived"
|
||||
)
|
||||
archived = await response.json()
|
||||
self.assertEqual(sorted(item["id"] for item in archived["items"]), gone)
|
||||
self.assertTrue(all(item["archived"] for item in archived["items"]))
|
||||
response = await self.client.request("GET", "/prompt_manager/library?shelf=all")
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
|
||||
class TestPairedDeleteRoutes(LibraryDeleteRouteCase):
|
||||
"""#482 (D160): Delete permanently with a prompt's own saved pairs."""
|
||||
|
||||
async def confirm_paired(self, review, ids, backup=None, digests=None):
|
||||
return await self.post(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": backup or review["backup"],
|
||||
"delete_paired_ids": ids,
|
||||
"paired_digests": digests
|
||||
or {str(pid): review["review_digests"][str(pid)] for pid in ids},
|
||||
},
|
||||
)
|
||||
|
||||
async def test_preview_reports_deletable_and_a_paired_only_delete_backs_up(self):
|
||||
self.policy(True)
|
||||
cat = self.paired("Cat")
|
||||
shared = self.save("Shared")
|
||||
self.api.db.save_prompt_pair("Dog", "Shared")
|
||||
self.api.db.link_image_to_prompt(shared, "/tmp/shared-482.png")
|
||||
review = await self.review([cat, shared])
|
||||
self.assertEqual(review["archive"], [cat, shared])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
self.assertEqual(list(review["review_digests"]), [str(cat)])
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 1, "archived_pairs": 0, "variants": 1, "kept_negatives": 1},
|
||||
)
|
||||
self.assertEqual(review["paired_image_links"], 0)
|
||||
# A selection with nothing in the delete class still checks the disk.
|
||||
self.assertTrue(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm_paired(review, [cat])
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
(body["deleted"], body["archived"], body["pairs_removed"]), (1, 1, 1)
|
||||
)
|
||||
self.assertEqual(self.backups(), [body["backup"]])
|
||||
self.assertNotIn(cat, self.ids())
|
||||
self.assertEqual(self.archived(), [shared])
|
||||
|
||||
async def test_a_body_without_the_new_keys_is_the_443_delete(self):
|
||||
cat = self.paired("Cat")
|
||||
review = await self.review([cat])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual((body["deleted"], body["archived"]), (0, 1))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
async def test_paired_bodies_are_validated(self):
|
||||
cat = self.paired("Cat")
|
||||
plain = self.save("Plain")
|
||||
review = await self.review([cat, plain])
|
||||
digest = review["review_digests"][str(cat)]
|
||||
base = {
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": "off",
|
||||
}
|
||||
cases = [
|
||||
{"delete_paired_ids": [cat]}, # without its digests
|
||||
{"delete_paired_ids": [plain], "paired_digests": {str(plain): digest}},
|
||||
{"delete_paired_ids": [cat], "paired_digests": {}},
|
||||
{"delete_paired_ids": [cat], "paired_digests": {str(cat): "x"}},
|
||||
{"delete_paired_ids": "all", "paired_digests": {}},
|
||||
]
|
||||
for extra in cases:
|
||||
with self.subTest(extra=extra):
|
||||
status, body = await self.post(DELETE, {**base, **extra})
|
||||
self.assertEqual((status, body["code"]), (400, "invalid-request"))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
async def test_a_short_disk_refuses_a_paired_only_delete(self):
|
||||
self.policy(True)
|
||||
cat = self.paired("Cat")
|
||||
from py.api import maintenance
|
||||
|
||||
with patch.object(maintenance, "_free_bytes", return_value=0):
|
||||
review = await self.review([cat])
|
||||
self.assertFalse(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm_paired(review, [cat])
|
||||
self.assertEqual((status, body["code"]), (507, "insufficient-space"))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
|
||||
class TestIngestionAfterArchive(LibraryDeleteRouteCase):
|
||||
def scan(self, name, positive, negative="blur"):
|
||||
meta = {"prompt": json.dumps(graph(positive, negative))}
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
return self.api._ingest_scanned_file(path, meta)
|
||||
|
||||
async def test_a_scan_after_archive_creates_no_duplicate_and_keeps_it_archived(
|
||||
self,
|
||||
):
|
||||
self.assertEqual(self.scan("first.png", "Sunset"), "added")
|
||||
positive = self.api.db.get_prompt_by_hash(generate_prompt_hash("Sunset"))["id"]
|
||||
await self.confirm(await self.review([positive]))
|
||||
before = self.ids()
|
||||
self.scan("second.png", "Sunset")
|
||||
self.scan("third.png", " sunset ", "other negative")
|
||||
self.assertEqual(
|
||||
[i for i in self.ids() if i not in before],
|
||||
[
|
||||
self.api.db.get_prompt_by_hash(generate_prompt_hash("other negative"))[
|
||||
"id"
|
||||
]
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.archived(), [positive])
|
||||
linked = self.rows(
|
||||
"SELECT COUNT(*) FROM generated_images WHERE prompt_id=?", (positive,)
|
||||
)
|
||||
self.assertEqual(linked, [(3,)])
|
||||
response = await self.client.request("GET", "/prompt_manager/library")
|
||||
self.assertNotIn(
|
||||
positive, [item["id"] for item in (await response.json())["items"]]
|
||||
)
|
||||
|
||||
async def test_a_scan_after_delete_creates_the_prompt_again(self):
|
||||
plain = self.save("Lonely sunset")
|
||||
await self.confirm(await self.review([plain]))
|
||||
self.assertEqual(self.scan("again.png", "Lonely sunset"), "added")
|
||||
again = self.api.db.get_prompt_by_hash(generate_prompt_hash("Lonely sunset"))
|
||||
self.assertIsNotNone(again)
|
||||
self.assertNotEqual(again["id"], plain)
|
||||
self.assertEqual(self.archived(), [])
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
@@ -753,6 +754,17 @@ class LocalMediaTests(LayoutTestCase):
|
||||
self.assertEqual(layout_module.VIDEO_SUFFIXES, frozenset(VIDEO_EXTENSIONS))
|
||||
|
||||
|
||||
# Windows path rules without the filesystem. On Windows ntpath.realpath asks
|
||||
# the network for \\server\share and waits seconds for a host that does not
|
||||
# exist (#487); elsewhere ntpath.realpath already is ntpath.abspath.
|
||||
LEXICAL_NT = SimpleNamespace(
|
||||
normcase=ntpath.normcase,
|
||||
realpath=ntpath.abspath,
|
||||
dirname=ntpath.dirname,
|
||||
sep=ntpath.sep,
|
||||
)
|
||||
|
||||
|
||||
class FolderGuardTests(unittest.TestCase):
|
||||
"""Portable path-flavor controls (TESTING.md Windows portability preflight)."""
|
||||
|
||||
@@ -762,7 +774,7 @@ class FolderGuardTests(unittest.TestCase):
|
||||
|
||||
def guard_nt(self, path, home="C:\\Users\\Vito"):
|
||||
return layout_module._guard_folder(
|
||||
path, home, pathmod=ntpath, isdir=lambda _p: True
|
||||
path, home, pathmod=LEXICAL_NT, isdir=lambda _p: True
|
||||
)
|
||||
|
||||
def test_filesystem_roots_and_home_are_rejected(self):
|
||||
@@ -832,8 +844,12 @@ class FolderGuardTests(unittest.TestCase):
|
||||
|
||||
def test_missing_folders_add_nothing(self):
|
||||
self.assertIsNone(layout_module._guard_folder(str(self.tmp / "x"), "/home"))
|
||||
# A missing share on this machine: an unknown server name made
|
||||
# Windows wait about 2.8 s for the network (#487).
|
||||
self.assertIsNone(
|
||||
layout_module._guard_folder("\\\\server\\share\\loras", str(self.tmp))
|
||||
layout_module._guard_folder(
|
||||
"\\\\localhost\\pm-no-such-share\\loras", str(self.tmp)
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
layout_module._guard_folder(str(self.tmp), "/nonexistent-home"),
|
||||
@@ -843,17 +859,17 @@ class FolderGuardTests(unittest.TestCase):
|
||||
def test_windows_model_path_matches_a_lower_case_root(self):
|
||||
self.assertTrue(
|
||||
layout_module._inside(
|
||||
"C:/Models/Loras/A.safetensors", ("c:\\models\\loras",), ntpath
|
||||
"C:/Models/Loras/A.safetensors", ("c:\\models\\loras",), LEXICAL_NT
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
layout_module._inside(
|
||||
"C:/Models/LorasX/A.safetensors", ("c:\\models\\loras",), ntpath
|
||||
"C:/Models/LorasX/A.safetensors", ("c:\\models\\loras",), LEXICAL_NT
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
layout_module._inside(
|
||||
"\\\\other\\share\\A.safetensors", ("c:\\models\\loras",), ntpath
|
||||
"\\\\other\\share\\A.safetensors", ("c:\\models\\loras",), LEXICAL_NT
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import email
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -669,6 +670,15 @@ class TestStaleDownloadSweep(unittest.TestCase):
|
||||
self.assertTrue(old.exists())
|
||||
|
||||
|
||||
def _unused_tls_context():
|
||||
"""A TLS context for the fake handlers, which never open a connection.
|
||||
|
||||
HTTPSHandler() builds a default context, and on Windows that reads the
|
||||
system certificate store: about 0.14 s per handler (#487).
|
||||
"""
|
||||
return ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
|
||||
|
||||
class _FakeHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
"""Serves canned responses through a real urllib opener, recording requests.
|
||||
|
||||
@@ -677,7 +687,7 @@ class _FakeHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
"""
|
||||
|
||||
def __init__(self, redirect_to):
|
||||
super().__init__()
|
||||
super().__init__(context=_unused_tls_context())
|
||||
self.redirect_to = redirect_to
|
||||
self.requests = []
|
||||
|
||||
@@ -745,7 +755,7 @@ class _ChainHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
urllib opener, recording every request; an unknown URL answers 404."""
|
||||
|
||||
def __init__(self, routes):
|
||||
super().__init__()
|
||||
super().__init__(context=_unused_tls_context())
|
||||
self.routes = routes
|
||||
self.requests = []
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The shared folder skip every media walker uses (#501)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.media_folders import in_skipped_folder, is_skipped_folder, is_trash_folder
|
||||
|
||||
|
||||
class TrashFolderNameTests(unittest.TestCase):
|
||||
def test_trash_folder_names_in_any_case(self):
|
||||
for name in (
|
||||
".Trash",
|
||||
".trash",
|
||||
".Trash-1000",
|
||||
".trash-0",
|
||||
".Trashes",
|
||||
"$RECYCLE.BIN",
|
||||
"$Recycle.Bin",
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
self.assertTrue(is_trash_folder(name))
|
||||
self.assertTrue(is_skipped_folder(name))
|
||||
|
||||
def test_other_names_are_not_trash(self):
|
||||
for name in (".Trash-abc", ".Trash-", "trash", "Recycle", "RECYCLE.BIN", "x"):
|
||||
with self.subTest(name=name):
|
||||
self.assertFalse(is_trash_folder(name))
|
||||
|
||||
def test_thumbnails_is_skipped_exactly(self):
|
||||
self.assertTrue(is_skipped_folder("thumbnails"))
|
||||
self.assertFalse(is_skipped_folder("Thumbnails"))
|
||||
self.assertFalse(is_trash_folder("thumbnails"))
|
||||
|
||||
def test_folder_parts(self):
|
||||
self.assertTrue(in_skipped_folder(("a", ".Trash-1000", "files")))
|
||||
self.assertTrue(in_skipped_folder(("thumbnails",)))
|
||||
self.assertFalse(in_skipped_folder(("a", "b")))
|
||||
self.assertFalse(in_skipped_folder(()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""#443: the Library parent archive table, its migration and its validation."""
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from database.prompt_archive import (
|
||||
TABLE,
|
||||
migrate_prompt_archive,
|
||||
prompt_archive_schema_error,
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def _tables(conn):
|
||||
return {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
|
||||
|
||||
class TestPromptArchiveSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = str(Path(self.temp.name) / "archive.db")
|
||||
self.db = PromptDatabase(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
self.temp.cleanup()
|
||||
|
||||
def conn(self):
|
||||
return self.db.model.get_connection()
|
||||
|
||||
def save(self, text):
|
||||
return self.db.save_prompt(text, prompt_hash=generate_prompt_hash(text))
|
||||
|
||||
def test_startup_creates_the_table_and_it_validates(self):
|
||||
self.assertIn(TABLE, _tables(self.conn()))
|
||||
self.assertIsNone(prompt_archive_schema_error(self.conn(), allow_missing=False))
|
||||
|
||||
def test_migration_is_idempotent(self):
|
||||
conn = self.conn()
|
||||
migrate_prompt_archive(conn)
|
||||
migrate_prompt_archive(conn)
|
||||
self.assertIsNone(prompt_archive_schema_error(conn, allow_missing=False))
|
||||
|
||||
def test_a_missing_table_is_accepted_only_when_allowed(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
self.addCleanup(conn.close)
|
||||
self.assertIsNone(prompt_archive_schema_error(conn))
|
||||
self.assertEqual(
|
||||
prompt_archive_schema_error(conn, allow_missing=False),
|
||||
"Missing prompt archive schema",
|
||||
)
|
||||
|
||||
def test_deleting_a_prompt_drops_its_archive_row(self):
|
||||
pid = self.save("Archived then deleted")
|
||||
conn = self.conn()
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE}(prompt_id, archived_at) VALUES (?, ?)",
|
||||
(pid, "2026-10-28T00:00:00+00:00"),
|
||||
)
|
||||
conn.commit()
|
||||
self.db.delete_prompt(pid)
|
||||
self.assertEqual(conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 0)
|
||||
|
||||
def _broken(self, *statements):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
self.addCleanup(conn.close)
|
||||
conn.execute("CREATE TABLE prompts (id INTEGER PRIMARY KEY)")
|
||||
for statement in statements:
|
||||
conn.execute(statement)
|
||||
return conn
|
||||
|
||||
def test_unsupported_structures_are_refused(self):
|
||||
good = (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
|
||||
)
|
||||
cases = {
|
||||
"other definition": (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT)",
|
||||
),
|
||||
"no cascade": (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY(prompt_id) REFERENCES prompts(id))",
|
||||
),
|
||||
"extra index": (good, f"CREATE INDEX x ON {TABLE}(archived_at)"),
|
||||
"trigger": (
|
||||
good,
|
||||
f"CREATE TRIGGER t AFTER INSERT ON {TABLE} BEGIN SELECT 1; END",
|
||||
),
|
||||
"view name": (f"CREATE VIEW {TABLE} AS SELECT 1",),
|
||||
}
|
||||
for name, statements in cases.items():
|
||||
with self.subTest(name):
|
||||
self.assertIsNotNone(
|
||||
prompt_archive_schema_error(self._broken(*statements))
|
||||
)
|
||||
|
||||
def test_invalid_values_and_dangling_rows_are_refused(self):
|
||||
good = (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
|
||||
)
|
||||
for name, row in {
|
||||
"number": "(1, 5)",
|
||||
"blob": "(1, x'00')",
|
||||
"long": f"(1, '{'x' * 65}')",
|
||||
"dangling": "(99, '2026-10-28T00:00:00+00:00')",
|
||||
}.items():
|
||||
with self.subTest(name):
|
||||
conn = self._broken(good, "INSERT INTO prompts(id) VALUES (1)")
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES {row}")
|
||||
self.assertIsNotNone(prompt_archive_schema_error(conn))
|
||||
|
||||
def test_a_refused_structure_stops_the_migration_without_changes(self):
|
||||
conn = self._broken(
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, archived_at TEXT)"
|
||||
)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_prompt_archive(conn)
|
||||
sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name=?", (TABLE,)
|
||||
).fetchone()[0]
|
||||
self.assertNotIn("NOT NULL", sql)
|
||||
|
||||
|
||||
class TestPromptArchiveBackups(unittest.TestCase):
|
||||
"""Restore validation accepts an older backup and refuses a damaged table."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.db = PromptDatabase(str(self.root / "live.db"))
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
self.temp.cleanup()
|
||||
|
||||
def copy(self, name):
|
||||
target = self.root / name
|
||||
self.db.model.backup_database(str(target))
|
||||
return str(target)
|
||||
|
||||
def test_a_backup_without_the_table_verifies_and_migrates(self):
|
||||
path = self.copy("old.db")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.close()
|
||||
ok, reason = self.db.model.verify_database_file(path)
|
||||
self.assertTrue(ok, reason)
|
||||
model = PromptModel(path)
|
||||
try:
|
||||
self.assertIn(TABLE, _tables(model.get_connection()))
|
||||
finally:
|
||||
model.close_all()
|
||||
|
||||
def test_a_backup_with_a_damaged_table_is_refused(self):
|
||||
path = self.copy("bad.db")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.execute(f"CREATE TABLE {TABLE} (prompt_id INTEGER, archived_at TEXT)")
|
||||
conn.close()
|
||||
ok, reason = self.db.model.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("archive", reason.lower())
|
||||
|
||||
def test_an_archived_prompt_survives_a_backup_copy(self):
|
||||
pid = self.db.save_prompt("Kept", prompt_hash=generate_prompt_hash("Kept"))
|
||||
conn = self.db.model.get_connection()
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE}(prompt_id, archived_at) VALUES (?, ?)",
|
||||
(pid, "2026-10-28T00:00:00+00:00"),
|
||||
)
|
||||
conn.commit()
|
||||
path = self.copy("copy.db")
|
||||
shutil.copy(path, path + ".2")
|
||||
with sqlite3.connect(path + ".2") as other:
|
||||
self.assertEqual(
|
||||
other.execute(f"SELECT prompt_id FROM {TABLE}").fetchall(), [(pid,)]
|
||||
)
|
||||
other.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+53
-2
@@ -18,11 +18,13 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SUBPROCESS_TIMEOUT = 60
|
||||
WATCHDOG_ENV = ("PM_TEST_HANG_LIMIT", "PM_TEST_SHUTDOWN_LIMIT")
|
||||
# Integer settings the runner reads; a bad value is a usage error.
|
||||
RUNNER_ENV = (*WATCHDOG_ENV, "PM_TEST_SLOWEST")
|
||||
|
||||
|
||||
def run_suite(folder, extra_env=None, *args, verbosity=("-v",)):
|
||||
"""Run the runner on ``folder`` and return the completed process."""
|
||||
env = {k: v for k, v in os.environ.items() if k not in WATCHDOG_ENV}
|
||||
env = {k: v for k, v in os.environ.items() if k not in RUNNER_ENV}
|
||||
# Windows writes a piped stderr in the ANSI codepage; ask for the UTF-8
|
||||
# this helper decodes, so non-ASCII values and paths survive.
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
@@ -269,8 +271,57 @@ class RunSuiteTests(unittest.TestCase):
|
||||
self.assertIn("ok", result.stderr, detail)
|
||||
self.assertIn("Timeout (", result.stderr, detail)
|
||||
|
||||
def test_slowest_tests_are_listed_when_asked(self):
|
||||
self.write(
|
||||
"test_probe_slowest.py",
|
||||
"""
|
||||
import time
|
||||
import unittest
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_fast(self):
|
||||
pass
|
||||
|
||||
def test_slow(self):
|
||||
time.sleep(0.3)
|
||||
|
||||
def test_slower(self):
|
||||
time.sleep(0.6)
|
||||
""",
|
||||
)
|
||||
result = run_suite(self.folder, {"PM_TEST_SLOWEST": "2"})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
report = result.stderr.split("slowest 2 of 3 tests:\n", 1)
|
||||
self.assertEqual(len(report), 2, result.stderr)
|
||||
lines = [line.split() for line in report[1].strip().splitlines()[:2]]
|
||||
self.assertEqual(
|
||||
[line[1] for line in lines],
|
||||
[
|
||||
"test_probe_slowest.Probe.test_slower",
|
||||
"test_probe_slowest.Probe.test_slow",
|
||||
],
|
||||
result.stderr,
|
||||
)
|
||||
self.assertGreaterEqual(float(lines[0][0].rstrip("s")), 0.6)
|
||||
self.assertNotIn("test_fast", report[1])
|
||||
|
||||
def test_slowest_tests_are_not_listed_by_default(self):
|
||||
self.write(
|
||||
"test_probe_quiet.py",
|
||||
"""
|
||||
import unittest
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_ok(self):
|
||||
pass
|
||||
""",
|
||||
)
|
||||
result = run_suite(self.folder)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn("slowest", result.stderr)
|
||||
|
||||
def test_invalid_limit_is_a_usage_error(self):
|
||||
for name in WATCHDOG_ENV:
|
||||
for name in RUNNER_ENV:
|
||||
for value in ("abc", "0", "-3", "1.5", "\u00b2"):
|
||||
with self.subTest(name=name, value=value):
|
||||
result = run_suite(self.folder, {name: value})
|
||||
|
||||
@@ -490,16 +490,25 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
real_write = images_module._write_image_thumbnail
|
||||
job_ids = []
|
||||
written = []
|
||||
# #504: the worker can write its first preview before the response
|
||||
# carrying the job id is read. The first write waits until the test
|
||||
# has stored the id, so the cancel always lands after exactly one write.
|
||||
first_written, id_stored = threading.Event(), threading.Event()
|
||||
self.addCleanup(id_stored.set)
|
||||
|
||||
def counting(src, dst, *args, **kwargs):
|
||||
real_write(src, dst, *args, **kwargs)
|
||||
written.append(dst)
|
||||
if len(written) == 1:
|
||||
first_written.set()
|
||||
id_stored.wait(10)
|
||||
self.api._jobs.cancel(job_ids[0])
|
||||
|
||||
with patch.object(images_module, "_write_image_thumbnail", counting):
|
||||
_, body = await self._start()
|
||||
await self._wait(first_written)
|
||||
job_ids.append(body["job"]["id"])
|
||||
id_stored.set()
|
||||
job = await self._finished(body["job"]["id"])
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.status_line, "Cancelled · 1 preview written")
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""The freedesktop trash backend (#480): rename into a trash or refuse, never unlink.
|
||||
|
||||
Every test builds a disposable tree: a fake ``XDG_DATA_HOME`` and, through the
|
||||
backend's ``mounts`` seam, a fake "drive" mount point, so the real trash of the
|
||||
user running the tests is never touched. Moves are real renames on one
|
||||
filesystem.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils import trash # noqa: E402
|
||||
|
||||
POSIX = os.name == "posix" and sys.platform != "darwin"
|
||||
|
||||
|
||||
@unittest.skipUnless(POSIX, "freedesktop trash is the Linux/BSD backend")
|
||||
class FreedesktopTrashTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="pm-trash-")
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.data_home = os.path.join(self.tmp, "xdg")
|
||||
self.drive = os.path.join(self.tmp, "drive")
|
||||
self.root = os.path.join(self.drive, "output")
|
||||
self.home_root = os.path.join(self.tmp, "home-output")
|
||||
for folder in (self.data_home, self.root, self.home_root):
|
||||
os.makedirs(folder)
|
||||
self.mounts = ["/", os.path.realpath(self.drive)]
|
||||
self.backend = trash.FreedesktopBackend(
|
||||
data_home=self.data_home, mounts=lambda: list(self.mounts)
|
||||
)
|
||||
|
||||
def _file(self, folder, name="a.png", content=b"png-bytes"):
|
||||
path = os.path.join(folder, name)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(content)
|
||||
return path
|
||||
|
||||
def _info(self, trash_dir, name):
|
||||
with open(os.path.join(trash_dir, "info", name + ".trashinfo")) as handle:
|
||||
return handle.read()
|
||||
|
||||
# ── choosing the trash ────────────────────────────────────────────
|
||||
|
||||
def test_file_on_the_home_mount_goes_to_the_home_trash(self):
|
||||
path = self._file(self.home_root)
|
||||
label = self.backend.move(path, [self.home_root])
|
||||
home_trash = os.path.join(self.data_home, "Trash")
|
||||
self.assertEqual(label, trash.HOME_TRASH_LABEL)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
with open(os.path.join(home_trash, "files", "a.png"), "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"png-bytes")
|
||||
info = self._info(home_trash, "a.png")
|
||||
self.assertIn("[Trash Info]\n", info)
|
||||
self.assertIn("Path=" + os.path.realpath(path) + "\n", info)
|
||||
self.assertRegex(info, r"DeletionDate=\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\n")
|
||||
|
||||
def test_file_on_another_mount_uses_its_trash_dash_uid(self):
|
||||
path = self._file(self.root, "b c%.png")
|
||||
self.backend.move(path, [self.root])
|
||||
top = os.path.join(self.drive, f".Trash-{os.getuid()}")
|
||||
info_mode = stat.S_IMODE(os.lstat(top).st_mode)
|
||||
self.assertEqual(info_mode, 0o700)
|
||||
self.assertTrue(os.path.exists(os.path.join(top, "files", "b c%.png")))
|
||||
# A top-directory trash records the path relative to its top folder,
|
||||
# percent-encoded.
|
||||
self.assertIn("Path=output/b%20c%25.png\n", self._info(top, "b c%.png"))
|
||||
|
||||
def test_sticky_dot_trash_folder_is_used(self):
|
||||
shared = os.path.join(self.drive, ".Trash")
|
||||
os.mkdir(shared)
|
||||
os.chmod(shared, 0o1777)
|
||||
path = self._file(self.root)
|
||||
self.backend.move(path, [self.root])
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(shared, str(os.getuid()), "files", "a.png"))
|
||||
)
|
||||
|
||||
def test_dot_trash_without_sticky_bit_is_skipped(self):
|
||||
shared = os.path.join(self.drive, ".Trash")
|
||||
os.mkdir(shared, 0o777)
|
||||
os.chmod(shared, 0o777)
|
||||
path = self._file(self.root)
|
||||
self.backend.move(path, [self.root])
|
||||
self.assertFalse(os.path.exists(os.path.join(shared, str(os.getuid()))))
|
||||
top = os.path.join(self.drive, f".Trash-{os.getuid()}")
|
||||
self.assertTrue(os.path.exists(os.path.join(top, "files", "a.png")))
|
||||
|
||||
def test_dot_trash_that_is_a_link_is_skipped(self):
|
||||
elsewhere = os.path.join(self.tmp, "elsewhere")
|
||||
os.mkdir(elsewhere)
|
||||
os.chmod(elsewhere, 0o1777)
|
||||
os.symlink(elsewhere, os.path.join(self.drive, ".Trash"))
|
||||
path = self._file(self.root)
|
||||
self.backend.move(path, [self.root])
|
||||
self.assertEqual(os.listdir(elsewhere), [])
|
||||
|
||||
def test_trash_dash_uid_that_is_a_link_is_refused(self):
|
||||
elsewhere = os.path.join(self.tmp, "elsewhere")
|
||||
os.mkdir(elsewhere)
|
||||
os.symlink(elsewhere, os.path.join(self.drive, f".Trash-{os.getuid()}"))
|
||||
path = self._file(self.root)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
self.backend.move(path, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
self.assertEqual(os.listdir(elsewhere), [])
|
||||
|
||||
def test_trash_inside_an_output_root_is_refused(self):
|
||||
# The drive's top folder is itself an output root: its .Trash-uid
|
||||
# would be listed as images.
|
||||
path = self._file(self.root)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
self.backend.move(path, [self.drive])
|
||||
self.assertEqual(caught.exception.code, trash.TRASH_IN_ROOT)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(self.drive, f".Trash-{os.getuid()}"))
|
||||
)
|
||||
|
||||
def test_home_trash_inside_an_output_root_is_refused(self):
|
||||
path = self._file(self.home_root)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
self.backend.move(path, [self.home_root, self.tmp])
|
||||
self.assertEqual(caught.exception.code, trash.TRASH_IN_ROOT)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_read_only_top_folder_is_no_trash(self):
|
||||
path = self._file(self.root)
|
||||
os.chmod(self.drive, 0o555)
|
||||
self.addCleanup(os.chmod, self.drive, 0o755)
|
||||
if os.access(self.drive, os.W_OK):
|
||||
self.skipTest("running as root: permissions are not enforced")
|
||||
self.assertEqual(self.backend.available(path, [self.root]), trash.NO_TRASH)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
self.backend.move(path, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_available_reports_without_writing(self):
|
||||
path = self._file(self.root)
|
||||
self.assertIsNone(self.backend.available(path, [self.root]))
|
||||
self.assertEqual(
|
||||
self.backend.available(path, [self.drive]), trash.TRASH_IN_ROOT
|
||||
)
|
||||
self.assertEqual(os.listdir(self.drive), ["output"])
|
||||
|
||||
# ── names and the no-replace rename ───────────────────────────────
|
||||
|
||||
def test_existing_name_in_the_trash_is_never_replaced(self):
|
||||
top = os.path.join(self.drive, f".Trash-{os.getuid()}")
|
||||
os.makedirs(os.path.join(top, "files"), mode=0o700)
|
||||
os.makedirs(os.path.join(top, "info"), mode=0o700)
|
||||
os.chmod(top, 0o700)
|
||||
# A stray trashed file with no info file (another tool crashed).
|
||||
stray = self._file(os.path.join(top, "files"), "a.png", b"older-file")
|
||||
path = self._file(self.root)
|
||||
self.backend.move(path, [self.root])
|
||||
with open(stray, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"older-file")
|
||||
self.assertTrue(os.path.exists(os.path.join(top, "files", "a.1.png")))
|
||||
self.assertTrue(os.path.exists(os.path.join(top, "info", "a.1.png.trashinfo")))
|
||||
self.assertFalse(os.path.exists(os.path.join(top, "info", "a.png.trashinfo")))
|
||||
|
||||
def test_existing_info_name_is_skipped(self):
|
||||
path = self._file(self.root)
|
||||
self.backend.move(path, [self.root])
|
||||
second = self._file(self.root)
|
||||
self.backend.move(second, [self.root])
|
||||
top = os.path.join(self.drive, f".Trash-{os.getuid()}")
|
||||
self.assertEqual(
|
||||
sorted(os.listdir(os.path.join(top, "files"))), ["a.1.png", "a.png"]
|
||||
)
|
||||
|
||||
def test_missing_no_replace_rename_is_no_trash(self):
|
||||
path = self._file(self.root)
|
||||
for error in (38, 22): # ENOSYS, EINVAL
|
||||
with self.subTest(error=error):
|
||||
backend = trash.FreedesktopBackend(
|
||||
data_home=self.data_home,
|
||||
mounts=lambda: list(self.mounts),
|
||||
rename=mock.Mock(side_effect=OSError(error, "no")),
|
||||
)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(path, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
top = os.path.join(self.drive, f".Trash-{os.getuid()}")
|
||||
self.assertEqual(os.listdir(os.path.join(top, "info")), [])
|
||||
|
||||
def test_cross_device_rename_is_no_trash_and_never_copies(self):
|
||||
path = self._file(self.root)
|
||||
backend = trash.FreedesktopBackend(
|
||||
data_home=self.data_home,
|
||||
mounts=lambda: list(self.mounts),
|
||||
rename=mock.Mock(side_effect=OSError(18, "EXDEV")),
|
||||
)
|
||||
with mock.patch("shutil.move") as move, mock.patch("shutil.copy2") as copy:
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(path, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
move.assert_not_called()
|
||||
copy.assert_not_called()
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_other_rename_failure_is_failed_and_removes_only_our_info(self):
|
||||
path = self._file(self.root)
|
||||
backend = trash.FreedesktopBackend(
|
||||
data_home=self.data_home,
|
||||
mounts=lambda: list(self.mounts),
|
||||
rename=mock.Mock(side_effect=OSError(5, "EIO")),
|
||||
)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(path, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.FAILED)
|
||||
top = os.path.join(self.drive, f".Trash-{os.getuid()}")
|
||||
self.assertEqual(os.listdir(os.path.join(top, "info")), [])
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_never_removes_or_moves_the_source(self):
|
||||
path = self._file(self.root)
|
||||
real_unlink = os.unlink
|
||||
|
||||
def guarded_unlink(target, *args, **kwargs):
|
||||
# Only our own .trashinfo may ever be removed.
|
||||
self.assertTrue(str(target).endswith(".trashinfo"), target)
|
||||
return real_unlink(target, *args, **kwargs)
|
||||
|
||||
backend = trash.FreedesktopBackend(
|
||||
data_home=self.data_home,
|
||||
mounts=lambda: list(self.mounts),
|
||||
rename=mock.Mock(side_effect=OSError(5, "EIO")),
|
||||
)
|
||||
with (
|
||||
mock.patch("os.unlink", guarded_unlink),
|
||||
mock.patch("os.remove", guarded_unlink),
|
||||
mock.patch("shutil.move") as move,
|
||||
mock.patch("os.rename") as rename,
|
||||
):
|
||||
with self.assertRaises(trash.TrashRefused):
|
||||
backend.move(path, [self.root])
|
||||
move.assert_not_called()
|
||||
rename.assert_not_called()
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_mountinfo_parsing_decodes_escapes(self):
|
||||
text = (
|
||||
"36 35 98:0 / / rw - ext4 /dev/root rw\n"
|
||||
"37 36 98:1 / /mnt/my\\040drive rw - ext4 /dev/sdb1 rw\n"
|
||||
)
|
||||
self.assertEqual(trash.parse_mountinfo(text), ["/", "/mnt/my drive"])
|
||||
|
||||
def test_mount_of_picks_the_longest_containing_mount(self):
|
||||
mounts = ["/", "/mnt", "/mnt/data"]
|
||||
self.assertEqual(trash.mount_of("/mnt/data/x/y.png", mounts), "/mnt/data")
|
||||
self.assertEqual(trash.mount_of("/mnt/datax/y.png", mounts), "/mnt")
|
||||
self.assertEqual(trash.mount_of("/home/u/y.png", mounts), "/")
|
||||
|
||||
|
||||
class UnavailableBackendTests(unittest.TestCase):
|
||||
def test_reports_no_trash_and_leaves_the_file(self):
|
||||
backend = trash.UnavailableBackend()
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = os.path.join(folder, "a.png")
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(b"x")
|
||||
self.assertEqual(backend.available(path, [folder]), trash.NO_TRASH)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(path, [folder])
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_each_platform_gets_its_backend(self):
|
||||
from utils import trash_macos
|
||||
|
||||
self.assertIsInstance(trash.default_backend("win32"), trash.UnavailableBackend)
|
||||
self.assertIsInstance(
|
||||
trash.default_backend("darwin"), trash_macos.MacTrashBackend
|
||||
)
|
||||
self.assertIsInstance(trash.default_backend("linux"), trash.FreedesktopBackend)
|
||||
|
||||
def test_windows_says_why_the_recycle_bin_is_not_used(self):
|
||||
# #500 ruling (a): the Recycle Bin can delete permanently, so it is never used.
|
||||
backend = trash.default_backend("win32")
|
||||
self.assertEqual(
|
||||
backend.available("C:/x/a.png", ["C:/x"]), trash.WINDOWS_RECYCLE
|
||||
)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move("C:/x/a.png", ["C:/x"])
|
||||
self.assertEqual(caught.exception.code, trash.WINDOWS_RECYCLE)
|
||||
self.assertIn(trash.WINDOWS_RECYCLE, trash.CODES)
|
||||
|
||||
def test_module_functions_use_the_replaceable_backend(self):
|
||||
fake = mock.Mock()
|
||||
fake.available.return_value = "network"
|
||||
fake.move.return_value = "label"
|
||||
with mock.patch.object(trash, "BACKEND", fake):
|
||||
self.assertEqual(trash.trash_available("/x/a.png", ["/x"]), "network")
|
||||
self.assertEqual(trash.move_to_trash("/x/a.png", ["/x"]), "label")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,360 @@
|
||||
"""The macOS trash backend's decisions (#499), with a fake Foundation bridge.
|
||||
|
||||
No Objective-C is involved: the bridge is faked, ``uid``/``home``/``lstat``/
|
||||
``access`` are injected, and the only real files are temp files used for the
|
||||
"source still exists" and output-root identity checks. Runs on every OS.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils import trash, trash_macos # noqa: E402
|
||||
from utils.trash_macos import Facts, MacTrashBackend, NSErr # noqa: E402
|
||||
|
||||
LOCAL = Facts(local=True, read_only=False, root_fs=True, volume="/", ubiquitous=False)
|
||||
COCOA = "NSCocoaErrorDomain"
|
||||
NO_TRASH_DIR = NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 2)
|
||||
|
||||
|
||||
class FakeBridge:
|
||||
"""The §6.3 bridge with canned answers; ``trash`` moves nothing by default."""
|
||||
|
||||
def __init__(self, facts=LOCAL, trash_dir=("/Users/me/.Trash", None), result=None):
|
||||
self.facts_value = facts
|
||||
self.trash_dir_value = trash_dir
|
||||
self.result = result or (True, None, None)
|
||||
self.calls = []
|
||||
|
||||
def facts(self, path):
|
||||
self.calls.append(("facts", path))
|
||||
return self.facts_value
|
||||
|
||||
def trash_dir(self, path):
|
||||
self.calls.append(("trash_dir", path))
|
||||
return self.trash_dir_value
|
||||
|
||||
def trash(self, path):
|
||||
self.calls.append(("trash", path))
|
||||
result = self.result
|
||||
return result(path) if callable(result) else result
|
||||
|
||||
|
||||
def _stat(mode=stat.S_IFREG | 0o644, uid=501, flags=0):
|
||||
return SimpleNamespace(st_mode=mode, st_uid=uid, st_flags=flags)
|
||||
|
||||
|
||||
class MacBackendTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# realpath: the backend resolves paths (/var -> /private/var on macOS, 8.3
|
||||
# short names on Windows), so the lstat fakes must use the same spelling.
|
||||
self.tmp = os.path.realpath(tempfile.mkdtemp(prefix="pm-mac-trash-"))
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.home = os.path.join(self.tmp, "home")
|
||||
self.root = os.path.join(self.tmp, "output")
|
||||
os.makedirs(os.path.join(self.home, ".Trash"))
|
||||
os.makedirs(self.root)
|
||||
self.file = os.path.join(self.root, "a.png")
|
||||
with open(self.file, "wb") as handle:
|
||||
handle.write(b"png")
|
||||
self.home_trash = os.path.join(self.home, ".Trash")
|
||||
self.stats = {}
|
||||
self.access_ok = True
|
||||
|
||||
def _lstat(self, path):
|
||||
if path in self.stats:
|
||||
value = self.stats[path]
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
return value
|
||||
return os.lstat(path)
|
||||
|
||||
def backend(self, **bridge_args):
|
||||
bridge_args.setdefault("trash_dir", (self.home_trash, None))
|
||||
self.bridge = FakeBridge(**bridge_args)
|
||||
return MacTrashBackend(
|
||||
bridge=self.bridge,
|
||||
uid=501,
|
||||
home=self.home,
|
||||
lstat=self._lstat,
|
||||
access=lambda path, mode: self.access_ok,
|
||||
)
|
||||
|
||||
def moved_to(self, destination):
|
||||
"""A ``trash`` result that really renames the file to *destination*."""
|
||||
|
||||
def run(path):
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
os.rename(path, destination)
|
||||
return True, destination, None
|
||||
|
||||
return run
|
||||
|
||||
def assert_refused(self, backend, code):
|
||||
self.assertEqual(backend.available(self.file, [self.root]), code)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, code)
|
||||
self.assertNotIn("trash", [name for name, _ in self.bridge.calls])
|
||||
self.assertTrue(os.path.exists(self.file))
|
||||
return caught.exception
|
||||
|
||||
|
||||
class AvailableOrderTests(MacBackendTestCase):
|
||||
def test_a_writable_local_file_is_available(self):
|
||||
self.assertIsNone(self.backend().available(self.file, [self.root]))
|
||||
|
||||
def test_network_volume_is_refused_without_trying(self):
|
||||
facts = Facts(False, False, False, "/Volumes/nas", False)
|
||||
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
|
||||
|
||||
def test_unknown_locality_counts_as_network(self):
|
||||
facts = Facts(None, False, True, "/", False)
|
||||
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
|
||||
|
||||
def test_read_only_volume_or_unknown_is_no_trash(self):
|
||||
for read_only in (True, None):
|
||||
with self.subTest(read_only=read_only):
|
||||
facts = Facts(True, read_only, False, "/Volumes/ro", False)
|
||||
self.assert_refused(self.backend(facts=facts), trash.NO_TRASH)
|
||||
|
||||
def test_ubiquitous_item_is_cloud_sync(self):
|
||||
facts = Facts(True, False, True, "/", True)
|
||||
self.assert_refused(self.backend(facts=facts), trash.CLOUD_SYNC)
|
||||
|
||||
def test_icloud_and_file_provider_folders_are_cloud_sync(self):
|
||||
for parts in (("Library", "Mobile Documents"), ("Library", "CloudStorage")):
|
||||
with self.subTest(parts=parts):
|
||||
folder = os.path.join(self.home, *parts, "Box")
|
||||
os.makedirs(folder)
|
||||
path = os.path.join(folder, "b.png")
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(b"x")
|
||||
backend = self.backend()
|
||||
self.assertEqual(backend.available(path, [folder]), trash.CLOUD_SYNC)
|
||||
with self.assertRaises(trash.TrashRefused):
|
||||
backend.move(path, [folder])
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_found_trash_folder_inside_an_output_root_is_trash_in_root(self):
|
||||
self.assertEqual(
|
||||
self.backend().available(self.file, [self.home]), trash.TRASH_IN_ROOT
|
||||
)
|
||||
|
||||
def test_trash_dir_error_other_than_missing_is_no_trash(self):
|
||||
backend = self.backend(
|
||||
trash_dir=(None, NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 30))
|
||||
)
|
||||
self.assert_refused(backend, trash.NO_TRASH)
|
||||
|
||||
def test_missing_trash_on_the_startup_volume_is_no_trash(self):
|
||||
self.assert_refused(
|
||||
self.backend(trash_dir=(None, NO_TRASH_DIR)), trash.NO_TRASH
|
||||
)
|
||||
|
||||
|
||||
class FreshVolumeTests(MacBackendTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.volume = os.path.join(self.tmp, "vol")
|
||||
self.root = os.path.join(self.volume, "output")
|
||||
os.makedirs(self.root)
|
||||
self.file = os.path.join(self.root, "a.png")
|
||||
with open(self.file, "wb") as handle:
|
||||
handle.write(b"png")
|
||||
self.facts = Facts(True, False, False, self.volume, False)
|
||||
|
||||
def test_missing_volume_trash_is_predicted(self):
|
||||
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
||||
self.assertIsNone(backend.available(self.file, [self.root]))
|
||||
|
||||
def test_prediction_refused_when_trashes_is_a_link(self):
|
||||
os.symlink(self.tmp, os.path.join(self.volume, ".Trashes"))
|
||||
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
||||
self.assert_refused(backend, trash.NO_TRASH)
|
||||
|
||||
def test_predicted_trash_inside_the_volume_root_is_trash_in_root(self):
|
||||
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
||||
self.assert_refused_root = backend.available(self.file, [self.volume])
|
||||
self.assertEqual(self.assert_refused_root, trash.TRASH_IN_ROOT)
|
||||
|
||||
def test_move_to_a_predicted_trash_returns_the_volume_label(self):
|
||||
destination = os.path.join(self.volume, ".Trashes", "501", "a.png")
|
||||
backend = self.backend(
|
||||
facts=self.facts,
|
||||
trash_dir=(None, NO_TRASH_DIR),
|
||||
result=self.moved_to(destination),
|
||||
)
|
||||
self.assertEqual(
|
||||
backend.move(self.file, [self.root]), ".Trashes/501 on its drive"
|
||||
)
|
||||
self.assertFalse(os.path.exists(self.file))
|
||||
|
||||
|
||||
class PermissionTests(MacBackendTestCase):
|
||||
def test_parent_not_writable_is_no_permission(self):
|
||||
self.access_ok = False
|
||||
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
||||
|
||||
def test_immutable_or_append_flags_on_file_or_parent(self):
|
||||
for flag in ("UF_IMMUTABLE", "SF_IMMUTABLE", "UF_APPEND", "SF_APPEND"):
|
||||
for target in (self.file, self.root):
|
||||
with self.subTest(flag=flag, target=target):
|
||||
mode = (
|
||||
stat.S_IFDIR | 0o755
|
||||
if target == self.root
|
||||
else stat.S_IFREG | 0o644
|
||||
)
|
||||
self.stats = {target: _stat(mode=mode, flags=getattr(stat, flag))}
|
||||
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
||||
|
||||
def test_sticky_parent_and_foreign_owner(self):
|
||||
self.stats = {
|
||||
self.root: _stat(mode=stat.S_IFDIR | 0o1777),
|
||||
self.file: _stat(uid=502),
|
||||
}
|
||||
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
||||
|
||||
def test_sticky_parent_with_own_file_is_allowed(self):
|
||||
self.stats = {self.root: _stat(mode=stat.S_IFDIR | 0o1777), self.file: _stat()}
|
||||
self.assertIsNone(self.backend().available(self.file, [self.root]))
|
||||
|
||||
|
||||
class MoveOutcomeTests(MacBackendTestCase):
|
||||
def test_success_into_the_home_trash(self):
|
||||
destination = os.path.join(self.home_trash, "a.png")
|
||||
backend = self.backend(result=self.moved_to(destination))
|
||||
self.assertEqual(backend.move(self.file, [self.root]), trash.HOME_TRASH_LABEL)
|
||||
self.assertTrue(os.path.exists(destination))
|
||||
|
||||
def test_cocoa_errors_map_to_codes_with_detail(self):
|
||||
cases = {
|
||||
3328: trash.NO_TRASH,
|
||||
642: trash.NO_TRASH,
|
||||
513: trash.NO_PERMISSION,
|
||||
257: trash.NO_PERMISSION,
|
||||
4: trash.FAILED,
|
||||
640: trash.FAILED,
|
||||
}
|
||||
for code, expected in cases.items():
|
||||
with self.subTest(code=code):
|
||||
error = NSErr(COCOA, code, "NSOSStatusErrorDomain", -5000)
|
||||
backend = self.backend(result=(False, None, error))
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, expected)
|
||||
self.assertEqual(
|
||||
caught.exception.detail,
|
||||
f"NSCocoaErrorDomain {code} (NSOSStatusErrorDomain -5000)",
|
||||
)
|
||||
self.assertTrue(os.path.exists(self.file))
|
||||
|
||||
def test_other_domain_or_missing_error_is_failed(self):
|
||||
for error in (NSErr("NSPOSIXErrorDomain", 13), None):
|
||||
with self.subTest(error=error):
|
||||
backend = self.backend(result=(False, None, error))
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.FAILED)
|
||||
|
||||
def test_yes_with_the_source_still_present_is_failed(self):
|
||||
backend = self.backend(
|
||||
result=(True, os.path.join(self.home_trash, "a.png"), None)
|
||||
)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.FAILED)
|
||||
self.assertTrue(os.path.exists(self.file))
|
||||
|
||||
def test_yes_with_no_result_path_is_unconfirmed(self):
|
||||
def vanished(path):
|
||||
os.rename(path, os.path.join(self.tmp, "elsewhere.png"))
|
||||
return True, None, None
|
||||
|
||||
backend = self.backend(result=vanished)
|
||||
with self.assertRaises(trash.TrashUnconfirmed):
|
||||
backend.move(self.file, [self.root])
|
||||
|
||||
def test_yes_into_an_output_root_is_unconfirmed(self):
|
||||
backend = self.backend(
|
||||
result=self.moved_to(os.path.join(self.root, "x", "a.png"))
|
||||
)
|
||||
with self.assertRaises(trash.TrashUnconfirmed):
|
||||
backend.move(self.file, [self.root])
|
||||
|
||||
def test_yes_into_an_unplanned_folder_is_trashed_and_logged(self):
|
||||
other = os.path.join(self.tmp, "other-trash", "a.png")
|
||||
backend = self.backend(result=self.moved_to(other))
|
||||
with self.assertLogs("prompt_manager.trash", level="WARNING") as logs:
|
||||
label = backend.move(self.file, [self.root])
|
||||
self.assertEqual(label, os.path.realpath(os.path.dirname(other)))
|
||||
self.assertIn(other, "\n".join(logs.output))
|
||||
|
||||
|
||||
class IdentityTests(MacBackendTestCase):
|
||||
@unittest.skipUnless(hasattr(os, "symlink") and os.name == "posix", "symlinks")
|
||||
def test_root_given_through_a_symlink_still_contains_its_trash(self):
|
||||
alias = os.path.join(self.tmp, "home-alias")
|
||||
os.symlink(self.home, alias)
|
||||
self.assertEqual(
|
||||
self.backend().available(self.file, [alias]), trash.TRASH_IN_ROOT
|
||||
)
|
||||
|
||||
def test_missing_roots_are_ignored(self):
|
||||
missing = os.path.join(self.tmp, "nope")
|
||||
self.assertIsNone(self.backend().available(self.file, [missing, self.root]))
|
||||
|
||||
|
||||
class LoadingTests(unittest.TestCase):
|
||||
def test_default_backend_for_darwin_does_not_load_the_bridge(self):
|
||||
with mock.patch.object(trash_macos, "load_bridge") as loader:
|
||||
backend = trash.default_backend("darwin")
|
||||
self.assertIsInstance(backend, MacTrashBackend)
|
||||
loader.assert_not_called()
|
||||
|
||||
def test_failed_load_answers_no_trash_and_is_cached_and_logged_once(self):
|
||||
backend = MacTrashBackend(uid=501)
|
||||
with (
|
||||
mock.patch.object(
|
||||
trash_macos, "load_bridge", side_effect=OSError("no Foundation")
|
||||
) as loader,
|
||||
self.assertLogs("prompt_manager.trash", level="WARNING") as logs,
|
||||
):
|
||||
self.assertEqual(backend.available("/x/a.png", ["/x"]), trash.NO_TRASH)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move("/x/a.png", ["/x"])
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
self.assertEqual(loader.call_count, 1)
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
|
||||
def test_load_bridge_off_macos_raises(self):
|
||||
if sys.platform == "darwin":
|
||||
self.skipTest("the real bridge loads on macOS (native test covers it)")
|
||||
with self.assertRaises(OSError):
|
||||
trash_macos.load_bridge()
|
||||
|
||||
|
||||
class ErrorTextTests(unittest.TestCase):
|
||||
def test_detail_without_underlying(self):
|
||||
self.assertEqual(
|
||||
trash_macos.error_detail(NSErr(COCOA, 4)), "NSCocoaErrorDomain 4"
|
||||
)
|
||||
self.assertEqual(trash_macos.error_detail(None), "no NSError")
|
||||
|
||||
def test_refusal_keeps_detail(self):
|
||||
refused = trash.TrashRefused(trash.FAILED, "why")
|
||||
self.assertEqual(
|
||||
(refused.code, refused.detail, str(refused)), ("failed", "why", "why")
|
||||
)
|
||||
self.assertEqual(trash.TrashRefused(trash.FAILED).detail, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Native proof of the macOS trash backend (#499 design §10.2).
|
||||
|
||||
Runs only on macOS with ``PM_NATIVE_TRASH=macos`` (the ``native-trash`` job in
|
||||
portability.yml), because it puts files into the real Trash of the user
|
||||
running it. With the variable set, a bridge that cannot load fails the test
|
||||
rather than skipping it. Files moved into a trash folder are left there: the
|
||||
runner is disposable and this test never deletes from a trash folder.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils import trash # noqa: E402
|
||||
|
||||
NATIVE = sys.platform == "darwin" and os.environ.get("PM_NATIVE_TRASH") == "macos"
|
||||
HDIUTIL_SECONDS = 60
|
||||
# Measured: macos-15-intel kept a fresh image busy after a trash (run 40954618922).
|
||||
DETACH_WAIT_SECONDS = 30
|
||||
|
||||
|
||||
class SpyBridge:
|
||||
"""The real bridge, recording every ``trash`` result."""
|
||||
|
||||
def __init__(self, real):
|
||||
self.real = real
|
||||
self.results = []
|
||||
|
||||
def facts(self, path):
|
||||
return self.real.facts(path)
|
||||
|
||||
def trash_dir(self, path):
|
||||
return self.real.trash_dir(path)
|
||||
|
||||
def trash(self, path):
|
||||
result = self.real.trash(path)
|
||||
self.results.append(result)
|
||||
return result
|
||||
|
||||
|
||||
def _hdiutil(*args):
|
||||
return subprocess.run(
|
||||
["hdiutil", *args], capture_output=True, text=True, timeout=HDIUTIL_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def _our_open_files(mount):
|
||||
"""Files this process holds open under *mount* (``lsof -p``): the handle proof."""
|
||||
listed = subprocess.run(
|
||||
["lsof", "-Fn", "-p", str(os.getpid())],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=HDIUTIL_SECONDS,
|
||||
)
|
||||
prefix = os.path.realpath(mount) + os.sep
|
||||
return [
|
||||
line[1:]
|
||||
for line in listed.stdout.splitlines()
|
||||
if line.startswith("n") and os.path.realpath(line[1:]).startswith(prefix)
|
||||
]
|
||||
|
||||
|
||||
def _detach(mount):
|
||||
"""Detach *mount*; None on success, else why (after a forced detach).
|
||||
|
||||
This process must hold nothing under it. A volume macOS has just written a
|
||||
trash folder to can stay busy for a moment (Spotlight, fseventsd), so the
|
||||
plain detach is retried until the system releases it, within a bound.
|
||||
"""
|
||||
ours = _our_open_files(mount)
|
||||
if ours:
|
||||
_hdiutil("detach", mount, "-force")
|
||||
return f"this test still held files open under {mount}: {ours}"
|
||||
deadline = time.monotonic() + DETACH_WAIT_SECONDS
|
||||
while True:
|
||||
if not os.path.ismount(mount):
|
||||
return None # a "busy" detach can still finish after it returns
|
||||
done = _hdiutil("detach", mount)
|
||||
if done.returncode == 0:
|
||||
return None
|
||||
if time.monotonic() > deadline:
|
||||
holders = subprocess.run(
|
||||
["lsof", "+f", "--", mount],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=HDIUTIL_SECONDS,
|
||||
).stdout
|
||||
_hdiutil("detach", mount, "-force")
|
||||
return f"detach {mount}: {done.stderr.strip()}; held by: {holders!r}"
|
||||
time.sleep(1) # poll the volume's release (a state), never a fixed wait
|
||||
|
||||
|
||||
@unittest.skipUnless(NATIVE, "macOS native trash proof (PM_NATIVE_TRASH=macos)")
|
||||
class MacTrashNativeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from utils import trash_macos
|
||||
|
||||
self.trash_macos = trash_macos
|
||||
self.spy = SpyBridge(trash_macos.load_bridge()) # fails, never skips
|
||||
self.backend = trash_macos.MacTrashBackend(bridge=self.spy)
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="pm-native-trash-")
|
||||
self.tmp = os.path.realpath(self._tmp.name)
|
||||
self._mounts = []
|
||||
self._modes = []
|
||||
|
||||
def tearDown(self):
|
||||
failures = []
|
||||
try:
|
||||
for path, mode in reversed(self._modes):
|
||||
os.chmod(path, mode)
|
||||
for mount in reversed(self._mounts):
|
||||
problem = _detach(mount)
|
||||
if problem:
|
||||
failures.append(problem)
|
||||
finally:
|
||||
self._tmp.cleanup()
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def _file(self, folder, name="a.png", content=None):
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
path = os.path.join(folder, name)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(content or os.urandom(64))
|
||||
return path
|
||||
|
||||
def _image(self, name, fs="APFS"):
|
||||
image = os.path.join(self.tmp, name + ".dmg")
|
||||
mount = os.path.join(self.tmp, name + "-mnt")
|
||||
os.makedirs(mount)
|
||||
made = _hdiutil("create", "-size", "40m", "-fs", fs, "-volname", name, image)
|
||||
self.assertEqual(made.returncode, 0, made.stderr)
|
||||
attached = _hdiutil("attach", image, "-nobrowse", "-mountpoint", mount)
|
||||
self.assertEqual(attached.returncode, 0, attached.stderr)
|
||||
self._mounts.append(mount)
|
||||
return image, mount
|
||||
|
||||
def _assert_trashed(self, source, content, info, folder):
|
||||
self.assertFalse(os.path.lexists(source))
|
||||
ok, resulting, error = self.spy.results[-1]
|
||||
self.assertTrue(ok, error)
|
||||
self.assertEqual(os.path.realpath(os.path.dirname(resulting)), folder)
|
||||
moved = os.lstat(resulting)
|
||||
self.assertEqual((moved.st_dev, moved.st_ino), (info.st_dev, info.st_ino))
|
||||
with open(resulting, "rb") as handle:
|
||||
self.assertEqual(handle.read(), content)
|
||||
return resulting
|
||||
|
||||
def _assert_kept(self, path, content):
|
||||
with open(path, "rb") as handle:
|
||||
self.assertEqual(handle.read(), content)
|
||||
|
||||
def _assert_refused(self, path, roots, code):
|
||||
with open(path, "rb") as handle:
|
||||
content = handle.read()
|
||||
self.assertEqual(self.backend.available(path, roots), code)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
self.backend.move(path, roots)
|
||||
self.assertEqual(caught.exception.code, code)
|
||||
self._assert_kept(path, content)
|
||||
|
||||
def test_n1_startup_volume_goes_to_the_home_trash(self):
|
||||
root = os.path.join(self.tmp, "output")
|
||||
source = self._file(root)
|
||||
with open(source, "rb") as handle:
|
||||
content = handle.read()
|
||||
info = os.lstat(source)
|
||||
self.assertIsNone(self.backend.available(source, [root]))
|
||||
self.assertEqual(self.backend.move(source, [root]), trash.HOME_TRASH_LABEL)
|
||||
home_trash = os.path.realpath(os.path.expanduser("~/.Trash"))
|
||||
self._assert_trashed(source, content, info, home_trash)
|
||||
|
||||
def test_n2_a_name_clash_never_replaces(self):
|
||||
first_root = os.path.join(self.tmp, "one")
|
||||
second_root = os.path.join(self.tmp, "two")
|
||||
first = self._file(first_root, "same.png", b"first")
|
||||
second = self._file(second_root, "same.png", b"second")
|
||||
self.backend.move(first, [first_root])
|
||||
self.backend.move(second, [second_root])
|
||||
(_, one, _), (_, two, _) = self.spy.results
|
||||
self.assertNotEqual(one, two)
|
||||
self._assert_kept(one, b"first")
|
||||
self._assert_kept(two, b"second")
|
||||
|
||||
def test_n3_fresh_external_volume_uses_its_own_trash(self):
|
||||
for fs in ("APFS", "MS-DOS FAT32"):
|
||||
with self.subTest(fs=fs):
|
||||
_image, mount = self._image("fresh" + fs.split()[0], fs)
|
||||
root = os.path.join(mount, "output")
|
||||
source = self._file(root)
|
||||
with open(source, "rb") as handle:
|
||||
content = handle.read()
|
||||
info = os.lstat(source)
|
||||
self.assertFalse(os.path.lexists(os.path.join(mount, ".Trashes")))
|
||||
self.assertIsNone(self.backend.available(source, [root]))
|
||||
label = self.backend.move(source, [root])
|
||||
uid = str(os.getuid())
|
||||
self.assertEqual(label, f".Trashes/{uid} on its drive")
|
||||
folder = os.path.realpath(os.path.join(mount, ".Trashes", uid))
|
||||
self._assert_trashed(source, content, info, folder)
|
||||
|
||||
def test_n4_volume_root_as_output_root_is_trash_in_root(self):
|
||||
_image, mount = self._image("rooted")
|
||||
source = self._file(os.path.join(mount, "output"))
|
||||
self._assert_refused(source, [mount], trash.TRASH_IN_ROOT)
|
||||
self.assertEqual(self.spy.results, [])
|
||||
|
||||
def test_n5_read_only_volume_is_no_trash(self):
|
||||
image, mount = self._image("readonly")
|
||||
source = self._file(mount, "ro.png", b"read-only bytes")
|
||||
self.assertIsNone(_detach(mount))
|
||||
self._mounts.remove(mount)
|
||||
attached = _hdiutil(
|
||||
"attach", image, "-nobrowse", "-readonly", "-mountpoint", mount
|
||||
)
|
||||
self.assertEqual(attached.returncode, 0, attached.stderr)
|
||||
self._mounts.append(mount)
|
||||
self._assert_refused(source, [os.path.join(mount, "output")], trash.NO_TRASH)
|
||||
|
||||
def test_n6_unwritable_folder_is_no_permission_and_macos_agrees(self):
|
||||
root = os.path.join(self.tmp, "locked")
|
||||
source = self._file(root, "p.png", b"kept")
|
||||
self._modes.append((root, 0o755))
|
||||
os.chmod(root, 0o555)
|
||||
self._assert_refused(source, [self.tmp], trash.NO_PERMISSION)
|
||||
self.assertEqual(self.spy.results, [])
|
||||
ok, _resulting, error = self.spy.real.trash(source)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual((error.domain, error.code), ("NSCocoaErrorDomain", 513))
|
||||
self._assert_kept(source, b"kept")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+127
@@ -0,0 +1,127 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { attachBrowserLogs, BROWSER_EXIT_PREFIX, createBrowserLogRecorder, isAbnormalExit, isTargetGone, parseExitLine,
|
||||
playwrightDebug } from '../../tools/vendor/browser-logs.mjs';
|
||||
|
||||
const debug = playwrightDebug();
|
||||
|
||||
/** Runs `body` with DEBUG set to `namespaces` and restores the caller's setting afterwards. */
|
||||
function withDebug(namespaces, body) {
|
||||
const saved = debug.disable();
|
||||
const savedLog = debug.log;
|
||||
if (namespaces) debug.enable(namespaces);
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
debug.log = savedLog;
|
||||
debug.disable();
|
||||
if (saved) debug.enable(saved);
|
||||
}
|
||||
}
|
||||
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'pm-browser-logs-'));
|
||||
test.after(() => rmSync(outputDir, { recursive: true, force: true }));
|
||||
|
||||
function testInfo({ status = 'failed', errors = [] } = {}) {
|
||||
const attachments = [];
|
||||
const dir = mkdtempSync(join(outputDir, 'test-'));
|
||||
return { status, expectedStatus: 'passed', errors, attachments, outputPath: name => join(dir, name),
|
||||
attach: async (name, options) => { attachments.push({ name, ...options }); } };
|
||||
}
|
||||
|
||||
test('parses the exit line Playwright logs for a browser process', () => {
|
||||
assert.deepEqual(parseExitLine('[pid=7124] <process did exit: exitCode=null, signal=SIGKILL> +21ms'),
|
||||
{ pid: 7124, exitCode: null, signal: 'SIGKILL' });
|
||||
assert.deepEqual(parseExitLine('[pid=9] <process did exit: exitCode=0, signal=null>'), { pid: 9, exitCode: 0, signal: null });
|
||||
assert.deepEqual(parseExitLine('[pid=9] <process did exit: exitCode=11, signal=null>'), { pid: 9, exitCode: 11, signal: null });
|
||||
assert.equal(parseExitLine('[pid=9][err] JavaScript warning'), null);
|
||||
assert.equal(isAbnormalExit({ pid: 9, exitCode: 0, signal: null }), false);
|
||||
assert.equal(isAbnormalExit({ pid: 9, exitCode: null, signal: 'SIGKILL' }), true);
|
||||
assert.equal(isAbnormalExit({ pid: 9, exitCode: 139, signal: null }), true);
|
||||
});
|
||||
|
||||
test('recognises the errors of a page, context or browser that went away', () => {
|
||||
for (const message of ['Error: browser.newContext: Target page, context or browser has been closed',
|
||||
'Protocol error (Page.navigate): Target closed', 'page.goto: Page crashed', 'Target crashed']) {
|
||||
assert.equal(isTargetGone(message), true, message);
|
||||
}
|
||||
for (const message of ['expect(locator).toBeVisible() failed', undefined, 'Test timeout of 30000ms exceeded.']) {
|
||||
assert.equal(isTargetGone(message), false, String(message));
|
||||
}
|
||||
});
|
||||
|
||||
test('records the browser channel into a bounded tail without printing it', () => withDebug('', () => {
|
||||
const printed = [];
|
||||
debug.log = (...args) => printed.push(args.join(' '));
|
||||
const written = [];
|
||||
const recorder = createBrowserLogRecorder({ debug, limit: 3, write: text => written.push(text) });
|
||||
const browser = debug('pw:browser');
|
||||
browser('<launched> pid=5');
|
||||
for (const n of [1, 2, 3, 4]) browser(`[pid=5][err] line ${n}`);
|
||||
assert.deepEqual(printed, [], 'the browser channel stays quiet when DEBUG did not ask for it');
|
||||
assert.match(recorder.tail(), /line 2[\s\S]*line 3[\s\S]*line 4/);
|
||||
assert.doesNotMatch(recorder.tail(), /line 1/);
|
||||
assert.deepEqual(written, []);
|
||||
assert.deepEqual(recorder.launches(), [5], 'launches outlive the bounded tail');
|
||||
browser('[pid=5] <process did exit: exitCode=null, signal=SIGKILL>');
|
||||
assert.deepEqual(recorder.exits(), [{ pid: 5, exitCode: null, signal: 'SIGKILL' }]);
|
||||
assert.deepEqual(written, [`${BROWSER_EXIT_PREFIX}browser pid=5 exited: exitCode=null, signal=SIGKILL\n`]);
|
||||
browser('[pid=6] <process did exit: exitCode=0, signal=null>');
|
||||
assert.equal(written.length, 1, 'a clean exit prints nothing');
|
||||
recorder.uninstall();
|
||||
assert.equal(debug.enabled('pw:browser'), false, 'uninstall restores the caller\'s DEBUG');
|
||||
}));
|
||||
|
||||
test('keeps printing the channels the user enabled, the browser one included', () => withDebug('pw:api,pw:browser', () => {
|
||||
const printed = [];
|
||||
debug.log = function (...args) { printed.push(`${this.namespace} ${args.join(' ')}`); };
|
||||
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
|
||||
debug('pw:api')('call');
|
||||
debug('pw:browser')('[pid=1][out] hello');
|
||||
debug('pw:protocol')('not enabled');
|
||||
assert.equal(printed.length, 2);
|
||||
assert.match(printed[0], /^pw:api /);
|
||||
assert.match(printed[1], /^pw:browser /);
|
||||
assert.doesNotMatch(recorder.tail(), /call/);
|
||||
recorder.uninstall();
|
||||
assert.equal(debug.enabled('pw:api'), true);
|
||||
assert.equal(debug.enabled('pw:browser'), true);
|
||||
}));
|
||||
|
||||
test('attaches the tail and the exit only to a test whose target went away', async () => withDebug('', async () => {
|
||||
debug.log = () => {};
|
||||
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
|
||||
const browser = debug('pw:browser');
|
||||
browser('\u001b[30m[pid=8][err] Crash Annotation GraphicsCriticalError\u001b[0m');
|
||||
browser('[pid=8] <process did exit: exitCode=null, signal=SIGKILL>');
|
||||
|
||||
const passed = testInfo({ status: 'passed' });
|
||||
assert.equal(await attachBrowserLogs(passed, recorder), false);
|
||||
const assertion = testInfo({ errors: [{ message: 'expect(locator).toBeVisible() failed' }] });
|
||||
assert.equal(await attachBrowserLogs(assertion, recorder), false);
|
||||
assert.deepEqual(assertion.attachments, []);
|
||||
|
||||
const closed = testInfo({ errors: [{ message: 'Error: browser.newContext: Target page, context or browser has been closed' }] });
|
||||
assert.equal(await attachBrowserLogs(closed, recorder), true);
|
||||
assert.equal(closed.attachments.length, 1);
|
||||
const [{ name, path, contentType }] = closed.attachments;
|
||||
assert.equal(name, 'browser-stderr');
|
||||
assert.equal(contentType, 'text/plain');
|
||||
const body = readFileSync(path, 'utf8');
|
||||
assert.match(body, /^browser pid=8 exited: exitCode=null, signal=SIGKILL\n/);
|
||||
assert.match(body, /\[pid=8\]\[err\] Crash Annotation GraphicsCriticalError/);
|
||||
assert.doesNotMatch(body, /\u001b/, 'the attached log has no terminal colours');
|
||||
recorder.uninstall();
|
||||
}));
|
||||
|
||||
test('says so when the worker logged no abnormal exit', async () => withDebug('', async () => {
|
||||
debug.log = () => {};
|
||||
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
|
||||
const closed = testInfo({ errors: [{ message: 'x', stack: 'Error: Target closed\n at y' }] });
|
||||
assert.equal(await attachBrowserLogs(closed, recorder), true);
|
||||
assert.match(readFileSync(closed.attachments[0].path, 'utf8'), /^no abnormal browser exit was logged in this worker\n/);
|
||||
recorder.uninstall();
|
||||
}));
|
||||
Vendored
+34
@@ -353,6 +353,40 @@ test('Windows and macOS run 3.12-3.14 on main, nightly and on demand, never on a
|
||||
assert.doesNotMatch(text, /cancel-in-progress: true|cancel-in-progress: \$/, 'no portability run is cancelled');
|
||||
});
|
||||
|
||||
// #499: the native trash proof runs on both macOS CPUs, apart from the suite so a red suite never skips it.
|
||||
test('portability runs the native trash proof on arm64 and Intel macOS', () => {
|
||||
const body = jobs(readWorkflow('portability.yml')).get('native-trash');
|
||||
assert.ok(body, 'portability.yml has a native-trash job');
|
||||
assert.deepEqual(matrixList(body, 'os'), ['macos-latest', 'macos-15-intel']);
|
||||
assert.match(body, /^ {6}fail-fast: false$/m);
|
||||
assert.doesNotMatch(body, /^ {4}needs:/m, 'it does not wait for the suite job');
|
||||
assert.match(body, /^ {10}PM_NATIVE_TRASH: macos$/m);
|
||||
assert.match(body, /^ {8}run: python -m unittest tests\.test_trash_macos_native tests\.test_trash_macos -v$/m);
|
||||
const jobMinutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(body)[1]);
|
||||
const stepMinutes = Number(/^ {8}timeout-minutes: (\d+)$/m.exec(body)?.[1]);
|
||||
assert.ok(stepMinutes > 0 && jobMinutes - stepMinutes >= 2, 'the proof step stops before the job limit');
|
||||
});
|
||||
|
||||
// #493: the portability limit follows the rule in its comment: ceil(1.5 x the slowest of three
|
||||
// green main runs) + 3 minutes, never under 5, with those runs and times written above jobs:.
|
||||
// The times are whole-job wall times. That the runs were green and on main is the author's word.
|
||||
test('the portability limit is computed from three measured green main runs', () => {
|
||||
const text = readWorkflow('portability.yml');
|
||||
const comment = text.slice(0, text.indexOf('\njobs:'));
|
||||
const runs = [...comment.matchAll(/^# Measured \d{4}-\d{2}-\d{2} on main runs (\d+) \([0-9a-f]{7}\), (\d+) \([0-9a-f]{7}\), (\d+) \([0-9a-f]{7}\):$/gm)];
|
||||
assert.equal(runs.length, 1, 'the comment names the three runs it measured, once');
|
||||
assert.equal(new Set(runs[0].slice(1)).size, 3, 'three different runs');
|
||||
const time = '(\\d+)m([0-5]\\d)s \\(([^)]+)\\)';
|
||||
const slowest = new RegExp(`^#\\s+slowest leg: ${time} / ${time} / ${time} -> (\\d+) min$`, 'm').exec(comment);
|
||||
assert.ok(slowest, 'the comment gives the slowest leg of each run and the limit');
|
||||
const seconds = [1, 4, 7].map(i => Number(slowest[i]) * 60 + Number(slowest[i + 1]));
|
||||
const expected = Math.max(5, Math.ceil(1.5 * Math.max(...seconds) / 60) + 3);
|
||||
assert.equal(Number(slowest[10]), expected, 'the written limit follows the rule');
|
||||
assert.equal(comment.match(/-> \d+ min/g).length, 1, 'no older measurement stays beside it');
|
||||
const limit = Number(/^ {4}timeout-minutes: (\d+)\s*$/m.exec(jobs(text).get('test'))[1]);
|
||||
assert.equal(limit, expected, 'the job uses the written limit');
|
||||
});
|
||||
|
||||
test('real ComfyUI runs only outside pull requests; the other test.yml checks run on every event', () => {
|
||||
const found = testJobs();
|
||||
assert.match(found.get('comfyui'), QUEUE_ONLY_IF);
|
||||
|
||||
Vendored
+91
@@ -0,0 +1,91 @@
|
||||
// What a browser printed before it died (#513). Playwright writes a browser's stderr, its launch and its exit
|
||||
// (`<process did exit: exitCode=…, signal=…>`) only to its `pw:browser` debug channel, which is off unless DEBUG is
|
||||
// set, so a browser that dies between two tests leaves the next test only "Target page, context or browser has been
|
||||
// closed". A worker records that channel into a bounded tail, prints one line when a browser exits abnormally and
|
||||
// attaches the tail to a test that fails on a closed or crashed target. Nothing is retried or hidden.
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { format, stripVTControlCharacters } from 'node:util';
|
||||
|
||||
const BROWSER_NAMESPACE = 'pw:browser';
|
||||
export const BROWSER_LOG_LIMIT = 300;
|
||||
export const BROWSER_EXIT_PREFIX = 'pm-browser-exit| ';
|
||||
const LAUNCH_LINE = /<launched> pid=(\d+)/;
|
||||
const EXIT_LINE = /\[pid=(\d+)\] <process did exit: exitCode=([^,]+), signal=([^>]+)>/;
|
||||
// The client's TargetClosedError, a protocol error of a closed or crashed target, and a crashed page.
|
||||
const TARGET_GONE = /Target page, context or browser has been closed|Target (?:closed|crashed)|Browser closed|browser has disconnected|Page crashed/i;
|
||||
|
||||
/** The `debug` module Playwright logs through (the instance this worker's tests launch browsers with). */
|
||||
export function playwrightDebug() {
|
||||
return createRequire(import.meta.url)('playwright-core/lib/utilsBundle').debug;
|
||||
}
|
||||
|
||||
/** The pid, exit code and signal of a `process did exit` line, or null. */
|
||||
export function parseExitLine(line) {
|
||||
const match = EXIT_LINE.exec(line);
|
||||
if (!match) return null;
|
||||
const value = text => (text === 'null' ? null : text);
|
||||
const code = value(match[2]);
|
||||
return { pid: Number(match[1]), exitCode: code === null ? null : Number(code), signal: value(match[3]) };
|
||||
}
|
||||
|
||||
export const isAbnormalExit = exit => exit !== null && (exit.exitCode !== 0 || exit.signal !== null);
|
||||
|
||||
/** True for an error message that says the page, context or browser under the test went away. */
|
||||
export const isTargetGone = message => TARGET_GONE.test(String(message ?? ''));
|
||||
|
||||
/**
|
||||
* Records `pw:browser` into a tail of at most `limit` lines. Other debug channels keep printing as before; the
|
||||
* browser channel prints only when the user's own DEBUG asked for it. `write` gets one line per abnormal exit.
|
||||
*/
|
||||
export function createBrowserLogRecorder({ debug = playwrightDebug(), limit = BROWSER_LOG_LIMIT,
|
||||
write = text => process.stderr.write(text) } = {}) {
|
||||
const lines = [];
|
||||
const launches = [];
|
||||
const exits = [];
|
||||
const userWantsBrowser = debug.enabled(BROWSER_NAMESPACE);
|
||||
const previous = debug.disable();
|
||||
const original = debug.log;
|
||||
debug.log = function record(...args) {
|
||||
if (this?.namespace !== BROWSER_NAMESPACE || userWantsBrowser) {
|
||||
(original ?? (() => {})).apply(this, args);
|
||||
}
|
||||
if (this?.namespace !== BROWSER_NAMESPACE) return;
|
||||
const line = stripVTControlCharacters(format(...args));
|
||||
lines.push(line);
|
||||
if (lines.length > limit) lines.splice(0, lines.length - limit);
|
||||
const launched = LAUNCH_LINE.exec(line);
|
||||
if (launched) launches.push(Number(launched[1]));
|
||||
const exit = parseExitLine(line);
|
||||
if (!exit) return;
|
||||
exits.push(exit);
|
||||
if (isAbnormalExit(exit)) {
|
||||
write(`${BROWSER_EXIT_PREFIX}browser pid=${exit.pid} exited: exitCode=${exit.exitCode}, signal=${exit.signal}\n`);
|
||||
}
|
||||
};
|
||||
debug.enable([previous, BROWSER_NAMESPACE].filter(Boolean).join(','));
|
||||
return {
|
||||
tail: () => lines.join('\n'),
|
||||
launches: () => [...launches],
|
||||
exits: () => [...exits],
|
||||
uninstall() {
|
||||
debug.log = original;
|
||||
debug.disable();
|
||||
if (previous) debug.enable(previous);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Attaches the browser log tail to a test that failed because its page, context or browser went away. */
|
||||
export async function attachBrowserLogs(testInfo, recorder) {
|
||||
if (testInfo.status === testInfo.expectedStatus) return false;
|
||||
if (!testInfo.errors.some(error => isTargetGone(error.message) || isTargetGone(error.stack))) return false;
|
||||
const exits = recorder.exits().filter(isAbnormalExit)
|
||||
.map(exit => `browser pid=${exit.pid} exited: exitCode=${exit.exitCode}, signal=${exit.signal}`);
|
||||
const summary = exits.length ? exits.join('\n') : 'no abnormal browser exit was logged in this worker';
|
||||
// A file, so the log travels with the uploaded test-results (the list reporter shortens a long body).
|
||||
const path = testInfo.outputPath('browser-stderr.txt');
|
||||
await writeFile(path, `${summary}\n--- last ${BROWSER_LOG_LIMIT} pw:browser lines ---\n${recorder.tail()}\n`);
|
||||
await testInfo.attach('browser-stderr', { path, contentType: 'text/plain' });
|
||||
return true;
|
||||
}
|
||||
+17
-5
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"metadata-autocomplete.js": {
|
||||
"lines": 98,
|
||||
"branches": 82,
|
||||
"branches": 83,
|
||||
"functions": 85,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -281,6 +281,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-delete.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
},
|
||||
"workspace/images-export.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -365,6 +371,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library-delete.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 92,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library-editor-state.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 92,
|
||||
@@ -391,8 +403,8 @@
|
||||
},
|
||||
"workspace/library-selection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 86,
|
||||
"functions": 84,
|
||||
"branches": 87,
|
||||
"functions": 89,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library-state.mjs": {
|
||||
@@ -404,7 +416,7 @@
|
||||
"workspace/library.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 85,
|
||||
"functions": 96,
|
||||
"functions": 97,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/local-inspection.mjs": {
|
||||
@@ -547,7 +559,7 @@
|
||||
},
|
||||
"workspace/system-backups.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 88,
|
||||
"branches": 89,
|
||||
"functions": 94,
|
||||
"statements": 95
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ from .foreign_runs import (
|
||||
store_foreign,
|
||||
)
|
||||
from .hashing import generate_prompt_hash
|
||||
from .media_folders import in_skipped_folder
|
||||
from .metadata_extractor import ComfyUIMetadataExtractor
|
||||
from .parallel import note_refusal, refused_at_shutdown
|
||||
from .logging_config import get_logger
|
||||
@@ -428,11 +429,12 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
|
||||
Returns:
|
||||
True if the file has a supported image extension and is not
|
||||
inside a thumbnails directory, False otherwise
|
||||
inside a thumbnails or trash folder, False otherwise
|
||||
"""
|
||||
# Skip files in thumbnails directory - those are derivatives, not
|
||||
# generated images
|
||||
if "/thumbnails/" in filepath or "\\thumbnails\\" in filepath:
|
||||
# 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):
|
||||
return False
|
||||
return filepath.lower().endswith(self.supported_extensions)
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The folders no media walker lists below an output root (#501).
|
||||
|
||||
``thumbnails`` holds PromptManager's own previews. A trash folder that sits
|
||||
inside an output root (``D:\\$RECYCLE.BIN`` when ``D:\\`` is a root,
|
||||
``.Trash-1000`` at a mount point, ``.Trashes`` on a macOS volume) holds files
|
||||
the user threw away: they are not outputs, so they are never listed, scanned,
|
||||
linked, tagged or previewed. Every walker asks these predicates instead of
|
||||
testing names itself; only folders *below* a root are tested.
|
||||
"""
|
||||
|
||||
TRASH_FOLDER_NAMES = frozenset({".trash", ".trashes", "$recycle.bin"})
|
||||
PREVIEW_FOLDER = "thumbnails"
|
||||
|
||||
|
||||
def is_trash_folder(name: str) -> bool:
|
||||
"""A trash folder name, in any case: ``.Trash``, ``.Trash-<uid>``,
|
||||
``.Trashes`` or ``$RECYCLE.BIN``."""
|
||||
folded = name.casefold()
|
||||
if folded in TRASH_FOLDER_NAMES:
|
||||
return True
|
||||
return folded.startswith(".trash-") and folded[7:].isdigit()
|
||||
|
||||
|
||||
def is_skipped_folder(name: str) -> bool:
|
||||
"""A folder no walker descends: ``thumbnails`` (exact) or a trash folder."""
|
||||
return name == PREVIEW_FOLDER or is_trash_folder(name)
|
||||
|
||||
|
||||
def in_skipped_folder(parts) -> bool:
|
||||
"""Whether any of the folder names *parts* is skipped."""
|
||||
return any(is_skipped_folder(part) for part in parts)
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
"""Move one media file into the system trash, or refuse (#480).
|
||||
|
||||
Two rules hold for every backend:
|
||||
|
||||
- **Rename or fail.** The only primitive is a no-replace rename within one
|
||||
filesystem. Nothing is ever copied, unlinked or overwritten: a file that
|
||||
cannot be moved recoverably stays where it is, and ``TrashRefused`` says why.
|
||||
- **Never into a root.** A trash folder inside an output root would be listed
|
||||
as images, so it is refused (``trash-in-root``).
|
||||
|
||||
Linux and BSD use :class:`FreedesktopBackend`, the freedesktop.org Trash
|
||||
specification 1.0: the home trash (``$XDG_DATA_HOME/Trash``) for a file on the
|
||||
same mount, else the file's mount's ``.Trash/$uid`` (only under a real, sticky
|
||||
``.Trash``) or ``.Trash-$uid``. Names are reserved by creating the
|
||||
``.trashinfo`` with ``O_EXCL`` and the file is moved with
|
||||
``renameat2(RENAME_NOREPLACE)``; without that call the move is refused.
|
||||
macOS uses :class:`utils.trash_macos.MacTrashBackend` (#499): NSFileManager's
|
||||
``trashItemAtURL`` through ctypes, after refusing what it cannot show to be
|
||||
recoverable. Windows never uses the Recycle Bin (#500: files over its capacity
|
||||
are deleted permanently, undetectably), so it answers ``windows-recycle``
|
||||
through :class:`UnavailableBackend`.
|
||||
|
||||
``BACKEND`` is the one replaceable seam; tests install a fake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import datetime
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
NO_TRASH = "no-trash"
|
||||
NETWORK = "network"
|
||||
TRASH_IN_ROOT = "trash-in-root"
|
||||
PATH_TOO_LONG = "path-too-long"
|
||||
LOCKED = "locked"
|
||||
FAILED = "failed"
|
||||
NO_PERMISSION = "no-permission"
|
||||
CLOUD_SYNC = "cloud-sync"
|
||||
# Windows never uses the Recycle Bin (#500): it can delete permanently.
|
||||
WINDOWS_RECYCLE = "windows-recycle"
|
||||
CODES = frozenset(
|
||||
{
|
||||
NO_TRASH,
|
||||
NETWORK,
|
||||
TRASH_IN_ROOT,
|
||||
PATH_TOO_LONG,
|
||||
LOCKED,
|
||||
FAILED,
|
||||
NO_PERMISSION,
|
||||
CLOUD_SYNC,
|
||||
WINDOWS_RECYCLE,
|
||||
}
|
||||
)
|
||||
|
||||
HOME_TRASH_LABEL = "home trash"
|
||||
RENAME_NOREPLACE = 1
|
||||
MAX_NAME_ATTEMPTS = 1000
|
||||
_DIR_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
# rename() errors that mean "this trash cannot take it", never a copy.
|
||||
_NO_TRASH_ERRNOS = {
|
||||
errno.ENOSYS,
|
||||
errno.EINVAL,
|
||||
errno.EXDEV,
|
||||
errno.EACCES,
|
||||
errno.EPERM,
|
||||
errno.EROFS,
|
||||
getattr(errno, "ENOTSUP", errno.EOPNOTSUPP),
|
||||
errno.EOPNOTSUPP,
|
||||
}
|
||||
|
||||
|
||||
class TrashRefused(Exception):
|
||||
"""The file was not moved; ``code`` is one of ``CODES``."""
|
||||
|
||||
def __init__(self, code: str, detail: str = ""):
|
||||
super().__init__(detail or code)
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class TrashUnconfirmed(Exception):
|
||||
"""The system reported the file moved, but not to a place shown to be a trash.
|
||||
|
||||
The file is no longer where it was, so this is not a refusal: the caller
|
||||
keeps the file's records and stops (#499 design §7.4).
|
||||
"""
|
||||
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def parse_mountinfo(text: str) -> list[str]:
|
||||
"""Mount points of ``/proc/self/mountinfo`` text (octal escapes decoded)."""
|
||||
points = []
|
||||
for line in text.splitlines():
|
||||
fields = line.split(" ")
|
||||
if len(fields) > 4:
|
||||
raw = fields[4]
|
||||
points.append(
|
||||
bytes(raw, "utf-8")
|
||||
.decode("unicode_escape")
|
||||
.encode("latin-1")
|
||||
.decode("utf-8", "surrogateescape")
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def _proc_mounts() -> list[str]:
|
||||
try:
|
||||
with open(
|
||||
"/proc/self/mountinfo", encoding="utf-8", errors="surrogateescape"
|
||||
) as f:
|
||||
return parse_mountinfo(f.read())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _inside(path: str, folder: str) -> bool:
|
||||
folder = folder.rstrip("/") or "/"
|
||||
return path == folder or path.startswith(folder if folder == "/" else folder + "/")
|
||||
|
||||
|
||||
def mount_of(path: str, mounts: list[str]) -> str:
|
||||
"""The longest mount point holding *path* (both real paths)."""
|
||||
best = "/"
|
||||
for point in mounts:
|
||||
if _inside(path, point) and len(point) > len(best):
|
||||
best = point
|
||||
return best
|
||||
|
||||
|
||||
def _ismount_walk(path: str) -> str:
|
||||
"""Fallback without mountinfo (BSD): walk up until ``os.path.ismount``."""
|
||||
current = path
|
||||
while not os.path.ismount(current):
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return current
|
||||
|
||||
|
||||
def _libc_renameat2():
|
||||
if os.name != "posix":
|
||||
return None # Windows: no libc, and no freedesktop backend
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
function = libc.renameat2
|
||||
except (OSError, AttributeError, TypeError):
|
||||
return None
|
||||
function.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
function.restype = ctypes.c_int
|
||||
return function
|
||||
|
||||
|
||||
_RENAMEAT2 = _libc_renameat2()
|
||||
|
||||
|
||||
def rename_noreplace(src_dir_fd: int, src: str, dst_dir_fd: int, dst: str) -> None:
|
||||
"""``renameat2(..., RENAME_NOREPLACE)``; raises OSError (ENOSYS when missing)."""
|
||||
if _RENAMEAT2 is None:
|
||||
raise OSError(errno.ENOSYS, "renameat2 is not available")
|
||||
result = _RENAMEAT2(
|
||||
src_dir_fd, os.fsencode(src), dst_dir_fd, os.fsencode(dst), RENAME_NOREPLACE
|
||||
)
|
||||
if result != 0:
|
||||
code = ctypes.get_errno()
|
||||
raise OSError(code, os.strerror(code))
|
||||
|
||||
|
||||
def _canonical(path: str) -> str:
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def _check_roots(trash_dir: str, roots) -> None:
|
||||
trash_key = _canonical(trash_dir)
|
||||
for root in roots:
|
||||
if _inside(trash_key, _canonical(str(root))):
|
||||
raise TrashRefused(TRASH_IN_ROOT)
|
||||
|
||||
|
||||
def _owned_folder(path: str, uid: int, *, create: bool) -> bool:
|
||||
"""Whether *path* is (or, with *create*, now is) a real folder we own."""
|
||||
try:
|
||||
info = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
return False
|
||||
try:
|
||||
os.mkdir(path, 0o700)
|
||||
except OSError:
|
||||
return False
|
||||
info = os.lstat(path)
|
||||
except OSError:
|
||||
return False
|
||||
return stat.S_ISDIR(info.st_mode) and info.st_uid == uid
|
||||
|
||||
|
||||
class UnavailableBackend:
|
||||
"""No trash on this system: every move is refused with *code*, untouched."""
|
||||
|
||||
def __init__(self, code: str = NO_TRASH):
|
||||
self.code = code
|
||||
|
||||
def available(self, path: str, roots) -> str | None:
|
||||
return self.code
|
||||
|
||||
def move(self, path: str, roots) -> str:
|
||||
raise TrashRefused(self.code)
|
||||
|
||||
|
||||
class FreedesktopBackend:
|
||||
"""The freedesktop.org Trash specification 1.0 (Linux, BSD)."""
|
||||
|
||||
def __init__(self, data_home=None, mounts=None, rename=None, uid=None, now=None):
|
||||
self._data_home = data_home
|
||||
self._mounts = mounts or _proc_mounts
|
||||
self._rename = rename or rename_noreplace
|
||||
if uid is None:
|
||||
uid = os.getuid() if hasattr(os, "getuid") else -1
|
||||
self._uid = uid
|
||||
self._now = now or datetime.datetime.now
|
||||
|
||||
def _home_data(self) -> str:
|
||||
if self._data_home:
|
||||
return self._data_home
|
||||
configured = os.environ.get("XDG_DATA_HOME")
|
||||
if configured and os.path.isabs(configured):
|
||||
return configured
|
||||
return os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
|
||||
def _mount(self, real: str, mounts) -> str:
|
||||
return mount_of(real, mounts) if mounts else _ismount_walk(real)
|
||||
|
||||
def _plan(self, path: str, roots, *, create: bool):
|
||||
"""``(trash_dir, topdir_or_None, label)`` for *path*; raises TrashRefused."""
|
||||
real = os.path.realpath(path)
|
||||
mounts = [os.path.realpath(point) for point in self._mounts()]
|
||||
home_data = os.path.realpath(self._home_data())
|
||||
if self._mount(real, mounts) == self._mount(home_data, mounts):
|
||||
trash_dir = os.path.join(home_data, "Trash")
|
||||
_check_roots(trash_dir, roots)
|
||||
if create:
|
||||
os.makedirs(trash_dir, mode=0o700, exist_ok=True)
|
||||
return trash_dir, None, HOME_TRASH_LABEL
|
||||
top = self._mount(real, mounts)
|
||||
shared = os.path.join(top, ".Trash")
|
||||
try:
|
||||
info = os.lstat(shared)
|
||||
sticky = stat.S_ISDIR(info.st_mode) and bool(info.st_mode & stat.S_ISVTX)
|
||||
except OSError:
|
||||
sticky = False
|
||||
candidates = []
|
||||
if sticky:
|
||||
candidates.append(os.path.join(shared, str(self._uid)))
|
||||
candidates.append(os.path.join(top, f".Trash-{self._uid}"))
|
||||
for trash_dir in candidates:
|
||||
_check_roots(trash_dir, roots)
|
||||
if self._usable(trash_dir, create=create):
|
||||
label = f"{os.path.relpath(trash_dir, top)} on its drive"
|
||||
return trash_dir, top, label
|
||||
raise TrashRefused(NO_TRASH)
|
||||
|
||||
def _usable(self, trash_dir: str, *, create: bool) -> bool:
|
||||
if os.path.lexists(trash_dir):
|
||||
return _owned_folder(trash_dir, self._uid, create=False)
|
||||
parent = os.path.dirname(trash_dir)
|
||||
if not create:
|
||||
return os.access(parent, os.W_OK | os.X_OK)
|
||||
return _owned_folder(trash_dir, self._uid, create=True)
|
||||
|
||||
def available(self, path: str, roots) -> str | None:
|
||||
"""None when *path* can be trashed, else the refusal code (no writes)."""
|
||||
try:
|
||||
self._plan(path, roots, create=False)
|
||||
except TrashRefused as refused:
|
||||
return refused.code
|
||||
except OSError:
|
||||
return NO_TRASH
|
||||
return None
|
||||
|
||||
def move(self, path: str, roots) -> str:
|
||||
"""Move *path* into its trash and return the trash's label."""
|
||||
try:
|
||||
trash_dir, top, label = self._plan(path, roots, create=True)
|
||||
except OSError as error:
|
||||
raise TrashRefused(NO_TRASH, str(error)) from error
|
||||
real = os.path.realpath(path)
|
||||
recorded = os.path.relpath(real, top) if top else real
|
||||
opened = []
|
||||
try:
|
||||
trash_fd = self._open_dir(trash_dir, opened)
|
||||
files_fd = self._subfolder(trash_fd, "files", opened)
|
||||
info_fd = self._subfolder(trash_fd, "info", opened)
|
||||
source_fd = self._open_dir(os.path.dirname(real), opened)
|
||||
self._move_named(
|
||||
source_fd, os.path.basename(real), files_fd, info_fd, recorded
|
||||
)
|
||||
finally:
|
||||
for fd in opened:
|
||||
os.close(fd)
|
||||
return label
|
||||
|
||||
def _open_dir(self, path: str, opened: list) -> int:
|
||||
try:
|
||||
fd = os.open(path, _DIR_FLAGS)
|
||||
except OSError as error:
|
||||
raise TrashRefused(NO_TRASH, str(error)) from error
|
||||
opened.append(fd)
|
||||
return fd
|
||||
|
||||
def _subfolder(self, parent_fd: int, name: str, opened: list) -> int:
|
||||
try:
|
||||
os.mkdir(name, 0o700, dir_fd=parent_fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError as error:
|
||||
raise TrashRefused(NO_TRASH, str(error)) from error
|
||||
try:
|
||||
info = os.lstat(name, dir_fd=parent_fd)
|
||||
except OSError as error:
|
||||
raise TrashRefused(NO_TRASH, str(error)) from error
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != self._uid:
|
||||
raise TrashRefused(NO_TRASH, f"{name} is not our folder")
|
||||
try:
|
||||
fd = os.open(name, _DIR_FLAGS, dir_fd=parent_fd)
|
||||
except OSError as error:
|
||||
raise TrashRefused(NO_TRASH, str(error)) from error
|
||||
opened.append(fd)
|
||||
return fd
|
||||
|
||||
def _info_text(self, recorded: str) -> bytes:
|
||||
encoded = urllib.parse.quote(os.fsencode(recorded), safe="/")
|
||||
stamp = self._now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
return f"[Trash Info]\nPath={encoded}\nDeletionDate={stamp}\n".encode("utf-8")
|
||||
|
||||
def _move_named(self, source_fd, name, files_fd, info_fd, recorded) -> None:
|
||||
stem, extension = os.path.splitext(name)
|
||||
for attempt in range(MAX_NAME_ATTEMPTS):
|
||||
candidate = name if attempt == 0 else f"{stem}.{attempt}{extension}"
|
||||
info_name = candidate + ".trashinfo"
|
||||
try:
|
||||
fd = os.open(
|
||||
info_name,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
dir_fd=info_fd,
|
||||
)
|
||||
except FileExistsError:
|
||||
continue
|
||||
except OSError as error:
|
||||
raise TrashRefused(NO_TRASH, str(error)) from error
|
||||
try:
|
||||
os.write(fd, self._info_text(recorded))
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
self._rename(source_fd, name, files_fd, candidate)
|
||||
return
|
||||
except OSError as error:
|
||||
os.unlink(info_name, dir_fd=info_fd)
|
||||
if error.errno == errno.EEXIST:
|
||||
continue # a stray trashed file has that name: never replace it
|
||||
if error.errno in _NO_TRASH_ERRNOS:
|
||||
raise TrashRefused(NO_TRASH, str(error)) from error
|
||||
raise TrashRefused(FAILED, str(error)) from error
|
||||
raise TrashRefused(FAILED, "no free name in the trash")
|
||||
|
||||
|
||||
def default_backend(platform: str = sys.platform):
|
||||
"""The backend for *platform* (#480, #499, #500).
|
||||
|
||||
macOS gets :class:`utils.trash_macos.MacTrashBackend`, which loads its
|
||||
Foundation bridge only on first use (#499). Windows never uses the Recycle
|
||||
Bin (#500).
|
||||
"""
|
||||
if platform == "darwin":
|
||||
from . import trash_macos
|
||||
|
||||
return trash_macos.MacTrashBackend()
|
||||
if platform == "win32" or platform.startswith("cygwin"):
|
||||
# #500, ruling (a): Windows deletes files larger than the Recycle Bin's
|
||||
# capacity permanently, and IFileOperation's PreDeleteItem guard cannot
|
||||
# tell (native probe runs 40951037575, 40953347904).
|
||||
return UnavailableBackend(WINDOWS_RECYCLE)
|
||||
return FreedesktopBackend()
|
||||
|
||||
|
||||
BACKEND = default_backend()
|
||||
|
||||
|
||||
def trash_available(path: str, roots) -> str | None:
|
||||
"""None when *path* can be trashed, else the refusal code (blocking, no writes)."""
|
||||
return BACKEND.available(path, roots)
|
||||
|
||||
|
||||
def move_to_trash(path: str, roots) -> str:
|
||||
"""Move one regular file into the trash; returns where. Raises TrashRefused."""
|
||||
return BACKEND.move(path, roots)
|
||||
@@ -0,0 +1,489 @@
|
||||
"""The macOS Trash backend (#499): NSFileManager through ctypes, or refuse.
|
||||
|
||||
``-[NSFileManager trashItemAtURL:resultingItemURL:error:]`` moves a file into
|
||||
its volume's trash folder (``~/.Trash`` on the startup volume,
|
||||
``<volume>/.Trashes/<uid>`` elsewhere) or fails with an ``NSError``; it renames
|
||||
name clashes itself and never replaces a file. Before calling it,
|
||||
:class:`MacTrashBackend` refuses what it cannot show to be recoverable:
|
||||
network volumes, read-only volumes, iCloud Drive and File Provider sync
|
||||
folders, a trash folder inside an output root, and files macOS would refuse
|
||||
for permission. Nothing here unlinks, copies or overwrites a file.
|
||||
|
||||
The :class:`Bridge` is the only code that touches the Objective-C runtime. It
|
||||
sends ten fixed messages, each through ``objc_msgSend`` cast to its exact
|
||||
prototype (required on arm64), checks every object for nil before a send (a
|
||||
nil can raise an Objective-C exception, which would abort the process), and
|
||||
runs each call inside its own autorelease pool. It is loaded lazily, on the
|
||||
first ``available``/``move``; when it cannot load, every file answers
|
||||
``no-trash`` as before. The facts behind these rules were measured on macOS
|
||||
14, 15 (x86_64) and 26 (docs/egm/499-macos-trash/design.md §4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import trash
|
||||
|
||||
logger = logging.getLogger("prompt_manager.trash")
|
||||
|
||||
COCOA = "NSCocoaErrorDomain"
|
||||
POSIX = "NSPOSIXErrorDomain"
|
||||
FEATURE_UNSUPPORTED = 3328
|
||||
_ENOENT = 2
|
||||
# Top-level Cocoa codes of a refused move (design §7.5); anything else is "failed".
|
||||
_MOVE_CODES = {
|
||||
FEATURE_UNSUPPORTED: trash.NO_TRASH,
|
||||
642: trash.NO_TRASH, # NSFileWriteVolumeReadOnlyError
|
||||
513: trash.NO_PERMISSION, # NSFileWriteNoPermissionError
|
||||
257: trash.NO_PERMISSION, # NSFileReadNoPermissionError
|
||||
}
|
||||
_LOCK_FLAGS = stat.UF_IMMUTABLE | stat.SF_IMMUTABLE | stat.UF_APPEND | stat.SF_APPEND
|
||||
_CLOUD_FOLDERS = (("Library", "Mobile Documents"), ("Library", "CloudStorage"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NSErr:
|
||||
domain: str
|
||||
code: int
|
||||
under_domain: str | None = None
|
||||
under_code: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Facts:
|
||||
"""Resource values of a file's URL; ``None`` means the lookup failed or was nil."""
|
||||
|
||||
local: bool | None
|
||||
read_only: bool | None
|
||||
root_fs: bool | None
|
||||
volume: str | None
|
||||
ubiquitous: bool | None
|
||||
|
||||
|
||||
def error_detail(error: NSErr | None) -> str:
|
||||
"""``"<domain> <code> (<under_domain> <under_code>)"`` for the log."""
|
||||
if error is None:
|
||||
return "no NSError"
|
||||
text = f"{error.domain} {error.code}"
|
||||
if error.under_domain is not None:
|
||||
text += f" ({error.under_domain} {error.under_code})"
|
||||
return text
|
||||
|
||||
|
||||
def refusal_code(error: NSErr | None) -> str:
|
||||
if error is None or error.domain != COCOA:
|
||||
return trash.FAILED
|
||||
return _MOVE_CODES.get(error.code, trash.FAILED)
|
||||
|
||||
|
||||
def _within(path: str, folder: str) -> bool:
|
||||
path = os.path.normcase(path)
|
||||
folder = os.path.normcase(folder)
|
||||
trimmed = folder.rstrip("\\/") or folder
|
||||
return path == trimmed or path.startswith(trimmed.rstrip(os.sep) + os.sep)
|
||||
|
||||
|
||||
def _identity(path: str):
|
||||
info = os.stat(path)
|
||||
return info.st_dev, info.st_ino
|
||||
|
||||
|
||||
def inside_any_root(path: str, roots) -> bool:
|
||||
"""Whether *path* (existing or not) lies in an output root, by file identity.
|
||||
|
||||
Every existing folder from *path* up to the top is compared with the
|
||||
roots' ``(st_dev, st_ino)``, so case, NFC/NFD and symlinked spellings of a
|
||||
root all match.
|
||||
"""
|
||||
identities = set()
|
||||
for root in roots:
|
||||
try:
|
||||
identities.add(_identity(str(root)))
|
||||
except OSError:
|
||||
continue
|
||||
current = os.path.abspath(path)
|
||||
while True:
|
||||
try:
|
||||
if _identity(current) in identities:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return False
|
||||
current = parent
|
||||
|
||||
|
||||
def _same_folder(left: str, right: str) -> bool:
|
||||
try:
|
||||
return _identity(left) == _identity(right)
|
||||
except OSError:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
|
||||
|
||||
class MacTrashBackend:
|
||||
"""``available``/``move`` for macOS; the bridge and file checks are seams."""
|
||||
|
||||
def __init__(
|
||||
self, bridge=None, uid=None, home=None, lstat=os.lstat, access=os.access
|
||||
):
|
||||
self._bridge = bridge
|
||||
self._load_failed = False
|
||||
self._lock = threading.Lock()
|
||||
if uid is None:
|
||||
uid = os.getuid() if hasattr(os, "getuid") else -1
|
||||
self._uid = uid
|
||||
self._home = home
|
||||
self._lstat = lstat
|
||||
self._access = access
|
||||
|
||||
def _get_bridge(self):
|
||||
if self._bridge is None and not self._load_failed:
|
||||
with self._lock:
|
||||
if self._bridge is None and not self._load_failed:
|
||||
try:
|
||||
self._bridge = load_bridge()
|
||||
except (OSError, AttributeError, ValueError) as error:
|
||||
self._load_failed = True
|
||||
logger.warning("The macOS trash is unavailable: %s", error)
|
||||
if self._bridge is None:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, "Foundation bridge not loaded")
|
||||
return self._bridge
|
||||
|
||||
def _home_dir(self) -> str:
|
||||
return os.path.realpath(self._home or os.path.expanduser("~"))
|
||||
|
||||
def _plan(self, path: str, roots):
|
||||
"""``(trash folder, facts)`` for *path*; raises TrashRefused (design §7.2)."""
|
||||
bridge = self._get_bridge()
|
||||
real = os.path.realpath(path)
|
||||
facts = bridge.facts(real)
|
||||
if facts.local is not True:
|
||||
raise trash.TrashRefused(trash.NETWORK)
|
||||
if facts.read_only is not False:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, "read-only volume")
|
||||
home = self._home_dir()
|
||||
if facts.ubiquitous is True or any(
|
||||
_within(real, os.path.join(home, *parts)) for parts in _CLOUD_FOLDERS
|
||||
):
|
||||
raise trash.TrashRefused(trash.CLOUD_SYNC)
|
||||
folder, error = bridge.trash_dir(real)
|
||||
if folder is None:
|
||||
folder = self._predict(facts, error)
|
||||
if inside_any_root(folder, roots):
|
||||
raise trash.TrashRefused(trash.TRASH_IN_ROOT)
|
||||
self._check_permission(real)
|
||||
return folder, facts
|
||||
|
||||
def _predict(self, facts: Facts, error: NSErr | None) -> str:
|
||||
"""``<volume>/.Trashes/<uid>`` for a volume whose trash is not made yet."""
|
||||
missing = (
|
||||
error is not None
|
||||
and error.domain == COCOA
|
||||
and error.code == FEATURE_UNSUPPORTED
|
||||
and error.under_domain == POSIX
|
||||
and error.under_code == _ENOENT
|
||||
)
|
||||
if not missing or facts.root_fs is not False or not facts.volume:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, error_detail(error))
|
||||
trashes = os.path.join(facts.volume, ".Trashes")
|
||||
try:
|
||||
info = self._lstat(trashes)
|
||||
except FileNotFoundError:
|
||||
info = None
|
||||
except OSError as failure:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
|
||||
if info is not None and not stat.S_ISDIR(info.st_mode):
|
||||
raise trash.TrashRefused(trash.NO_TRASH, ".Trashes is not a folder")
|
||||
return os.path.join(trashes, str(self._uid))
|
||||
|
||||
def _check_permission(self, real: str) -> None:
|
||||
parent = os.path.dirname(real)
|
||||
if not self._access(parent, os.W_OK | os.X_OK):
|
||||
raise trash.TrashRefused(trash.NO_PERMISSION, "folder not writable")
|
||||
try:
|
||||
file_info = self._lstat(real)
|
||||
parent_info = self._lstat(parent)
|
||||
except OSError as failure:
|
||||
raise trash.TrashRefused(trash.FAILED, str(failure)) from failure
|
||||
for info in (file_info, parent_info):
|
||||
if getattr(info, "st_flags", 0) & _LOCK_FLAGS:
|
||||
raise trash.TrashRefused(trash.NO_PERMISSION, "immutable flag")
|
||||
if parent_info.st_mode & stat.S_ISVTX and file_info.st_uid != self._uid:
|
||||
raise trash.TrashRefused(trash.NO_PERMISSION, "sticky folder, foreign file")
|
||||
|
||||
def _label(self, folder: str, facts: Facts) -> str:
|
||||
if _same_folder(folder, os.path.join(self._home_dir(), ".Trash")):
|
||||
return trash.HOME_TRASH_LABEL
|
||||
real = os.path.realpath(folder)
|
||||
volume = os.path.realpath(facts.volume) if facts.volume else None
|
||||
if volume and volume != os.path.realpath(os.sep) and _within(real, volume):
|
||||
relative = os.path.relpath(real, volume).replace(os.sep, "/")
|
||||
return f"{relative} on its drive"
|
||||
return real
|
||||
|
||||
def available(self, path: str, roots) -> str | None:
|
||||
"""None when *path* can be trashed, else the refusal code (no writes)."""
|
||||
try:
|
||||
self._plan(path, roots)
|
||||
except trash.TrashRefused as refused:
|
||||
return refused.code
|
||||
except OSError:
|
||||
return trash.NO_TRASH
|
||||
return None
|
||||
|
||||
def move(self, path: str, roots) -> str:
|
||||
"""Move *path* into its trash folder and return the label (design §7.4)."""
|
||||
try:
|
||||
planned, facts = self._plan(path, roots)
|
||||
except OSError as failure:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
|
||||
real = os.path.realpath(path)
|
||||
ok, resulting, error = self._get_bridge().trash(real)
|
||||
if not ok:
|
||||
raise trash.TrashRefused(refusal_code(error), error_detail(error))
|
||||
if os.path.lexists(real):
|
||||
raise trash.TrashRefused(trash.FAILED, "reported moved, still in place")
|
||||
if not resulting or inside_any_root(os.path.dirname(resulting), roots):
|
||||
raise trash.TrashUnconfirmed(f"{real} -> {resulting!r}")
|
||||
folder = os.path.dirname(resulting)
|
||||
if not _same_folder(folder, planned):
|
||||
logger.warning(
|
||||
"macOS trashed %s to %s, not the planned %s", real, resulting, planned
|
||||
)
|
||||
return self._label(folder, facts)
|
||||
|
||||
|
||||
# ── the Foundation bridge ────────────────────────────────────────────────
|
||||
|
||||
_NS_TRASH_DIRECTORY = 102
|
||||
_NS_USER_DOMAIN_MASK = 1
|
||||
_LIBOBJC = "/usr/lib/libobjc.A.dylib"
|
||||
_FOUNDATION = "/System/Library/Frameworks/Foundation.framework/Foundation"
|
||||
_KEYS = (
|
||||
"NSURLVolumeIsLocalKey",
|
||||
"NSURLVolumeIsReadOnlyKey",
|
||||
"NSURLVolumeIsRootFileSystemKey",
|
||||
"NSURLVolumeURLKey",
|
||||
"NSURLIsUbiquitousItemKey",
|
||||
"NSUnderlyingErrorKey",
|
||||
)
|
||||
_REQUIRED_SELECTORS = (
|
||||
("NSURL", "fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"),
|
||||
("NSURL", "getResourceValue:forKey:error:"),
|
||||
("NSFileManager", "URLForDirectory:inDomain:appropriateForURL:create:error:"),
|
||||
("NSFileManager", "trashItemAtURL:resultingItemURL:error:"),
|
||||
)
|
||||
|
||||
|
||||
class _Nil(Exception):
|
||||
"""An Objective-C object was nil where a send needs one."""
|
||||
|
||||
|
||||
class Bridge: # pragma: no cover - macOS only; the native-trash job runs it
|
||||
"""Foundation through ctypes: ``facts``, ``trash_dir`` and ``trash`` (design §6)."""
|
||||
|
||||
def __init__(self):
|
||||
if sys.platform != "darwin":
|
||||
raise OSError("the Foundation bridge exists only on macOS")
|
||||
objc = ctypes.CDLL(_LIBOBJC)
|
||||
foundation = ctypes.CDLL(_FOUNDATION)
|
||||
void_p, char_p, byte = ctypes.c_void_p, ctypes.c_char_p, ctypes.c_byte
|
||||
objc.objc_getClass.argtypes = [char_p]
|
||||
objc.objc_getClass.restype = void_p
|
||||
objc.sel_registerName.argtypes = [char_p]
|
||||
objc.sel_registerName.restype = void_p
|
||||
objc.objc_autoreleasePoolPush.argtypes = []
|
||||
objc.objc_autoreleasePoolPush.restype = void_p
|
||||
objc.objc_autoreleasePoolPop.argtypes = [void_p]
|
||||
objc.objc_autoreleasePoolPop.restype = None
|
||||
self._objc = objc
|
||||
address = ctypes.cast(objc.objc_msgSend, void_p).value
|
||||
|
||||
def send(restype, *argtypes):
|
||||
return ctypes.CFUNCTYPE(restype, void_p, void_p, *argtypes)(address)
|
||||
|
||||
out = ctypes.POINTER(void_p)
|
||||
self._send_id = send(void_p)
|
||||
self._send_responds = send(byte, void_p)
|
||||
self._send_url = send(void_p, char_p, byte, void_p)
|
||||
self._send_resource = send(byte, out, void_p, out)
|
||||
self._send_bool = send(byte)
|
||||
self._send_dir = send(void_p, ctypes.c_ulong, ctypes.c_ulong, void_p, byte, out)
|
||||
self._send_trash = send(byte, void_p, out, out)
|
||||
self._send_cstr = send(char_p)
|
||||
self._send_long = send(ctypes.c_long)
|
||||
self._send_object = send(void_p, void_p)
|
||||
self._keys = {}
|
||||
for name in _KEYS:
|
||||
value = void_p.in_dll(foundation, name).value
|
||||
if not value:
|
||||
raise ValueError(f"Foundation constant {name} is nil")
|
||||
self._keys[name] = value
|
||||
for class_name, selector in _REQUIRED_SELECTORS:
|
||||
if not self._send_responds(
|
||||
self._class(class_name),
|
||||
self._sel("respondsToSelector:"),
|
||||
self._sel(selector),
|
||||
) and not self._instances_respond(class_name, selector):
|
||||
raise ValueError(f"{class_name} does not respond to {selector}")
|
||||
|
||||
# helpers (call inside a pool) ------------------------------------------------
|
||||
|
||||
def _class(self, name: str):
|
||||
value = self._objc.objc_getClass(name.encode())
|
||||
if not value:
|
||||
raise ValueError(f"no Objective-C class {name}")
|
||||
return value
|
||||
|
||||
def _sel(self, name: str):
|
||||
return self._objc.sel_registerName(name.encode())
|
||||
|
||||
def _instances_respond(self, class_name: str, selector: str) -> bool:
|
||||
return (
|
||||
self._send_responds(
|
||||
self._class(class_name),
|
||||
self._sel("instancesRespondToSelector:"),
|
||||
self._sel(selector),
|
||||
)
|
||||
!= 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _need(value):
|
||||
if not value:
|
||||
raise _Nil()
|
||||
return value
|
||||
|
||||
def _manager(self):
|
||||
return self._need(
|
||||
self._send_id(self._class("NSFileManager"), self._sel("defaultManager"))
|
||||
)
|
||||
|
||||
def _url(self, path: str):
|
||||
return self._need(
|
||||
self._send_url(
|
||||
self._class("NSURL"),
|
||||
self._sel(
|
||||
"fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"
|
||||
),
|
||||
os.fsencode(path),
|
||||
0,
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
def _path(self, url) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
raw = self._send_cstr(url, self._sel("fileSystemRepresentation"))
|
||||
return os.fsdecode(raw) if raw else None
|
||||
|
||||
def _text(self, obj) -> str | None:
|
||||
if not obj:
|
||||
return None
|
||||
raw = self._send_cstr(obj, self._sel("UTF8String"))
|
||||
return raw.decode("utf-8", "replace") if raw else None
|
||||
|
||||
def _error(self, error) -> NSErr | None:
|
||||
if not error:
|
||||
return None
|
||||
domain = self._text(self._send_id(error, self._sel("domain"))) or "?"
|
||||
code = self._send_long(error, self._sel("code"))
|
||||
under_domain = under_code = None
|
||||
info = self._send_id(error, self._sel("userInfo"))
|
||||
if info:
|
||||
under = self._send_object(
|
||||
info, self._sel("objectForKey:"), self._keys["NSUnderlyingErrorKey"]
|
||||
)
|
||||
if under:
|
||||
under_domain = self._text(self._send_id(under, self._sel("domain")))
|
||||
under_code = self._send_long(under, self._sel("code"))
|
||||
return NSErr(domain, code, under_domain, under_code)
|
||||
|
||||
def _resource(self, url, key_name: str):
|
||||
value, error = ctypes.c_void_p(), ctypes.c_void_p()
|
||||
ok = self._send_resource(
|
||||
url,
|
||||
self._sel("getResourceValue:forKey:error:"),
|
||||
ctypes.byref(value),
|
||||
self._keys[key_name],
|
||||
ctypes.byref(error),
|
||||
)
|
||||
return value.value if ok != 0 and value.value else None
|
||||
|
||||
def _flag(self, url, key_name: str) -> bool | None:
|
||||
value = self._resource(url, key_name)
|
||||
if value is None:
|
||||
return None
|
||||
return self._send_bool(value, self._sel("boolValue")) != 0
|
||||
|
||||
def _pooled(self, work):
|
||||
pool = self._objc.objc_autoreleasePoolPush()
|
||||
try:
|
||||
return work()
|
||||
except _Nil as nil:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, "nil Objective-C object") from nil
|
||||
finally:
|
||||
self._objc.objc_autoreleasePoolPop(pool)
|
||||
|
||||
# the bridge API (design §6.3) ----------------------------------------------
|
||||
|
||||
def facts(self, path: str) -> Facts:
|
||||
def work():
|
||||
url = self._url(path)
|
||||
return Facts(
|
||||
local=self._flag(url, "NSURLVolumeIsLocalKey"),
|
||||
read_only=self._flag(url, "NSURLVolumeIsReadOnlyKey"),
|
||||
root_fs=self._flag(url, "NSURLVolumeIsRootFileSystemKey"),
|
||||
volume=self._path(self._resource(url, "NSURLVolumeURLKey")),
|
||||
ubiquitous=self._flag(url, "NSURLIsUbiquitousItemKey"),
|
||||
)
|
||||
|
||||
return self._pooled(work)
|
||||
|
||||
def trash_dir(self, path: str):
|
||||
def work():
|
||||
error = ctypes.c_void_p()
|
||||
folder = self._send_dir(
|
||||
self._manager(),
|
||||
self._sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
|
||||
_NS_TRASH_DIRECTORY,
|
||||
_NS_USER_DOMAIN_MASK,
|
||||
self._url(path),
|
||||
0,
|
||||
ctypes.byref(error),
|
||||
)
|
||||
if folder:
|
||||
return self._path(folder), None
|
||||
return None, self._error(error.value)
|
||||
|
||||
return self._pooled(work)
|
||||
|
||||
def trash(self, path: str):
|
||||
def work():
|
||||
resulting, error = ctypes.c_void_p(), ctypes.c_void_p()
|
||||
ok = self._send_trash(
|
||||
self._manager(),
|
||||
self._sel("trashItemAtURL:resultingItemURL:error:"),
|
||||
self._url(path),
|
||||
ctypes.byref(resulting),
|
||||
ctypes.byref(error),
|
||||
)
|
||||
if ok != 0:
|
||||
return True, self._path(resulting.value), None
|
||||
return False, None, self._error(error.value)
|
||||
|
||||
return self._pooled(work)
|
||||
|
||||
|
||||
def load_bridge() -> Bridge:
|
||||
"""The real Foundation bridge; raises OSError/ValueError where it cannot load."""
|
||||
return Bridge()
|
||||
@@ -234,7 +234,7 @@ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
|
||||
* collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes.
|
||||
*/
|
||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback,
|
||||
collections = null, onCollectionsChanged = null}) {
|
||||
collections = null, onCollectionsChanged = null, onDelete = null}) {
|
||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
let contentScope;
|
||||
@@ -339,6 +339,9 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
onChanged:onCollectionsChanged, focusFallback:fallback}));
|
||||
footer.append(button);
|
||||
}
|
||||
// #480: Delete… for a listed Images file only, last in the actions.
|
||||
const removable = onDelete ? revealTarget(record.identity) : null;
|
||||
if (removable) footer.append(deleteButton(removable));
|
||||
if (!full && footer.childNodes.length) container.append(footer);
|
||||
}
|
||||
/**
|
||||
@@ -456,6 +459,12 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
});
|
||||
return button;
|
||||
}
|
||||
/** #480: "Delete…" opens the Images delete review for this one file. */
|
||||
function deleteButton(target) {
|
||||
const button = createButton('Delete…', 'danger');
|
||||
contentScope.listen(button, 'click', () => onDelete(target, button));
|
||||
return button;
|
||||
}
|
||||
/** "Workflow & raw metadata": each supported record (`pre[tabindex=0] > code`), then the two downloads. */
|
||||
function rawMetadata(record) {
|
||||
const {metadata} = record, section = disclosure('Workflow & raw metadata');
|
||||
|
||||
@@ -317,6 +317,9 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
// A save in flight: closing hands the capture release to its completion.
|
||||
let saving = false;
|
||||
let observedOnce = false;
|
||||
// #515: the user typed since the last observation started (or before the first one answered), so the answer
|
||||
// keeps that text.
|
||||
let typed = false;
|
||||
|
||||
function set(patch) {
|
||||
if (closed) return;
|
||||
@@ -345,7 +348,8 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
async function open({keepDraft = false} = {}) {
|
||||
if (closed) return;
|
||||
const serial = ++openSerial;
|
||||
const draft = keepDraft ? state.manual : null;
|
||||
// A reopen after an observation starts from its answer; text typed before any observation is kept (#515).
|
||||
if (observedOnce) typed = false;
|
||||
const seenRevision = state.tagRevision;
|
||||
stopGenerating();
|
||||
releaseCapture();
|
||||
@@ -385,15 +389,19 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
const savedModel = !chosen && !modelPicked && read.model ? {model: read.model} : {};
|
||||
observedOnce = true;
|
||||
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
|
||||
// A kept draft stays as typed unless another writer changed the set meanwhile: then the set leads it (I2).
|
||||
const changed = draft !== null && value.tag_revision !== seenRevision;
|
||||
// The draft is read now, so text typed while observing is kept (#515). A kept draft stays as typed unless
|
||||
// another writer changed the set meanwhile: then the set leads it (I2). Text typed before the user saw any
|
||||
// saved tags follows them, so a Save never drops tags it never showed.
|
||||
const draft = keepDraft || typed ? state.manual : null;
|
||||
const changed = keepDraft && value.tag_revision !== seenRevision;
|
||||
const unseen = draft !== null && !keepDraft;
|
||||
const reason = generate.supported ? '' : (generate.reason || '');
|
||||
set({
|
||||
phase: 'ready',
|
||||
image: {filename: value.filename, title: value.title, width, height, mediaType, previewUrl,
|
||||
dimensions: width && height ? `${width} × ${height}` : null},
|
||||
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
|
||||
manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
|
||||
manual: draft === null ? value.tags.join(', ') : changed || unseen ? mergeCurrent(value.tags, draft) : draft,
|
||||
draftRevision: state.draftRevision + 1,
|
||||
...savedModel, options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
|
||||
generateSupported: generate.supported, generateReason: generate.reason,
|
||||
@@ -402,6 +410,7 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
}
|
||||
|
||||
function setManual(text) {
|
||||
typed = true;
|
||||
set({manual: typeof text === 'string' ? text : ''});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Images › Delete… (#480, D159): the review dialog for the checked images (or "all N matching", or the one image of
|
||||
* the inspector or viewer) and the apply.
|
||||
*
|
||||
* - The concept's selection bar has "Remove links…" (`danger`) opening its `maintenancePlan` review (`dl.meta-grid`
|
||||
* Scope / Protected / Backup / Media on disk, Cancel and a primary). Production names it "Delete…" and asks each
|
||||
* time (maintainer ruling): a radio group "What should happen to the files?" with nothing checked, "Remove from the
|
||||
* library only" (records go, the file stays on disk, hidden from Images) or "Also move the files to the trash".
|
||||
* - One preview (`POST /images/delete/preview`) answers both modes; choosing a mode only redraws the rows. The primary
|
||||
* (`danger`) is enabled once a mode with images to act on is chosen. Apply sends `{token, mode}` to the reviewed-plan
|
||||
* apply; the job is followed in the Activity store and `onDeleted(job)` runs when it ends.
|
||||
* - Nothing here deletes a file: the server never unlinks one (it renames into the system trash or leaves it).
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { closeWorkspaceDialog, openCloseOnlyReview, openContentDialog } from './workspace-dialogs.mjs';
|
||||
import { followJobEnd } from './images-tagging-bulk.mjs';
|
||||
import { applyRefusal, APPLY_URL } from './maintenance-review.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { countOf, noun } from './plural.mjs';
|
||||
|
||||
export const PREVIEW_URL = '/prompt_manager/images/delete/preview';
|
||||
export const MAX_DELETE = 500;
|
||||
export const TOO_MANY = 'Select at most 500 images.';
|
||||
export const QUESTION = 'What should happen to the files?';
|
||||
export const MODES = Object.freeze([
|
||||
Object.freeze({value: 'library', label: 'Remove from the library only', hint: 'Files stay on disk, hidden from Images.'}),
|
||||
Object.freeze({value: 'trash', label: 'Also move the files to the trash',
|
||||
hint: 'You can restore them from the trash until it is emptied.'}),
|
||||
]);
|
||||
export const NONE_DELETABLE = 'None of the selected images can be deleted.';
|
||||
export const REVIEW_FAILED = 'The review failed. Nothing was changed.';
|
||||
export const SOURCE_CHANGED = 'The sources changed. Refresh the images and try again.';
|
||||
export const STORAGE_CHANGED = 'Prompt storage was restored. Reopen Delete.';
|
||||
export const USE_LIBRARY_ONLY = 'To remove these files from the library anyway, choose “Remove from the library only”.';
|
||||
export const NO_TRASH_HERE = 'Moving to the trash is not available for these files; each one lists why.';
|
||||
const SENTENCES = Object.freeze({
|
||||
libraryBackup: 'Removed images stay hidden unless the file is modified or replaced. Their tags, category, collections '
|
||||
+ 'and favorite can only be recovered by restoring the safety backup, which also undoes every later change.',
|
||||
libraryNoBackup: 'Removed images stay hidden unless the file is modified or replaced. Their tags, category, '
|
||||
+ 'collections and favorite cannot be recovered.',
|
||||
trash: 'A file restored from the trash shows again without its tags, category, collections or favorite; a scan '
|
||||
+ 'links it to its prompt again.',
|
||||
});
|
||||
export { SENTENCES };
|
||||
|
||||
const RECORD_WORDS = [
|
||||
['image_tags', 'with image tags', 'with image tags'],
|
||||
['auto_tagged', 'auto-tagged', 'auto-tagged'],
|
||||
['categories', 'category', 'categories'],
|
||||
['collections', 'collection membership', 'collection memberships'],
|
||||
['favorites', 'favorite', 'favorites'],
|
||||
['review_items', 'Needs review item', 'Needs review items'],
|
||||
];
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
return Number(value ?? 0).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/** The images a mode acts on. */
|
||||
export function modeCount(plan, mode) {
|
||||
return Number(plan?.[mode]?.count ?? 0);
|
||||
}
|
||||
|
||||
/** "Name, name, name and K more" for up to three refused rows, grouped by their sentence. */
|
||||
export function refusedValue(summary) {
|
||||
const count = Number(summary?.count ?? 0);
|
||||
if (!count) return null;
|
||||
const rows = Array.isArray(summary.rows) ? summary.rows : [];
|
||||
const names = rows.slice(0, 3).map(row => String(row.path).split('/').at(-1));
|
||||
const more = count - names.length;
|
||||
const reasons = [...new Set(rows.map(row => row.detail).filter(Boolean))];
|
||||
return `${number(count)}: ${names.join(', ')}${more > 0 ? ` and ${number(more)} more` : ''}`
|
||||
+ (reasons.length ? `. ${reasons.join(' ')}` : '');
|
||||
}
|
||||
|
||||
/** The `dl.meta-grid` rows for `mode` (design §9.3). */
|
||||
export function deleteRows(plan, mode) {
|
||||
const rows = [['Scope', countOf(modeCount(plan, mode), 'image')]];
|
||||
const refused = {count: 0, rows: []};
|
||||
for (const part of mode === 'trash' ? [plan.refused, plan.trash_refused] : [plan.refused]) {
|
||||
refused.count += Number(part?.count ?? 0);
|
||||
refused.rows.push(...(part?.rows ?? []));
|
||||
}
|
||||
const notChanged = refusedValue(refused);
|
||||
if (notChanged) rows.push(['Not changed', notChanged]);
|
||||
const records = plan.records ?? {};
|
||||
const links = Number(records.prompt_links ?? 0);
|
||||
if (links) {
|
||||
const orphans = plan.orphaned_prompts ?? {count: 0, titles: []};
|
||||
let text = `${countOf(links, 'link')}.`;
|
||||
if (orphans.count > 0) {
|
||||
const titles = (orphans.titles ?? []).filter(Boolean);
|
||||
const more = orphans.count - titles.length;
|
||||
text += ` ${countOf(orphans.count, 'prompt')} ${orphans.count === 1 ? 'loses its' : 'lose their'} last image`
|
||||
+ (titles.length ? `: ${titles.join(', ')}${more > 0 ? ` and ${number(more)} more` : ''}` : '')
|
||||
+ `. The ${noun(orphans.count, 'prompt')} ${orphans.count === 1 ? 'stays' : 'stay'} on the Library page.`;
|
||||
}
|
||||
rows.push(['Prompt links', text]);
|
||||
}
|
||||
const details = RECORD_WORDS.filter(([key]) => Number(records[key] ?? 0) > 0)
|
||||
.map(([key, one, many]) => `${number(records[key])} ${Number(records[key]) === 1 ? one : many}`);
|
||||
if (details.length) rows.push(['Image details', details.join(', ')]);
|
||||
rows.push(['Backup', backupOn(plan) ? 'Create and verify before changes (the database, not the files)'
|
||||
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
|
||||
rows.push(['Media on disk', mode === 'trash'
|
||||
? 'Files move to the trash. Nothing is deleted permanently. Disk space is freed when you empty the trash.'
|
||||
: 'Files stay on disk, hidden from Images.']);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function backupOn(plan) {
|
||||
return plan?.backup?.policy !== 'off';
|
||||
}
|
||||
|
||||
/** The `p.small` sentences for `mode`. */
|
||||
export function deleteSentences(plan, mode) {
|
||||
if (mode === 'library') return [backupOn(plan) ? SENTENCES.libraryBackup : SENTENCES.libraryNoBackup];
|
||||
const sentences = [SENTENCES.trash];
|
||||
const trashRefused = Number(plan?.trash_refused?.count ?? 0);
|
||||
if (trashRefused && trashRefused >= modeCount(plan, 'library')) sentences.push(NO_TRASH_HERE);
|
||||
if (trashRefused) sentences.push(USE_LIBRARY_ONLY);
|
||||
return sentences;
|
||||
}
|
||||
|
||||
/** The primary's label: "Remove N images from the library" or "Move N images to the trash". */
|
||||
export function primaryLabel(mode, count) {
|
||||
return mode === 'trash' ? `Move ${countOf(count, 'image')} to the trash` : `Remove ${countOf(count, 'image')} from the library`;
|
||||
}
|
||||
|
||||
/** Why the review cannot start at all, or null. */
|
||||
export function closeOnlyReason(plan) {
|
||||
if (modeCount(plan, 'library') === 0 && modeCount(plan, 'trash') === 0) return NONE_DELETABLE;
|
||||
if (plan?.disk && plan.disk.sufficient === false) {
|
||||
return `There is not enough free space for the safety backup (needs ${number(plan.disk.required_bytes)} bytes, `
|
||||
+ `has ${number(plan.disk.free_bytes)}).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The toast for a review that could not be read. */
|
||||
export function reviewFailure(error) {
|
||||
if (error?.code === 'source-changed') return SOURCE_CHANGED;
|
||||
if (error?.code === 'storage-changed') return STORAGE_CHANGED;
|
||||
if (error?.code === 'too-many-images') return TOO_MANY;
|
||||
if (error?.code === 'busy' && error.message) return error.message;
|
||||
return REVIEW_FAILED;
|
||||
}
|
||||
|
||||
/** The toast when the job ends (design §9.4). */
|
||||
export function outcomeToast(job) {
|
||||
const counters = job?.counters ?? {};
|
||||
const mode = job?.scope?.mode;
|
||||
if (job?.scope?.failure === 'backup-failed') return 'The safety backup failed; nothing was changed.';
|
||||
if (job?.scope?.failure === 'sources-changed') return 'The sources changed; nothing was changed.';
|
||||
const done = Number(mode === 'trash' ? counters.trashed : counters.removed) || 0;
|
||||
const total = Number(job?.total ?? 0);
|
||||
if (job?.scope?.failure === 'records-failed') {
|
||||
return `Stopped after ${number(done)} of ${countOf(total, 'image')}; the rest were not changed. See Activity.`;
|
||||
}
|
||||
const head = mode === 'trash' ? `Moved ${countOf(done, 'image')} to the trash.` : `Removed ${countOf(done, 'image')} from the library.`;
|
||||
const unchanged = Number(counters.not_changed ?? 0);
|
||||
return unchanged ? `${head} ${number(unchanged)} ${unchanged === 1 ? 'was' : 'were'} not changed (see Activity).` : head;
|
||||
}
|
||||
|
||||
function modeGroup(onChange) {
|
||||
const group = el('div', undefined, 'field');
|
||||
group.setAttribute('role', 'radiogroup');
|
||||
const caption = el('span', QUESTION);
|
||||
caption.id = 'image-delete-mode-label';
|
||||
group.setAttribute('aria-labelledby', caption.id);
|
||||
group.append(caption);
|
||||
for (const choice of MODES) {
|
||||
const label = el('label');
|
||||
const input = el('input');
|
||||
Object.assign(input, {type: 'radio', name: 'image-delete-mode', value: choice.value, checked: false});
|
||||
input.addEventListener('change', () => { if (input.checked) onChange(choice.value); });
|
||||
label.append(input, ` ${choice.label}`, el('span', ` ${choice.hint}`, 'small muted'));
|
||||
group.append(label);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** The review for an answered preview `plan`; `onApply(mode)` runs on the primary. */
|
||||
export function showDeleteReview(plan, {scope, count, trigger = null, onApply}) {
|
||||
const title = `Delete ${countOf(count, 'image')}`;
|
||||
const reason = closeOnlyReason(plan);
|
||||
if (reason) {
|
||||
openCloseOnlyReview({scope, title, grid: deleteRows(plan, 'library'), sentence: reason, trigger});
|
||||
return null;
|
||||
}
|
||||
let mode = null, started = false;
|
||||
const list = el('dl', undefined, 'meta-grid');
|
||||
list.hidden = true;
|
||||
const notes = el('div', undefined, 'image-delete-notes');
|
||||
const cancel = createButton('Cancel');
|
||||
const start = createButton('Choose what happens to the files', 'danger image-delete-start');
|
||||
start.setAttribute('aria-disabled', 'true');
|
||||
function render() {
|
||||
list.replaceChildren();
|
||||
notes.replaceChildren();
|
||||
if (!mode) return;
|
||||
for (const [term, value] of deleteRows(plan, mode)) {
|
||||
const entry = el('div');
|
||||
entry.append(el('dt', term), el('dd', value));
|
||||
list.append(entry);
|
||||
}
|
||||
list.hidden = false;
|
||||
for (const sentence of deleteSentences(plan, mode)) notes.append(el('p', sentence, 'small'));
|
||||
const acting = modeCount(plan, mode);
|
||||
start.textContent = primaryLabel(mode, acting);
|
||||
start.setAttribute('aria-disabled', String(acting === 0));
|
||||
}
|
||||
const group = modeGroup(value => { mode = value; render(); });
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
// Nothing starts without an explicit mode: an aria-disabled primary ignores every activation (click, Enter, Space),
|
||||
// and a chosen one starts once however often it is pressed. The dialog has no form, so Enter elsewhere submits nothing.
|
||||
start.addEventListener('click', () => {
|
||||
if (started || !MODES.some(choice => choice.value === mode) || start.getAttribute('aria-disabled') === 'true') return;
|
||||
started = true;
|
||||
start.setAttribute('aria-disabled', 'true');
|
||||
const chosen = mode;
|
||||
closeWorkspaceDialog();
|
||||
onApply(chosen);
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, start);
|
||||
openContentDialog({scope, title, nodes: [group, list, notes, actions], trigger});
|
||||
return {group, list, start};
|
||||
}
|
||||
|
||||
/** Apply `plan` in `mode`; the started job, or null (a toast said why). */
|
||||
export async function applyDelete({api, store, plan, mode, notify = showWorkspaceToast}) {
|
||||
if (!MODES.some(choice => choice.value === mode)) throw new TypeError('A delete needs an explicit mode');
|
||||
let result;
|
||||
try {
|
||||
result = await api.post(APPLY_URL, {token: plan.token, mode});
|
||||
} catch (error) {
|
||||
const refusal = applyRefusal(error);
|
||||
notify(refusal.toast);
|
||||
if (refusal.lost) store?.load?.();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const job = store ? store.track(result?.job) : result?.job;
|
||||
notify(`${job.title} started.`);
|
||||
return job;
|
||||
} catch {
|
||||
store?.load?.();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole flow for `images` (the checked ones) or `filter` ("all N matching", `count` of them): the preview, the
|
||||
* review and the apply. `trigger` reads "Reviewing…" and is aria-disabled until the review opens; a second press is
|
||||
* ignored. `onDeleted(job)` runs when the started job ends.
|
||||
*/
|
||||
export async function openDeleteImages({api, scope, store, images = [], filter = null, count = images.length, trigger = null,
|
||||
onDeleted = () => {}, notify = showWorkspaceToast}) {
|
||||
if (count > MAX_DELETE) { notify(TOO_MANY); return; }
|
||||
if (!filter && !images.length) return;
|
||||
if (trigger?.getAttribute('aria-disabled') === 'true') return;
|
||||
const label = trigger?.textContent;
|
||||
trigger?.setAttribute('aria-disabled', 'true');
|
||||
if (trigger) trigger.textContent = 'Reviewing…';
|
||||
let plan;
|
||||
try {
|
||||
const body = filter ? {filter} : {items: images.map(({root_index, root_scope, path}) => ({root_index, root_scope, path}))};
|
||||
plan = await api.post(PREVIEW_URL, body, {signal: scope.signal});
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(reviewFailure(error));
|
||||
return;
|
||||
} finally {
|
||||
if (trigger) { trigger.textContent = label; trigger.removeAttribute('aria-disabled'); }
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
showDeleteReview(plan, {scope, count, trigger, onApply: async mode => {
|
||||
const job = await applyDelete({api, store, plan, mode, notify});
|
||||
if (!job) return;
|
||||
if (store) followJobEnd({store, scope, id: job.id, onEnd: ended => { notify(outcomeToast(ended)); onDeleted(ended); }});
|
||||
else onDeleted(job);
|
||||
}});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-taggin
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
|
||||
import { openAddToCollection, removeFromCollection } from './images-collections.mjs';
|
||||
import { openDeleteImages } from './images-delete.mjs';
|
||||
|
||||
const CHECKBOX = '.image-overlays input[type="checkbox"]';
|
||||
const CHECKBOX_LABEL = '.image-overlays > label';
|
||||
@@ -50,7 +51,7 @@ function el(tag, text, className) {
|
||||
*/
|
||||
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
|
||||
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
|
||||
collection = null, onCollectionsChanged = null, onSourceChanged = null }) {
|
||||
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null }) {
|
||||
let checked = initial;
|
||||
let exporting = false, categorizing = false;
|
||||
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
|
||||
@@ -93,7 +94,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
|
||||
scope.listen(removeButton, 'click', () => removeChecked(removeButton));
|
||||
bulk.append(removeButton);
|
||||
}
|
||||
bulk.append(exportMetadata, clearButton);
|
||||
bulk.append(exportMetadata);
|
||||
// #480 (D159): the concept's "Remove links…" (`danger`) place, after Export metadata and before Clear.
|
||||
if (onDeleted) {
|
||||
const deleteButton = createButton('Delete…', 'danger');
|
||||
scope.listen(deleteButton, 'click', () => openDeleteImages({ api, scope, store: activity, images: checkedImages(),
|
||||
...matchingTarget(), trigger: deleteButton, onDeleted }));
|
||||
bulk.append(deleteButton);
|
||||
}
|
||||
bulk.append(clearButton);
|
||||
|
||||
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
|
||||
function render() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { promptRoles } from './prompt-blocks.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { openDeleteImages } from './images-delete.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { mountToolbar } from './toolbar.mjs';
|
||||
@@ -299,6 +300,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
|
||||
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
|
||||
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
|
||||
onDeleted:deleted,
|
||||
// The Set category field suggests the toolbar's categories (#332 MC13).
|
||||
getCategories:() => categoryNames,
|
||||
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
|
||||
@@ -321,6 +323,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
};
|
||||
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy,
|
||||
onDelete:embedded ? null : (item,button) => deleteOne(item,button,() => mediaViewer.close()),
|
||||
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
|
||||
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
|
||||
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
|
||||
@@ -338,7 +341,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
|
||||
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy});
|
||||
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy,
|
||||
onDelete:embedded ? null : (target, button) => deleteOne(target, button)});
|
||||
detailInspector.setState(inspection.state);
|
||||
inspection.select(image);
|
||||
for (const [key, card] of rendered) markSelected(card, key === selection);
|
||||
@@ -647,6 +651,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
clearChecks:() => bar?.clear(), listsNow:() => !recovering,
|
||||
current:started => !scope.signal.aborted && started === generation,
|
||||
rediscover:() => loadOptions().then(found => { if (found) renderLoadStatus(); })})});
|
||||
/** #480: a finished delete job reloads the listing (the deleted files leave it, and the selection clears). */
|
||||
function deleted() {
|
||||
loadCategories();
|
||||
reset();
|
||||
}
|
||||
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
|
||||
function deleteOne(target, trigger, after = null) {
|
||||
openDeleteImages({api, scope, store:activity, images:[target], trigger, onDeleted:() => { after?.(); deleted(); }});
|
||||
}
|
||||
function reset() {
|
||||
generation += 1;
|
||||
mediaViewer.stopSlideshow();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
|
||||
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';
|
||||
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
|
||||
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||||
@@ -137,7 +138,13 @@ function mountWorkspace() {
|
||||
onSave: name => savedViews.create(page, name, state) });
|
||||
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
||||
const countRequests = new Map();
|
||||
// #494: a page that has begun to leave (beforeunload) starts no count read: WebKit fails one with "Fetch API cannot
|
||||
// load … due to access control checks." (a page error). Counts asked for meanwhile wait; a leave that is called off
|
||||
// (the page is still here) reads them on its next input, as the legacy admin page does (#290).
|
||||
let leaving = false;
|
||||
const deferredCounts = new Set();
|
||||
function refreshCounts(ids) {
|
||||
if (leaving) { for (const id of ids) deferredCounts.add(id); return; }
|
||||
for (const id of ids) {
|
||||
countRequests.get(id)?.abort();
|
||||
const request = new AbortController();
|
||||
@@ -167,26 +174,53 @@ function mountWorkspace() {
|
||||
let favoriteCountTimer = 0;
|
||||
scope.cleanup(onFavoriteChange(() => {
|
||||
clearTimeout(favoriteCountTimer);
|
||||
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
|
||||
favoriteCountTimer = setTimeout(() => { favoriteCountTimer = 0; refreshCounts(FAVORITE_WRITE_COUNTS); }, FAVORITE_COUNT_DELAY_MS);
|
||||
}));
|
||||
scope.cleanup(() => clearTimeout(favoriteCountTimer));
|
||||
// A metadata scan that ends may have added items to Needs review: re-read its count once per ended scan.
|
||||
const runningScans = new Set();
|
||||
// Any beforeunload: a page that stays (Stop, a download, a "stay" prompt) revives on its next input or when shown.
|
||||
scope.listen(window, 'beforeunload', () => {
|
||||
leaving = true;
|
||||
if (favoriteCountTimer) { clearTimeout(favoriteCountTimer); favoriteCountTimer = 0; for (const id of FAVORITE_WRITE_COUNTS) deferredCounts.add(id); }
|
||||
for (const [id, request] of countRequests) { request.abort(); deferredCounts.add(id); }
|
||||
countRequests.clear();
|
||||
});
|
||||
const revive = () => {
|
||||
if (!leaving) return;
|
||||
leaving = false;
|
||||
const ids = [...deferredCounts]; deferredCounts.clear();
|
||||
if (ids.length) refreshCounts(ids);
|
||||
if (restorePending) { restorePending = false; restoreReload.request(); }
|
||||
};
|
||||
// Capture, as the admin page does, so no handler that stops an input keeps the page from reviving.
|
||||
for (const type of ['pointerdown', 'keydown']) {
|
||||
window.addEventListener(type, revive, true);
|
||||
scope.cleanup(() => window.removeEventListener(type, revive, true));
|
||||
}
|
||||
scope.listen(document, 'visibilitychange', () => { if (document.visibilityState === 'visible') revive(); });
|
||||
// A job that ends re-reads the counts it can change, once per ended job: a metadata scan may have added items to
|
||||
// Needs review; deleting images (#480) hides or trashes them, with their favorites, review items and collection
|
||||
// memberships (#501).
|
||||
const runningJobs = new Map();
|
||||
scope.cleanup(activity.subscribe(() => {
|
||||
let ended = false;
|
||||
const ended = new Set();
|
||||
for (const job of activity.jobs()) {
|
||||
if (job.kind !== 'scan') continue;
|
||||
if (job.state === 'running') runningScans.add(job.id);
|
||||
else if (runningScans.delete(job.id)) ended = true;
|
||||
if (!JOB_END_COUNTS[job.kind]) continue;
|
||||
if (job.state === 'running') runningJobs.set(job.id, job.kind);
|
||||
else if (runningJobs.delete(job.id)) ended.add(job.kind);
|
||||
}
|
||||
if (ended) refreshCounts(REVIEW_WRITE_COUNTS);
|
||||
if (!ended.size) return;
|
||||
refreshCounts([...new Set([...ended].flatMap(kind => JOB_END_COUNTS[kind]))]);
|
||||
if (ended.has('image_delete') && !leaving) collections.load();
|
||||
}));
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
// S9: a finished restore replaced the library. The cached visits are dropped, the counts and saved views read
|
||||
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
|
||||
// #415: an open review closes (its plan no longer describes storage); while any other dialog is open, all of it
|
||||
// waits until the last one has closed, so the dialog keeps what was typed.
|
||||
// #494: a restore that ends while the page leaves reloads nothing then; a page that stays reloads on revive.
|
||||
let restorePending = false;
|
||||
const restoreReload = createRestoreReload(() => {
|
||||
if (leaving) { restorePending = true; return; }
|
||||
forgetImagesVisit();
|
||||
forgetLibraryVisit();
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
|
||||
@@ -115,9 +115,39 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
retiring.delete(url);
|
||||
entry.waiting.abort(); clearTimeout(entry.timer);
|
||||
releaseMedia(entry.preview);
|
||||
if (entry.revoke) URL.revokeObjectURL(url);
|
||||
if (entry.revoke) revokeObjectURL(url);
|
||||
entry.done();
|
||||
}
|
||||
// #483 (D62): the viewer's own video, when a new selection closes the viewer while that video is still starting.
|
||||
// The viewer stays open until the video reports metadata or an error (or D62's 15 s), then closes; its URL, which
|
||||
// the session asked to revoke, goes after that. A viewer asked for meanwhile opens then: only the latest request,
|
||||
// and only if its selection is still current (an automatic open waits for its own inline video again).
|
||||
let held = null;
|
||||
function closeViewer() {
|
||||
const node = document.querySelector('#viewer-dialog video');
|
||||
if (!node || !isStarting(node)) { viewer.close(); return; }
|
||||
const waiting = new AbortController();
|
||||
held = { url: node.getAttribute('src'), revoke: false, waiting, pending: null, timer: setTimeout(finishHeld, STARTING_VIDEO_MS) };
|
||||
for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, finishHeld, { once: true, signal: waiting.signal });
|
||||
}
|
||||
function endHeld() {
|
||||
const entry = held;
|
||||
if (!entry) return null;
|
||||
held = null;
|
||||
entry.waiting.abort(); clearTimeout(entry.timer);
|
||||
return entry;
|
||||
}
|
||||
function finishHeld() {
|
||||
const entry = endHeld();
|
||||
if (!entry) return;
|
||||
viewer.close();
|
||||
// Through the session's hook, so an inline video still starting on the same URL keeps it until it settles.
|
||||
if (entry.revoke) revokeObjectURL(entry.url);
|
||||
const request = entry.pending;
|
||||
if (!request || scope.signal.aborted || session.entry !== request.entry || session.state.phase !== 'ready') return;
|
||||
if (request.auto) autoOpen(request.entry);
|
||||
else openViewer(request.trigger);
|
||||
}
|
||||
function retire(preview, url) {
|
||||
const waiting = new AbortController();
|
||||
const entry = { preview, waiting, revoke: false, timer: setTimeout(() => finishRetiring(url), STARTING_VIDEO_MS) };
|
||||
@@ -129,6 +159,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
}
|
||||
const finishAllRetiring = () => { for (const url of [...retiring.keys()]) finishRetiring(url); };
|
||||
const revokeObjectURL = url => {
|
||||
if (held?.url === url) { held.revoke = true; return; }
|
||||
const entry = retiring.get(url);
|
||||
if (entry) entry.revoke = true;
|
||||
else URL.revokeObjectURL(url);
|
||||
@@ -136,7 +167,8 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL });
|
||||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
|
||||
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); },
|
||||
onClose: () => { viewerKey = null; } });
|
||||
// A held viewer closed by hand (Escape, Close) ends its hold at once: its video is gone.
|
||||
onClose: () => { viewerKey = null; if (held) finishHeld(); } });
|
||||
|
||||
let viewerKey = null, media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null;
|
||||
function statusElement(message) {
|
||||
@@ -209,7 +241,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
scope.cleanup(session.subscribe(state => {
|
||||
// D155: a new selection can start while the viewer shows the previous one; it closes first, so no element holds
|
||||
// the old URL when the session revokes it (the new file's viewer opens once it is ready).
|
||||
if (viewerKey !== null && (state.phase !== 'ready' || session.entry?.key !== viewerKey)) { viewer.close(); viewerKey = null; }
|
||||
if (viewerKey !== null && (state.phase !== 'ready' || session.entry?.key !== viewerKey)) { closeViewer(); viewerKey = null; }
|
||||
showMedia(state);
|
||||
showStatus(state.message);
|
||||
const entry = session.entry;
|
||||
@@ -220,17 +252,14 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
// while this selection is still the current one and no other dialog is open (the viewer is replaced). D62:
|
||||
// a video opens it once the inline preview has settled, so a replacement while it starts never tears down a
|
||||
// second starting video in the viewer (that deadlocked WebKit).
|
||||
if (AUTO_OPEN.has(state.message)) whenSettled(media, () => {
|
||||
if (scope.signal.aborted || session.entry !== entry || session.state.phase !== 'ready' || document.querySelector('dialog[open]:not(#viewer-dialog)')) return;
|
||||
openViewer(input);
|
||||
});
|
||||
if (AUTO_OPEN.has(state.message)) autoOpen(entry);
|
||||
// An unreadable or failed preview says so in its alert; the toast never claims metadata is available.
|
||||
else if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast);
|
||||
}
|
||||
}));
|
||||
// A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the
|
||||
// viewer (on abort), drops the media, then ends the session.
|
||||
scope.cleanup(() => viewer.dispose());
|
||||
scope.cleanup(() => { const entry = endHeld(); viewer.dispose(); if (entry?.revoke) revokeObjectURL(entry.url); });
|
||||
scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
|
||||
scope.cleanup(finishAllRetiring);
|
||||
scope.cleanup(() => sessionScope.dispose());
|
||||
@@ -239,9 +268,11 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
// cleared, so a page restored from the back-forward cache never names a file beside the empty selection.
|
||||
function leave() {
|
||||
clearRejection();
|
||||
const entry = endHeld();
|
||||
viewer.close();
|
||||
releaseMedia(media); media = null; mediaUrl = null;
|
||||
finishAllRetiring();
|
||||
if (entry?.revoke) revokeObjectURL(entry.url);
|
||||
session.clear();
|
||||
chosen = null; input.value = '';
|
||||
}
|
||||
@@ -287,10 +318,20 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, done, { once: true, signal: settled.signal });
|
||||
scope.cleanup(done);
|
||||
}
|
||||
/** D155: the viewer opens on `entry` once its inline preview has settled, while it is still the current selection. */
|
||||
function autoOpen(entry) {
|
||||
whenSettled(media, () => {
|
||||
if (scope.signal.aborted || session.entry !== entry || session.state.phase !== 'ready' || document.querySelector('dialog[open]:not(#viewer-dialog)')) return;
|
||||
// #483: the previous viewer is still closing around its starting video; this one opens once it has.
|
||||
if (held) { held.pending = { entry, auto: true }; return; }
|
||||
openViewer(input);
|
||||
});
|
||||
}
|
||||
/** The viewer on the current selection, details showing (D155 amends D97); focus returns to `trigger` on close. */
|
||||
function openViewer(trigger) {
|
||||
const entry = session.entry;
|
||||
if (!entry || session.state.phase !== 'ready') return;
|
||||
if (held) { held.pending = { entry, trigger, auto: false }; return; }
|
||||
viewer.open({ items: [entry], selectedKey: entry.key, trigger, details: 'shown' });
|
||||
viewerKey = entry.key;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { countOf } from './plural.mjs';
|
||||
import { openCloseOnlyReview, openReviewDialog } from './workspace-dialogs.mjs';
|
||||
|
||||
/**
|
||||
* The Prompt library's Delete… review and outcomes (#443, D152, beyond the concept). The concept maps the legacy prompt
|
||||
* delete to a "reference-safe archive" reviewed in its `maintenancePlan` dialog; the workspace offers Delete… on the
|
||||
* D148 bar, whose review (the shared `openReviewDialog`) says what happens to each selected prompt: deleted (in no saved
|
||||
* pair), archived (paired: hidden, restorable) or kept (tagged `__protected__`). The server executes the reviewed
|
||||
* lists (`POST /prompt_manager/library/delete`), taking a verified safety backup before any delete when the policy is
|
||||
* on. Restore (`/library/restore`) puts archived prompts back on the shelf.
|
||||
*/
|
||||
export const PREVIEW_URL = '/prompt_manager/library/delete/preview';
|
||||
export const DELETE_URL = '/prompt_manager/library/delete';
|
||||
export const RESTORE_URL = '/prompt_manager/library/restore';
|
||||
export const REVIEWING = 'Reviewing…';
|
||||
export const DELETE_LABEL = 'Delete…';
|
||||
export const RESTORE_LABEL = 'Restore';
|
||||
// Answers that may follow a committed write: no status, or a gateway's.
|
||||
const UNCONFIRMED_STATUSES = new Set([502, 503, 504]);
|
||||
const NOTHING = 'Nothing was deleted or archived.';
|
||||
export const COPY = Object.freeze({
|
||||
reviewFailed: 'The selected prompts could not be reviewed. Try again.',
|
||||
storage: `The library was restored or moved since this page loaded. ${NOTHING}`,
|
||||
unconfirmed: 'The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.',
|
||||
failed: 'The selected prompts could not be changed. Try again.',
|
||||
nothing: `${NOTHING} The Library was reloaded.`,
|
||||
none: 'None of the selected prompts can 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.',
|
||||
archivedText: 'This text is an archived prompt. Restore it to show it in the Prompt library again.',
|
||||
});
|
||||
|
||||
const count = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
|
||||
const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInteger(id) && id > 0) : []);
|
||||
const were = (value, singular, plural) => `${value.toLocaleString('en-US')} ${value === 1 ? singular : plural}`;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** The preview's classes as counts and id lists, whatever else the answer holds. */
|
||||
export function reviewOf(preview) {
|
||||
return {
|
||||
epoch: typeof preview?.library_epoch === 'string' ? preview.library_epoch : null,
|
||||
delete: ids(preview?.delete), archive: ids(preview?.archive), protected: ids(preview?.protected),
|
||||
alreadyArchived: count(preview?.already_archived), notFound: count(preview?.not_found),
|
||||
imageLinks: count(preview?.image_links), backup: preview?.backup === 'off' ? 'off' : 'on',
|
||||
disk: preview?.disk && typeof preview.disk === 'object' ? preview.disk : null,
|
||||
...pairedOf(preview),
|
||||
};
|
||||
}
|
||||
|
||||
/** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */
|
||||
function pairedOf(preview) {
|
||||
const archive = new Set(ids(preview?.archive)), given = preview?.review_digests ?? {};
|
||||
const deletable = ids(preview?.deletable).filter(id => archive.has(id) && DIGEST.test(String(given?.[id] ?? '')));
|
||||
const counts = preview?.paired_counts ?? {};
|
||||
return {
|
||||
deletable, digests: Object.fromEntries(deletable.map(id => [id, given[id]])),
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
/** #482: whether the review asks what to do with prompts in a saved pair. */
|
||||
export const hasPairedChoice = review => review.deletable.length > 0;
|
||||
|
||||
/** #482: the review with its saved-pair choice (`archive`, the default, or `delete`); the original is unchanged. */
|
||||
export function withChoice(review, choice) {
|
||||
return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' };
|
||||
}
|
||||
|
||||
const permanent = review => review.choice === 'delete';
|
||||
/** What is deleted and archived: Delete permanently moves the deletable ids from archive to delete. */
|
||||
function plan(review) {
|
||||
if (!permanent(review)) return { plain: review.delete, paired: [], archive: review.archive };
|
||||
const chosen = new Set(review.deletable);
|
||||
return { plain: review.delete, paired: review.deletable, archive: review.archive.filter(id => !chosen.has(id)) };
|
||||
}
|
||||
|
||||
export function reviewTitle(review) {
|
||||
const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length;
|
||||
if (!archive.length) return 'Delete selected prompts';
|
||||
if (!deletes) return 'Archive selected prompts';
|
||||
return 'Delete or archive selected prompts';
|
||||
}
|
||||
|
||||
/** "Title A, Title B, Title C and 2 more": the protected prompts the page has loaded, in selection order. */
|
||||
export function protectedNames(review, titleOf) {
|
||||
const names = review.protected.map(titleOf).filter(name => typeof name === 'string' && name);
|
||||
const shown = names.slice(0, 3).join(', ');
|
||||
const more = review.protected.length - Math.min(3, names.length);
|
||||
return more > 0 ? `${shown}${shown ? ' and ' : ''}${more.toLocaleString('en-US')} more` : shown;
|
||||
}
|
||||
|
||||
/** An archived prompt's page notice: why it is archived, and its negative variants only when it has any. */
|
||||
export function archivedNoticeText(variantCount) {
|
||||
const hidden = Number.isSafeInteger(variantCount) && variantCount > 0
|
||||
? `It and its ${countOf(variantCount, 'negative variant')} are hidden`
|
||||
: 'It is hidden';
|
||||
return `This prompt is archived because it is in a saved pair. ${hidden} from the Prompt library; nothing was removed.`;
|
||||
}
|
||||
|
||||
/** 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`],
|
||||
];
|
||||
keptRows(rows, review, titleOf);
|
||||
if (review.delete.length) deleteRows(rows, review, review.imageLinks);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function keptRows(rows, review, titleOf) {
|
||||
if (review.protected.length) {
|
||||
const names = protectedNames(review, titleOf);
|
||||
rows.push(['Protected (kept)', `${review.protected.length.toLocaleString('en-US')}${names ? `: ${names}` : ''}`]);
|
||||
}
|
||||
if (review.alreadyArchived) rows.push(['Already archived', review.alreadyArchived.toLocaleString('en-US')]);
|
||||
if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]);
|
||||
}
|
||||
|
||||
function deleteRows(rows, review, images) {
|
||||
rows.push(['Image links removed', `${countOf(images, 'image')} will no longer show these prompts (files stay on disk)`]);
|
||||
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
|
||||
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
|
||||
}
|
||||
|
||||
/** #482: the grid when Delete permanently is chosen. */
|
||||
function permanentGrid(review, titleOf) {
|
||||
const { plain, paired, archive } = plan(review), counts = review.pairedCounts;
|
||||
const parts = [plain.length && `${plain.length.toLocaleString('en-US')} not in a saved pair`,
|
||||
`${paired.length.toLocaleString('en-US')} in a saved pair`].filter(Boolean);
|
||||
const total = plain.length + paired.length;
|
||||
const deleted = plain.length ? `${countOf(total, 'prompt')}: ${parts.join(', ')}` : `${countOf(total, 'prompt')} in a saved pair`;
|
||||
const details = [counts.variants && `${counts.variants.toLocaleString('en-US')} with a negative prompt`,
|
||||
counts.archivedPairs && `${counts.archivedPairs.toLocaleString('en-US')} archived`].filter(Boolean);
|
||||
const rows = [['Delete permanently', deleted],
|
||||
['Saved pairs removed', `${countOf(counts.pairs, 'saved pair')}${details.length ? ` (${details.join(', ')})` : ''}`]];
|
||||
if (counts.keptNegatives) rows.push(['Negatives kept', `${countOf(counts.keptNegatives, 'negative prompt')} ${counts.keptNegatives === 1 ? 'is' : 'are'} kept`]);
|
||||
if (archive.length) rows.push(['Archive', `${countOf(archive.length, 'prompt')} used as another prompt's negative: hidden, restorable`]);
|
||||
keptRows(rows, review, titleOf);
|
||||
deleteRows(rows, review, review.imageLinks + review.pairedImageLinks);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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. Archived prompts cannot be deleted. 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;
|
||||
}
|
||||
|
||||
const PROTECTED_SENTENCE = 'Protected prompts are tagged __protected__; remove the tag to delete them.';
|
||||
function removeSentence(review, pairs = false) {
|
||||
const undo = review.backup === 'on'
|
||||
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
|
||||
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
|
||||
'10 safety backups.'
|
||||
: ' Deleted prompts cannot be brought back.';
|
||||
return "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
|
||||
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
|
||||
`node run with the same text adds it again as a new prompt.${pairs ? ' A pair node run with the same text saves a new pair.' : ''}`;
|
||||
}
|
||||
|
||||
/** #482: the sentences when Delete permanently is chosen. */
|
||||
function permanentSentences(review) {
|
||||
const sentences = ['Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
|
||||
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
|
||||
'and Activity, listed by their text.', removeSentence(review, true)];
|
||||
if (plan(review).archive.length) sentences.push("Prompts used as another prompt's negative are archived instead of deleted, " +
|
||||
"so that prompt's variant stays.");
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
return sentences;
|
||||
}
|
||||
|
||||
export function primaryLabel(review) {
|
||||
const { plain, paired, archive } = plan(review);
|
||||
const deletes = plain.length + paired.length, archives = archive.length;
|
||||
if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`;
|
||||
return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`;
|
||||
}
|
||||
|
||||
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
|
||||
export function blockedReason(review) {
|
||||
if (!review.delete.length && !review.archive.length) return 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
|
||||
? review.disk.message : 'There is not enough free space for the safety backup. Nothing was changed.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the review for `review` (`reviewOf`). `titleOf(id)` names a loaded prompt. A review that cannot start is the
|
||||
* close-only review (`onDismiss` runs at once, as nothing is pending); otherwise Start calls `onStart()` and any other
|
||||
* close `onDismiss()`.
|
||||
*/
|
||||
export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onDismiss }) {
|
||||
let current = withChoice(review, 'archive');
|
||||
const paragraphs = target => reviewSentences(target).map(text => paragraph(text));
|
||||
const title = reviewTitle(current), grid = reviewGrid(current, titleOf);
|
||||
let sentences = paragraphs(current);
|
||||
const blocked = blockedReason(current);
|
||||
if (blocked) {
|
||||
onDismiss();
|
||||
openCloseOnlyReview({ scope, trigger, title, grid, options: sentences, sentence: blocked });
|
||||
return;
|
||||
}
|
||||
// #482: prompts in a saved pair ask Archive (the default, as #443) or Delete permanently, with their pairs.
|
||||
const choice = hasPairedChoice(review) ? pairedChoice(value => update(value)) : null;
|
||||
const dialog = openReviewDialog({ scope, trigger, title, grid, options: choice ? [choice, ...sentences] : sentences,
|
||||
cancelLabel: 'Cancel', primary: { label: primaryLabel(current), danger: true },
|
||||
onStart: () => onStart(current), onDismiss });
|
||||
function update(value) {
|
||||
current = withChoice(review, value);
|
||||
dialog.setRows(reviewGrid(current, titleOf));
|
||||
const reason = blockedReason(current);
|
||||
const next = paragraphs(current);
|
||||
if (reason) next.push(paragraph(reason));
|
||||
for (const node of sentences) node.remove();
|
||||
choice.after(...next);
|
||||
sentences = next;
|
||||
const heading = choice.closest('dialog')?.querySelector('#modal-title');
|
||||
if (heading) heading.textContent = reviewTitle(current);
|
||||
dialog.setPrimary({ label: primaryLabel(current), disabled: Boolean(reason) });
|
||||
}
|
||||
}
|
||||
|
||||
const paragraph = text => Object.assign(document.createElement('p'), { className: 'small', textContent: text });
|
||||
export const PAIRED_CHOICES = Object.freeze([
|
||||
Object.freeze({ value: 'archive', label: 'Archive (hidden, restorable)' }),
|
||||
Object.freeze({ value: 'delete', label: 'Delete permanently, with their saved pairs' }),
|
||||
]);
|
||||
|
||||
/** #482: the D90 radio row ("Prompts in a saved pair"), Archive checked; it is built fresh for every review. */
|
||||
function pairedChoice(onChange) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'field';
|
||||
group.setAttribute('role', 'radiogroup');
|
||||
const caption = document.createElement('span');
|
||||
caption.textContent = 'Prompts in a saved pair';
|
||||
caption.id = 'library-delete-choice-label';
|
||||
group.setAttribute('aria-labelledby', caption.id);
|
||||
group.append(caption);
|
||||
for (const choice of PAIRED_CHOICES) {
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type: 'radio', name: 'library-delete-choice', value: choice.value, checked: choice.value === 'archive' });
|
||||
input.addEventListener('change', () => { if (input.checked) onChange(choice.value); });
|
||||
label.append(input, ` ${choice.label}`);
|
||||
group.append(label);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */
|
||||
export function deleteBody(review) {
|
||||
const body = { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
|
||||
// #482: the archive ids to delete permanently, each with the digest of what the review showed.
|
||||
if (permanent(review)) Object.assign(body, { delete_paired_ids: review.deletable, paired_digests: review.digests });
|
||||
return body;
|
||||
}
|
||||
|
||||
function failureOutcome(failure, copy) {
|
||||
if (failure?.code === 'storage-changed') return { toast: copy.storage, committed: false, reload: true };
|
||||
if (failure?.code === 'busy') {
|
||||
const name = failure.running?.title || 'Another job';
|
||||
return { toast: `${name} is running. Nothing was changed; try again when it finishes.`, committed: false, reload: false };
|
||||
}
|
||||
const status = failure?.status;
|
||||
if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true };
|
||||
// The server's own refusals that name their reason (backup-failed, insufficient-space) say "Nothing was changed".
|
||||
if (['backup-failed', 'insufficient-space'].includes(failure?.code) && typeof failure.message === 'string' && failure.message) {
|
||||
const lead = failure.code === 'backup-failed' ? 'The safety backup failed. ' : '';
|
||||
return { toast: `${lead}${failure.message}`, committed: false, reload: false };
|
||||
}
|
||||
return { toast: copy.failed, committed: false, reload: false };
|
||||
}
|
||||
|
||||
/** What a settled delete means: the toast, whether it may have `committed` and whether the shelf must `reload`. */
|
||||
export function deleteOutcome(result, failure, review) {
|
||||
if (failure !== null || !result?.success) {
|
||||
return failureOutcome(failure, { storage: COPY.storage, unconfirmed: COPY.unconfirmed, failed: COPY.failed });
|
||||
}
|
||||
const deleted = count(result.deleted), archived = count(result.archived);
|
||||
const parts = [], pairs = count(result.pairs_removed);
|
||||
if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`);
|
||||
if (pairs) parts.push(`Removed ${countOf(pairs, 'saved pair')}.`);
|
||||
if (archived) parts.push(`Archived ${countOf(archived, 'prompt')}.`);
|
||||
if (!parts.length) parts.push(NOTHING);
|
||||
const already = count(result.already_archived), missing = count(result.not_found), changed = count(result.changed);
|
||||
if (already) parts.push(`${were(already, 'was', 'were')} already archived.`);
|
||||
if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`);
|
||||
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} not deleted.`);
|
||||
const kept = review?.protected?.length || 0;
|
||||
if (kept) parts.push(`${countOf(kept, 'protected prompt')} ${kept === 1 ? 'was' : 'were'} kept.`);
|
||||
if (typeof result.backup === 'string' && result.backup) parts.push(`Safety backup saved: ${result.backup} (System › Backup & export).`);
|
||||
if (!deleted && !archived) parts.push('The Library was reloaded.');
|
||||
return { toast: parts.join(' '), committed: deleted + archived > 0, reload: true };
|
||||
}
|
||||
|
||||
/** What a settled restore means (the same shape as `deleteOutcome`). */
|
||||
export function restoreOutcome(result, failure) {
|
||||
if (failure !== null || !result?.success) {
|
||||
return failureOutcome(failure, { storage: COPY.restoreStorage, unconfirmed: COPY.restoreUnconfirmed, failed: COPY.restoreFailed });
|
||||
}
|
||||
const restored = count(result.restored), unchanged = count(result.unchanged), missing = count(result.not_found);
|
||||
const parts = [restored ? `Restored ${countOf(restored, 'prompt')} to the Prompt library.` : 'No prompt was restored.'];
|
||||
if (unchanged) parts.push(`${unchanged.toLocaleString('en-US')} ${unchanged === 1 ? 'was' : 'were'} not archived.`);
|
||||
if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`);
|
||||
return { toast: parts.join(' '), committed: restored > 0, reload: true };
|
||||
}
|
||||
|
||||
/** A failed preview: a restored storage reloads the shelf; anything else keeps the selection. */
|
||||
export function previewOutcome(failure) {
|
||||
if (failure?.code === 'storage-changed') return { toast: COPY.storage, committed: false, reload: true };
|
||||
if (failure?.code === 'busy') return failureOutcome(failure, {});
|
||||
return { toast: COPY.reviewFailed, committed: false, reload: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* #481: review and run a delete of `ids` outside the selection bar (a prompt's own page): the same preview, review
|
||||
* (#482's choice included), backup and write. `onSettled(outcome, result, failure)` runs once the write has answered
|
||||
* (or the preview failed: `result` null); a dismissed review calls `onDismiss()`. The toast is the caller's.
|
||||
*/
|
||||
export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds, epoch, titleOf, onSettled, onDismiss = () => {} }) {
|
||||
const body = { prompt_ids: promptIds, expected_epoch: epoch };
|
||||
let preview = null, failure = null;
|
||||
try {
|
||||
preview = await api.post(PREVIEW_URL, body, { signal: scope.signal });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; }
|
||||
const review = { ...reviewOf(preview), epoch };
|
||||
openDeleteReview({ scope, trigger, review, titleOf, onDismiss,
|
||||
onStart: async chosen => {
|
||||
let result = null, error = null;
|
||||
try {
|
||||
result = await api.post(DELETE_URL, deleteBody(chosen));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
onSettled(deleteOutcome(result, error, chosen), result?.success ? result : null, error);
|
||||
} });
|
||||
}
|
||||
@@ -122,7 +122,7 @@ function datalist(id, values) {
|
||||
* exit when prompt storage was restored; the dialog closes first.
|
||||
*/
|
||||
export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {},
|
||||
onRefreshRequired = () => window.location.reload()}) {
|
||||
onRefreshRequired = () => window.location.reload(), onDeletePrompt = null}) {
|
||||
let session = null, disposed = false;
|
||||
const dialog = el('dialog', undefined, 'library-editor');
|
||||
dialog.setAttribute('aria-labelledby', 'library-editor-title');
|
||||
@@ -222,7 +222,10 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
} else form.append(el('p', NEGATIVE_HELP, 'small'));
|
||||
const line = statusLine(), recovery = new Map();
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger');
|
||||
// D161 (#481): the variant's archive is named for it, beside the prompt's own Delete prompt… (the concept's one
|
||||
// "Archive…" in this row archives the prompt, app.js 976).
|
||||
const revise = createButton('Create text revision'), archive = createButton('Archive variant…', 'danger');
|
||||
const removePrompt = createButton('Delete prompt…', 'danger');
|
||||
const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary');
|
||||
save.type = 'submit';
|
||||
form.append(line.note, actions);
|
||||
@@ -237,6 +240,12 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
cancel.addEventListener('click', () => finish(), {signal: views.signal});
|
||||
revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal});
|
||||
archive.addEventListener('click', () => archiveView(), {signal: views.signal});
|
||||
// #481: the prompt's own delete review (#443/#482) replaces this dialog; focus returns to its trigger.
|
||||
removePrompt.addEventListener('click', () => {
|
||||
const {trigger} = session;
|
||||
finish();
|
||||
onDeletePrompt?.({positiveId, trigger});
|
||||
}, {signal: views.signal});
|
||||
if (!negativeOnly) loadVocabulary(tags, views.signal);
|
||||
|
||||
let categoryKey = null, refocus = null;
|
||||
@@ -256,6 +265,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
cancel.disabled = close.disabled = saving;
|
||||
// After a restore the numeric IDs may name another prompt; only the Library reload exit stays.
|
||||
revise.disabled = saving || restoredStorage(state);
|
||||
removePrompt.disabled = saving || restoredStorage(state);
|
||||
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
|
||||
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
|
||||
const status = statusOf(state, false, mode);
|
||||
@@ -263,7 +273,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id);
|
||||
else archive.removeAttribute('aria-describedby');
|
||||
const retry = recoveryButton(recovery, status?.action, views.signal);
|
||||
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
|
||||
const prompt = readOnly && onDeletePrompt ? [removePrompt] : [];
|
||||
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive, ...prompt] : []), cancel, save]);
|
||||
refocus = restoreFocus(refocus, state, [retry, save]);
|
||||
if (state.phase === 'complete') finish();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs';
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { COPY as DELETE_COPY, DELETE_LABEL, DELETE_URL, PREVIEW_URL, REVIEWING, RESTORE_LABEL, RESTORE_URL, deleteBody, deleteOutcome,
|
||||
openDeleteReview, previewOutcome, restoreOutcome, reviewOf } from './library-delete.mjs';
|
||||
|
||||
/**
|
||||
* The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set
|
||||
@@ -19,6 +21,10 @@ import { showWorkspaceToast } from './toast.mjs';
|
||||
* Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`:
|
||||
* the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
|
||||
* Add tags writes prompt tags only, never image tags or auto-tags.
|
||||
*
|
||||
* Delete… (#443, D152) previews the selection, opens its review (`library-delete.mjs`) and posts the reviewed lists:
|
||||
* unpaired prompts are deleted, paired ones archived, protected ones kept. On the archived shelf (`getShelf()` is
|
||||
* 'archived') the bar offers Restore instead of the other actions.
|
||||
*/
|
||||
export const CATEGORY_URL = '/prompt_manager/bulk/category';
|
||||
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
|
||||
@@ -123,29 +129,34 @@ export function categoryProblem(value) {
|
||||
* to `focusFallback` when it was on the bar the reload hides.
|
||||
*/
|
||||
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
|
||||
onReload }) {
|
||||
onReload, getShelf = () => 'active', nameOf = () => null }) {
|
||||
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
|
||||
const element = el('div');
|
||||
element.id = 'library-bulkbar';
|
||||
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
|
||||
const addTags = createButton('Add tags');
|
||||
const setCategory = createButton('Set category');
|
||||
const deleteButton = createButton(DELETE_LABEL, 'danger');
|
||||
const restoreButton = createButton(RESTORE_LABEL);
|
||||
const clearButton = createButton('Clear');
|
||||
const selectLoaded = createButton('');
|
||||
const actions = [addTags, setCategory, selectLoaded];
|
||||
bulk.append(count, addTags, setCategory, clearButton);
|
||||
// Every bar action is aria-disabled while a review or a write runs (D148, D151, D152), Clear too.
|
||||
const actions = [addTags, setCategory, deleteButton, restoreButton, selectLoaded, clearButton];
|
||||
let shownShelf = null;
|
||||
scope.listen(selectLoaded, 'click', () => {
|
||||
if (writing) return;
|
||||
selectAll();
|
||||
// The pressed offer left the bar: focus moves to its first action, as after D135's offer.
|
||||
addTags.focus({ preventScroll: true });
|
||||
(shownShelf === 'archived' ? restoreButton : addTags).focus({ preventScroll: true });
|
||||
});
|
||||
scope.listen(addTags, 'click', () => openTags());
|
||||
scope.listen(setCategory, 'click', () => openCategory());
|
||||
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
|
||||
scope.listen(deleteButton, 'click', () => openDelete());
|
||||
scope.listen(restoreButton, 'click', () => restoreSelected());
|
||||
scope.listen(clearButton, 'click', () => { if (writing) return; clear(); focusFallback.focus({ preventScroll: true }); });
|
||||
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
|
||||
scope.listen(element, 'keydown', event => {
|
||||
if (event.key !== 'Escape' || !checked.size) return;
|
||||
if (event.key !== 'Escape' || !checked.size || writing) return;
|
||||
event.preventDefault();
|
||||
clear();
|
||||
focusFallback.focus({ preventScroll: true });
|
||||
@@ -154,6 +165,11 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
const ids = () => getItems().map(item => item.id);
|
||||
function render() {
|
||||
if (!checked.size) { bulk.remove(); return; }
|
||||
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
|
||||
if (shelf !== shownShelf) {
|
||||
shownShelf = shelf;
|
||||
bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton] : [addTags, setCategory, deleteButton]), clearButton);
|
||||
}
|
||||
const label = selectionLabel(checked.size);
|
||||
if (count.textContent !== label) count.textContent = label;
|
||||
const keys = ids();
|
||||
@@ -240,20 +256,63 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem,
|
||||
onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') });
|
||||
}
|
||||
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
|
||||
async function write(url, body, kind) {
|
||||
/** Hold every bar action while a review or write runs; `label` is what Delete… reads meanwhile. */
|
||||
function lock(label = null) {
|
||||
writing = true;
|
||||
for (const button of actions) button.setAttribute('aria-disabled', 'true');
|
||||
if (label !== null) deleteButton.textContent = label;
|
||||
}
|
||||
function unlock() {
|
||||
writing = false;
|
||||
for (const button of actions) button.removeAttribute('aria-disabled');
|
||||
deleteButton.textContent = DELETE_LABEL;
|
||||
}
|
||||
/** Delete… (#443): preview the selection, then review it; Start posts what the review showed. */
|
||||
async function openDelete() {
|
||||
const chosen = target(DELETE_COPY.reviewFailed);
|
||||
if (!chosen) return;
|
||||
lock(REVIEWING);
|
||||
let preview = null, failure = null;
|
||||
try {
|
||||
preview = await api.post(PREVIEW_URL, chosen, { signal: scope.signal });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (failure !== null || !preview?.success) {
|
||||
unlock();
|
||||
const outcome = previewOutcome(failure);
|
||||
showWorkspaceToast(outcome.toast);
|
||||
if (outcome.reload) onReload();
|
||||
return;
|
||||
}
|
||||
const review = { ...reviewOf(preview), epoch: chosen.expected_epoch };
|
||||
// The review is open: the bar stays held behind it, under its own name again.
|
||||
deleteButton.textContent = DELETE_LABEL;
|
||||
openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf,
|
||||
onDismiss: unlock,
|
||||
// #482: the review as chosen (Archive, or Delete permanently with the prompts' saved pairs).
|
||||
onStart: chosen => write(DELETE_URL, deleteBody(chosen), 'delete', (result, error) => deleteOutcome(result, error, chosen),
|
||||
chosen.delete.length || chosen.choice === 'delete' ? 'Deleting…' : 'Archiving…') });
|
||||
}
|
||||
/** Restore (#443): the selected archived prompts go back on the shelf. */
|
||||
function restoreSelected() {
|
||||
const chosen = target(DELETE_COPY.restoreFailed);
|
||||
if (!chosen) return;
|
||||
write(RESTORE_URL, chosen, 'restore', restoreOutcome);
|
||||
}
|
||||
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
|
||||
async function write(url, body, kind, outcomeOf = (result, failure) => writeOutcome(result, failure, kind), label = null) {
|
||||
lock(label);
|
||||
let result = null, failure = null;
|
||||
try {
|
||||
result = await api.post(url, body);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
writing = false;
|
||||
for (const button of actions) button.removeAttribute('aria-disabled');
|
||||
unlock();
|
||||
}
|
||||
const outcome = writeOutcome(result, failure, kind);
|
||||
const outcome = outcomeOf(result, failure);
|
||||
// A left page still invalidates what it cached, so a return never shows the old categories.
|
||||
if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' });
|
||||
if (scope.signal.aborted) return;
|
||||
@@ -274,7 +333,9 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
selectAll,
|
||||
/** A loaded batch: the count and the offer follow it. */
|
||||
listingChanged: () => { if (checked.size) render(); },
|
||||
/** Whether a Set category write is still in flight. */
|
||||
/** Whether a review or a write is still in flight. */
|
||||
writing: () => writing,
|
||||
/** The shelf changed (Show archived prompts): the bar's actions follow it. */
|
||||
shelfChanged: () => { shownShelf = null; render(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ function parameters(filters,limit) {
|
||||
if(typeof folder!=='string' || folder.length>1024) throw new TypeError('Invalid library folder');
|
||||
if(tagMode!=='all') query.set('tag_mode',tagMode);
|
||||
if(folder) query.set('folder',folder);
|
||||
query.set('sort',sort);return query;
|
||||
query.set('sort',sort);
|
||||
// #443 D152: the archived shelf; the default shelf sends nothing, as before.
|
||||
const shelf=filters.shelf || 'active';
|
||||
if(!['active','archived'].includes(shelf)) throw new TypeError('Invalid library shelf');
|
||||
if(shelf==='archived') query.set('shelf','archived');
|
||||
return query;
|
||||
}
|
||||
/** The Library's tag match values (#444); the first is the default, sent as no parameter. */
|
||||
export const LIBRARY_TAG_MODES=Object.freeze(['all','any','untagged']);
|
||||
@@ -31,7 +36,7 @@ function validatePage(page,cursor,query) {
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
}
|
||||
export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
|
||||
let query=parameters({},40),request,disposed=false,seen=new Set();
|
||||
if(restore) {
|
||||
query=parameters(restore.filters,40);const saved=restore.state;
|
||||
@@ -57,11 +62,13 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
const incoming=[];const added=new Set();
|
||||
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
|
||||
for(const value of added) seen.add(value);
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
// #443: the archived parents matching the filters (Show archived prompts); null from an older server.
|
||||
const archivedTotal=Number.isSafeInteger(page.archived_total) && page.archived_total>=0?page.archived_total:null;
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,archivedTotal,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
|
||||
}
|
||||
return {get state(){return state;},loadMore,async reset(filters={}) {
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:state.archivedTotal ?? null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
|
||||
},dispose(){disposed=true;request?.abort();request=null;}};
|
||||
}
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user