Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bebf4c2986 | ||
|
|
d88cf9e7c6 | ||
|
|
df70b5dfd6 | ||
|
|
7e1847955b | ||
|
|
ecb6b09813 | ||
|
|
cdb791d4a9 | ||
|
|
f924d7195d | ||
|
|
3789d85e58 | ||
|
|
fb6452934f | ||
|
|
395dc74cbe | ||
|
|
2f061904a2 | ||
|
|
f80e67c812 | ||
|
|
ab6fee3a82 | ||
|
|
f53a43152d | ||
|
|
fe63f01214 | ||
|
|
aab0fedc10 | ||
|
|
085e45e475 | ||
|
|
89572e03c6 | ||
|
|
ae3e191990 | ||
|
|
cd284432f5 | ||
|
|
a6d6129ec6 | ||
|
|
e8d96a0f55 | ||
|
|
d0134b2d13 | ||
|
|
9af3271dbb |
@@ -29,6 +29,9 @@ env:
|
|||||||
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
|
# 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
|
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
|
||||||
# after three green runs.
|
# after three green runs.
|
||||||
|
# native-trash-linux (#543) has no green main runs yet: it installs the dev requirements and runs
|
||||||
|
# one test module, about 1 minute by the same estimate, so ceil(1.5 x 2) + 3 gives it 6 minutes.
|
||||||
|
# Recalibrate after three green runs.
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||||
@@ -104,3 +107,39 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PM_NATIVE_TRASH: macos
|
PM_NATIVE_TRASH: macos
|
||||||
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
|
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
|
||||||
|
|
||||||
|
# The same tests as the fake mount table, on a real ext4 mount used as the output root (#543). A
|
||||||
|
# runner can mount with sudo; the tests run as the normal user, so owner and mode checks are real.
|
||||||
|
native-trash-linux:
|
||||||
|
name: native trash on a real mount
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Mount an ext4 image
|
||||||
|
run: |
|
||||||
|
truncate -s 64M "$RUNNER_TEMP/drive.img"
|
||||||
|
mkfs.ext4 -q "$RUNNER_TEMP/drive.img"
|
||||||
|
mkdir "$RUNNER_TEMP/drive"
|
||||||
|
sudo mount -o loop "$RUNNER_TEMP/drive.img" "$RUNNER_TEMP/drive"
|
||||||
|
sudo rmdir "$RUNNER_TEMP/drive/lost+found"
|
||||||
|
sudo chown "$(id -u):$(id -g)" "$RUNNER_TEMP/drive"
|
||||||
|
findmnt "$RUNNER_TEMP/drive"
|
||||||
|
|
||||||
|
- name: Native trash proof
|
||||||
|
timeout-minutes: 4
|
||||||
|
run: PM_TRASH_MOUNT="$RUNNER_TEMP/drive" python -m unittest tests.test_trash_mount_root -v
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
|||||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||||
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
|
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
|
||||||
content without the cover, then "File & provenance" (the honest file facts, the
|
content without the cover, then "File & provenance" (the honest file facts, the
|
||||||
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
|
`Prompt evidence` line, Download original and, for a listed file, D38's
|
||||||
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
|
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
|
||||||
two downloads), then the pair send and Prompt history buttons. D22 words an empty
|
two downloads), then the pair send and Prompt history buttons. D22 words an empty
|
||||||
positive explicitly and makes confirmed prompt text focusable.
|
positive explicitly and makes confirmed prompt text focusable.
|
||||||
@@ -1402,6 +1402,13 @@ seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
|
|||||||
Tag) and asserts that every row reserves the tallest footer and that their own footer
|
Tag) and asserts that every row reserves the tallest footer and that their own footer
|
||||||
is shorter than the row it reserves.
|
is shorter than the row it reserves.
|
||||||
|
|
||||||
|
### Overview counts (#547)
|
||||||
|
|
||||||
|
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
|
||||||
|
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
|
||||||
|
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
|
||||||
|
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
|
||||||
|
|
||||||
### Images page controls and recorded deviations (#249 PR C)
|
### Images page controls and recorded deviations (#249 PR C)
|
||||||
|
|
||||||
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
|
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
|
||||||
@@ -1566,20 +1573,27 @@ Recorded deviations on this page (spec §8):
|
|||||||
|
|
||||||
| Result | Toast |
|
| Result | Toast |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Success | "Opened the folder on this computer." |
|
| Success | "Opened the folder on the computer running ComfyUI." |
|
||||||
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
|
| 403 `not-same-origin` | "The folder was not opened: this page's address does not match the server's." |
|
||||||
| 501 `unavailable` | "No file manager is available on this computer." |
|
| 501 `unavailable` | "No file manager is available on the computer running ComfyUI." |
|
||||||
| 429 `busy` | "The folder is already opening." |
|
| 429 `busy` | "The folder is already opening." |
|
||||||
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
|
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
|
||||||
|
|
||||||
The route is local-only. It answers 403 `not-local` unless all of these hold: no
|
**D176 (#533, beyond the concept):** the viewer is a modal, so `showWorkspaceToast`
|
||||||
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
|
moves `#workspace-toast` into the topmost open modal (by hit test when several are
|
||||||
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
|
open), keeping its fixed position, and back when that modal closes. A modal opened
|
||||||
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
|
after a toast covers it until the next toast. Otherwise every toast raised from a modal is
|
||||||
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
|
covered and inert while `toHaveText` still passes: assert `toBeVisible` and the
|
||||||
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
|
hit test as `full-inspector.spec.mjs` does.
|
||||||
`null`, another port or another scheme is refused; browsers send `Origin` on every
|
|
||||||
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
|
The route opens the folder on the computer running ComfyUI wherever the browser is,
|
||||||
|
through a proxy too (maintainer decision, #533, amending the original local-only
|
||||||
|
rule; accepted risk: anyone who can reach the server can open file windows on it).
|
||||||
|
It answers 403 `not-same-origin` unless `Origin` is present and matches: its host
|
||||||
|
and port against `X-Forwarded-Host` when sent, else `Host`, with the scheme's default
|
||||||
|
port filled in; its scheme against `X-Forwarded-Proto` when sent, else the socket's
|
||||||
|
(an https page on an http socket passes, TLS having ended at a proxy); and, when
|
||||||
|
sent, `Sec-Fetch-Site: same-origin`. A body that is not
|
||||||
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
|
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
|
||||||
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
|
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
|
||||||
relative path; absolute, drive and `..` paths are 400), the scope goes through
|
relative path; absolute, drive and `..` paths are 400), the scope goes through
|
||||||
@@ -1596,7 +1610,9 @@ Recorded deviations on this page (spec §8):
|
|||||||
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
|
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
|
||||||
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
|
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
|
||||||
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
|
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
|
||||||
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
|
`xdg-open` on the folder, or answers 501 without `DISPLAY` and `WAYLAND_DISPLAY`.
|
||||||
|
`open` and `xdg-open` get `OPENER_WAIT` (1 s): a nonzero exit is the 500 above, and
|
||||||
|
one still running counts as started and goes to the reaper (#533). Those openers are argv lists resolved only on a fixed
|
||||||
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
|
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
|
||||||
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
|
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
|
||||||
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
|
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
|
||||||
@@ -1604,12 +1620,11 @@ Recorded deviations on this page (spec §8):
|
|||||||
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
|
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
|
||||||
closed disclosures as hidden, so the button needs no mapping there.
|
closed disclosures as hidden, so the button needs no mapping there.
|
||||||
|
|
||||||
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
|
Limits of that check: a proxy that rewrites `Host` and sends no `X-Forwarded-Host` is
|
||||||
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
|
refused with that toast until it sends one. A non-browser client can forge every
|
||||||
from a local client, and a non-browser client behind it can forge a matching
|
header. The check stops a plain cross-site POST, not every other site: a DNS-rebinding
|
||||||
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
|
page has a matching `Host` and `Origin`, so it can open a folder of a listed file, at
|
||||||
screen, at most once a second, and learn whether that file exists (which the
|
most once a second. The path check confines it, one check ahead of every opener.
|
||||||
listing routes already tell it). Operators who expose ComfyUI that way accept this.
|
|
||||||
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
|
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
|
||||||
category when set, otherwise the linked positive prompt's, and its recipe's own tags
|
category when set, otherwise the linked positive prompt's, and its recipe's own tags
|
||||||
when it has any, otherwise the positive's; each field falls back on its own and the
|
when it has any, otherwise the positive's; each field falls back on its own and the
|
||||||
@@ -2487,10 +2502,30 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
|
|||||||
`$RECYCLE.BIN` (any case), as none descends `thumbnails`: the workspace and legacy
|
`$RECYCLE.BIN` (any case), as none descends `thumbnails`: the workspace and legacy
|
||||||
listings (`_walk_media`, `_settled_entry`), the metadata scan and duplicate find
|
listings (`_walk_media`, `_settled_entry`), the metadata scan and duplicate find
|
||||||
(`_collect_media_files`), the legacy auto-tag listing (`_collect_scan_files`) and the
|
(`_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
|
delete review (`trash-folder`), and the image monitor, which tests the folders below the
|
||||||
absolute path, so it also ignores outputs written into a source that itself lies
|
watched folder holding the new file (a source named like a trash folder is still watched). Tests: `tests/test_media_folders.py`,
|
||||||
inside such a folder. Tests: `tests/test_media_folders.py`,
|
`tests/test_image_removal_gaps.py`. Media routes answer not found for a path in such a
|
||||||
`tests/test_image_removal_gaps.py`.
|
folder, resolved first so a link or another spelling cannot pass (#543):
|
||||||
|
`_media_access_error` is the one gate (by-id, inspect, reveal, link, rescope as
|
||||||
|
`file-missing`, a prompt's media listing); `/images/serve` and the autotag target ask
|
||||||
|
`in_trash_below_roots` too, `media_target` refuses by name (annotations, collections,
|
||||||
|
whose capture store denies links and aliases), and previews are neither made nor served, the fallback past the preview gate's cap
|
||||||
|
included (`_quick_source`). A History output in one answers `missing`, checked at the
|
||||||
|
candidate and again at the open (a preview can be a link). The legacy duplicate delete
|
||||||
|
removes exactly the folder's real path plus the name (a link itself, never its target)
|
||||||
|
and refuses if either spelling is in a trash folder; `file_problem` refuses too. The delete
|
||||||
|
and Return flows never use this gate. Windows spellings (trailing dots and spaces, case,
|
||||||
|
8.3) run for real on the Windows job in `tests/test_trash_gate_routes.py`.
|
||||||
|
- **An output folder that is a mount point** (#543): the mount's own `.Trash-$uid` and
|
||||||
|
`.Trash/$uid` are allowed only for a root that is exactly that mount, with the spec's
|
||||||
|
checks intact. A root above the mount, a mount nested under the root, a source that is or
|
||||||
|
sits in the trash folder, and a link in place of the trash folder stay refused. Tests:
|
||||||
|
`tests/test_trash_mount_root.py`, on a fake mount table in the suite and, with
|
||||||
|
`PM_TRASH_MOUNT=<real mount>`, in portability.yml's `native-trash-linux` job (loop-mounted
|
||||||
|
ext4). Locally, without root: `unshare -Urm` over a tmpfs.
|
||||||
|
- **D190** (#543, beyond the concept): a trash review where nothing can move shows a primary
|
||||||
|
that clearly cannot act: `aria-disabled`, no danger colors (also on hover), and a press does
|
||||||
|
nothing. Tests: `tests/browser/workspace-images-delete.spec.mjs`.
|
||||||
- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the
|
- **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
|
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
|
`.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with
|
||||||
@@ -2520,6 +2555,34 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
|
|||||||
a read-only image and an unwritable folder (design and probe:
|
a read-only image and an unwritable folder (design and probe:
|
||||||
`docs/egm/499-macos-trash/`, local).
|
`docs/egm/499-macos-trash/`, local).
|
||||||
|
|
||||||
|
- **D162** (#498, maintainer ruling Q1 of #480; the concept has no screen for removed images, so this follows D152's
|
||||||
|
Show archived prompts): **Show removed images (N)** in the Images heading actions, before Import, a toggle
|
||||||
|
(`aria-pressed`) shown only while N > 0 or the view is on. N is `removed_total`, every removed image in the selected
|
||||||
|
source(s) whatever the filters (D152 counts the matching ones). The view (`GET /images/output?mode=workspace&
|
||||||
|
removed=1`, refused with `favorites`, `collection`, `category`, `tags` or `sort=rating`; a cursor never crosses
|
||||||
|
views) lists only files a removal mark hides, each card with a "Removed" `.chip` and without the star or Tag action;
|
||||||
|
the caption reads "N removed images · files on disk, hidden from Images", the empty view "No removed images." /
|
||||||
|
"No removed images match.". Entering clears Category and Tags; choosing a category or tags while it is on
|
||||||
|
(toolbar, Refine this view) ends the view. A card's image and Info both inspect (the viewer is
|
||||||
|
not opened: its Collections… and Delete… would write); the panel shows Return to library in Delete…'s place and
|
||||||
|
no Collections… or tag-filter chips. The bar reads "N selected", Return to library, Clear. The view is never saved
|
||||||
|
or restored: leaving the page, Browse or a saved view ends it, and a return that leaves N = 0 ends it too.
|
||||||
|
- **Return to library** (no review: nothing is lost or moved, as D152's Restore) posts `{items | filter}` to
|
||||||
|
`POST /prompt_manager/images/removed/return` (only this route takes `filter.removed`; every other bulk route
|
||||||
|
refuses it). 202 `{job, returned, not_removed}` (400 `no-images` when none is hidden now, `too-many-images` over
|
||||||
|
500; 409 `busy` beside a scan, restore or delete, nothing changed). The job is a `scan`, "Return images to the
|
||||||
|
library": its first step deletes, in one transaction, the marks that still hide their file (same size and
|
||||||
|
`mtime_ns`; the marks generation moves again after the commit), then it scans exactly those files with the scan's
|
||||||
|
pipeline and groups. Toasts: "Returning N images to the library…", then "Returned N images to the library; K
|
||||||
|
linked to their prompts. Tags, categories, collections and favorites do not come back." When it ends, the
|
||||||
|
sidebar's Needs review, Library and Images counts are read again (`JOB_END_COUNTS.scan`).
|
||||||
|
- What comes back: the file in Images; its prompt, links, pairs and Needs review items from its embedded metadata
|
||||||
|
(a file without usable metadata comes back unlinked); previews on demand. Not: image tags, auto-tag marks, own
|
||||||
|
category, collection memberships, favorite (only a safety-backup restore has them). If the server stops between
|
||||||
|
the mark delete and the scan, the files are back unlinked and a "new files" scan links them.
|
||||||
|
Tests: `tests/test_image_return.py`, `tests/js/workspace-images-return.test.js`,
|
||||||
|
`tests/browser/workspace-images-removed.spec.mjs`. A Python change: ComfyUI needs a restart.
|
||||||
|
|
||||||
### Prompt library cards and recorded deviations (#249 PR E)
|
### Prompt library cards and recorded deviations (#249 PR E)
|
||||||
|
|
||||||
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||||
@@ -2614,8 +2677,8 @@ applied), D35 (singular captions), D36 (sorted categories), D40 (the continuatio
|
|||||||
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
|
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
|
||||||
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
|
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
|
||||||
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
|
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
|
||||||
"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open
|
"Current prompt library" link is removed from the shelf; the legacy Library stays at
|
||||||
existing prompt library") and the sidebar still link `/prompt_manager/admin`
|
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
|
||||||
(`tests/browser/workspace.spec.mjs`). `library comparison detects
|
(`tests/browser/workspace.spec.mjs`). `library comparison detects
|
||||||
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
|
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
|
||||||
|
|
||||||
@@ -2646,7 +2709,7 @@ Category". It is built only from the concept's Images parts:
|
|||||||
- `div#library-bulkbar` sits between the toolbar and the caption, empty until something
|
- `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
|
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; D152 adds Delete… before
|
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
|
Clear, and Restore then Delete… on the archived shelf, #492). The
|
||||||
selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their
|
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
|
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
|
write) and leaving the page clear it. Clicking a checkbox toggles it and sets the
|
||||||
@@ -2861,6 +2924,37 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library
|
|||||||
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
||||||
change: ComfyUI needs a restart.
|
change: ComfyUI needs a restart.
|
||||||
|
|
||||||
|
### Pair node gives a new prompt its category and tags (#546)
|
||||||
|
|
||||||
|
A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that
|
||||||
|
save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as
|
||||||
|
another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes
|
||||||
|
no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550.
|
||||||
|
Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart.
|
||||||
|
|
||||||
|
### Delete… on the archived shelf (#492)
|
||||||
|
|
||||||
|
Extends **D160** (coordinator ruling 2026-10-29, cutover item): the archived shelf's bar reads "N selected · Restore ·
|
||||||
|
Delete… · Clear". Its Delete… review is the permanent delete only, **without** #482's "Prompts in a saved pair" choice
|
||||||
|
(on this shelf "Archive" would change nothing; Cancel keeps them archived). Title "Delete archived prompts"; the grid
|
||||||
|
is #482's Delete permanently grid ("Saved pairs removed" and "Negatives kept" only when a selected prompt is in a saved
|
||||||
|
pair); an archived prompt used as another prompt's negative cannot be deleted and **stays archived** ("Stays archived: R
|
||||||
|
prompts used as another prompt's negative"); one restored since the shelf was read is "No longer archived (kept)" and
|
||||||
|
never written; a review with nothing to delete is close-only ("None of the selected prompts can be deleted."). The
|
||||||
|
primary is "Delete N prompts"; the review opens with focus on Cancel (it is only a permanent delete; `openReviewDialog`'s
|
||||||
|
`focusCancel`). The same preview, safety backup, review digests, `records` hold and epoch fence apply.
|
||||||
|
|
||||||
|
The routes take an optional `shelf` (`"active"`, the default, or `"archived"`; anything else 400). Under it an archived
|
||||||
|
prompt is classified by its pairs and a prompt without an archive row is `not_archived`; the held write classifies the
|
||||||
|
same shelf, so a permanent delete is backed up. The client sends only what it deletes. On this shelf the write archives
|
||||||
|
and rewrites nothing; every reviewed id not deleted counts `changed`, and the answer adds `not_archived` ("N were
|
||||||
|
restored since the review and kept."). The main shelf's archive sentence now ends "To delete them later or bring them
|
||||||
|
back, press Show archived prompts, select them and press Delete… or Restore."
|
||||||
|
|
||||||
|
Tests: `tests/test_bulk_library_delete.py` TestArchivedShelfDelete, `tests/test_library_delete_routes.py`
|
||||||
|
TestArchivedShelfDeleteRoutes, `tests/js/workspace-library-delete.test.js` (archived shelf) and
|
||||||
|
`tests/browser/workspace-library-delete.spec.mjs` (#492 cases). A Python change: ComfyUI needs a restart.
|
||||||
|
|
||||||
### Delete on a prompt's own page (#481)
|
### 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
|
**D161** (beyond the concept, coordinator ruling (A) 2026-10-29, open to the maintainer at the live test): the recipe
|
||||||
@@ -2878,6 +2972,65 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete
|
|||||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
|
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.
|
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
|
||||||
|
|
||||||
|
### Card tops, the Reusable tags window and System's width (#530)
|
||||||
|
|
||||||
|
Found in the maintainer's test of the cutover branch; each departs from the concept, whose files are not edited. The
|
||||||
|
comparisons add `CONCEPT_LAYOUT_DEVIATIONS` (`concept-inventory.mjs`, four rules: card margins, System limit, tag header
|
||||||
|
background and line) to the opened concept page, so the boxes that follow from them are still compared. The new spec pins the
|
||||||
|
real values and that the unmapped concept keeps its 18px offset.
|
||||||
|
|
||||||
|
- **D182**: the second card of a `.two-col` starts level with the first. The concept's `.surface + .surface` 18px margin
|
||||||
|
also applies inside its grid, which put Categories and Workspace preferences 18px lower. Stacked cards are now one 18px
|
||||||
|
gap apart instead of 36px. A surface after a two-column row (Backup & export's Recovery policy) touched the row in the
|
||||||
|
concept and on main; it now sits 18px below it.
|
||||||
|
- **D183**: Reusable tags scrolls in its own window inside the card (`max(280px, 100dvh - 500px)`), so a long vocabulary no
|
||||||
|
longer runs the page off screen. The column headers stick to the top of the window, with the surface background and a
|
||||||
|
drawn line. Load more stays below the window; the end of the list inside the window (`.vocabulary-end`, observed with
|
||||||
|
the window as root) appends the next page. A new search or sort returns the window to its top. Reaching Load more by
|
||||||
|
keyboard no longer appends on its own; Enter does. A rename or merge reloads page one, so the window returns to its top.
|
||||||
|
- **D184**: `.settings-layout` has no 1000px limit, so every System section uses the full content width, as the section
|
||||||
|
nav and the other pages do.
|
||||||
|
- **D185**: on touch and at 390px the same window stays shorter than the screen and overscroll chains to the page, so a
|
||||||
|
swipe outside the window, or past its top, moves the page.
|
||||||
|
|
||||||
|
Measured in `workspace-layout-tags-system.spec.mjs`; the swipe case runs in the touch project.
|
||||||
|
|
||||||
|
### Prompt page heading on one row (#524)
|
||||||
|
|
||||||
|
**D164** (beyond the concept, which never has four heading actions beside a long title): from 951px, the concept's
|
||||||
|
sidebar breakpoint, the prompt page's heading actions keep one row and the title takes the remaining width, clamped to
|
||||||
|
two lines with its full text in the DOM and in `title`. Below 951px the concept's wrapping and stacked heading is
|
||||||
|
unchanged. A short title lays out as the concept's, so the history concept comparison is unaffected. A deleted prompt's
|
||||||
|
page has no "Prompt history · …" description: it is emptied and hidden while the gone message shows.
|
||||||
|
|
||||||
|
### Delete review and prompt-page polish (#512)
|
||||||
|
|
||||||
|
Found in the live test of #505/#507/#509. **D163** (beyond the concept, maintainer ruling 2026-10-29: delete was hard to
|
||||||
|
find on a prompt's page): the prompt page heading ends with a **"⋯" More menu** button after Send to ComfyUI
|
||||||
|
(`more-menu.mjs`: `aria-haspopup="menu"`, `aria-expanded`; Enter, Space or ArrowDown opens it on its first item, ArrowUp
|
||||||
|
on its last; arrows, Home and End move; Escape closes it and returns focus to ⋯; Tab, an outside press or a choice
|
||||||
|
closes it). It holds one danger item, "Delete prompt…", which opens the same review as Edit metadata's Delete prompt…
|
||||||
|
(D161, kept) and returns focus to ⋯. The menu uses the concept's button, surface, border and toast shadow, opens
|
||||||
|
right-aligned under ⋯, and opens rightwards where the heading wraps it to the left edge (390px). It is held with Edit
|
||||||
|
metadata while the page's review or write runs, and disabled on a gone prompt's page.
|
||||||
|
|
||||||
|
Also:
|
||||||
|
- The Delete… review leaves out rows with nothing in them ("Delete permanently: 0 …", "Archive: 0 …"), close-only
|
||||||
|
reviews included.
|
||||||
|
- A review opened from a prompt's page is titled "Delete this prompt" or "Archive this prompt" (following the choice),
|
||||||
|
and its close-only sentence reads "This prompt cannot be deleted or archived."
|
||||||
|
- An archived prompt that is another prompt's negative is named so: "1 prompt used as another prompt's negative:
|
||||||
|
hidden, restorable" (or "N prompts in a saved pair (M used as another prompt's negative)"), with the sentence that its
|
||||||
|
variant stays.
|
||||||
|
- A prompt page whose prompt no longer exists (404, after a delete or from a stale link) reads "This prompt no longer
|
||||||
|
exists. It may have been deleted." with "Open the Prompt library", no Retry; Edit metadata and ⋯ are disabled.
|
||||||
|
- Item 5 of #512 does not fail on the code after #505: the archived shelf's button reads "← Prompt library", and under a
|
||||||
|
remembered search the heading's count equals what the archived shelf lists. A regression test pins both.
|
||||||
|
|
||||||
|
Tests: `tests/js/workspace-library-delete.test.js` (#512) and `tests/browser/workspace-library-delete.spec.mjs` (#512
|
||||||
|
cases: the menu by mouse, keyboard and at 390px, the gone page, the titles, the count under a search). JS only: no
|
||||||
|
restart.
|
||||||
|
|
||||||
### No 1–5 prompt rating in the workspace (#447)
|
### No 1–5 prompt rating in the workspace (#447)
|
||||||
|
|
||||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||||
@@ -3031,8 +3184,8 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
|
|||||||
### Inspect a file page and recorded deviations (#249 local Inspect, PR L2)
|
### Inspect a file page and recorded deviations (#249 local Inspect, PR L2)
|
||||||
|
|
||||||
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
|
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
|
||||||
the legacy metadata page, which stays reachable from Overview's "Open existing metadata
|
the legacy metadata page, which stays at its URL
|
||||||
inspector" card (`/prompt_manager/gallery.html`). The page is the concept's `#metadata`
|
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
|
||||||
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
|
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
|
||||||
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
|
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
|
||||||
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
|
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
|
||||||
@@ -3310,15 +3463,20 @@ document title "Review" (the concept's), heading "Metadata review" with its line
|
|||||||
concept's sentence, then `.gallery-layout`: the queue's concept media cards
|
concept's sentence, then `.gallery-layout`: the queue's concept media cards
|
||||||
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
|
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
|
||||||
the item, Tag opens the shared Tag image dialog with the review scope note) and
|
the item, Tag opens the shared Tag image dialog with the review scope note) and
|
||||||
`section.surface` "Resolve the association" (filename, Tag image, Sampler context,
|
`section.surface` "Resolve the association" (filename, Tag image, Link to a prompt…,
|
||||||
the chosen path's prompt blocks, Leave for later and primary Apply association). The
|
Sampler context, the chosen path's prompt blocks, Leave for later, Leave unresolved and
|
||||||
|
primary Apply association). The
|
||||||
first item is selected on load, as the concept's first image is. Sampler context lists
|
first item is selected on load, as the concept's first image is. Sampler context lists
|
||||||
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
|
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
|
||||||
unusable ones, preselects the first workable one and ends with "Leave unresolved"
|
unusable ones with the server's reason and preselects the first workable one
|
||||||
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
|
(rulings R2/R3). Apply
|
||||||
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
|
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
|
||||||
under no current root posts without a capture. Both actions return to Images (R7); a
|
under no current root posts without a capture. Each decision and Leave for later opens
|
||||||
recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
the next item and the page stays (#536 overturns ruling R7); Leave for later writes
|
||||||
|
nothing. Link to a prompt… (#536) records a Library prompt the reviewer chose,
|
||||||
|
`POST /prompt_manager/review/items/{id}/link`: the capture guards of Apply, the same
|
||||||
|
one-prompt-per-file rules, and link provenance `kind: "manual-link"` with no prompt
|
||||||
|
sides, so it never reads as file evidence. A recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||||
refused decision keeps the selection and shows the server's sentence (the
|
refused decision keeps the selection and shows the server's sentence (the
|
||||||
association conflict's explanation included) as `p.warning-note` in the panel; a
|
association conflict's explanation included) as `p.warning-note` in the panel; a
|
||||||
restored storage or a changed queue reads the queue again; a request with no answer
|
restored storage or a changed queue reads the queue again; a request with no answer
|
||||||
@@ -3333,6 +3491,14 @@ pending count (`GET /prompt_manager/review/items?limit=1`, its `total`).
|
|||||||
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
|
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
|
||||||
item after the item's line, and in its job view's heading after Download report. The
|
item after the item's line, and in its job view's heading after Download report. The
|
||||||
item's chip still names its state (D54), where the concept's says "Needs attention".
|
item's chip still names its state (D54), where the concept's says "Needs attention".
|
||||||
|
- **D179** (#536, maintainer decision 2026-10-30): "Leave unresolved" is a button beside
|
||||||
|
Leave for later, not the Sampler context select's last option; a disabled option names
|
||||||
|
the server's reason.
|
||||||
|
- **D180** (#536): with nothing to apply, the select, the prompt blocks and Apply are
|
||||||
|
hidden; the panel gives the path count and each reason, and Link to a prompt… is primary.
|
||||||
|
- **D181** (#536): the concept has no picker. Link to a prompt… opens the shared content
|
||||||
|
dialog: Library search, the prompt's recipes as Negative prompt (one preselected,
|
||||||
|
several must be chosen, always "No negative"), Cancel and Link image.
|
||||||
- **D119**: the queue cards have no selection checkbox and
|
- **D119**: the queue cards have no selection checkbox and
|
||||||
no star (the page has no selection bar and the queue carries no favorite state); a
|
no star (the page has no selection bar and the queue carries no favorite state); a
|
||||||
file under no current root has an empty, `aria-disabled` image button and Tag; more
|
file under no current root has an empty, `aria-disabled` image button and Tag; more
|
||||||
@@ -3906,6 +4072,23 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
|||||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||||
|
- **D170** (#532, maintainer request 2026-10-30, beyond the concept): every viewer has
|
||||||
|
`button#viewer-keys.icon-btn` right before the D156 view switch (accessible name and
|
||||||
|
`title` "Keyboard shortcuts", a `command` outline icon, hidden at ≤700px with the
|
||||||
|
switch). It opens `dialog#viewer-keys-dialog`, a modal built on demand inside the
|
||||||
|
viewer, so a closed overlay adds nothing to the concept comparisons but the button:
|
||||||
|
`.dialog-header` with × and the keys grouped as Move (← → Space), Image (+ - 0 R H),
|
||||||
|
View (F I ? Esc) and File (D, Delete where the viewer has Delete…), one column at
|
||||||
|
390px. Esc or × closes only the overlay and focus returns to the button; opening it
|
||||||
|
stops the slideshow. ← and → navigate from anywhere in the viewer, on `document` as the
|
||||||
|
legacy gallery does, unless a modifier is held, text is selected, another dialog is on
|
||||||
|
top, or focus is in a video, form field, `pre`, `summary`, or anything in the details
|
||||||
|
but a button or link. The other keys share that gate but allow Shift, and each clicks
|
||||||
|
the toolbar's own control, so an aria-disabled, missing or hidden control does nothing
|
||||||
|
(Rotate on a video, I when Centered, F at ≤700px, Delete without Delete…). `?` opens
|
||||||
|
the overlay directly. Delete only opens the review. Space on a focused button or link
|
||||||
|
activates it instead, and only + and - repeat. `workspace-viewer-keys.spec.mjs` pins
|
||||||
|
this.
|
||||||
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
|
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
|
||||||
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
|
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
|
||||||
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
|
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
|
||||||
@@ -4149,6 +4332,24 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
|||||||
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||||
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
||||||
long file name keeps the concept's two equal columns at phone width.
|
long file name keeps the concept's two equal columns at phone width.
|
||||||
|
- **D173** (#535, amends D106; maintainer 2026-10-30): "always asks" now means asks when
|
||||||
|
there is a choice. One eligible window is selected, and a selected window with exactly
|
||||||
|
one eligible node selects that node; two or more nodes are never selected. A node whose
|
||||||
|
requested field is linked is not offered (`linkedRoles` on each destination), and the
|
||||||
|
status line says which case applies: linked, wrong roles, no node, or no window. It
|
||||||
|
counts the other nodes that cannot receive, and the review names the node and window.
|
||||||
|
- **D174** (#535): the window option shows the tab's current title, read at each discovery
|
||||||
|
(a getter on the host options, which the Toolkit reads per discover), without the old
|
||||||
|
"PromptManager —" prefix. Same-named windows get " (window 2)", numbered by host id so the
|
||||||
|
number is stable while the tabs stay loaded. Every node option reads "#<id> <title>", so
|
||||||
|
identical nodes can be told apart. Discovery waits 300 ms, as System does.
|
||||||
|
- **D175** (#535, maintainer 2026-10-30): the concept README previews before applying;
|
||||||
|
production makes Preview optional. Apply is enabled once a window and node are selected
|
||||||
|
and requests the ticket itself, in one click. It stays closed after "Sent to ComfyUI.",
|
||||||
|
so a double click sends once, and after an unknown outcome until a new preview or
|
||||||
|
choice. The host's checks still refuse a linked field or a changed workflow, and the
|
||||||
|
sender also refuses if the destination's `revision` (node object plus graph version)
|
||||||
|
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
|
||||||
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
||||||
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
|
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
|
||||||
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
|
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
|
||||||
@@ -4254,6 +4455,15 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
|||||||
`system shell and overview match the concept` (the mapping inserts the row) and
|
`system shell and overview match the concept` (the mapping inserts the row) and
|
||||||
`library notice matches the concept notice` with its mutation check
|
`library notice matches the concept notice` with its mutation check
|
||||||
(system-concept.spec), and the D129 tests of workspace-system.spec.
|
(system-concept.spec), and the D129 tests of workspace-system.spec.
|
||||||
|
- **D166** (one-time upgrade notice, #527): the concept's README puts the notice in the
|
||||||
|
new shell, while its `app.js` draws it in System › Sources. Production does both: the
|
||||||
|
Sources `.notice` is kept and compared as drawn, and `#older-images-notice`, styled as
|
||||||
|
`#library-banner`, shows the same notice above every other page until dismissed. The
|
||||||
|
shell hides on Sources, so they never show together. Dismissal is the legacy key
|
||||||
|
`pm_update_notice_dismissed`, shared with the admin modal and across tabs. Browser
|
||||||
|
contexts start dismissed (`tests/browser/fixtures.mjs`, `storageState`); a spec about
|
||||||
|
the notice sets `test.use({ olderImagesNotice: true })`. Pinned by
|
||||||
|
workspace-older-images-notice.spec.
|
||||||
- **D130** (Appearance, #317): the concept keeps "Thumbnail quality, size limit" in
|
- **D130** (Appearance, #317): the concept keeps "Thumbnail quality, size limit" in
|
||||||
Appearance (README) but draws no rows for them. Appearance gains a second
|
Appearance (README) but draws no rows for them. Appearance gains a second
|
||||||
`section.surface`, "Thumbnails", after "Browsing & display", built from the concept's
|
`section.surface`, "Thumbnails", after "Browsing & display", built from the concept's
|
||||||
@@ -5531,6 +5741,23 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-backups.spec.mj
|
|||||||
--project chromium --project touch --project firefox --project webkit
|
--project chromium --project touch --project firefox --project webkit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Sticky chrome (#531)
|
||||||
|
|
||||||
|
Beyond the concept, from the maintainer's test of #528; the concept is unchanged. `workspace-sticky-chrome.spec.mjs` holds these.
|
||||||
|
|
||||||
|
- **D186:** the selection bars (Images `#bulkbar`, Library `#library-bulkbar`; there are no others) are sticky: under the
|
||||||
|
top bar at the top of the page, and at the top of the window once that non-sticky top bar has scrolled away (one rule
|
||||||
|
in workspace.css). While the bar's flow place is above the window, its appearing, resizing or leaving does not move the cards
|
||||||
|
(`keepingPlace`). Focused card controls and the Images panel keep clear of it (`--selection-bar-block`). At 700px and below
|
||||||
|
the bar is one row that scrolls sideways. After a bulk action finishes the page scrolls to the top and refreshes the
|
||||||
|
list; Set category, Delete… and Return to library clear the selection as before, while tagging and collections keep
|
||||||
|
it (D85, D138). Export metadata changes nothing, so it stays put. Single-image actions from the inspector and viewer stay put.
|
||||||
|
- **D187:** Back to top: one shell chip past 200px of scroll, above the toast's spot; it rises only for a toast taller than two lines. It never animates, because the
|
||||||
|
pages' height corrections would cancel a smooth scroll, and focus moves to `#workspace-main`. D42's band grows to 140px.
|
||||||
|
- **D188:** the Needs review panel is sticky like the Images panel, with its own scroll on a window under 720px tall, and static at 700px and below.
|
||||||
|
- **D189:** where a panel is one column (static): Info brings the panel into view. On
|
||||||
|
Images the panel follows the whole list, past infinite scroll, so loading waits for the reader's next move (wheel, touch or key) rather than pushing the panel away.
|
||||||
|
|
||||||
## Workspace data fields for the concept pages (#249)
|
## Workspace data fields for the concept pages (#249)
|
||||||
|
|
||||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||||
@@ -6570,7 +6797,7 @@ checkout or point this harness at a live ComfyUI checkout.
|
|||||||
|
|
||||||
The package-qualified config import also corrects #211: the configured database
|
The package-qualified config import also corrects #211: the configured database
|
||||||
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
|
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
|
||||||
[database path compatibility and safe backup sequence](README.md#database-path-compatibility-211)
|
[database path compatibility and safe backup sequence](README-legacy.md#database-path-compatibility-211)
|
||||||
before restarting an existing deployment.
|
before restarting an existing deployment.
|
||||||
|
|
||||||
The ledger records only new dequeued jobs and observed outcomes. Existing
|
The ledger records only new dequeued jobs and observed outcomes. Existing
|
||||||
@@ -6943,8 +7170,8 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
|||||||
## Native vocabulary UI (#236)
|
## Native vocabulary UI (#236)
|
||||||
|
|
||||||
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
|
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
|
||||||
longer hands off to the legacy tag manager. That page stays reachable from Overview's
|
longer hands off to the legacy tag manager. That page stays at
|
||||||
"Open existing tag manager" card for its own page; tag search and sort, the untagged
|
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
|
||||||
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
|
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
|
||||||
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
|
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
|
||||||
library's selection bar, D151). A prompt's category is changed in the Prompt library
|
library's selection bar, D151). A prompt's category is changed in the Prompt library
|
||||||
@@ -7066,3 +7293,23 @@ until acceptance. Not compared: hover states and the dialog focus ring (the page
|
|||||||
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
|
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
|
||||||
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
|
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
|
||||||
floors in `frontend-coverage-baseline.json`.
|
floors in `frontend-coverage-baseline.json`.
|
||||||
|
|
||||||
|
## Cutover entry points (#525)
|
||||||
|
|
||||||
|
The top-bar PM button, both Web UI buttons and `/prompt_manager/web` open
|
||||||
|
`/prompt_manager/workspace#/library` (`web/js/workspace-entry.js`; `web/index.html` keeps its
|
||||||
|
own copy, pinned by `tests/js/workspace-entry.test.js`). Overview's cards open native hashes.
|
||||||
|
The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.html`) stay at
|
||||||
|
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
|
||||||
|
ruling; pinned by `legacy-system-links.spec.mjs`).
|
||||||
|
|
||||||
|
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
|
||||||
|
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
|
||||||
|
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
|
||||||
|
`mapOverviewConcept` adds it to the page tree.
|
||||||
|
- **Load failure** (maintainer ruling 2026-10-30): `web/workspace.html` holds static
|
||||||
|
`#workspace-boot-failure` with the same four links, styled inline so it needs no stylesheet.
|
||||||
|
An inline guard shows it at `load` unless `index.mjs` set `data-workspace-booted`, so a failed
|
||||||
|
fetch of any module and a throw while starting are both covered; `workspace.spec.mjs` tests
|
||||||
|
both. Editing `workspace.html` needs a ComfyUI restart, because the server caches it. The PM button has no
|
||||||
|
page-fixture test; it is proven on the managed host (`tests/comfyui/prompt-manager.spec.mjs`).
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ except Exception as e:
|
|||||||
logger = get_logger("prompt_manager.init")
|
logger = get_logger("prompt_manager.init")
|
||||||
logger.error(f"Failed to register API routes: {e}")
|
logger.error(f"Failed to register API routes: {e}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# logging failed too; a report must never stop the node loading
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Shared database, set up with the monitoring system below. Stays None when that
|
# Shared database, set up with the monitoring system below. Stays None when that
|
||||||
@@ -411,6 +412,7 @@ except Exception as e:
|
|||||||
f"Failed to register the exit signal: {e}"
|
f"Failed to register the exit signal: {e}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# logging failed too; a report must never stop the node loading
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -428,6 +430,7 @@ except Exception as e:
|
|||||||
f"Failed to register the background work teardown: {e}"
|
f"Failed to register the background work teardown: {e}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# logging failed too; a report must never stop the node loading
|
||||||
pass
|
pass
|
||||||
|
|
||||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||||
|
|||||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -43,6 +43,7 @@ from .image_categories import DELETE_SQL as DELETE_CATEGORY_SQL
|
|||||||
from .image_removal_schema import (
|
from .image_removal_schema import (
|
||||||
META_TABLE,
|
META_TABLE,
|
||||||
TABLE,
|
TABLE,
|
||||||
|
bump_generation,
|
||||||
clear_marks,
|
clear_marks,
|
||||||
has_marks_table,
|
has_marks_table,
|
||||||
mark_of,
|
mark_of,
|
||||||
@@ -319,4 +320,29 @@ class ImageRemovalOperations:
|
|||||||
if not locators:
|
if not locators:
|
||||||
return 0
|
return 0
|
||||||
with self._pair_transaction() as conn:
|
with self._pair_transaction() as conn:
|
||||||
return clear_marks(conn, locators)
|
removed = clear_marks(conn, locators)
|
||||||
|
# #498: again after the commit, so a reader that cached the marks
|
||||||
|
# between the in-transaction bump and the commit reloads them.
|
||||||
|
bump_generation()
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def clear_hiding_marks(self, files) -> set:
|
||||||
|
"""Delete the marks that still hide their file (#498, Return to library).
|
||||||
|
|
||||||
|
*files* are ``(locator, path)`` pairs. In one ``BEGIN IMMEDIATE``
|
||||||
|
transaction each mark is read again and deleted only while the file at
|
||||||
|
*path* still has its size and ``mtime_ns``. Returns the locators whose
|
||||||
|
marks went.
|
||||||
|
"""
|
||||||
|
cleared = set()
|
||||||
|
files = list(files)
|
||||||
|
if not files:
|
||||||
|
return cleared
|
||||||
|
with self._pair_transaction() as conn:
|
||||||
|
for locator, path in files:
|
||||||
|
mark = mark_of(conn, locator)
|
||||||
|
if mark is not None and mark_hides({locator: mark}, locator, path):
|
||||||
|
cleared.add(locator)
|
||||||
|
clear_marks(conn, sorted(cleared))
|
||||||
|
bump_generation()
|
||||||
|
return cleared
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ def _bump() -> None:
|
|||||||
_generation += 1
|
_generation += 1
|
||||||
|
|
||||||
|
|
||||||
|
def bump_generation() -> None:
|
||||||
|
"""Move the generation after a commit (a cached read made before it reloads)."""
|
||||||
|
_bump()
|
||||||
|
|
||||||
|
|
||||||
def has_marks_table(conn: sqlite3.Connection) -> bool:
|
def has_marks_table(conn: sqlite3.Connection) -> bool:
|
||||||
return (
|
return (
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -49,7 +49,26 @@ _NEGATIVE_OF_OTHER = (
|
|||||||
"WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)"
|
"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 in the order a prompt is tested; the first that holds wins.
|
||||||
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
|
CLASSES = (
|
||||||
|
"not_found",
|
||||||
|
"protected",
|
||||||
|
"already_archived",
|
||||||
|
"not_archived",
|
||||||
|
"archive",
|
||||||
|
"delete",
|
||||||
|
)
|
||||||
|
# #492: the shelf a Delete… runs on. On the archived shelf an archived prompt is
|
||||||
|
# classified by its pairs, and one restored since is ``not_archived``.
|
||||||
|
SHELVES = ("active", "archived")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_shelf(shelf: str) -> bool:
|
||||||
|
"""Whether ``shelf`` is the archived shelf (ValueError when unknown)."""
|
||||||
|
if shelf not in SHELVES:
|
||||||
|
raise ValueError("shelf must be active or archived")
|
||||||
|
return shelf == "archived"
|
||||||
|
|
||||||
|
|
||||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
@@ -86,8 +105,9 @@ class LibraryArchiveOperations:
|
|||||||
"""PromptDatabase mixin: classify, archive or delete, and restore parents."""
|
"""PromptDatabase mixin: classify, archive or delete, and restore parents."""
|
||||||
|
|
||||||
def _classify_library_delete(
|
def _classify_library_delete(
|
||||||
self, conn: sqlite3.Connection, prompt_ids: list[int]
|
self, conn: sqlite3.Connection, prompt_ids: list[int], shelf: str = "active"
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
on_archived_shelf = _check_shelf(shelf)
|
||||||
found, deletable = {}, set()
|
found, deletable = {}, set()
|
||||||
for chunk in _batches(prompt_ids):
|
for chunk in _batches(prompt_ids):
|
||||||
marks = ",".join("?" for _ in chunk)
|
marks = ",".join("?" for _ in chunk)
|
||||||
@@ -100,7 +120,9 @@ class LibraryArchiveOperations:
|
|||||||
protected, archived, paired, negative_of_other = row[1:5]
|
protected, archived, paired, negative_of_other = row[1:5]
|
||||||
if protected:
|
if protected:
|
||||||
found[row[0]] = "protected"
|
found[row[0]] = "protected"
|
||||||
elif archived:
|
elif on_archived_shelf and not archived:
|
||||||
|
found[row[0]] = "not_archived"
|
||||||
|
elif archived and not on_archived_shelf:
|
||||||
found[row[0]] = "already_archived"
|
found[row[0]] = "already_archived"
|
||||||
elif paired:
|
elif paired:
|
||||||
found[row[0]] = "archive"
|
found[row[0]] = "archive"
|
||||||
@@ -237,18 +259,20 @@ class LibraryArchiveOperations:
|
|||||||
return list(dict.fromkeys(prompt_ids))
|
return list(dict.fromkeys(prompt_ids))
|
||||||
|
|
||||||
def classify_library_delete(
|
def classify_library_delete(
|
||||||
self, prompt_ids: list[int], *, expected_epoch: str
|
self, prompt_ids: list[int], *, expected_epoch: str, shelf: str = "active"
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""What a delete of ``prompt_ids`` would do now (reads only).
|
"""What a delete of ``prompt_ids`` would do now (reads only).
|
||||||
|
|
||||||
Returns the id lists per class, in request order, ``image_links``
|
Returns the id lists per class, in request order, ``image_links``
|
||||||
(the image link rows the ``delete`` class would remove) and
|
(the image link rows the ``delete`` class would remove) and
|
||||||
``fingerprints`` of the ``delete`` class (``_fingerprints``).
|
``fingerprints`` of the ``delete`` class (``_fingerprints``). On the
|
||||||
|
archived ``shelf`` (#492) archived prompts are classified by their pairs.
|
||||||
"""
|
"""
|
||||||
|
_check_shelf(shelf)
|
||||||
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
|
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
|
||||||
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
||||||
epoch = self._library_epoch(conn, expected_epoch)
|
epoch = self._library_epoch(conn, expected_epoch)
|
||||||
classes = self._classify_library_delete(conn, prompt_ids)
|
classes = self._classify_library_delete(conn, prompt_ids, shelf)
|
||||||
deletable = classes["deletable"]
|
deletable = classes["deletable"]
|
||||||
return {
|
return {
|
||||||
"library_epoch": epoch,
|
"library_epoch": epoch,
|
||||||
@@ -290,6 +314,7 @@ class LibraryArchiveOperations:
|
|||||||
expected_epoch: str,
|
expected_epoch: str,
|
||||||
candidates: dict | None,
|
candidates: dict | None,
|
||||||
delete_paired: dict | None = None,
|
delete_paired: dict | None = None,
|
||||||
|
shelf: str = "active",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Archive and delete what the user reviewed, all or nothing.
|
"""Archive and delete what the user reviewed, all or nothing.
|
||||||
|
|
||||||
@@ -305,7 +330,12 @@ class LibraryArchiveOperations:
|
|||||||
Delete permanently to their review digest. Each is deleted with its
|
Delete permanently to their review digest. Each is deleted with its
|
||||||
own saved pairs only while it is still deletable and its digest is
|
own saved pairs only while it is still deletable and its digest is
|
||||||
unchanged; otherwise it is archived and counted ``changed``.
|
unchanged; otherwise it is archived and counted ``changed``.
|
||||||
|
|
||||||
|
On the archived ``shelf`` (#492) the ids are archived already: nothing
|
||||||
|
is archived or rewritten, every reviewed id not deleted (protected or
|
||||||
|
changed) counts ``changed``, and one restored since ``not_archived``.
|
||||||
"""
|
"""
|
||||||
|
on_archived_shelf = _check_shelf(shelf)
|
||||||
_validate_epoch(expected_epoch)
|
_validate_epoch(expected_epoch)
|
||||||
for ids in (archive_ids, delete_ids):
|
for ids in (archive_ids, delete_ids):
|
||||||
if not isinstance(ids, list) or any(
|
if not isinstance(ids, list) or any(
|
||||||
@@ -330,7 +360,7 @@ class LibraryArchiveOperations:
|
|||||||
prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids]))
|
prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids]))
|
||||||
with self._pair_transaction() as conn:
|
with self._pair_transaction() as conn:
|
||||||
epoch = self._library_epoch(conn, expected_epoch)
|
epoch = self._library_epoch(conn, expected_epoch)
|
||||||
classes = self._classify_library_delete(conn, prompt_ids)
|
classes = self._classify_library_delete(conn, prompt_ids, shelf)
|
||||||
now_prints = self._fingerprints(conn, classes["delete"])
|
now_prints = self._fingerprints(conn, classes["delete"])
|
||||||
deletes, archives, changed = [], [], 0
|
deletes, archives, changed = [], [], 0
|
||||||
paired_now = set(classes["deletable"])
|
paired_now = set(classes["deletable"])
|
||||||
@@ -356,6 +386,11 @@ class LibraryArchiveOperations:
|
|||||||
else:
|
else:
|
||||||
changed += 1
|
changed += 1
|
||||||
changed += len(classes["protected"])
|
changed += len(classes["protected"])
|
||||||
|
if on_archived_shelf:
|
||||||
|
# Already archived: the rest stay as they are, each one counted.
|
||||||
|
archives = []
|
||||||
|
changed = len(prompt_ids) - len(deletes) - len(paired_deletes)
|
||||||
|
changed -= len(classes["not_found"]) + len(classes["not_archived"])
|
||||||
image_links = self._image_links(conn, [*deletes, *paired_deletes])
|
image_links = self._image_links(conn, [*deletes, *paired_deletes])
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
for pid in archives:
|
for pid in archives:
|
||||||
@@ -393,6 +428,7 @@ class LibraryArchiveOperations:
|
|||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
"archived": len(archives),
|
"archived": len(archives),
|
||||||
"already_archived": len(classes["already_archived"]),
|
"already_archived": len(classes["already_archived"]),
|
||||||
|
"not_archived": len(classes["not_archived"]),
|
||||||
"not_found": len(classes["not_found"]),
|
"not_found": len(classes["not_found"]),
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"image_links": image_links,
|
"image_links": image_links,
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ class _Move:
|
|||||||
try:
|
try:
|
||||||
os.rmdir(self.plan.target_dir)
|
os.rmdir(self.plan.target_dir)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# not empty or already gone: the undo leaves it alone
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,38 +35,51 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import operator
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import weakref
|
import weakref
|
||||||
from typing import Iterable, Iterator, List, Optional
|
from collections import deque
|
||||||
|
from typing import Any, Callable, Iterable, Iterator, List, Optional
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
CLOSED_MESSAGE = "Cannot operate on a closed database."
|
CLOSED_MESSAGE = "Cannot operate on a closed database."
|
||||||
# Rows fetched per step when a cursor is iterated: the connection is marked
|
# Rows fetched per step when a cursor is iterated: the connection is marked
|
||||||
# in use once per chunk instead of once per row (iteration costs about what
|
# in use once per chunk instead of once per row (iteration costs about what
|
||||||
# a plain cursor costs). A loop that stops early leaves up to this many rows
|
# a plain cursor costs). Prefetched rows belong to the cursor so iteration
|
||||||
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
|
# and explicit fetches always advance the same position.
|
||||||
ITER_CHUNK = 128
|
ITER_CHUNK = 128
|
||||||
|
|
||||||
|
|
||||||
class ManagedCursor(sqlite3.Cursor):
|
class ManagedCursor(sqlite3.Cursor):
|
||||||
"""A cursor whose calls into SQLite count as its connection being in use."""
|
"""A cursor whose calls into SQLite count as its connection being in use."""
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._pending = deque()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
def execute(self, sql, parameters=(), /):
|
def execute(self, sql, parameters=(), /):
|
||||||
conn = self.connection
|
return self._run_statement(super().execute, sql, parameters)
|
||||||
conn._enter()
|
|
||||||
try:
|
|
||||||
return super().execute(sql, parameters)
|
|
||||||
finally:
|
|
||||||
conn._leave()
|
|
||||||
|
|
||||||
def executemany(self, sql, seq_of_parameters, /):
|
def executemany(self, sql, seq_of_parameters, /):
|
||||||
|
return self._run_statement(super().executemany, sql, seq_of_parameters)
|
||||||
|
|
||||||
|
def _run_statement(self, operation: Callable, *args: Any) -> ManagedCursor:
|
||||||
conn = self.connection
|
conn = self.connection
|
||||||
conn._enter()
|
conn._enter()
|
||||||
|
description = self.description
|
||||||
|
completed = False
|
||||||
try:
|
try:
|
||||||
return super().executemany(sql, seq_of_parameters)
|
result = operation(*args)
|
||||||
|
completed = True
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
|
# Argument parsing can fail before SQLite replaces the old statement.
|
||||||
|
if completed or self.description is not description:
|
||||||
|
self._pending.clear()
|
||||||
conn._leave()
|
conn._leave()
|
||||||
|
|
||||||
def executescript(self, sql_script, /):
|
def executescript(self, sql_script, /):
|
||||||
@@ -81,6 +94,8 @@ class ManagedCursor(sqlite3.Cursor):
|
|||||||
conn = self.connection
|
conn = self.connection
|
||||||
conn._enter()
|
conn._enter()
|
||||||
try:
|
try:
|
||||||
|
if self._pending and not self._closed:
|
||||||
|
return self._pending.popleft()
|
||||||
return super().fetchone()
|
return super().fetchone()
|
||||||
finally:
|
finally:
|
||||||
conn._leave()
|
conn._leave()
|
||||||
@@ -89,7 +104,20 @@ class ManagedCursor(sqlite3.Cursor):
|
|||||||
conn = self.connection
|
conn = self.connection
|
||||||
conn._enter()
|
conn._enter()
|
||||||
try:
|
try:
|
||||||
return super().fetchmany(*args, **kwargs)
|
# Let SQLite validate the original arguments, including version-specific
|
||||||
|
# handling of zero/negative sizes, before touching prefetched rows.
|
||||||
|
rows = super().fetchmany(*args, **kwargs)
|
||||||
|
if not self._pending:
|
||||||
|
return rows
|
||||||
|
size = operator.index(
|
||||||
|
kwargs.get("size", args[0] if args else self.arraysize)
|
||||||
|
)
|
||||||
|
# Python 3.12 reads all rows for size=0; 3.13+ reads none.
|
||||||
|
if size == 0 and sys.version_info >= (3, 13):
|
||||||
|
return rows
|
||||||
|
self._pending.extend(rows)
|
||||||
|
count = len(self._pending) if size <= 0 else min(size, len(self._pending))
|
||||||
|
return [self._pending.popleft() for _ in range(count)]
|
||||||
finally:
|
finally:
|
||||||
conn._leave()
|
conn._leave()
|
||||||
|
|
||||||
@@ -97,25 +125,31 @@ class ManagedCursor(sqlite3.Cursor):
|
|||||||
conn = self.connection
|
conn = self.connection
|
||||||
conn._enter()
|
conn._enter()
|
||||||
try:
|
try:
|
||||||
return super().fetchall()
|
rows = super().fetchall()
|
||||||
|
pending = list(self._pending)
|
||||||
|
self._pending.clear()
|
||||||
|
return pending + rows
|
||||||
finally:
|
finally:
|
||||||
conn._leave()
|
conn._leave()
|
||||||
|
|
||||||
def __iter__(self) -> Iterator:
|
def __iter__(self) -> Iterator:
|
||||||
return self._chunks()
|
return self
|
||||||
|
|
||||||
def _chunks(self) -> Iterator:
|
|
||||||
while True:
|
|
||||||
rows = self.fetchmany(ITER_CHUNK)
|
|
||||||
if not rows:
|
|
||||||
return
|
|
||||||
yield from rows
|
|
||||||
|
|
||||||
def __next__(self):
|
def __next__(self):
|
||||||
conn = self.connection
|
conn = self.connection
|
||||||
|
pending = self._pending
|
||||||
|
# Buffered Python rows need no SQLite lock, but retirement and cursor
|
||||||
|
# close must still stop an iterator obtained before either operation.
|
||||||
|
if conn._retired or self._closed:
|
||||||
|
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
|
||||||
|
if pending:
|
||||||
|
return pending.popleft()
|
||||||
conn._enter()
|
conn._enter()
|
||||||
try:
|
try:
|
||||||
return super().__next__()
|
pending.extend(super().fetchmany(ITER_CHUNK))
|
||||||
|
if not pending:
|
||||||
|
raise StopIteration
|
||||||
|
return pending.popleft()
|
||||||
finally:
|
finally:
|
||||||
conn._leave()
|
conn._leave()
|
||||||
|
|
||||||
@@ -126,7 +160,10 @@ class ManagedCursor(sqlite3.Cursor):
|
|||||||
except sqlite3.ProgrammingError:
|
except sqlite3.ProgrammingError:
|
||||||
return super().close() # the connection is closed or closing
|
return super().close() # the connection is closed or closing
|
||||||
try:
|
try:
|
||||||
return super().close()
|
result = super().close()
|
||||||
|
self._pending = deque()
|
||||||
|
self._closed = True
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
conn._leave()
|
conn._leave()
|
||||||
|
|
||||||
@@ -293,6 +330,9 @@ class ManagedConnection(sqlite3.Connection):
|
|||||||
cursors = list(self._cursors)
|
cursors = list(self._cursors)
|
||||||
try:
|
try:
|
||||||
for cursor in cursors:
|
for cursor in cursors:
|
||||||
|
if isinstance(cursor, ManagedCursor):
|
||||||
|
cursor._pending = deque()
|
||||||
|
cursor._closed = True
|
||||||
with contextlib.suppress(sqlite3.Error):
|
with contextlib.suppress(sqlite3.Error):
|
||||||
sqlite3.Cursor.close(cursor)
|
sqlite3.Cursor.close(cursor)
|
||||||
super().close()
|
super().close()
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from .history_reads import HistoryReadOperations
|
|||||||
from .health import HealthOperations, iter_consistency_issues
|
from .health import HealthOperations, iter_consistency_issues
|
||||||
from .duplicate_copies import DuplicateCopyOperations
|
from .duplicate_copies import DuplicateCopyOperations
|
||||||
from .workflow_storage import WorkflowStorageOperations
|
from .workflow_storage import WorkflowStorageOperations
|
||||||
from .category_import import CategoryImportOperations, EmptyReplacement # noqa: F401
|
from .category_import import CategoryImportOperations
|
||||||
from .image_categories import ImageCategoryOperations
|
from .image_categories import ImageCategoryOperations
|
||||||
from .workflow_documents import (
|
from .workflow_documents import (
|
||||||
DocumentReader,
|
DocumentReader,
|
||||||
|
|||||||
@@ -112,8 +112,14 @@ class PairOperations:
|
|||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
rating: Optional[int] = None,
|
rating: Optional[int] = None,
|
||||||
notes: Optional[str] = None,
|
notes: Optional[str] = None,
|
||||||
|
fill_new_positive: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
"""Save an ordered pair atomically; repeat saves preserve its metadata.
|
||||||
|
|
||||||
|
``fill_new_positive`` also gives the positive prompt the category and
|
||||||
|
tags, only if this save inserted its row (#546). An existing prompt
|
||||||
|
never changes, so a cleared category stays cleared.
|
||||||
|
"""
|
||||||
with self._pair_transaction() as conn:
|
with self._pair_transaction() as conn:
|
||||||
return self._save_pair(
|
return self._save_pair(
|
||||||
conn,
|
conn,
|
||||||
@@ -124,6 +130,7 @@ class PairOperations:
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
rating=rating,
|
rating=rating,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
fill_new_positive=fill_new_positive,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _save_pair(
|
def _save_pair(
|
||||||
@@ -137,6 +144,7 @@ class PairOperations:
|
|||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
rating: Optional[int] = None,
|
rating: Optional[int] = None,
|
||||||
notes: Optional[str] = None,
|
notes: Optional[str] = None,
|
||||||
|
fill_new_positive: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||||
if not isinstance(positive_text, str) or not positive_text.strip():
|
if not isinstance(positive_text, str) or not positive_text.strip():
|
||||||
@@ -144,8 +152,13 @@ class PairOperations:
|
|||||||
if not isinstance(negative_text, str):
|
if not isinstance(negative_text, str):
|
||||||
raise ValueError("Negative prompt text must be a string; use '' for empty")
|
raise ValueError("Negative prompt text must be a string; use '' for empty")
|
||||||
# Validate metadata before any component insertion.
|
# Validate metadata before any component insertion.
|
||||||
_validate_metadata(title, category, tags, rating, notes)
|
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||||
positive = self._pair_component(conn, positive_text)
|
positive, created = self._pair_component_outcome(conn, positive_text)
|
||||||
|
if created and fill_new_positive:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prompts SET category = ? WHERE id = ?", (category, positive)
|
||||||
|
)
|
||||||
|
self._sync_prompt_tags(conn, positive, tags or [])
|
||||||
negative = (
|
negative = (
|
||||||
self._pair_component(conn, negative_text) if negative_text.strip() else None
|
self._pair_component(conn, negative_text) if negative_text.strip() else None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ A decision is one row of ``media_review_decisions`` for one item revision.
|
|||||||
ingester's compatible-only rules (``_ingest_resolved_pair_in``) in the same
|
ingester's compatible-only rules (``_ingest_resolved_pair_in``) in the same
|
||||||
``_pair_transaction`` as the decision row, the item's new state and revision
|
``_pair_transaction`` as the decision row, the item's new state and revision
|
||||||
and the review index revision; any conflict or later failure rolls all of it
|
and the review index revision; any conflict or later failure rolls all of it
|
||||||
back. ``unresolved`` records the decision and links nothing. No decision
|
back. ``manual`` (#536) is the same but for a Library prompt the reviewer chose:
|
||||||
counts a run, touches usage or calls an execution writer.
|
its link's provenance names a manual choice and carries no prompt sides, so it
|
||||||
|
cannot pose as file evidence, and the decision's ``candidate_hash`` (the table
|
||||||
|
requires one) is the digest of that choice. ``unresolved`` records the decision
|
||||||
|
and links nothing. No decision counts a run, touches usage or calls an execution
|
||||||
|
writer.
|
||||||
|
|
||||||
Callers verify the file first (the capture must be the item's bytes and the
|
Callers verify the file first (the capture must be the item's bytes and the
|
||||||
current file must still hash to them); nothing here reads the filesystem.
|
current file must still hash to them); nothing here reads the filesystem.
|
||||||
@@ -17,6 +21,7 @@ import sqlite3
|
|||||||
|
|
||||||
from .annotation_schema import BYTES_TABLE
|
from .annotation_schema import BYTES_TABLE
|
||||||
from .annotations import _new_id
|
from .annotations import _new_id
|
||||||
|
from .library import _ELIGIBLE, _FROM, SHELVED
|
||||||
from .library_commands import LibraryCommandError
|
from .library_commands import LibraryCommandError
|
||||||
from .models import utc_now_iso
|
from .models import utc_now_iso
|
||||||
from .review import (
|
from .review import (
|
||||||
@@ -30,9 +35,28 @@ from .review import (
|
|||||||
same_name_rows,
|
same_name_rows,
|
||||||
)
|
)
|
||||||
from .review_changes import notify_review_change
|
from .review_changes import notify_review_change
|
||||||
from .review_schema import DECISIONS_TABLE, ITEMS_TABLE, canonical_evidence
|
from .review_schema import (
|
||||||
|
DECISIONS_TABLE,
|
||||||
|
ITEMS_TABLE,
|
||||||
|
canonical_evidence,
|
||||||
|
evidence_digest,
|
||||||
|
)
|
||||||
|
|
||||||
DECISION_KIND = "user-selection"
|
DECISION_KIND = "user-selection"
|
||||||
|
MANUAL_KIND = "manual-link"
|
||||||
|
_MANUAL_POSITIVE = (
|
||||||
|
f"SELECT p.id,p.text FROM {_FROM} " # nosec B608
|
||||||
|
f"WHERE p.id=? AND {_ELIGIBLE} AND {SHELVED}"
|
||||||
|
)
|
||||||
|
_MANUAL_PAIR = (
|
||||||
|
"SELECT COALESCE(n.text,'') AS negative FROM prompt_pairs pp "
|
||||||
|
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
||||||
|
"WHERE pp.id=? AND pp.positive_prompt_id=? AND pp.archived=0"
|
||||||
|
)
|
||||||
|
_ARCHIVED_BARE_PAIR = (
|
||||||
|
"SELECT 1 FROM prompt_pairs WHERE positive_prompt_id=? "
|
||||||
|
"AND negative_prompt_id IS NULL AND archived=1"
|
||||||
|
)
|
||||||
_INSERT_DECISION = (
|
_INSERT_DECISION = (
|
||||||
f"INSERT INTO {DECISIONS_TABLE}(id,item_id,decision_revision," # nosec B608
|
f"INSERT INTO {DECISIONS_TABLE}(id,item_id,decision_revision," # nosec B608
|
||||||
"created_at,outcome,candidate_hash,evidence,pair_id,image_id) "
|
"created_at,outcome,candidate_hash,evidence,pair_id,image_id) "
|
||||||
@@ -59,6 +83,8 @@ class ReviewCommandError(LibraryCommandError):
|
|||||||
``fingerprint`` the current dependency fingerprint, when known;
|
``fingerprint`` the current dependency fingerprint, when known;
|
||||||
``changed`` marks an association conflict caused by links that changed
|
``changed`` marks an association conflict caused by links that changed
|
||||||
since the reviewer read them (rather than an incompatible choice).
|
since the reviewer read them (rather than an incompatible choice).
|
||||||
|
``prompt-missing``: the Library prompt or recipe of a manual link is gone,
|
||||||
|
archived or not that prompt's.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -93,6 +119,7 @@ class ReviewDecisionOperations:
|
|||||||
sha256: str,
|
sha256: str,
|
||||||
outcome: str,
|
outcome: str,
|
||||||
candidate: dict | None = None,
|
candidate: dict | None = None,
|
||||||
|
manual: dict | None = None,
|
||||||
image_path: str | None = None,
|
image_path: str | None = None,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
expected_fingerprint: str | None = None,
|
expected_fingerprint: str | None = None,
|
||||||
@@ -101,7 +128,9 @@ class ReviewDecisionOperations:
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Record the decision; return ``{item, total, pair}``.
|
"""Record the decision; return ``{item, total, pair}``.
|
||||||
|
|
||||||
``selected`` needs the evidence ``candidate`` (with its ``hash``), the
|
``selected`` needs the evidence ``candidate`` (with its ``hash``) or
|
||||||
|
``manual`` (``{positive_id, pair_id}``; a null ``pair_id`` is no
|
||||||
|
negative), the
|
||||||
``image_path`` the scan and the monitor link under, the captured
|
``image_path`` the scan and the monitor link under, the captured
|
||||||
``metadata`` and the ``expected_fingerprint`` the reviewer saw.
|
``metadata`` and the ``expected_fingerprint`` the reviewer saw.
|
||||||
``aliases`` are the other spellings legacy rows of the same physical
|
``aliases`` are the other spellings legacy rows of the same physical
|
||||||
@@ -115,9 +144,12 @@ class ReviewDecisionOperations:
|
|||||||
if outcome not in ("selected", "unresolved"):
|
if outcome not in ("selected", "unresolved"):
|
||||||
raise ValueError("outcome must be selected or unresolved")
|
raise ValueError("outcome must be selected or unresolved")
|
||||||
if outcome == "selected" and (
|
if outcome == "selected" and (
|
||||||
not isinstance(candidate, dict) or not image_path
|
(not isinstance(candidate, dict)) == (not isinstance(manual, dict))
|
||||||
|
or not image_path
|
||||||
):
|
):
|
||||||
raise ValueError("A selected decision needs a candidate and a file")
|
raise ValueError(
|
||||||
|
"A selected decision needs one candidate or manual choice and a file"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with self._pair_transaction() as conn:
|
with self._pair_transaction() as conn:
|
||||||
decided = self._decide_in(
|
decided = self._decide_in(
|
||||||
@@ -128,6 +160,7 @@ class ReviewDecisionOperations:
|
|||||||
sha256=sha256,
|
sha256=sha256,
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
candidate=candidate,
|
candidate=candidate,
|
||||||
|
manual=manual,
|
||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
expected_fingerprint=expected_fingerprint,
|
expected_fingerprint=expected_fingerprint,
|
||||||
@@ -158,13 +191,24 @@ class ReviewDecisionOperations:
|
|||||||
raise _Rollback(ReviewCommandError("file-changed", epoch))
|
raise _Rollback(ReviewCommandError("file-changed", epoch))
|
||||||
decision_id = _new_id()
|
decision_id = _new_id()
|
||||||
pair = None
|
pair = None
|
||||||
candidate = command["candidate"] if command["outcome"] == "selected" else None
|
selected = command["outcome"] == "selected"
|
||||||
|
candidate = command["candidate"] if selected else None
|
||||||
|
manual = command["manual"] if selected else None
|
||||||
|
candidate_hash = candidate["hash"] if candidate is not None else None
|
||||||
if candidate is not None:
|
if candidate is not None:
|
||||||
pair = self._apply_candidate(
|
pair = self._apply_candidate(
|
||||||
conn, epoch, current, decision_id, total, **command
|
conn, epoch, current, decision_id, total, **command
|
||||||
)
|
)
|
||||||
|
elif manual is not None:
|
||||||
|
pair = self._apply_manual(
|
||||||
|
conn, epoch, current, decision_id, total, **command
|
||||||
|
)
|
||||||
|
manual = {"positive_id": manual["positive_id"], "pair_id": pair["id"]}
|
||||||
|
candidate_hash = evidence_digest(
|
||||||
|
canonical_evidence({"kind": MANUAL_KIND, **manual})
|
||||||
|
)
|
||||||
evidence = {
|
evidence = {
|
||||||
"kind": DECISION_KIND,
|
"kind": MANUAL_KIND if manual is not None else DECISION_KIND,
|
||||||
"decision_id": decision_id,
|
"decision_id": decision_id,
|
||||||
"outcome": command["outcome"],
|
"outcome": command["outcome"],
|
||||||
"reviewed_digest": command["sha256"],
|
"reviewed_digest": command["sha256"],
|
||||||
@@ -174,6 +218,8 @@ class ReviewDecisionOperations:
|
|||||||
"dependency_fingerprint": command["expected_fingerprint"],
|
"dependency_fingerprint": command["expected_fingerprint"],
|
||||||
"pair": pair,
|
"pair": pair,
|
||||||
}
|
}
|
||||||
|
if manual is not None:
|
||||||
|
evidence["manual"] = manual
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
self._insert_review_decision(
|
self._insert_review_decision(
|
||||||
conn,
|
conn,
|
||||||
@@ -183,7 +229,7 @@ class ReviewDecisionOperations:
|
|||||||
current["revision"] + 1,
|
current["revision"] + 1,
|
||||||
now,
|
now,
|
||||||
command["outcome"],
|
command["outcome"],
|
||||||
candidate["hash"] if candidate is not None else None,
|
candidate_hash,
|
||||||
canonical_evidence(evidence),
|
canonical_evidence(evidence),
|
||||||
pair["id"] if pair else None,
|
pair["id"] if pair else None,
|
||||||
pair["image_id"] if pair else None,
|
pair["image_id"] if pair else None,
|
||||||
@@ -204,6 +250,77 @@ class ReviewDecisionOperations:
|
|||||||
self, conn, epoch, current, decision_id, total, **command
|
self, conn, epoch, current, decision_id, total, **command
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Link the candidate's pair, compatible-only; raise on any conflict."""
|
"""Link the candidate's pair, compatible-only; raise on any conflict."""
|
||||||
|
candidate = command["candidate"]
|
||||||
|
positive, negative = candidate["positive"], candidate["negative"]
|
||||||
|
provenance = {
|
||||||
|
"kind": DECISION_KIND,
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"reviewed_digest": command["sha256"],
|
||||||
|
"candidate_hash": candidate["hash"],
|
||||||
|
"resolver_version": current["resolver_version"],
|
||||||
|
"positive": positive,
|
||||||
|
"negative": negative,
|
||||||
|
}
|
||||||
|
return self._link_compatible(
|
||||||
|
conn, epoch, current, total, positive, negative, provenance, command
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_manual(self, conn, epoch, current, decision_id, total, **command):
|
||||||
|
"""Link the chosen Library prompt, compatible-only; raise otherwise."""
|
||||||
|
missing = _Rollback(
|
||||||
|
ReviewCommandError("prompt-missing", epoch, item=current, total=total)
|
||||||
|
)
|
||||||
|
positive_id = command["manual"]["positive_id"]
|
||||||
|
pair_id = command["manual"]["pair_id"]
|
||||||
|
row = conn.execute(_MANUAL_POSITIVE, (positive_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise missing
|
||||||
|
positive_text, negative_text = row[1], ""
|
||||||
|
if pair_id is not None:
|
||||||
|
chosen = conn.execute(_MANUAL_PAIR, (pair_id, positive_id)).fetchone()
|
||||||
|
if chosen is None:
|
||||||
|
raise missing
|
||||||
|
negative_text = chosen[0]
|
||||||
|
elif conn.execute(_ARCHIVED_BARE_PAIR, (positive_id,)).fetchone():
|
||||||
|
raise missing
|
||||||
|
provenance = {
|
||||||
|
"kind": MANUAL_KIND,
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"reviewed_digest": command["sha256"],
|
||||||
|
"resolver_version": current["resolver_version"],
|
||||||
|
"positive_id": positive_id,
|
||||||
|
"pair_id": pair_id,
|
||||||
|
}
|
||||||
|
# The sides name no provenance: only the ingester reads them.
|
||||||
|
pair = self._link_compatible(
|
||||||
|
conn,
|
||||||
|
epoch,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
{"text": positive_text},
|
||||||
|
{"text": negative_text},
|
||||||
|
provenance,
|
||||||
|
command,
|
||||||
|
)
|
||||||
|
# A prompt whose stored hash is stale reaches another row: never link that.
|
||||||
|
reached = conn.execute(
|
||||||
|
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair["id"],)
|
||||||
|
).fetchone()[0]
|
||||||
|
if reached != positive_id or pair_id not in (None, pair["id"]):
|
||||||
|
raise missing
|
||||||
|
return pair
|
||||||
|
|
||||||
|
def _link_compatible(
|
||||||
|
self,
|
||||||
|
conn,
|
||||||
|
epoch,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
positive,
|
||||||
|
negative,
|
||||||
|
provenance,
|
||||||
|
command,
|
||||||
|
) -> dict:
|
||||||
# Imported here: pair_ingestion imports operations, which composes this.
|
# Imported here: pair_ingestion imports operations, which composes this.
|
||||||
from .pair_ingestion import _ingest_resolved_pair_in
|
from .pair_ingestion import _ingest_resolved_pair_in
|
||||||
|
|
||||||
@@ -242,17 +359,6 @@ class ReviewDecisionOperations:
|
|||||||
# Enrich the one row of this file under the spelling it was
|
# Enrich the one row of this file under the spelling it was
|
||||||
# linked with, so the ingester's compatibility rules see it.
|
# linked with, so the ingester's compatibility rules see it.
|
||||||
image_path = rows[0][1]
|
image_path = rows[0][1]
|
||||||
candidate = command["candidate"]
|
|
||||||
positive, negative = candidate["positive"], candidate["negative"]
|
|
||||||
provenance = {
|
|
||||||
"kind": DECISION_KIND,
|
|
||||||
"decision_id": decision_id,
|
|
||||||
"reviewed_digest": command["sha256"],
|
|
||||||
"candidate_hash": candidate["hash"],
|
|
||||||
"resolver_version": current["resolver_version"],
|
|
||||||
"positive": positive,
|
|
||||||
"negative": negative,
|
|
||||||
}
|
|
||||||
result = _ingest_resolved_pair_in(
|
result = _ingest_resolved_pair_in(
|
||||||
conn,
|
conn,
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def remove_quietly(*paths):
|
|||||||
if path and os.path.exists(path):
|
if path and os.path.exists(path):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# cleanup after a failure must not replace that failure
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
|
|
||||||
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
|
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
|
||||||
# "created" follow the id, so every order is total and keyset pages are exact.
|
# "created" follow the id, so every order is total and keyset pages are exact.
|
||||||
TAG_SORTS = {
|
TAG_SORTS = {
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ protocol and peer checks reject unrelated messages. Same-origin scripts are
|
|||||||
trusted; this transport is not authentication against other scripts on that origin.
|
trusted; this transport is not authentication against other scripts on that origin.
|
||||||
Unsupported messaging is reported without breaking existing ComfyUI nodes.
|
Unsupported messaging is reported without breaking existing ComfyUI nodes.
|
||||||
|
|
||||||
|
Apply works in one click: without a preview, the sender requests the ticket itself
|
||||||
|
and applies it. Preview remains optional, and shows the exact change first.
|
||||||
|
|
||||||
An apply timeout or abort after posting means the outcome is unknown. It cannot
|
An apply timeout or abort after posting means the outcome is unknown. It cannot
|
||||||
prove the host cancelled the write. The sender must inspect a fresh destination
|
prove the host cancelled the write. The sender must inspect a fresh destination
|
||||||
preview before deciding to send again; automatic retry must not overwrite edits.
|
preview or choose the destination again before sending again; automatic retry must
|
||||||
|
not overwrite edits. An error the host returned is a refusal: nothing was written.
|
||||||
|
|
||||||
## PromptManager adapter
|
## PromptManager adapter
|
||||||
|
|
||||||
@@ -31,7 +35,9 @@ field. Unknown/ambiguous metadata must not become an empty string.
|
|||||||
|
|
||||||
Previews show original and requested text plus positive prepend/append composition.
|
Previews show original and requested text plus positive prepend/append composition.
|
||||||
Connected composition is marked unknown rather than guessing upstream values.
|
Connected composition is marked unknown rather than guessing upstream values.
|
||||||
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
|
Connected destination fields reject writes. The sender compares the destination's revision
|
||||||
|
(node object and graph version) from discovery with the one at the preview, and refuses if the workflow changed.
|
||||||
|
Applying rechecks graph, node, widgets,
|
||||||
values and connections. Ordinary serialization cache synchronization is accepted
|
values and connections. Ordinary serialization cache synchronization is accepted
|
||||||
only when it preserves live values. Both requested roles are checked before writing.
|
only when it preserves live values. Both requested roles are checked before writing.
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ class PromptManagerPair(PromptManagerFocused):
|
|||||||
negative_text,
|
negative_text,
|
||||||
category=category.strip() or None,
|
category=category.strip() or None,
|
||||||
tags=self._parse_tags(tags),
|
tags=self._parse_tags(tags),
|
||||||
|
# The Library shows the prompt's own category and tags, not the pair's.
|
||||||
|
fill_new_positive=True,
|
||||||
)
|
)
|
||||||
pair = self.db.get_prompt_pair(pair_id)
|
pair = self.db.get_prompt_pair(pair_id)
|
||||||
prompt_id = pair["positive_prompt_id"]
|
prompt_id = pair["positive_prompt_id"]
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ from .lora_integration import LoraIntegrationMixin
|
|||||||
from .lora_import_routes import LoraImportRoutesMixin
|
from .lora_import_routes import LoraImportRoutesMixin
|
||||||
from .image_categories import ImageCategoryRoutesMixin
|
from .image_categories import ImageCategoryRoutesMixin
|
||||||
from .image_delete import ImageDeleteMixin
|
from .image_delete import ImageDeleteMixin
|
||||||
|
from .image_return import ImageReturnMixin
|
||||||
from .source_rescope import SourceRescopeMixin
|
from .source_rescope import SourceRescopeMixin
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -349,6 +350,7 @@ class PromptManagerAPI(
|
|||||||
LoraImportRoutesMixin,
|
LoraImportRoutesMixin,
|
||||||
ImageCategoryRoutesMixin,
|
ImageCategoryRoutesMixin,
|
||||||
ImageDeleteMixin,
|
ImageDeleteMixin,
|
||||||
|
ImageReturnMixin,
|
||||||
SourceRescopeMixin,
|
SourceRescopeMixin,
|
||||||
):
|
):
|
||||||
"""REST API handler for PromptManager operations and web interface.
|
"""REST API handler for PromptManager operations and web interface.
|
||||||
@@ -728,6 +730,7 @@ class PromptManagerAPI(
|
|||||||
self._register_lora_import_routes(routes)
|
self._register_lora_import_routes(routes)
|
||||||
self._register_image_category_routes(routes)
|
self._register_image_category_routes(routes)
|
||||||
self._register_image_delete_routes(routes)
|
self._register_image_delete_routes(routes)
|
||||||
|
self._register_image_return_routes(routes)
|
||||||
self._register_rescope_routes(routes)
|
self._register_rescope_routes(routes)
|
||||||
|
|
||||||
# Register gzip compression middleware (once)
|
# Register gzip compression middleware (once)
|
||||||
|
|||||||
@@ -36,10 +36,18 @@ except ImportError:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from ...utils import exit_signal
|
from ...utils import exit_signal
|
||||||
from ...utils.media_folders import is_skipped_folder
|
from ...utils.media_folders import (
|
||||||
|
in_trash_below_roots,
|
||||||
|
in_trash_folder,
|
||||||
|
is_skipped_folder,
|
||||||
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from utils import exit_signal
|
from utils import exit_signal
|
||||||
from utils.media_folders import is_skipped_folder
|
from utils.media_folders import (
|
||||||
|
in_trash_below_roots,
|
||||||
|
in_trash_folder,
|
||||||
|
is_skipped_folder,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ...database.image_removal import mark_hides
|
from ...database.image_removal import mark_hides
|
||||||
@@ -168,7 +176,7 @@ def _physical_file_key(path, stat):
|
|||||||
volumes on Windows) falls back to the resolved, case-folded path.
|
volumes on Windows) falls back to the resolved, case-folded path.
|
||||||
"""
|
"""
|
||||||
if stat.st_ino:
|
if stat.st_ino:
|
||||||
return ("inode", stat.st_dev, stat.st_ino)
|
return ("inode", (stat.st_dev, stat.st_ino))
|
||||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||||
|
|
||||||
|
|
||||||
@@ -241,7 +249,12 @@ SCAN_FINAL_STATES = {
|
|||||||
"error": "failed",
|
"error": "failed",
|
||||||
}
|
}
|
||||||
# The scope as the scan log's first line names it (the review dialog's words).
|
# The scope as the scan log's first line names it (the review dialog's words).
|
||||||
SCAN_SCOPE_LOG = {"new": "new and unresolved files", "all": "all files"}
|
SCAN_SCOPE_LOG = {
|
||||||
|
"new": "new and unresolved files",
|
||||||
|
"all": "all files",
|
||||||
|
"returned": "images returned to the library",
|
||||||
|
}
|
||||||
|
RETURN_JOB_TITLE = "Return images to the library" # #498
|
||||||
MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches
|
MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches
|
||||||
# Log line prefix per outcome group (spec: + added, = known, ? review, ! error,
|
# Log line prefix per outcome group (spec: + added, = known, ? review, ! error,
|
||||||
# - no usable metadata).
|
# - no usable metadata).
|
||||||
@@ -526,6 +539,7 @@ def _diagnose_output_dirs(configured_dirs, public_path=None):
|
|||||||
|
|
||||||
candidates.append(folder_paths.get_output_directory())
|
candidates.append(folder_paths.get_output_directory())
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
# outside ComfyUI there is no output folder to add
|
||||||
pass
|
pass
|
||||||
|
|
||||||
output_dirs = []
|
output_dirs = []
|
||||||
@@ -1084,13 +1098,21 @@ class AdminRoutesMixin:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _delete_one_duplicate(self, image_path, output_path):
|
def _delete_one_duplicate(self, image_path, output_path):
|
||||||
"""Delete one file and its thumbnail; return a failure reason or None."""
|
"""Delete one file and its thumbnail; return a failure reason or None.
|
||||||
|
|
||||||
|
What is removed is exactly the folder's real path plus the name: a
|
||||||
|
link is removed itself, never its target, and the preview removed is
|
||||||
|
that name's. Both the link's place and what it leads to are checked.
|
||||||
|
"""
|
||||||
file_path = Path(image_path)
|
file_path = Path(image_path)
|
||||||
if not file_path.is_absolute():
|
if not file_path.is_absolute():
|
||||||
file_path = output_path / file_path
|
file_path = output_path / file_path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rel_path = file_path.resolve().relative_to(output_path.resolve())
|
real_root = output_path.resolve()
|
||||||
|
rel_path = file_path.resolve().relative_to(real_root)
|
||||||
|
exact = Path(os.path.realpath(file_path.parent)) / file_path.name
|
||||||
|
exact_rel = exact.relative_to(real_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"Attempted to delete file outside output directory: {image_path}"
|
f"Attempted to delete file outside output directory: {image_path}"
|
||||||
@@ -1099,15 +1121,19 @@ class AdminRoutesMixin:
|
|||||||
|
|
||||||
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||||
return "not a media file"
|
return "not a media file"
|
||||||
if "thumbnails" in rel_path.parts:
|
if "thumbnails" in rel_path.parts or "thumbnails" in exact_rel.parts:
|
||||||
return "thumbnails are managed by the gallery"
|
return "thumbnails are managed by the gallery"
|
||||||
|
if in_trash_below_roots(file_path, [output_path]) or in_trash_folder(
|
||||||
|
str(exact_rel)
|
||||||
|
):
|
||||||
|
return "it is in a trash folder" # never unlinked from a trash (#543)
|
||||||
|
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
return "file not found"
|
return "file not found"
|
||||||
|
|
||||||
os.remove(file_path)
|
os.remove(exact)
|
||||||
self.logger.info(f"Deleted duplicate image: {image_path}")
|
self.logger.info(f"Deleted duplicate image: {image_path}")
|
||||||
_remove_thumbnail(output_path.resolve(), rel_path, self.logger)
|
_remove_thumbnail(real_root, exact_rel, self.logger)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_statistics(self, request):
|
async def get_statistics(self, request):
|
||||||
@@ -1381,13 +1407,17 @@ class AdminRoutesMixin:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None):
|
def _start_scan_job(
|
||||||
|
self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None, returned=None
|
||||||
|
):
|
||||||
"""Register the scan job synchronously; its runner starts on the loop.
|
"""Register the scan job synchronously; its runner starts on the loop.
|
||||||
|
|
||||||
``files`` is the scope (``SCAN_SCOPES``). ``roots`` selects sources by
|
``files`` is the scope (``SCAN_SCOPES``). ``roots`` selects sources by
|
||||||
their index in ``output_dirs`` (the effective sources the caller
|
their index in ``output_dirs`` (the effective sources the caller
|
||||||
resolved); None scans every source, resolved when the job starts.
|
resolved); None scans every source, resolved when the job starts.
|
||||||
Raises JobBusy while another record-changing job runs.
|
Raises JobBusy while another record-changing job runs. ``returned``
|
||||||
|
(#498, ``[(path, locator)]``) makes it "Return images to the library":
|
||||||
|
those files' removal marks go first, then only they are scanned.
|
||||||
"""
|
"""
|
||||||
if files not in SCAN_SCOPES:
|
if files not in SCAN_SCOPES:
|
||||||
raise ValueError(f"Unknown scan scope: {files!r}")
|
raise ValueError(f"Unknown scan scope: {files!r}")
|
||||||
@@ -1395,20 +1425,26 @@ class AdminRoutesMixin:
|
|||||||
output_dirs is None or not roots or max(roots) >= len(output_dirs)
|
output_dirs is None or not roots or max(roots) >= len(output_dirs)
|
||||||
):
|
):
|
||||||
raise ValueError("A scoped scan needs the sources its roots index")
|
raise ValueError("A scoped scan needs the sources its roots index")
|
||||||
|
scope = {"files": files, "roots": None if roots is None else list(roots)}
|
||||||
|
if returned is not None:
|
||||||
|
scope = {"files": "returned", "roots": None, "images": len(returned)}
|
||||||
job = self._jobs.start(
|
job = self._jobs.start(
|
||||||
SCAN_JOB_KIND,
|
SCAN_JOB_KIND,
|
||||||
SCAN_JOB_TITLE,
|
SCAN_JOB_TITLE if returned is None else RETURN_JOB_TITLE,
|
||||||
record_changing=True,
|
record_changing=True,
|
||||||
scope={"files": files, "roots": None if roots is None else list(roots)},
|
scope=scope,
|
||||||
runner=functools.partial(self._run_scan_job, output_dirs=output_dirs),
|
runner=functools.partial(
|
||||||
|
self._run_scan_job, output_dirs=output_dirs, returned=returned
|
||||||
|
),
|
||||||
groups=SCAN_GROUP_NAMES,
|
groups=SCAN_GROUP_NAMES,
|
||||||
)
|
)
|
||||||
self._scan_job = job
|
self._scan_job = job
|
||||||
return job
|
return job
|
||||||
|
|
||||||
async def _run_scan_job(self, job, output_dirs=None):
|
async def _run_scan_job(self, job, output_dirs=None, returned=None):
|
||||||
final = None
|
final = None
|
||||||
async for payload in self._scan_images_events(job, output_dirs):
|
extra = {} if returned is None else {"returned": returned}
|
||||||
|
async for payload in self._scan_images_events(job, output_dirs, **extra):
|
||||||
job.publish(payload)
|
job.publish(payload)
|
||||||
final = payload.get("type")
|
final = payload.get("type")
|
||||||
state = SCAN_FINAL_STATES.get(final, "finished")
|
state = SCAN_FINAL_STATES.get(final, "finished")
|
||||||
@@ -1446,7 +1482,7 @@ class AdminRoutesMixin:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def _scan_images_events(self, job, output_dirs=None):
|
async def _scan_images_events(self, job, output_dirs=None, returned=None):
|
||||||
"""Yield progress payloads while scanning the output directories.
|
"""Yield progress payloads while scanning the output directories.
|
||||||
|
|
||||||
Every effective source is walked, or only those ``job.scope["roots"]``
|
Every effective source is walked, or only those ``job.scope["roots"]``
|
||||||
@@ -1484,9 +1520,16 @@ class AdminRoutesMixin:
|
|||||||
0, f"Scanning {len(walked)} directory(ies) for media files..."
|
0, f"Scanning {len(walked)} directory(ies) for media files..."
|
||||||
)
|
)
|
||||||
|
|
||||||
media_files = await self._run_in_executor(
|
if returned is None:
|
||||||
self._collect_output_media_sync, walked
|
media_files = await self._run_in_executor(
|
||||||
)
|
self._collect_output_media_sync, walked
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# #498: only the files whose marks this job deleted.
|
||||||
|
media_files = await self._run_in_executor(
|
||||||
|
self._clear_returned_marks, returned
|
||||||
|
)
|
||||||
|
job.log_line(f"Returned {_plural(len(media_files), 'image')}")
|
||||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||||
job.update(
|
job.update(
|
||||||
total=len(media_files),
|
total=len(media_files),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import math
|
|||||||
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
|
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
|
||||||
LLM_MODEL_TYPES as LLM_MODELS,
|
LLM_MODEL_TYPES as LLM_MODELS,
|
||||||
MAX_INSTRUCTION_BYTES,
|
MAX_INSTRUCTION_BYTES,
|
||||||
MAX_INSTRUCTION_CHARS,
|
|
||||||
MODEL_TYPES,
|
MODEL_TYPES,
|
||||||
WD14_MODEL_TYPES as WD14_MODELS,
|
WD14_MODEL_TYPES as WD14_MODELS,
|
||||||
validate_template,
|
validate_template,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ from . import thumbnail_settings
|
|||||||
from .. import model_gate
|
from .. import model_gate
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ...utils.media_folders import is_skipped_folder
|
from ...utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from utils.media_folders import is_skipped_folder
|
from utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -424,6 +424,7 @@ class AutotagRoutesMixin:
|
|||||||
title = f"Tagging model download · {label}"
|
title = f"Tagging model download · {label}"
|
||||||
_download_titles.clear()
|
_download_titles.clear()
|
||||||
_download_titles[owner] = title
|
_download_titles[owner] = title
|
||||||
|
admitted = False
|
||||||
try:
|
try:
|
||||||
job = self._jobs.start(
|
job = self._jobs.start(
|
||||||
MODEL_DOWNLOAD_JOB_KIND,
|
MODEL_DOWNLOAD_JOB_KIND,
|
||||||
@@ -439,13 +440,13 @@ class AutotagRoutesMixin:
|
|||||||
self._run_model_download, service, model_type, owner
|
self._run_model_download, service, model_type, owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except BaseException as refused:
|
admitted = True
|
||||||
# JobBusy, or anything else the registry raised: give the gate back.
|
except JobBusy as refused:
|
||||||
model_gate.release(owner)
|
return None, busy_response(refused)
|
||||||
_download_titles.pop(owner, None)
|
finally:
|
||||||
if isinstance(refused, JobBusy):
|
if not admitted: # JobBusy, or anything else: give the gate back.
|
||||||
return None, busy_response(refused)
|
model_gate.release(owner)
|
||||||
raise
|
_download_titles.pop(owner, None)
|
||||||
|
|
||||||
def ended(_task):
|
def ended(_task):
|
||||||
model_gate.release(owner)
|
model_gate.release(owner)
|
||||||
@@ -849,9 +850,12 @@ class AutotagRoutesMixin:
|
|||||||
"""Return the real path when it is a media file inside an output directory."""
|
"""Return the real path when it is a media file inside an output directory."""
|
||||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||||
return None
|
return None
|
||||||
for output_dir in self._get_all_output_dirs():
|
roots = list(self._get_all_output_dirs())
|
||||||
|
for output_dir in roots:
|
||||||
if path_is_within(raw_path, output_dir):
|
if path_is_within(raw_path, output_dir):
|
||||||
real = os.path.realpath(raw_path)
|
real = os.path.realpath(raw_path)
|
||||||
|
if in_trash_below_roots(real, roots):
|
||||||
|
return None # thrown away, not an output (#543)
|
||||||
return real if os.path.isfile(real) else None
|
return real if os.path.isfile(real) else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1038,12 +1042,12 @@ class AutotagRoutesMixin:
|
|||||||
return self._bad_request('existing must be "skip" or "merge"')
|
return self._bad_request('existing must be "skip" or "merge"')
|
||||||
try:
|
try:
|
||||||
options = self._single_options(data, model_type)
|
options = self._single_options(data, model_type)
|
||||||
|
except OptionError as error:
|
||||||
|
return self._bad_request(str(error))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return self._bad_request(
|
return self._bad_request(
|
||||||
"general_threshold and character_threshold must be numeric"
|
"general_threshold and character_threshold must be numeric"
|
||||||
)
|
)
|
||||||
except OptionError as error:
|
|
||||||
return self._bad_request(str(error))
|
|
||||||
target, error = await self._select_autotag_target(data)
|
target, error = await self._select_autotag_target(data)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
return error
|
return error
|
||||||
|
|||||||
@@ -971,6 +971,7 @@ class BackupRestoreRoutesMixin:
|
|||||||
line = restored_line(staged.summary)
|
line = restored_line(staged.summary)
|
||||||
job.log_line(line)
|
job.log_line(line)
|
||||||
job.update(progress=100, status_line=line)
|
job.update(progress=100, status_line=line)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _outlive_cancel(future):
|
async def _outlive_cancel(future):
|
||||||
|
|||||||
@@ -574,12 +574,13 @@ class DuplicateFilesMixin:
|
|||||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||||
if not ok:
|
if not ok:
|
||||||
return
|
return None
|
||||||
try:
|
try:
|
||||||
await self._remove_duplicates(job, db, plan, backup)
|
await self._remove_duplicates(job, db, plan, backup)
|
||||||
finally:
|
finally:
|
||||||
if backup:
|
if backup:
|
||||||
await self._prune_backup_pool(job, db)
|
await self._prune_backup_pool(job, db)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _remove_duplicates(self, job, db, plan, backup):
|
async def _remove_duplicates(self, job, db, plan, backup):
|
||||||
targets = plan.payload["targets"]
|
targets = plan.payload["targets"]
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from pathlib import Path
|
|||||||
from .admin import DELETABLE_MEDIA_EXTENSIONS
|
from .admin import DELETABLE_MEDIA_EXTENSIONS
|
||||||
from .maintenance import DUPLICATE_FILES
|
from .maintenance import DUPLICATE_FILES
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ...utils.media_folders import in_trash_below_roots, is_trash_folder
|
||||||
|
except ImportError:
|
||||||
|
from utils.media_folders import in_trash_below_roots, is_trash_folder
|
||||||
|
|
||||||
# Keep entries one review takes; with paths of a few hundred characters the
|
# Keep entries one review takes; with paths of a few hundred characters the
|
||||||
# list stays inside the preview body cap (DUPLICATE_PREVIEW_BODY_MAX, 1 MiB).
|
# list stays inside the preview body cap (DUPLICATE_PREVIEW_BODY_MAX, 1 MiB).
|
||||||
MAX_KEEP = 2000
|
MAX_KEEP = 2000
|
||||||
@@ -197,6 +202,8 @@ def file_problem(
|
|||||||
parts = Path(os.path.relpath(lexical, base)).parts
|
parts = Path(os.path.relpath(lexical, base)).parts
|
||||||
if "thumbnails" in parts or ".." in parts:
|
if "thumbnails" in parts or ".." in parts:
|
||||||
return OUTSIDE_SOURCE
|
return OUTSIDE_SOURCE
|
||||||
|
if any(is_trash_folder(part) for part in parts[:-1]):
|
||||||
|
return OUTSIDE_SOURCE # never removed from a trash folder (#543)
|
||||||
if os.path.splitext(lexical)[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
if os.path.splitext(lexical)[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||||
return OUTSIDE_SOURCE
|
return OUTSIDE_SOURCE
|
||||||
try:
|
try:
|
||||||
@@ -212,6 +219,8 @@ def file_problem(
|
|||||||
try:
|
try:
|
||||||
if not _lexically_inside(_canonical_path(path), [_canonical_path(root)]):
|
if not _lexically_inside(_canonical_path(path), [_canonical_path(root)]):
|
||||||
return OUTSIDE_SOURCE # a linked folder on the way
|
return OUTSIDE_SOURCE # a linked folder on the way
|
||||||
|
if in_trash_below_roots(path, [root]):
|
||||||
|
return OUTSIDE_SOURCE # a linked folder into a trash folder
|
||||||
if keeper is not None:
|
if keeper is not None:
|
||||||
same = keeper_problem(path, root, info, keeper, keeper_root)
|
same = keeper_problem(path, root, info, keeper, keeper_root)
|
||||||
if same:
|
if same:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def _identity(path):
|
|||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
if info.st_ino:
|
if info.st_ino:
|
||||||
return ("inode", info.st_dev, info.st_ino)
|
return ("inode", (info.st_dev, info.st_ino))
|
||||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from aiohttp import web
|
|||||||
|
|
||||||
from . import thumbnail_settings
|
from . import thumbnail_settings
|
||||||
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
||||||
|
from .images import in_trash_below_roots
|
||||||
from .images import existing_media_thumbnail, media_thumbnail_rel
|
from .images import existing_media_thumbnail, media_thumbnail_rel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -198,6 +199,9 @@ def resolve_current_candidate(
|
|||||||
if namespace is None or not namespace.is_dir():
|
if namespace is None or not namespace.is_dir():
|
||||||
return _unavailable("root_unavailable")
|
return _unavailable("root_unavailable")
|
||||||
target = _canonical(lexical)
|
target = _canonical(lexical)
|
||||||
|
if in_trash_below_roots(target, [namespace]):
|
||||||
|
# Every kind and the outputs listing come through here (#543).
|
||||||
|
return _unavailable("missing")
|
||||||
if not _authorized(target, namespace, scope):
|
if not _authorized(target, namespace, scope):
|
||||||
return _unavailable("not_authorized")
|
return _unavailable("not_authorized")
|
||||||
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||||
@@ -248,6 +252,10 @@ def _open_current_target(
|
|||||||
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
|
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
|
||||||
if _canonical(target) != target:
|
if _canonical(target) != target:
|
||||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||||
|
if in_trash_below_roots(target, [namespace]):
|
||||||
|
# Also at the open: the cover chooses its preview after the candidate
|
||||||
|
# check, and a preview can be a link into a trash folder (#543).
|
||||||
|
raise CurrentLocationUnavailable("missing")
|
||||||
if not _authorized(target, namespace, scope):
|
if not _authorized(target, namespace, scope):
|
||||||
raise CurrentLocationUnavailable("not_authorized")
|
raise CurrentLocationUnavailable("not_authorized")
|
||||||
suffix = target.suffix.lower()
|
suffix = target.suffix.lower()
|
||||||
|
|||||||
@@ -232,15 +232,17 @@ class ImageCategoryRoutesMixin:
|
|||||||
return None, _coded(400, "too-many-images", TOO_MANY)
|
return None, _coded(400, "too-many-images", TOO_MANY)
|
||||||
return tuple(selection.targets), None
|
return tuple(selection.targets), None
|
||||||
|
|
||||||
async def _category_request(self, request, fields: frozenset):
|
async def _category_request(self, request, fields: frozenset, data=None):
|
||||||
"""``(data, targets, roots, None)`` or ``(None, None, None, error)``.
|
"""``(data, targets, roots, None)`` or ``(None, None, None, error)``.
|
||||||
|
|
||||||
The images are ``items`` (1 to 500 targets) or ``filter`` (all images
|
The images are ``items`` (1 to 500 targets) or ``filter`` (all images
|
||||||
an Images listing filter admits), besides *fields*.
|
an Images listing filter admits), besides *fields*. *data* is the body
|
||||||
|
when the caller has already read it (a body is read once).
|
||||||
"""
|
"""
|
||||||
data, error = await _json_body_or_error(request)
|
if data is None:
|
||||||
if error is not None:
|
data, error = await _json_body_or_error(request)
|
||||||
return None, None, None, error
|
if error is not None:
|
||||||
|
return None, None, None, error
|
||||||
names = set(data) - fields
|
names = set(data) - fields
|
||||||
if set(data) & fields != fields or names not in ({"items"}, {"filter"}):
|
if set(data) & fields != fields or names not in ({"items"}, {"filter"}):
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -330,12 +330,13 @@ class ImageDeleteMixin:
|
|||||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||||
if not ok:
|
if not ok:
|
||||||
return
|
return None
|
||||||
try:
|
try:
|
||||||
await self._delete_images(job, db, targets, mode, backup)
|
await self._delete_images(job, db, targets, mode, backup)
|
||||||
finally:
|
finally:
|
||||||
if backup:
|
if backup:
|
||||||
await self._prune_backup_pool(job, db)
|
await self._prune_backup_pool(job, db)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _delete_images(self, job, db, targets, mode, backup):
|
async def _delete_images(self, job, db, targets, mode, backup):
|
||||||
job.update(total=len(targets), status_line=TITLES[mode])
|
job.update(total=len(targets), status_line=TITLES[mode])
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Return removed images to the library (#498).
|
||||||
|
|
||||||
|
``POST /prompt_manager/images/removed/return {items | filter}`` names files a
|
||||||
|
removal mark hides (#480, "Remove from the library only"): ``items`` are 1 to
|
||||||
|
500 listing identities, ``filter`` the removed view's listing query
|
||||||
|
(``removed: "1"`` plus the view's other parameters, at most 500 images). Only
|
||||||
|
this route takes ``removed``: every other bulk route refuses it, because an
|
||||||
|
explicit write there would show a file again without re-reading it.
|
||||||
|
|
||||||
|
The route answers 202 ``{job, returned, not_removed}`` after starting a
|
||||||
|
``scan`` job titled "Return images to the library" (admission ``records``:
|
||||||
|
409 ``busy`` beside a scan, restore or delete, nothing changed). The job
|
||||||
|
first deletes the marks that still hide their file (same size and
|
||||||
|
``mtime_ns``), in one transaction, then scans exactly those files with the
|
||||||
|
scan's own pipeline: prompts, links, pairs and Needs review items come back
|
||||||
|
from the files' embedded metadata. Image tags, auto-tag marks, the own
|
||||||
|
category, collection memberships and the favorite were deleted by the remove
|
||||||
|
and do not come back. No file is moved, written or deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .image_categories import TOO_MANY, _coded
|
||||||
|
from .images import (
|
||||||
|
ListingSourceChanged,
|
||||||
|
_favorite_relative_key,
|
||||||
|
_json_body_or_error,
|
||||||
|
parse_listing_filter,
|
||||||
|
)
|
||||||
|
from .image_tag_jobs import MAX_JOB_TARGETS
|
||||||
|
from .jobs import JobBusy
|
||||||
|
from .job_routes import busy_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ...database.image_removal import mark_hides
|
||||||
|
except ImportError:
|
||||||
|
from database.image_removal import mark_hides
|
||||||
|
|
||||||
|
NONE_REMOVED = "None of the selected images are removed from the library."
|
||||||
|
SOURCE_CHANGED = "The image sources changed. Reload Images and try again."
|
||||||
|
|
||||||
|
|
||||||
|
def _bad(message):
|
||||||
|
return web.json_response({"success": False, "error": message}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageReturnMixin:
|
||||||
|
"""Return to library (#498): clear the marks, then scan those files."""
|
||||||
|
|
||||||
|
def _register_image_return_routes(self, routes):
|
||||||
|
# A fixed path beside the dynamic /images/{image_id} routes.
|
||||||
|
@routes.post("/prompt_manager/images/removed/return")
|
||||||
|
async def return_removed_images_route(request):
|
||||||
|
return await self.return_removed_images(request)
|
||||||
|
|
||||||
|
async def return_removed_images(self, request):
|
||||||
|
data, error = await _json_body_or_error(request)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
if set(data) == {"filter"}:
|
||||||
|
targets, roots, error = await self._removed_filter_targets(data["filter"])
|
||||||
|
else:
|
||||||
|
_data, targets, roots, error = await self._category_request(
|
||||||
|
request, frozenset(), data=data
|
||||||
|
)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
files = {}
|
||||||
|
for index in dict.fromkeys(t[0] for t in targets):
|
||||||
|
files[index] = await self._get_gallery_files(roots[index])
|
||||||
|
returned, not_removed = await self._run_in_executor(
|
||||||
|
self._hidden_targets, roots, targets, files
|
||||||
|
)
|
||||||
|
if not returned:
|
||||||
|
return _coded(400, "no-images", NONE_REMOVED)
|
||||||
|
try:
|
||||||
|
job = self._start_scan_job(
|
||||||
|
files="all", output_dirs=list(roots), returned=returned
|
||||||
|
)
|
||||||
|
except JobBusy as busy:
|
||||||
|
return busy_response(busy)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"job": job.summary(),
|
||||||
|
"returned": len(returned),
|
||||||
|
"not_removed": not_removed,
|
||||||
|
},
|
||||||
|
status=202,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _removed_filter_targets(self, raw):
|
||||||
|
"""``(targets, roots, None)`` for the removed view's filter, else an error."""
|
||||||
|
if not isinstance(raw, dict) or raw.get("removed") != "1":
|
||||||
|
return None, None, _bad('filter must hold removed: "1"')
|
||||||
|
rest = {key: value for key, value in raw.items() if key != "removed"}
|
||||||
|
try:
|
||||||
|
parse_listing_filter(rest)
|
||||||
|
selection = await self.resolve_listing_filter(
|
||||||
|
{**rest, "removed": "1"}, MAX_JOB_TARGETS
|
||||||
|
)
|
||||||
|
except ListingSourceChanged:
|
||||||
|
return None, None, _coded(409, "source-changed", SOURCE_CHANGED)
|
||||||
|
except ValueError as exc:
|
||||||
|
return None, None, _bad(str(exc))
|
||||||
|
if selection.truncated:
|
||||||
|
return None, None, _coded(400, "too-many-images", TOO_MANY)
|
||||||
|
if not selection.targets:
|
||||||
|
return None, None, _coded(400, "no-images", NONE_REMOVED)
|
||||||
|
return tuple(selection.targets), list(self._get_all_output_dirs()), None
|
||||||
|
|
||||||
|
def _hidden_targets(self, roots, targets, files):
|
||||||
|
"""``([(path, locator)], not_removed)``: the targets a mark hides now.
|
||||||
|
|
||||||
|
Each target is matched to its listed spelling (the walk's). One
|
||||||
|
physical file named twice (nested roots) is returned once; a target
|
||||||
|
that is not listed or not hidden counts in ``not_removed``.
|
||||||
|
"""
|
||||||
|
marks = self._removal_marks()
|
||||||
|
listings, seen, returned, not_removed = {}, set(), [], 0
|
||||||
|
for index, _scope, relative in targets:
|
||||||
|
root = roots[index]
|
||||||
|
if index not in listings:
|
||||||
|
listings[index] = self._favorite_listing(root, files[index])
|
||||||
|
spelled = listings[index].get(_favorite_relative_key(relative))
|
||||||
|
if spelled is None:
|
||||||
|
not_removed += 1
|
||||||
|
continue
|
||||||
|
path = os.path.join(str(root), *spelled.split("/"))
|
||||||
|
locator = os.path.normcase(os.path.realpath(path))
|
||||||
|
if locator in seen:
|
||||||
|
continue
|
||||||
|
if not mark_hides(marks, locator, path):
|
||||||
|
not_removed += 1
|
||||||
|
continue
|
||||||
|
seen.add(locator)
|
||||||
|
returned.append((Path(path), locator))
|
||||||
|
return returned, not_removed
|
||||||
|
|
||||||
|
def _clear_returned_marks(self, returned) -> list:
|
||||||
|
"""The paths whose marks this deleted (blocking; one transaction)."""
|
||||||
|
cleared = self.db.clear_hiding_marks(
|
||||||
|
[(locator, str(path)) for path, locator in returned]
|
||||||
|
)
|
||||||
|
return [path for path, locator in returned if locator in cleared]
|
||||||
@@ -531,6 +531,7 @@ class ImageTagJobsMixin:
|
|||||||
run = AutotagRun(
|
run = AutotagRun(
|
||||||
model_type, existing, options, keep_in_memory, backup_on, owner
|
model_type, existing, options, keep_in_memory, backup_on, owner
|
||||||
)
|
)
|
||||||
|
admitted = False
|
||||||
try:
|
try:
|
||||||
job = self._jobs.start(
|
job = self._jobs.start(
|
||||||
AUTOTAG_BATCH_JOB_KIND,
|
AUTOTAG_BATCH_JOB_KIND,
|
||||||
@@ -541,11 +542,12 @@ class ImageTagJobsMixin:
|
|||||||
groups=AUTOTAG_GROUP_NAMES,
|
groups=AUTOTAG_GROUP_NAMES,
|
||||||
runner=functools.partial(self._run_autotag_batch, run),
|
runner=functools.partial(self._run_autotag_batch, run),
|
||||||
)
|
)
|
||||||
except BaseException as refused:
|
admitted = True
|
||||||
model_gate.release(owner)
|
except JobBusy as refused:
|
||||||
if isinstance(refused, JobBusy):
|
return None, _busy_refusal(refused)
|
||||||
return None, _busy_refusal(refused)
|
finally:
|
||||||
raise
|
if not admitted:
|
||||||
|
model_gate.release(owner)
|
||||||
job.task.add_done_callback(lambda _task: model_gate.release(owner))
|
job.task.add_done_callback(lambda _task: model_gate.release(owner))
|
||||||
return job, None
|
return job, None
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,8 @@ from .thumbnail_encode import (
|
|||||||
PREVIEW_HELD,
|
PREVIEW_HELD,
|
||||||
EncoderClosed,
|
EncoderClosed,
|
||||||
PreviewWriteHeld,
|
PreviewWriteHeld,
|
||||||
replace_unless_held,
|
|
||||||
run_encoder,
|
run_encoder,
|
||||||
sweep_stray_temps,
|
sweep_stray_temps,
|
||||||
temp_sibling,
|
|
||||||
thumbnail_frame,
|
thumbnail_frame,
|
||||||
writer_settings,
|
writer_settings,
|
||||||
write_thumbnail,
|
write_thumbnail,
|
||||||
@@ -66,7 +64,12 @@ try:
|
|||||||
from ...database.models import normalize_image_path
|
from ...database.models import normalize_image_path
|
||||||
from ...database.run_attribution import recorded_output_lookup
|
from ...database.run_attribution import recorded_output_lookup
|
||||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||||
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
|
from ...utils.media_folders import (
|
||||||
|
in_skipped_folder,
|
||||||
|
in_trash_below_roots,
|
||||||
|
in_trash_folder,
|
||||||
|
is_skipped_folder,
|
||||||
|
)
|
||||||
from ...utils.parallel import map_parallel
|
from ...utils.parallel import map_parallel
|
||||||
from ...utils.json_strict import dumps_strict
|
from ...utils.json_strict import dumps_strict
|
||||||
from ...utils.inspection_response import (
|
from ...utils.inspection_response import (
|
||||||
@@ -80,7 +83,7 @@ try:
|
|||||||
OpenerUnavailable,
|
OpenerUnavailable,
|
||||||
RevealGate,
|
RevealGate,
|
||||||
RevealTargetChanged,
|
RevealTargetChanged,
|
||||||
is_local_request,
|
is_same_origin_request,
|
||||||
reveal_executor,
|
reveal_executor,
|
||||||
reveal_in_file_manager,
|
reveal_in_file_manager,
|
||||||
)
|
)
|
||||||
@@ -92,7 +95,12 @@ except ImportError:
|
|||||||
from database.models import normalize_image_path
|
from database.models import normalize_image_path
|
||||||
from database.run_attribution import recorded_output_lookup
|
from database.run_attribution import recorded_output_lookup
|
||||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||||
from utils.media_folders import in_skipped_folder, is_skipped_folder
|
from utils.media_folders import (
|
||||||
|
in_skipped_folder,
|
||||||
|
in_trash_below_roots,
|
||||||
|
in_trash_folder,
|
||||||
|
is_skipped_folder,
|
||||||
|
)
|
||||||
from utils.parallel import map_parallel
|
from utils.parallel import map_parallel
|
||||||
from utils.json_strict import dumps_strict
|
from utils.json_strict import dumps_strict
|
||||||
from utils.inspection_response import (
|
from utils.inspection_response import (
|
||||||
@@ -106,7 +114,7 @@ except ImportError:
|
|||||||
OpenerUnavailable,
|
OpenerUnavailable,
|
||||||
RevealGate,
|
RevealGate,
|
||||||
RevealTargetChanged,
|
RevealTargetChanged,
|
||||||
is_local_request,
|
is_same_origin_request,
|
||||||
reveal_executor,
|
reveal_executor,
|
||||||
reveal_in_file_manager,
|
reveal_in_file_manager,
|
||||||
)
|
)
|
||||||
@@ -277,10 +285,20 @@ def _forbidden(message="Access denied"):
|
|||||||
return web.json_response({"success": False, "error": message}, status=403)
|
return web.json_response({"success": False, "error": message}, status=403)
|
||||||
|
|
||||||
|
|
||||||
def _media_access_error(path, allowed_dirs):
|
def _file_not_found():
|
||||||
"""Return a 403 response when *path* must not be served, else None.
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Image file not found"},
|
||||||
|
status=404,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
Fails closed: an empty *allowed_dirs* denies everything.
|
|
||||||
|
def _media_access_error(path, allowed_dirs):
|
||||||
|
"""The response refusing *path*, or None when it may be read.
|
||||||
|
|
||||||
|
Fails closed: an empty *allowed_dirs* denies everything (403). A file in a
|
||||||
|
trash folder below its root is thrown away, not an output: a 404, as if it
|
||||||
|
were not there (#543), also through a link or another spelling.
|
||||||
"""
|
"""
|
||||||
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
|
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||||
return _forbidden("Only media files can be served")
|
return _forbidden("Only media files can be served")
|
||||||
@@ -288,6 +306,8 @@ def _media_access_error(path, allowed_dirs):
|
|||||||
return _forbidden("No allowed image directories configured")
|
return _forbidden("No allowed image directories configured")
|
||||||
if not any(_is_within(path, root) for root in allowed_dirs):
|
if not any(_is_within(path, root) for root in allowed_dirs):
|
||||||
return _forbidden()
|
return _forbidden()
|
||||||
|
if in_trash_below_roots(path, allowed_dirs):
|
||||||
|
return _file_not_found()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -317,11 +337,6 @@ def _thumbnail_batch_size(workers):
|
|||||||
return max(4, workers * 2)
|
return max(4, workers * 2)
|
||||||
|
|
||||||
|
|
||||||
# Preview files are written by thumbnail_encode in the configured format
|
|
||||||
# (thumbnail_settings, #317); these names stay importable from here.
|
|
||||||
_temp_sibling = temp_sibling
|
|
||||||
_replace_unless_held = replace_unless_held
|
|
||||||
|
|
||||||
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
|
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
|
||||||
# over a file another process has open (WinError 5 or 32), such as a preview
|
# over a file another process has open (WinError 5 or 32), such as a preview
|
||||||
# the gallery is serving; the old preview stays and the file counts as an error.
|
# the gallery is serving; the old preview stays and the file counts as an error.
|
||||||
@@ -374,6 +389,7 @@ def _video_placeholder_image(thumbnail_size):
|
|||||||
font=font,
|
font=font,
|
||||||
)
|
)
|
||||||
except (OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
|
# a placeholder without its label still works
|
||||||
pass
|
pass
|
||||||
return img
|
return img
|
||||||
|
|
||||||
@@ -477,6 +493,7 @@ def _preview_names(folders, folder):
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# an unreadable folder lists as empty; nothing there to cache
|
||||||
pass
|
pass
|
||||||
folders[folder] = names
|
folders[folder] = names
|
||||||
return names
|
return names
|
||||||
@@ -795,12 +812,12 @@ def _reveal_not_found():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _not_local():
|
def _not_same_origin():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"code": "not-local",
|
"code": "not-same-origin",
|
||||||
"error": "Show in folder works only on the computer running ComfyUI.",
|
"error": "This page's address does not match the server's.",
|
||||||
},
|
},
|
||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
@@ -979,6 +996,8 @@ def _settled_entry(root, relative):
|
|||||||
return None
|
return None
|
||||||
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
|
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
|
||||||
return None
|
return None
|
||||||
|
if in_trash_below_roots(path, [root]):
|
||||||
|
return None # a linked folder on the way leads into a trash folder
|
||||||
folder = os.fspath(root)
|
folder = os.fspath(root)
|
||||||
for part in ("", *folders):
|
for part in ("", *folders):
|
||||||
folder = os.path.join(folder, part) if part else folder
|
folder = os.path.join(folder, part) if part else folder
|
||||||
@@ -1160,7 +1179,10 @@ def _workspace_filters(query: Mapping) -> dict:
|
|||||||
the linked positive, and ``favorites=1`` keeps only favorites (the
|
the linked positive, and ``favorites=1`` keeps only favorites (the
|
||||||
Favorites page, #249 D51). ``collection=<id>`` (1 to 9 digits) keeps only
|
Favorites page, #249 D51). ``collection=<id>`` (1 to 9 digits) keeps only
|
||||||
that collection's members (#331); it wins over ``favorites`` (a page never
|
that collection's members (#331); it wins over ``favorites`` (a page never
|
||||||
sets both). Raises ValueError for any unknown or oversized value.
|
sets both). ``removed=1`` lists only the files a removal mark hides (#498,
|
||||||
|
Show removed images); such a file has no records, so it never combines
|
||||||
|
with ``favorites``, ``collection``, ``category``, ``tags`` or the rating
|
||||||
|
sort. Raises ValueError for any unknown or oversized value.
|
||||||
"""
|
"""
|
||||||
text = query.get("q", "").strip()
|
text = query.get("q", "").strip()
|
||||||
folded = text.casefold()
|
folded = text.casefold()
|
||||||
@@ -1175,6 +1197,12 @@ def _workspace_filters(query: Mapping) -> dict:
|
|||||||
)
|
)
|
||||||
favorites = query.get("favorites", "0")
|
favorites = query.get("favorites", "0")
|
||||||
collection = query.get("collection", "")
|
collection = query.get("collection", "")
|
||||||
|
removed = query.get("removed", "0")
|
||||||
|
if removed not in ("0", "1") or (
|
||||||
|
removed == "1"
|
||||||
|
and (favorites == "1" or collection or category or tags or order == "rating")
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid gallery filters")
|
||||||
if (
|
if (
|
||||||
favorites not in ("0", "1")
|
favorites not in ("0", "1")
|
||||||
or (collection != "" and _COLLECTION_ID.fullmatch(collection) is None)
|
or (collection != "" and _COLLECTION_ID.fullmatch(collection) is None)
|
||||||
@@ -1202,6 +1230,7 @@ def _workspace_filters(query: Mapping) -> dict:
|
|||||||
"tags": tags,
|
"tags": tags,
|
||||||
"favorites": favorites == "1" and not collection,
|
"favorites": favorites == "1" and not collection,
|
||||||
"collection": int(collection) if collection else None,
|
"collection": int(collection) if collection else None,
|
||||||
|
"removed": removed == "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1504,7 +1533,7 @@ class ImageRoutesMixin:
|
|||||||
async def clear_image_favorite_route(request):
|
async def clear_image_favorite_route(request):
|
||||||
return await self.update_image_favorite(request, favorite=False)
|
return await self.update_image_favorite(request, favorite=False)
|
||||||
|
|
||||||
# POST, local-only: opens this computer's file manager (D38).
|
# POST, same-origin: opens the server computer's file manager (D38).
|
||||||
@routes.post("/prompt_manager/images/reveal")
|
@routes.post("/prompt_manager/images/reveal")
|
||||||
async def reveal_image_route(request):
|
async def reveal_image_route(request):
|
||||||
return await self.reveal_image(request)
|
return await self.reveal_image(request)
|
||||||
@@ -2073,6 +2102,7 @@ class ImageRoutesMixin:
|
|||||||
filters["tags"],
|
filters["tags"],
|
||||||
filters["favorites"],
|
filters["favorites"],
|
||||||
filters["collection"],
|
filters["collection"],
|
||||||
|
filters["removed"],
|
||||||
]
|
]
|
||||||
).encode()
|
).encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -2102,6 +2132,11 @@ class ImageRoutesMixin:
|
|||||||
self._select_workspace_page, listings, filters, after, limit, scope
|
self._select_workspace_page, listings, filters, after, limit, scope
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# #498: Show removed images (N), every removed file in the selected
|
||||||
|
# source(s) whatever the filters (the hidden set is cached work).
|
||||||
|
removed_total = len(
|
||||||
|
await self._run_in_executor(self._hidden_entries, listings)
|
||||||
|
)
|
||||||
except CollectionMissing:
|
except CollectionMissing:
|
||||||
return collection_not_found()
|
return collection_not_found()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2128,6 +2163,7 @@ class ImageRoutesMixin:
|
|||||||
"limit": limit,
|
"limit": limit,
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
"next_cursor": next_cursor,
|
"next_cursor": next_cursor,
|
||||||
|
"removed_total": removed_total,
|
||||||
}
|
}
|
||||||
if positive_count is _COUNT_OMITTED:
|
if positive_count is _COUNT_OMITTED:
|
||||||
del result["positive_count"]
|
del result["positive_count"]
|
||||||
@@ -2451,9 +2487,12 @@ class ImageRoutesMixin:
|
|||||||
def key(entry):
|
def key(entry):
|
||||||
return _workspace_key(entry, order)
|
return _workspace_key(entry, order)
|
||||||
|
|
||||||
# #480: files removed from the library stay on disk, hidden here.
|
# #480: files removed from the library stay on disk, hidden here; the
|
||||||
|
# removed view (#498) lists only them.
|
||||||
hidden = self._hidden_entries(listings)
|
hidden = self._hidden_entries(listings)
|
||||||
if hidden:
|
if filters.get("removed"):
|
||||||
|
entries = [entry for entry in entries if entry.normalized in hidden]
|
||||||
|
elif hidden:
|
||||||
entries = [entry for entry in entries if entry.normalized not in hidden]
|
entries = [entry for entry in entries if entry.normalized not in hidden]
|
||||||
return (
|
return (
|
||||||
entries,
|
entries,
|
||||||
@@ -2741,6 +2780,9 @@ class ImageRoutesMixin:
|
|||||||
if _has_traversal(filepath):
|
if _has_traversal(filepath):
|
||||||
return _forbidden()
|
return _forbidden()
|
||||||
suffix = Path(filepath).suffix.lower()
|
suffix = Path(filepath).suffix.lower()
|
||||||
|
if in_trash_folder(filepath):
|
||||||
|
# Before any file access, so no preview is made for it (#543).
|
||||||
|
return _file_not_found()
|
||||||
if suffix not in SERVABLE_EXTENSIONS and not (
|
if suffix not in SERVABLE_EXTENSIONS and not (
|
||||||
# A JPEG XL preview is served from thumbnails/ only (#317).
|
# A JPEG XL preview is served from thumbnails/ only (#317).
|
||||||
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||||
@@ -2764,6 +2806,8 @@ class ImageRoutesMixin:
|
|||||||
# a fall-through to the next root.
|
# a fall-through to the next root.
|
||||||
if not _is_within(image_path, output_path):
|
if not _is_within(image_path, output_path):
|
||||||
return _forbidden()
|
return _forbidden()
|
||||||
|
if in_trash_below_roots(image_path, output_dirs):
|
||||||
|
return _file_not_found()
|
||||||
if image_path.is_file():
|
if image_path.is_file():
|
||||||
return self._file_response(image_path)
|
return self._file_response(image_path)
|
||||||
|
|
||||||
@@ -2815,8 +2859,9 @@ class ImageRoutesMixin:
|
|||||||
if _is_thumbnail_cache_path(filepath):
|
if _is_thumbnail_cache_path(filepath):
|
||||||
return _forbidden()
|
return _forbidden()
|
||||||
image_path = root / filepath
|
image_path = root / filepath
|
||||||
if not _is_within(image_path, root):
|
denied = _media_access_error(image_path, [root])
|
||||||
return _forbidden()
|
if denied is not None:
|
||||||
|
return denied
|
||||||
if not image_path.is_file():
|
if not image_path.is_file():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Image file not found"}, status=404
|
{"success": False, "error": "Image file not found"}, status=404
|
||||||
@@ -3662,12 +3707,12 @@ class ImageRoutesMixin:
|
|||||||
async def reveal_image(self, request):
|
async def reveal_image(self, request):
|
||||||
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
|
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
|
||||||
|
|
||||||
Local-only (a same-origin browser request from a loopback client, no
|
Same-origin and JSON-only, directly or through a proxy: the folder
|
||||||
forwarding header) and JSON-only: the browser must run on the
|
opens on the computer running ComfyUI, wherever the browser is
|
||||||
computer whose file manager opens. One reveal at a time (429 busy).
|
(maintainer decision, #533). One reveal at a time (429 busy).
|
||||||
"""
|
"""
|
||||||
if not is_local_request(request.remote, request.headers, request.scheme):
|
if not is_same_origin_request(request.headers, request.scheme):
|
||||||
return _not_local()
|
return _not_same_origin()
|
||||||
if request.content_type != "application/json":
|
if request.content_type != "application/json":
|
||||||
# A non-JSON POST is a CORS "simple" request with no preflight.
|
# A non-JSON POST is a CORS "simple" request with no preflight.
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3745,7 +3790,9 @@ class ImageRoutesMixin:
|
|||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"code": "unavailable",
|
"code": "unavailable",
|
||||||
"error": "No file manager is available on this computer.",
|
"error": (
|
||||||
|
"No file manager is available on the computer running ComfyUI."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
status=501,
|
status=501,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ class JobRoutesMixin:
|
|||||||
try:
|
try:
|
||||||
await response.write(_summary_event(job))
|
await response.write(_summary_event(job))
|
||||||
async with contextlib.aclosing(job.events()) as events:
|
async with contextlib.aclosing(job.events()) as events:
|
||||||
async for _payload in events:
|
async for _ in events:
|
||||||
await response.write(_summary_event(job))
|
await response.write(_summary_event(job))
|
||||||
# The last event is published before the job takes its final state.
|
# The last event is published before the job takes its final state.
|
||||||
await response.write(_summary_event(job))
|
await response.write(_summary_event(job))
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ _SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"})
|
|||||||
_DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"})
|
_DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"})
|
||||||
# #482: Delete permanently, with its digests; both or neither (a #443 body).
|
# #482: Delete permanently, with its digests; both or neither (a #443 body).
|
||||||
_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"})
|
_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"})
|
||||||
|
# #492: the shelf the Delete… runs on, optional on its own (default "active").
|
||||||
|
_SHELF_KEYS = frozenset({"shelf"})
|
||||||
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
|
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
|
||||||
|
|
||||||
|
|
||||||
@@ -66,8 +68,8 @@ def _storage_changed(exc):
|
|||||||
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
|
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
|
||||||
|
|
||||||
|
|
||||||
async def _body(request, keys, optional=frozenset()):
|
async def _body(request, keys, *groups):
|
||||||
"""The JSON object with exactly ``keys`` (plus all or none of ``optional``)."""
|
"""The JSON object with exactly ``keys``, plus all or none of each group."""
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -75,11 +77,24 @@ async def _body(request, keys, optional=frozenset()):
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Expected a JSON object")
|
raise ValueError("Expected a JSON object")
|
||||||
extra = set(data) - keys
|
extra = set(data) - keys
|
||||||
if not keys <= set(data) or (extra and extra != optional):
|
partial = False
|
||||||
|
for group in groups:
|
||||||
|
if extra & group:
|
||||||
|
partial = partial or not group <= extra
|
||||||
|
extra -= group
|
||||||
|
if not keys <= set(data) or extra or partial:
|
||||||
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
|
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _shelf(data):
|
||||||
|
"""#492: the shelf a Delete… was reviewed on."""
|
||||||
|
shelf = data.get("shelf", "active")
|
||||||
|
if shelf not in ("active", "archived"):
|
||||||
|
raise ValueError("shelf must be active or archived")
|
||||||
|
return shelf
|
||||||
|
|
||||||
|
|
||||||
def _paired_delete(data, archive_ids):
|
def _paired_delete(data, archive_ids):
|
||||||
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
|
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
|
||||||
if "delete_paired_ids" not in data:
|
if "delete_paired_ids" not in data:
|
||||||
@@ -119,8 +134,9 @@ class LibraryDeleteRoutesMixin:
|
|||||||
|
|
||||||
async def preview_library_delete(self, request):
|
async def preview_library_delete(self, request):
|
||||||
try:
|
try:
|
||||||
data = await _body(request, _SELECTION_KEYS)
|
data = await _body(request, _SELECTION_KEYS, _SHELF_KEYS)
|
||||||
ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||||
|
shelf = _shelf(data)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return _invalid(str(exc))
|
return _invalid(str(exc))
|
||||||
db = self.db
|
db = self.db
|
||||||
@@ -131,6 +147,7 @@ class LibraryDeleteRoutesMixin:
|
|||||||
db.classify_library_delete,
|
db.classify_library_delete,
|
||||||
ids,
|
ids,
|
||||||
expected_epoch=data["expected_epoch"],
|
expected_epoch=data["expected_epoch"],
|
||||||
|
shelf=shelf,
|
||||||
)
|
)
|
||||||
disk = None
|
disk = None
|
||||||
if (review["delete"] or review["deletable"]) and policy == "on":
|
if (review["delete"] or review["deletable"]) and policy == "on":
|
||||||
@@ -165,6 +182,7 @@ class LibraryDeleteRoutesMixin:
|
|||||||
"archive": review["archive"],
|
"archive": review["archive"],
|
||||||
"protected": review["protected"],
|
"protected": review["protected"],
|
||||||
"already_archived": len(review["already_archived"]),
|
"already_archived": len(review["already_archived"]),
|
||||||
|
"not_archived": len(review["not_archived"]),
|
||||||
"not_found": len(review["not_found"]),
|
"not_found": len(review["not_found"]),
|
||||||
"image_links": review["image_links"],
|
"image_links": review["image_links"],
|
||||||
# #482: the archive ids Delete permanently may delete with pairs.
|
# #482: the archive ids Delete permanently may delete with pairs.
|
||||||
@@ -181,7 +199,8 @@ class LibraryDeleteRoutesMixin:
|
|||||||
|
|
||||||
async def delete_library_prompts(self, request):
|
async def delete_library_prompts(self, request):
|
||||||
try:
|
try:
|
||||||
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS)
|
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS, _SHELF_KEYS)
|
||||||
|
shelf = _shelf(data)
|
||||||
archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids")
|
archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids")
|
||||||
delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids")
|
delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids")
|
||||||
if not archive_ids and not delete_ids:
|
if not archive_ids and not delete_ids:
|
||||||
@@ -203,7 +222,7 @@ class LibraryDeleteRoutesMixin:
|
|||||||
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
|
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
|
||||||
) as job:
|
) as job:
|
||||||
response = await self._held_library_delete(
|
response = await self._held_library_delete(
|
||||||
self.db, data, archive_ids, delete_ids, paired
|
self.db, data, archive_ids, delete_ids, paired, shelf
|
||||||
)
|
)
|
||||||
if response.status >= 400:
|
if response.status >= 400:
|
||||||
job.finish("failed")
|
job.finish("failed")
|
||||||
@@ -211,7 +230,9 @@ class LibraryDeleteRoutesMixin:
|
|||||||
except JobBusy as busy:
|
except JobBusy as busy:
|
||||||
return busy_response(busy)
|
return busy_response(busy)
|
||||||
|
|
||||||
async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired):
|
async def _held_library_delete(
|
||||||
|
self, db, data, archive_ids, delete_ids, paired, shelf="active"
|
||||||
|
):
|
||||||
epoch = data["expected_epoch"]
|
epoch = data["expected_epoch"]
|
||||||
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
|
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
|
||||||
backup = None
|
backup = None
|
||||||
@@ -222,6 +243,8 @@ class LibraryDeleteRoutesMixin:
|
|||||||
db.classify_library_delete,
|
db.classify_library_delete,
|
||||||
[*delete_ids, *paired],
|
[*delete_ids, *paired],
|
||||||
expected_epoch=epoch,
|
expected_epoch=epoch,
|
||||||
|
# #492: the same shelf, or archived ids would skip the backup.
|
||||||
|
shelf=shelf,
|
||||||
)
|
)
|
||||||
candidates = review["fingerprints"]
|
candidates = review["fingerprints"]
|
||||||
# #482: a permanent delete of a prompt in a saved pair is backed up too.
|
# #482: a permanent delete of a prompt in a saved pair is backed up too.
|
||||||
@@ -248,6 +271,7 @@ class LibraryDeleteRoutesMixin:
|
|||||||
expected_epoch=epoch,
|
expected_epoch=epoch,
|
||||||
candidates=candidates if candidates is not None else {},
|
candidates=candidates if candidates is not None else {},
|
||||||
delete_paired=paired,
|
delete_paired=paired,
|
||||||
|
shelf=shelf,
|
||||||
)
|
)
|
||||||
except LibraryCommandError as exc:
|
except LibraryCommandError as exc:
|
||||||
return _storage_changed(exc)
|
return _storage_changed(exc)
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class LibraryLocationRoutesMixin:
|
|||||||
try:
|
try:
|
||||||
size = os.path.getsize(location.db_path)
|
size = os.path.getsize(location.db_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# no library file yet: report size 0
|
||||||
pass
|
pass
|
||||||
return {
|
return {
|
||||||
"path": self._library_public_path(location.db_path),
|
"path": self._library_public_path(location.db_path),
|
||||||
@@ -373,6 +374,7 @@ class LibraryLocationRoutesMixin:
|
|||||||
line += ". Logs move at the next restart."
|
line += ". Logs move at the next restart."
|
||||||
job.log_line(line)
|
job.log_line(line)
|
||||||
job.update(progress=100, status_line=line)
|
job.update(progress=100, status_line=line)
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fail_library_move(job, code, message):
|
def _fail_library_move(job, code, message):
|
||||||
|
|||||||
@@ -205,3 +205,4 @@ class LogCleanupMixin:
|
|||||||
line += f", {failed} could not be deleted"
|
line += f", {failed} could not be deleted"
|
||||||
job.log_line(line)
|
job.log_line(line)
|
||||||
job.update(processed=len(files), progress=100, status_line=line)
|
job.update(processed=len(files), progress=100, status_line=line)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ class LoraImportRoutesMixin:
|
|||||||
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
||||||
job.update(status_line=CANCELLED)
|
job.update(status_line=CANCELLED)
|
||||||
job.finish("cancelled")
|
job.finish("cancelled")
|
||||||
return
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("LoRA import staging failed")
|
self.logger.exception("LoRA import staging failed")
|
||||||
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
|
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
|
||||||
@@ -455,10 +455,10 @@ class LoraImportRoutesMixin:
|
|||||||
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
||||||
job.update(status_line=CANCELLED)
|
job.update(status_line=CANCELLED)
|
||||||
job.finish("cancelled")
|
job.finish("cancelled")
|
||||||
return
|
return None
|
||||||
ok, _backup = await self._safety_backup_step(job, db, backup_on)
|
ok, _backup = await self._safety_backup_step(job, db, backup_on)
|
||||||
if not ok:
|
if not ok:
|
||||||
return
|
return None
|
||||||
# I1: the files are listed and fingerprinted again right before the
|
# I1: the files are listed and fingerprinted again right before the
|
||||||
# replace, so a file changed, removed or added while the downloads ran
|
# replace, so a file changed, removed or added while the downloads ran
|
||||||
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
|
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
|
||||||
@@ -466,6 +466,7 @@ class LoraImportRoutesMixin:
|
|||||||
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
|
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
|
||||||
job.update(status_line="Replacing the LoRA collection", progress=95)
|
job.update(status_line="Replacing the LoRA collection", progress=95)
|
||||||
await self._replace_in_job(job, db, plan, staged)
|
await self._replace_in_job(job, db, plan, staged)
|
||||||
|
return None
|
||||||
|
|
||||||
def _count_downloads(self, job, staged):
|
def _count_downloads(self, job, staged):
|
||||||
downloaded = staged.stats.get("downloaded", 0)
|
downloaded = staged.stats.get("downloaded", 0)
|
||||||
@@ -518,3 +519,4 @@ class LoraImportRoutesMixin:
|
|||||||
staged.stats.get("download_errors", 0),
|
staged.stats.get("download_errors", 0),
|
||||||
)
|
)
|
||||||
job.update(progress=100, processed=job.total, status_line=line)
|
job.update(progress=100, processed=job.total, status_line=line)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ _FILES_CHANGED = (
|
|||||||
"The LoRA metadata files changed during the scan; the previous imports "
|
"The LoRA metadata files changed during the scan; the previous imports "
|
||||||
"were kept. Run the scan again."
|
"were kept. Run the scan again."
|
||||||
)
|
)
|
||||||
|
_LORA_SCAN_FAILED = "The LoRA import failed. Check the server log for details."
|
||||||
_IMPORTS_CHANGED = (
|
_IMPORTS_CHANGED = (
|
||||||
"The imported LoRA prompts changed during the scan; the previous imports "
|
"The imported LoRA prompts changed during the scan; the previous imports "
|
||||||
"were kept. Run the scan again."
|
"were kept. Run the scan again."
|
||||||
@@ -407,10 +408,21 @@ class LoraIntegrationMixin:
|
|||||||
return
|
return
|
||||||
if isinstance(error, PlanChanged):
|
if isinstance(error, PlanChanged):
|
||||||
message = _IMPORTS_CHANGED
|
message = _IMPORTS_CHANGED
|
||||||
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
|
elif isinstance(
|
||||||
|
error,
|
||||||
|
(
|
||||||
|
LoraFilesChanged,
|
||||||
|
LoraSettingsUnusable,
|
||||||
|
EmptyReplacement,
|
||||||
|
PairDependencyError,
|
||||||
|
),
|
||||||
|
):
|
||||||
message = str(error)
|
message = str(error)
|
||||||
else:
|
elif isinstance(error, OSError):
|
||||||
|
# Reduced to its reason and the file's basename.
|
||||||
message = self._public_error(error)
|
message = self._public_error(error)
|
||||||
|
else:
|
||||||
|
message = _LORA_SCAN_FAILED
|
||||||
if isinstance(
|
if isinstance(
|
||||||
error,
|
error,
|
||||||
(
|
(
|
||||||
@@ -536,6 +548,7 @@ class LoraIntegrationMixin:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
interrupted = True
|
interrupted = True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# the await after this loop raises it for the caller
|
||||||
pass
|
pass
|
||||||
if interrupted:
|
if interrupted:
|
||||||
raise asyncio.CancelledError
|
raise asyncio.CancelledError
|
||||||
|
|||||||
@@ -673,6 +673,7 @@ class MaintenanceRoutesMixin:
|
|||||||
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
|
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
|
||||||
job.log_line(line)
|
job.log_line(line)
|
||||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _maintenance_rows(self, operation, described):
|
def _maintenance_rows(self, operation, described):
|
||||||
"""Outcome rows for the changed records (root-relative paths only)."""
|
"""Outcome rows for the changed records (root-relative paths only)."""
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ by the caller.
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .images import _forbidden, _has_traversal
|
from .images import _file_not_found, _forbidden, _has_traversal
|
||||||
from .prompts import bad_request
|
from .prompts import bad_request
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ...utils.media_capture import CAPTURE_EXTENSIONS
|
from ...utils.media_capture import CAPTURE_EXTENSIONS
|
||||||
|
from ...utils.media_folders import in_trash_folder
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from utils.media_capture import CAPTURE_EXTENSIONS
|
from utils.media_capture import CAPTURE_EXTENSIONS
|
||||||
|
from utils.media_folders import in_trash_folder
|
||||||
|
|
||||||
MAX_TARGET_PATH = 1024
|
MAX_TARGET_PATH = 1024
|
||||||
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
|
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
|
||||||
@@ -29,7 +31,9 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
|
|||||||
Only the listing identity is accepted: an int root index, the root's
|
Only the listing identity is accepted: an int root index, the root's
|
||||||
64-hex scope and a bounded root-relative POSIX media path, never an
|
64-hex scope and a bounded root-relative POSIX media path, never an
|
||||||
absolute, drive-qualified, backslash or ``..`` path (a 400 for a malformed
|
absolute, drive-qualified, backslash or ``..`` path (a 400 for a malformed
|
||||||
shape, a 403 for an unsafe or non-media path, carrying *media_message*).
|
shape, a 403 for an unsafe or non-media path, carrying *media_message*, a
|
||||||
|
404 for a path in a trash folder, #543). A link or a short-name spelling
|
||||||
|
is refused later by the capture store's alias check.
|
||||||
"""
|
"""
|
||||||
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
|
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
|
||||||
return None, bad_request("target must name root_index, root_scope and path")
|
return None, bad_request("target must name root_index, root_scope and path")
|
||||||
@@ -51,4 +55,6 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
|
|||||||
return None, _forbidden()
|
return None, _forbidden()
|
||||||
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
|
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
|
||||||
return None, _forbidden(media_message)
|
return None, _forbidden(media_message)
|
||||||
|
if in_trash_folder(relative):
|
||||||
|
return None, _file_not_found()
|
||||||
return (index, scope, relative), None
|
return (index, scope, relative), None
|
||||||
|
|||||||
@@ -102,7 +102,12 @@ class PairRoutesMixin:
|
|||||||
validate_prompt_text(negative)
|
validate_prompt_text(negative)
|
||||||
fields = metadata_fields(data)
|
fields = metadata_fields(data)
|
||||||
pair_id = await self._run_in_executor(
|
pair_id = await self._run_in_executor(
|
||||||
self.db.save_prompt_pair, positive, negative, **fields
|
self.db.save_prompt_pair,
|
||||||
|
positive,
|
||||||
|
negative,
|
||||||
|
# The node's Save pair button: as a run, a new prompt gets the metadata.
|
||||||
|
fill_new_positive=True,
|
||||||
|
**fields,
|
||||||
)
|
)
|
||||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||||
return web.json_response({"success": True, "pair": pair})
|
return web.json_response({"success": True, "pair": pair})
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ candidates and the dependency fingerprint. ``POST .../items/{id}/decision``
|
|||||||
records one explicit decision: Apply links the chosen sampler's pair only
|
records one explicit decision: Apply links the chosen sampler's pair only
|
||||||
when the capture is the item's bytes, the current file still hashes to them,
|
when the capture is the item's bytes, the current file still hashes to them,
|
||||||
the evidence they yield is unchanged and the ingester's compatible-only rules
|
the evidence they yield is unchanged and the ingester's compatible-only rules
|
||||||
allow it; Leave unresolved links nothing.
|
allow it; Leave unresolved links nothing. ``POST .../items/{id}/link`` (#536)
|
||||||
|
links a Library prompt the reviewer chose, under the same capture and
|
||||||
|
compatibility guards; it is their choice, so what the bytes resolve to is not
|
||||||
|
asked.
|
||||||
|
|
||||||
Targets are formatted from the recorded spelling against the current roots
|
Targets are formatted from the recorded spelling against the current roots
|
||||||
(no file access beyond the roots' real paths); a file under no current root
|
(no file access beyond the roots' real paths); a file under no current root
|
||||||
@@ -28,12 +31,13 @@ import urllib.parse
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from .annotations import _HEX64, _TOKEN, _UUID, _capture_error, _error
|
from .annotations import _capture_error, _error
|
||||||
from .images import media_thumbnail_url
|
from .images import media_thumbnail_url
|
||||||
from .inspection_service import VIDEO_EXTENSIONS
|
from .inspection_service import VIDEO_EXTENSIONS
|
||||||
from .job_routes import busy_response
|
from .job_routes import busy_response
|
||||||
from .jobs import JobBusy
|
from .jobs import JobBusy
|
||||||
from .prompts import bad_request
|
from .prompts import bad_request
|
||||||
|
from .review_guards import UNRESOLVED, _decision_guards, _link_guards
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ...database.library import display_title
|
from ...database.library import display_title
|
||||||
@@ -59,17 +63,6 @@ MAX_CURSOR = 4096
|
|||||||
MAX_BODY_BYTES = 64 * 1024
|
MAX_BODY_BYTES = 64 * 1024
|
||||||
# The serialized detail stays under this; past it the detail is ``limited``.
|
# The serialized detail stays under this; past it the detail is ``limited``.
|
||||||
MAX_DETAIL_BYTES = 8 * 1024 * 1024
|
MAX_DETAIL_BYTES = 8 * 1024 * 1024
|
||||||
UNRESOLVED = "unresolved"
|
|
||||||
_DECISION_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"expected_epoch",
|
|
||||||
"expected_revision",
|
|
||||||
"capture_token",
|
|
||||||
"sha256",
|
|
||||||
"dependency_fingerprint",
|
|
||||||
"choice",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_CURSOR_SECRET = os.urandom(32)
|
_CURSOR_SECRET = os.urandom(32)
|
||||||
# The real path of a spelling; module-level so a test can fake a junction.
|
# The real path of a spelling; module-level so a test can fake a junction.
|
||||||
_realpath = os.path.realpath
|
_realpath = os.path.realpath
|
||||||
@@ -84,6 +77,7 @@ _STATUS = {
|
|||||||
"storage-changed": 409,
|
"storage-changed": 409,
|
||||||
"source-changed": 409,
|
"source-changed": 409,
|
||||||
"capture-expired": 409,
|
"capture-expired": 409,
|
||||||
|
"prompt-missing": 409,
|
||||||
}
|
}
|
||||||
_MESSAGES = {
|
_MESSAGES = {
|
||||||
"review-missing": "This review item no longer exists. Reload the queue.",
|
"review-missing": "This review item no longer exists. Reload the queue.",
|
||||||
@@ -95,6 +89,9 @@ _MESSAGES = {
|
|||||||
"sampler path cannot be attached safely. The existing links were "
|
"sampler path cannot be attached safely. The existing links were "
|
||||||
"preserved."
|
"preserved."
|
||||||
),
|
),
|
||||||
|
"prompt-missing": (
|
||||||
|
"That prompt or recipe is no longer in the Library. Search again."
|
||||||
|
),
|
||||||
"dependencies-changed": (
|
"dependencies-changed": (
|
||||||
"The image's saved links changed since this item was opened. Reopen it "
|
"The image's saved links changed since this item was opened. Reopen it "
|
||||||
"to see the current association."
|
"to see the current association."
|
||||||
@@ -251,6 +248,7 @@ class ReviewRoutesMixin:
|
|||||||
routes.get(ITEMS_URL)(self.list_review_items)
|
routes.get(ITEMS_URL)(self.list_review_items)
|
||||||
routes.get(ITEMS_URL + "/{item_id}")(self.get_review_item)
|
routes.get(ITEMS_URL + "/{item_id}")(self.get_review_item)
|
||||||
routes.post(ITEMS_URL + "/{item_id}/decision")(self.decide_review_item)
|
routes.post(ITEMS_URL + "/{item_id}/decision")(self.decide_review_item)
|
||||||
|
routes.post(ITEMS_URL + "/{item_id}/link")(self.link_review_item)
|
||||||
|
|
||||||
# -- shared ----------------------------------------------------------------
|
# -- shared ----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -464,6 +462,43 @@ class ReviewRoutesMixin:
|
|||||||
except JobBusy as busy:
|
except JobBusy as busy:
|
||||||
return busy_response(busy)
|
return busy_response(busy)
|
||||||
|
|
||||||
|
async def link_review_item(self, request: web.Request) -> web.Response:
|
||||||
|
"""POST the Library prompt the reviewer chose for one pending item.
|
||||||
|
|
||||||
|
Under the same lease as a decision. The capture must be the item's
|
||||||
|
current bytes; the choice and the file's links are checked in the
|
||||||
|
decision's transaction.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._jobs.lease(REVIEW_DECISION_LEASE):
|
||||||
|
return await self._link_review_item(request)
|
||||||
|
except JobBusy as busy:
|
||||||
|
return busy_response(busy)
|
||||||
|
|
||||||
|
async def _link_review_item(self, request: web.Request) -> web.Response:
|
||||||
|
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
guards, error = _link_guards(data)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
item = await self._run_in_executor(
|
||||||
|
self.db.review_item, request.match_info["item_id"]
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Review item read failed")
|
||||||
|
return _failed()
|
||||||
|
if item is None:
|
||||||
|
return _coded("review-missing")
|
||||||
|
verified, error = await self._verified_review_capture(
|
||||||
|
item, guards, need_metadata=True
|
||||||
|
)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
manual = {"positive_id": guards["positive_id"], "pair_id": guards["pair_id"]}
|
||||||
|
return await self._record_decision(item, guards, None, verified, manual)
|
||||||
|
|
||||||
async def _decide_review_item(self, request: web.Request) -> web.Response:
|
async def _decide_review_item(self, request: web.Request) -> web.Response:
|
||||||
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
@@ -549,22 +584,25 @@ class ReviewRoutesMixin:
|
|||||||
return None, _coded("file-changed")
|
return None, _coded("file-changed")
|
||||||
metadata = None
|
metadata = None
|
||||||
if need_metadata:
|
if need_metadata:
|
||||||
if captured is None or not _candidate_still_offered(
|
# A manual link has no ``choice``: it rests on the bytes alone.
|
||||||
captured[1], guards["choice"]
|
if captured is None or (
|
||||||
|
"choice" in guards
|
||||||
|
and not _candidate_still_offered(captured[1], guards["choice"])
|
||||||
):
|
):
|
||||||
return None, _coded("review-conflict", "evidence-changed")
|
return None, _coded("review-conflict", "evidence-changed")
|
||||||
metadata = captured[0]
|
metadata = captured[0]
|
||||||
image_path = os.path.join(str(root), *relative.split("/"))
|
image_path = os.path.join(str(root), *relative.split("/"))
|
||||||
return (capture, facts, image_path, metadata), None
|
return (capture, facts, image_path, metadata), None
|
||||||
|
|
||||||
def _decide_sync(self, item, guards, candidate, verified):
|
def _decide_sync(self, item, guards, candidate, verified, manual=None):
|
||||||
"""Worker thread: resolve the file's spellings, decide, build the view.
|
"""Worker thread: resolve the file's spellings, decide, build the view.
|
||||||
|
|
||||||
Returns ``(result, view)``; a refusal raises with ``exc.view`` set.
|
Returns ``(result, view)``; a refusal raises with ``exc.view`` set.
|
||||||
"""
|
"""
|
||||||
_capture, facts, image_path, metadata = verified or (None, None, None, None)
|
_capture, facts, image_path, metadata = verified or (None, None, None, None)
|
||||||
aliases, known = (), None
|
aliases, known = (), None
|
||||||
if candidate is not None:
|
selected = candidate is not None or manual is not None
|
||||||
|
if selected:
|
||||||
aliases, known = physical_spellings(self.db, image_path, item["locator"])
|
aliases, known = physical_spellings(self.db, image_path, item["locator"])
|
||||||
try:
|
try:
|
||||||
result = self.db.decide_review_item(
|
result = self.db.decide_review_item(
|
||||||
@@ -573,8 +611,9 @@ class ReviewRoutesMixin:
|
|||||||
expected_revision=guards["expected_revision"],
|
expected_revision=guards["expected_revision"],
|
||||||
# The bytes the re-hash proved, or the body's for Leave unresolved.
|
# The bytes the re-hash proved, or the body's for Leave unresolved.
|
||||||
sha256=facts.sha256 if facts is not None else guards["sha256"],
|
sha256=facts.sha256 if facts is not None else guards["sha256"],
|
||||||
outcome="selected" if candidate is not None else UNRESOLVED,
|
outcome="selected" if selected else UNRESOLVED,
|
||||||
candidate=candidate,
|
candidate=candidate,
|
||||||
|
manual=manual,
|
||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
expected_fingerprint=guards["dependency_fingerprint"],
|
expected_fingerprint=guards["dependency_fingerprint"],
|
||||||
@@ -589,11 +628,11 @@ class ReviewRoutesMixin:
|
|||||||
roots, real_roots = self._review_roots()
|
roots, real_roots = self._review_roots()
|
||||||
return result, self._review_view(result["item"], roots, real_roots)
|
return result, self._review_view(result["item"], roots, real_roots)
|
||||||
|
|
||||||
async def _record_decision(self, item, guards, candidate, verified):
|
async def _record_decision(self, item, guards, candidate, verified, manual=None):
|
||||||
capture = verified[0] if verified else None
|
capture = verified[0] if verified else None
|
||||||
try:
|
try:
|
||||||
result, view = await self._run_in_executor(
|
result, view = await self._run_in_executor(
|
||||||
self._decide_sync, item, guards, candidate, verified
|
self._decide_sync, item, guards, candidate, verified, manual
|
||||||
)
|
)
|
||||||
except ReviewCommandError as exc:
|
except ReviewCommandError as exc:
|
||||||
return self._decision_refused(exc)
|
return self._decision_refused(exc)
|
||||||
@@ -629,36 +668,6 @@ class ReviewRoutesMixin:
|
|||||||
return _coded(exc.code, message, **extra)
|
return _coded(exc.code, message, **extra)
|
||||||
|
|
||||||
|
|
||||||
def _decision_guards(data: dict):
|
|
||||||
"""The validated decision fields, or ``(None, error)``."""
|
|
||||||
if set(data) != _DECISION_FIELDS:
|
|
||||||
return None, bad_request(
|
|
||||||
"Request must contain exactly " + ", ".join(sorted(_DECISION_FIELDS))
|
|
||||||
)
|
|
||||||
epoch, revision = data["expected_epoch"], data["expected_revision"]
|
|
||||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
|
||||||
return None, bad_request("expected_epoch must be a canonical UUID")
|
|
||||||
if type(revision) is not int or revision < 0:
|
|
||||||
return None, bad_request("expected_revision must be a non-negative integer")
|
|
||||||
token = data["capture_token"]
|
|
||||||
if token is not None and (
|
|
||||||
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
|
|
||||||
):
|
|
||||||
return None, bad_request("Invalid capture token")
|
|
||||||
for name in ("sha256", "dependency_fingerprint"):
|
|
||||||
value = data[name]
|
|
||||||
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
|
|
||||||
return None, bad_request(
|
|
||||||
f"{name} must be 64 lowercase hexadecimal characters"
|
|
||||||
)
|
|
||||||
choice = data["choice"]
|
|
||||||
if not isinstance(choice, str) or (
|
|
||||||
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
|
|
||||||
):
|
|
||||||
return None, bad_request("choice must be a candidate hash or unresolved")
|
|
||||||
return {**data}, None
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_and_read(store, capture, item, root, need_metadata: bool):
|
def _verify_and_read(store, capture, item, root, need_metadata: bool):
|
||||||
"""Worker thread: ``(relative, facts, captured)``, or None off the root.
|
"""Worker thread: ``(relative, facts, captured)``, or None off the root.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Body validation of Needs review decisions and manual links (#249 E3, #536)."""
|
||||||
|
|
||||||
|
from .annotations import _HEX64, _TOKEN, _UUID
|
||||||
|
from .prompts import bad_request
|
||||||
|
|
||||||
|
UNRESOLVED = "unresolved"
|
||||||
|
_GUARD_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"expected_epoch",
|
||||||
|
"expected_revision",
|
||||||
|
"capture_token",
|
||||||
|
"sha256",
|
||||||
|
"dependency_fingerprint",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_DECISION_FIELDS = _GUARD_FIELDS | {"choice"}
|
||||||
|
_LINK_FIELDS = _GUARD_FIELDS | {"positive_id", "pair_id"}
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_error(data: dict, fields: frozenset):
|
||||||
|
"""The first problem with a decision's shared fields, as a response."""
|
||||||
|
if set(data) != fields:
|
||||||
|
return bad_request("Request must contain exactly " + ", ".join(sorted(fields)))
|
||||||
|
epoch, revision = data["expected_epoch"], data["expected_revision"]
|
||||||
|
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||||
|
return bad_request("expected_epoch must be a canonical UUID")
|
||||||
|
if type(revision) is not int or revision < 0:
|
||||||
|
return bad_request("expected_revision must be a non-negative integer")
|
||||||
|
token = data["capture_token"]
|
||||||
|
if token is not None and (
|
||||||
|
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
|
||||||
|
):
|
||||||
|
return bad_request("Invalid capture token")
|
||||||
|
for name in ("sha256", "dependency_fingerprint"):
|
||||||
|
value = data[name]
|
||||||
|
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
|
||||||
|
return bad_request(f"{name} must be 64 lowercase hexadecimal characters")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_guards(data: dict):
|
||||||
|
"""The validated decision fields, or ``(None, error)``."""
|
||||||
|
error = _guard_error(data, _DECISION_FIELDS)
|
||||||
|
if error is not None:
|
||||||
|
return None, error
|
||||||
|
choice = data["choice"]
|
||||||
|
if not isinstance(choice, str) or (
|
||||||
|
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
|
||||||
|
):
|
||||||
|
return None, bad_request("choice must be a candidate hash or unresolved")
|
||||||
|
return {**data}, None
|
||||||
|
|
||||||
|
|
||||||
|
def _link_guards(data: dict):
|
||||||
|
"""The validated manual link fields, or ``(None, error)``."""
|
||||||
|
error = _guard_error(data, _LINK_FIELDS)
|
||||||
|
if error is not None:
|
||||||
|
return None, error
|
||||||
|
if data["capture_token"] is None:
|
||||||
|
return None, bad_request("A link needs a capture of the file")
|
||||||
|
positive, pair = data["positive_id"], data["pair_id"]
|
||||||
|
if type(positive) is not int or positive < 1:
|
||||||
|
return None, bad_request("positive_id must be a positive integer")
|
||||||
|
if pair is not None and (type(pair) is not int or pair < 1):
|
||||||
|
return None, bad_request("pair_id must be a positive integer or null")
|
||||||
|
return {**data}, None
|
||||||
@@ -41,6 +41,13 @@ def _coded(status: int, code: str, message: str) -> web.Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_if_not_found(response: web.Response) -> web.Response:
|
||||||
|
"""A file in a trash folder answers as a missing file does (#543)."""
|
||||||
|
if response.status == 404:
|
||||||
|
return _coded(404, "file-missing", "Image file not found")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _no_store(response: web.Response) -> web.Response:
|
def _no_store(response: web.Response) -> web.Response:
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
@@ -71,7 +78,7 @@ class SourceRescopeMixin:
|
|||||||
path = root / relative
|
path = root / relative
|
||||||
denied = _media_access_error(path, [root])
|
denied = _media_access_error(path, [root])
|
||||||
if denied is not None:
|
if denied is not None:
|
||||||
return denied
|
return _missing_if_not_found(denied)
|
||||||
try:
|
try:
|
||||||
mtime = path.stat().st_mtime if path.is_file() else None
|
mtime = path.stat().st_mtime if path.is_file() else None
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -89,7 +96,7 @@ class SourceRescopeMixin:
|
|||||||
return bad_request("Send exactly one target")
|
return bad_request("Send exactly one target")
|
||||||
target, error = media_target(data["target"])
|
target, error = media_target(data["target"])
|
||||||
if error is not None:
|
if error is not None:
|
||||||
return error
|
return _missing_if_not_found(error)
|
||||||
old_index, scope, relative = target
|
old_index, scope, relative = target
|
||||||
if old_index > MAX_ROOT_INDEX:
|
if old_index > MAX_ROOT_INDEX:
|
||||||
return bad_request("Invalid gallery root")
|
return bad_request("Invalid gallery root")
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from aiohttp import web
|
|||||||
|
|
||||||
from . import thumbnail_settings
|
from . import thumbnail_settings
|
||||||
from .images import (
|
from .images import (
|
||||||
|
in_trash_below_roots,
|
||||||
MEDIA_EXTENSIONS,
|
MEDIA_EXTENSIONS,
|
||||||
THUMBNAIL_CLEAR_TITLE,
|
THUMBNAIL_CLEAR_TITLE,
|
||||||
VIDEO_EXTENSIONS,
|
VIDEO_EXTENSIONS,
|
||||||
@@ -224,6 +225,8 @@ def _quick_source(root, parts):
|
|||||||
for name in (base + extension, base + extension.upper()):
|
for name in (base + extension, base + extension.upper()):
|
||||||
path = folder / name
|
path = folder / name
|
||||||
if _regular_file(path) and _is_within(path, root):
|
if _regular_file(path) and _is_within(path, root):
|
||||||
|
if in_trash_below_roots(path, [root]):
|
||||||
|
return None # also the fallback past the gate's cap (#543)
|
||||||
return path, extension in VIDEO_EXTENSIONS
|
return path, extension in VIDEO_EXTENSIONS
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -248,6 +251,8 @@ def _thumbnail_source(root, parts):
|
|||||||
found = _scan_folder(root.joinpath(*folder_parts), base, set(wanted))
|
found = _scan_folder(root.joinpath(*folder_parts), base, set(wanted))
|
||||||
if found is None or not _is_within(found, root):
|
if found is None or not _is_within(found, root):
|
||||||
return None
|
return None
|
||||||
|
if in_trash_below_roots(found, [root]):
|
||||||
|
return None
|
||||||
rel = Path(*folder_parts, found.name)
|
rel = Path(*folder_parts, found.name)
|
||||||
dst = root / media_thumbnail_rel(rel)
|
dst = root / media_thumbnail_rel(rel)
|
||||||
if dst != _current_preview(root, parts):
|
if dst != _current_preview(root, parts):
|
||||||
@@ -528,6 +533,7 @@ class AutoThumbnailer:
|
|||||||
try:
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# the process has already exited
|
||||||
pass
|
pass
|
||||||
if worker is None:
|
if worker is None:
|
||||||
return True
|
return True
|
||||||
@@ -835,7 +841,7 @@ class AutoThumbnailMixin:
|
|||||||
if not folded.startswith(base) or not _is_within(path, root):
|
if not folded.startswith(base) or not _is_within(path, root):
|
||||||
continue
|
continue
|
||||||
rel = Path(os.path.relpath(path, root))
|
rel = Path(os.path.relpath(path, root))
|
||||||
if "thumbnails" in rel.parts[:-1]:
|
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
|
||||||
return None
|
return None
|
||||||
dst = root / media_thumbnail_rel(rel)
|
dst = root / media_thumbnail_rel(rel)
|
||||||
if not _is_within(root / "thumbnails", root) or not _is_within(
|
if not _is_within(root / "thumbnails", root) or not _is_within(
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ def _unlink_quietly(path):
|
|||||||
try:
|
try:
|
||||||
Path(path).unlink()
|
Path(path).unlink()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
# already gone, which is what was wanted
|
||||||
pass
|
pass
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
|
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
|
||||||
@@ -221,6 +222,7 @@ def shutdown_encoders():
|
|||||||
try:
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# the process has already exited
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -359,10 +361,12 @@ def thumbnail_frame(img, edge):
|
|||||||
try:
|
try:
|
||||||
img.seek(0)
|
img.seek(0)
|
||||||
except (EOFError, AttributeError, ValueError):
|
except (EOFError, AttributeError, ValueError):
|
||||||
|
# not seekable or a single frame: use the current one
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
|
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
|
||||||
except (AttributeError, ValueError, OSError):
|
except (AttributeError, ValueError, OSError):
|
||||||
|
# draft is only a speedup; the full decode below still works
|
||||||
pass
|
pass
|
||||||
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
|
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
|
||||||
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
|
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
|
||||||
|
|||||||
@@ -275,3 +275,4 @@ class WorkflowStorageRoutesMixin:
|
|||||||
job.record_counted("changed", 1)
|
job.record_counted("changed", 1)
|
||||||
job.log_line(line)
|
job.log_line(line)
|
||||||
job.update(progress=100, status_line=line)
|
job.update(progress=100, status_line=line)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ def _write_atomically(path: str, payload: bytes) -> None:
|
|||||||
try:
|
try:
|
||||||
os.unlink(temp_path)
|
os.unlink(temp_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# nothing to clean up; the original error is raised below
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
|
|||||||
relative = Path(settings_file).relative_to(home)
|
relative = Path(settings_file).relative_to(home)
|
||||||
settings_file = os.path.join("~", str(relative))
|
settings_file = os.path.join("~", str(relative))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
# outside the home folder: show the full path
|
||||||
pass
|
pass
|
||||||
return {
|
return {
|
||||||
"source": layout.source,
|
"source": layout.source,
|
||||||
|
|||||||
@@ -585,6 +585,7 @@ def _read_capped(resp, cap: int) -> Optional[bytes]:
|
|||||||
if int(declared) > cap:
|
if int(declared) > cap:
|
||||||
return None
|
return None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
# a bad header says nothing; the read below enforces the cap
|
||||||
pass
|
pass
|
||||||
|
|
||||||
chunks = []
|
chunks = []
|
||||||
@@ -707,6 +708,7 @@ def _save_atomically(image, local_path: Path) -> None:
|
|||||||
try:
|
try:
|
||||||
os.remove(temp)
|
os.remove(temp)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# nothing to clean up; the original error is raised below
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "promptmanager"
|
name = "promptmanager"
|
||||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||||
version = "3.3.1"
|
version = "3.4.0"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate
|
|||||||
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
|
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
|
||||||
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
|
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
|
||||||
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
|
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
|
||||||
const workspace = '/prompt_manager/workspace';
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page, request, browserName }) => {
|
test.beforeEach(async ({ page, request, browserName }) => {
|
||||||
await startCoverage(page, browserName);
|
await startCoverage(page, browserName);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ test.beforeEach(async ({page, browserName}) => {
|
|||||||
page.classicErrors = [];
|
page.classicErrors = [];
|
||||||
page.on('pageerror', error => page.classicErrors.push(error.message));
|
page.on('pageerror', error => page.classicErrors.push(error.message));
|
||||||
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
|
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
|
||||||
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
|
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => (window.fixtureApiBase ?? "") + path, queuePrompt: async (...args) => args};'}));
|
||||||
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
|
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
|
||||||
await page.goto('/prompt_manager/test');
|
await page.goto('/prompt_manager/test');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -64,6 +64,28 @@ test('Classic keeps immediate selection, search query and metadata serialization
|
|||||||
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
|
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Classic Web UI opens the workspace Library in the saved display mode, through the mount prefix (#525)',async({page})=>{
|
||||||
|
await page.evaluate(()=>{window.fixtureApiBase='/mounted';window.open=(...args)=>{window.openArgs=args;return {};};});
|
||||||
|
const webUi=page.getByRole('button',{name:'🌐 Web UI',exact:true});
|
||||||
|
for (const [mode,args] of [
|
||||||
|
['newtab',['/mounted/prompt_manager/workspace#/library','_blank']],
|
||||||
|
['popup',['/mounted/prompt_manager/workspace#/library','_blank','width=1200,height=800,scrollbars=yes,resizable=yes']],
|
||||||
|
]) {
|
||||||
|
await page.evaluate(value=>{node.properties.webuiDisplayMode=value;},mode);
|
||||||
|
await webUi.click();
|
||||||
|
expect(await page.evaluate(()=>window.openArgs)).toEqual(args);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Classic Web UI reports a blocked popup and keeps the canvas open (#525)',async({page})=>{
|
||||||
|
const before=page.url();
|
||||||
|
await page.evaluate(()=>{window.open=()=>null;});
|
||||||
|
await page.getByRole('button',{name:'🌐 Web UI',exact:true}).click();
|
||||||
|
await expect(page.getByText('Allow popups to open Web UI, then try again.')).toBeVisible();
|
||||||
|
expect(page.url()).toBe(before);
|
||||||
|
await expect(page.locator('.pm-classic')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('Classic detaches native text synchronization on removal',async({page})=>{
|
test('Classic detaches native text synchronization on removal',async({page})=>{
|
||||||
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
|
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
|
||||||
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
|
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
|
||||||
|
|||||||
@@ -111,9 +111,23 @@ async function selectTheme(page,theme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The concept at `#<route>` with its own Theme select. */
|
/** The concept at `#<route>` with its own Theme select. */
|
||||||
|
/**
|
||||||
|
* The recorded layout deviations (#530), applied to the opened concept page and never to its files, so every box that
|
||||||
|
* follows from them (wider columns, wrapped text, row heights) is compared rather than excused path by path.
|
||||||
|
* Every other measurement still differs if production moves. workspace-layout-tags-system.spec.mjs pins the real values
|
||||||
|
* and that the concept itself still has the old ones.
|
||||||
|
* D182: the second `.two-col` card starts level, and a surface after a row is 18px below it. D184: no 1000px limit on System. D183: the sticky tag header's background and drawn line.
|
||||||
|
*/
|
||||||
|
export const CONCEPT_LAYOUT_DEVIATIONS=`.two-col > .surface + .surface { margin-top: 0; }
|
||||||
|
.two-col + .surface { margin-top: 18px; }
|
||||||
|
.settings-layout { max-width: none; }
|
||||||
|
.two-col > .surface > .table-wrap thead th { background: var(--ca-surface); border-bottom-color: transparent; }`;
|
||||||
|
export const mapConceptLayout=page=>page.addStyleTag({content:CONCEPT_LAYOUT_DEVIATIONS});
|
||||||
|
|
||||||
export async function openConcept(page,route,theme,motion='no-preference') {
|
export async function openConcept(page,route,theme,motion='no-preference') {
|
||||||
await page.emulateMedia({reducedMotion:motion});
|
await page.emulateMedia({reducedMotion:motion});
|
||||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||||||
|
await mapConceptLayout(page);
|
||||||
await selectTheme(page,theme);
|
await selectTheme(page,theme);
|
||||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -421,7 +435,8 @@ export function measureInventory(scope) {
|
|||||||
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
||||||
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
||||||
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
||||||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
// D163: a closed menu's items are not heading actions.
|
||||||
|
buttons:[...actions.querySelectorAll('button')].filter(node=>!node.closest('[role="menu"]')).map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||||||
}
|
}
|
||||||
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
// browser that exits abnormally prints one `pm-browser-exit|` line, and a test
|
// 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
|
// that fails on a closed or crashed target gets that log attached as
|
||||||
// `browser-stderr`.
|
// `browser-stderr`.
|
||||||
|
// The one-time older-images notice (#527) shows above every workspace page and as
|
||||||
|
// a modal on the admin page until dismissed, so every context starts with it
|
||||||
|
// dismissed. A spec about the notice sets `test.use({ olderImagesNotice: true })`.
|
||||||
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
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 { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||||
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
|
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
|
||||||
@@ -44,6 +47,11 @@ export const test = base.extend({
|
|||||||
if (crash) throw crash;
|
if (crash) throw crash;
|
||||||
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||||
baseURL: async ({ workerServer }, use) => use(workerServer.url),
|
baseURL: async ({ workerServer }, use) => use(workerServer.url),
|
||||||
|
olderImagesNotice: [false, { option: true }],
|
||||||
|
storageState: async ({ baseURL, olderImagesNotice }, use) => use(olderImagesNotice ? undefined : {
|
||||||
|
cookies: [],
|
||||||
|
origins: [{ origin: new URL(baseURL).origin, localStorage: [{ name: 'pm_update_notice_dismissed', value: '3.2.0' }] }],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export { expect };
|
export { expect };
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ test(`${variant} respects the existing Web UI display preference`,async({page})=
|
|||||||
});
|
});
|
||||||
await page.evaluate(()=>node._focusedSettingsReady);
|
await page.evaluate(()=>node._focusedSettingsReady);
|
||||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
|
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/workspace#/library','_blank']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
|
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
|
||||||
|
|||||||
@@ -362,10 +362,14 @@ test('Show in folder asks the server to open the listed file and announces each
|
|||||||
const body=sent.postDataJSON();
|
const body=sent.postDataJSON();
|
||||||
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
|
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
|
||||||
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
|
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
|
||||||
await expect(toast).toHaveText('Opened the folder on this computer.');
|
await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
|
||||||
|
// #533: the answer is really on screen and reachable above the modal viewer, not covered by it.
|
||||||
|
await expect(toast).toBeVisible();
|
||||||
|
expect(await toast.evaluate(node=>{const box=node.getBoundingClientRect();const top=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);return top===node;})).toBe(true);
|
||||||
|
await expect(page.locator('#viewer-dialog').getByRole('status').filter({hasText:'Opened the folder on the computer running ComfyUI.'})).toHaveCount(1);
|
||||||
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
|
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
|
||||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||||
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
|
for (const [outcome,message] of [['unavailable','No file manager is available on the computer running ComfyUI.'],['fail','The folder could not be opened. Try again.']]) {
|
||||||
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
|
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
|
||||||
await show.click();await expect(toast).toHaveText(message);
|
await show.click();await expect(toast).toHaveText(message);
|
||||||
}
|
}
|
||||||
@@ -373,11 +377,56 @@ test('Show in folder asks the server to open the listed file and announces each
|
|||||||
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
|
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
|
||||||
await show.click();await expect(toast).toHaveText('The folder is already opening.');
|
await show.click();await expect(toast).toHaveText('The folder is already opening.');
|
||||||
await page.unroute('**/prompt_manager/images/reveal');
|
await page.unroute('**/prompt_manager/images/reveal');
|
||||||
// A proxied request counts as remote, so the real route answers 403 not-local.
|
// A proxied click opens the folder: the proxy's headers, not the browser's address, name the page (#533).
|
||||||
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
|
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
|
||||||
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
|
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
|
||||||
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
|
await show.click();await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
|
||||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
expect(await opened()).toHaveLength(2);
|
||||||
|
await page.unroute('**/prompt_manager/images/reveal');
|
||||||
|
// A plain cross-site POST is refused, and the toast says so.
|
||||||
|
// Chromium keeps its own Origin on a routed fetch, so the real route is asked directly and the toast gets its answer.
|
||||||
|
const refused=await request.post('/prompt_manager/images/reveal',{data:body,headers:{origin:'https://evil.example'}});
|
||||||
|
expect(refused.status()).toBe(403);expect((await refused.json()).code).toBe('not-same-origin');
|
||||||
|
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:403,contentType:'application/json',body:JSON.stringify({success:false,code:'not-same-origin',error:"This page's address does not match the server's."})}));
|
||||||
|
await show.click();await expect(toast).toHaveText("The folder was not opened: this page's address does not match the server's.");
|
||||||
|
await expect(toast).toBeVisible();
|
||||||
|
expect(await opened()).toHaveLength(2);
|
||||||
|
// #533: closing the viewer takes the toast back to the page, still showing the message.
|
||||||
|
await page.keyboard.press('Escape');await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||||
|
await expect(toast).toBeVisible();await expect(page.locator('body > #workspace-toast')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
// #533 D176: real modals in the real top layer, with the page's own toast module.
|
||||||
|
test('a toast stays on screen across nested, switched and closing modals',async({page,request})=>{
|
||||||
|
await fullFixture(page,request);
|
||||||
|
await page.evaluate(async()=>{
|
||||||
|
const url=performance.getEntriesByType('resource').map(entry=>entry.name).find(name=>name.includes('/toast.mjs'));
|
||||||
|
const {showWorkspaceToast}=await import(url);
|
||||||
|
window.say=showWorkspaceToast;
|
||||||
|
window.modal=(id,first=false)=>{const dialog=document.createElement('dialog');dialog.id=id;dialog.textContent=id;document.body[first?'prepend':'append'](dialog);dialog.showModal();return dialog;};
|
||||||
|
window.onTop=()=>{const node=document.getElementById('workspace-toast');const box=node.getBoundingClientRect();return box.width>0&&document.elementFromPoint(box.x+box.width/2,box.y+box.height/2)===node;};
|
||||||
|
});
|
||||||
|
const parent=()=>page.evaluate(()=>{const host=document.getElementById('workspace-toast')?.parentElement;return host?host.id||host.localName:null;});
|
||||||
|
const onTop=()=>page.evaluate(()=>window.onTop());
|
||||||
|
await page.evaluate(()=>{window.modal('a');say('one');});
|
||||||
|
expect(await parent()).toBe('a');expect(await onTop()).toBe(true);
|
||||||
|
// Nested: the top one holds it, even earlier in the page than the one under it.
|
||||||
|
await page.evaluate(()=>{window.modal('b',true);say('two');});
|
||||||
|
expect(await parent()).toBe('b');expect(await onTop()).toBe(true);
|
||||||
|
// The top one closes while the toast shows: the message stays, in the modal now on top.
|
||||||
|
await page.evaluate(()=>document.getElementById('b').close());
|
||||||
|
await expect.poll(parent).toBe('a');expect(await onTop()).toBe(true);
|
||||||
|
await expect(page.locator('#workspace-toast')).toHaveText('two');
|
||||||
|
// Switched: the next modal takes the next toast.
|
||||||
|
await page.evaluate(()=>{document.getElementById('a').close();window.modal('c');say('three');});
|
||||||
|
expect(await parent()).toBe('c');expect(await onTop()).toBe(true);
|
||||||
|
// Closed and removed at once, as the workspace's dialogs do: home, still showing.
|
||||||
|
await page.evaluate(()=>{const c=document.getElementById('c');c.close();c.remove();});
|
||||||
|
await expect.poll(parent).toBe('body');expect(await onTop()).toBe(true);
|
||||||
|
await expect(page.locator('#workspace-toast')).toHaveText('three');
|
||||||
|
// Removed while open, with no close event: the next message still shows.
|
||||||
|
await page.evaluate(()=>{window.modal('d');say('four');document.getElementById('d').remove();say('five');});
|
||||||
|
expect(await parent()).toBe('body');expect(await onTop()).toBe(true);
|
||||||
|
await expect(page.locator('#workspace-toast')).toHaveText('five');
|
||||||
});
|
});
|
||||||
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
|
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
|
||||||
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
|
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
|
||||||
@@ -415,7 +464,7 @@ test('full inspector preserves long role text and exact sends',async({page,reque
|
|||||||
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
||||||
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||||
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
|
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
|
||||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
|
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair Exact pair'});
|
||||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||||
expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]);
|
expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]);
|
||||||
} finally {await host.evaluate(()=>window.bridge.dispose());await host.close();}
|
} finally {await host.evaluate(()=>window.bridge.dispose());await host.close();}
|
||||||
@@ -627,6 +676,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(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');
|
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));
|
production.paths=production.paths.filter(path=>!modePaths.includes(path));
|
||||||
|
// #532 D170: production's shortcuts button sits right before the view switch; the concept has none.
|
||||||
|
const keysPaths=production.paths.filter(path=>path.includes('button#viewer-keys'));
|
||||||
|
expect(keysPaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-keys.icon-btn','button#viewer-keys.icon-btn > svg.icon','button#viewer-keys.icon-btn > svg.icon > path']);
|
||||||
|
production.paths=production.paths.filter(path=>!keysPaths.includes(path));
|
||||||
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
|
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
|
||||||
expect(production.toolbar[8].text).toBe('Delete…');
|
expect(production.toolbar[8].text).toBe('Delete…');
|
||||||
production.toolbar.splice(8,1);
|
production.toolbar.splice(8,1);
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export async function exerciseCurrentPairSend(page, roles) {
|
|||||||
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click();
|
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click();
|
||||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
|
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
|
||||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
|
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
|
||||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
|
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#canonical-pair Canonical destination' });
|
||||||
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||||
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
|
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
|
||||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const SECTIONS=['heading','history','noPageScroll'];
|
|||||||
*/
|
*/
|
||||||
export function mapHistoryConcept(reference) {
|
export function mapHistoryConcept(reference) {
|
||||||
const {history}=reference.oracle;
|
const {history}=reference.oracle;
|
||||||
return {...reference,history:{...history,
|
// D163 (#512): the heading as laid out with the More menu button (the oracle's heading).
|
||||||
|
return {...reference,heading:reference.oracle.heading,history:{...history,
|
||||||
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
|
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
|
||||||
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
|
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
|
||||||
summary:history.summary,
|
summary:history.summary,
|
||||||
@@ -120,11 +121,14 @@ async function conceptAsRecorded(page) {
|
|||||||
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
|
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
|
||||||
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
|
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
|
||||||
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
|
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
|
||||||
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
|
// D163 (#512): the heading ends with the "⋯" More menu button, drawn as the concept's Edit metadata button.
|
||||||
|
const edit=[...document.querySelectorAll('#main > .heading .heading-actions > button')].find(node=>node.textContent.trim()==='Edit metadata');
|
||||||
|
const moreButton=edit.cloneNode(false);moreButton.removeAttribute('data-action');moreButton.textContent='⋯';edit.parentElement.append(moreButton);
|
||||||
|
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived,moreButton};
|
||||||
},SHOW_ARCHIVED);
|
},SHOW_ARCHIVED);
|
||||||
await settle(page);
|
await settle(page);
|
||||||
return ()=>page.evaluate(()=>{
|
return ()=>page.evaluate(()=>{
|
||||||
const {timeline,order,texts,archived}=window.__historyOracle;
|
const {timeline,order,texts,archived,moreButton}=window.__historyOracle;moreButton.remove();
|
||||||
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
||||||
for(const [span,text] of texts)span.textContent=text;
|
for(const [span,text] of texts)span.textContent=text;
|
||||||
archived.remove();delete window.__historyOracle;
|
archived.remove();delete window.__historyOracle;
|
||||||
@@ -190,7 +194,7 @@ function productionSent(host) {
|
|||||||
return async page=>{
|
return async page=>{
|
||||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
|
||||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||||
|
|||||||
@@ -474,7 +474,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
|||||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
|
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','Delete…','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(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||||
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
// D186: the panel's sticky offset clears the shown bar; at one column the bar is one sideways row (its items are checked above).
|
||||||
|
const stuckPanel={...oracle,panel:{...oracle.panel,...(width<=700?{top:actual.panel.top}:{}),box:{...oracle.panel.box,top:actual.panel.box.top}}};
|
||||||
|
expect(parseFloat(actual.panel.box.top)).toBeGreaterThanOrEqual(20);
|
||||||
|
expect.soft(compare(stuckPanel,actual,manifest,width<=700?['panel']:['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||||
// The bar comparison notices a change to the production bar.
|
// The bar comparison notices a change to the production bar.
|
||||||
await mutate(page,{css:'.bulkbar { padding: 4px }'});
|
await mutate(page,{css:'.bulkbar { padding: 4px }'});
|
||||||
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {test,expect} from './fixtures.mjs';
|
||||||
|
import {seedConceptAt,openConcept,openProduction} from './concept-inventory.mjs';
|
||||||
|
|
||||||
|
// #498 (D162): the concept has no Show removed images screen, so production builds it from the concept's own parts:
|
||||||
|
// the heading action button (as the concept's Images heading button), the card's `.chip` (as a concept card chip) and
|
||||||
|
// the selection bar's buttons (as the concept bar's plain buttons). Each is compared with its concept counterpart.
|
||||||
|
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||||
|
|
||||||
|
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||||
|
const KEYS=['color','backgroundColor','borderTopColor','borderTopWidth','fontSize','fontWeight','paddingTop','paddingLeft',
|
||||||
|
'borderTopLeftRadius','height'];
|
||||||
|
const CHIP_KEYS=['color','backgroundColor','borderTopColor','fontSize','fontWeight','paddingTop','paddingLeft',
|
||||||
|
'borderTopLeftRadius'];
|
||||||
|
|
||||||
|
/** Runs in the page: the computed style of the first node `selector` matches. */
|
||||||
|
function styleOf([selector,keys]) {
|
||||||
|
const node=document.querySelector(selector);
|
||||||
|
return node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measure(page,selectors) {
|
||||||
|
const found={};
|
||||||
|
for (const [name,selector,keys] of selectors) found[name]=await page.evaluate(styleOf,[selector,keys]);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async({page,request})=>{await seedConceptAt(page,request);});
|
||||||
|
|
||||||
|
for (const theme of ['dark','light']) {
|
||||||
|
test(`Show removed images uses the concept's heading button, chip and bar buttons (${theme})`,async({page})=>{
|
||||||
|
await page.setViewportSize({width:1600,height:1100});
|
||||||
|
await openConcept(page,'images',theme);
|
||||||
|
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||||
|
await page.mouse.move(0,0);
|
||||||
|
const reference=await measure(page,[
|
||||||
|
['heading','#main .heading-actions > button:not(.ca-primary)',KEYS],
|
||||||
|
['chip','#main .chip',CHIP_KEYS],
|
||||||
|
['bar','#main #bulkbar > .bulkbar > button:not(.danger)',KEYS],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await openProduction(page,'images',theme);
|
||||||
|
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||||
|
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||||
|
await page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Delete…',exact:true}).click();
|
||||||
|
const dialog=page.locator('dialog#modal[open]');
|
||||||
|
await dialog.getByRole('radio',{name:/^Remove from the library only/}).check();
|
||||||
|
await dialog.locator('.dialog-actions button.danger').click();
|
||||||
|
const toggle=page.locator('.heading-actions').getByRole('button',{name:/^Show removed images \(1\)$/});
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
await page.mouse.move(0,0);
|
||||||
|
const heading=await measure(page,[['heading','#workspace-main .heading-actions > button[aria-pressed]',KEYS]]);
|
||||||
|
await toggle.click();
|
||||||
|
await expect(page.locator(`${CARDS} .chip`,{hasText:'Removed'})).toBeVisible();
|
||||||
|
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||||
|
await expect(page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Return to library'})).toBeVisible();
|
||||||
|
await page.mouse.move(0,0);
|
||||||
|
const actual={...heading,...await measure(page,[
|
||||||
|
['chip',`${CARDS} .media-info .chip`,CHIP_KEYS],
|
||||||
|
['bar','#workspace-main #bulkbar > .bulkbar > button',KEYS],
|
||||||
|
])};
|
||||||
|
// Every part was found on both sides: two missing nodes must never compare equal.
|
||||||
|
expect(Object.values(reference).every(Boolean)).toBe(true);
|
||||||
|
expect(actual).toEqual(reference);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -259,7 +259,7 @@ async function productionSendOf(page,context) {
|
|||||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
||||||
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||||
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
|
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
|
||||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
|
||||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
||||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// A job's end toast follows the job, which has no time limit: under load it outlasts the 5 s expect default (#533, as
|
||||||
|
// #520). Do `action`, whose POST to `url` answers with the job it started, then wait for that job's event stream to end.
|
||||||
|
export const APPLY_URL = '/prompt_manager/maintenance/apply';
|
||||||
|
export const RETURN_URL = '/prompt_manager/images/removed/return';
|
||||||
|
|
||||||
|
export async function startAndAwaitJob(page, action, url = APPLY_URL) {
|
||||||
|
const applied = page.waitForResponse(response => response.url().endsWith(url));
|
||||||
|
await action();
|
||||||
|
const { job } = await (await applied).json();
|
||||||
|
const events = await page.request.get(`/prompt_manager/jobs/${job.id}/events`);
|
||||||
|
if (!events.ok()) throw new Error(`Job ${job.id} events answered ${events.status()}`);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
@@ -233,7 +233,7 @@ function productionSent(host) {
|
|||||||
return async page=>{
|
return async page=>{
|
||||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
|
||||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||||
@@ -374,7 +374,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
|
|||||||
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
|
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
|
||||||
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
|
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
|
||||||
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
|
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
|
||||||
expect(actual).toEqual(reference);
|
// D186: at one column the bar is one row that scrolls sideways, with the same buttons.
|
||||||
|
const mapped=width<=700?{...reference,bar:{...reference.bar,flexWrap:'nowrap',buttons:reference.bar.buttons.map((button,index)=>({...button,left:actual.bar.buttons[index].left,top:actual.bar.buttons[index].top}))}}:reference;
|
||||||
|
expect(actual).toEqual(mapped);
|
||||||
// The comparison notices a moved checkbox (spec §7 mutation check).
|
// The comparison notices a moved checkbox (spec §7 mutation check).
|
||||||
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
|
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
|
||||||
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
|
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ function mapDeleteConcept({ danger, review }) {
|
|||||||
['Backup', 'Create and verify before deleting']] },
|
['Backup', 'Create and verify before deleting']] },
|
||||||
notes: [
|
notes: [
|
||||||
{ ...note, text: 'Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the '
|
{ ...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, '
|
+ 'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them '
|
||||||
+ 'select them and press Restore.' },
|
+ 'and press Delete… or Restore.' },
|
||||||
// A second paragraph takes the concept's own `p + p` margin (style.css 93-95).
|
// 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 "
|
{ ...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 '
|
+ 'image links. Deleted prompts cannot be brought back one by one: the only way back is to restore the safety '
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function chooseDestination(page) {
|
|||||||
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||||
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
|
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
|
||||||
await host.selectOption({label:'Test ComfyUI'});
|
await host.selectOption({label:'Test ComfyUI'});
|
||||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
|
||||||
return sender;
|
return sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +165,8 @@ test('host apply rejection retains the current text and requires a new preview',
|
|||||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||||
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
|
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
|
||||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
// D175: a refusal wrote nothing, so Apply may try again.
|
||||||
|
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();
|
||||||
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
|
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
|
||||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||||
});
|
});
|
||||||
@@ -193,7 +194,7 @@ test('empty negative send clears only the negative destination and preserves its
|
|||||||
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
|
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
|
||||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||||
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||||
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
|
const sender=dialog(page);await expect(sender).toContainText('No ComfyUI window answered in this browser');
|
||||||
const host=await fakeHost(context,page);
|
const host=await fakeHost(context,page);
|
||||||
try {
|
try {
|
||||||
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
|
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ async function openReview(page, theme) {
|
|||||||
await openProduction(page, 'review', theme);
|
await openProduction(page, 'review', theme);
|
||||||
await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
|
await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
// D179, D181: the two buttons the concept lacks are pinned here, then left out of the comparison.
|
||||||
|
await expect(panel(page).getByRole('button', { name: 'Leave unresolved', exact: true })).toBeVisible();
|
||||||
|
await expect(panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true })).toBeVisible();
|
||||||
|
await page.addStyleTag({ content: '.review-panel .review-leave-unresolved, .review-panel .review-link-open { display: none !important; }' });
|
||||||
}
|
}
|
||||||
async function settle(page) {
|
async function settle(page) {
|
||||||
await page.mouse.move(0, 0);
|
await page.mouse.move(0, 0);
|
||||||
@@ -90,7 +94,7 @@ for (const [width, height] of SIZES) for (const theme of THEMES) {
|
|||||||
expect(surface.children[1].text).toBe(CONCEPT_FILE);
|
expect(surface.children[1].text).toBe(CONCEPT_FILE);
|
||||||
const actions = surface.children.at(-1);
|
const actions = surface.children.at(-1);
|
||||||
expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]);
|
expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]);
|
||||||
expect(node(surface, 'select').options.at(-1)).toEqual(['Leave unresolved', false]);
|
expect(node(surface, 'select').options.map(option => option[0])).not.toContain('Leave unresolved'); // D179
|
||||||
expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text))
|
expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text))
|
||||||
.toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']);
|
.toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']);
|
||||||
expect(actual.noPageScroll).toBe(true);
|
expect(actual.noPageScroll).toBe(true);
|
||||||
@@ -169,7 +173,7 @@ test('the comparison notices a dropped notice border and reordered actions', asy
|
|||||||
const reordered = await productionInventory(page);
|
const reordered = await productionInventory(page);
|
||||||
expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([
|
expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([
|
||||||
'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary']));
|
'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary']));
|
||||||
await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild));
|
await panel(page).locator('.dialog-actions').evaluate(row => row.append(row.firstElementChild));
|
||||||
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
|
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ class ResetWatch:
|
|||||||
stream.write(text)
|
stream.write(text)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
# the stream closed; a watcher must not fail the server
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ function measureSystem(mainSelector) {
|
|||||||
const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect();
|
const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect();
|
||||||
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
|
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
|
||||||
};
|
};
|
||||||
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. This pins the
|
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. D182 (#530)
|
||||||
// concept's `.surface + .surface` 18px offset, which concept.css applies inside `.two-col` too (copied, not ruled).
|
// levels the second `.two-col` card, on both sides through mapConceptLayout; the real values are in workspace-layout-tags-system.spec.
|
||||||
const top = node => {
|
const top = node => {
|
||||||
const rect = node.getBoundingClientRect(), previous = node.previousElementSibling?.getBoundingClientRect();
|
const rect = node.getBoundingClientRect(), previous = node.previousElementSibling?.getBoundingClientRect();
|
||||||
if (previous && rect.top >= previous.bottom) return Math.round(rect.top - previous.bottom);
|
if (previous && rect.top >= previous.bottom) return Math.round(rect.top - previous.bottom);
|
||||||
@@ -165,6 +165,8 @@ const number = value => value.toLocaleString('en-US');
|
|||||||
|
|
||||||
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
||||||
function mapOverviewConcept(reference, overview) {
|
function mapOverviewConcept(reference, overview) {
|
||||||
|
// D165: the discreet legacy-pages line follows the settings layout.
|
||||||
|
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
|
||||||
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
|
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
|
||||||
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
|
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
|
||||||
const rows = {
|
const rows = {
|
||||||
@@ -257,7 +259,7 @@ const UNMAPPED_PATHS = [
|
|||||||
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
|
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
|
||||||
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
||||||
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
||||||
'save.button.cursor', 'save.button.opacity', 'save.status.text',
|
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
test('system comparison detects mutations', async ({ page, request }) => {
|
test('system comparison detects mutations', async ({ page, request }) => {
|
||||||
@@ -683,6 +685,8 @@ async function watchedSources(request) {
|
|||||||
|
|
||||||
for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) {
|
for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) {
|
||||||
const variant = notice ? 'with' : 'without';
|
const variant = notice ? 'with' : 'without';
|
||||||
|
test.describe(() => {
|
||||||
|
test.use({ olderImagesNotice: notice });
|
||||||
test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||||
await page.setViewportSize({ width, height });
|
await page.setViewportSize({ width, height });
|
||||||
const server = await watchedSources(request);
|
const server = await watchedSources(request);
|
||||||
@@ -693,8 +697,11 @@ for (const theme of THEMES) for (const [width, height] of SIZES) for (const noti
|
|||||||
expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]);
|
expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]);
|
||||||
expect(production.pageScroll).toBe(false);
|
expect(production.pageScroll).toBe(false);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test.describe(() => {
|
||||||
|
test.use({ olderImagesNotice: true });
|
||||||
test('sources comparison detects mutations', async ({ page, request }) => {
|
test('sources comparison detects mutations', async ({ page, request }) => {
|
||||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||||
const server = await watchedSources(request);
|
const server = await watchedSources(request);
|
||||||
@@ -737,6 +744,7 @@ test('sources comparison detects mutations', async ({ page, request }) => {
|
|||||||
await undoToken();
|
await undoToken();
|
||||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title,
|
* Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function measureIntegrations(mainSelector) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEND_REASON = 'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.';
|
const SEND_REASON = 'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.';
|
||||||
const RENDERER_REASON = 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.';
|
const RENDERER_REASON = 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.';
|
||||||
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
|
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
|
||||||
const RETURN_REASON = 'Not available: sending applies the text in ComfyUI and keeps this page open.';
|
const RETURN_REASON = 'Not available: sending applies the text in ComfyUI and keeps this page open.';
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ export function mapConceptReview(reference,actual) {
|
|||||||
const actualActions=findNode(actual.main,'div.dialog-actions');
|
const actualActions=findNode(actual.main,'div.dialog-actions');
|
||||||
let cardIndex=0;
|
let cardIndex=0;
|
||||||
let main={...reference.main,id:actual.main.id};
|
let main={...reference.main,id:actual.main.id};
|
||||||
|
// D188: the Resolve panel is sticky.
|
||||||
|
const actualPanel=findNode(actual.main,'div.gallery-layout')?.children[1];
|
||||||
|
if(actualPanel)main=editNodes(main,'div.gallery-layout',layout=>({...layout,children:layout.children.map((child,index)=>index===1?{...child,position:actualPanel.position}:child)}));
|
||||||
let noticeLoss=0;
|
let noticeLoss=0;
|
||||||
main=editNodes(main,'div.notice',notice=>{
|
main=editNodes(main,'div.notice',notice=>{
|
||||||
const chip=notice.children.find(child=>child.tag==='span.chip');
|
const chip=notice.children.find(child=>child.tag==='span.chip');
|
||||||
@@ -279,7 +282,7 @@ export function mapConceptReview(reference,actual) {
|
|||||||
});
|
});
|
||||||
main=editNodes(main,'select',select=>{
|
main=editNodes(main,'select',select=>{
|
||||||
const twin=findNode(actual.main,'select');
|
const twin=findNode(actual.main,'select');
|
||||||
return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select;
|
return select.id==='review-context'?{...select,options:twin.options}:select; // D179: no Leave unresolved option
|
||||||
});
|
});
|
||||||
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
|
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
|
||||||
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
|
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {writeFile} from 'node:fs/promises';
|
import {writeFile} from 'node:fs/promises';
|
||||||
import {test,expect} from './fixtures.mjs';
|
import {test,expect} from './fixtures.mjs';
|
||||||
|
import {mapConceptLayout} from './concept-inventory.mjs';
|
||||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,compareVocabularyPage} from './vocabulary-canonical-comparison.mjs';
|
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,compareVocabularyPage} from './vocabulary-canonical-comparison.mjs';
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ const PREVIEW='“Landscapes” is used by 3 positive prompts and 2 images. Appl
|
|||||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||||
async function openConceptRoute(page,route,theme) {
|
async function openConceptRoute(page,route,theme) {
|
||||||
await page.goto('about:blank');await page.goto(`${concept}#${route}`);
|
await page.goto('about:blank');await page.goto(`${concept}#${route}`);
|
||||||
|
await mapConceptLayout(page);
|
||||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||||
}
|
}
|
||||||
/** The concept page as D143 records it: image counts after the prompt counts, image-only rows before Manage category. */
|
/** The concept page as D143 records it: image counts after the prompt counts, image-only rows before Manage category. */
|
||||||
|
|||||||
@@ -764,6 +764,11 @@ test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', as
|
|||||||
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
|
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
|
||||||
const viewer = page.locator('#viewer-dialog[open]');
|
const viewer = page.locator('#viewer-dialog[open]');
|
||||||
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
|
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
|
||||||
|
// #532: the arrows move through the collection from the first moment.
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(viewer.locator('#viewer-title')).toContainText('landscape_02.png');
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
|
||||||
// Review L7: no listing request while the viewer is open.
|
// Review L7: no listing request while the viewer is open.
|
||||||
let listings = 0;
|
let listings = 0;
|
||||||
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
|
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ test('every ordered receipt remains selected through distinct current locations,
|
|||||||
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
|
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
|
||||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||||
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
|
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
|
||||||
|
// #532: the arrows work over the recorded run from the first moment.
|
||||||
|
await page.keyboard.press('ArrowRight');await expect(viewer).toContainText('2 of 2 loaded');await page.keyboard.press('ArrowLeft');await expect(viewer).toContainText('1 of 2 loaded');
|
||||||
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
|
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
|
||||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
|
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
|
||||||
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
|
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
|
||||||
@@ -468,7 +470,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
|||||||
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||||
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
|
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
|
||||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
|
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
|
||||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#history-pair History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||||
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
|
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
|
||||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
|
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||||
import { test, expect } from './fixtures.mjs';
|
import { test, expect } from './fixtures.mjs';
|
||||||
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
|
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
|
||||||
|
import { startAndAwaitJob } from './job-end.mjs';
|
||||||
|
|
||||||
// #480 (D159): Images › Delete…: the review asks every time (nothing preselected), "Remove from the library only"
|
// #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).
|
// hides the file and keeps it on disk, "Also move the files to the trash" moves it (the server's fake trash here).
|
||||||
@@ -62,7 +63,7 @@ for (const mode of ['library', 'trash']) {
|
|||||||
const { dialog, primary } = await deleteChecked(page, mode);
|
const { dialog, primary } = await deleteChecked(page, mode);
|
||||||
await expect(dialog.locator('dt', { hasText: 'Media on disk' })).toBeVisible();
|
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 expect(primary).toHaveText(mode === 'trash' ? 'Move 1 image to the trash' : 'Remove 1 image from the library');
|
||||||
await primary.click();
|
await startAndAwaitJob(page, () => primary.click());
|
||||||
await expect(toast(page)).toContainText(mode === 'trash' ? 'Moved 1 image to the trash.' : 'Removed 1 image from the library.');
|
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}[data-image-key="${key}"]`)).toHaveCount(0);
|
||||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||||
@@ -81,11 +82,25 @@ test('a finished delete re-reads the sidebar Images count', async ({ page }) =>
|
|||||||
const before = Number((await badge.textContent()).replace(/,/g, ''));
|
const before = Number((await badge.textContent()).replace(/,/g, ''));
|
||||||
await check(page, 0);
|
await check(page, 0);
|
||||||
const { primary } = await deleteChecked(page, 'library');
|
const { primary } = await deleteChecked(page, 'library');
|
||||||
await primary.click();
|
await startAndAwaitJob(page, () => primary.click());
|
||||||
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
||||||
await expect(badge).toHaveText((before - 1).toLocaleString('en-US'));
|
await expect(badge).toHaveText((before - 1).toLocaleString('en-US'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #533: the toast says "started" for as long as the job's end has not reached the page, then says how it ended.
|
||||||
|
test('a slow job keeps its started toast until the end arrives', async ({ page }) => {
|
||||||
|
await openImages(page);
|
||||||
|
await check(page, 0);
|
||||||
|
let release;
|
||||||
|
const held = new Promise(resolve => { release = resolve; });
|
||||||
|
await page.route('**/prompt_manager/jobs/*/events', async route => { await held; await route.continue(); });
|
||||||
|
const { primary } = await deleteChecked(page, 'library');
|
||||||
|
await primary.click();
|
||||||
|
await expect(toast(page)).toHaveText('Delete images (remove from the library) started.');
|
||||||
|
release();
|
||||||
|
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
||||||
|
});
|
||||||
|
|
||||||
test('Cancel changes nothing', async ({ page }) => {
|
test('Cancel changes nothing', async ({ page }) => {
|
||||||
const before = await openImages(page);
|
const before = await openImages(page);
|
||||||
await check(page, 0);
|
await check(page, 0);
|
||||||
@@ -169,13 +184,66 @@ test('without a chosen mode nothing can start a delete', async ({ page }) => {
|
|||||||
expect(applies).toEqual([]);
|
expect(applies).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #543 (D190): a review where nothing can move shows a primary that clearly cannot act: no danger colors, ignored presses.
|
||||||
|
test('a trash review with nothing to move has a clearly disabled primary', async ({ page }) => {
|
||||||
|
const before = await openImages(page);
|
||||||
|
await check(page, 0);
|
||||||
|
await page.route('**/images/delete/preview', async route => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const plan = await response.json();
|
||||||
|
const reason = "This drive's trash is inside an output folder.";
|
||||||
|
await route.fulfill({ response, json: {
|
||||||
|
...plan,
|
||||||
|
trash: { count: 0 },
|
||||||
|
trash_refused: { count: 1, counts: { 'trash-in-root': 1 }, rows: [{ name: 'a.png', code: 'trash-in-root', detail: reason }] },
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
const applies = [];
|
||||||
|
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.url()); });
|
||||||
|
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||||
|
const dialog = review(page);
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('radio', { name: /^Also move the files to the trash/ }).check();
|
||||||
|
const primary = dialog.locator('.dialog-actions button.danger');
|
||||||
|
await expect(primary).toHaveText('Move 0 images to the trash');
|
||||||
|
await expect(primary).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect(primary).toHaveCSS('cursor', 'not-allowed');
|
||||||
|
const look = element => { const style = getComputedStyle(element); return [style.color, style.borderTopColor]; };
|
||||||
|
const cancel = dialog.getByRole('button', { name: 'Cancel', exact: true });
|
||||||
|
const [color, border] = await primary.evaluate(look);
|
||||||
|
const [cancelColor] = await cancel.evaluate(look);
|
||||||
|
const red = await page.evaluate(() => {
|
||||||
|
const probe = document.createElement('i');
|
||||||
|
probe.style.color = 'var(--red)';
|
||||||
|
document.querySelector('.ca-tools').append(probe);
|
||||||
|
const value = getComputedStyle(probe).color;
|
||||||
|
probe.remove();
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
expect(color).not.toBe(red);
|
||||||
|
expect(border).not.toBe(red);
|
||||||
|
expect(color).not.toBe(cancelColor);
|
||||||
|
await primary.hover({ force: true });
|
||||||
|
await expect(primary).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
|
||||||
|
await primary.click({ force: true });
|
||||||
|
await primary.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect(applies).toEqual([]);
|
||||||
|
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
|
||||||
|
await expect(primary).not.toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect(primary).toHaveCSS('color', red);
|
||||||
|
await cancel.click();
|
||||||
|
await expect(page.locator(CARDS)).toHaveCount(before);
|
||||||
|
});
|
||||||
|
|
||||||
test('a chosen mode starts exactly one delete, with that mode, however often it is pressed', async ({ page }) => {
|
test('a chosen mode starts exactly one delete, with that mode, however often it is pressed', async ({ page }) => {
|
||||||
const before = await openImages(page);
|
const before = await openImages(page);
|
||||||
await check(page, 0);
|
await check(page, 0);
|
||||||
const applies = [];
|
const applies = [];
|
||||||
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.postDataJSON()); });
|
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.postDataJSON()); });
|
||||||
const { primary } = await deleteChecked(page, 'library');
|
const { primary } = await deleteChecked(page, 'library');
|
||||||
await primary.dblclick();
|
await startAndAwaitJob(page, () => primary.dblclick());
|
||||||
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
||||||
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
await expect(page.locator(CARDS)).toHaveCount(before - 1);
|
||||||
expect(applies.length).toBe(1);
|
expect(applies.length).toBe(1);
|
||||||
|
|||||||