Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fabd87e1bc | ||
|
|
9d183a26f6 | ||
|
|
ca5dc5192e | ||
|
|
1050deffed | ||
|
|
efe05a6a6f | ||
|
|
d605a6a758 | ||
|
|
1d4446082f |
@@ -36,11 +36,6 @@ concurrency:
|
||||
# 12m53s, node units and the viewer suite before 11.8m of workflows), so coverage is the tight one
|
||||
# in its 18 minutes. This holds only when the npm cache hits: a cold cache adds up to 5m to npm ci,
|
||||
# and then the job limit can still end the job before the step limit does.
|
||||
# #473: firefox/webkit shard 1/2 reached its 18-minute step limit (queue run 40866252975: webkit 622 tests
|
||||
# passed in 17m56s, 48.5 worker-minutes vs 28.0 in shard 2/2; recent shard 1/2 jobs 13.7m-18.9m, shard 2/2
|
||||
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
|
||||
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
|
||||
# re-measured after three green main runs.
|
||||
jobs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (shard ${{ matrix.shard }}/3)
|
||||
@@ -180,7 +175,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
browser-workflows:
|
||||
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/3)
|
||||
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
|
||||
# Merge queue, main and manual runs only (#420); pull requests run the Chromium/touch coverage shards.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -199,7 +194,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [firefox, webkit]
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
@@ -226,9 +221,9 @@ jobs:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/3
|
||||
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
|
||||
timeout-minutes: 18
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/3
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
|
||||
# Traces and the blob report (test-results/blob/) of a failed, timed-out or cancelled step.
|
||||
- name: Upload browser traces
|
||||
if: always()
|
||||
|
||||
+12
-41
@@ -1830,9 +1830,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
|
||||
blocks' context ("Current file image tags" in History). An empty source draws no
|
||||
section. Repeats are removed within a source only, so a linked tag that is also an
|
||||
image tag shows in both sections and each count is true. D21's filter rules are
|
||||
unchanged. History's current-file read carries the same `image_tags` facet as the Images
|
||||
read (#457), so History's inline current file and its viewer show the image tags section
|
||||
too (`test_current_inspection_carries_the_files_image_tags`). `tests/browser/inspector-concept.spec.mjs` wraps the
|
||||
unchanged. History's current-file read carries no `image_tags` facet (D88), so there
|
||||
only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the
|
||||
concept's row in the same sections at runtime (`withTagSections`, the D154 rule
|
||||
injected) and compares the measured sections (frame, heading, label, row); a
|
||||
mutation of the label color, the row margin or the top rule fails it. Browser tests:
|
||||
@@ -2443,38 +2442,10 @@ Recorded deviations on this page:
|
||||
view" filters are active, and plain "Filters" otherwise; the text is its accessible
|
||||
name, so the count is spoken. The concept has no indicator, and without one a Tags
|
||||
filter set by Tags & categories Browse was invisible on the shelf. The Library counts
|
||||
Tags, Folder and Tag match (D150); Images counts Source, Folder, Media and Tags (each once). The toolbar category is
|
||||
Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is
|
||||
not a Refine filter and is not counted. The caption's count is a `role="status"`
|
||||
region, written only when it changes and kept shown while the shelf reloads, so
|
||||
Load more and scrolling never re-announce it.
|
||||
- **D150** (#444, cutover #334): "Refine this view" on the Library holds the concept
|
||||
Filters dialog's **Folder** ("All output folders", then each folder below an output
|
||||
root as a tree built by path segment (each folder directly under its own parent,
|
||||
case variants as distinct siblings, siblings case-insensitive then exact), indented
|
||||
by depth, read from `GET /prompt_manager/library/options?include=folders`
|
||||
when the dialog opens; the chosen folder stays listed even when the list lacks it) and
|
||||
**Tags**, plus **Tag match** ("All of these tags" / "Any of these tags" / "Prompts without tags", D149) drawn as the
|
||||
concept's own select; the concept's "Prompt evidence" and prototype note are absent.
|
||||
`GET /prompt_manager/library` takes `folder` (`/`-joined; `\` is a separator only on
|
||||
Windows, on POSIX it is part of a folder's name as the listing shows it; `.`, `..`,
|
||||
empty segments and, on Windows, drives are 400) and `tag_mode` (`all` default, `any`, `untagged` from D149), and echoes
|
||||
both; a client whose non-default value is not echoed (an un-restarted older server)
|
||||
shows the read error, never the unfiltered shelf. A folder keeps a parent with one
|
||||
linked image (`generated_images.prompt_id`) in it or below it under any spelling of
|
||||
any output root (the root as configured or detected and after `realpath`, so a
|
||||
symlinked output folder works): `file_path` (`normcase(normpath(abspath))`) is
|
||||
compared with prefixes built by the same normalization, so case folding is the
|
||||
platform's own, including non-ASCII on Windows. The folder is an outer AND around the
|
||||
parent and pair arms; Any puts one `t.name IN (...)` in each arm, so a card matches
|
||||
when the parent or one active pair carries any tag (text and category still AND
|
||||
inside each arm); with one tag Any equals All. Filters counts Tags, Folder and a Tag
|
||||
match other than All (D44). Saved Library views store `tag_mode` (`all|any|untagged`) and
|
||||
`folder` (text 1024); older rows read the defaults. Tests:
|
||||
`tests/test_library_folders.py` (pure prefixes and names with `posixpath`/`ntpath`,
|
||||
drive, UNC, non-ASCII, symlinked root, the any-tag union), `test_api_library.py`,
|
||||
`test_saved_views.py`, the library-state and saved-views JS units, and
|
||||
`tests/browser/workspace-library-filters.spec.mjs` (behavior, the Refine dialog against
|
||||
the concept Filters dialog, artifact captures). Windows: run `portability.yml` on the PR.
|
||||
|
||||
`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the
|
||||
concept-shaped fixture, which creates the concept's prompts last to first so Recently
|
||||
@@ -6361,7 +6332,7 @@ The versioned public reads are:
|
||||
| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` |
|
||||
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL; `inspection` carries the shared inspector envelope including the file's `image_tags` facet (#457) |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
|
||||
|
||||
The newest-pair read takes no query. It answers 404 when the positive is not an
|
||||
eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact
|
||||
@@ -6665,12 +6636,12 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
|
||||
`#/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
|
||||
"Open existing tag manager" card for its own page; tag search and sort, the untagged
|
||||
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
|
||||
library's selection bar, D151). A prompt's category is changed in the Prompt library
|
||||
editor, on many prompts at once with the Prompt library's selection bar (D148, #437), or
|
||||
with the legacy admin page's "Set Category" (both `POST /prompt_manager/bulk/category`);
|
||||
"Open existing tag manager" card for multi-tag AND/OR browsing (tag search and sort
|
||||
and the untagged entry moved here with D149, #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 editor, on many prompts at once with
|
||||
the Prompt library's selection bar (D148, #437), or with the legacy admin page's "Set
|
||||
Category" (both `POST /prompt_manager/bulk/category`);
|
||||
the Images selection bar's Set category sets images' own categories only (D140, #332),
|
||||
and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes
|
||||
above and adds no route, so PR B needed no ComfyUI restart; D149 (#444) adds read
|
||||
@@ -6743,10 +6714,10 @@ fixture-only tags after the concept's sample tags.
|
||||
counted every prompt without `prompt_tags` and hid the entry at 0; this row always
|
||||
shows) and echoes `search` and `sort`. Browse opens the Library with `tag_mode=untagged`
|
||||
(`GET /prompt_manager/library`, echoed as `tag_mode`; with a `tag` it is 400): "Filters ·
|
||||
1", and Refine this view adds **Tag match** after Tags ("All of these tags" / "Any of these tags" (D150) / "Prompts
|
||||
1", and Refine this view adds **Tag match** after Tags ("All of these tags" / "Prompts
|
||||
without tags", the latter disables Tags and drops them on Apply). A client whose request
|
||||
the server did not echo (an un-restarted older server) shows the read error with Retry,
|
||||
never an unfiltered list. Saved Library views store `tag_mode` (`all|any|untagged`, default
|
||||
never an unfiltered list. Saved Library views store `tag_mode` (`all|untagged`, default
|
||||
`all`); untagged with tags is refused on write and reads back as `all`; an older build
|
||||
opens an untagged view as the whole Library. The concept comparison holds the row to the
|
||||
concept's first category row, its top to the concept table's offset, the table to that
|
||||
|
||||
+19
-56
@@ -5,7 +5,6 @@ import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .library_folders import folder_condition, list_library_folders
|
||||
from .library_tokens import library_edit_token
|
||||
|
||||
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
|
||||
@@ -122,9 +121,6 @@ def _archive(value: str) -> str:
|
||||
return ARCHIVE_SCOPES[value]
|
||||
|
||||
|
||||
# How the Tags filter matches (#444): every tag (AND), any of them (OR), or
|
||||
# parents without any tag.
|
||||
TAG_MODES = ("all", "any", "untagged")
|
||||
# A parent that no tag's Browse returns (#444 "Untagged prompts"): no tag of its
|
||||
# own and no active pair carrying one. The vocabulary's untagged count uses it too.
|
||||
UNTAGGED = (
|
||||
@@ -132,6 +128,8 @@ UNTAGGED = (
|
||||
"AND NOT EXISTS(SELECT 1 FROM prompt_pairs up JOIN prompt_pair_tags upt "
|
||||
"ON upt.pair_id=up.id WHERE up.positive_prompt_id=p.id AND up.archived=0)"
|
||||
)
|
||||
# How the Tags filter matches: every tag (AND), or parents without any tag.
|
||||
TAG_MODES = ("all", "untagged")
|
||||
|
||||
|
||||
def _tag_mode(tag_mode: object, tags: list[str]) -> str:
|
||||
@@ -142,33 +140,8 @@ def _tag_mode(tag_mode: object, tags: list[str]) -> str:
|
||||
return tag_mode
|
||||
|
||||
|
||||
def _tag_conditions(tags: list[str], any_tag: bool) -> tuple[list, list, list]:
|
||||
"""Parent-arm and pair-arm tag conditions and the tags' parameters."""
|
||||
parent_exists = (
|
||||
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.prompt_id=p.id AND t.name{test})"
|
||||
)
|
||||
pair_exists = (
|
||||
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.pair_id=pp.id AND t.name{test})"
|
||||
)
|
||||
if any_tag and tags:
|
||||
# One condition per arm: the card, or one active pair, carries any tag.
|
||||
test = " IN (" + ",".join("?" for _ in tags) + ")"
|
||||
return [parent_exists.format(test=test)], [pair_exists.format(test=test)], tags
|
||||
return (
|
||||
[parent_exists.format(test="=?") for _ in tags],
|
||||
[pair_exists.format(test="=?") for _ in tags],
|
||||
tags,
|
||||
)
|
||||
|
||||
|
||||
def _filter(
|
||||
text: str,
|
||||
category: str | None,
|
||||
tags: list[str],
|
||||
tag_mode: str = "all",
|
||||
folder_prefixes: list[str] | None = None,
|
||||
text: str, category: str | None, tags: list[str], tag_mode: str = "all"
|
||||
) -> tuple[str, list]:
|
||||
if not isinstance(text, str) or len(text) > 500:
|
||||
raise ValueError("Search text must be a string of at most 500 characters")
|
||||
@@ -180,8 +153,7 @@ def _filter(
|
||||
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
|
||||
):
|
||||
raise ValueError("Invalid library tags")
|
||||
mode = _tag_mode(tag_mode, tags)
|
||||
untagged = mode == "untagged"
|
||||
untagged = _tag_mode(tag_mode, tags) == "untagged"
|
||||
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
|
||||
if text:
|
||||
parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')")
|
||||
@@ -196,11 +168,17 @@ def _filter(
|
||||
pair.append("pp.category=?")
|
||||
parent_params.append(category)
|
||||
pair_params.append(category)
|
||||
parent_tags, pair_tags, tag_params = _tag_conditions(tags, mode == "any")
|
||||
parent.extend(parent_tags)
|
||||
pair.extend(pair_tags)
|
||||
parent_params.extend(tag_params)
|
||||
pair_params.extend(tag_params)
|
||||
for tag in tags:
|
||||
parent.append(
|
||||
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.prompt_id=p.id AND t.name=?)"
|
||||
)
|
||||
pair.append(
|
||||
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.pair_id=pp.id AND t.name=?)"
|
||||
)
|
||||
parent_params.append(tag)
|
||||
pair_params.append(tag)
|
||||
condition = (
|
||||
"("
|
||||
+ (" AND ".join(parent) or "1=1")
|
||||
@@ -216,13 +194,7 @@ def _filter(
|
||||
# A whole-card condition: inside the parent-or-pair OR an untagged pair
|
||||
# would let a tagged parent through.
|
||||
condition = f"({condition}) AND {UNTAGGED}"
|
||||
params = [*parent_params, *pair_params]
|
||||
if folder_prefixes:
|
||||
# A whole-card condition around both arms: images link to the parent.
|
||||
folder, folder_params = folder_condition(folder_prefixes)
|
||||
condition = f"({condition}) AND {folder}"
|
||||
params.extend(folder_params)
|
||||
return f"({_ELIGIBLE}) AND ({condition})", params
|
||||
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
|
||||
|
||||
|
||||
class LibraryOperations:
|
||||
@@ -306,7 +278,6 @@ class LibraryOperations:
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tag_mode: str = "all",
|
||||
folder_prefixes: list[str] | None = None,
|
||||
sort: str = "newest",
|
||||
limit: int = 40,
|
||||
after: dict | None = None,
|
||||
@@ -314,9 +285,7 @@ class LibraryOperations:
|
||||
_limit(limit)
|
||||
if sort not in _ORDERS:
|
||||
raise ValueError("Unknown library sort order")
|
||||
where, params = _filter(
|
||||
text, category, [] if tags is None else tags, tag_mode, folder_prefixes
|
||||
)
|
||||
where, params = _filter(text, category, [] if tags is None else tags, tag_mode)
|
||||
if after is not None:
|
||||
if (
|
||||
not isinstance(after, dict)
|
||||
@@ -382,7 +351,6 @@ class LibraryOperations:
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tag_mode: str = "all",
|
||||
folder_prefixes: list[str] | None = None,
|
||||
sort: str = "newest",
|
||||
limit: int = 40,
|
||||
after: dict | None = None,
|
||||
@@ -396,7 +364,6 @@ class LibraryOperations:
|
||||
category=category,
|
||||
tags=tags,
|
||||
tag_mode=tag_mode,
|
||||
folder_prefixes=folder_prefixes,
|
||||
sort=sort,
|
||||
limit=limit,
|
||||
after=after,
|
||||
@@ -527,13 +494,9 @@ class LibraryOperations:
|
||||
"categories": [row["category"] for row in rows],
|
||||
}
|
||||
|
||||
def get_library_options(self, folder_roots=None) -> dict:
|
||||
"""Categories; with *folder_roots* (root spellings) also ``folders``."""
|
||||
def get_library_options(self) -> dict:
|
||||
with self.library_snapshot() as (conn, epoch, _ceiling):
|
||||
result = self._get_library_options(conn, epoch)
|
||||
if folder_roots is not None:
|
||||
result["folders"] = list_library_folders(conn, folder_roots, _ELIGIBLE)
|
||||
return result
|
||||
return self._get_library_options(conn, epoch)
|
||||
|
||||
def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
|
||||
"""Read at most two links per parent in one bounded page query."""
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Library output-folder filter (#444): folder names, root prefixes and listing.
|
||||
|
||||
A folder is a ``/``-joined path relative to an output root, as Images' folder
|
||||
filter and the legacy ``/prompt_manager/subfolders`` name them. A parent
|
||||
matches a folder when one of its linked images (``generated_images.prompt_id``,
|
||||
the link its previews and image count read) lies in that folder or below it,
|
||||
under any spelling of any output root.
|
||||
|
||||
Matching compares ``generated_images.file_path``, which is always
|
||||
``normcase(normpath(abspath(path)))`` (``models.normalize_image_path``), with
|
||||
prefixes built by the same normalization here, so case folding is the
|
||||
platform's own on both sides (Windows folds non-ASCII too, unlike SQLite's
|
||||
``lower()``) and the comparison is an exact ``substr`` with no wildcards.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
_MAX_FOLDER = 1024
|
||||
_MAX_PREFIXES = 64
|
||||
|
||||
|
||||
def normalize_folder(value: object, pathmod=os.path) -> str:
|
||||
"""The folder as ``a/b``, outer slashes dropped; empty means no filter.
|
||||
|
||||
``/`` always separates. ``\\`` separates only on Windows (``ntpath``): on
|
||||
POSIX it is an ordinary character of a folder's name, and the listing
|
||||
(``relative_folder``) names such a folder with it, so both agree.
|
||||
``.``/``..``/empty segments, control characters and (on Windows) a drive
|
||||
are refused: the value only ever names a folder below a root.
|
||||
"""
|
||||
if not isinstance(value, str) or len(value) > _MAX_FOLDER:
|
||||
raise ValueError(f"folder must be text of at most {_MAX_FOLDER} characters")
|
||||
windows = pathmod.sep == "\\"
|
||||
folder = (value.replace("\\", "/") if windows else value).strip("/")
|
||||
if not folder:
|
||||
return ""
|
||||
parts = folder.split("/")
|
||||
if (
|
||||
any(part in ("", ".", "..") for part in parts)
|
||||
or (windows and ":" in parts[0])
|
||||
or any(ord(char) < 32 or ord(char) == 127 for char in folder)
|
||||
):
|
||||
raise ValueError("folder must be a folder below an output root")
|
||||
return folder
|
||||
|
||||
|
||||
def _base(root: object, pathmod) -> str:
|
||||
return pathmod.normcase(pathmod.normpath(str(root)))
|
||||
|
||||
|
||||
def folder_prefixes(roots, folder: str, pathmod=os.path) -> list[str]:
|
||||
"""One ``file_path`` prefix per root spelling for a normalized folder."""
|
||||
prefixes = []
|
||||
for root in roots:
|
||||
joined = pathmod.join(_base(root, pathmod), *folder.split("/"))
|
||||
prefix = pathmod.normcase(pathmod.normpath(joined)) + pathmod.sep
|
||||
if prefix not in prefixes:
|
||||
prefixes.append(prefix)
|
||||
if not prefixes or len(prefixes) > _MAX_PREFIXES:
|
||||
raise ValueError("No output folder to filter by")
|
||||
return prefixes
|
||||
|
||||
|
||||
def folder_condition(prefixes: list[str]) -> tuple[str, list]:
|
||||
"""A condition on alias ``p``: one linked image under any prefix."""
|
||||
tests = " OR ".join("substr(fg.file_path,1,?)=?" for _ in prefixes)
|
||||
params = [value for prefix in prefixes for value in (len(prefix), prefix)]
|
||||
return (
|
||||
"EXISTS(SELECT 1 FROM generated_images fg WHERE fg.prompt_id=p.id "
|
||||
f"AND ({tests}))",
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def relative_folder(image_path: str, roots, pathmod=os.path) -> str | None:
|
||||
"""The folder of *image_path* below the first root spelling holding it.
|
||||
|
||||
The spelling of the stored path is kept (its case on Windows); ``None``
|
||||
for an image directly in a root or outside every root.
|
||||
"""
|
||||
directory = pathmod.normpath(pathmod.dirname(image_path))
|
||||
folded = pathmod.normcase(directory)
|
||||
for root in roots:
|
||||
base = _base(root, pathmod)
|
||||
start = base if base.endswith(pathmod.sep) else base + pathmod.sep
|
||||
if not folded.startswith(start):
|
||||
continue
|
||||
# normcase keeps the length for ordinary names; else show the folded form.
|
||||
rest = (directory if len(directory) == len(folded) else folded)[len(start) :]
|
||||
parts = rest.split(pathmod.sep)
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
return None
|
||||
|
||||
|
||||
def list_library_folders(
|
||||
conn: sqlite3.Connection, roots, eligible: str, pathmod=os.path
|
||||
) -> list[str]:
|
||||
"""Every folder (and ancestor) holding an image of an eligible parent."""
|
||||
folders = set()
|
||||
for (image_path,) in conn.execute(
|
||||
"SELECT DISTINCT g.image_path FROM generated_images g "
|
||||
"JOIN prompts p ON p.id=g.prompt_id WHERE " + eligible
|
||||
):
|
||||
if not isinstance(image_path, str) or not image_path:
|
||||
continue
|
||||
folder = relative_folder(image_path, roots, pathmod)
|
||||
if folder is None:
|
||||
continue
|
||||
parts = folder.split("/")
|
||||
folders.update("/".join(parts[:index]) for index in range(1, len(parts) + 1))
|
||||
return sorted(folders, key=lambda value: (value.casefold(), value))
|
||||
@@ -33,9 +33,8 @@ MAX_TAG_LENGTH = 50
|
||||
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_MEDIA = ("all", "image", "video")
|
||||
# How a Library view's Tags match (#444): every tag, any of them, or parents
|
||||
# without tags ("untagged" never goes with tags; the listing refuses both).
|
||||
_TAG_MODES = ("all", "any", "untagged")
|
||||
# How a Library view's Tags match (#444): every tag, or parents without tags.
|
||||
_TAG_MODES = ("all", "untagged")
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
|
||||
# A collection id as the listing's ``collection`` query takes it (#331): none
|
||||
@@ -70,9 +69,8 @@ _STATE_FIELDS = {
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _LIBRARY_SORTS, "newest"),
|
||||
"tags": ("tags", None, ()),
|
||||
# #444: the Tags match and the output folder (relative, "/"-joined).
|
||||
# #444: "untagged" never goes with tags (the listing refuses both).
|
||||
"tag_mode": ("choice", _TAG_MODES, "all"),
|
||||
"folder": ("text", 1024, ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+13
-26
@@ -40,7 +40,6 @@ from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
from .prompts import PromptRoutesMixin
|
||||
from .revalidation import revalidated_response
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .vocabulary import VocabularyRoutesMixin
|
||||
@@ -654,39 +653,35 @@ class PromptManagerAPI(
|
||||
|
||||
@routes.get("/prompt_manager/workspace")
|
||||
async def serve_workspace_ui(request: web.Request) -> web.Response:
|
||||
return self._serve_html_page(request, "workspace.html", "Workspace")
|
||||
return self._serve_html_page("workspace.html", "Workspace")
|
||||
|
||||
@routes.get("/prompt_manager/web")
|
||||
async def serve_web_ui(request):
|
||||
return self._serve_html_page(request, "index.html", "Web UI", cache=False)
|
||||
return self._serve_html_page("index.html", "Web UI", cache=False)
|
||||
|
||||
@routes.get("/prompt_manager/gallery.html")
|
||||
async def serve_gallery_ui(request):
|
||||
return self._serve_html_page(request, "metadata.html", "Gallery")
|
||||
return self._serve_html_page("metadata.html", "Gallery")
|
||||
|
||||
@routes.get("/prompt_manager/admin")
|
||||
async def serve_admin_ui(request):
|
||||
return self._serve_html_page(request, "admin.html", "Admin UI")
|
||||
return self._serve_html_page("admin.html", "Admin UI")
|
||||
|
||||
@routes.get("/prompt_manager/gallery")
|
||||
async def serve_gallery_admin_ui(request):
|
||||
return self._serve_html_page(request, "gallery.html", "Gallery")
|
||||
return self._serve_html_page("gallery.html", "Gallery")
|
||||
|
||||
# ── Static file serving ───────────────────────────────────────
|
||||
|
||||
@routes.get("/prompt_manager/lib/{filepath:.*}")
|
||||
async def serve_lib_static(request):
|
||||
"""Serve static library files (JS, CSS) from web/lib directory."""
|
||||
return self._serve_static_file(
|
||||
request, "lib", request.match_info.get("filepath")
|
||||
)
|
||||
return self._serve_static_file("lib", request.match_info.get("filepath"))
|
||||
|
||||
@routes.get("/prompt_manager/js/{filepath:.*}")
|
||||
async def serve_js_static(request):
|
||||
"""Serve static JavaScript files from web/js directory."""
|
||||
return self._serve_static_file(
|
||||
request, "js", request.match_info.get("filepath")
|
||||
)
|
||||
return self._serve_static_file("js", request.match_info.get("filepath"))
|
||||
|
||||
# ── Register domain-specific routes from mixins ───────────────
|
||||
|
||||
@@ -731,11 +726,8 @@ class PromptManagerAPI(
|
||||
|
||||
self.logger.info("All routes registered with decorator pattern")
|
||||
|
||||
def _serve_static_file(self, request, subdir, filepath):
|
||||
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing.
|
||||
|
||||
A served file is revalidated on every reuse (#478, ``revalidation``).
|
||||
"""
|
||||
def _serve_static_file(self, subdir, filepath):
|
||||
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
|
||||
root = os.path.join(_get_project_root(), "web", subdir)
|
||||
exact = STATIC_EXACT_FILES.get((subdir, filepath))
|
||||
# An exact file passes the same containment check, its extension only.
|
||||
@@ -750,13 +742,10 @@ class PromptManagerAPI(
|
||||
return web.Response(text="Not Found", status=404)
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
return revalidated_response(request, content, content_type)
|
||||
return web.Response(body=content, content_type=content_type)
|
||||
|
||||
def _serve_html_page(self, request, filename, title, cache=True):
|
||||
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable.
|
||||
|
||||
A served page is revalidated on every reuse (#478, ``revalidation``).
|
||||
"""
|
||||
def _serve_html_page(self, filename, title, cache=True):
|
||||
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
|
||||
html_path = os.path.join(_get_project_root(), "web", filename)
|
||||
try:
|
||||
if cache and html_path in self._html_cache:
|
||||
@@ -773,9 +762,7 @@ class PromptManagerAPI(
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
)
|
||||
return revalidated_response(
|
||||
request, content.encode("utf-8"), "text/html", charset="utf-8"
|
||||
)
|
||||
return web.Response(text=content, content_type="text/html", charset="utf-8")
|
||||
except Exception:
|
||||
self.logger.exception(f"Failed to load {title}")
|
||||
return web.Response(
|
||||
|
||||
+1
-13
@@ -27,11 +27,7 @@ from .history_current import (
|
||||
resolve_current_candidate,
|
||||
snapshot_current_output_scope,
|
||||
)
|
||||
from .inspection_service import (
|
||||
CurrentFileChanged,
|
||||
image_tags_reader,
|
||||
inspect_current_file,
|
||||
)
|
||||
from .inspection_service import CurrentFileChanged, inspect_current_file
|
||||
from .images import image_category_key
|
||||
|
||||
try:
|
||||
@@ -782,14 +778,6 @@ class HistoryRoutesMixin:
|
||||
),
|
||||
own,
|
||||
),
|
||||
# The file's own image tags, keyed as the Tag dialog
|
||||
# keys them: the normcased realpath (#457).
|
||||
image_tags=image_tags_reader(
|
||||
db,
|
||||
str(resolved.canonical_target),
|
||||
self.logger,
|
||||
close=True,
|
||||
),
|
||||
recorded=recorded_output_lookup(
|
||||
db, close=True, reference=receipt["reference"]
|
||||
),
|
||||
|
||||
+21
-3
@@ -55,7 +55,6 @@ from .inspection_service import (
|
||||
IMAGE_EXTENSIONS as _IMAGE_EXTENSIONS,
|
||||
SERVABLE_EXTENSIONS,
|
||||
VIDEO_EXTENSIONS,
|
||||
image_tags_reader,
|
||||
inspect_current_file,
|
||||
)
|
||||
|
||||
@@ -3963,9 +3962,28 @@ class ImageRoutesMixin:
|
||||
)
|
||||
|
||||
def _inspection_image_tags(self, root: Path, parts: tuple) -> Callable:
|
||||
"""The inspector's ``image_tags`` reader for one rooted file (S1)."""
|
||||
"""The inspector's ``image_tags`` reader for one rooted file (S1).
|
||||
|
||||
The returned function takes the inspection's own stable stat and
|
||||
answers ``current`` only for a byte record verified at exactly that
|
||||
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
|
||||
"""
|
||||
locator = os.path.normcase(os.path.join(os.path.realpath(root), *parts))
|
||||
return image_tags_reader(self.db, locator, self.logger)
|
||||
|
||||
def read(size: int, mtime_ns: int) -> dict:
|
||||
try:
|
||||
found = self.db.current_image_tags({locator: (size, mtime_ns)})
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"Inspection image tags could not be read", exc_info=True
|
||||
)
|
||||
return {"state": "unavailable", "tags": []}
|
||||
tags = found.get(locator)
|
||||
if tags is None:
|
||||
return {"state": "none", "tags": []}
|
||||
return {"state": "current", "tags": list(tags)}
|
||||
|
||||
return read
|
||||
|
||||
def _read_own_category(self, sources, path):
|
||||
"""One file's own category, None, or OWN_CATEGORY_UNREAD (blocking).
|
||||
|
||||
@@ -93,35 +93,6 @@ def _in_host_temp(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def image_tags_reader(
|
||||
db: Any, locator: str, logger: Any, *, close: bool = False
|
||||
) -> Callable[[int, int], dict]:
|
||||
"""The inspector's ``image_tags`` reader for one file *locator* (S1).
|
||||
|
||||
The returned function takes the inspection's own stable stat and answers
|
||||
``current`` only for a byte record verified at exactly that
|
||||
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
|
||||
``close`` closes the calling thread's connection afterwards, for routes
|
||||
that close theirs after reading (History).
|
||||
"""
|
||||
|
||||
def read(size: int, mtime_ns: int) -> dict:
|
||||
try:
|
||||
found = db.current_image_tags({locator: (size, mtime_ns)})
|
||||
except Exception:
|
||||
logger.warning("Inspection image tags could not be read", exc_info=True)
|
||||
return {"state": "unavailable", "tags": []}
|
||||
finally:
|
||||
if close:
|
||||
db.model.close()
|
||||
tags = found.get(locator)
|
||||
if tags is None:
|
||||
return {"state": "none", "tags": []}
|
||||
return {"state": "current", "tags": list(tags)}
|
||||
|
||||
return read
|
||||
|
||||
|
||||
def inspect_current_file(
|
||||
path: Path,
|
||||
*,
|
||||
|
||||
+2
-51
@@ -21,11 +21,9 @@ from .pairs import pair_response
|
||||
try:
|
||||
from ...database.library import ARCHIVE_SCOPES
|
||||
from ...database.library_commands import LibraryCommandError
|
||||
from ...database.library_folders import folder_prefixes, normalize_folder
|
||||
except ImportError:
|
||||
from database.library import ARCHIVE_SCOPES
|
||||
from database.library_commands import LibraryCommandError
|
||||
from database.library_folders import folder_prefixes, normalize_folder
|
||||
|
||||
_SECRET = os.urandom(32)
|
||||
|
||||
@@ -285,47 +283,13 @@ class LibraryRoutesMixin:
|
||||
outputs = list(self._get_all_output_dirs())
|
||||
return self._media_roots(outputs), len(outputs)
|
||||
|
||||
def _library_root_spellings(self) -> list[str]:
|
||||
"""Every spelling of each output root (#444 folder filter): the
|
||||
validated, resolved root and each configured or detected path that
|
||||
resolves to it, so images saved through a symlinked root match."""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
roots = [str(root) for root in self._get_all_output_dirs()]
|
||||
candidates = [
|
||||
*GalleryConfig.MONITORING_DIRECTORIES,
|
||||
self._find_comfyui_output_dir(),
|
||||
*self._comfyui_output_dir_list(),
|
||||
]
|
||||
spellings = []
|
||||
for root in roots:
|
||||
for value in (root, *candidates):
|
||||
if not value or not isinstance(value, (str, os.PathLike)):
|
||||
continue
|
||||
value = str(value)
|
||||
same = value == root or os.path.normcase(
|
||||
os.path.realpath(value)
|
||||
) == os.path.normcase(root)
|
||||
if same and value not in spellings:
|
||||
spellings.append(value)
|
||||
return spellings
|
||||
|
||||
@pair_response
|
||||
async def get_library_options(self, request: web.Request) -> web.Response:
|
||||
include = request.query.get("include", "")
|
||||
if include not in ("", "folders"):
|
||||
raise ValueError("include must be empty or 'folders'")
|
||||
if include:
|
||||
# Folders are read only when Refine opens (a walk of linked paths).
|
||||
roots = await self._run_in_executor(self._library_root_spellings)
|
||||
result = await self._run_in_executor(self.db.get_library_options, roots)
|
||||
else:
|
||||
result = await self._run_in_executor(self.db.get_library_options)
|
||||
result = await self._run_in_executor(self.db.get_library_options)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@pair_response
|
||||
async def list_library(self, request: web.Request) -> web.Response:
|
||||
folder = normalize_folder(request.query.get("folder", ""))
|
||||
options = {
|
||||
"text": request.query.get("text", ""),
|
||||
"category": request.query.get("category"),
|
||||
@@ -335,34 +299,21 @@ class LibraryRoutesMixin:
|
||||
}
|
||||
limit = _page_limit(request)
|
||||
cursor = request.query.get("cursor")
|
||||
roots = (
|
||||
await self._run_in_executor(self._library_root_spellings) if folder else []
|
||||
)
|
||||
prefixes = folder_prefixes(roots, folder) if folder else None
|
||||
|
||||
def read() -> tuple[dict, dict]:
|
||||
with self.db.library_snapshot(include_ceiling=True) as snapshot:
|
||||
conn, epoch, ceiling = snapshot
|
||||
# The folder and the roots it was resolved under join the scope.
|
||||
scope = _scope(
|
||||
{
|
||||
"kind": "parents",
|
||||
"projection": 2,
|
||||
"epoch": epoch,
|
||||
**options,
|
||||
**({"folder": folder, "roots": roots} if folder else {}),
|
||||
}
|
||||
{"kind": "parents", "projection": 2, "epoch": epoch, **options}
|
||||
)
|
||||
result = self.db._list_library(
|
||||
conn,
|
||||
epoch,
|
||||
ceiling,
|
||||
**options,
|
||||
folder_prefixes=prefixes,
|
||||
limit=limit,
|
||||
after=_decode(cursor, scope),
|
||||
)
|
||||
result["folder"] = folder
|
||||
records = self.db.get_library_previews(
|
||||
[parent["id"] for parent in result["items"]]
|
||||
)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Revalidated responses for the pages and static files PromptManager serves.
|
||||
|
||||
``Cache-Control: no-cache`` lets the browser keep a copy but makes it ask the
|
||||
server before every reuse, so a PromptManager update reaches the browser on a
|
||||
normal reload (#478). The ETag lets an unchanged file answer 304 without a
|
||||
body, which keeps that reload cheap.
|
||||
|
||||
The tag hashes the bytes rather than the file's mtime and size: an update can
|
||||
keep both (an archive extract, a same-length edit within one clock tick), and
|
||||
the handlers read the whole file anyway. It is weak because the gzip
|
||||
middleware may send the same content in a different encoding.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
REVALIDATE = "no-cache"
|
||||
|
||||
|
||||
def entity_tag(body: bytes) -> str:
|
||||
"""Weak ETag for ``body``."""
|
||||
return f'W/"{hashlib.sha256(body).hexdigest()[:32]}"'
|
||||
|
||||
|
||||
def etag_matches(if_none_match, etag: str) -> bool:
|
||||
"""True when an If-None-Match header names ``etag`` (weak comparison)."""
|
||||
if not if_none_match:
|
||||
return False
|
||||
if if_none_match.strip() == "*":
|
||||
return True
|
||||
opaque = etag.removeprefix("W/")
|
||||
return any(
|
||||
candidate.strip().removeprefix("W/") == opaque
|
||||
for candidate in if_none_match.split(",")
|
||||
)
|
||||
|
||||
|
||||
def revalidated_response(request, body: bytes, content_type: str, charset=None):
|
||||
"""200 with ``body``, or 304 when the request already holds this version."""
|
||||
etag = entity_tag(body)
|
||||
headers = {"Cache-Control": REVALIDATE, "ETag": etag}
|
||||
if etag_matches(request.headers.get("If-None-Match"), etag):
|
||||
return web.Response(status=304, headers=headers)
|
||||
return web.Response(
|
||||
body=body, content_type=content_type, charset=charset, headers=headers
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {saveImageTags} from './concept-inventory.mjs';
|
||||
import {readFile,writeFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
@@ -496,38 +495,23 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
// #449 (D154), #457: History's inline current file draws the file's own image tags in the shared labelled section, named
|
||||
// with its context so they stay apart from the frozen run's fields. The tags are saved on the real file through the Tag
|
||||
// routes and come from History's own current-file read (its `image_tags` facet), as Images reads them.
|
||||
test('the inline current file labels its image tags after its negative prompt',async({page,request})=>{
|
||||
const reads=[];
|
||||
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
|
||||
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
|
||||
// given one here to prove the shared render in History.
|
||||
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
reads.push(body.image_tags);
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
body.image_tags={state:'current',tags:['history_tag']};
|
||||
await route.fulfill({response,json:body});});
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
|
||||
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
|
||||
const showCurrent=async()=>{
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true})).toBeVisible();
|
||||
};
|
||||
// An untagged file draws no image tags section.
|
||||
await showCurrent();await expect(tags).toHaveCount(0);expect(reads.at(-1)).toEqual({state:'none',tags:[]});
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
expect(await saveImageTags(request,'history/current.png',['history_tag','dusk'])).toEqual(['history_tag','dusk']);
|
||||
await showCurrent();expect(reads.at(-1)).toEqual({state:'current',tags:['history_tag','dusk']});
|
||||
await expect(tags.locator('.role-label')).toHaveText('Image tags · 2');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag','dusk']);
|
||||
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
|
||||
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
|
||||
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
|
||||
// The viewer opened from History shares the inspection: the same section, plain chips (no Tags filter there, D88).
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
const viewerTags=page.locator('dialog#viewer-dialog #viewer-details section.tag-section.image-tags');
|
||||
await expect(viewerTags.locator('.role-label')).toHaveText('Image tags · 2');
|
||||
await expect(viewerTags.locator('span.chip')).toHaveText(['history_tag','dusk']);await expect(viewerTags.locator('button')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
});
|
||||
|
||||
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// #444 (D150): the Library's output-folder filter and multi-tag OR in "Refine this view", against the real server, and
|
||||
// the Refine dialog against the concept's Filters dialog (app.js:1345-1352).
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {inventoryOf,diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
const route='/prompt_manager/workspace#/library';
|
||||
const concept='/__test__/canonical/workspace';
|
||||
const cards=page=>page.locator('article[data-prompt-id]');
|
||||
const card=(page,text)=>cards(page).filter({has:page.getByText(text,{exact:true})});
|
||||
const toolbar=page=>page.locator('#workspace-main search.toolbar');
|
||||
const filtersButton=page=>toolbar(page).getByRole('button',{name:/^Filters(?: · \d)?$/});
|
||||
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
const folder=page=>refine(page).getByRole('combobox',{name:'Folder',exact:true});
|
||||
const tags=page=>refine(page).getByRole('textbox',{name:'Tags',exact:true});
|
||||
const match=page=>refine(page).getByRole('combobox',{name:'Tag match',exact:true});
|
||||
const apply=page=>refine(page).getByRole('button',{name:'Apply filters',exact:true});
|
||||
const caption=page=>page.locator('#workspace-main .section-caption > span').first();
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
|
||||
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
|
||||
|
||||
async function open(page) {
|
||||
await page.goto(route);await expect(caption(page)).toHaveText(/^3 positive prompts · /);
|
||||
}
|
||||
/** Open Refine and wait for the folder list it reads on opening. */
|
||||
async function openRefine(page) {
|
||||
const folders=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/library/options'&&new URL(item.url()).searchParams.get('include')==='folders');
|
||||
await filtersButton(page).click();await expect(refine(page)).toBeVisible();await folders;
|
||||
}
|
||||
|
||||
test('Refine offers Folder, Tags and Tag match; a folder narrows the shelf to prompts with images there',async({page})=>{
|
||||
await open(page);await openRefine(page);
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);
|
||||
// Both roots hold a "shared" folder; the root's own images name no folder.
|
||||
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);
|
||||
await expect(match(page).locator('option')).toHaveText(['All of these tags','Any of these tags','Prompts without tags']);
|
||||
await folder(page).selectOption('shared');await apply(page).click();
|
||||
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
|
||||
await expect(cards(page)).toHaveCount(1);await expect(card(page,'Cat portrait')).toHaveCount(1);
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await openRefine(page);await expect(folder(page)).toHaveValue('shared');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();
|
||||
await expect(caption(page)).toHaveText(/^3 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters');
|
||||
});
|
||||
|
||||
test('Tag match Any shows every prompt carrying one of the tags; changing only the match applies it',async({page})=>{
|
||||
await open(page);await openRefine(page);
|
||||
await tags(page).fill('cats, dogs');await apply(page).click();
|
||||
await expect(caption(page)).toHaveText(/^0 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await openRefine(page);await match(page).selectOption('any');await apply(page).click();
|
||||
await expect(caption(page)).toHaveText(/^2 positive prompts · /);
|
||||
await expect(cards(page)).toHaveCount(2);await expect(card(page,'Cat portrait')).toHaveCount(1);await expect(card(page,'Dog landscape')).toHaveCount(1);
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
});
|
||||
|
||||
test('a saved view keeps its folder and tag match',async({page})=>{
|
||||
await open(page);await openRefine(page);
|
||||
await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
|
||||
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
|
||||
await toolbar(page).getByRole('button',{name:'Save view',exact:true}).click();
|
||||
const save=page.getByRole('dialog',{name:'Save this view',exact:true});
|
||||
await save.getByRole('textbox',{name:'View name',exact:true}).fill('Shared cats or dogs');
|
||||
const created=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/saved-views'&&item.request().method()==='POST');
|
||||
await save.getByRole('button',{name:'Save',exact:true}).click();
|
||||
const body=(await created).request().postDataJSON();
|
||||
expect(body.state).toMatchObject({folder:'shared',tag_mode:'any',tags:['cats','dogs']});
|
||||
await page.goto('about:blank');await open(page);
|
||||
await page.getByRole('navigation',{name:'Saved views',exact:true}).getByRole('link',{name:'Shared cats or dogs',exact:true}).click();
|
||||
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 3');
|
||||
});
|
||||
|
||||
test('a chosen folder missing from the folder list stays listed and chosen in Refine',async({page})=>{
|
||||
await open(page);await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
|
||||
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
|
||||
// The folder has no image any more as far as the list knows (or the read failed): Apply must not drop it.
|
||||
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders:[]}}));
|
||||
await openRefine(page);
|
||||
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);await expect(folder(page)).toHaveValue('shared');
|
||||
await apply(page).click();await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
});
|
||||
|
||||
// Live B1: the server's flat list sorts full paths as text ("-" and "_" before "/"); Refine shows each folder directly
|
||||
// under its own parent and keeps case variants apart.
|
||||
test('the Folder list nests each folder under its exact parent',async({page})=>{
|
||||
const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',
|
||||
'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','kiko','kiko-v6','kiko/char'].sort();
|
||||
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders}}));
|
||||
await open(page);await openRefine(page);
|
||||
const I='\u00a0\u00a0';
|
||||
await expect(folder(page).locator('option')).toHaveText(['All output folders','CCC',`${I}mabes9sans-dataset`,`${I}${I}mabes9sans-dataset_IMG_GEN`,
|
||||
`${I}mabes9sans-dataset-v1`,'FLUX',`${I}2025-12-11`,'flux',`${I}images`,'flux-klein','kiko',`${I}char`,'kiko-v6']);
|
||||
expect(await folder(page).locator('option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(['','CCC','CCC/mabes9sans-dataset',
|
||||
'CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN','CCC/mabes9sans-dataset-v1','FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein',
|
||||
'kiko','kiko/char','kiko-v6']);
|
||||
});
|
||||
|
||||
test('an older server that ignores the folder shows an error with Retry, never the whole shelf',async({page})=>{
|
||||
await open(page);
|
||||
await page.route('**/prompt_manager/library?*',async route=>{
|
||||
const response=await route.fetch();const body=await response.json();delete body.folder;delete body.tag_mode;
|
||||
await route.fulfill({response,json:body});
|
||||
});
|
||||
await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
|
||||
await expect(page.locator('#workspace-main').getByText('Prompt library could not be loaded. Retry.')).toBeVisible();
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// D150: the Refine dialog is the concept's Filters dialog: its Folder select and Tags input, plus Tag match drawn as the
|
||||
// concept's own select; the concept's "Prompt evidence" (no data yet) and its prototype note are not shown.
|
||||
function mapFiltersDialog(reference,{folders,matchOptions}) {
|
||||
const [conceptFolder,,conceptTags]=reference.fields;
|
||||
return {...reference,
|
||||
fields:[{...conceptFolder,options:folders},{...conceptTags},{...conceptFolder,label:'Tag match',options:matchOptions}],
|
||||
small:[],
|
||||
// Production's Apply reads "Apply filters" (Images and Library share it, #249).
|
||||
actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Apply tag filter'?{...item,text:'Apply filters'}:item)}};
|
||||
}
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`Refine dialog matches the concept Filters dialog ${width} ${theme} (D150)`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
await page.goto('about:blank');await page.goto(`${concept}#library`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const reference=await page.locator('#modal').evaluate(inventoryOf);
|
||||
await captureCanonicalState(page,testInfo,`concept-filters-${width}-${theme}`,'#modal');
|
||||
await open(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openRefine(page);
|
||||
await expect(folder(page).locator('option')).toHaveCount(2);
|
||||
const actual=await refine(page).evaluate(inventoryOf);
|
||||
await captureCanonicalState(page,testInfo,`production-refine-${width}-${theme}`,'dialog[open]');
|
||||
const expected=mapFiltersDialog(reference,{folders:[['','All output folders'],['shared','shared']],matchOptions:[['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]});
|
||||
const path=testInfo.outputPath('refine-inventory.json');await writeFile(path,JSON.stringify({reference,actual},null,2));
|
||||
await testInfo.attach('refine-inventory',{path,contentType:'application/json'});
|
||||
expect(reference.fields.map(item=>item.label)).toEqual(['Folder','Prompt evidence','Tags']);
|
||||
expect(diffInventory(expected,actual)).toEqual([]);
|
||||
await refine(page).getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
await openRefine(page);await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
|
||||
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await page.mouse.move(0,0);
|
||||
await captureCanonicalState(page,testInfo,`production-library-folder-any-${width}-${theme}`,'#workspace-main');
|
||||
});
|
||||
@@ -155,8 +155,7 @@ test('Retry re-reads a failed category list with the shelf',async({page})=>{
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
|
||||
// Only the folder list Refine reads on opening (D150), never the shelf.
|
||||
await page.waitForTimeout(400);expect(reads).toEqual(['/prompt_manager/library/options']);await expect(cards(page)).toHaveCount(3);
|
||||
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
|
||||
@@ -201,7 +200,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
|
||||
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
|
||||
|
||||
@@ -208,7 +208,7 @@ test('a view stored under older rules still lists and opens with its usable valu
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
|
||||
expect(await listed(request)).toEqual([
|
||||
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all' } }),
|
||||
]);
|
||||
// A state that is no JSON object is never listed, and its link behaves like a missing view.
|
||||
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
|
||||
@@ -233,7 +233,7 @@ test('a view stored under older rules still lists and opens with its usable valu
|
||||
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
|
||||
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
|
||||
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
|
||||
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
|
||||
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all' } });
|
||||
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
|
||||
const listings = [];
|
||||
page.on('request', item => {
|
||||
@@ -257,7 +257,7 @@ test('a view saved under Highest rated opens on Recently added on Images and the
|
||||
|
||||
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
|
||||
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
|
||||
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
@@ -300,7 +300,7 @@ test('a toolbar change while sources load keeps the saved Source and Folder (#24
|
||||
});
|
||||
|
||||
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
let releaseList = () => {};
|
||||
const holdList = async () => {
|
||||
const held = new Promise(resolve => { releaseList = resolve; });
|
||||
@@ -348,7 +348,7 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
|
||||
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
|
||||
@@ -539,7 +539,7 @@ test('Edit renames an open view, and deletes it only after confirming inside the
|
||||
});
|
||||
|
||||
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
|
||||
@@ -104,20 +104,6 @@ test('archived variant reads use their own scope and cursor without changing the
|
||||
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
|
||||
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
|
||||
});
|
||||
test('tag mode any and a folder are sent, echoed and validated (#444)',async()=>{
|
||||
const {createLibraryStream,LIBRARY_TAG_MODES}=await source;assert.deepEqual(LIBRARY_TAG_MODES,['all','any','untagged']);
|
||||
const calls=[];const pending=[{...page([item(1)]),tag_mode:'any',folder:'2026/Aug'},{...page([item(2)]),tag_mode:'all',folder:''},page([item(3)]),{...page([item(4)]),tag_mode:'all'}];
|
||||
const s=createLibraryStream({api:{get:async(url)=>{calls.push(new URL(url,'https://x').searchParams);return pending.shift();}}});
|
||||
assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),1);
|
||||
assert.equal(calls[0].get('tag_mode'),'any');assert.equal(calls[0].get('folder'),'2026/Aug');assert.deepEqual(calls[0].getAll('tag'),['a','b']);
|
||||
// An older server that ignored them answers without the echo: an error, never the unfiltered shelf.
|
||||
assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),0);assert.ok(s.state.error instanceof TypeError);
|
||||
// The defaults send neither, so they need no echo.
|
||||
assert.equal(await s.reset({tagMode:'all',folder:''}),1);assert.equal(calls[2].has('tag_mode'),false);assert.equal(calls[2].has('folder'),false);
|
||||
assert.equal(await s.reset({folder:'x'}),0);assert.ok(s.state.error instanceof TypeError,'a folder without its echo is refused');
|
||||
await assert.rejects(s.reset({tagMode:'or'}),TypeError);await assert.rejects(s.reset({folder:3}),TypeError);
|
||||
assert.equal(calls.length,4);s.dispose();
|
||||
});
|
||||
test('tag mode untagged is sent, echoed and never combined with tags (#444)',async()=>{
|
||||
const {createLibraryStream}=await source;const calls=[];const pending=[{...page([item(1)]),tag_mode:'untagged'},page([item(2)]),page([item(3)])];
|
||||
const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
|
||||
@@ -126,7 +112,7 @@ test('tag mode untagged is sent, echoed and never combined with tags (#444)',asy
|
||||
assert.equal(await s.reset({tagMode:'untagged'}),0);assert.ok(s.state.error instanceof TypeError);assert.deepEqual(s.state.items,[]);
|
||||
// The default sends no tag_mode, so it needs no echo.
|
||||
assert.equal(await s.reset({tagMode:'all'}),1);assert.equal(new URL(calls[2],'https://x').searchParams.has('tag_mode'),false);
|
||||
await assert.rejects(s.reset({tagMode:'untagged',tags:['a']}),TypeError);await assert.rejects(s.reset({tagMode:'or'}),TypeError);
|
||||
await assert.rejects(s.reset({tagMode:'untagged',tags:['a']}),TypeError);await assert.rejects(s.reset({tagMode:'none'}),TypeError);
|
||||
assert.equal(calls.length,3);s.dispose();
|
||||
});
|
||||
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
|
||||
@@ -145,28 +131,3 @@ test('the shelf no longer sorts by rating (D153)',async()=>{
|
||||
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
|
||||
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
|
||||
});
|
||||
test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{
|
||||
const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function');
|
||||
const I=' ';
|
||||
// The server's flat list sorts full paths as text, so "-" and "_" land before "/" (live report shapes).
|
||||
const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',
|
||||
'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','illustrious','illustrious-base','illustrious/2026-10-26','illustrious/training',
|
||||
'illustrious/training/a','kiko','kiko-v6','kiko/char','kiko/XXX','QWEN','QWEN/x','Qwen','Qwen/y','Qwen-Training','Video','Video/v','video'].sort();
|
||||
const options=libraryFolderOptions(folders);
|
||||
assert.deepEqual(options[0],['','All output folders']);
|
||||
assert.deepEqual(options.slice(1),[
|
||||
['CCC','CCC'],['CCC/mabes9sans-dataset',`${I}mabes9sans-dataset`],['CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',`${I}${I}mabes9sans-dataset_IMG_GEN`],
|
||||
['CCC/mabes9sans-dataset-v1',`${I}mabes9sans-dataset-v1`],
|
||||
['FLUX','FLUX'],['FLUX/2025-12-11',`${I}2025-12-11`],['flux','flux'],['flux/images',`${I}images`],['flux-klein','flux-klein'],
|
||||
['illustrious','illustrious'],['illustrious/2026-10-26',`${I}2026-10-26`],['illustrious/training',`${I}training`],['illustrious/training/a',`${I}${I}a`],
|
||||
['illustrious-base','illustrious-base'],
|
||||
['kiko','kiko'],['kiko/char',`${I}char`],['kiko/XXX',`${I}XXX`],['kiko-v6','kiko-v6'],
|
||||
['QWEN','QWEN'],['QWEN/x',`${I}x`],['Qwen','Qwen'],['Qwen/y',`${I}y`],['Qwen-Training','Qwen-Training'],
|
||||
['Video','Video'],['Video/v',`${I}v`],['video','video'],
|
||||
]);
|
||||
// Every parent precedes its children directly; a chosen folder missing from the list is kept, with its parents.
|
||||
assert.deepEqual(libraryFolderOptions(['a'],'a/b/c').slice(1),[['a','a'],['a/b',`${I}b`],['a/b/c',`${I}${I}c`]]);
|
||||
assert.deepEqual(libraryFolderOptions([],''),[['','All output folders']]);
|
||||
// A POSIX folder whose name holds a backslash is one segment.
|
||||
assert.deepEqual(libraryFolderOptions(['Metadata\\Klein_4b Wildcard Run']).slice(1),[['Metadata\\Klein_4b Wildcard Run','Metadata\\Klein_4b Wildcard Run']]);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/saved-views.mjs');
|
||||
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all' };
|
||||
|
||||
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
|
||||
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
|
||||
@@ -31,8 +31,8 @@ test('a saved state always holds every page key and keeps only values its page a
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
|
||||
{ ...full, q: '', folder: '', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
|
||||
// Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own.
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
|
||||
// Library has no source, folder or media, and no Name Z–A.
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all' });
|
||||
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
|
||||
// The returned tags are a copy.
|
||||
const tags = ['one'];
|
||||
@@ -62,7 +62,7 @@ test('the listing keeps usable views in order and normalizes their state', async
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
|
||||
] });
|
||||
assert.deepEqual(views, [
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all', folder: '' } },
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all' } },
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
|
||||
]);
|
||||
assert.equal(parseSavedView(null), null);
|
||||
@@ -84,8 +84,8 @@ test('page filters and saved states convert both ways', async () => {
|
||||
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
|
||||
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
|
||||
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'any', folder: '2026' };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'any', folder: '2026' });
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'all' };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'all' });
|
||||
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
|
||||
});
|
||||
|
||||
@@ -216,27 +216,16 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
|
||||
assert.deepEqual(api.calls, []);
|
||||
});
|
||||
|
||||
test('a Library view keeps its tag match and folder; an old view reads the defaults (#444)', async () => {
|
||||
const { normalizeViewState, libraryFilters } = await source;
|
||||
assert.deepEqual(normalizeViewState('library', { tag_mode: 'any', folder: '2026/Aug' }), { ...LIBRARY_DEFAULTS, tag_mode: 'any', folder: '2026/Aug' });
|
||||
assert.deepEqual(normalizeViewState('library', { tag_mode: 'or', folder: 'x'.repeat(1025) }), LIBRARY_DEFAULTS);
|
||||
assert.deepEqual(libraryFilters({ q: 'a' }), { text: 'a', category: '', sort: 'newest', tags: [], tagMode: 'all', folder: '' });
|
||||
});
|
||||
|
||||
test('a Library view keeps untagged, and untagged never travels with tags (#444)', async () => {
|
||||
test('a Library view keeps its tag match, and untagged never travels with tags (#444)', async () => {
|
||||
const { normalizeViewState, libraryFilters, libraryViewState } = await source;
|
||||
assert.equal(normalizeViewState('library', { tag_mode: 'untagged' }).tag_mode, 'untagged');
|
||||
assert.equal(normalizeViewState('library', { tag_mode: 'any' }).tag_mode, 'all');
|
||||
assert.deepEqual(normalizeViewState('library', { tags: ['a'], tag_mode: 'untagged' }), { ...LIBRARY_DEFAULTS, tags: ['a'] });
|
||||
const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged', folder: '' };
|
||||
const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged' };
|
||||
assert.equal(libraryViewState(shelf).tag_mode, 'untagged');
|
||||
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
|
||||
});
|
||||
|
||||
test('a shelf without the #444 filters saves their defaults', async () => {
|
||||
const { libraryViewState } = await source;
|
||||
assert.deepEqual(libraryViewState({ text: '', category: '', sort: 'newest', tags: [] }), LIBRARY_DEFAULTS);
|
||||
});
|
||||
|
||||
test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => {
|
||||
const { normalizeViewState, imagesFilters, libraryFilters } = await source;
|
||||
assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest');
|
||||
|
||||
@@ -684,132 +684,6 @@ class TestStaticRoutes(CoreRoutesTestCase):
|
||||
self.assertEqual(resp.status, 403)
|
||||
|
||||
|
||||
class TestRevalidatedCaching(CoreRoutesTestCase):
|
||||
"""Every served page and static file is revalidated on reload (#478).
|
||||
|
||||
``Cache-Control: no-cache`` makes the browser ask before it reuses a copy,
|
||||
and the ETag lets an unchanged file answer 304 without a body, so an update
|
||||
reaches the browser on a normal reload and an unchanged reload stays cheap.
|
||||
"""
|
||||
|
||||
STATIC_PATHS = (
|
||||
"/prompt_manager/js/workspace/index.mjs",
|
||||
"/prompt_manager/js/admin.js",
|
||||
"/prompt_manager/js/inspection-semantics.json",
|
||||
"/prompt_manager/lib/viewerjs/viewer.min.css",
|
||||
)
|
||||
|
||||
async def _assert_revalidated(self, path):
|
||||
resp = await self.client.request("GET", path)
|
||||
self.assertEqual(resp.status, 200, path)
|
||||
self.assertEqual(resp.headers.get("Cache-Control"), "no-cache", path)
|
||||
etag = resp.headers.get("ETag")
|
||||
self.assertTrue(etag, path)
|
||||
body = await resp.read()
|
||||
self.assertTrue(body, path)
|
||||
|
||||
again = await self.client.request("GET", path, headers={"If-None-Match": etag})
|
||||
self.assertEqual(again.status, 304, path)
|
||||
self.assertEqual(again.headers.get("ETag"), etag, path)
|
||||
self.assertEqual(again.headers.get("Cache-Control"), "no-cache", path)
|
||||
self.assertEqual(await again.read(), b"", path)
|
||||
|
||||
stale = await self.client.request(
|
||||
"GET", path, headers={"If-None-Match": 'W/"stale"'}
|
||||
)
|
||||
self.assertEqual(stale.status, 200, path)
|
||||
self.assertEqual(await stale.read(), body, path)
|
||||
return etag
|
||||
|
||||
async def test_static_files_are_revalidated(self):
|
||||
for path in self.STATIC_PATHS:
|
||||
await self._assert_revalidated(path)
|
||||
|
||||
async def test_pages_are_revalidated(self):
|
||||
for path in UI_PATHS:
|
||||
await self._assert_revalidated(path)
|
||||
|
||||
async def test_etag_matches_in_a_list_and_as_wildcard(self):
|
||||
path = "/prompt_manager/js/workspace/index.mjs"
|
||||
etag = (await self.client.request("GET", path)).headers["ETag"]
|
||||
for header in (f'W/"other", {etag}', etag.removeprefix("W/"), "*"):
|
||||
resp = await self.client.request(
|
||||
"GET", path, headers={"If-None-Match": header}
|
||||
)
|
||||
self.assertEqual(resp.status, 304, header)
|
||||
|
||||
async def test_changed_file_gets_a_new_etag_and_its_new_body(self):
|
||||
web_dir = self._fake_root()
|
||||
module = web_dir / "js" / "m.mjs"
|
||||
module.write_text("export const v = 1;")
|
||||
path = "/prompt_manager/js/m.mjs"
|
||||
first = await self.client.request("GET", path)
|
||||
etag = first.headers["ETag"]
|
||||
# Same size and, on coarse clocks, the same mtime: only the bytes differ.
|
||||
module.write_text("export const v = 2;")
|
||||
resp = await self.client.request("GET", path, headers={"If-None-Match": etag})
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertNotEqual(resp.headers["ETag"], etag)
|
||||
self.assertEqual(await resp.text(), "export const v = 2;")
|
||||
|
||||
async def test_gzipped_and_plain_bodies_share_a_weak_etag(self):
|
||||
"""The gzip middleware changes the bytes, so the tag must be weak."""
|
||||
resp = await self.client.request("GET", "/prompt_manager/js/admin.js")
|
||||
self.assertTrue(resp.headers["ETag"].startswith('W/"'))
|
||||
|
||||
async def test_errors_carry_no_validator(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/js/nope.js")
|
||||
self.assertEqual(resp.status, 404)
|
||||
self.assertNotIn("ETag", resp.headers)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def _comfyui_cache_control(request, handler):
|
||||
"""ComfyUI's own middleware (middleware/cache_middleware.py), JS/CSS branch.
|
||||
|
||||
It only fills a missing Cache-Control, and ``.mjs`` is not ``.js``: that is
|
||||
why the workspace modules went out with no header at all before #478.
|
||||
"""
|
||||
response = await handler(request)
|
||||
if request.path.endswith(".js") or request.path.endswith(".css"):
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
return response
|
||||
|
||||
|
||||
class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
|
||||
"""The host's middlewares keep the header and the 304 intact."""
|
||||
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
app.middlewares.append(_comfyui_cache_control)
|
||||
app.middlewares.append(api_module._gzip_middleware)
|
||||
return app
|
||||
|
||||
async def test_gzipped_files_keep_no_cache_and_answer_304(self):
|
||||
for path in (
|
||||
"/prompt_manager/js/workspace/index.mjs",
|
||||
"/prompt_manager/js/admin.js",
|
||||
"/prompt_manager/lib/viewerjs/viewer.min.css",
|
||||
"/prompt_manager/workspace",
|
||||
):
|
||||
resp = await self.client.request(
|
||||
"GET", path, headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
self.assertEqual(resp.status, 200, path)
|
||||
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip", path)
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-cache", path)
|
||||
again = await self.client.request(
|
||||
"GET",
|
||||
path,
|
||||
headers={
|
||||
"Accept-Encoding": "gzip",
|
||||
"If-None-Match": resp.headers["ETag"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(again.status, 304, path)
|
||||
self.assertEqual(again.headers["Cache-Control"], "no-cache", path)
|
||||
|
||||
|
||||
# ── instance helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -1015,122 +1015,6 @@ class HistoryOutputAPITests(ImageAPITestCase):
|
||||
response = await self.current(descriptor["inspection_url"])
|
||||
self.assert_error(response, await response.json(), 422, "response-limit")
|
||||
|
||||
async def tag_image(self, relative, tags):
|
||||
"""Save *tags* on Images root 0's *relative* file through the Tag routes.
|
||||
|
||||
The real observe and save routes store the locator the Tag dialog
|
||||
stores (``media_capture._resolve``), so History must find it (#457).
|
||||
"""
|
||||
self.addCleanup(self.api.close_annotation_captures)
|
||||
roots = self.api._get_all_output_dirs()
|
||||
target = {
|
||||
"root_index": 0,
|
||||
"root_scope": self.api._gallery_root_scope(roots[0], 0),
|
||||
"path": relative,
|
||||
}
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/image-annotations/observe", json={"target": target}
|
||||
)
|
||||
observed = await response.json()
|
||||
self.assertEqual(response.status, 200, observed)
|
||||
token = observed["capture"]["token"]
|
||||
try:
|
||||
response = await self.client.put(
|
||||
"/prompt_manager/image-annotations/tags",
|
||||
json={
|
||||
"target": target,
|
||||
"capture_token": token,
|
||||
"sha256": observed["capture"]["sha256"],
|
||||
"expected_epoch": observed["library_epoch"],
|
||||
"expected_tag_revision": observed["tag_revision"],
|
||||
"tags": list(tags),
|
||||
},
|
||||
)
|
||||
saved = await response.json()
|
||||
self.assertEqual(response.status, 200, saved)
|
||||
finally:
|
||||
await self.client.delete(
|
||||
f"/prompt_manager/image-annotations/capture/{token}"
|
||||
)
|
||||
return saved["tags"]
|
||||
|
||||
async def test_current_inspection_carries_the_files_image_tags(self):
|
||||
"""#457: History reads the file's own image tags, as Images does (S1)."""
|
||||
sub = self.output_dir / "batch"
|
||||
sub.mkdir()
|
||||
tagged = png(sub / "tagged.png", prompt=graph("Tagged file"))
|
||||
png(self.output_dir / "plain.png", prompt=graph("Plain file"))
|
||||
self.assertEqual(
|
||||
await self.tag_image("batch/tagged.png", ["dunes", "warm_light"]),
|
||||
["dunes", "warm_light"],
|
||||
)
|
||||
start = self.capture(
|
||||
[
|
||||
self.ref("1", subfolder="batch", filename="tagged.png"),
|
||||
self.ref("2", filename="plain.png"),
|
||||
]
|
||||
)
|
||||
_, page = await self.outputs(start)
|
||||
urls = [
|
||||
item["current_candidate"]["media"]["inspection_url"]
|
||||
for item in page["items"]
|
||||
]
|
||||
bodies = []
|
||||
for url in urls:
|
||||
response = await self.current(url)
|
||||
body = await response.json()
|
||||
self.assertEqual(response.status, 200, body)
|
||||
bodies.append(body)
|
||||
self.assertEqual(
|
||||
bodies[0].get("image_tags"),
|
||||
{"state": "current", "tags": ["dunes", "warm_light"]},
|
||||
)
|
||||
# Image tags are the file's own search metadata, never prompt tags.
|
||||
self.assertEqual(bodies[0]["prompts"]["positive"]["text"], "Tagged file")
|
||||
self.assertEqual(bodies[1].get("image_tags"), {"state": "none", "tags": []})
|
||||
# New bytes at the same path: the tags belong to the old byte record.
|
||||
stat = tagged.stat()
|
||||
os.utime(tagged, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9))
|
||||
response = await self.current(urls[0])
|
||||
body = await response.json()
|
||||
self.assertEqual(response.status, 200, body)
|
||||
self.assertEqual(body["image_tags"], {"state": "none", "tags": []})
|
||||
|
||||
async def test_current_inspection_finds_image_tags_under_a_symlinked_output(self):
|
||||
"""An output folder reached through a link (another drive) still matches."""
|
||||
link = self.comfy_dir / "linked_output"
|
||||
self._make_symlink(self.output_dir, link, target_is_directory=True)
|
||||
self.api._get_all_output_dirs = lambda: [link]
|
||||
self.host_output = str(link)
|
||||
png(self.output_dir / "image.png", prompt=graph("Linked folder"))
|
||||
self.assertEqual(await self.tag_image("image.png", ["dunes"]), ["dunes"])
|
||||
start = self.capture([self.ref()])
|
||||
descriptor = await self.media(start)
|
||||
response = await self.current(descriptor["inspection_url"])
|
||||
body = await response.json()
|
||||
self.assertEqual(response.status, 200, body)
|
||||
self.assertEqual(body["image_tags"], {"state": "current", "tags": ["dunes"]})
|
||||
|
||||
async def test_current_inspection_image_tag_failure_is_unavailable(self):
|
||||
png(self.output_dir / "image.png", prompt=graph("File evidence"))
|
||||
await self.tag_image("image.png", ["dunes"])
|
||||
start = self.capture([self.ref()])
|
||||
descriptor = await self.media(start)
|
||||
with (
|
||||
self.assertLogs(self.api.logger, "WARNING") as logs,
|
||||
patch.object(
|
||||
type(self.api.db),
|
||||
"current_image_tags",
|
||||
side_effect=sqlite3.OperationalError("locked"),
|
||||
),
|
||||
):
|
||||
response = await self.current(descriptor["inspection_url"])
|
||||
body = await response.json()
|
||||
self.assertEqual(response.status, 200, body)
|
||||
self.assertEqual(body["image_tags"], {"state": "unavailable", "tags": []})
|
||||
self.assertEqual(body["prompts"]["positive"]["text"], "File evidence")
|
||||
self.assertNotIn(str(self.output_dir), "\n".join(logs.output))
|
||||
|
||||
async def test_optional_candidate_failure_preserves_every_occurrence_without_urls(
|
||||
self,
|
||||
):
|
||||
|
||||
@@ -35,7 +35,6 @@ from aiohttp import web # noqa: E402
|
||||
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from database.models import PromptModel # noqa: E402
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
import py.api.images as images_module
|
||||
from py.api import gallery_listing # noqa: E402
|
||||
@@ -93,41 +92,14 @@ class ImageAPITestCase(AioHTTPTestCase):
|
||||
self._anchor_patch.stop()
|
||||
# Windows refuses to unlink a database that still has open handles.
|
||||
self.api.db.close_all()
|
||||
try:
|
||||
leaked = self._close_leaked_connections()
|
||||
finally:
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
self._temp_db.name + "-wal",
|
||||
self._temp_db.name + "-shm",
|
||||
):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
self._output_tmp.cleanup()
|
||||
if leaked:
|
||||
raise AssertionError(
|
||||
f"{leaked} connection(s) to the test database were still open at "
|
||||
"teardown; Windows cannot unlink it (WinError 32). Close every "
|
||||
"extra PromptDatabase before tearDownAsync, not in a cleanup."
|
||||
)
|
||||
|
||||
def _close_leaked_connections(self):
|
||||
"""Close and count connections other models still hold on the temp DB.
|
||||
|
||||
Linux unlinks an open SQLite file, Windows does not; counting here makes
|
||||
a handle left open past teardown fail on every OS (#475).
|
||||
"""
|
||||
key = os.path.normcase(os.path.realpath(self._temp_db.name))
|
||||
with PromptModel._instances_lock:
|
||||
models = list(PromptModel._instances)
|
||||
leaked = 0
|
||||
for model in models:
|
||||
if model._schema_key() != key:
|
||||
continue
|
||||
with model._conn_lock:
|
||||
leaked += len(model._connections)
|
||||
model.close_all()
|
||||
return leaked
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
self._temp_db.name + "-wal",
|
||||
self._temp_db.name + "-shm",
|
||||
):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
self._output_tmp.cleanup()
|
||||
|
||||
# -- helpers ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -123,72 +123,6 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
async def test_folder_filter_lists_and_matches_prompts_by_output_folder(self):
|
||||
deep = self._save_prompt("deep")
|
||||
top = self._save_prompt("top")
|
||||
other = self._save_prompt("other")
|
||||
self._save_prompt("no images")
|
||||
for prompt, parts in ((deep, ("a", "b")), (top, ("a",)), (other, ("ab",))):
|
||||
path = make_png(self.output_dir.joinpath(*parts, f"{prompt}.png"))
|
||||
self.api.db.link_image_to_prompt(prompt, str(path))
|
||||
response, options = await self.get("/options", include="folders")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(options["folders"], ["a", "a/b", "ab"])
|
||||
_, plain = await self.get("/options")
|
||||
self.assertNotIn("folders", plain)
|
||||
_, first = await self.get(folder="a", limit=1)
|
||||
self.assertEqual((first["total"], first["folder"]), (2, "a"))
|
||||
# Outer slashes are dropped (a backslash separates only on Windows).
|
||||
outer = "\\a\\" if os.name == "nt" else "/a/"
|
||||
_, second = await self.get(folder=outer, limit=1, cursor=first["next_cursor"])
|
||||
self.assertEqual(
|
||||
sorted([first["items"][0]["text"], second["items"][0]["text"]]),
|
||||
["deep", "top"],
|
||||
)
|
||||
_, all_rows = await self.get()
|
||||
self.assertEqual((all_rows["total"], all_rows["folder"]), (4, ""))
|
||||
for params in (
|
||||
{"folder": "a/../ab"},
|
||||
*([{"folder": "C:/x"}] if os.name == "nt" else []),
|
||||
{"folder": "b", "cursor": first["next_cursor"]},
|
||||
{"cursor": first["next_cursor"]},
|
||||
):
|
||||
response, result = await self.get(limit=1, **params)
|
||||
self.assertEqual(response.status, 400, params)
|
||||
self.assertEqual(result["success"], False)
|
||||
response, _ = await self.get("/options", include="tags")
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows")
|
||||
async def test_a_posix_folder_name_with_a_backslash_filters_its_prompts(self):
|
||||
prompt = self._save_prompt("klein")
|
||||
name = "Metadata\\Klein_4b Wildcard Run"
|
||||
path = make_png(self.output_dir / name / "x.png")
|
||||
self.api.db.link_image_to_prompt(prompt, str(path))
|
||||
_, options = await self.get("/options", include="folders")
|
||||
self.assertIn(name, options["folders"])
|
||||
_, result = await self.get(folder=name)
|
||||
self.assertEqual((result["total"], result["folder"]), (1, name))
|
||||
|
||||
async def test_any_tag_mode_is_sent_through_and_echoed(self):
|
||||
self._save_prompt("a only", tags=["a"])
|
||||
self._save_prompt("b only", tags=["b"])
|
||||
self._save_prompt("neither")
|
||||
_, result = await self.get(tag=["a", "b"], tag_mode="any")
|
||||
self.assertEqual(result["tag_mode"], "any")
|
||||
self.assertEqual(
|
||||
sorted(i["text"] for i in result["items"]), ["a only", "b only"]
|
||||
)
|
||||
_, both = await self.get(tag=["a", "b"])
|
||||
self.assertEqual((both["total"], both["tag_mode"]), (0, "all"))
|
||||
_, first = await self.get(tag=["a", "b"], tag_mode="any", limit=1)
|
||||
response, _ = await self.get(
|
||||
tag=["a", "b"], limit=1, cursor=first["next_cursor"]
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
response, _ = await self.get(tag_mode="or")
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_variant_cursor_is_positive_scoped_and_required_limits_are_bounded(
|
||||
self,
|
||||
):
|
||||
|
||||
@@ -38,25 +38,11 @@ def _payload(token):
|
||||
|
||||
class TestRestartKeepsTheOpenPage(ImageAPITestCase):
|
||||
async def get_application(self):
|
||||
self._restarts = []
|
||||
app = await super().get_application()
|
||||
for index in range(3):
|
||||
make_png(self.output_dir / f"image_{index}.png")
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
# Before the base teardown unlinks the database: cleanups run after
|
||||
# tearDownAsync, and Windows cannot unlink a file still open (#475).
|
||||
errors = []
|
||||
while self._restarts:
|
||||
try:
|
||||
await self._restarts.pop()()
|
||||
except Exception as error: # every closer runs; the first error wins
|
||||
errors.append(error)
|
||||
await super().tearDownAsync()
|
||||
if errors:
|
||||
raise errors[0]
|
||||
|
||||
async def first_page(self, client=None, **params):
|
||||
response = await (client or self.client).get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", **params}
|
||||
@@ -81,7 +67,7 @@ class TestRestartKeepsTheOpenPage(ImageAPITestCase):
|
||||
api.close_auto_thumbnails()
|
||||
api.db.close_all()
|
||||
|
||||
self._restarts.append(close)
|
||||
self.addAsyncCleanup(close)
|
||||
return api, client
|
||||
|
||||
async def test_a_restart_keeps_every_held_scope_and_cursor_valid(self):
|
||||
@@ -166,13 +152,8 @@ class TestRestartKeepsTheOpenPage(ImageAPITestCase):
|
||||
other_db = self.comfy_dir / "elsewhere" / "prompts.db"
|
||||
other_db.parent.mkdir()
|
||||
api = PromptManagerAPI(db=PromptDatabase(str(other_db)))
|
||||
|
||||
async def close():
|
||||
api.close_auto_thumbnails()
|
||||
api.db.close_all()
|
||||
|
||||
# Closed before teardown removes the folder that holds its database.
|
||||
self._restarts.append(close)
|
||||
self.addCleanup(api.db.close_all)
|
||||
self.addCleanup(api.close_auto_thumbnails)
|
||||
self.assertNotEqual(
|
||||
api._gallery_root_scope(self.output_dir, 0), page["images"][0]["root_scope"]
|
||||
)
|
||||
|
||||
@@ -887,36 +887,6 @@ class TestImageTagsFacet(unittest.TestCase):
|
||||
self.assertNotIn("image_tags", json.loads(encode(inspection({}))))
|
||||
self.assertNotIn("image_tags", self.encoded(None))
|
||||
|
||||
def history_encoded(self, facet):
|
||||
from utils.inspection_response import encode_history_inspection_response
|
||||
|
||||
body = encode_history_inspection_response(
|
||||
current_output={"history_epoch": "epoch"},
|
||||
file={"observation": "current-file", "size": 100},
|
||||
prompts=PROMPTS,
|
||||
resolution={"state": "resolved", "sampler_id": "3", "reason": ""},
|
||||
inspection=inspection({"prompt": graph_text()}),
|
||||
library={"status": "none"},
|
||||
image_tags=facet,
|
||||
)
|
||||
return json.loads(body)
|
||||
|
||||
def test_history_envelope_validates_image_tags_like_the_rooted_one(self):
|
||||
"""#457: History's current-file read carries the same facet."""
|
||||
for facet in (
|
||||
{"state": "current", "tags": ["dunes", "warm_light"]},
|
||||
{"state": "none", "tags": []},
|
||||
{"state": "unavailable", "tags": []},
|
||||
{"state": "current", "tags": ["a,b"]},
|
||||
{"state": "current", "tags": [], "locator": "/abs/path.png"},
|
||||
):
|
||||
with self.subTest(facet=facet):
|
||||
self.assertEqual(
|
||||
self.history_encoded(facet)["image_tags"],
|
||||
self.encoded(facet)["image_tags"],
|
||||
)
|
||||
self.assertNotIn("image_tags", self.history_encoded(None))
|
||||
|
||||
|
||||
class TestVideoProbeBoundsMatchPngBudgets(unittest.TestCase):
|
||||
"""ffprobe escapes tag text; the stdout bounds must never gate a record
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"""#444: the Library's output-folder filter and multi-tag OR."""
|
||||
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database.library_folders import (
|
||||
folder_prefixes,
|
||||
normalize_folder,
|
||||
relative_folder,
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class TestFolderNames(unittest.TestCase):
|
||||
def test_normalize_strips_outer_slashes_on_both_flavors(self):
|
||||
for flavor in (posixpath, ntpath):
|
||||
with self.subTest(flavor=flavor.__name__):
|
||||
self.assertEqual(normalize_folder("", flavor), "")
|
||||
self.assertEqual(normalize_folder("/", flavor), "")
|
||||
self.assertEqual(normalize_folder("/a/b/", flavor), "a/b")
|
||||
self.assertEqual(normalize_folder("..foo/b", flavor), "..foo/b")
|
||||
|
||||
def test_a_backslash_separates_only_on_windows(self):
|
||||
# Live B1: on POSIX a backslash is part of a folder name, as listed.
|
||||
name = "Metadata\\Klein_4b Wildcard Run"
|
||||
self.assertEqual(normalize_folder(name, posixpath), name)
|
||||
self.assertEqual(
|
||||
normalize_folder("2026\\08-Aug\\", posixpath), "2026\\08-Aug\\"
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_folder(name, ntpath), "Metadata/Klein_4b Wildcard Run"
|
||||
)
|
||||
self.assertEqual(normalize_folder("2026\\08-Aug\\", ntpath), "2026/08-Aug")
|
||||
# A colon names a drive only on Windows.
|
||||
self.assertEqual(normalize_folder("a:b/c", posixpath), "a:b/c")
|
||||
|
||||
def test_normalize_refuses_anything_but_a_folder_below_a_root(self):
|
||||
for flavor, value in (
|
||||
*(
|
||||
(flavor, value)
|
||||
for flavor in (posixpath, ntpath)
|
||||
for value in ("a/../b", "./a", "a//b", "a\x00b", 3, "x" * 1025)
|
||||
),
|
||||
(ntpath, "C:/x"),
|
||||
(ntpath, "c:"),
|
||||
(ntpath, "a\\..\\b"),
|
||||
):
|
||||
with self.subTest(flavor=flavor.__name__, value=value):
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_folder(value, flavor)
|
||||
|
||||
def test_the_default_flavor_is_the_platform_path_module(self):
|
||||
self.assertEqual(normalize_folder("a\\b"), normalize_folder("a\\b", os.path))
|
||||
|
||||
def test_posix_prefixes_are_exact_and_end_in_a_separator(self):
|
||||
self.assertEqual(
|
||||
folder_prefixes(["/out", "/data/out/"], "a/b", posixpath),
|
||||
["/out/a/b/", "/data/out/a/b/"],
|
||||
)
|
||||
self.assertEqual(folder_prefixes(["/"], "a", posixpath), ["/a/"])
|
||||
self.assertEqual(
|
||||
folder_prefixes(["/out", "/out/"], "a", posixpath), ["/out/a/"]
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
folder_prefixes([], "a", posixpath)
|
||||
|
||||
def test_windows_prefixes_fold_case_including_non_ascii(self):
|
||||
self.assertEqual(
|
||||
folder_prefixes(["C:\\ComfyUI\\Output"], "Ä/Sub", ntpath),
|
||||
["c:\\comfyui\\output\\ä\\sub\\"],
|
||||
)
|
||||
self.assertEqual(folder_prefixes(["D:\\"], "x", ntpath), ["d:\\x\\"])
|
||||
self.assertEqual(
|
||||
folder_prefixes(["\\\\Server\\Share\\Out"], "a", ntpath),
|
||||
["\\\\server\\share\\out\\a\\"],
|
||||
)
|
||||
# The stored file_path is normcase(normpath(abspath)), the same fold.
|
||||
stored = ntpath.normcase(ntpath.normpath("C:/ComfyUI/output/Ä/sub/x.png"))
|
||||
self.assertTrue(
|
||||
stored.startswith(
|
||||
folder_prefixes(["C:/ComfyUI/Output"], "ä/SUB", ntpath)[0]
|
||||
)
|
||||
)
|
||||
|
||||
def test_relative_folder_keeps_the_stored_spelling(self):
|
||||
self.assertEqual(
|
||||
relative_folder("/out/2026/Aug/x.png", ["/out"], posixpath), "2026/Aug"
|
||||
)
|
||||
self.assertIsNone(relative_folder("/out/x.png", ["/out"], posixpath))
|
||||
self.assertIsNone(relative_folder("/outside/x.png", ["/out"], posixpath))
|
||||
self.assertIsNone(relative_folder("/output2/a/x.png", ["/out"], posixpath))
|
||||
self.assertEqual(
|
||||
relative_folder("/out/..foo/x.png", ["/out"], posixpath), "..foo"
|
||||
)
|
||||
self.assertEqual(
|
||||
relative_folder("C:\\Out\\Ä\\Sub\\x.png", ["c:\\out"], ntpath), "Ä/Sub"
|
||||
)
|
||||
self.assertEqual(relative_folder("D:\\a\\x.png", ["D:\\"], ntpath), "a")
|
||||
|
||||
|
||||
class TestLibraryFolderAndAnyTag(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
base = Path(os.path.realpath(self.temp.name))
|
||||
self.root = base / "output"
|
||||
self.root.mkdir()
|
||||
self.db = PromptDatabase(str(base / "folders.db"))
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self.db.close_all()
|
||||
finally:
|
||||
self.temp.cleanup()
|
||||
|
||||
def save(self, text, **metadata):
|
||||
return self.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **metadata
|
||||
)
|
||||
|
||||
def link(self, prompt_id, *parts):
|
||||
path = self.root.joinpath(*parts)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x")
|
||||
self.db.link_image_to_prompt(prompt_id, str(path))
|
||||
|
||||
def texts(self, **options):
|
||||
return sorted(item["text"] for item in self.db.list_library(**options)["items"])
|
||||
|
||||
def in_folder(self, folder, roots=None):
|
||||
return self.texts(
|
||||
folder_prefixes=folder_prefixes(roots or [str(self.root)], folder)
|
||||
)
|
||||
|
||||
def test_folder_matches_its_descendants_only(self):
|
||||
self.link(self.save("deep"), "a", "b", "x.png")
|
||||
self.link(self.save("top"), "a", "y.png")
|
||||
self.link(self.save("sibling"), "ab", "z.png")
|
||||
self.link(self.save("literal"), "100%_off", "w.png")
|
||||
self.save("no images")
|
||||
self.assertEqual(self.in_folder("a"), ["deep", "top"])
|
||||
self.assertEqual(self.in_folder("a/b"), ["deep"])
|
||||
self.assertEqual(self.in_folder("ab"), ["sibling"])
|
||||
self.assertEqual(self.in_folder("100%_off"), ["literal"])
|
||||
self.assertEqual(self.in_folder("1"), [])
|
||||
cut = self.db.list_library(
|
||||
folder_prefixes=folder_prefixes([str(self.root)], "a"), limit=1
|
||||
)
|
||||
self.assertEqual(cut["total"], 2)
|
||||
|
||||
def test_folder_combines_with_text_category_and_tags(self):
|
||||
self.link(self.save("red cat", category="Art", tags=["cat"]), "a", "1.png")
|
||||
self.link(self.save("red dog", category="Art", tags=["dog"]), "a", "2.png")
|
||||
self.link(self.save("red bird", category="Art", tags=["cat"]), "b", "3.png")
|
||||
prefixes = folder_prefixes([str(self.root)], "a")
|
||||
self.assertEqual(
|
||||
self.texts(folder_prefixes=prefixes, tags=["cat"]), ["red cat"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.texts(folder_prefixes=prefixes, text="dog", category="Art"),
|
||||
["red dog"],
|
||||
)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows")
|
||||
def test_folder_listing_and_match_through_a_symlinked_root(self):
|
||||
link = self.root.parent / "linked"
|
||||
os.symlink(self.root, link)
|
||||
prompt = self.save("via link")
|
||||
(self.root / "a").mkdir()
|
||||
(self.root / "a" / "x.png").write_bytes(b"x")
|
||||
self.db.link_image_to_prompt(prompt, str(link / "a" / "x.png"))
|
||||
spellings = [str(self.root), str(link)]
|
||||
self.assertEqual(self.in_folder("a", spellings), ["via link"])
|
||||
self.assertEqual(self.in_folder("a", [str(self.root)]), [])
|
||||
self.assertEqual(self.db.get_library_options(spellings)["folders"], ["a"])
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows")
|
||||
def test_a_posix_folder_with_a_backslash_lists_and_filters_alike(self):
|
||||
name = "Metadata\\Klein_4b Wildcard Run"
|
||||
self.link(self.save("klein"), name, "x.png")
|
||||
self.link(self.save("other"), "Metadata", "y.png")
|
||||
listed = self.db.get_library_options([str(self.root)])["folders"]
|
||||
self.assertEqual(listed, ["Metadata", name])
|
||||
self.assertEqual(self.in_folder(normalize_folder(name)), ["klein"])
|
||||
self.assertEqual(self.in_folder(normalize_folder("Metadata")), ["other"])
|
||||
|
||||
def test_folder_listing_offers_ancestors_and_skips_outside_images(self):
|
||||
self.link(self.save("deep"), "2026", "08-Aug", "x.png")
|
||||
self.link(self.save("root image"), "y.png")
|
||||
outside = Path(self.temp.name) / "elsewhere" / "z.png"
|
||||
outside.parent.mkdir()
|
||||
outside.write_bytes(b"x")
|
||||
self.db.link_image_to_prompt(self.save("outside"), str(outside))
|
||||
options = self.db.get_library_options([str(self.root)])
|
||||
self.assertEqual(options["folders"], ["2026", "2026/08-Aug"])
|
||||
self.assertNotIn("folders", self.db.get_library_options())
|
||||
|
||||
def test_folder_combines_with_untagged(self):
|
||||
self.link(self.save("plain in a"), "a", "1.png")
|
||||
self.link(self.save("tagged in a", tags=["t"]), "a", "2.png")
|
||||
self.link(self.save("plain in b"), "b", "3.png")
|
||||
prefixes = folder_prefixes([str(self.root)], "a")
|
||||
self.assertEqual(
|
||||
self.texts(folder_prefixes=prefixes, tag_mode="untagged"), ["plain in a"]
|
||||
)
|
||||
|
||||
def test_any_tag_mode_is_the_union_across_parent_and_pair(self):
|
||||
self.save("parent a", tags=["a"])
|
||||
self.save("parent b", tags=["b"])
|
||||
self.save("both", tags=["a", "b"])
|
||||
self.save("neither", tags=["c"])
|
||||
self.db.save_prompt_pair("pair b", "noise", tags=["b"])
|
||||
split = self.save("split", tags=["a"])
|
||||
self.db.save_prompt_pair("split", "split noise", tags=["b"])
|
||||
archived = self.db.save_prompt_pair("archived b", "old", tags=["b"])
|
||||
self.db.archive_prompt_pair(archived)
|
||||
self.assertIsInstance(split, int)
|
||||
self.assertEqual(
|
||||
self.texts(tags=["a", "b"], tag_mode="any"),
|
||||
["both", "pair b", "parent a", "parent b", "split"],
|
||||
)
|
||||
self.assertEqual(self.texts(tags=["a", "b"], tag_mode="all"), ["both"])
|
||||
self.assertEqual(self.texts(tags=["a", "b"]), ["both"])
|
||||
for tags in (["a"], ["b"], ["c"]):
|
||||
self.assertEqual(
|
||||
self.texts(tags=tags, tag_mode="any"), self.texts(tags=tags)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.texts(tags=["a", "b"], tag_mode="any", text="parent"),
|
||||
["parent a", "parent b"],
|
||||
)
|
||||
self.assertEqual(self.texts(tag_mode="any"), self.texts())
|
||||
for mode in ("none", None, "ANY"):
|
||||
with self.subTest(mode=mode), self.assertRaises(ValueError):
|
||||
self.db.list_library(tag_mode=mode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -772,13 +772,6 @@ class TestMonitorHistoryRecheck(RecordedRuns, unittest.TestCase):
|
||||
run_attribution.notify_outputs_sealed("run", []) # no dead callback raises
|
||||
|
||||
|
||||
def folder_is_case_sensitive(folder):
|
||||
"""True when ``folder``'s volume tells names apart by case: the existing
|
||||
folder spelled with swapped case is not found (#476)."""
|
||||
head, name = os.path.split(folder)
|
||||
return not os.path.exists(os.path.join(head, name.swapcase()))
|
||||
|
||||
|
||||
class TestMonitorForeignRuns(RecordedRuns, unittest.TestCase):
|
||||
"""#247: outputs of workflows without a PromptManager node, in the monitor."""
|
||||
|
||||
@@ -949,21 +942,11 @@ class TestMonitorForeignRuns(RecordedRuns, unittest.TestCase):
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
|
||||
def test_the_sealed_reference_spells_the_file_as_history_recorded_it(self):
|
||||
"""Windows: the watcher's spelling differs in case from the run's report.
|
||||
|
||||
On a case-sensitive folder the two spellings are two files and the
|
||||
folded key joins them; on a case-insensitive one (Windows, default
|
||||
macOS) they already name one file, so only history's spelling and
|
||||
the watcher's differ (#476)."""
|
||||
"""Windows: the watcher's spelling differs in case from the run's report."""
|
||||
Path(self.output, "Dataset").mkdir()
|
||||
recorded = self.image("IMG_GEN_00007_.png", subfolder="Dataset")
|
||||
seen = os.path.join(self.output, "dataset", "img_gen_00007_.png")
|
||||
Path(seen).write_bytes(b"png")
|
||||
recorded = os.path.join(self.output, "Dataset", "IMG_GEN_00007_.png")
|
||||
self.assertNotEqual(os.path.basename(recorded), os.path.basename(seen))
|
||||
if folder_is_case_sensitive(os.path.join(self.output, "dataset")):
|
||||
Path(self.output, "Dataset").mkdir()
|
||||
self.image("IMG_GEN_00007_.png", subfolder="Dataset")
|
||||
else:
|
||||
self.assertTrue(os.path.exists(recorded))
|
||||
folded = staticmethod(lambda path: os.path.realpath(path).lower())
|
||||
with mock.patch.object(ImageGenerationHandler, "_awaiting_key", folded):
|
||||
self.handler.process_new_image(seen)
|
||||
|
||||
+11
-23
@@ -48,7 +48,6 @@ LIBRARY_STATE = {
|
||||
"sort": "rating",
|
||||
"tags": [],
|
||||
"tag_mode": "untagged",
|
||||
"folder": "2026/Aug",
|
||||
}
|
||||
IMAGES_DEFAULTS = {
|
||||
"q": "",
|
||||
@@ -67,7 +66,6 @@ LIBRARY_DEFAULTS = {
|
||||
"sort": "newest",
|
||||
"tags": [],
|
||||
"tag_mode": "all",
|
||||
"folder": "",
|
||||
}
|
||||
# A row saved by an older build: no favorites key, a retired key, a sort
|
||||
# that is no longer offered and an over-long folder.
|
||||
@@ -244,15 +242,14 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
for sort in ("newest", "oldest", "name", "rating"):
|
||||
await self.create(
|
||||
page="library",
|
||||
state={"sort": sort, "tags": ["a b"], "tag_mode": "all", "folder": ""},
|
||||
state={"sort": sort, "tags": ["a b"], "tag_mode": "all"},
|
||||
)
|
||||
for mode in ("untagged", "any"):
|
||||
view = await self.create(
|
||||
page="library", state={"tags": [], "tag_mode": mode}
|
||||
)
|
||||
self.assertEqual(view["state"]["tag_mode"], mode)
|
||||
untagged = await self.create(
|
||||
page="library", state={"tags": [], "tag_mode": "untagged"}
|
||||
)
|
||||
self.assertEqual(untagged["state"]["tag_mode"], "untagged")
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(len(listed["views"]), 22)
|
||||
self.assertEqual(len(listed["views"]), 21)
|
||||
|
||||
async def test_write_requires_the_collection_key(self):
|
||||
# #331 D140: a view saved on a collection page keeps the collection id.
|
||||
@@ -697,24 +694,13 @@ class TestSavedViewsSchema(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(normalize_state("history", {}), {})
|
||||
|
||||
def test_library_tag_mode_and_folder_are_kept_with_defaults_for_old_rows(self):
|
||||
# A row from before #444 has neither key.
|
||||
def test_library_tag_mode_is_kept_and_never_paired_with_tags(self):
|
||||
# A row from before #444 has no tag_mode: it opens matching every tag.
|
||||
old = {"q": "", "category": "", "sort": "rating", "tags": ["a"]}
|
||||
self.assertEqual(normalize_state("library", old), {**LIBRARY_DEFAULTS, **old})
|
||||
self.assertEqual(
|
||||
normalize_state("library", {"tag_mode": "or", "folder": 3}),
|
||||
LIBRARY_DEFAULTS,
|
||||
normalize_state("library", {"tag_mode": "any"}), LIBRARY_DEFAULTS
|
||||
)
|
||||
for mode in ("all", "any", "untagged"):
|
||||
self.assertEqual(
|
||||
normalize_state("library", {"tag_mode": mode})["tag_mode"], mode
|
||||
)
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
encode_state("library", {**LIBRARY_STATE, "tag_mode": "or"})
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
encode_state("library", {**LIBRARY_STATE, "folder": "x" * 1025})
|
||||
|
||||
def test_library_untagged_is_never_paired_with_tags(self):
|
||||
# Never written by this build: the tags win, as the listing refuses both.
|
||||
self.assertEqual(
|
||||
normalize_state("library", {"tags": ["a"], "tag_mode": "untagged"}),
|
||||
@@ -722,6 +708,8 @@ class TestSavedViewsSchema(unittest.TestCase):
|
||||
)
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
encode_state("library", {**LIBRARY_STATE, "tags": ["a"]})
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
encode_state("library", {**LIBRARY_STATE, "tag_mode": "none"})
|
||||
|
||||
def test_validation_errors_are_saved_view_invalid(self):
|
||||
for call in (
|
||||
|
||||
Vendored
+3
-7
@@ -212,15 +212,11 @@ test('every browser job sets PM_TEST_WORKERS and runs in the pinned image', () =
|
||||
assert.match(mergeJob(), PINNED_IMAGE, 'the merge sees the same workspace paths as the shards');
|
||||
});
|
||||
|
||||
// #473: two shards split by test count put the slow concept specs in shard 1, which reached the 18-minute step limit.
|
||||
test('page workflows run each browser in three shards', () => {
|
||||
test('page workflows run each browser in two shards', () => {
|
||||
const workflows = allJobs.get('browser-workflows');
|
||||
assert.deepEqual(matrixList(workflows, 'browser'), ['firefox', 'webkit']);
|
||||
assert.deepEqual(matrixList(workflows, 'shard'), ['1', '2', '3']);
|
||||
assert.match(workflows, /test:workflows -- --project \$\{\{ matrix\.browser \}\} --shard \$\{\{ matrix\.shard \}\}\/3/);
|
||||
assert.match(workflows, /^ {4}name: page workflows \(\$\{\{ matrix\.browser \}\}, shard \$\{\{ matrix\.shard \}\}\/3\)$/m);
|
||||
assert.match(workflows, /name: Page workflows in \$\{\{ matrix\.browser \}\}, shard \$\{\{ matrix\.shard \}\}\/3$/m);
|
||||
assert.doesNotMatch(workflows, /\/2\b/, 'no step still names two shards');
|
||||
assert.deepEqual(matrixList(workflows, 'shard'), ['1', '2']);
|
||||
assert.match(workflows, /test:workflows -- --project \$\{\{ matrix\.browser \}\} --shard \$\{\{ matrix\.shard \}\}\/2/);
|
||||
assert.match(workflows, /^ {6}fail-fast: false$/m);
|
||||
assert.match(workflows, /name: browser-traces-\$\{\{ matrix\.browser \}\}-shard-\$\{\{ matrix\.shard \}\}/);
|
||||
});
|
||||
|
||||
@@ -548,12 +548,8 @@ def encode_history_inspection_response(
|
||||
resolution: dict,
|
||||
inspection: "InspectionRead | None",
|
||||
library: dict,
|
||||
image_tags: dict | None = None,
|
||||
) -> bytes:
|
||||
"""Use occurrence identity with the same current-file evidence projection.
|
||||
|
||||
*image_tags*, when given, adds the validated ``image_tags`` facet (#457).
|
||||
"""
|
||||
"""Use occurrence identity with the same current-file evidence projection."""
|
||||
return _encode_inspection(
|
||||
identity={
|
||||
"history_version": 1,
|
||||
@@ -565,5 +561,4 @@ def encode_history_inspection_response(
|
||||
resolution=resolution,
|
||||
inspection=inspection,
|
||||
library=library,
|
||||
image_tags=image_tags,
|
||||
)
|
||||
|
||||
@@ -12,22 +12,18 @@ function parameters(filters,limit) {
|
||||
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
|
||||
const sort=filters.sort || 'newest';
|
||||
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
// #444: how Tags match (every tag, any of them, or parents no tag's Browse returns, which never goes with tags) and
|
||||
// the output folder below a root.
|
||||
const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? '';
|
||||
// #444: how Tags match; `untagged` lists parents no tag's Browse returns and never goes with tags.
|
||||
const tagMode=filters.tagMode ?? 'all';
|
||||
if(!LIBRARY_TAG_MODES.includes(tagMode) || (tagMode==='untagged' && query.has('tag'))) throw new TypeError('Invalid library tag mode');
|
||||
if(typeof folder!=='string' || folder.length>1024) throw new TypeError('Invalid library folder');
|
||||
if(tagMode!=='all') query.set('tag_mode',tagMode);
|
||||
if(folder) query.set('folder',folder);
|
||||
query.set('sort',sort);return query;
|
||||
}
|
||||
/** The Library's tag match values (#444); the first is the default, sent as no parameter. */
|
||||
export const LIBRARY_TAG_MODES=Object.freeze(['all','any','untagged']);
|
||||
export const LIBRARY_TAG_MODES=Object.freeze(['all','untagged']);
|
||||
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
|
||||
function validatePage(page,cursor,query) {
|
||||
// A tag match or folder the server did not echo was not applied (an older server): never show the whole shelf as filtered.
|
||||
// A tag match the server did not echo was not applied (an older server): never show the whole shelf as filtered.
|
||||
if(query?.has('tag_mode') && page?.tag_mode!==query.get('tag_mode')) throw new TypeError('Unconfirmed library tag mode');
|
||||
if(query?.has('folder') && page?.folder!==query.get('folder')) throw new TypeError('Unconfirmed library folder');
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
}
|
||||
export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
@@ -125,28 +121,3 @@ export function libraryWindow(count,columns,top,height,focused=-1) {
|
||||
if(focused>=0 && focused<count) keep.add(Math.floor(focused/columns));
|
||||
return [...keep].sort((a,b)=>a-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index<count));
|
||||
}
|
||||
|
||||
const byFolderName=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
/**
|
||||
* Refine › Folder options (#444 D150): the concept's "All output folders" first, then the folders as a tree built by
|
||||
* path segment, so each folder sits directly under its own parent (never under a longer sibling such as `kiko-v6`) and
|
||||
* folders differing only in case stay distinct siblings. Siblings sort case-insensitively, then by exact spelling. A
|
||||
* label is the last segment indented by depth (the legacy admin select). The chosen folder is kept even when the list
|
||||
* lacks it, with its parents.
|
||||
*/
|
||||
export function libraryFolderOptions(folders,current='') {
|
||||
const root=new Map();
|
||||
for(const folder of current?[...folders,current]:folders) {
|
||||
let level=root;
|
||||
for(const segment of folder.split('/')) {if(!level.has(segment)) level.set(segment,new Map());level=level.get(segment);}
|
||||
}
|
||||
const options=[['','All output folders']];
|
||||
(function walk(level,prefix,depth) {
|
||||
for(const segment of [...level.keys()].sort(byFolderName)) {
|
||||
const path=prefix?`${prefix}/${segment}`:segment;
|
||||
options.push([path,`${'\u00a0\u00a0'.repeat(depth)}${segment}`]);
|
||||
walk(level.get(segment),path,depth+1);
|
||||
}
|
||||
})(root,'',0);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {createScope,createButton} from '../shared/toolkit/core.js';
|
||||
import {createPromptSender} from './prompt-sender.mjs';
|
||||
import {mountPromptBlocks} from './prompt-inspector.mjs';
|
||||
import {mountImages} from './images.mjs';
|
||||
import {createLibraryStream,createLibraryDetail,libraryPromptRoles,LIBRARY_TAG_MODES,libraryFolderOptions} from './library-state.mjs';
|
||||
import {createLibraryStream,createLibraryDetail,libraryPromptRoles,LIBRARY_TAG_MODES} from './library-state.mjs';
|
||||
import {promptRoles} from './prompt-blocks.mjs';
|
||||
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
|
||||
import {createLibraryEditorController} from './library-editor-state.mjs';
|
||||
@@ -31,8 +31,8 @@ const metadataDrafts=new Map();
|
||||
const archivedVisible=new Map();
|
||||
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
|
||||
// Refine › Tag match (#444 D149, D150), in LIBRARY_TAG_MODES order.
|
||||
const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]);
|
||||
// Refine › Tag match (#444 D149), in LIBRARY_TAG_MODES order.
|
||||
const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['untagged','Prompts without tags']]);
|
||||
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
|
||||
const CARD_CONTROLS='button, input[type="checkbox"]';
|
||||
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
|
||||
@@ -85,9 +85,9 @@ export function onLibraryWrite(listener) {
|
||||
* with the existing Library filters, and the heading takes focus as after a save.
|
||||
*/
|
||||
export function browseLibrary(filters) {
|
||||
const {text='',category='',tags=[],sort='newest',tagMode='all',folder=''}=filters || {};
|
||||
if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string' || !LIBRARY_TAG_MODES.includes(tagMode) || typeof folder!=='string') throw new TypeError('Invalid Library filters');
|
||||
shelfVisit={filters:{text,category,tags:[...tags],sort,tagMode,folder},anchor:null};
|
||||
const {text='',category='',tags=[],sort='newest',tagMode='all'}=filters || {};
|
||||
if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string' || !LIBRARY_TAG_MODES.includes(tagMode)) throw new TypeError('Invalid Library filters');
|
||||
shelfVisit={filters:{text,category,tags:[...tags],sort,tagMode},anchor:null};
|
||||
focusHeadingOnMount=true;window.location.hash='#/library';
|
||||
}
|
||||
/** Remember the selection a reconciled save names, so the destination history opens on it once. */
|
||||
@@ -160,16 +160,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
|
||||
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',folder:'',...visit?.filters,...saved};
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',...visit?.filters,...saved};
|
||||
// D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added.
|
||||
if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest';
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
const toolbarScope=createScope();
|
||||
// D44: Filters reads "Filters · N" while Refine filters (from Refine or a Browse) narrow the shelf: Tags, a Folder
|
||||
// and a Tag match other than All (#444 D149, D150) each count once.
|
||||
const refinements=()=>(filters.tags.length?1:0)+(filters.folder?1:0)+(filters.tagMode!=='all'?1:0);
|
||||
// D44: Filters reads "Filters · N" while Refine filters (from Refine or a Browse) narrow the shelf: Tags, and a
|
||||
// Tag match other than All (#444 D149).
|
||||
const refinements=()=>(filters.tags.length?1:0)+(filters.tagMode!=='all'?1:0);
|
||||
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters,
|
||||
onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}});
|
||||
@@ -216,7 +216,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
|
||||
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,selection.element,caption,grid,continuation);main.append(section);
|
||||
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
|
||||
let state,frame=0,loadFrame=0,observer,optionsRequest,folderRequest,focusOverride=-1;
|
||||
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
|
||||
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
|
||||
const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64;
|
||||
let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map();
|
||||
@@ -423,26 +423,17 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
if(Object.entries(next).every(([key,item])=>filters[key]===item)) return;
|
||||
Object.assign(filters,next);reset();
|
||||
}
|
||||
/**
|
||||
* "Refine this view" with the Library's filters: the concept's Folder (#444 D150, its options read when the dialog
|
||||
* opens), Tags (D14) and Tag match (D149, D150); Clear also clears the toolbar category.
|
||||
*/
|
||||
/** "Refine this view" with the Library's extra filters, Tags (D14) and Tag match (D149); Clear also clears the toolbar category. */
|
||||
function openFilters(trigger) {
|
||||
const refine=openRefineDialog({scope,trigger,fields:[{key:'folder',options:libraryFolderOptions([],filters.folder)},{key:'tags'},
|
||||
{key:'tagMode',options:TAG_MATCH_OPTIONS}],values:{folder:filters.folder,tags:filters.tags.join(', '),tagMode:filters.tagMode},
|
||||
onApply:applyRefinement,onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}});
|
||||
folderRequest?.abort();const request=folderRequest=new AbortController();
|
||||
// A failed read leaves "All output folders" and the current folder; nothing else depends on it.
|
||||
api.get('/prompt_manager/library/options?include=folders',{signal:request.signal}).then(data=>{
|
||||
if(scope.signal.aborted || folderRequest!==request || !Array.isArray(data?.folders) || data.folders.some(folder=>typeof folder!=='string')) return;
|
||||
refine.setOptions('folder',libraryFolderOptions(data.folders,filters.folder));
|
||||
},()=>{});
|
||||
openRefineDialog({scope,trigger,fields:[{key:'tags'},{key:'tagMode',options:TAG_MATCH_OPTIONS}],
|
||||
values:{tags:filters.tags.join(', '),tagMode:filters.tagMode},onApply:applyRefinement,
|
||||
onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}});
|
||||
}
|
||||
function applyRefinement({tags,tagMode='all',folder='',category=filters.category}) {
|
||||
function applyRefinement({tags,tagMode='all',category=filters.category}) {
|
||||
// "Prompts without tags" takes no Tags (the dialog disables them).
|
||||
const next=tagMode==='untagged'?[]:[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))];
|
||||
if(category===filters.category && tagMode===filters.tagMode && folder===filters.folder && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return;
|
||||
filters.category=category;filters.tags=next;filters.tagMode=tagMode;filters.folder=folder;reset();
|
||||
if(category===filters.category && tagMode===filters.tagMode && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return;
|
||||
filters.category=category;filters.tags=next;filters.tagMode=tagMode;reset();
|
||||
}
|
||||
scope.listen(more,'click',()=>stream.loadMore());
|
||||
// Retry also re-reads a category list that failed (D15: Refresh is gone).
|
||||
@@ -455,7 +446,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// A write while mounted makes this stream stale: keep only what still means something.
|
||||
// A saved view is not the plain shelf, so it never replaces the Library return state (#249 M2, as Favorites).
|
||||
if(!saved) shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
|
||||
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();folderRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,8 @@ const FIELDS = Object.freeze({
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
|
||||
// #444: how the Tags match ('untagged' never goes with tags; the listing refuses both) and the output folder
|
||||
// ('/'-joined, below a root).
|
||||
tag_mode: choice(['all', 'any', 'untagged'], 'all'), folder: text(1024),
|
||||
// #444: how the Tags match; 'untagged' never goes with tags (the listing refuses both).
|
||||
tag_mode: choice(['all', 'untagged'], 'all'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -118,12 +117,11 @@ export function imagesViewState(filters) {
|
||||
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
export function libraryFilters(state) {
|
||||
const view = normalizeViewState('library', state);
|
||||
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags, tagMode: view.tag_mode, folder: view.folder };
|
||||
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags, tagMode: view.tag_mode };
|
||||
}
|
||||
export function libraryViewState(filters) {
|
||||
return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags,
|
||||
// A filter the shelf lacks (undefined) takes its key's default in normalizeViewState.
|
||||
tag_mode: filters.tagMode, folder: filters.folder });
|
||||
tag_mode: filters.tagMode ?? 'all' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { createButton, createSwitch } from '../shared/toolkit/core.js';
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
// `tagMode` (#444, D149, D150): the Library's Tag match select after Tags; its `untagged` value disables Tags.
|
||||
// `tagMode` (#444, D149): the Library's Tag match select after Tags; its `untagged` value disables Tags.
|
||||
const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags', tagMode: 'Tag match' });
|
||||
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user