Compare commits

..
Author SHA1 Message Date
Vito Sansevero db5a1dc131 fix(ci): write browser coverage without each script's source (#432)
Every raw browser coverage file repeated the full source of each mapped
script, about 84% of its bytes, and the frontend coverage merge ran out of
Node's heap at 1,884 files. c8 reads the source from the mapped file on disk,
so the entries keep only the URL, script id and functions.
2026-10-28 13:31:05 -07:00
53 changed files with 524 additions and 4628 deletions
+1 -74
View File
@@ -3282,11 +3282,7 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
mid-response is not discarded by the page: the server's 15-minute expiry removes it. When a restore the page saw running
finishes, the cached Images and Library visits are dropped, the sidebar counts and
saved views are read again and the open route is shown again (the Activity job view
stays). #415: an open review (`openReviewDialog`) then closes as dismissed, since its plan no
longer describes storage; while any other dialog is open, all of that waits until the last
one has closed, so a dialog keeps what was typed and its next write meets the restored-storage
refusal, as when the page never saw the restore. `followRestore` (tests/browser/unseen-restore.mjs)
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
stays). A refused file toasts "This file can't be restored: <validator reason>.". A
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
long file name keeps the concept's two equal columns at phone width.
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
@@ -5346,75 +5342,6 @@ same as a file the scan could not attribute, so the file stays on disk and in th
gallery as unlinked, and its favorite and tags (kept by file) are untouched. Every
run prints this. Queue-linked batch images can't be proven after the fact and read
`unlink`, so review before applying. Stop ComfyUI first.
### Foreign workflows (#247): the shared per-file decision
The output scan decides each file through `utils/file_prompt.py`
(`decide_file_prompt`, the ladder), which the monitor will share for outputs of
workflows without a PromptManager node. It does no file or database I/O.
- **Save to library (#90).** Before anything else, a file whose PromptManager
node had Save to library off is `not_saved`, and one whose switch is wired
from a node the file cannot show is `not_saved_wired`: nothing is written, no
recorded-run fallback, no Needs review item. A resolved pair whose negative's
node is off stores the positive alone (source `opted-out-negative`). The
#90 graph walk follows only prompt-like inputs, so the ladder also walks
every input upstream of each sampler or guider positive (not negative, model,
clip, latent or image inputs; every PromptManager node of a graph without a
sampler) and checks the text later steps name: a resolved or inspected
positive whose path passes such a node (`side_opted_out`), or a legacy answer
that is such a node's text (resolved, else literal). A switch that is off
makes the file `not_saved`; one wired from a node the graph cannot read makes
it `not_saved_wired`, as #90's direct check does.
- **Order.** The default profile decides first, exactly as before (resolved
pair, A1111, sampler-less). Only a graph it leaves ambiguous or unresolved
reaches the inspector's resolve (`inspection_resolution` in
`utils/prompt_inspector.py`, the inspection profile, then the recorded saver
anchor of #386; `name_anchor=False`: the file-name anchor never decides a
link; the scan's legacy floor may still link the file, as before #247, and a
file nothing links is queued with the anchor's choice per #390), and only that positive's `storage_text` is stored (never
prepend or append text, never `recorded_texts`). Then the #170 browser parser
(`graph_prompt_text`), the legacy floor, then the recorded run's stored
prompt. The legacy answer is refused when a PromptManager-family node has a
prepend or append part and the answer is not that node's own `text` (the
parser joins the parts, so the stored text cannot be told from it). History is asked once per file and only at that step; the scan's
batch lookup stays lazy and makes one `recorded_runs` pass for the stored
prompt and the saver (`_BatchHistory`).
- **Policies.** `SCAN` keeps the legacy floor for every undecided file.
`MONITOR` waits for history inside the output folder (`history`) and stores
nothing for a file the inspector calls ambiguous; an untraceable or
malformed one still gets the legacy floor.
- **Junk.** `junk_text` refuses `blank`, `number` (the #252 `_numeric` rule),
`lora-only` (only `<lora:...>` tags and separators) and `model-file` (one
token ending in a model extension) at whichever step found the text and
nothing later is tried; the file reports `found`, except at the undecided
graph step, which keeps its own status (an ambiguous file is still queued).
- **One prompt per file.** `database/scanned_prompts.py` stores a positive in
one `BEGIN IMMEDIATE` transaction: a file already linked to another prompt
(either spelling, `file_link_rows`/`link_conflict`, the pair ingester's rule)
is `conflict` and nothing is written; a failed link rolls the new prompt back
(`found`); two writers of one text store one prompt. A recorded run's stored
prompt links the same way (`link_stored_prompt_or_raise`). A rescan therefore
never adds a second link to a file an older scan linked to the legacy answer;
the row reads "Kept: already linked to a different prompt". Link rows are
matched under the path and its `realpath` spelling (`file_link_rows`), so a
file the scan stored through the resolved folder is found when it arrives
through a symlinked or junction folder. The reverse order (a row stored under
the watcher's raw spelling, then a scan whose root resolves differently) is
matched only when that raw spelling equals the realpath. Every scan write
runs through `survive_library_switch`, so a library move reruns it.
- **Marks.** Every prompt ingestion creates, pair components included, carries
category `scanned`, tag `auto-scanned` and the note `Auto-scanned from
<file>` (`SCAN_MARKS`); a reused prompt keeps its fields. Run counts are
never touched.
Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class),
`tests/test_scanned_prompts.py` (store, conflict, rollback, concurrency with
`FirstWriterHold`, pair marks), `tests/test_pair_scan.py` `TestScanDecision`,
`tests/test_run_attribution.py` (dataset branches linked by the recorded
saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and
`tests/test_recorded_saver_anchor.py` `TestInspectionResolution`.
## Linked prompt text (#343)
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
+15 -111
View File
@@ -1,14 +1,7 @@
"""Atomic metadata enrichment for a single known generation context.
Also the helpers both ingestion store paths share (#247): the file's link
rows and the one-prompt-per-file rule (``file_link_rows``,
``link_conflict``) and the marks a prompt created by ingestion carries
(``Marks``, ``mark_created``).
"""
"""Atomic metadata enrichment for a single known generation context."""
import os
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict
from .models import normalize_image_path
from .operations import _coerce_file_info
@@ -44,85 +37,16 @@ def blank_positive(resolution: Dict[str, Any]) -> bool:
return not (isinstance(text, str) and text.strip())
@dataclass(frozen=True)
class Marks:
"""What a prompt ingestion creates carries, so it can be found and removed.
``note`` is formatted with ``name``, the file's name.
"""
category: str
tags: Tuple[str, ...]
note: str
SCAN_MARKS = Marks("scanned", ("auto-scanned",), "Auto-scanned from {name}")
RUN_MARKS = Marks("scanned", ("auto-scanned",), "Saved from a workflow run: {name}")
def mark_created(conn: Any, db: Any, prompt_id: int, marks: Marks, name: str) -> None:
"""Apply ``marks`` to a prompt row the caller just created on ``conn``."""
conn.execute(
"UPDATE prompts SET category = ?, notes = ? WHERE id = ?",
(marks.category, marks.note.format(name=name), prompt_id),
)
db._sync_prompt_tags(conn, prompt_id, list(marks.tags))
def file_link_rows(conn: Any, image_path: str) -> List[Any]:
"""The file's link rows, each with its prompt's hash.
Matched under the path and its resolved spelling (an output folder
reached through a symlink or junction), each normalized and raw;
deduplicated by row id.
"""
rows: Dict[Any, Any] = {}
for spelling in dict.fromkeys((image_path, _resolved(image_path))):
for row in conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalize_image_path(spelling), spelling),
).fetchall():
rows.setdefault(row["id"], row)
return list(rows.values())
def _resolved(path: str) -> str:
try:
return os.path.realpath(path)
except (OSError, ValueError):
return path
def link_conflict(rows: List[Any], prompt_hash: str) -> bool:
"""One prompt per file: more than one row, or a row of another prompt."""
return len(rows) > 1 or bool(rows and rows[0]["positive_hash"] != prompt_hash)
def ingest_resolved_pair(
db: Any,
image_path: str,
metadata: Dict[str, Any],
resolution: Dict[str, Any],
*,
marks: Optional[Marks] = None,
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
) -> Dict[str, Any]:
"""Store a resolved pair and link the file to it in one transaction.
With ``marks`` (ingestion from files), each pair component this call
creates carries them; without (the PromptManager node path) components
are created bare, as always.
"""
state = resolution.get("state", "unresolved")
if state == "resolved" and blank_positive(resolution):
state = "unresolved"
if state != "resolved":
return {"status": state, "pairs_added": 0, "links_added": 0}
with db._pair_transaction() as conn:
return _ingest_resolved_pair_in(
conn, db, image_path, metadata, resolution, marks=marks
)
return _ingest_resolved_pair_in(conn, db, image_path, metadata, resolution)
def _conflict() -> Dict[str, Any]:
@@ -137,7 +61,6 @@ def _ingest_resolved_pair_in(
resolution: Dict[str, Any],
*,
provenance: Dict[str, Any] | None = None,
marks: Optional[Marks] = None,
) -> Dict[str, Any]:
"""``ingest_resolved_pair`` inside the caller's open ``_pair_transaction``.
@@ -146,8 +69,7 @@ def _ingest_resolved_pair_in(
negative is a ``conflict`` and nothing is written. The caller owns the
transaction, so its own later failure rolls the link back too.
``provenance`` replaces the link's default resolver provenance (Needs
review records the user's selection there). ``marks`` as in
``ingest_resolved_pair``.
review records the user's selection there).
"""
positive, negative = resolution["positive"], resolution["negative"]
positive_text = positive.get("storage_text", positive["text"])
@@ -160,8 +82,15 @@ def _ingest_resolved_pair_in(
"negative": negative,
}
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
images = file_link_rows(conn, image_path)
if link_conflict(images, generate_prompt_hash(positive_text)):
images = conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalized, image_path),
).fetchall()
if len(images) > 1 or (
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
):
return _conflict()
existing = images[0] if images else None
if existing:
@@ -186,12 +115,7 @@ def _ingest_resolved_pair_in(
generate_prompt_hash(negative_text) if negative_text.strip() else None,
),
).fetchone()
if marks is None:
pair_id = db._save_pair(conn, positive_text, negative_text)
else:
pair_id = _save_marked_pair(
conn, db, positive_text, negative_text, marks, os.path.basename(image_path)
)
pair_id = db._save_pair(conn, positive_text, negative_text)
pair = conn.execute("SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)).fetchone()
pairs_added = int(prior_pair is None)
file_size, width, height, image_format = _coerce_file_info(metadata)
@@ -273,26 +197,6 @@ def _ingest_resolved_pair_in(
}
def _save_marked_pair(
conn: Any,
db: Any,
positive_text: str,
negative_text: str,
marks: Marks,
name: str,
) -> int:
"""``_save_pair`` that marks each component it creates."""
texts = [positive_text] + ([negative_text] if negative_text.strip() else [])
ids = []
for text in texts:
prompt_id, created = db._pair_component_outcome(conn, text)
if created:
mark_created(conn, db, prompt_id, marks, name)
ids.append(prompt_id)
negative_id = ids[1] if len(ids) > 1 else None
return db._save_pair_components(conn, ids[0], negative_id)[0]
def _document_form(conn: Any, values: Dict[str, Any]) -> Dict[str, Any]:
"""``values`` with the workflow and prompt graph texts stored once.
+6 -7
View File
@@ -164,7 +164,7 @@ def _runs_by_location(db: Any, names: List[str]) -> Optional[Dict[tuple, dict]]:
return found
def recorded_runs(
def _recorded_runs(
db: Any,
image_paths: Iterable[str],
output_root: Optional[str],
@@ -201,8 +201,7 @@ def recorded_runs(
return attributed
def named_prompt(db: Any, run_id: str) -> Optional[int]:
"""The one stored prompt every positive context of ``run_id`` names, else None."""
def _named_prompt(db: Any, run_id: str) -> Optional[int]:
contexts = db.get_execution_contexts(run_id).get("contexts") or []
named = {
context.get("positive_prompt_id")
@@ -229,11 +228,11 @@ def stored_run_prompts(
paths = list(dict.fromkeys(path for path in image_paths if path))
result: Dict[str, Optional[int]] = dict.fromkeys(paths)
prompts: Dict[str, Optional[int]] = {}
for path, (run_id, _nodes) in recorded_runs(
for path, (run_id, _nodes) in _recorded_runs(
db, paths, output_root, path_style
).items():
if run_id not in prompts:
prompts[run_id] = named_prompt(db, run_id)
prompts[run_id] = _named_prompt(db, run_id)
result[path] = prompts[run_id]
return result
@@ -276,7 +275,7 @@ def recorded_output(
) -> Optional[RecordedOutput]:
"""The recorded run facts for ``image_path``, else None (#386).
The file must be attributed to one run (``recorded_runs``: one run
The file must be attributed to one run (``_recorded_runs``: one run
reported its location and it was written during that run). Read-only; it
never links anything.
"""
@@ -287,7 +286,7 @@ def recorded_output(
if own_transaction: # outputs and texts from one state of history
conn.execute("BEGIN")
try:
found = recorded_runs(db, [image_path], output_root, path_style)
found = _recorded_runs(db, [image_path], output_root, path_style)
if image_path not in found:
return None
run_id, nodes = found[image_path]
-14
View File
@@ -35,9 +35,6 @@ _LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
_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
# ("") or 1 to 9 digits without a leading zero.
_COLLECTION = re.compile(r"|[1-9][0-9]{0,8}")
FALLBACK_NAME = "Saved view"
@@ -58,9 +55,6 @@ _STATE_FIELDS = {
"tags": ("tags", None, ()),
# The Favorites page's filter (#249 D51): a view saved there reopens it.
"favorites": ("bool", None, False),
# A view saved on a collection page (#331 D140) reopens that
# collection; never together with favorites.
"collection": ("collection", None, ""),
},
"library": {
"q": ("text", 500, ""),
@@ -131,8 +125,6 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None:
raise SavedViewInvalid(f"State '{key}' is not a supported value")
if kind == "source" and not _SOURCE.fullmatch(value):
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
if kind == "collection" and not _COLLECTION.fullmatch(value):
raise SavedViewInvalid(f"State '{key}' must be empty or a collection id")
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
@@ -150,8 +142,6 @@ def encode_state(page: object, state: object) -> str:
)
for key, (kind, limit, _default) in fields.items():
_validate_value(key, kind, limit, state[key])
if state.get("favorites") is True and state.get("collection"):
raise SavedViewInvalid("A view cannot filter by favorites and a collection")
encoded = json.dumps(
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
)
@@ -191,10 +181,6 @@ def normalize_state(page: str, raw: object) -> dict:
except SavedViewInvalid:
value = default
state[key] = list(value) if kind == "tags" else value
# Never written by this build; a stored row with both opens the collection,
# as the listing does for a URL with both (#331 D140).
if state.get("favorites") is True and state.get("collection"):
state["favorites"] = False
return state
-87
View File
@@ -1,87 +0,0 @@
"""Link an output file to the prompt of one text, creating it when new (#247).
The store step of ingestion from file metadata when the decision is a
positive text (``utils.file_prompt``). The prompt insert and the link run in
one ``BEGIN IMMEDIATE`` transaction: two writers never store one text twice,
a failed link leaves no new prompt behind, and a file stays linked to one
prompt (a file already linked to another prompt is a ``conflict``).
"""
import logging
import os
from typing import Any, Dict, Optional, Tuple
from .pair_ingestion import Marks, file_link_rows, link_conflict, mark_created
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
_LOGGER = logging.getLogger("prompt_manager.database.scanned_prompts")
def store_scanned_prompt_or_raise(
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
) -> Tuple[str, Optional[int]]:
"""``(status, prompt id)`` after linking ``path`` to ``text``'s prompt.
``added`` (prompt created, carrying ``marks``), ``linked`` (stored text
reused), ``unchanged`` (already linked to it) or ``conflict`` (linked to
another prompt; nothing written, id None). Database errors are raised
and roll the whole store back, so a caller can run it again.
"""
with db._pair_transaction() as conn:
rows = file_link_rows(conn, path)
if link_conflict(rows, generate_prompt_hash(text)):
return "conflict", None
if rows:
return "unchanged", rows[0]["prompt_id"]
prompt_id, created = db._pair_component_outcome(conn, text)
if created:
mark_created(conn, db, prompt_id, marks, os.path.basename(path))
db._insert_image_link(conn, prompt_id, path, canonical)
return ("added" if created else "linked"), prompt_id
def store_scanned_prompt(
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
) -> Tuple[str, Optional[int]]:
"""The scan's form: an error is logged and reported as ``("found", None)``.
A write that failed because Move library switched runs again on the new
library (``survive_library_switch``); the rerun re-checks the link rows.
"""
try:
return db.survive_library_switch(
lambda: store_scanned_prompt_or_raise(db, path, canonical, text, marks)
)
except Exception as error:
_LOGGER.error(
f"Failed to store the prompt of {os.path.basename(path)}: {error}"
)
return "found", None
def link_stored_prompt_or_raise(
db: Any, path: str, canonical: Dict[str, Any], prompt_id: int
) -> Optional[str]:
"""Link ``path`` to the stored prompt ``prompt_id`` (recorded history, #342).
``linked``, ``unchanged`` (already linked to it), ``conflict`` (linked to
another prompt; nothing written) or None when the prompt is gone. One
``BEGIN IMMEDIATE`` transaction; database errors are raised.
"""
with db._pair_transaction() as conn:
rows = file_link_rows(conn, path)
if len(rows) > 1 or (rows and rows[0]["prompt_id"] != prompt_id):
return "conflict"
if rows:
return "unchanged"
exists = conn.execute(
"SELECT 1 FROM prompts WHERE id = ?", (prompt_id,)
).fetchone()
if exists is None:
return None
db._insert_image_link(conn, prompt_id, path, canonical)
return "linked"
+82 -3
View File
@@ -77,7 +77,6 @@ from .image_categories import ImageCategoryRoutesMixin
try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
@@ -87,7 +86,6 @@ except ImportError:
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.video_metadata import is_video_path, read_video_metadata
@@ -1033,4 +1031,85 @@ class PromptManagerAPI(
def _parse_comfyui_prompt(self, metadata):
"""Parse ComfyUI and A1111 prompt data from metadata."""
return parse_comfyui_prompt(metadata)
result = {
"prompt": None,
"workflow": None,
"parameters": {},
"positive_prompt": None,
"negative_prompt": None,
}
# Check for A1111/Forge style parameters first.
# The chunk is one block: a possibly multi-line positive prompt, then a
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
# Taking lines[0] as the positive prompt truncates any multi-line prompt
# to one line, so split on the settings line instead.
if "parameters" in metadata:
import re as _re
params = metadata["parameters"]
body = params
settings = ""
_sm = _re.search(r"\nSteps:\s", body)
if _sm:
settings = body[_sm.start() + 1 :].strip()
body = body[: _sm.start()]
negative = None
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
if _nm:
negative = body[_nm.end() :].strip()
body = body[: _nm.start()]
elif body.lower().lstrip().startswith("negative prompt:"):
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
body = ""
result["positive_prompt"] = body.strip() or None
if negative:
result["negative_prompt"] = negative
# Store raw parameters too
result["parameters"]["parameters"] = params
if settings:
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
_fm = _re.search(
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
)
if _fm:
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
1
).strip()
# If no A1111 format found, proceed with ComfyUI parsing
if result["positive_prompt"] is None:
# Check for direct prompt field
if "prompt" in metadata:
try:
prompt_data = json.loads(metadata["prompt"])
result["prompt"] = prompt_data
except json.JSONDecodeError:
result["prompt"] = metadata["prompt"]
# Check for workflow
if "workflow" in metadata:
try:
workflow_data = json.loads(metadata["workflow"])
result["workflow"] = workflow_data
except json.JSONDecodeError:
result["workflow"] = metadata["workflow"]
# Check for other common ComfyUI fields
common_fields = [
"positive",
"negative",
"steps",
"cfg",
"sampler",
"scheduler",
"seed",
]
for field in common_fields:
if field in metadata:
try:
result["parameters"][field] = json.loads(metadata[field])
except json.JSONDecodeError:
result["parameters"][field] = metadata[field]
return result
+248 -153
View File
@@ -40,51 +40,45 @@ except ImportError:
from utils import exit_signal
try:
from ...database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
from ...database.pair_ingestion import blank_positive, ingest_resolved_pair
from ...database.run_attribution import (
RecordedOutput,
host_output_directory,
named_prompt,
recorded_runs,
stored_run_prompt,
stored_run_prompts,
)
from ...database.scanned_prompts import (
link_stored_prompt_or_raise,
store_scanned_prompt,
)
from ...utils.file_prompt import (
NEVER,
NOT_SAVED,
NOT_SAVED_WIRED,
PAIR,
Policy,
decide_file_prompt,
from ...utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
)
from ...utils.prompt_graph import metadata_opted_out, side_opted_out
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
from ...utils.review_capture import reviewable
except ImportError:
from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
from database.pair_ingestion import blank_positive, ingest_resolved_pair
from database.run_attribution import (
RecordedOutput,
host_output_directory,
named_prompt,
recorded_runs,
stored_run_prompt,
stored_run_prompts,
)
from database.scanned_prompts import (
link_stored_prompt_or_raise,
store_scanned_prompt,
)
from utils.file_prompt import (
NEVER,
NOT_SAVED,
NOT_SAVED_WIRED,
PAIR,
Policy,
decide_file_prompt,
from utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
)
from utils.prompt_graph import metadata_opted_out, side_opted_out
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
from utils.review_capture import reviewable
try:
from ...database.library import display_title
from ...utils.hashing import generate_prompt_hash
except ImportError:
from database.library import display_title
from utils.hashing import generate_prompt_hash
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
@@ -264,7 +258,7 @@ SCAN_DETAILS = {
"skipped": "Skipped · already linked",
"ambiguous": "Needs review: more than one prompt could match",
"unresolved": "Needs review: the prompt could not be traced in the workflow",
"conflict": "Kept: already linked to a different prompt",
"conflict": "Needs review: already linked to a different prompt",
"failed": "Error: the prompt could not be stored",
"error": "Error: the file could not be processed (see the server log)",
"found": "Metadata found, but no prompt text to store",
@@ -331,6 +325,22 @@ def _file_size(path):
return None
def _with_file_size(canonical, media_file):
"""Record the file's size in ``file_info`` unless the metadata carries one.
Stored as ``generated_images.file_size``; the "new" scan scope skips a known
file only while its size is unchanged.
"""
info = canonical.get("file_info")
info = dict(info) if isinstance(info, dict) else {}
if info.get("size") is None:
size = _file_size(media_file)
if size is not None:
info["size"] = size
if info:
canonical["file_info"] = info
def _scan_roots(output_dirs):
"""(absolute root, public label, listing index), the most specific first.
@@ -586,56 +596,18 @@ class _BatchHistory:
"""A scan batch's recorded-history lookup, made on the first file needing it.
Most files link through their metadata and never ask; the first one that
does triggers one ``recorded_runs`` pass for the whole batch, from which
both the stored prompt (``get``) and the recorded saver (``recorded``)
of every file are read. A failure is logged and names nothing.
does triggers one lookup for the whole batch (``_recorded_runs_for``).
"""
def __init__(self, api, media_files):
self._api = api
self._files = [str(path) for path in media_files]
self._runs = None
self._prompts = {}
def _found(self):
if self._runs is None:
try:
self._runs = recorded_runs(
self._api.db, self._files, host_output_directory(), None
)
except Exception:
self._api.logger.warning(
"History attribution failed for a scan batch", exc_info=True
)
self._runs = {}
return self._runs
self._files = media_files
self._found = None
def get(self, path):
"""The stored prompt id history attributes to ``path``, else None."""
found = self._found().get(path)
if found is None:
return None
run_id = found[0]
if run_id not in self._prompts:
try:
self._prompts[run_id] = named_prompt(self._api.db, run_id)
except Exception:
self._api.logger.warning(
"History attribution failed for a scan batch", exc_info=True
)
self._prompts[run_id] = None
return self._prompts[run_id]
def recorded(self, path):
"""``RecordedOutput(saver, {})`` for an attributed file, else None.
``saver`` is set only when the run reported the file from one node.
"""
found = self._found().get(path)
if found is None:
return None
nodes = found[1]
return RecordedOutput(next(iter(nodes)) if len(nodes) == 1 else None, {})
if self._found is None:
self._found = self._api._recorded_runs_for(self._files)
return self._found.get(path)
class AdminRoutesMixin:
@@ -1700,6 +1672,24 @@ class AdminRoutesMixin:
note(media_file, status)
return counts
@staticmethod
def _graph_prompt_text(metadata):
"""The positive prompt the browser parser traces from the graphs, or None.
Records parse as the legacy pages parsed them (NaN reads as null).
"""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_page_json(value) if isinstance(value, str) else value
try:
positive = extract_generation_params(records)["positivePrompt"]
except GenerationParamsError:
return None
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
return None
return positive.strip() or None
def _ingest_scanned_file(
self,
media_file,
@@ -1710,81 +1700,148 @@ class AdminRoutesMixin:
records=None,
history=None,
):
"""Store the prompt the shared per-file decision names (#247).
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
``utils.file_prompt.decide_file_prompt`` (scan policy) decides; this
stores the result. Returns the file's scan status, or None without
prompt metadata. With ``detailed``, a stored or linked file returns a
dict instead: its ``status``, the prompt's display ``title`` and, for
a resolved pair, the pair counts. When the resolver finds the file
ambiguous (whether or not a later step then links it) and
``ambiguous`` is a list, ``media_file`` is appended to it; only a file
that then returns ``"ambiguous"`` (nothing linked it) is a Needs
review case. An ambiguous result with nothing to choose
(``reviewable`` is False: fewer than two candidates carry a prompt)
goes to ``untraced`` instead and returns ``"unresolved"`` when nothing
links it. The parsed records of either ambiguous file go to
``records["canonical"]`` when ``records`` is a dict. ``history`` is
the batch's lazy recorded-history lookup.
Returns the file's scan status, or None without prompt metadata. With
``detailed``, a stored or linked file returns a dict instead: its
``status``, the prompt's display ``title`` and, for a resolved pair,
the pair counts. When the resolver finds the file ambiguous (whether
or not the browser parser then links it) and ``ambiguous`` is a list,
``media_file`` is appended to it; only a file that then returns
``"ambiguous"`` (nothing linked it) is a Needs review case. An
ambiguous result with nothing to choose (``reviewable`` is False: fewer
than two candidates carry a prompt) that nothing links returns
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``.
The parsed records of either ambiguous file go to
``records["canonical"]`` when ``records`` is a dict.
"""
path = str(media_file)
if history is None: # one file: history is still asked once
history = _BatchHistory(self, [media_file])
decision = decide_file_prompt(
metadata,
file_name=Path(path).name,
file_size=functools.partial(_file_size, media_file),
in_output=False, # only the monitor policy reads it
lookup=functools.partial(history.recorded, path),
policy=Policy.SCAN,
)
if decision.kind in (NOT_SAVED, NOT_SAVED_WIRED):
# Save to library off (#90): no write, recorded-run fallback or
# Needs review item
return decision.kind
self._note_review(media_file, decision, ambiguous, untraced, records)
if decision.kind == NEVER:
if decision.try_recorded_run:
linked = self._link_recorded_run(
media_file, decision.canonical, detailed, history
)
if linked is not None:
return linked
return decision.unlinked_status
if decision.kind == PAIR:
# A rerun after a library move re-checks the link rows.
result = self.db.survive_library_switch(
lambda: ingest_resolved_pair(
self.db,
path,
decision.canonical,
decision.resolution,
marks=SCAN_MARKS,
)
if not metadata:
# No prompt metadata (--disable-metadata, bare savers): only the
# recorded run can say which prompt made it (#342)
return self._link_recorded_run(media_file, {}, detailed, history)
canonical = dict(metadata)
for key in ("prompt", "workflow"):
if isinstance(canonical.get(key), str):
try:
canonical[key] = json.loads(canonical[key])
except (ValueError, TypeError):
# Preserve malformed metadata for conservative resolver rejection.
pass
if metadata_opted_out(canonical):
# Save to library was off for the run's prompt: no prompt, link,
# recorded-run fallback or Needs review item (#90)
return "not_saved"
if metadata_opted_out(canonical, unreadable=True):
# The switch is wired from a node the file cannot show: only the
# live run (the node saw the value) can link it, never a scan
return "not_saved_wired"
if not canonical.get("prompt") and not canonical.get("parameters"):
return self._link_recorded_run(media_file, canonical, detailed, history)
_with_file_size(canonical, media_file)
resolution = resolve_prompt_pair(canonical)
if resolution["state"] == "resolved" and side_opted_out(
canonical.get("prompt"), resolution["negative"]
):
# Only the negative's node is off: store the positive alone (#90)
prompt_text = (
resolution["positive"]
.get("storage_text", resolution["positive"]["text"])
.strip()
)
if not detailed:
return result["status"]
title_text = decision.resolution["positive"]["text"]
return {**result, "title": self._scan_title(media_file, title_text)}
status, _prompt_id = store_scanned_prompt(
self.db, path, decision.canonical, decision.text, SCAN_MARKS
)
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
return {"status": status, "title": self._scan_title(media_file, decision.text)}
@staticmethod
def _note_review(media_file, decision, ambiguous, untraced, records):
"""Hand an ambiguous file to the batch's Needs review lists."""
if decision.review == "untraced":
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
return {
"status": status,
"title": self._scan_title(media_file, prompt_text),
}
if blank_positive(resolution):
# Only prepend/append text: no stored prompt can own it (#342)
resolution = {**resolution, "state": "unresolved"}
# Ambiguous without a choice: unresolved unless something links it.
unchoosable = resolution["state"] == "ambiguous" and not reviewable(resolution)
if unchoosable:
if untraced is not None:
untraced.append(media_file)
if records is not None:
records["canonical"] = decision.canonical
elif decision.review == "ambiguous" and ambiguous is not None:
records["canonical"] = canonical
elif resolution["state"] == "ambiguous" and ambiguous is not None:
ambiguous.append(media_file)
if records is not None:
records["canonical"] = decision.canonical
records["canonical"] = canonical
if resolution["state"] == "resolved":
result = ingest_resolved_pair(
self.db, str(media_file), canonical, resolution
)
if not detailed:
return result["status"]
return {
**result,
"title": self._scan_title(media_file, resolution["positive"]["text"]),
}
if canonical.get("prompt") and resolution["state"] in (
"ambiguous",
"unresolved",
):
# The resolver could not decide: link the prompt the inspector and the
# legacy pages show for this file (the browser parser, in Python).
# Graphs without a recognized sampler keep the legacy extractor below.
prompt_text = self._graph_prompt_text(metadata)
if not prompt_text:
linked = self._link_recorded_run(
media_file, canonical, detailed, history
)
if linked is not None:
return linked
return "unresolved" if unchoosable else resolution["state"]
elif resolution["state"] == "ambiguous":
return "unresolved" if unchoosable else "ambiguous"
elif resolution["state"] == "unresolved" and not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
elif canonical.get("prompt"):
# No node takes a positive or negative input (a guider or wrapper
# graph): the one wired text, never a guess (#252).
found = sampler_less_prompt(canonical["prompt"])
prompt_text = found["text"]
if prompt_text is None and isinstance(canonical.get("parameters"), str):
# A metadata saver also wrote A1111 text: its positive prompt.
parsed = self._parse_comfyui_prompt(metadata)["positive_prompt"]
prompt_text = parsed.strip() if parsed and parsed.strip() else None
if prompt_text is None:
linked = self._link_recorded_run(
media_file, canonical, detailed, history
)
if linked is not None:
return linked
return "unresolved" if found["state"] == "ambiguous" else "found"
else:
prompt_text = self._parse_comfyui_prompt(metadata)["positive_prompt"]
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
def _recorded_runs_for(self, media_files):
"""``{path: prompt id or None}`` from recorded history for a scan batch.
One output lookup per chunk of names (``stored_run_prompts``), so a
batch never queries history once per file. An empty dict on failure.
"""
try:
return stored_run_prompts(
self.db, [str(path) for path in media_files], host_output_directory()
)
except Exception:
self.logger.warning(
"History attribution failed for a scan batch", exc_info=True
)
return {}
def _link_recorded_run(self, media_file, canonical, detailed=False, history=None):
"""Link a file recorded history attributes to one stored prompt (#342).
@@ -1793,25 +1850,31 @@ class AdminRoutesMixin:
``{"status", "title"}``), or None when history names no single stored
prompt for the file (``database.run_attribution``). Never creates a
prompt. Works without any prompt metadata in the file. ``history`` is
the batch's lookup (``_BatchHistory``). A file already linked to
another prompt is a ``conflict``: one prompt per file.
the batch's precomputed lookup; without it the file is looked up alone.
"""
if history is None:
history = _BatchHistory(self, [media_file])
prompt_id = history.get(str(media_file))
if history is not None:
prompt_id = history.get(str(media_file))
else:
try:
prompt_id = stored_run_prompt(
self.db, str(media_file), host_output_directory()
)
except Exception:
self.logger.warning(
f"History attribution failed for {media_file.name}",
exc_info=True,
)
return None
if prompt_id is None:
return None
try:
status = self.db.survive_library_switch(
lambda: link_stored_prompt_or_raise(
self.db, str(media_file), canonical, prompt_id
)
image_id = self.db.link_image_to_prompt(
prompt_id, str(media_file), metadata=canonical
)
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} from history: {e}")
return "found"
if status is None:
return None
status = "linked" if image_id else "unchanged"
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
prompt = self.db.get_prompt_by_id(prompt_id) or {}
@@ -1831,3 +1894,35 @@ class AdminRoutesMixin:
except Exception:
self.logger.warning("Scan row title could not be read; using its text")
return display_title((), text)
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
prompt_hash = generate_prompt_hash(prompt_text)
existing = self.db.get_prompt_by_hash(prompt_hash)
if existing:
try:
image_id = self.db.link_image_to_prompt(
existing["id"], str(media_file), metadata=canonical
)
except Exception as e:
self.logger.error(
f"Failed to link {media_file.name} to existing prompt: {e}"
)
return "found"
return "linked" if image_id else "unchanged"
prompt_id = self.db.save_prompt(
prompt_text,
"scanned",
["auto-scanned"],
None,
f"Auto-scanned from {media_file.name}",
prompt_hash,
)
if not prompt_id:
return "found"
try:
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
return "added"
+1 -1
View File
@@ -3,7 +3,7 @@
The rule (maintainer test of #284/#288): a file is queued only when its
final outcome is ambiguous and unlinked. The strict resolver must find it
``ambiguous``, the scan's legacy fallback (the browser parser,
``graph_prompt_text``) must not link it, and no saved row may link it
``_graph_prompt_text``) must not link it, and no saved row may link it
already. A file the fallback links, an exact pair the ingester stored and an
already linked file are never queued. Neither is an ambiguous file with
nothing to choose: fewer than two of its candidates carry a prompt
-2
View File
@@ -47,7 +47,6 @@ from tests.browser.workspace_library_editor_fixture import (
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
from tests.browser.workspace_tagging_fixture import register_tagging_commands
from tests.browser.workspace_review_fixture import register_review_commands
from tests.browser.workspace_collections_fixture import register_collections_commands
from tests.browser.workspace_diagnostics_fixture import (
register_diagnostics_commands,
repoint_logs,
@@ -233,7 +232,6 @@ def create_app(root: Path) -> web.Application:
register_vocabulary_commands(routes, api, root)
register_tagging_commands(routes, api, root, model)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
register_activity_commands(app, routes, api)
register_system_commands(app, routes, api, root)
+7 -35
View File
@@ -76,41 +76,13 @@ function savedNav(nav) {
return {...nav,rows:nav.rows.map(row=>row.text==='Needs review'?{...row,count:{...row.count,text:'0'}}:row)};
}
/**
* D136 (#331): the concept's COLLECTIONS rows (app.js a336–339) as production lists them, by name (DC1): the fixture's
* "Landscapes" and "Experiments" hold the concept's 12 and 8 images, so text and counts compare as they are and each
* place keeps the concept's row geometry. At <=950px the concept hides the nav, which would leave a phone no way to
* open a collection: production shows it as the concept lays out every other sidebar nav there (its SAVED VIEWS nav),
* its rows styled as that nav's rows. Text, widths and wrap gaps then follow the rows' own content.
*/
function collectionsNav(nav,navs) {
const byName=[...nav.rows].sort((a,b)=>a.text<b.text?-1:a.text>b.text?1:0);
if(nav.shown)return {...nav,rows:byName.map((row,index)=>({...row,gapAfter:nav.rows[index].gapAfter}))};
const saved=navs.find(item=>item.id==='saved-nav');
const {id,label,rows,...frame}=saved;
return {...nav,...frame,id:nav.id,label:nav.label,rows:byName.map(row=>({...rows[0],text:row.text,
count:row.count&&rows[0].count?{...rows[0].count,text:row.count.text}:rows[0].count,
icon:rows[0].icon&&row.icon?{...rows[0].icon,d:row.icon.d}:row.icon,
width:'(content)',hugs:'(content)',gapAfter:'(content)',current:null}))};
}
/** The production collections nav at <=950px with its content-sized values replaced, as collectionsNav maps them. */
function contentFree(navs) {
return navs.map(nav=>nav.id!=='collections-nav'||!nav.shown?nav:{...nav,
rows:nav.rows.map(row=>({...row,width:'(content)',hugs:'(content)',gapAfter:'(content)'}))});
}
/** D136: where the concept hides COLLECTIONS, the production rows' content-sized values are not compared. */
function comparableNavs(reference,navs) {
return reference.chrome.navs.find(nav=>nav.id==='collections-nav')?.shown?navs:contentFree(navs);
}
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
function mapShellConcept(reference) {
const {chrome}=reference;
const navs=chrome.navs
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
.filter(nav=>nav.id!=='collections-nav')
.map(nav=>{
if(nav.id==='collections-nav')return collectionsNav(nav,chrome.navs);
if(nav.id==='saved-nav')return savedNav(nav);
if(nav.id!=='system-nav')return nav;
const system=rowNamed(nav,'System');
@@ -127,8 +99,9 @@ function mapShellConcept(reference) {
return {...reference,chrome:{...chrome,
// D29: no prototype suffix in the document title.
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
// D49: SAVED VIEWS without its "PROPOSED" badge. D136: COLLECTIONS without it either.
labels:chrome.labels.map(label=>label.text.endsWith(' PROPOSED')?{...label,text:label.text.replace(/ PROPOSED$/,'')}:label),
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
navs,
connection:{...chrome.connection,
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
@@ -161,9 +134,8 @@ function headingFrame(heading) {
function compare(route,reference,actual) {
const expected=mapShellConcept(reference);
const heading=FULL_HEADING.has(route);
const chrome={...actual.chrome,navs:comparableNavs(reference,actual.chrome.navs)};
return [
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome,noPageScroll:actual.noPageScroll}),
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
.map(item=>({...item,path:`heading.${item.path}`})),
];
@@ -270,7 +242,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
await openProduction(page,'images','dark');
const actual=await save('Save','#workspace-main');
expect(diffInventory(mapShellConcept(reference).chrome.navs,comparableNavs(reference,actual.chrome.navs))).toEqual([]);
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
const referenceRows=reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows;
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
.toEqual([['Favorites',referenceRows[0].count],['Needs review',{...referenceRows[1].count,text:'0'}],['Golden hour',null]]);
-40
View File
@@ -52,43 +52,3 @@ export async function unseenRestore(page, restoring) {
open();
}
}
// A restore the open page does see run and finish (#415), driven by the test instead of the 15 s poll's timing. The
// job is shaped as the server's (py/api/backup_restore.py: kind `restore`, one `errors` group, not cancellable).
const RESTORE_JOB_ID = '415415415415415415415415415415ab';
const restoreJob = state => ({ id: RESTORE_JOB_ID, kind: 'restore', title: 'Database restore', state, started_at: 1,
finished_at: state === 'running' ? null : 2, progress: 0, status_line: state === 'running' ? '' : 'Restored the test snapshot.',
processed: 0, total: 0, counters: { errors: 0 }, scope: {}, listed: { errors: 0 }, log_length: 0, cancellable: false });
/** The Activity pill's link while the page follows that restore. */
export const SEEN_RESTORE_PILL = `#/activity/${RESTORE_JOB_ID}`;
/** The toast the page shows when it sees that restore finish (activity-kinds.mjs `restore.finishToast`). */
export const SEEN_RESTORE_TOAST = 'Database restore finished · Restored the test snapshot.';
/**
* Call before the page's first navigation, after `armUnseenRestores(page)` (a later route wins). The page's first
* job-list read lists a restore job running, so its Activity store follows it; its job stream is refused, so the store
* reads the list again, and every later read is held until `finish()`, which answers them with the job finished and
* resolves once one of those answers reached the page. The real restore the test runs meanwhile goes through the API.
*/
export async function followRestore(page) {
let release;
const released = new Promise(resolve => { release = resolve; });
let first = true;
await page.route(`${JOBS}/*/events`, route => route.abort().catch(() => {}));
await page.route(JOBS, async route => {
if (first) {
first = false;
return route.fulfill({ json: { success: true, jobs: [restoreJob('running')] } }).catch(() => {});
}
await released;
// A read the page has aborted since (a newer read supersedes it) can no longer be answered.
return route.fulfill({ json: { success: true, jobs: [restoreJob('finished')] } }).catch(() => {});
});
return {
async finish() {
const answered = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/jobs');
release();
await answered;
},
};
}
@@ -1,493 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
// Collections (#331): the sidebar COLLECTIONS group (D136, concept index.html 28–31, app.js 336–339), the collection
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/collections';
const main = page => page.locator('#workspace-main');
const heading = page => main(page).getByRole('heading', { level: 1 });
const breadcrumb = page => page.locator('#workspace-breadcrumb');
const description = page => main(page).locator('.heading p.muted');
const missingNote = page => main(page).locator('.heading p.small');
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const imageCards = page => page.locator('article[data-image-key]');
const collectionsNav = page => page.getByRole('navigation', { name: 'Collections', exact: true });
const collectionsLabel = page => page.locator('aside.sidebar .sidebar-label', { hasText: 'COLLECTIONS' });
const rows = page => collectionsNav(page).locator('a[data-route^="collection/"]');
const row = (page, name) => rows(page).filter({ hasText: name });
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
const editButton = page => main(page).locator('.heading-actions > button').first();
const editDialog = page => page.getByRole('dialog', { name: 'Edit collection', exact: true });
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete collection', exact: true });
const nameField = page => editDialog(page).getByRole('textbox', { name: 'Collection name', exact: true });
const toast = page => page.locator('#workspace-toast');
async function seed(request) {
const response = await request.post('/__test__/collections/seed');
await expect(response).toBeOK();
return response.json();
}
async function listed(request) {
const response = await request.get(API);
await expect(response).toBeOK();
return (await response.json()).collections;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.collectionErrors = [];
page.on('pageerror', error => page.collectionErrors.push(error.message));
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
page.on('dialog', dialog => { page.collectionErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.collectionErrors).toEqual([]);
});
test('a collection row opens its page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
// D136: name order, the listed-member count, no "PROPOSED" badge.
await expect(collectionsLabel(page)).toBeVisible();
await expect(collectionsLabel(page)).toHaveText('COLLECTIONS');
await expect(rows(page)).toHaveCount(2);
await expect(rows(page).nth(0)).toContainText('Experiments');
await expect(rows(page).nth(1)).toContainText('Landscapes');
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('12');
await expect(row(page, 'Landscapes')).toHaveAttribute('href', `#/collection/${id}`);
await expect(row(page, 'Landscapes').locator('svg')).toHaveCount(1);
await expect(page.locator('aside.sidebar .new-label')).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(21);
await row(page, 'Landscapes').click();
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
await expect(heading(page)).toHaveText('Landscapes');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(page).toHaveTitle('Collection · Prompt Manager');
await expect(row(page, 'Landscapes')).toHaveAttribute('aria-current', 'page');
await expect(description(page)).toHaveText('Your generations, their prompts, and the next idea.');
await expect(imageCards(page)).toHaveCount(12);
for (const path of manifest.files.Landscapes) {
await expect(page.locator(`article[data-image-key$=":${path}"]`)).toHaveCount(1);
}
// MC3: the member whose file is gone is not listed and is counted in the note.
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
// Every Images filter narrows the members.
await search(page).fill('landscape_03');
await expect(imageCards(page)).toHaveCount(1);
await search(page).fill('');
await expect(imageCards(page)).toHaveCount(12);
await row(page, 'Experiments').click();
await expect(heading(page)).toHaveText('Experiments');
await expect(imageCards(page)).toHaveCount(8);
await expect(missingNote(page)).toHaveCount(0);
});
test('without collections the group shows no label and no rows', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page).first()).toBeVisible();
await expect(collectionsNav(page)).toHaveCount(1);
await expect(rows(page)).toHaveCount(0);
await expect(collectionsLabel(page)).toBeHidden();
});
test('a collection page neither restores nor replaces the Images return state', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await search(page).fill('experiment');
await expect(imageCards(page)).toHaveCount(8);
await row(page, 'Landscapes').click();
await expect(search(page)).toHaveValue('');
await expect(imageCards(page)).toHaveCount(12);
await search(page).fill('landscape_01');
await expect(imageCards(page)).toHaveCount(1);
await page.goto(`${workspace}#/images`);
await expect(search(page)).toHaveValue('experiment');
expect(manifest.collections.Landscapes.count).toBe(12);
});
test('Edit renames the open collection; a taken name stays in the dialog', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Landscapes');
await expect(editButton(page)).toHaveText('Edit');
await editButton(page).click();
await expect(nameField(page)).toHaveValue('Landscapes');
await expect(editDialog(page).locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
// CUJ 3: names are unique ignoring case.
await nameField(page).fill('experiments');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(editDialog(page).getByRole('alert')).toHaveText('A collection named “experiments” already exists.');
await expect(heading(page)).toHaveText('Landscapes');
await nameField(page).fill(' Landscape grids ');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(editDialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Collection renamed.');
await expect(heading(page)).toHaveText('Landscape grids');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(row(page, 'Landscape grids')).toHaveAttribute('aria-current', 'page');
await expect(rows(page).nth(1)).toContainText('Landscape grids');
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments', 'Landscape grids']);
});
test('a failed rename keeps the dialog; a failed delete changes nothing', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Experiments');
let failing = true;
await page.route(`**${API}/${id}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"The collection could not be changed."}' })
: route.fallback());
await editButton(page).click();
await nameField(page).fill('Tests');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The collection could not be renamed. Try again.');
await expect(editDialog(page)).toBeVisible();
await expect(heading(page)).toHaveText('Experiments');
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await expect(deleteDialog(page).locator('p')).toHaveText(
'Delete “Experiments”? Its 8 images stay where they are; only the collection is removed.');
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('The collection could not be deleted. Try again.');
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
expect(await listed(request)).toHaveLength(2);
failing = false;
});
test('Delete removes the collection only after confirming, and leaves every file', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await editButton(page).click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
// The stored count: 12 listed members and the missing one.
await expect(deleteDialog(page).locator('p')).toHaveText(
'Delete “Landscapes”? Its 13 images stay where they are; only the collection is removed.');
await deleteDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(deleteDialog(page)).toHaveCount(0);
expect(await listed(request)).toHaveLength(2);
await editButton(page).click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Collection deleted.');
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(rows(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveCount(21);
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments']);
});
test('an unknown collection says it no longer exists, with the way back to Images', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/collection/999999`);
await expect(heading(page)).toHaveText('Collection');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(imageCards(page)).toHaveCount(0);
await main(page).getByRole('link', { name: 'Open Images', exact: true }).click();
await expect(page).toHaveURL(/#\/images$/);
await expect(imageCards(page)).toHaveCount(21);
});
test('a failed list says so and Retry reads it again', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
let failing = true;
await page.route(`**${API}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
: route.fallback());
await page.goto(`${workspace}#/collection/${id}`);
await expect(description(page)).toHaveText('Collections could not be loaded. Your saved data has not changed.');
await expect(description(page)).toHaveAttribute('role', 'alert');
await expect(rows(page)).toHaveCount(0);
failing = false;
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(heading(page)).toHaveText('Experiments');
await expect(imageCards(page)).toHaveCount(8);
await expect(rows(page)).toHaveCount(2);
});
test('a saved view keeps its collection, and says so once the collection is deleted (MC6)', async ({ page, request }) => {
const manifest = await seed(request);
const view = manifest.saved_view;
await page.goto(`${workspace}#/images`);
await savedNav(page).locator(`a[data-route="saved/${view.id}"]`).click();
await expect(heading(page)).toHaveText(view.name);
await expect(breadcrumb(page)).toHaveText(view.name);
await expect(imageCards(page)).toHaveCount(8);
// A saved view is not the collection page: no missing note.
await expect(missingNote(page)).toHaveCount(0);
// Save view on a collection page stores the collection.
await row(page, 'Landscapes').click();
await expect(imageCards(page)).toHaveCount(12);
await search(page).fill('landscape_1');
await expect(imageCards(page)).toHaveCount(3);
await main(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('Grid seed');
await save.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const views = (await (await request.get('/prompt_manager/saved-views')).json()).views;
const saved = views.find(item => item.name === 'Grid seed');
expect(saved.state).toMatchObject({ q: 'landscape_1', favorites: false, collection: String(manifest.collections.Landscapes.id) });
await expect(await request.delete(`${API}/${manifest.collections.Experiments.id}`)).toBeOK();
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText(view.name);
await expect(main(page).locator('.empty')).toHaveText('The collection this view uses was deleted.');
await expect(main(page).locator('.empty')).toBeVisible();
await expect(imageCards(page)).toHaveCount(0);
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
});
test('a collection deleted in another tab shows the deleted-collection page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/images`);
await expect(rows(page)).toHaveCount(2);
await expect(await request.delete(`${API}/${id}`)).toBeOK();
await row(page, 'Experiments').click();
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(rows(page)).toHaveCount(1);
});
// D136 / CUJ 15: the rows show and open at desktop and phone width in both themes. The concept's own phone rule hides
// every nav count there, so the rows keep their counts only at desktop width.
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
test(`collection rows show and open at ${width}x${height} ${theme}`, async ({ page, request }) => {
const manifest = await seed(request);
await page.setViewportSize({ width, height });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
for (const name of ['Experiments', 'Landscapes']) await expect(row(page, name)).toBeVisible();
const count = row(page, 'Landscapes').locator('.nav-count');
if (width > 950) await expect(count).toBeVisible();
else await expect(count).toBeHidden();
// Phone width: the label stays hidden as every sidebar label is; desktop shows it.
if (width > 950) await expect(collectionsLabel(page)).toBeVisible();
else await expect(collectionsLabel(page)).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await row(page, 'Landscapes').click();
await expect(page).toHaveURL(new RegExp(`#/collection/${manifest.collections.Landscapes.id}$`));
await expect(heading(page)).toHaveText('Landscapes');
await expect(imageCards(page)).toHaveCount(12);
});
}
test('a finished restore reads the collections again', async ({ page, request }) => {
await seed(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
try {
const backup = await request.get('/prompt_manager/backup');
await expect(backup).toBeOK();
const bytes = await backup.body();
const [experiments] = await listed(request);
await expect(await request.delete(`${API}/${experiments.id}`)).toBeOK();
await page.goto(`${workspace}#/system/backups`);
await expect(rows(page)).toHaveCount(1);
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
await expect(toast(page)).toHaveText(/^Database restore finished · /);
// The list was read again without a reload.
await expect(rows(page)).toHaveCount(2);
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
} finally {
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
}
});
test('renaming a collection deleted in another tab says so and shows the deleted-collection page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Experiments');
await editButton(page).click();
await expect(await request.delete(`${API}/${id}`)).toBeOK();
await nameField(page).fill('Tests');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('That collection no longer exists.');
await expect(editDialog(page)).toHaveCount(0);
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(rows(page)).toHaveCount(1);
});
/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */
async function holdListReads(page, answer = route => route.fallback()) {
const held = [];
let open = false;
await page.route(`**${API}`, route => {
if (route.request().method() !== 'GET') return route.fallback();
if (open) return answer(route);
held.push(route);
return undefined;
});
return {
held,
async release() {
open = true;
for (const route of held.splice(0)) await answer(route).catch(() => {});
},
};
}
test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => {
const manifest = await seed(request);
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json();
const [file] = listing.images;
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(heading(page)).toHaveText('Landscapes');
// Another tab makes "Fresh"; this tab does not list it yet.
const added = await request.post(`${API}/items`, { data: { name: 'Fresh',
items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } });
await expect(added).toBeOK();
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
// supersedes that one while both are held.
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
await expect(imageCards(page)).toHaveCount(1);
});
test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
let gone = true;
await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`)
? route.fulfill({ status: 404, contentType: 'application/json',
body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await expect(rows(page)).toHaveCount(2);
// The shell's re-read is held, then fails: the page must not stay blank.
const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"Collections could not be read."}' }));
await row(page, 'Experiments').click();
await expect.poll(() => hold.held.length).toBe(1);
await hold.release();
const retry = main(page).getByRole('button', { name: 'Retry', exact: true });
await expect(retry).toBeVisible();
await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.');
gone = false;
await page.unroute(`**${API}`);
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
// The list read again still lists it: the same Retry, never a blank page.
gone = true;
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await row(page, 'Experiments').click();
await expect(retry).toBeVisible();
gone = false;
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
});
test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
let missing = null;
await page.route(`**${API}`, async route => {
if (route.request().method() !== 'GET' || missing === null) return route.fallback();
const response = await route.fetch();
const data = await response.json();
data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item);
return route.fulfill({ response, json: data });
});
await page.goto(`${workspace}#/collection/${id}`);
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
const rename = async name => {
await editButton(page).click();
await nameField(page).fill(name);
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
};
missing = 3;
await rename('Landscapes A');
await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.');
missing = 0;
await rename('Landscapes B');
await expect(missingNote(page)).toHaveCount(0);
missing = 1;
await rename('Landscapes C');
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
});
test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(imageCards(page)).toHaveCount(12);
let release;
const released = new Promise(resolve => { release = resolve; });
await page.route(`**${API}/${id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await released;
return route.fallback();
});
await editButton(page).click();
await nameField(page).fill('Later');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.keyboard.press('Escape');
await expect(editDialog(page)).toHaveCount(0);
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
await expect(save).toBeVisible();
release();
await expect(heading(page)).toHaveText('Later');
await expect(save).toBeVisible();
});
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(heading(page)).toBeFocused();
await page.route(`**${API}`, route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999998'; });
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible();
await expect(heading(page)).toBeFocused();
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
@@ -454,37 +454,6 @@ async function leaveAndReturn(page,id) {
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
// #415: a restore the page sees finish while New prompt is open keeps the dialog and its entries; the Library is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps New prompt open and reloads the Library after it closes',async({page,request})=>{
const f=page.fixture;const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
const buffer=await snapshot.body();
const current=await detail(request,f.positive_id,'active');
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{title:'Retitled elsewhere'}}})).toBeOK();
const restoreRun=await followRestore(page);
await page.goto(`${workspace}#/library`);const card=page.locator(`article[data-prompt-id="${f.positive_id}"]`);
await expect(card).toContainText('Retitled elsewhere');
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await headingActions(page).getByRole('button',{name:'+ New prompt',exact:true}).click();
const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Typed before restore');await field(dialog,'Positive prompt').fill('A prompt typed while storage was restored');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
page.writes.length=0;
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(dialog).toBeVisible();
await expect(field(dialog,'Title')).toHaveValue('Typed before restore');
await expect(field(dialog,'Positive prompt')).toHaveValue('A prompt typed while storage was restored');
await expect(card).toContainText('Retitled elsewhere');
await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1);
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(card).not.toContainText('Retitled elsewhere');await expect(card).toContainText('Editor portrait');
await expect(page.locator('#workspace-main h1')).toBeFocused();
expect(page.writes).toHaveLength(1);
});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
+2 -3
View File
@@ -23,7 +23,7 @@ const heading = page => main(page).getByRole('heading', { level: 1 });
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const toast = page => page.locator('#workspace-toast');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
async function refine(page, values) {
await filtersButton(page).click();
@@ -73,8 +73,7 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
// D136: the COLLECTIONS label stays hidden while no collection is listed.
await expect(page.locator('aside.sidebar .sidebar-label:not([hidden])')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(viewRows(page)).toHaveCount(0);
await search(page).fill('dogs');
@@ -1,7 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
import { followRestore, SEEN_RESTORE_PILL, SEEN_RESTORE_TOAST } from './unseen-restore.mjs';
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
@@ -266,24 +265,6 @@ test('a finished restore shows the open page again from the restored data', asyn
expect(await libraryTotal(request)).toBe(restoredTotal);
});
// #415: an open dialog waits out a finished restore, except a review: the plan it staged no longer describes storage,
// so it closes as dismissed (its stage discarded), as before.
test('a restore seen finishing elsewhere closes an open restore review and discards its stage', async ({ page, request }) => {
const bytes = await backupBytes(request);
const restoreRun = await followRestore(page);
await openBackups(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href', SEEN_RESTORE_PILL);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
const discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
expect((await discarded).status()).toBe(200);
await expect(dialog(page)).toHaveCount(0);
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
+1 -1
View File
@@ -472,7 +472,7 @@ async function openImages(page) {
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' } } });
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
expect(created.status()).toBe(201);
const view = (await created.json()).view;
try {
+1 -26
View File
@@ -1,7 +1,7 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
@@ -552,31 +552,6 @@ test('conflict and restore states keep the dialog and write nothing',async({page
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
// #415: a restore the page sees finish while Create tag is open keeps the dialog and its entries; the page is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps an open dialog and reloads the page after it closes',async({page,request})=>{
const saved=await backup(request);
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
const restoreRun=await followRestore(page);const writes=trackWrites(page);await openPage(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await expect(tagRow(page,'fog')).toHaveCount(1);
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
await field(box,'Tag name').fill('sunrise');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer:saved}}})).toBeOK();
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(box).toBeVisible();await expect(field(box,'Tag name')).toHaveValue('sunrise');
await expect(tagRow(page,'fog')).toHaveCount(1);
await button(box,'Apply').click();
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
expect(tagNames(await snapshot(request))).not.toContain('sunrise');
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
test('lost response is outcome-unknown',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
+5 -9
View File
@@ -548,16 +548,12 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
const sidebar = page.locator('aside.sidebar');
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
'nav#saved-nav', 'div.sidebar-label', 'nav#collections-nav', 'div.sidebar-bottom']);
'nav#saved-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
// D49: SAVED VIEWS without the concept's PROPOSED badge. D136: COLLECTIONS without it either, its label hidden
// and its nav empty while no collection exists.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS', 'COLLECTIONS']);
await expect(sidebar.locator('.sidebar-label').nth(2)).toBeHidden();
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
await expect(page.locator('nav#collections-nav')).toHaveAttribute('aria-label', 'Collections');
await expect(page.locator('nav#collections-nav > a')).toHaveCount(0);
await expect(page.locator('.new-label')).toHaveCount(0);
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
const primary = page.locator('nav#primary-nav');
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
@@ -584,7 +580,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
await page.setViewportSize({ width: 390, height: 844 });
await expect(navCount(page, 'library')).toBeHidden();
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(4);
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
});
@@ -1,131 +0,0 @@
"""Owned, deterministic collections for the workspace collection workflows (#331).
Twenty-one PNGs under the output source: twelve listed members of
"Landscapes", eight of "Experiments" and one loose file in neither. Landscapes
also holds one member whose file does not exist (MC3: hidden, counted as
missing). One saved view reopens "Experiments" (D140). Members are added
through ``ImageCollectionOperations`` with favorites' keys
(``_favorite_root_key``, ``_favorite_relative_key``), as the routes add them.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
FOLDER = "collections"
LANDSCAPES = "Landscapes"
EXPERIMENTS = "Experiments"
LANDSCAPE_FILES = tuple(f"landscape_{index:02d}.png" for index in range(1, 13))
EXPERIMENT_FILES = tuple(f"experiment_{index:02d}.png" for index in range(1, 9))
LOOSE_FILE = "loose.png"
MISSING_FILE = "gone.png"
SAVED_VIEW = "Experiments view"
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
"media_review_items",
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompt_library_state",
"prompts",
)
_IMAGES_STATE = {
"q": "",
"category": "",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": [],
"favorites": False,
}
def _write_png(path: Path, shade: int, stamp: float) -> None:
with Image.new("RGB", (64, 48), (shade, 120, 255 - shade)) as image:
image.save(path)
os.utime(path, (stamp, stamp))
def seed_collections(api: PromptManagerAPI, root: Path) -> dict:
"""Replace this server's disposable rows and files with the two collections."""
# The API package needs the host stubs the server installs before importing it.
from py.api.images import _favorite_relative_key, _favorite_root_key
db = api.db
with db.model.get_connection() as conn:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
replace_folder(root / name)
folder = root / "output" / FOLDER
folder.mkdir()
# Newest first in this order: Landscapes, then Experiments, then the loose file.
now = time.time()
ordered = (*LANDSCAPE_FILES, *EXPERIMENT_FILES, LOOSE_FILE)
for index, name in enumerate(ordered):
_write_png(folder / name, (index * 11) % 256, now - 60 - index * 60)
root_key = _favorite_root_key(list(api._get_all_output_dirs())[0])
members = {
LANDSCAPES: (*LANDSCAPE_FILES, MISSING_FILE),
EXPERIMENTS: EXPERIMENT_FILES,
}
ids = {}
for name, files in members.items():
added = db.add_to_collection(
name=name,
items=[
(root_key, _favorite_relative_key(f"{FOLDER}/{file}")) for file in files
],
)
ids[name] = added["collection"]["id"]
view = db.create_saved_view(
"images", SAVED_VIEW, {**_IMAGES_STATE, "collection": str(ids[EXPERIMENTS])}
)
api.invalidate_gallery_cache()
return {
"collections": {
LANDSCAPES: {"id": ids[LANDSCAPES], "count": 12, "missing": 1},
EXPERIMENTS: {"id": ids[EXPERIMENTS], "count": 8, "missing": 0},
},
"files": {
LANDSCAPES: [f"{FOLDER}/{file}" for file in LANDSCAPE_FILES],
EXPERIMENTS: [f"{FOLDER}/{file}" for file in EXPERIMENT_FILES],
"loose": f"{FOLDER}/{LOOSE_FILE}",
},
"saved_view": {"id": view["id"], "name": SAVED_VIEW},
}
def register_collections_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> None:
"""Register the fixed seed command; no path or SQL comes from a request."""
@routes.post("/__test__/collections/seed")
async def seed(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
if data != {} or request.query:
raise web.HTTPBadRequest()
manifest = await api._run_in_executor(seed_collections, api, root)
return web.json_response({"success": True, **manifest})
@@ -174,8 +174,6 @@ _TABLES = (
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -195,10 +193,6 @@ _TABLES = (
)
# The concept's collections and the slice of its images each holds (#331).
CONCEPT_COLLECTIONS = (("Landscapes", slice(0, 12)), ("Experiments", slice(12, 20)))
def concept_starred(prompt_index: int, round_index: int) -> bool:
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
return round_index == 0 and prompt_index % 2 == 0
@@ -501,19 +495,6 @@ def seed_workspace_concept(
)
images.reverse()
runs.reverse()
# The concept's COLLECTIONS rows (app.js a336–339): "Landscapes" holds the
# first 12 images in the concept's order and "Experiments" the next 8, so
# the rows and counts compare as the concept shows them (#331 D136).
collections = {}
for name, members in CONCEPT_COLLECTIONS:
added = db.add_to_collection(
name=name,
items=[
(favorite_root, _favorite_relative_key(image["path"]))
for image in images[members]
],
)
collections[name] = added["collection"]["id"]
seedless = []
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
@@ -550,5 +531,4 @@ def seed_workspace_concept(
"images": images,
"runs": runs,
"tags": tags,
"collections": collections,
}
@@ -26,8 +26,6 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -78,8 +78,6 @@ LIMITED_SAMPLERS = 17
# More than one queue page (40), so Load more has a second page.
MAX_ITEMS = 45
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -56,8 +56,6 @@ def _valid_missing(value) -> bool:
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
MAX_EXTRA_IMAGES = 160
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -112,8 +112,6 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -83,8 +83,6 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
-313
View File
@@ -1,313 +0,0 @@
// Collections (#331 D136–D140): the client store, the route hash, the names, every user-facing string and the
// mapping of the routes' error codes.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/collections.mjs');
const FILE = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'grids/one.png' });
function apiError(status, code) {
return Object.assign(new Error(code || `HTTP ${status}`), { status, code: code || null });
}
function fakeApi() {
const calls = [];
const pending = [];
const api = {
calls,
get(url, options) {
calls.push(['GET', url, options?.cache]);
if (url.includes('/membership?')) return api.membershipResult ?? Promise.resolve({ success: true, collection_ids: [2] });
return new Promise((resolve, reject) => pending.push({ resolve, reject }));
},
post(url, body) {
calls.push(['POST', url, body]);
return api.postResult ?? Promise.resolve({ success: true, collection: { id: 5, name: 'Grids' }, created: true,
added: body.items.length, already: 0, not_found: 0 });
},
patch(url, body) {
calls.push(['PATCH', url, body]);
return api.patchResult ?? Promise.resolve({ success: true, collection: { id: 2, name: body.name } });
},
del(url, options) {
calls.push(['DELETE', url, options?.body]);
return api.deleteResult ?? Promise.resolve(options?.body
? { success: true, collection: { id: options.body.collection_id, name: 'Portraits' }, removed: options.body.items.length }
: { success: true, deleted: true });
},
};
return { api, pending };
}
const LISTED = { success: true, collections: [
{ id: 4, name: 'Experiments', count: 8, missing: 0 },
{ id: 2, name: 'Landscapes', count: 12, missing: 1 },
] };
/** A store whose first read answered with `data`; the test's own timer seam replaces the 300 ms count timer. */
async function readyStore(data = LISTED) {
const { createCollections } = await source;
const { api, pending } = fakeApi();
const timers = [];
const controller = new AbortController();
const store = createCollections(api, controller.signal, {
setTimer: (run, delay) => { timers.push({ run, delay, cleared: false }); return timers.length; },
clearTimer: id => { if (timers[id - 1]) timers[id - 1].cleared = true; },
});
store.load();
pending.shift().resolve(data);
await store.settled();
return { store, api, pending, timers, controller };
}
test('the route hash, the name rule and the limits', async () => {
const { collectionHash, normalizeCollectionName, MAX_COLLECTION_NAME_LENGTH, MAX_COLLECTION_ITEMS,
COLLECTION_COUNT_DELAY_MS } = await source;
assert.equal(collectionHash(12), '#/collection/12');
assert.throws(() => collectionHash(0), TypeError);
assert.throws(() => collectionHash('3'), TypeError);
assert.equal(MAX_COLLECTION_NAME_LENGTH, 100);
assert.equal(MAX_COLLECTION_ITEMS, 500);
assert.equal(COLLECTION_COUNT_DELAY_MS, 300);
// Trimmed, each run of whitespace one space, 1 to 100 characters, no control characters (design §1).
assert.equal(normalizeCollectionName(' LoRa testing   grids '), 'LoRa testing grids');
assert.equal(normalizeCollectionName('x'.repeat(100)), 'x'.repeat(100));
for (const bad of ['', ' ', 'x'.repeat(101), 'bad\u0007name', 7, null, undefined]) {
assert.equal(normalizeCollectionName(bad), null, String(bad));
}
// A tab or newline is whitespace, collapsed rather than refused.
assert.equal(normalizeCollectionName('a\tb\nc'), 'a b c');
});
test('the listing keeps usable rows in the server order, with counts only when they are counts', async () => {
const { parseCollections, parseCollection } = await source;
assert.deepEqual(parseCollections({ success: true, collections: [
{ id: 3, name: 'b', count: 2, missing: 0 },
{ id: 0, name: 'bad id' },
{ id: 1, name: ' ', count: 1, missing: 0 },
{ id: 7, name: 'A', count: null, missing: null },
{ id: 9, name: 'c', count: -1, missing: 1.5 },
] }), [
{ id: 3, name: 'b', count: 2, missing: 0 },
{ id: 7, name: 'A', count: null, missing: null },
{ id: 9, name: 'c', count: null, missing: null },
]);
assert.equal(parseCollection(null), null);
assert.throws(() => parseCollections({ success: true }), TypeError);
});
test('every user-facing string of the design (§7.4, §8)', async () => {
const c = await source;
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 0 }), 'Added 1 image to “X”.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 3, already: 0, notFound: 0 }), 'Added 3 images to “X”.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 1, notFound: 0 }),
'Added 1 image to “X”. 1 was already in it.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 2, already: 3, notFound: 1 }),
'Added 2 images to “X”. 3 were already in it. 1 could not be found.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 2 }),
'Added 1 image to “X”. 2 could not be found.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 0, already: 4, notFound: 0 }),
'All selected images were already in “X”.');
assert.equal(c.FILES_NOT_FOUND_TOAST, 'None of the selected images could be found. Refresh the images and try again.');
assert.equal(c.SOURCE_CHANGED_TOAST, 'The sources changed. Refresh the images and try again.');
assert.equal(c.ADD_FAILED_TOAST, 'The images could not be added. Try again.');
assert.equal(c.removedMessage('X', 1), 'Removed 1 image from “X”.');
assert.equal(c.removedMessage('X', 4), 'Removed 4 images from “X”.');
assert.equal(c.REMOVE_FAILED_TOAST, 'The images could not be removed. Try again.');
assert.equal(c.COLLECTION_GONE_TOAST, 'That collection no longer exists.');
assert.deepEqual([c.COLLECTION_NOUNS.title, c.COLLECTION_NOUNS.label, c.COLLECTION_NOUNS.deleteTitle],
['Edit collection', 'Collection name', 'Delete collection']);
assert.equal(c.COLLECTION_NOUNS.renamed, 'Collection renamed.');
assert.equal(c.RENAMED_TOAST, 'Collection renamed.');
assert.equal(c.nameTakenMessage('LoRa'), 'A collection named “LoRa” already exists.');
assert.equal(c.RENAME_FAILED_TOAST, 'The collection could not be renamed. Try again.');
// The stored count: listed members plus missing ones.
assert.equal(c.deleteQuestion({ name: 'X', count: 1, missing: 0 }),
'Delete “X”? Its 1 image stays where it is; only the collection is removed.');
assert.equal(c.deleteQuestion({ name: 'X', count: 3, missing: 1 }),
'Delete “X”? Its 4 images stay where they are; only the collection is removed.');
assert.equal(c.deleteQuestion({ name: 'X', count: null, missing: null }),
'Delete “X”? Its images stay where they are; only the collection is removed.');
assert.equal(c.COLLECTION_NOUNS.deleteText({ name: 'X', count: 2, missing: 0 }), c.deleteQuestion({ name: 'X', count: 2, missing: 0 }));
assert.equal(c.DELETED_TOAST, 'Collection deleted.');
assert.equal(c.DELETE_FAILED_TOAST, 'The collection could not be deleted. Try again.');
assert.equal(c.DELETED_PAGE_TEXT, 'This collection no longer exists.');
assert.equal(c.OPEN_IMAGES_LINK, 'Open Images');
assert.equal(c.VIEW_COLLECTION_DELETED, 'The collection this view uses was deleted.');
assert.equal(c.LIST_FAILED_TEXT, 'Collections could not be loaded. Your saved data has not changed.');
assert.equal(c.missingNote(1), '1 image in this collection is not in your current sources.');
assert.equal(c.missingNote(5), '5 images in this collection are not in your current sources.');
for (const none of [0, null, undefined, -1, 1.5]) assert.equal(c.missingNote(none), '');
assert.equal(c.TOO_MANY_TOAST, 'Select at most 500 images.');
assert.deepEqual([c.ADD_DIALOG_TITLE, c.ADD_DIALOG_LABEL, c.ADD_DIALOG_ACTION],
['Add selected images to collection', 'Collection name', 'Add']);
assert.equal(c.COLLECTIONS_UPDATED_TOAST, 'Collections updated.');
assert.equal(c.UPDATE_FAILED_TEXT, 'The collections could not be updated. Try again.');
assert.equal(c.MEMBERSHIP_LOADING_TEXT, 'Loading collections…');
assert.equal(c.MEMBERSHIP_FAILED_TEXT, 'Collections could not be loaded.');
assert.equal(c.NO_COLLECTIONS_TEXT, 'No collections yet. Name one below.');
});
test('a write failure maps the routes\' error codes to their strings', async () => {
const { writeFailure, renameFailure, isCollectionGone, COLLECTION_GONE_TOAST, SOURCE_CHANGED_TOAST,
FILES_NOT_FOUND_TOAST, ADD_FAILED_TOAST, RENAME_FAILED_TOAST } = await source;
assert.equal(writeFailure(apiError(404, 'collection-not-found'), ADD_FAILED_TOAST), COLLECTION_GONE_TOAST);
assert.equal(writeFailure(apiError(409, 'source-changed'), ADD_FAILED_TOAST), SOURCE_CHANGED_TOAST);
assert.equal(writeFailure(apiError(404, 'files-not-found'), ADD_FAILED_TOAST), FILES_NOT_FOUND_TOAST);
for (const other of [apiError(500), apiError(400), apiError(404), new TypeError('x'), null]) {
assert.equal(writeFailure(other, ADD_FAILED_TOAST), ADD_FAILED_TOAST);
}
assert.equal(isCollectionGone(apiError(404, 'collection-not-found')), true);
assert.equal(isCollectionGone(apiError(404)), false);
// The Edit dialog: a taken name stays in the dialog; a gone collection closes it; anything else keeps it open.
assert.deepEqual(renameFailure('LoRa')(apiError(409, 'name-taken')), { field: 'A collection named “LoRa” already exists.' });
assert.deepEqual(renameFailure('LoRa')(apiError(404, 'collection-not-found')), { toast: COLLECTION_GONE_TOAST, close: true });
assert.deepEqual(renameFailure('LoRa')(apiError(500)), { toast: RENAME_FAILED_TOAST });
});
test('the store reports loads, keeps the last list after a failed read, and a newer read supersedes', async () => {
const { createCollections, COLLECTIONS_URL } = await source;
const { api, pending } = fakeApi();
const store = createCollections(api);
assert.equal(store.status, 'loading');
const seen = [];
store.subscribe(next => seen.push([next.status, next.collections.map(item => item.id)]));
store.load();
const settled = store.settled();
pending[0].resolve(LISTED);
assert.equal((await settled).status, 'ready');
assert.deepEqual(api.calls[0], ['GET', COLLECTIONS_URL, 'no-store']);
assert.deepEqual(store.find(2), { id: 2, name: 'Landscapes', count: 12, missing: 1 });
assert.equal(store.find(3), null);
assert.deepEqual(store.collections.map(item => item.name), ['Experiments', 'Landscapes']);
store.load();
pending[1].reject(new Error('offline'));
await store.settled();
assert.equal(store.status, 'error');
assert.deepEqual(store.collections.map(item => item.id), [4, 2]);
store.load();
store.load();
pending[3].resolve({ success: true, collections: [{ id: 8, name: 'New', count: 0, missing: 0 }] });
pending[2].resolve(LISTED);
await store.settled();
assert.deepEqual(store.collections.map(item => item.id), [8]);
assert.deepEqual(seen.at(-1), ['ready', [8]]);
// A malformed listing is a failed read.
store.load();
pending[4].resolve({ success: true });
await store.settled();
assert.equal(store.status, 'error');
});
test('adding by name or id sends only the file identity and re-reads the counts 300 ms after the last change', async () => {
const { store, api, pending, timers } = await readyStore();
const { COLLECTION_ITEMS_URL } = await source;
const result = await store.add({ name: ' Grids ' }, [{ ...FILE, title: 'ignored', favorite: true }]);
assert.deepEqual(api.calls.at(-1), ['POST', COLLECTION_ITEMS_URL, { name: 'Grids', items: [FILE] }]);
assert.deepEqual(result, { collection: { id: 5, name: 'Grids' }, created: true, added: 1, already: 0, notFound: 0 });
// The new collection is listed at once (its count arrives with the re-read), in name order.
assert.deepEqual(store.collections.map(item => [item.id, item.count]), [[4, 8], [5, null], [2, 12]]);
api.postResult = Promise.resolve({ success: true, collection: { id: 2, name: 'Landscapes' }, created: false, added: 0,
already: 1, not_found: 1 });
const again = await store.add({ collectionId: 2 }, [FILE, { ...FILE, path: 'b.png' }]);
assert.deepEqual(api.calls.at(-1)[2], { collection_id: 2, items: [FILE, { ...FILE, path: 'b.png' }] });
assert.deepEqual(again, { collection: { id: 2, name: 'Landscapes' }, created: false, added: 0, already: 1, notFound: 1 });
// One trailing re-read for the run of writes.
assert.deepEqual(timers.map(timer => [timer.delay, timer.cleared]), [[300, true], [300, false]]);
const reads = api.calls.filter(call => call[0] === 'GET').length;
timers.at(-1).run();
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
pending.shift().resolve(LISTED);
await store.settled();
});
test('a write is refused before sending when its target or files are not valid', async () => {
const { store, api } = await readyStore();
const sent = api.calls.length;
const many = Array.from({ length: 501 }, (_, index) => ({ ...FILE, path: `${index}.png` }));
for (const [target, items] of [
[{ name: '' }, [FILE]], [{ name: 'x'.repeat(101) }, [FILE]], [{ collectionId: 0 }, [FILE]], [{}, [FILE]],
[{ name: 'a', collectionId: 2 }, [FILE]], [{ collectionId: 2 }, []], [{ collectionId: 2 }, many],
[{ collectionId: 2 }, [{ ...FILE, root_index: -1 }]], [{ collectionId: 2 }, [{ ...FILE, path: '' }]],
[{ collectionId: 2 }, [{ ...FILE, root_scope: 3 }]], [{ collectionId: 2 }, 'a.png'],
]) {
await assert.rejects(store.add(target, items), TypeError);
}
await assert.rejects(store.remove(0, [FILE]), TypeError);
await assert.rejects(store.remove(2, []), TypeError);
await assert.rejects(store.rename(2, ' '), TypeError);
await assert.rejects(store.delete(-1), TypeError);
await assert.rejects(store.membership({ root_index: 'x', root_scope: 'a', path: 'p' }), TypeError);
assert.equal(api.calls.length, sent);
});
test('a write to a collection that is gone re-reads the list at once and rejects with the error', async () => {
const { store, api, pending } = await readyStore();
const { isCollectionGone } = await source;
api.postResult = Promise.reject(apiError(404, 'collection-not-found'));
const reads = api.calls.filter(call => call[0] === 'GET').length;
await assert.rejects(store.add({ collectionId: 2 }, [FILE]), error => isCollectionGone(error));
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
pending.shift().resolve({ success: true, collections: [LISTED.collections[0]] });
await store.settled();
assert.equal(store.find(2), null);
// Another failure re-reads nothing.
api.deleteResult = Promise.reject(apiError(500));
await assert.rejects(store.remove(4, [FILE]), error => error.status === 500);
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
});
test('remove sends the collection and files; membership reads one file\'s collections', async () => {
const { store, api, timers } = await readyStore();
const { COLLECTION_ITEMS_URL, COLLECTION_MEMBERSHIP_URL } = await source;
assert.deepEqual(await store.remove(2, [FILE]), { collection: { id: 2, name: 'Portraits' }, removed: 1 });
assert.deepEqual(api.calls.at(-1), ['DELETE', COLLECTION_ITEMS_URL, { collection_id: 2, items: [FILE] }]);
assert.equal(timers.length, 1);
assert.deepEqual(await store.membership(FILE), [2]);
const [method, url] = api.calls.at(-1);
assert.equal(method, 'GET');
const query = new URL(url, 'http://local');
assert.equal(query.pathname, COLLECTION_MEMBERSHIP_URL);
assert.deepEqual(Object.fromEntries(query.searchParams), { root_index: '0', root_scope: FILE.root_scope, path: FILE.path });
api.membershipResult = Promise.resolve({ success: true, collection_ids: [1, 'x'] });
await assert.rejects(store.membership(FILE), TypeError);
});
test('rename and delete change the listed row at once and re-read the counts', async () => {
const { store, api, timers } = await readyStore();
const { COLLECTIONS_URL } = await source;
const renamed = await store.rename(2, ' A grids ');
assert.deepEqual(api.calls.at(-1), ['PATCH', `${COLLECTIONS_URL}/2`, { name: 'A grids' }]);
assert.deepEqual(renamed, { id: 2, name: 'A grids' });
// The row keeps its counts and moves to its new place in name order.
assert.deepEqual(store.collections.map(item => [item.id, item.name, item.count]), [[2, 'A grids', 12], [4, 'Experiments', 8]]);
assert.equal(await store.delete(4), 4);
assert.deepEqual(api.calls.at(-1), ['DELETE', `${COLLECTIONS_URL}/4`, undefined]);
assert.deepEqual(store.collections.map(item => item.id), [2]);
// Already gone (another tab): the outcome the user asked for.
api.deleteResult = Promise.reject(apiError(404, 'collection-not-found'));
assert.equal(await store.delete(2), 2);
assert.deepEqual(store.collections, []);
api.patchResult = Promise.reject(apiError(409, 'name-taken'));
await assert.rejects(store.rename(9, 'Taken'), error => error.code === 'name-taken');
assert.equal(timers.filter(timer => !timer.cleared).length, 1);
});
test('a disposed store stops its count timer, drops a read in flight and reads no more', async () => {
const { store, timers, controller, api, pending } = await readyStore();
await store.rename(2, 'Kept');
const seen = [];
store.subscribe(next => seen.push(next));
store.load();
controller.abort();
assert.equal(timers.at(-1).cleared, true);
pending.shift().resolve(LISTED);
await new Promise(resolve => setImmediate(resolve));
assert.equal(seen.filter(next => next.status === 'ready').length, 0);
const sent = api.calls.length;
await store.load();
assert.equal(api.calls.length, sent);
});
-18
View File
@@ -141,22 +141,6 @@ test('the Favorites page asks the listing for favorites only (#249 D51)', async
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
});
test('a collection page asks the listing for that collection only (#331 D137)', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({collection:'12',q:'fog'});
assert.equal(query.get('collection'),'12');assert.equal(query.get('q'),'fog');assert.equal(query.has('favorites'),false);
await stream.reset({collection:7});
assert.equal(query.get('collection'),'7');
for(const collection of ['',null,undefined]) {
await stream.reset({collection});
assert.equal(query.has('collection'),false);
}
for(const collection of ['0','07','x',-1,1.5,'1234567890',true])await assert.rejects(stream.reset({collection}),/Invalid collection filter/);
// Favorites and a collection are never both a page's filter.
await assert.rejects(stream.reset({collection:'3',favorites:true}),/Invalid collection filter/);
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
const {createImageStream}=await source;
const calls=[];
@@ -533,7 +517,5 @@ test('listingFilter is the listing query as an object, without paging (#392)', a
assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}),
{q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'});
assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'});
// #331: "Select all N matching" on a collection page acts on that collection's listing.
assert.deepEqual(listingFilter({collection:'4', q:'cat'}), {q:'cat', sort:'newest', collection:'4'});
assert.throws(() => listingFilter({sort:'sideways'}), TypeError);
});
+5 -20
View File
@@ -4,7 +4,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 IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
@@ -12,17 +12,8 @@ test('a saved state always holds every page key and keeps only values its page a
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true, collection: '' };
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
assert.deepEqual(normalizeViewState('images', full), full);
// #331 D140: a collection id is '' or 1 to 9 digits without a leading zero; an old view without the key reads ''.
for (const collection of ['1', '12', '999999999']) {
assert.equal(normalizeViewState('images', { collection }).collection, collection);
}
for (const collection of ['0', 'x', 12, '012', '1234567890', null]) {
assert.equal(normalizeViewState('images', { collection }).collection, '');
}
// Favorites and a collection are never both a view's filter: the collection wins, as the listing's URL does.
assert.deepEqual(normalizeViewState('images', { ...full, collection: '4' }), { ...full, favorites: false, collection: '4' });
// D51: favorites is a boolean; anything else is the whole listing.
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
// Each rejected value falls back on its own; the rest of the view still applies.
@@ -72,17 +63,11 @@ test('the listing keeps usable views in order and normalizes their state', async
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
// #331 D140: the open collection round-trips; a missing one is ''.
const inCollection = { ...filters, favorites: false, collection: '7' };
assert.equal(imagesViewState(inCollection).collection, '7');
assert.deepEqual(imagesFilters(imagesViewState(inCollection)), inCollection);
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
-26
View File
@@ -311,29 +311,3 @@ test('Needs review is a native SAVED VIEWS route titled Review, with its own cou
assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']);
assert.deepEqual([...counts], [['review', 4]]);
});
// Collections (#331 D137): `#/collection/<id>` opens Images filtered to one collection; its breadcrumb is "Collection".
test('collection links resolve to their id, title Collection, and re-render only when the id changes', async () => {
const {resolveRoute,mountRouter,isHandoff,routeTitle,routes,COLLECTION_ROUTE}=await source;
assert.deepEqual(COLLECTION_ROUTE,{id:'collection',label:'Collection'});
assert.ok(Object.isFrozen(COLLECTION_ROUTE));
const route=resolveRoute('#/collection/7');
assert.deepEqual([route.id,route.collectionId,route.hash,routeTitle(route)],['collection',7,'#/collection/7','Collection']);
assert.equal(isHandoff(route),false);
assert.equal(resolveRoute('#/collection/999999999').collectionId,999999999);
assert.equal(routes.some(item=>item.id==='collection'),false,'a collection is not an Overview card or a WORKSPACE link');
// The listing's and routes' id rule: 1 to 9 digits without a leading zero.
for(const hash of ['#/collection','#/collection/','#/collection/0','#/collection/07','#/collection/-2','#/collection/1.5',
'#/collection/1234567890','#/collection/3/x','#/collection/abc','#/collections/3']) {
assert.equal(resolveRoute(hash).id,'images',hash);
}
const win=new EventTarget();win.location={hash:'#/collection/3'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.collectionId]));
win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['collection',3],['collection',4],['saved',undefined],['collection',4]]);
dispose();
});
-65
View File
@@ -192,71 +192,6 @@ test('views reload once after a restore this page saw finish; a failed restore c
assert.equal(listeners.size, 0);
});
// #415: a fake document whose open dialogs the test sets, and a MutationObserver stand-in it triggers.
function dialogPage() {
const page = { open: 0, observers: new Set() };
page.doc = { documentElement: {}, querySelector: selector => (selector === 'dialog[open]' && page.open > 0 ? {} : null) };
page.Observer = class {
constructor(callback) { this.callback = callback; }
observe(target, options) { this.target = target; this.options = options; page.observers.add(this); }
disconnect() { page.observers.delete(this); }
};
page.mutate = open => { page.open = open; for (const observer of [...page.observers]) observer.callback([]); };
return page;
}
test('the restore reload runs at once when no dialog is open', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'nothing waits');
reload.request();
assert.equal(reloads, 2);
});
test('the restore reload waits for every open dialog to close, then runs once (#415)', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.request();
assert.equal(reloads, 0, 'an open dialog holds the reload');
assert.equal(page.observers.size, 1, 'one observer for repeated requests');
const [observer] = page.observers;
assert.equal(observer.target, page.doc.documentElement);
assert.deepEqual(observer.options, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
page.mutate(1);
assert.equal(reloads, 0, 'a dialog swapped for another keeps it waiting');
page.mutate(0);
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'the observer stops once it ran');
page.mutate(0);
assert.equal(reloads, 1);
page.open = 1;
reload.request();
page.mutate(0);
assert.equal(reloads, 2, 'a later restore waits again');
});
test('disposing drops a waiting restore reload', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.dispose();
assert.equal(page.observers.size, 0);
page.mutate(0);
assert.equal(reloads, 0);
reload.dispose();
});
test('a downloaded backup the server could not verify says so', async () => {
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
const toasts = [];
-28
View File
@@ -908,34 +908,6 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
}
});
// #415: a finished restore ends an open review (its reviewed plan no longer describes storage) as a dismissal, and
// leaves any other dialog, with what was typed in it, open.
test('closeReviewDialog closes only an open review, as dismissed', async () => {
const { openReviewDialog, openInputDialog, closeReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
closeReviewDialog();
const outcomes = [];
openInputDialog({ scope, title: 'Save view', label: 'Name', value: 'typed', action: 'Save', onApply() {} });
closeReviewDialog();
assert.equal(modal(dom).open, true, 'an input dialog stays');
assert.equal(byId(dom, 'action-input').value, 'typed');
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], cancelLabel: 'Cancel',
primary: { label: 'Restore', danger: true }, onStart: () => outcomes.push('start'), onDismiss: () => outcomes.push('dismiss') });
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
assert.equal(modal(dom), undefined, 'the shell is gone');
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;
+9 -15
View File
@@ -2205,30 +2205,24 @@ class TestOutputScanInternals(AdminAPITestCase):
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
def test_ingest_link_failures_are_tolerated(self):
"""A failed link rolls the new prompt back: found, nothing left behind."""
f = Path("gen.png")
meta = {"prompt": json.dumps(_wired_prompt("a dog"))}
self.api.db._insert_image_link = _raise
with self.assertLogs("prompt_manager.database.scanned_prompts", "ERROR"):
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
self.api.db.link_image_to_prompt = _raise
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
def test_ingest_save_failure_counts_as_found(self):
f = Path("gen.png")
meta = {"prompt": json.dumps(_wired_prompt("a bird"))}
with patch(
"py.api.admin.store_scanned_prompt", return_value=("found", None)
) as store:
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
self.assertEqual(store.call_args.args[3], "a bird")
self.api.db.save_prompt = lambda *a, **k: None
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
def test_batch_counts_and_per_file_errors(self):
meta = {"prompt": json.dumps(_wired_prompt("a fish"))}
with patch("py.api.admin.store_scanned_prompt", side_effect=_raise):
counts = self.api._ingest_scan_batch_sync(
[(Path("a.png"), meta), (Path("b.png"), {})]
)
self.api.db.get_prompt_by_hash = _raise
counts = self.api._ingest_scan_batch_sync(
[(Path("a.png"), meta), (Path("b.png"), {})]
)
self.assertEqual(
counts,
{
-705
View File
@@ -1,705 +0,0 @@
"""The per-file prompt decision shared by the scan and the monitor (#247).
One test per case, named by the ladder step of the design
(``docs/egm/247-foreign-workflows``). The ladder does no file or database
I/O: history comes in through ``lookup``.
"""
import json
import unittest
from unittest import mock
from database.run_attribution import RecordedOutput
from tests.test_pair_scan import flux_graph, sampled_graph
from tests.test_prompt_attribution import issue_graph, pair_evidence
from tests import test_save_to_library as save_tests
from utils import file_prompt
from utils.file_prompt import Policy, decide_file_prompt, junk_text
from utils.prompt_inspector import InspectionResult
try:
from tests import inspection_parity as parity
from tests import saver_anchor_graphs as shapes
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import inspection_parity as parity
import saver_anchor_graphs as shapes
SHARED_NAME = "IMG_GEN_00371_.png"
LORA = "<lora:style:1.00>"
def _decide(
metadata,
*,
policy=Policy.SCAN,
lookup=None,
in_output=True,
file_name=SHARED_NAME,
file_size=None,
):
return decide_file_prompt(
metadata,
file_name=file_name,
file_size=file_size,
in_output=in_output,
lookup=lookup,
policy=policy,
)
def _graph(graph):
return {"prompt": json.dumps(graph)}
class _Lookup:
"""A history lookup that counts its calls."""
def __init__(self, saver=None):
self.calls = 0
self.found = RecordedOutput(saver, {}) if saver is not None else None
def __call__(self):
self.calls += 1
return self.found
def _inspected(state, malformed=False):
resolution = {"state": state, "positive": None, "negative": None}
return InspectionResult(resolution, malformed=malformed)
def _untraced_by_both():
"""A sampled graph whose positive no profile traces; the legacy parser does."""
return mock.patch.object(
file_prompt, "inspection_resolution", return_value=_inspected("unresolved")
)
def _gated(graph):
"""``graph`` past the default profile's node gate, so it is unresolved."""
graph = dict(graph)
for index in range(510):
graph[f"pad{index}"] = {"class_type": "Note", "inputs": {}}
return graph
def _prompt_manager_branches(prepend="PRE", append="APP"):
"""Two KSampler branches, each fed by a PromptManagerText string."""
graph = sampled_graph()
del graph["1"]
sampler = graph.pop("3")
for index, text in enumerate(("a cat", "a dog"), start=1):
base = index * 10
graph[str(base)] = {
"class_type": "PromptManagerText",
"inputs": {"text": text, "prepend_text": prepend, "append_text": append},
}
graph[str(base + 1)] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": [str(base), 0]},
}
graph[str(base + 2)] = {
**sampler,
"inputs": {**sampler["inputs"], "positive": [str(base + 1), 0]},
}
return graph
def _numeric_first_branches():
"""Three branches; the legacy parser's answer (the first) is a number."""
graph = sampled_graph("2048")
for index, text in ((5, "a cat"), (6, "a dog")):
graph[str(index * 10)] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": text},
}
graph[str(index * 10 + 1)] = {
**graph["3"],
"inputs": {**graph["3"]["inputs"], "positive": [str(index * 10), 0]},
}
return graph
class TestStepsBeforeResolving(unittest.TestCase):
def test_step_1_no_metadata_tries_the_recorded_run(self):
for metadata in ({}, None):
decision = _decide(metadata)
self.assertEqual(decision.kind, "never")
self.assertTrue(decision.try_recorded_run)
self.assertIsNone(decision.unlinked_status)
self.assertEqual(decision.canonical, {})
def test_step_3_neither_prompt_nor_parameters_tries_the_recorded_run(self):
decision = _decide({"workflow": json.dumps({"nodes": []})})
self.assertEqual(decision.kind, "never")
self.assertTrue(decision.try_recorded_run)
self.assertIsNone(decision.unlinked_status)
self.assertEqual(decision.canonical, {"workflow": {"nodes": []}})
def test_strings_are_parsed_and_malformed_ones_kept(self):
decision = _decide({"prompt": "{not json", "workflow": "{}"})
self.assertEqual(decision.canonical["prompt"], "{not json")
self.assertEqual(decision.canonical["workflow"], {})
def test_the_file_size_is_recorded_unless_the_metadata_has_one(self):
meta = _graph(sampled_graph())
self.assertEqual(
_decide(meta, file_size=42).canonical["file_info"], {"size": 42}
)
meta["file_info"] = {"size": 7, "width": 3}
self.assertEqual(
_decide(meta, file_size=42).canonical["file_info"], {"size": 7, "width": 3}
)
self.assertNotIn("file_info", _decide(_graph(sampled_graph())).canonical)
def test_a_file_size_reader_is_called_only_for_prompt_metadata(self):
reads = []
def size():
reads.append(1)
return 9
for metadata in ({}, {"workflow": "{}"}):
_decide(metadata, file_size=size)
self.assertEqual(reads, [])
decision = _decide(_graph(sampled_graph()), file_size=size)
self.assertEqual((reads, decision.canonical["file_info"]), ([1], {"size": 9}))
def _off_behind(middle, key):
"""A switched-off PromptManagerText reaching the encoder through
``middle``'s ``key`` input."""
graph = save_tests.run_graph()
graph["1"] = {
"class_type": "PromptManagerText",
"inputs": {"text": "SECRET OFF TEXT", "save_to_library": False},
}
graph["7"] = {"class_type": middle, "inputs": {key: ["1", 0]}}
graph["8"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
}
graph["3"]["inputs"]["positive"] = ["8", 0]
return graph
class TestSaveToLibrarySteps(unittest.TestCase):
"""#90: a PromptManager node with Save to library off stores nothing."""
def test_step_2_an_opted_out_file_is_not_saved(self):
lookup = _Lookup("9")
graph = save_tests.run_graph(save=False)
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
graph["5"]["inputs"]["positive"] = ["2", 0] # ambiguous otherwise
for policy in Policy:
decision = _decide(_graph(graph), policy=policy, lookup=lookup)
self.assertEqual(decision.kind, "not_saved")
self.assertFalse(decision.try_recorded_run)
self.assertIsNone(decision.review)
self.assertEqual(lookup.calls, 0)
def test_step_2_a_wired_switch_is_not_saved_wired(self):
graph = save_tests.run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
lookup = _Lookup("9")
decision = _decide(_graph(graph), lookup=lookup)
self.assertEqual(decision.kind, "not_saved_wired")
self.assertFalse(decision.try_recorded_run)
self.assertIsNone(decision.review)
self.assertEqual(lookup.calls, 0)
def test_step_8_1_an_off_node_behind_a_pass_through_is_not_saved(self):
"""The #90 graph walk does not follow ``anything`` or ``value``; the
inspector does, so its positive is checked against the switch."""
for middle, key in (("easy showAnything", "anything"), ("MyNode", "value")):
with self.subTest(middle=middle):
graph = _off_behind(middle, key)
for policy in Policy:
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
self.assertEqual(decision.kind, "not_saved")
self.assertIsNone(decision.text)
def test_step_8_4_a_legacy_answer_from_an_off_node_is_not_saved(self):
graph = _off_behind("MyNode", "value")
graph["9"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "on text"}}
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
graph["5"]["inputs"]["positive"] = ["9", 0]
decision = _decide(_graph(graph), lookup=_Lookup())
self.assertEqual(decision.kind, "not_saved")
self.assertIsNone(decision.text)
def test_step_2_an_off_node_on_any_positive_input_is_not_saved(self):
"""Any input name upstream of a positive, not only prompt-like ones."""
guider = _off_behind("MyNode", "value")
guider["3"] = {
"class_type": "BasicGuider",
"inputs": {"model": ["4", 0], "conditioning": ["8", 0]},
}
prepended = _off_behind("MyNode", "value")
prepended["1"]["inputs"]["prepend_text"] = "pre"
pair = _off_behind("MyNode", "value")
pair["1"] = {
"class_type": "PromptManagerPair",
"inputs": {"text": "x", "negative_text": "y", "save_to_library": False},
}
pair["2"]["inputs"]["text"] = ["1", 1]
cases = {"sampler-less guider": guider, "prepend": prepended, "pair": pair}
for name, graph in cases.items():
with self.subTest(name=name):
lookup = _Lookup("9")
decision = _decide(_graph(graph), lookup=lookup)
self.assertEqual(decision.kind, "not_saved")
self.assertFalse(decision.try_recorded_run)
self.assertEqual(lookup.calls, 0)
def test_step_2_a_wired_switch_behind_a_pass_through_is_not_saved_wired(self):
graph = _off_behind("MyNode", "value")
graph["1"]["inputs"]["save_to_library"] = ["20", 0]
graph["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
self.assertEqual(_decide(_graph(graph)).kind, "not_saved_wired")
def test_a_switched_on_node_behind_a_pass_through_is_stored(self):
"""Regression guard: on, or no switch at all (older files)."""
for value in (True, None):
with self.subTest(value=value):
graph = _off_behind("MyNode", "value")
if value is None:
del graph["1"]["inputs"]["save_to_library"]
else:
graph["1"]["inputs"]["save_to_library"] = value
decision = _decide(_graph(graph), lookup=_Lookup())
self.assertEqual(decision.kind, "positive")
self.assertEqual(decision.text, "SECRET OFF TEXT")
def test_an_off_node_on_a_negative_input_name_never_blocks(self):
"""The walk skips negative inputs: #90's own rule (``neg``,
``neg_text``, ``text_neg``) and any name containing "negative"."""
for key in (
"neg",
"neg_text",
"text_neg",
"negative_text",
"negative2",
"negativeprompt",
"NegativePrompt",
):
with self.subTest(key=key):
graph = {
"1": {
"class_type": "PromptManagerText",
"inputs": {"text": "bad hands", "save_to_library": False},
},
"9": {
"class_type": "PromptManagerText",
"inputs": {"text": "a cat", "save_to_library": True},
},
"7": {
"class_type": "PipeEncode",
"inputs": {"pos_text": ["9", 0], key: ["1", 0]},
},
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["7", 0],
"negative": ["7", 1],
},
},
}
self.assertIsNone(file_prompt._switched_off_upstream(graph))
self.assertNotIn(
_decide(_graph(graph)).kind, ("not_saved", "not_saved_wired")
)
def test_the_guards_accept_malformed_nodes(self):
graph = {
"1": {"class_type": "PromptManagerText", "inputs": None},
"2": {"class_type": "PromptManagerText"},
}
self.assertIsNone(file_prompt._off_text(graph, "x"))
side = {"provenance": ["not", "a", "dict"]}
with mock.patch.object(file_prompt, "side_opted_out", return_value=True):
self.assertIsNone(file_prompt._side_switch(graph, side))
def test_the_off_text_guard_reads_a_nodes_resolved_text(self):
graph = {
"1": {
"class_type": "PromptManagerText",
"inputs": {"text": ["30", 0], "save_to_library": False},
},
"30": {
"class_type": "PrimitiveStringMultiline",
"inputs": {"value": "SECRET OFF TEXT"},
},
"2": {
"class_type": "PromptManagerText",
"inputs": {"text": "WIRED", "save_to_library": ["9", 0]},
},
"9": {"class_type": "SomeBoolNode", "inputs": {}},
}
self.assertEqual(file_prompt._off_text(graph, " SECRET OFF TEXT"), "not_saved")
self.assertEqual(file_prompt._off_text(graph, "WIRED"), "not_saved_wired")
self.assertIsNone(file_prompt._off_text(graph, "other"))
def test_steps_5_and_7_check_the_resolved_positive_too(self):
"""A resolved positive through an off node the #90 walk missed."""
cases = {
"pair": save_tests.run_graph(save=False),
"negative off": save_tests.run_graph(save=False, negative_save=False),
}
for name, graph in cases.items():
with (
self.subTest(name=name),
mock.patch.object(
file_prompt, "metadata_opted_out", return_value=False
),
):
self.assertEqual(_decide(_graph(graph)).kind, "not_saved")
def test_step_5_a_junk_positive_is_never_stored(self):
"""Pinned: the positive alone still passes the junk gate, and a blank
positive (never storable) stores nothing either."""
graph = save_tests.run_graph(save=True, negative_save=False)
graph["1"]["inputs"]["text"] = "2048"
decision = _decide(_graph(graph))
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
self.assertEqual(decision.unlinked_status, "found")
graph["1"]["inputs"]["text"] = ""
decision = _decide(_graph(graph))
self.assertEqual((decision.kind, decision.text), ("never", None))
def test_step_2_a_workflow_only_opt_out_comes_before_step_3(self):
workflow = {
"nodes": [{"id": 1, "type": "PromptManager", "widgets_values": [False]}]
}
decision = _decide({"workflow": json.dumps(workflow)})
self.assertEqual(decision.kind, "not_saved")
def test_step_5_an_opted_out_negative_stores_the_positive_alone(self):
graph = save_tests.run_graph(save=True, negative_save=False)
decision = _decide(_graph(graph))
self.assertEqual(decision.kind, "positive")
self.assertEqual(decision.source, "opted-out-negative")
self.assertEqual(decision.text, save_tests.POSITIVE)
class TestResolvedSteps(unittest.TestCase):
def test_step_6_a_blank_positive_counts_as_unresolved(self):
meta = {
"prompt": json.dumps(issue_graph()),
"comfyassets_prompt_pairs": json.dumps(pair_evidence()),
}
decision = _decide(meta, policy=Policy.MONITOR, in_output=False)
self.assertEqual(decision.kind, "never")
self.assertEqual(decision.resolution["state"], "unresolved")
def test_step_7_a_resolved_pair(self):
lookup = _Lookup("9")
decision = _decide(_graph(sampled_graph("cat", "blur")), lookup=lookup)
self.assertEqual(decision.kind, "pair")
self.assertEqual(decision.source, "resolver")
self.assertEqual(decision.text, "cat")
self.assertEqual(decision.resolution["negative"]["text"], "blur")
self.assertIsNone(decision.review)
self.assertEqual(lookup.calls, 0, "decided before step 8: history not asked")
def test_step_7_a_blank_negative_stays_a_pair(self):
decision = _decide(_graph(sampled_graph("cat", "")))
self.assertEqual(decision.kind, "pair")
self.assertEqual(decision.resolution["negative"]["text"], "")
class TestUndecidedGraph(unittest.TestCase):
def test_step_8_1_the_inspector_alone_decides(self):
decision = _decide({"prompt": parity.build("f03")["prompt"]})
self.assertEqual(decision.kind, "positive")
self.assertEqual(decision.source, "inspector")
self.assertEqual(decision.text, parity.POS)
self.assertEqual(decision.resolution["state"], "unresolved")
def test_step_8_1_the_recorded_saver_names_its_branch(self):
lookup = _Lookup("23")
decision = _decide(_graph(shapes.shared_prefix()), lookup=lookup)
self.assertEqual(decision.kind, "positive")
self.assertEqual(decision.source, "recorded-run")
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
self.assertEqual(decision.review, "ambiguous")
self.assertEqual(lookup.calls, 1)
def test_step_8_1_stores_the_storage_text_without_prepend_or_append(self):
graph = shapes.shared_prefix()
graph["20"]["inputs"]["text"] = ["25", 1]
graph["25"] = {
"class_type": "PromptManagerSplit",
"inputs": {
"text": shapes.BRANCH_TEXTS[1],
"prepend_text": "trigger",
"append_text": "detail",
},
}
decision = _decide(_graph(graph), lookup=_Lookup("23"))
self.assertEqual(decision.source, "recorded-run")
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
def test_step_8_1_a_file_name_naming_another_saver_anchors_nothing(self):
graph = shapes.branch_bench()
for index in (1, 3, 4):
graph[str(index * 10 + 3)]["inputs"]["filename_prefix"] = "bench/shared"
decision = _decide(
_graph(graph), lookup=_Lookup("43"), file_name="s45_branch_2_00001_.png"
)
# The name contradicts history's branch 4: no recorded anchor.
self.assertEqual(decision.source, "legacy-parser")
def test_step_8_1_the_file_name_anchor_alone_never_decides(self):
"""#390: the file-name anchor never decides a link. The scan's legacy
floor may still link the file (as before #247), and a file nothing
links is queued with the anchor's choice per #390; the monitor
stores nothing."""
name = "s45_branch_2_00001_.png"
scan = _decide(_graph(shapes.branch_bench()), lookup=_Lookup(), file_name=name)
self.assertEqual((scan.kind, scan.source), ("positive", "legacy-parser"))
self.assertEqual(scan.review, "ambiguous")
monitor = _decide(
_graph(shapes.branch_bench()),
policy=Policy.MONITOR,
lookup=_Lookup(),
file_name=name,
)
self.assertEqual(monitor.kind, "never")
recorded = _decide(
_graph(shapes.branch_bench()), lookup=_Lookup("43"), file_name=name
)
# The recorded saver still decides: history says branch 4, but the
# file name names branch 2, which contradicts it.
self.assertNotEqual(recorded.source, "recorded-run")
recorded = _decide(_graph(shapes.branch_bench()), lookup=_Lookup("43"))
self.assertEqual(recorded.source, "recorded-run")
self.assertEqual(recorded.text, shapes.BRANCH_TEXTS[3])
def test_step_8_2_the_monitor_waits_for_history_inside_the_output_folder(self):
decision = _decide(_graph(shapes.shared_prefix()), policy=Policy.MONITOR)
self.assertEqual(decision.kind, "history")
def test_step_8_2_outside_the_output_folder_steps_8_3_and_8_4_apply(self):
decision = _decide(
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, in_output=False
)
self.assertEqual(decision.kind, "never")
with _untraced_by_both():
decision = _decide(
_graph(_gated(sampled_graph())), policy=Policy.MONITOR, in_output=False
)
self.assertEqual(
(decision.kind, decision.source), ("positive", "legacy-parser")
)
def test_step_8_3_an_ambiguous_file_is_left_unlinked_by_the_monitor(self):
lookup = _Lookup("missing")
decision = _decide(
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, lookup=lookup
)
self.assertEqual(decision.kind, "never")
self.assertEqual(decision.unlinked_status, "ambiguous")
self.assertFalse(decision.try_recorded_run)
self.assertEqual(lookup.calls, 1)
def test_step_8_4_joined_prompt_manager_text_is_never_the_legacy_answer(self):
"""The legacy parser returns prepend + text + append; the stored text
of a PromptManager node cannot be told from it."""
decision = _decide(_graph(_prompt_manager_branches()), lookup=_Lookup())
self.assertEqual(decision.kind, "never")
self.assertEqual(decision.unlinked_status, "ambiguous")
self.assertEqual(decision.review, "ambiguous")
self.assertTrue(decision.try_recorded_run)
# A linked part the parser does not follow: its answer is the node's
# own text, which is what the node stores (#170 parity).
linked = _prompt_manager_branches(prepend=["99", 0], append="")
decision = _decide(_graph(linked), lookup=_Lookup())
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
def test_step_8_4_prompt_manager_text_without_parts_keeps_the_floor(self):
"""Regression guard (passes on the base): no prepend or append."""
graph = _prompt_manager_branches(prepend="", append=" ")
decision = _decide(_graph(graph), lookup=_Lookup())
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
def test_step_8_4_an_ambiguous_file_gets_the_legacy_answer_in_the_scan(self):
decision = _decide(_graph(shapes.shared_prefix()), lookup=_Lookup())
self.assertEqual(decision.kind, "positive")
self.assertEqual(decision.source, "legacy-parser")
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[0])
self.assertEqual(decision.review, "ambiguous")
def test_step_8_4_an_untraceable_file_gets_the_legacy_answer_in_both(self):
for policy in Policy:
for inspected in (_inspected("unresolved"), _inspected("x", True)):
with (
self.subTest(policy=policy, malformed=inspected.malformed),
mock.patch.object(
file_prompt, "inspection_resolution", return_value=inspected
),
):
decision = _decide(
_graph(_gated(sampled_graph("cat"))),
policy=policy,
lookup=_Lookup(),
)
self.assertEqual(decision.kind, "positive")
self.assertEqual(decision.source, "legacy-parser")
self.assertEqual(decision.text, "cat")
def test_step_8_4_nothing_traced_tries_the_recorded_run(self):
graph = sampled_graph()
graph["5"] = {**graph["3"]}
for sampler in ("3", "5"):
graph[sampler]["inputs"]["positive"] = ["9", 0]
graph["9"] = {"class_type": "BasicGuider", "inputs": {"width": 2048}}
decision = _decide(_graph(graph), lookup=_Lookup())
self.assertEqual(decision.kind, "never")
self.assertTrue(decision.try_recorded_run)
# Ambiguous without a choice is reported unresolved.
self.assertEqual(decision.unlinked_status, "unresolved")
self.assertEqual(decision.review, "untraced")
class TestStepsWithoutAnUndecidedGraph(unittest.TestCase):
def test_step_9_ambiguous_parameters_only(self):
meta = {
"parameters": (
"cat\nNegative prompt: blur\nNegative prompt: noise"
"\nSteps: 20, Sampler: Euler"
)
}
decision = _decide(meta)
self.assertEqual(decision.kind, "never")
self.assertFalse(decision.try_recorded_run)
self.assertEqual(decision.unlinked_status, "unresolved")
def test_step_10_unresolved_without_a1111_text(self):
decision = _decide({"prompt": {}, "parameters": 5})
self.assertEqual(decision.kind, "never")
self.assertFalse(decision.try_recorded_run)
self.assertEqual(decision.unlinked_status, "unresolved")
def test_step_11_sampler_less_with_one_text(self):
decision = _decide(_graph(flux_graph()))
self.assertEqual((decision.kind, decision.source), ("positive", "sampler-less"))
self.assertEqual(decision.text, "a red fox")
def test_step_11_sampler_less_with_several_texts(self):
graph = flux_graph()
graph["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "a lora note"},
}
graph["22"]["inputs"]["model"] = ["50", 0]
graph["6"]["inputs"]["clip"] = ["50", 1]
decision = _decide(_graph(graph))
self.assertEqual(decision.kind, "never")
self.assertTrue(decision.try_recorded_run)
self.assertEqual(decision.unlinked_status, "unresolved")
meta = {**_graph(graph), "parameters": "a red fox\nNegative prompt: x"}
decision = _decide(meta)
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
self.assertEqual(decision.text, "a red fox")
def test_step_11_sampler_less_without_a_text(self):
graph = {"40": {"class_type": "easy showAnything", "inputs": {"text": "x"}}}
decision = _decide(_graph(graph))
self.assertEqual(decision.kind, "never")
self.assertTrue(decision.try_recorded_run)
self.assertEqual(decision.unlinked_status, "found")
def test_step_12_a1111_only(self):
decision = _decide({"parameters": "a cat"})
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
self.assertEqual(decision.text, "a cat")
decision = _decide({"parameters": "Negative prompt: blur"})
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
self.assertFalse(decision.try_recorded_run)
def test_step_12_parsed_metadata_never_raises(self):
# The monitor hands parsed values: a dict prompt, non-string parameters.
decision = _decide({"prompt": {}, "parameters": "a cat"})
self.assertEqual(decision.text, "a cat")
decision = _decide({"parameters": 5})
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
class TestJunkGate(unittest.TestCase):
JUNK = {
"blank": " ",
"number": "2048",
"lora-only": f"{LORA}, <lora:detail:0.5> |",
"model-file": "models/checkpoints/Flux-Dev.SafeTensors",
}
def test_each_junk_class_is_named(self):
for name, text in self.JUNK.items():
with self.subTest(name=name):
self.assertEqual(junk_text(text), name)
for ending in ("ckpt", "pt", "pth", "bin", "gguf", "sft"):
self.assertEqual(junk_text(f"model.{ending}"), "model-file")
def test_near_misses_are_not_junk(self):
for text in (
f"a red fox {LORA}",
"a.safetensors b.safetensors",
"nan",
"a portrait saved as final.safetensors",
"4k",
"C:\\My Models\\x.safetensors",
):
with self.subTest(text=text):
self.assertIsNone(junk_text(text))
def test_step_13_junk_from_any_step_is_never_stored(self):
cases = {
"resolver": _graph(sampled_graph("2048", "blur")),
"sampler-less": _graph(flux_graph(text=LORA)),
"a1111": {"parameters": "model.safetensors"},
}
for step, meta in cases.items():
with self.subTest(step=step):
decision = _decide(meta)
self.assertEqual(decision.kind, "never")
self.assertTrue(decision.source.startswith("junk:"), decision.source)
self.assertTrue(decision.try_recorded_run)
self.assertEqual(decision.unlinked_status, "found")
def test_step_13_a_junk_legacy_answer_is_never_stored(self):
with _untraced_by_both():
decision = _decide(_graph(_gated(sampled_graph(LORA))), lookup=_Lookup())
self.assertEqual(decision.source, "junk:lora-only")
self.assertEqual(decision.kind, "never")
self.assertEqual(decision.unlinked_status, "unresolved")
def test_step_13_junk_at_step_8_keeps_that_steps_status_and_review(self):
# An ambiguous file whose legacy answer is a number is still a
# Needs review case: its status stays "ambiguous".
decision = _decide(_graph(_numeric_first_branches()), lookup=_Lookup())
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
self.assertEqual(decision.unlinked_status, "ambiguous")
self.assertEqual(decision.review, "ambiguous")
self.assertTrue(decision.try_recorded_run)
class TestParseComfyuiPrompt(unittest.TestCase):
def test_a_parsed_prompt_and_non_string_values_are_kept(self):
parsed = file_prompt.parse_comfyui_prompt(
{"prompt": {"1": {}}, "workflow": {"nodes": []}, "steps": 20}
)
self.assertEqual(parsed["prompt"], {"1": {}})
self.assertEqual(parsed["workflow"], {"nodes": []})
self.assertEqual(parsed["parameters"]["steps"], 20)
self.assertIsNone(parsed["positive_prompt"])
def test_non_string_parameters_are_ignored(self):
parsed = file_prompt.parse_comfyui_prompt({"parameters": {"a": 1}})
self.assertIsNone(parsed["positive_prompt"])
if __name__ == "__main__":
unittest.main()
+14 -18
View File
@@ -1,11 +1,9 @@
"""Ingestion decides with the default resolver profile first (D1, #247 D-Q1).
"""Ingestion keeps the default resolver profile; only inspection routes moved (D1).
The live monitor and the admin scan write pairs and links to the user's
database, so the default profile decides every file it can, byte-for-byte as
before. f03 (506 executable nodes) separates the profiles: the default
profile's 500-node gate leaves it unresolved, while the inspection profile
resolves it. Only such an undecided file reaches the inspector's resolve
(``inspection_resolution``), and only its positive is stored (#247).
database, so their resolver semantics stay byte-for-byte today's. f03 (506
executable nodes) separates the profiles: the default profile's 500-node gate
leaves it unresolved, while the inspection profile resolves it.
"""
import logging
@@ -70,26 +68,24 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
spy.assert_default_and_gated(self)
handler.db_manager.link_image_to_prompt_or_raise.assert_called_once()
def test_admin_scan_ingestion_uses_the_default_profile_first(self):
def test_admin_scan_ingestion_uses_the_default_profile(self):
from py.api import admin
from utils import file_prompt
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
mixin.db = Mock()
mixin.db.get_prompt_by_hash.return_value = None
mixin.db.save_prompt.return_value = 7
spy = _Spy()
with (
patch.object(file_prompt, "resolve_prompt_pair", spy),
patch.object(
admin, "store_scanned_prompt", return_value=("added", 7)
) as store,
):
with patch.object(admin, "resolve_prompt_pair", spy):
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
spy.assert_default_and_gated(self)
# The default profile leaves f03 unresolved; the inspector's resolve
# names the prompt the inspector shows, and the scan stores it.
# The resolver leaves f03 unresolved; the scan links the prompt the
# browser parser traces, as the inspector shows it.
self.assertEqual(status, "added")
self.assertEqual(store.call_args.args[3], "a red fox in snow, detailed fur")
mixin.db.link_image_to_prompt.assert_not_called()
self.assertEqual(
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
)
mixin.db.link_image_to_prompt.assert_called_once()
if __name__ == "__main__":
-159
View File
@@ -1,12 +1,9 @@
"""Metadata scanning enriches known pairs and conservatively leaves conflicts."""
import json
import sqlite3
from pathlib import Path
from unittest.mock import patch
from tests.test_api_admin import AdminAPITestCase
from utils.hashing import generate_prompt_hash
def graph(positive="cat", negative="blur"):
@@ -452,159 +449,3 @@ class TestSamplerLessScan(AdminAPITestCase):
path = self.output_dir / "saver.png"
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
self.assertEqual(self._texts(), ["a red fox"])
class TestScanDecision(AdminAPITestCase):
"""The scan applies the shared per-file decision (#247): one prompt per
file, no junk text, marks on every prompt it creates."""
def _path(self, name):
path = self.output_dir / name
path.write_bytes(b"png")
return path
def _links(self, path):
rows = self.api.db.model.get_connection().execute(
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
)
return [row[0] for row in rows]
def _prompt(self, text):
return self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
path = self._path("fox.png")
other = self.api.db.save_prompt(
"a dog", prompt_hash=generate_prompt_hash("a dog")
)
self.api.db.link_image_to_prompt(other, str(path))
meta = {"prompt": json.dumps(flux_graph())}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
self.assertEqual(self._links(path), [other])
self.assertIsNone(self._prompt("a red fox"))
def test_junk_text_is_never_stored(self):
cases = {
"lora.png": {"prompt": json.dumps(flux_graph(text="<lora:style:1.00>"))},
"model.png": {"parameters": "models/flux-dev.safetensors"},
}
for name, meta in cases.items():
with self.subTest(name=name):
path = self._path(name)
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
self.assertEqual(self._links(path), [])
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
def test_created_prompts_carry_the_scan_marks(self):
path = self._path("pair.png")
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
for text in ("cat", "blur"):
prompt = self._prompt(text)
self.assertEqual(prompt["category"], "scanned")
self.assertEqual(prompt["tags"], ["auto-scanned"])
self.assertEqual(prompt["notes"], "Auto-scanned from pair.png")
def test_outcome_of_a_positive_reports_its_title(self):
"""Regression guard (passes on the base): the detailed positive row."""
path = self._path("fox.png")
meta = {"prompt": json.dumps(flux_graph())}
first = self.api._ingest_scanned_file(path, meta, detailed=True)
self.assertEqual(first, {"status": "added", "title": "a red fox"})
again = self.api._ingest_scanned_file(path, meta, detailed=True)
self.assertEqual(again["status"], "unchanged")
other = self._path("fox2.png")
linked = self.api._ingest_scanned_file(other, meta, detailed=True)
self.assertEqual(linked["status"], "linked")
def test_outcome_of_a_pair_reports_its_counts(self):
"""Regression guard (passes on the base): the detailed pair row."""
path = self._path("pair.png")
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
result = self.api._ingest_scanned_file(path, meta, detailed=True)
self.assertEqual(result["status"], "added")
self.assertEqual((result["pairs_added"], result["links_added"]), (1, 1))
self.assertEqual(result["title"], "cat")
def test_outcome_without_a_store_reports_the_unlinked_status(self):
"""Regression guard (passes on the base): never-stored rows."""
path = self._path("notes.png")
meta = {"prompt": json.dumps({"40": {"class_type": "Textbox", "inputs": {}}})}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
self.assertIsNone(self.api._ingest_scanned_file(path, {}))
def test_review_lists_get_the_default_profiles_ambiguous_files(self):
"""Regression guard (passes on the base): Needs review inputs."""
g = sampled_graph()
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
ambiguous, untraced, records = [], [], {}
path = self._path("two.png")
status = self.api._ingest_scanned_file(
path,
{"prompt": json.dumps(g)},
ambiguous=ambiguous,
untraced=untraced,
records=records,
)
# Linked by the legacy floor, still listed (queued only if unlinked).
self.assertEqual(status, "added")
self.assertEqual((ambiguous, untraced), ([path], []))
self.assertEqual(records["canonical"]["prompt"], g)
def test_an_ambiguous_file_with_a_junk_answer_is_still_queued(self):
from tests.test_file_prompt import _numeric_first_branches
path = self._path("numbers.png")
meta = {"prompt": json.dumps(_numeric_first_branches())}
with patch.object(
type(self.api), "_record_scanned_review", autospec=True
) as record:
counts = self.api._ingest_scan_batch_sync([(path, meta)])
self.assertEqual(counts["ambiguous"], 1, counts)
record.assert_called_once()
self.assertEqual(self._links(path), [])
def test_a_conflict_row_says_the_link_was_kept(self):
from py.api.admin import _scan_outcome
_group, row, _line = _scan_outcome(self._path("c.png"), [], "conflict")
self.assertEqual(row["detail"], "Kept: already linked to a different prompt")
def test_a_library_move_reruns_the_scan_writes(self):
from py.api import admin
real = admin.ingest_resolved_pair
model = self.api.db.model
def moved_once(*args, **kwargs):
if not calls:
calls.append(1)
model._generation += 1
raise sqlite3.OperationalError("closed")
return real(*args, **kwargs)
calls = []
path = self._path("pair.png")
with patch.object(admin, "ingest_resolved_pair", side_effect=moved_once):
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
self.assertEqual(calls, [1])
def test_an_off_node_behind_a_pass_through_stores_nothing(self):
"""#90 regression guard for the scan (main stored nothing here)."""
from tests.test_save_to_library import run_graph
g = run_graph(save=False)
g["1"]["class_type"] = "PromptManagerText"
g["7"] = {"class_type": "easy showAnything", "inputs": {"anything": ["1", 0]}}
g["8"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
}
g["3"]["inputs"]["positive"] = ["8", 0]
path = self._path("off.png")
meta = {"prompt": json.dumps(g)}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "not_saved")
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
self.assertEqual(self._links(path), [])
-84
View File
@@ -375,89 +375,5 @@ class TestRecordedTexts(unittest.TestCase):
self.assertEqual(evidence["resolution"]["sampler_id"], sampler)
class TestInspectionResolution(unittest.TestCase):
"""``inspection_resolution``: the inspector's resolve and recorded anchor,
shared with ingestion (#247), so the two cannot drift."""
def _result(self, graph, saver=None, *, as_text=True, name="IMG_GEN_00371_.png"):
from utils.prompt_inspector import inspection_resolution
prompt = json.dumps(graph) if as_text else graph
return inspection_resolution({"prompt": prompt}, name, saver)
def test_the_recorded_saver_names_its_branch_by_record(self):
result = self._result(shapes.shared_prefix(), _saver(2))
self.assertTrue(result.by_record)
self.assertFalse(result.malformed)
self.assertEqual(result.resolution["state"], "resolved")
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
self.assertIs(result.anchored, result.resolution)
def test_without_a_saver_nothing_is_anchored(self):
result = self._result(shapes.shared_prefix())
self.assertEqual(result.resolution["state"], "ambiguous")
self.assertIsNone(result.anchored)
self.assertFalse(result.by_record)
def test_a_saver_that_names_nothing_keeps_the_open_result(self):
result = self._result(shapes.shared_prefix(), "missing")
self.assertEqual(result.resolution["state"], "ambiguous")
self.assertIsNotNone(result.anchored)
self.assertFalse(result.by_record)
def test_a_file_that_resolves_alone_is_never_anchored(self):
result = self._result(
shapes.branch_bench(), _saver(4), name="s45_branch_2_00001_.png"
)
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
self.assertIsNone(result.anchored)
self.assertFalse(result.by_record)
def test_parsed_metadata_resolves_as_the_file_text_does(self):
# The monitor hands parsed dicts; the scan hands the file's strings.
parsed = self._result(shapes.shared_prefix(), _saver(3), as_text=False)
text = self._result(shapes.shared_prefix(), _saver(3))
self.assertEqual(parsed.resolution, text.resolution)
def test_malformed_metadata_is_reported_as_malformed(self):
from utils.prompt_inspector import inspection_resolution
result = inspection_resolution({"prompt": "{not json"}, None, None)
self.assertTrue(result.malformed)
self.assertEqual(result.resolution["state"], "unresolved")
self.assertEqual(result.resolution["reason_code"], "malformed")
def test_metadata_without_a_role_record_is_absent(self):
from utils.prompt_inspector import inspection_resolution
result = inspection_resolution({"workflow": "{}"}, None, "9")
self.assertFalse(result.malformed)
self.assertEqual(result.resolution["state"], "absent")
def test_without_the_name_anchor_a_file_name_names_no_branch(self):
from utils.prompt_inspector import inspection_resolution
graph = {"prompt": json.dumps(shapes.branch_bench())}
name = "s45_branch_2_00001_.png"
named = inspection_resolution(graph, name, None)
self.assertEqual(_positive(named.resolution), shapes.BRANCH_TEXTS[1])
blind = inspection_resolution(graph, name, None, name_anchor=False)
self.assertEqual(blind.resolution["state"], "ambiguous")
# The recorded saver still decides, and the name still contradicts it.
recorded = inspection_resolution(graph, name, _saver(2), name_anchor=False)
self.assertTrue(recorded.by_record)
shared = {"prompt": json.dumps(shapes.shared_prefix())}
bench = shapes.branch_bench()
for index in (1, 3, 4):
bench[_saver(index)]["inputs"]["filename_prefix"] = "bench/shared"
contradicted = inspection_resolution(
{"prompt": json.dumps(bench)}, name, _saver(4), name_anchor=False
)
self.assertFalse(contradicted.by_record)
self.assertTrue(
inspection_resolution(shared, name, _saver(4), name_anchor=False).by_record
)
if __name__ == "__main__":
unittest.main()
+3 -155
View File
@@ -17,8 +17,6 @@ from database.run_attribution import (
from tests import test_execution_history_database as history_tests
from tests.background_stops import close_handler
from tests.test_api_admin import AdminAPITestCase
from tests import saver_anchor_graphs as shapes
from tests.test_pair_scan import sampled_graph
from tests.test_execution_context_database import known, snapshot
from tests.test_prompt_attribution import issue_graph, llm_text_graph, make_tracker
from utils.execution_contexts import (
@@ -328,7 +326,7 @@ class TestRecordedOutputNode(RecordedRuns, unittest.TestCase):
self.assertIsNone(recorded_output_lookup(broken)(self.image))
with (
mock.patch(
"database.run_attribution.recorded_runs",
"database.run_attribution._recorded_runs",
side_effect=sqlite3.OperationalError("locked"),
),
self.assertLogs("prompt_manager.run_attribution", "WARNING"),
@@ -453,86 +451,6 @@ class TestScanUsesHistory(RecordedRuns, AdminAPITestCase):
self.assertIsNone(self.linked_prompt(path))
self.assertEqual(db.list_prompt_pairs()["pairs"], [])
def scan_dataset(self, names, nodes):
"""Two branches of the shared-prefix dataset graph, one run, then a scan."""
paths = [self.write(name) for name in names]
self.record_run(
self.api.db,
str(self.output_dir),
[("", path.name) for path in paths],
[],
nodes=nodes,
)
meta = {"prompt": json.dumps(shapes.shared_prefix())}
return paths, self.api._ingest_scan_batch_sync([(p, meta) for p in paths])
def test_the_recorded_saver_links_each_dataset_file_to_its_branch(self):
"""#247: before, the legacy parser gave every branch the first prompt."""
paths, counts = self.scan_dataset(
["IMG_GEN_00001_.png", "IMG_GEN_00002_.png"], ["13", "23"]
)
self.assertEqual(counts["added"], 2, counts)
for path, text in zip(paths, shapes.BRANCH_TEXTS):
prompt = self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
self.assertEqual(self.linked_prompt(path), prompt["id"])
self.assertEqual(prompt["category"], "scanned")
def test_a_file_the_legacy_answer_linked_is_left_as_it_is(self):
"""#247 Q5: a rescan never adds a second link to the branch prompt."""
db = self.api.db
legacy = shapes.BRANCH_TEXTS[0]
legacy_id = db.save_prompt(legacy, prompt_hash=generate_prompt_hash(legacy))
path = self.write("IMG_GEN_00002_.png")
db.link_image_to_prompt(legacy_id, str(path))
self.record_run(db, str(self.output_dir), [("", path.name)], [], nodes=["23"])
meta = {"prompt": json.dumps(shapes.shared_prefix())}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
self.assertEqual(self.linked_prompt(path), legacy_id)
self.assertIsNone(
db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[1]))
)
def test_a_recorded_run_never_adds_a_second_link(self):
db = self.api.db
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
other = db.save_prompt(text="a dog", prompt_hash=generate_prompt_hash("a dog"))
path = self.write("bare_00003_.png")
db.link_image_to_prompt(other, str(path))
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
self.assertEqual(self.api._ingest_scanned_file(path, {}), "conflict")
rows = db.model.get_connection().execute(
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
)
self.assertEqual([row[0] for row in rows], [other])
def test_a_single_file_asks_history_once(self):
db = self.api.db
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
path = self.write("llm_00002_.png")
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
meta = {"prompt": json.dumps(llm_text_graph())}
statements = []
conn = db.model.get_connection()
conn.set_trace_callback(statements.append)
try:
self.assertEqual(self.api._ingest_scanned_file(path, meta), "linked")
finally:
conn.set_trace_callback(None)
lookups = [s for s in statements if "FROM execution_run_outputs" in s]
self.assertEqual(len(lookups), 1)
def test_a_file_the_recorded_saver_links_is_not_queued(self):
"""A default-ambiguous file linked at the inspector's step is no
Needs review case."""
with mock.patch.object(
type(self.api), "_record_scanned_review", autospec=True
) as record:
_paths, counts = self.scan_dataset(
["IMG_GEN_00003_.png", "IMG_GEN_00004_.png"], ["13", "23"]
)
self.assertEqual(counts["added"], 2, counts)
record.assert_not_called()
class TestHostHelpers(unittest.TestCase):
"""The host lookups degrade to "no proof", never to an error."""
@@ -834,17 +752,13 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
"""M5: a scan batch asks history once per chunk of names, not per file."""
def test_a_batch_its_metadata_links_never_asks_history(self):
"""R3: the batch lookup is made only when a file needs it.
Since #247 every file the default profile leaves undecided asks (the
recorded saver may name its branch), so these files resolve as pairs.
"""
"""R3: the batch lookup is made only when a file needs it."""
files = []
for n in range(3):
path = self.output_dir / f"typed_{n:05}_.png"
path.write_bytes(b"png")
files.append(path)
meta = {"prompt": json.dumps(sampled_graph("typed mountains"))}
meta = {"prompt": json.dumps(issue_graph(text="typed mountains"))}
statements = []
conn = self.api.db.model.get_connection()
conn.set_trace_callback(statements.append)
@@ -888,72 +802,6 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
}
self.assertEqual(linked, {f.name for f in files[:4]})
def test_the_batch_saver_equals_the_files_recorded_output(self):
from py.api.admin import _BatchHistory
db = self.api.db
names = ["one_node.png", "two_nodes.png", "two_runs.png", "unrecorded.png"]
files = []
for name in names:
path = self.output_dir / name
path.write_bytes(b"png")
files.append(path)
root = str(self.output_dir)
self.record_run(db, root, [("", "one_node.png")], [], nodes=["13"])
self.record_run(
db, root, [("", "two_nodes.png")] * 2, [], item=1, nodes=["13", "23"]
)
self.record_run(db, root, [("", "two_runs.png")], [], item=2)
self.record_run(db, root, [("", "two_runs.png")], [], item=3)
history = _BatchHistory(self.api, files)
with mock.patch(
"database.run_attribution.host_output_directory", return_value=root
):
for path in files:
with self.subTest(path=path.name):
expected = recorded_output(db, str(path), root)
found = history.recorded(str(path))
if expected is None:
self.assertIsNone(found)
else:
self.assertEqual(found.saver_id, expected.saver_id)
self.assertEqual(history.recorded(str(files[0])).saver_id, "13")
self.assertIsNone(history.recorded(str(files[1])).saver_id)
def test_a_failed_batch_lookup_is_logged_and_names_nothing(self):
from py.api.admin import _BatchHistory
path = self.output_dir / "x.png"
history = _BatchHistory(self.api, [path])
with (
mock.patch(
"py.api.admin.recorded_runs", side_effect=sqlite3.OperationalError("x")
),
self.assertLogs("prompt_manager", "WARNING"),
):
self.assertIsNone(history.recorded(str(path)))
self.assertIsNone(history.get(str(path)))
def test_a_failed_prompt_lookup_is_logged_and_names_nothing(self):
from py.api.admin import _BatchHistory
path = self.output_dir / "one.png"
path.write_bytes(b"png")
root = str(self.output_dir)
self.record_run(self.api.db, root, [("", path.name)], [LLM_TEXT])
history = _BatchHistory(self.api, [path])
with (
mock.patch(
"database.run_attribution.host_output_directory", return_value=root
),
mock.patch(
"py.api.admin.named_prompt", side_effect=sqlite3.OperationalError("x")
),
self.assertLogs("prompt_manager", "WARNING"),
):
self.assertIsNone(history.get(str(path)))
self.assertEqual(history.recorded(str(path)).saver_id, "9")
class TestLinkedOutputRoot(RecordedRuns, unittest.TestCase):
"""R2: ComfyUI's output folder reached through a symlink or junction."""
-55
View File
@@ -40,7 +40,6 @@ IMAGES_STATE = {
"media": "all",
"tags": ["fluffy"],
"favorites": False,
"collection": "",
}
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
IMAGES_DEFAULTS = {
@@ -52,7 +51,6 @@ IMAGES_DEFAULTS = {
"media": "all",
"tags": [],
"favorites": False,
"collection": "",
}
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
# A row saved by an older build: no favorites key, a retired key, a sort
@@ -232,48 +230,6 @@ class TestSavedViewsApi(ImageAPITestCase):
_, listed = await self.send("GET")
self.assertEqual(len(listed["views"]), 20)
async def test_write_requires_the_collection_key(self):
# #331 D140: a view saved on a collection page keeps the collection id.
without = {k: v for k, v in IMAGES_STATE.items() if k != "collection"}
good = {"page": "images", "name": "Grid", "state": IMAGES_STATE}
cases = [
{**good, "state": without},
{**good, "state": {**IMAGES_STATE, "collection": "0"}},
{**good, "state": {**IMAGES_STATE, "collection": "x"}},
{**good, "state": {**IMAGES_STATE, "collection": 12}},
{**good, "state": {**IMAGES_STATE, "collection": "012"}},
{**good, "state": {**IMAGES_STATE, "collection": "1" * 10}},
{**good, "state": {**IMAGES_STATE, "collection": None}},
# Favorites and a collection are never both a view's filter.
{
**good,
"state": {**IMAGES_STATE, "favorites": True, "collection": "3"},
},
]
for body in cases:
with self.subTest(body=body):
response, data = await self.send("POST", body=body)
self.assertEqual(response.status, 400, data)
self.assertFalse(data["success"])
for collection in ("1", "12", "9" * 9):
view = await self.create(state={"collection": collection})
self.assertEqual(view["state"]["collection"], collection)
self.assertIs(view["state"]["favorites"], False)
async def test_an_old_row_without_a_collection_reads_empty(self):
with self.api.db.model.get_connection() as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', 'Old', ?, 0, 'x', 'x')",
(json.dumps({"q": "cat", "favorites": True}),),
)
_, data = await self.send("GET")
(view,) = data["views"]
self.assertEqual(
view["state"], {**IMAGES_DEFAULTS, "q": "cat", "favorites": True}
)
self.assertEqual(view["state"]["collection"], "")
async def test_rename_and_delete_validate_id_and_body(self):
view = await self.create()
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
@@ -662,17 +618,6 @@ class TestSavedViewsSchema(unittest.TestCase):
),
{**IMAGES_DEFAULTS, "folder": "kept"},
)
# A stored row with both filters (never written by this build) opens the
# collection, as a listing URL with both does.
self.assertEqual(
normalize_state("images", {"favorites": True, "collection": "4"}),
{**IMAGES_DEFAULTS, "collection": "4"},
)
for bad in ("0", "x", 12, "012"):
with self.subTest(collection=bad):
self.assertEqual(
normalize_state("images", {"collection": bad}), IMAGES_DEFAULTS
)
self.assertEqual(normalize_state("history", {}), {})
def test_validation_errors_are_saved_view_invalid(self):
-274
View File
@@ -1,274 +0,0 @@
"""Storing a file's prompt from its text: one transaction, one prompt each (#247)."""
import os
import sqlite3
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest import mock
try:
from tests.open_handles import assert_closed
from tests import test_execution_history_database as history_tests
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed
import test_execution_history_database as history_tests
from database.operations import PromptDatabase
from database.pair_ingestion import (
RUN_MARKS,
SCAN_MARKS,
Marks,
ingest_resolved_pair,
)
from database.scanned_prompts import store_scanned_prompt, store_scanned_prompt_or_raise
from utils.hashing import generate_prompt_hash
META = {"prompt": {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}}}
def resolved(positive="cat", negative="blur"):
return {
"state": "resolved",
"positive": {"text": positive, "provenance": {"source": "api"}},
"negative": {"text": negative, "provenance": {"source": "api"}},
}
class StoreCase(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = PromptDatabase(str(Path(self.temp.name) / "test.db"))
self.path = str(Path(self.temp.name) / "gen.png")
def tearDown(self):
self.db.close_all()
assert_closed(self, self.temp.name)
self.temp.cleanup()
def prompts(self):
rows = self.db.model.get_connection().execute(
"SELECT id, text FROM prompts ORDER BY id"
)
return [tuple(row) for row in rows]
def links(self):
rows = self.db.model.get_connection().execute(
"SELECT prompt_id, image_path FROM generated_images ORDER BY id"
)
return [tuple(row) for row in rows]
def save(self, text, **fields):
return self.db.save_prompt(
text, prompt_hash=generate_prompt_hash(text), **fields
)
class TestStoreScannedPrompt(StoreCase):
def test_a_new_text_is_created_with_the_scan_marks_and_linked(self):
status, prompt_id = store_scanned_prompt_or_raise(
self.db, self.path, META, " a cat ", SCAN_MARKS
)
self.assertEqual(status, "added")
prompt = self.db.get_prompt_by_id(prompt_id)
self.assertEqual(prompt["text"], "a cat")
self.assertEqual(prompt["category"], "scanned")
self.assertEqual(prompt["tags"], ["auto-scanned"])
self.assertEqual(prompt["notes"], "Auto-scanned from gen.png")
self.assertEqual(prompt["run_count"], 0)
self.assertEqual(self.links(), [(prompt_id, self.path)])
def test_a_run_created_prompt_names_the_workflow_run(self):
_status, prompt_id = store_scanned_prompt_or_raise(
self.db, self.path, META, "a cat", RUN_MARKS
)
prompt = self.db.get_prompt_by_id(prompt_id)
self.assertEqual(prompt["notes"], "Saved from a workflow run: gen.png")
self.assertEqual(prompt["tags"], ["auto-scanned"])
def test_a_stored_text_is_reused_by_hash_and_keeps_its_fields(self):
existing = self.save("A Cat", category="mine", notes="kept")
status, prompt_id = store_scanned_prompt_or_raise(
self.db, self.path, META, "a cat ", SCAN_MARKS
)
self.assertEqual((status, prompt_id), ("linked", existing))
prompt = self.db.get_prompt_by_id(existing)
self.assertEqual((prompt["category"], prompt["notes"]), ("mine", "kept"))
self.assertEqual(prompt["tags"], [])
self.assertEqual(len(self.prompts()), 1)
def test_a_repeat_is_unchanged(self):
first = store_scanned_prompt_or_raise(
self.db, self.path, META, "a cat", SCAN_MARKS
)
again = store_scanned_prompt_or_raise(
self.db, self.path, META, "a cat", SCAN_MARKS
)
self.assertEqual(again, ("unchanged", first[1]))
self.assertEqual(len(self.links()), 1)
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
other = self.save("a dog")
# Linked under another spelling of the same file.
spelled = os.path.join(self.temp.name, ".", "gen.png")
self.db.link_image_to_prompt(other, spelled)
result = store_scanned_prompt_or_raise(
self.db, self.path, META, "a cat", SCAN_MARKS
)
self.assertEqual(result, ("conflict", None))
self.assertEqual(self.prompts(), [(other, "a dog")])
self.assertEqual(self.links(), [(other, spelled)])
def test_a_failed_link_raises_and_leaves_no_prompt_behind(self):
with mock.patch.object(
self.db, "_insert_image_link", side_effect=sqlite3.OperationalError("x")
):
with self.assertRaises(sqlite3.OperationalError):
store_scanned_prompt_or_raise(
self.db, self.path, META, "a cat", SCAN_MARKS
)
with self.assertLogs("prompt_manager", "ERROR"):
result = store_scanned_prompt(
self.db, self.path, META, "a cat", SCAN_MARKS
)
self.assertEqual(result, ("found", None))
self.assertEqual(self.prompts(), [])
self.assertEqual(self.links(), [])
def test_two_concurrent_writers_store_one_prompt_and_one_link(self):
hold = history_tests.FirstWriterHold()
outcome = self.db._pair_component_outcome
started = threading.Barrier(2)
def held_open(*args, **kwargs):
hold()
return outcome(*args, **kwargs)
def write(_index):
started.wait(timeout=30)
try:
return store_scanned_prompt_or_raise(
self.db, self.path, META, "a cat", SCAN_MARKS
)[0]
finally:
self.db.model.close()
with (
mock.patch.object(
self.db, "_pair_component_outcome", side_effect=held_open
),
ThreadPoolExecutor(max_workers=2) as pool,
):
statuses = sorted(pool.map(write, range(2)))
self.assertEqual(statuses, ["added", "unchanged"])
self.assertEqual(len(self.prompts()), 1)
self.assertEqual(len(self.links()), 1)
def test_a_library_move_reruns_the_store(self):
from database import scanned_prompts
real = scanned_prompts.store_scanned_prompt_or_raise
calls = []
def moved_once(*args, **kwargs):
if not calls:
calls.append(1)
self.db.model._generation += 1
raise sqlite3.OperationalError("closed")
return real(*args, **kwargs)
with mock.patch.object(
scanned_prompts, "store_scanned_prompt_or_raise", side_effect=moved_once
):
status, _id = store_scanned_prompt(
self.db, self.path, META, "a cat", SCAN_MARKS
)
self.assertEqual((status, calls), ("added", [1]))
def _linked_folder(self):
"""``(out_real, out_link)``: a symlink to the same output folder."""
from tests.test_api_duplicate_files import TestReview
if not TestReview._can_symlink(self.temp.name): # pragma: no cover
self.skipTest("symlinks are not available")
real = Path(self.temp.name) / "out_real"
real.mkdir()
link = Path(self.temp.name) / "out_link"
os.symlink(str(real), str(link))
(real / "a.png").write_bytes(b"png")
return os.path.realpath(str(real)), str(link)
def test_a_file_linked_through_its_real_folder_is_found_through_a_link(self):
"""The scan stores realpath spellings, the monitor the watcher's."""
real, link = self._linked_folder()
other = self.save("a dog")
self.db.link_image_to_prompt(other, os.path.join(real, "a.png"))
through_link = os.path.join(link, "a.png")
self.assertEqual(
store_scanned_prompt_or_raise(
self.db, through_link, META, "a cat", SCAN_MARKS
),
("conflict", None),
)
self.assertEqual(
store_scanned_prompt_or_raise(
self.db, through_link, META, "a dog", SCAN_MARKS
),
("unchanged", other),
)
pair = ingest_resolved_pair(self.db, through_link, META, resolved())
self.assertEqual(pair["status"], "conflict")
self.assertEqual(len(self.links()), 1)
class TestPairMarks(StoreCase):
def test_marks_reach_only_created_components(self):
kept = self.save("cat", category="mine")
result = ingest_resolved_pair(
self.db, self.path, META, resolved(), marks=RUN_MARKS
)
self.assertEqual(result["status"], "added")
positive = self.db.get_prompt_by_id(kept)
self.assertEqual((positive["category"], positive["tags"]), ("mine", []))
negative_id = next(pid for pid, text in self.prompts() if text == "blur")
negative = self.db.get_prompt_by_id(negative_id)
self.assertEqual(negative["category"], "scanned")
self.assertEqual(negative["tags"], ["auto-scanned"])
self.assertEqual(negative["notes"], "Saved from a workflow run: gen.png")
def test_without_marks_created_components_stay_bare(self):
"""Regression guard (passes on the base): the node path is unchanged."""
ingest_resolved_pair(self.db, self.path, META, resolved())
for prompt_id, _text in self.prompts():
prompt = self.db.get_prompt_by_id(prompt_id)
self.assertIsNone(prompt["category"])
self.assertEqual(prompt["tags"], [])
def test_a_blank_negative_creates_only_the_positive(self):
result = ingest_resolved_pair(
self.db, self.path, META, resolved(negative=""), marks=SCAN_MARKS
)
self.assertEqual(result["status"], "added")
self.assertEqual([text for _id, text in self.prompts()], ["cat"])
def test_marks_are_a_frozen_value(self):
self.assertEqual(
SCAN_MARKS, Marks("scanned", ("auto-scanned",), SCAN_MARKS.note)
)
with self.assertRaises(AttributeError):
SCAN_MARKS.category = "x"
class TestPublicRunAttribution(unittest.TestCase):
def test_recorded_runs_and_named_prompt_are_public(self):
from database import run_attribution
self.assertTrue(callable(run_attribution.recorded_runs))
self.assertTrue(callable(run_attribution.named_prompt))
self.assertFalse(hasattr(run_attribution, "_recorded_runs"))
if __name__ == "__main__":
unittest.main()
-6
View File
@@ -203,12 +203,6 @@
"functions": 98,
"statements": 96
},
"workspace/collections.mjs": {
"lines": 98,
"branches": 95,
"functions": 95,
"statements": 98
},
"workspace/file-inspector.mjs": {
"lines": 96,
"branches": 93,
-634
View File
@@ -1,634 +0,0 @@
"""The per-file prompt decision shared by the output scan and the monitor (#247).
``decide_file_prompt`` is the ladder: which prompt one output file names,
from its metadata alone plus, when the graph cannot decide, the save node
recorded history says wrote it. It does no file or database I/O; the caller
reads the metadata, the file size and history (through ``lookup``) and
stores the ``Decision``.
The default resolver profile decides first, as ingestion always has. Only
for a file it leaves ambiguous or unresolved does the ladder ask the
inspector's resolve (``inspection_resolution``: the inspection profile, then
the recorded saver anchor; never the file-name saver anchor, whose files go
to Needs review by the #390 ruling), then the #170 browser parser (the legacy floor).
The policies differ in one place: for a file the inspector calls ambiguous,
the scan keeps the legacy floor and the monitor stores nothing. Junk text
(``junk_text``) is never stored, whichever step found it.
"""
import json
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Mapping, Optional, Union
from .generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
)
from .prompt_graph import (
PROMPT_MANAGER_TYPES,
_positive_roots,
is_negative_input,
metadata_opted_out,
resolve_text,
save_setting,
side_opted_out,
)
from .prompt_inspector import inspection_resolution
from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt
from .review_capture import reviewable
try:
from ..database.pair_ingestion import blank_positive
except ImportError:
from database.pair_ingestion import blank_positive
class Policy(Enum):
SCAN = "scan"
MONITOR = "monitor"
PAIR = "pair"
POSITIVE = "positive"
HISTORY = "history"
NOT_SAVED = "not_saved"
NOT_SAVED_WIRED = "not_saved_wired"
NEVER = "never"
KINDS = (PAIR, POSITIVE, HISTORY, NOT_SAVED, NOT_SAVED_WIRED, NEVER)
_UNDECIDED = ("ambiguous", "unresolved")
@dataclass(frozen=True)
class Decision:
"""What to store for one file (see the design's "The ladder").
``kind``: one of ``KINDS``. ``text``: the storage text of the positive.
``canonical``: the parsed metadata stored with the link. ``resolution``:
the resolution that decided a ``pair``, else the default profile's
result. ``source``: which step named the text (log lines and the scan's
outcome detail). ``review``: ``ambiguous`` or ``untraced`` when the
default profile was ambiguous (the scan's Needs review lists).
``try_recorded_run``: the scan tries the recorded run's stored prompt
when nothing else links. ``unlinked_status``: the scan status when
nothing links (None: no prompt metadata).
"""
kind: str
text: Optional[str] = None
canonical: dict = field(default_factory=dict)
resolution: Optional[dict] = None
source: str = "none"
review: Optional[str] = None
try_recorded_run: bool = False
unlinked_status: Optional[str] = None
@dataclass(frozen=True)
class _File:
"""The ladder's inputs beside the metadata."""
metadata: Mapping
canonical: dict
resolution: dict
review: Optional[str]
file_name: Optional[str]
in_output: bool
lookup: Optional[Callable[[], Any]]
policy: Policy
def decide_file_prompt(
metadata: Any,
*,
file_name: Optional[str],
file_size: Union[None, int, Callable[[], Optional[int]]],
in_output: bool,
lookup: Optional[Callable[[], Any]],
policy: Policy,
) -> Decision:
"""The ladder for one file's raw ``metadata``.
``prompt``/``workflow`` may be JSON strings (the scan) or parsed values
(the monitor). ``lookup`` returns the file's ``RecordedOutput`` or None;
it is None when history must not be asked yet. It is called at most
once, and only for a graph the default profile leaves undecided.
``file_size`` may be a callable: it is read only for a file with prompt
metadata whose ``file_info`` has no size.
"""
if not metadata:
return Decision(NEVER, try_recorded_run=True)
canonical = _parsed(metadata)
if metadata_opted_out(canonical):
# Save to library was off for the run's prompt: nothing is stored,
# no recorded-run fallback, no Needs review item (#90)
return Decision(NOT_SAVED, canonical=canonical)
if metadata_opted_out(canonical, unreadable=True):
# The switch is wired from a node the file cannot show: only the
# live run (the node saw the value) can link it
return Decision(NOT_SAVED_WIRED, canonical=canonical)
switched = _switched_off_upstream(canonical.get("prompt"))
if switched is not None:
# The #90 walk follows only prompt-like inputs; any input name
# upstream of a positive can carry a switched-off node's text
return Decision(switched, canonical=canonical)
if not canonical.get("prompt") and not canonical.get("parameters"):
return Decision(NEVER, canonical=canonical, try_recorded_run=True)
_with_size(canonical, file_size)
resolution = resolve_prompt_pair(canonical)
switched = (
_side_switch(canonical.get("prompt"), resolution["positive"])
if resolution["state"] == "resolved"
else None
)
if switched is not None:
# The positive passes a node that is off, though the #90 graph walk
# did not reach it (steps 5 and 7)
return Decision(switched, canonical=canonical)
if resolution["state"] == "resolved" and side_opted_out(
canonical.get("prompt"), resolution["negative"]
):
# Only the negative's node is off: the positive alone, never a pair
positive = resolution["positive"]
text = positive.get("storage_text", positive["text"]).strip()
return _gated(
Decision(
POSITIVE,
text=text,
canonical=canonical,
resolution=resolution,
source="opted-out-negative",
),
None,
)
if blank_positive(resolution):
# Only prepend/append text: no stored prompt can own it (#342)
resolution = {**resolution, "state": "unresolved"}
found = _File(
metadata,
canonical,
resolution,
_review(resolution),
file_name,
in_output,
lookup,
policy,
)
return _gated(_decide(found), found)
def _decide(found: _File) -> Decision:
state = found.resolution["state"]
graph = found.canonical.get("prompt")
if state == "resolved":
positive = found.resolution["positive"]
text = positive.get("storage_text", positive["text"])
return _decision(found, PAIR, text, "resolver")
if graph and state in _UNDECIDED:
return _undecided_graph(found)
if state == "ambiguous":
return _never(found, _unlinked(found))
if state == "unresolved" and not isinstance(found.canonical.get("parameters"), str):
return _never(found, "unresolved")
if graph:
return _sampler_less(found, graph)
text = _a1111_positive(found.metadata)
if text is None:
return _never(found, "found")
return _decision(found, POSITIVE, text, "a1111")
def _undecided_graph(found: _File) -> Decision:
recorded = found.lookup() if found.lookup is not None else None
saver = recorded.saver_id if recorded is not None else None
inspected = inspection_resolution(
found.metadata, found.file_name, saver, name_anchor=False
)
text = _inspected_text(inspected.resolution)
switched = (
_side_switch(found.canonical["prompt"], inspected.resolution["positive"])
if text is not None
else None
)
if switched is not None:
# The inspector follows pass-throughs the #90 graph walk does not
return Decision(switched, canonical=found.canonical)
if text is not None:
source = "recorded-run" if inspected.by_record else "inspector"
return _decision(found, POSITIVE, text, source)
if found.policy is Policy.MONITOR:
if found.lookup is None and found.in_output:
return _decision(found, HISTORY, None, "none")
if not inspected.malformed and inspected.resolution["state"] == "ambiguous":
return _never(found, _unlinked(found))
text = graph_prompt_text(found.metadata)
switched = _off_text(found.canonical["prompt"], text) if text else None
if switched is not None:
# The legacy answer is a switched-off node's own text (#90)
return Decision(switched, canonical=found.canonical)
if text is not None and _joined_text(found.canonical["prompt"], text):
# The legacy parser joins prepend + text + append: the text a
# PromptManager node stores cannot be told from it (#343).
return _never(found, _unlinked(found), try_recorded_run=True)
if text is not None:
return _decision(found, POSITIVE, text, "legacy-parser")
return _never(found, _unlinked(found), try_recorded_run=True)
def _sampler_less(found: _File, graph: Any) -> Decision:
# No node takes a positive or negative input (a guider or wrapper graph):
# the one wired text, never a guess (#252).
wired = sampler_less_prompt(graph)
if wired["text"] is not None:
return _decision(found, POSITIVE, wired["text"], "sampler-less")
if isinstance(found.canonical.get("parameters"), str):
# A metadata saver also wrote A1111 text: its positive prompt.
text = _a1111_positive(found.metadata)
if text is not None:
return _decision(found, POSITIVE, text, "a1111")
status = "unresolved" if wired["state"] == "ambiguous" else "found"
return _never(found, status, try_recorded_run=True)
_UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser")
def _switch_kind(settings: Any) -> Optional[str]:
"""``not_saved`` when a setting is off, ``not_saved_wired`` when one is
unknowable (wired from a node the graph cannot read), else None."""
settings = list(settings)
if False in settings:
return NOT_SAVED
if None in settings:
return NOT_SAVED_WIRED
return None
def _prompt_manager_ids(graph: Any) -> list:
if not isinstance(graph, dict):
return []
return [
str(node_id)
for node_id, node in graph.items()
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
]
def _side_switch(graph: Any, side: Any) -> Optional[str]:
"""The switch outcome of the PromptManager nodes on a side's path."""
if not isinstance(graph, dict) or not side_opted_out(graph, side):
return None
provenance = side.get("provenance")
path = provenance.get("path") if isinstance(provenance, dict) else None
path = path if isinstance(path, list) else []
nodes = {str(step.get("node_id")) for step in path if isinstance(step, dict)} & set(
_prompt_manager_ids(graph)
)
return _switch_kind(save_setting(graph, node_id) for node_id in nodes)
# Inputs that never carry prompt text or positive conditioning.
_NOT_TEXT_INPUTS = frozenset(
{
"model",
"clip",
"vae",
"latent_image",
"samples",
"pixels",
"image",
"images",
"mask",
"noise",
"sigmas",
"sampler",
"control_net",
"upscale_model",
}
)
_MAX_SWITCH_VISITS = 5000
def _negative_name(key: Any) -> bool:
"""#90's negative rule, plus any name containing "negative"
(``negative2``, ``NegativePrompt``)."""
return is_negative_input(key) or "negative" in str(key).lower()
def _switched_off_upstream(graph: Any) -> Optional[str]:
"""The switch outcome of every PromptManager node upstream of a positive.
Follows every input except negative and non-text ones (#90's walk follows
only prompt-like names). A graph without a recognisable sampler checks
every PromptManager node, as #90 does.
"""
if not isinstance(graph, dict):
return None
roots = _positive_roots(graph)
if not roots:
ids = _prompt_manager_ids(graph)
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
seen, found = set(), set()
stack = [str(link[0]) for link in roots]
while stack and len(seen) < _MAX_SWITCH_VISITS:
node_id = stack.pop()
if node_id in seen:
continue
seen.add(node_id)
node = graph.get(node_id)
if not isinstance(node, dict):
continue
if node.get("class_type") in PROMPT_MANAGER_TYPES:
found.add(node_id)
inputs = node.get("inputs")
for key, value in (inputs.items() if isinstance(inputs, dict) else ()):
if key in _NOT_TEXT_INPUTS or _negative_name(key):
continue
if isinstance(value, (list, tuple)) and len(value) == 2:
if isinstance(value[0], (str, int)) and isinstance(value[1], int):
stack.append(str(value[0]))
return _switch_kind(save_setting(graph, node_id) for node_id in found)
def _off_text(graph: Any, text: str) -> Optional[str]:
"""The switch outcome when ``text`` is the text (resolved, else literal)
of a PromptManager-family node whose Save to library is not on."""
wanted = text.strip()
matched = []
for node_id in _prompt_manager_ids(graph):
node_text = resolve_text(graph, node_id)
if node_text is None:
inputs = graph[node_id].get("inputs")
literal = inputs.get("text") if isinstance(inputs, dict) else None
node_text = literal if isinstance(literal, str) else None
if node_text is not None and node_text.strip() == wanted:
matched.append(save_setting(graph, node_id))
return _switch_kind(matched)
def _joined_text(graph: Any, text: str) -> bool:
"""Whether ``text`` may hold a PromptManager node's prepend or append.
True when a PromptManager-family node has a prepend or append part and
``text`` is not the literal ``text`` of one such node (the legacy
parser's answer when it does not follow a linked part).
"""
own = _prompt_manager_parts(graph)
return own is not None and text.strip() not in own
def _prompt_manager_parts(graph: Any) -> Optional[set]:
"""The literal texts of PromptManager-family nodes with a prepend or
append part, or None when no such node exists."""
if not isinstance(graph, dict):
return None
found = None
for node in graph.values():
if not isinstance(node, dict):
continue
name, inputs = node.get("class_type"), node.get("inputs")
if not isinstance(name, str) or "promptmanager" not in name.lower():
continue
if not isinstance(inputs, dict) or not _has_part(inputs):
continue
found = found if found is not None else set()
if isinstance(inputs.get("text"), str):
found.add(inputs["text"].strip())
return found
def _has_part(inputs: dict) -> bool:
for key in ("prepend_text", "append_text"):
part = inputs.get(key)
if part is not None and not (isinstance(part, str) and not part.strip()):
return True
return False
def _gated(decision: Decision, found: Optional[_File]) -> Decision:
"""Junk text is never stored (step 13).
A junk answer of the undecided-graph step keeps that step's unlinked
status, so an ambiguous file is still a Needs review case; any other
step reports ``found``.
"""
if decision.kind not in (PAIR, POSITIVE):
return decision
junk = junk_text(decision.text)
if junk is None:
return decision
return Decision(
NEVER,
canonical=decision.canonical,
resolution=decision.resolution,
source=f"junk:{junk}",
review=decision.review,
try_recorded_run=True,
unlinked_status=(
_unlinked(found)
if found is not None and decision.source in _UNDECIDED_SOURCES
else "found"
),
)
def _decision(found: _File, kind: str, text: Optional[str], source: str) -> Decision:
return Decision(
kind,
text=text,
canonical=found.canonical,
resolution=found.resolution,
source=source,
review=found.review,
)
def _never(found: _File, status: str, *, try_recorded_run: bool = False) -> Decision:
return Decision(
NEVER,
canonical=found.canonical,
resolution=found.resolution,
review=found.review,
try_recorded_run=try_recorded_run,
unlinked_status=status,
)
def _review(resolution: dict) -> Optional[str]:
if resolution["state"] != "ambiguous":
return None
return "ambiguous" if reviewable(resolution) else "untraced"
def _unlinked(found: _File) -> str:
"""Ambiguous without a choice is reported unresolved."""
if found.review == "untraced":
return "unresolved"
return found.resolution["state"]
def _inspected_text(resolution: dict) -> Optional[str]:
"""The storage text of a positive the inspector shows, else None."""
if resolution.get("state") in _UNDECIDED:
return None
positive = resolution.get("positive")
if not isinstance(positive, dict):
return None
text = positive.get("storage_text", positive.get("text"))
return text if isinstance(text, str) and text.strip() else None
def _parsed(metadata: Mapping) -> dict:
canonical = dict(metadata)
for key in ("prompt", "workflow"):
if isinstance(canonical.get(key), str):
try:
canonical[key] = json.loads(canonical[key])
except (ValueError, TypeError):
# Preserve malformed metadata for conservative resolver rejection.
pass
return canonical
def _with_size(canonical: dict, file_size: Any) -> None:
"""Record the file's size in ``file_info`` unless the metadata carries one.
Stored as ``generated_images.file_size``; the "new" scan scope skips a
known file only while its size is unchanged.
"""
info = canonical.get("file_info")
info = dict(info) if isinstance(info, dict) else {}
if info.get("size") is None:
size = file_size() if callable(file_size) else file_size
if size is not None:
info["size"] = size
if info:
canonical["file_info"] = info
def _a1111_positive(metadata: Mapping) -> Optional[str]:
text = parse_comfyui_prompt(metadata)["positive_prompt"]
return text.strip() if text and text.strip() else None
_LORA_TAG = re.compile(r"<lora:[^>]*>")
_LORA_SEPARATORS = " \t\r\n\f\v,;|"
_MODEL_FILE = re.compile(r"\S*\.(?:safetensors|ckpt|pt|pth|bin|gguf|sft)", re.I)
def junk_text(text: Any) -> Optional[str]:
"""The #252 junk class of ``text``, or None when it may be a prompt."""
stripped = text.strip() if isinstance(text, str) else ""
if not stripped:
return "blank"
if _numeric(stripped):
return "number"
if not _LORA_TAG.sub("", stripped).strip(_LORA_SEPARATORS):
return "lora-only"
if _MODEL_FILE.fullmatch(stripped):
return "model-file"
return None
def graph_prompt_text(metadata: Mapping) -> Optional[str]:
"""The positive prompt the browser parser traces from the graphs, or None.
Records parse as the legacy pages parsed them (NaN reads as null).
"""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_page_json(value) if isinstance(value, str) else value
try:
positive = extract_generation_params(records)["positivePrompt"]
except GenerationParamsError:
return None
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
return None
return positive.strip() or None
_COMMON_FIELDS = (
"positive",
"negative",
"steps",
"cfg",
"sampler",
"scheduler",
"seed",
)
def _json_or_raw(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def parse_comfyui_prompt(metadata: Mapping) -> dict:
"""Parse ComfyUI and A1111 prompt data from metadata.
Accepts the file's strings or parsed values: a parsed ``prompt`` or
``workflow`` is kept as it is and a non-string ``parameters`` is ignored.
"""
result = {
"prompt": None,
"workflow": None,
"parameters": {},
"positive_prompt": None,
"negative_prompt": None,
}
params = metadata.get("parameters")
if isinstance(params, str):
_parse_a1111(params, result)
if result["positive_prompt"] is None:
for key in ("prompt", "workflow"):
if key in metadata:
result[key] = _json_or_raw(metadata[key])
for name in _COMMON_FIELDS:
if name in metadata:
result["parameters"][name] = _json_or_raw(metadata[name])
return result
_SETTINGS_FIELDS = ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed")
def _parse_a1111(params: str, result: dict) -> None:
"""A1111/Forge ``parameters``: a possibly multi-line positive, then a
"Negative prompt:" section, then a trailing "Steps: ..." settings line.
Taking the first line as the positive would truncate a multi-line prompt,
so the text is split on the settings line instead.
"""
body = params
settings = ""
found = re.search(r"\nSteps:\s", body)
if found:
settings = body[found.start() + 1 :].strip()
body = body[: found.start()]
negative = None
found = re.search(r"(?im)^\s*negative prompt:\s*", body)
if found:
negative = body[found.end() :].strip()
body = body[: found.start()]
elif body.lower().lstrip().startswith("negative prompt:"):
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
body = ""
result["positive_prompt"] = body.strip() or None
if negative:
result["negative_prompt"] = negative
result["parameters"]["parameters"] = params
for name in _SETTINGS_FIELDS if settings else ():
found = re.search(
r"(?i)(?:^|,\s*)" + re.escape(name) + r":\s*([^,]+)", settings
)
if found:
key = name.lower().replace(" ", "_")
result["parameters"][key] = found.group(1).strip()
+1 -6
View File
@@ -41,14 +41,9 @@ _TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
_NEGATIVE_KEY = re.compile(r"(^|_)neg(ative)?($|_)", re.IGNORECASE)
def is_negative_input(key: Any) -> bool:
"""Whether an input name carries a negative prompt (#90's one rule)."""
return isinstance(key, str) and bool(_NEGATIVE_KEY.search(key))
def follows_input(key: Any) -> bool:
"""Whether an input name carries positive conditioning or prompt text."""
if not isinstance(key, str) or is_negative_input(key):
if not isinstance(key, str) or _NEGATIVE_KEY.search(key):
return False
return key == "positive" or bool(
_CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key)
+64 -131
View File
@@ -1,10 +1,4 @@
"""Read prompt evidence without inference, indexing, or decoding image pixels.
``inspection_resolution`` is the inspector's resolve (inspection profile, then
the recorded saver anchor, #386). Ingestion (the output scan, through
``utils.file_prompt``) calls it too, only for files the default profile
cannot decide (#247), so the inspector and ingestion cannot drift.
"""
"""Read prompt evidence without inference, indexing, or decoding image pixels."""
import hashlib
import io
@@ -577,102 +571,6 @@ def _recorded_execution(resolved: dict, texts: Mapping) -> dict | None:
return {"prompts": prompts, "resolution": resolution}
_ABSENT_RESOLUTION = {
"state": "absent",
"sampler_id": None,
"reason": "No parsed prompt metadata",
"reason_code": None,
"node": None,
}
_MALFORMED_RESOLUTION = {
"state": "unresolved",
"sampler_id": None,
"reason": MALFORMED_EVIDENCE_REASON,
"reason_code": "malformed",
"node": None,
}
@dataclass(frozen=True)
class InspectionResult:
"""The inspector's resolution of one file's metadata (#386, #247).
``resolution`` is the result to use. ``anchored`` is the result anchored
on the recorded saver, kept or not (None when no anchored resolve ran).
``by_record`` says the anchored result was kept: history's saver chose
the samplers and the result names the positive. ``malformed`` marks
metadata the inspector cannot read as a graph.
"""
resolution: dict
anchored: dict | None = None
by_record: bool = False
malformed: bool = False
def inspection_resolution(
metadata: Mapping,
file_name: str | None,
recorded_saver_id: str | None,
*,
profile: ResolverProfile = INSPECTION_PROFILE,
name_anchor: bool = True,
) -> InspectionResult:
"""Resolve raw file metadata as the inspector does.
Keeps only the role records, parses a string ``prompt`` and attaches the
parsed ``workflow`` only when the graph may hold a PromptManager node.
Resolves with ``profile``; when that leaves the prompt open and
``recorded_saver_id`` is given, resolves again anchored on that saver
and keeps the anchored result only when it names the positive.
``name_anchor=False`` (ingestion, #247) resolves the unanchored pass
without ``file_name``, so the saver its file name names anchors nothing:
such a file goes to Needs review, never linked (#390 ruling). The
recorded-saver pass still gets ``file_name``, only to refuse a saver the
name contradicts.
"""
workflow = metadata.get("workflow")
canonical = {
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
}
if not canonical:
return InspectionResult(dict(_ABSENT_RESOLUTION))
malformed = InspectionResult(dict(_MALFORMED_RESOLUTION), malformed=True)
if "prompt" not in canonical and "parameters" not in canonical:
return malformed
if isinstance(canonical.get("prompt"), str):
try:
canonical["prompt"] = json.loads(canonical["prompt"])
except (ValueError, RecursionError):
return malformed
workflow = (
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
)
if workflow is not None and "prompt" in canonical:
# Only to recognize overwritten PromptManager text (R1); never a role.
canonical["workflow"] = workflow
resolved = resolve_prompt_pair(
canonical, profile=profile, file_name=file_name if name_anchor else None
)
if resolved["state"] not in _UNDECIDED or recorded_saver_id is None:
return InspectionResult(resolved)
anchored = resolve_prompt_pair(
canonical,
profile=profile,
file_name=file_name,
recorded_saver_id=recorded_saver_id,
)
# Only a result whose samplers history's saver chose is about this file.
if (
anchored.get("recorded_anchor") is True
and anchored["state"] not in _UNDECIDED
and isinstance(anchored["positive"], dict)
):
return InspectionResult(anchored, anchored, True)
return InspectionResult(resolved, anchored)
def inspect_prompt_evidence(
metadata: dict,
saved: dict | None = None,
@@ -695,13 +593,22 @@ def inspect_prompt_evidence(
``execution``.
Inspection routes resolve with ``INSPECTION_PROFILE``: bounded work, not
graph size. The resolve itself is ``inspection_resolution``, which
ingestion also calls for files its default profile cannot decide (#247).
``resolution`` carries the closed ``reason_code`` and, for a blocking
node, ``node: {id, class_type}``.
graph size. Ingestion (scan, live monitor, admin import) calls the resolver
directly with its default profile. ``resolution`` carries the closed
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
"""
roles = {key for key in metadata if key in _ROLE_METADATA_KEYS}
if not roles:
workflow = metadata.get("workflow")
metadata = {
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
}
resolution = {
"state": "unresolved",
"sampler_id": None,
"reason": MALFORMED_EVIDENCE_REASON,
"reason_code": "malformed",
"node": None,
}
if not metadata:
result = {role: _side("absent") for role in ("positive", "negative")}
if saved:
context = saved.get("pair_context")
@@ -714,30 +621,56 @@ def inspect_prompt_evidence(
"verified against this file."
),
}
return {"prompts": result, "resolution": dict(_ABSENT_RESOLUTION)}
inspected = inspection_resolution(
metadata, file_name, recorded_saver_id, profile=profile
)
if inspected.malformed:
resolution = inspected.resolution
return {
"prompts": _explained(
{role: _side("unresolved") for role in ("positive", "negative")},
resolution,
),
"resolution": resolution,
"prompts": result,
"resolution": {
"state": "absent",
"sampler_id": None,
"reason": "No parsed prompt metadata",
"reason_code": None,
"node": None,
},
}
resolved, recorded = inspected.resolution, inspected.by_record
anchored = inspected.anchored
if (
not recorded
and recorded_texts
and anchored is not None
and anchored.get("recorded_anchor") is True
):
executed = _recorded_execution(anchored, recorded_texts)
if executed is not None:
return executed
canonical = dict(metadata)
malformed = {
"prompts": _explained(
{role: _side("unresolved") for role in ("positive", "negative")},
resolution,
),
"resolution": resolution,
}
if "prompt" not in canonical and "parameters" not in canonical:
return malformed
if isinstance(canonical.get("prompt"), str):
try:
canonical["prompt"] = json.loads(canonical["prompt"])
except (ValueError, RecursionError):
return malformed
workflow = (
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
)
if workflow is not None and "prompt" in canonical:
# Only to recognize overwritten PromptManager text (R1); never a role.
canonical["workflow"] = workflow
resolved = resolve_prompt_pair(canonical, profile=profile, file_name=file_name)
recorded = False
if resolved["state"] in _UNDECIDED and recorded_saver_id is not None:
anchored = resolve_prompt_pair(
canonical,
profile=profile,
file_name=file_name,
recorded_saver_id=recorded_saver_id,
)
# Only a result whose samplers history's saver chose is about this file.
if anchored.get("recorded_anchor") is True:
if anchored["state"] not in _UNDECIDED and isinstance(
anchored["positive"], dict
):
resolved, recorded = anchored, True
elif recorded_texts:
executed = _recorded_execution(anchored, recorded_texts)
if executed is not None:
return executed
state = resolved["state"]
result = {}
for role in ("positive", "negative"):
-314
View File
@@ -1,314 +0,0 @@
/**
* Collections (#331): named, hand-picked groups of images, persisted through `/prompt_manager/collections*`.
*
* - D136: the sidebar COLLECTIONS group (the concept's `#collections-nav`, index.html 28–31, app.js 336–339) lists
* each collection as `#/collection/<id>` with its listed-member count, without the concept's "PROPOSED" badge.
* - D137: a collection page is Images filtered to the collection; its Edit renames or deletes it through the saved
* views' Edit dialog (D51) with collection nouns.
* - This module holds the client store (`createCollections`), the route hash, the name rule and every user-facing
* string of the design (§7.4, §8), so the selection bar, the inspector and the shell say the same things.
*/
import { noun } from './plural.mjs';
import { openEditDialog } from './saved-views.mjs';
export const COLLECTIONS_URL = '/prompt_manager/collections';
export const COLLECTION_ITEMS_URL = `${COLLECTIONS_URL}/items`;
export const COLLECTION_MEMBERSHIP_URL = `${COLLECTIONS_URL}/membership`;
export const MAX_COLLECTION_NAME_LENGTH = 100;
// The routes' cap on one add or remove (the bulk tagging cap, MAX_BULK_IMAGES).
export const MAX_COLLECTION_ITEMS = 500;
// Counts are re-read once after a run of confirmed changes settles, as the Favorites count (#249 M1).
export const COLLECTION_COUNT_DELAY_MS = 300;
// Strings (design §7.4 and §8).
export const ADD_DIALOG_TITLE = 'Add selected images to collection';
export const ADD_DIALOG_LABEL = 'Collection name';
export const ADD_DIALOG_ACTION = 'Add';
export const TOO_MANY_TOAST = `Select at most ${MAX_COLLECTION_ITEMS} images.`;
export const FILES_NOT_FOUND_TOAST = 'None of the selected images could be found. Refresh the images and try again.';
export const SOURCE_CHANGED_TOAST = 'The sources changed. Refresh the images and try again.';
export const ADD_FAILED_TOAST = 'The images could not be added. Try again.';
export const REMOVE_FAILED_TOAST = 'The images could not be removed. Try again.';
export const COLLECTION_GONE_TOAST = 'That collection no longer exists.';
export const RENAMED_TOAST = 'Collection renamed.';
export const RENAME_FAILED_TOAST = 'The collection could not be renamed. Try again.';
export const DELETED_TOAST = 'Collection deleted.';
export const DELETE_FAILED_TOAST = 'The collection could not be deleted. Try again.';
export const DELETED_PAGE_TEXT = 'This collection no longer exists.';
export const OPEN_IMAGES_LINK = 'Open Images';
export const VIEW_COLLECTION_DELETED = 'The collection this view uses was deleted.';
export const LIST_FAILED_TEXT = 'Collections could not be loaded. Your saved data has not changed.';
export const COLLECTIONS_UPDATED_TOAST = 'Collections updated.';
export const UPDATE_FAILED_TEXT = 'The collections could not be updated. Try again.';
export const MEMBERSHIP_LOADING_TEXT = 'Loading collections…';
export const MEMBERSHIP_FAILED_TEXT = 'Collections could not be loaded.';
export const NO_COLLECTIONS_TEXT = 'No collections yet. Name one below.';
const CONTROL = /[\u0000-\u001f\u007f]/;
const isCount = value => Number.isSafeInteger(value) && value >= 0;
const isId = value => Number.isSafeInteger(value) && value >= 1 && value <= 999_999_999;
/** The route of one collection. */
export function collectionHash(id) {
if (!isId(id)) throw new TypeError('Invalid collection id');
return `#/collection/${id}`;
}
/**
* A name as the database stores it (design §1): trimmed, each run of whitespace one space, 1 to 100 characters
* without control characters; null otherwise. Tabs and newlines are whitespace, so they collapse.
*/
export function normalizeCollectionName(value) {
if (typeof value !== 'string') return null;
const name = value.trim().replace(/\s+/g, ' ');
return name.length >= 1 && name.length <= MAX_COLLECTION_NAME_LENGTH && !CONTROL.test(name) ? name : null;
}
/** One listed collection, or null when the row is not usable. `count` and `missing` are null unless counts. */
export function parseCollection(row) {
if (!isId(row?.id) || typeof row.name !== 'string' || !row.name.trim()) return null;
const counted = isCount(row.count) && isCount(row.missing);
return { id: row.id, name: row.name, count: counted ? row.count : null, missing: counted ? row.missing : null };
}
/** The listing's collections in the server's order (name, then id), skipping unusable rows. */
export function parseCollections(data) {
if (!Array.isArray(data?.collections)) throw new TypeError('Invalid collections');
return data.collections.map(parseCollection).filter(Boolean);
}
/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */
const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
const ordered = list => [...list].sort((a, b) => {
const left = nameKey(a.name), right = nameKey(b.name);
return left < right ? -1 : left > right ? 1 : a.id - b.id;
});
/** "Added N images to “X”." with what was already in it and what could not be found. */
export function addedMessage({ collection, added, already, notFound }) {
if (added === 0 && notFound === 0) return `All selected images were already in “${collection.name}”.`;
let text = `Added ${added} ${noun(added, 'image')} to “${collection.name}”.`;
if (already > 0) text += already === 1 ? ' 1 was already in it.' : ` ${already} were already in it.`;
if (notFound > 0) text += ` ${notFound} could not be found.`;
return text;
}
export function removedMessage(name, removed) {
return `Removed ${removed} ${noun(removed, 'image')} from “${name}”.`;
}
export function nameTakenMessage(name) {
return `A collection named “${name}” already exists.`;
}
/** The Delete confirmation, with the stored count (listed plus missing) when the counts are known. */
export function deleteQuestion({ name, count, missing }) {
if (!isCount(count) || !isCount(missing)) {
return `Delete “${name}”? Its images stay where they are; only the collection is removed.`;
}
const stored = count + missing;
return stored === 1
? `Delete “${name}”? Its 1 image stays where it is; only the collection is removed.`
: `Delete “${name}”? Its ${stored} images stay where they are; only the collection is removed.`;
}
/** The collection page's note about members its sources do not list now (MC3); empty when there are none. */
export function missingNote(missing) {
if (!isCount(missing) || missing === 0) return '';
return missing === 1
? '1 image in this collection is not in your current sources.'
: `${missing} images in this collection are not in your current sources.`;
}
export const isCollectionGone = error => error?.code === 'collection-not-found';
/** A failed add or remove's toast: the routes' error codes, else `fallback`. */
export function writeFailure(error, fallback) {
if (isCollectionGone(error)) return COLLECTION_GONE_TOAST;
if (error?.code === 'source-changed') return SOURCE_CHANGED_TOAST;
if (error?.code === 'files-not-found') return FILES_NOT_FOUND_TOAST;
return fallback;
}
/** The Edit dialog's mapping of a failed rename of `name` (openEditDialog `renameError`). */
export function renameFailure(name) {
return error => {
if (error?.code === 'name-taken') return { field: nameTakenMessage(name) };
if (isCollectionGone(error)) return { toast: COLLECTION_GONE_TOAST, close: true };
return { toast: RENAME_FAILED_TOAST };
};
}
/** The collection nouns of the shared Edit dialog (D51, D137). */
export const COLLECTION_NOUNS = Object.freeze({
title: 'Edit collection', label: 'Collection name', deleteTitle: 'Delete collection', deleteText: deleteQuestion,
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
});
/**
* D137: the open collection's Edit. A taken name stays in the dialog under the field; a failed rename keeps it open;
* a collection deleted meanwhile closes it and runs `onGone()` after the toast. `onRename(name)` sends the normalized
* name; a delete's 404 counts as deleted (the store's `delete`).
*/
export function openEditCollectionDialog({ scope, trigger, collection, onRename, onDelete, onGone = () => {} }) {
let attempted = '';
const mapping = error => {
const outcome = renameFailure(attempted)(error);
if (outcome.close) queueMicrotask(onGone);
return outcome;
};
openEditDialog({ scope, trigger, item: collection, nouns: COLLECTION_NOUNS, onDelete, renameError: mapping,
onRename: name => {
attempted = normalizeCollectionName(name) ?? name;
return onRename(attempted);
} });
}
function fileTargets(items) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_COLLECTION_ITEMS) {
throw new TypeError('A collection change needs 1 to 500 files');
}
return items.map(item => {
if (!Number.isSafeInteger(item?.root_index) || item.root_index < 0 || typeof item.root_scope !== 'string' ||
!item.root_scope || typeof item.path !== 'string' || !item.path) {
throw new TypeError('Invalid collection file');
}
return { root_index: item.root_index, root_scope: item.root_scope, path: item.path };
});
}
function requireId(id) {
if (!isId(id)) throw new TypeError('Invalid collection id');
return id;
}
function requireName(name) {
const normalized = normalizeCollectionName(name);
if (normalized === null) throw new TypeError('Invalid collection name');
return normalized;
}
/** The `{id, name}` a write answered with. */
function answered(data) {
const collection = parseCollection(data?.collection);
if (!collection) throw new TypeError('Invalid collection');
return { id: collection.id, name: collection.name };
}
function countOf(value) {
if (!isCount(value)) throw new TypeError('Invalid collection count');
return value;
}
/**
* The shell's collection list. `load()` re-reads it (a newer read supersedes an older one), `subscribe(fn)` reports
* `{collections, status}` on every change, `settled()` resolves once no read is in flight; `status` is 'loading',
* 'ready' or 'error' and a failed read keeps the last list. `add`, `remove`, `rename` and `delete` change the listed
* rows at once and re-read the counts once, 300 ms after the last confirmed change; a write to a collection that is
* gone (404 `collection-not-found`) re-reads the list at once and rejects. `timers` is a test seam.
*/
export function createCollections(api, signal, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
let collections = [], status = 'loading', request = null, timer = 0;
const listeners = new Set();
let waiting = [];
const emit = () => {
const snapshot = { collections, status };
for (const listener of listeners) listener(snapshot);
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
};
const stopTimer = () => { if (timer) clearTimer(timer); timer = 0; };
signal?.addEventListener('abort', () => { stopTimer(); request?.abort(); }, { once: true });
function load() {
if (signal?.aborted) return Promise.resolve();
request?.abort();
const controller = new AbortController();
request = controller;
status = 'loading';
emit();
return api.get(COLLECTIONS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
if (request !== controller) return;
collections = parseCollections(data);
status = 'ready';
}).catch(() => {
if (request !== controller) return;
status = 'error';
}).finally(() => {
if (request !== controller || signal?.aborted) return;
request = null;
emit();
});
}
function changed(next) {
collections = ordered(next);
emit();
stopTimer();
timer = setTimer(() => { timer = 0; load(); }, COLLECTION_COUNT_DELAY_MS);
}
/** Send one write; a collection found gone meanwhile re-reads the list before the error reaches the caller. */
async function send(write) {
try {
return await write();
} catch (error) {
if (isCollectionGone(error)) load();
throw error;
}
}
async function add(target, items) {
const byId = target?.collectionId !== undefined, byName = target?.name !== undefined;
if (byId === byName) throw new TypeError('A collection is named by its id or a name');
const body = byId ? { collection_id: requireId(target.collectionId) } : { name: requireName(target.name) };
body.items = fileTargets(items);
const data = await send(() => api.post(COLLECTION_ITEMS_URL, body, { signal }));
const result = { collection: answered(data), created: data.created === true, added: countOf(data.added),
already: countOf(data.already), notFound: countOf(data.not_found) };
const { id, name } = result.collection;
changed(collections.some(item => item.id === id)
? collections.map(item => item.id === id ? { ...item, name } : item)
: [...collections, { id, name, count: null, missing: null }]);
return result;
}
async function remove(id, items) {
const body = { collection_id: requireId(id), items: fileTargets(items) };
const data = await send(() => api.del(COLLECTION_ITEMS_URL, { signal, body }));
const result = { collection: answered(data), removed: countOf(data.removed) };
changed(collections);
return result;
}
async function membership(identity) {
const [file] = fileTargets([identity]);
const query = new URLSearchParams({ root_index: String(file.root_index), root_scope: file.root_scope, path: file.path });
const data = await api.get(`${COLLECTION_MEMBERSHIP_URL}?${query}`, { signal, cache: 'no-store' });
const ids = data?.collection_ids;
if (!Array.isArray(ids) || !ids.every(isId)) throw new TypeError('Invalid collection membership');
return [...ids];
}
async function rename(id, name) {
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
const body = { name: requireName(name) };
const renamed = answered(await send(() => api.patch(url, body, { signal })));
changed(collections.map(item => item.id === renamed.id ? { ...item, name: renamed.name } : item));
return renamed;
}
async function deleteCollection(id) {
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
try {
await api.del(url, { signal });
} catch (error) {
// Already gone (another tab deleted it): the outcome the user asked for.
if (error?.status !== 404) throw error;
}
changed(collections.filter(item => item.id !== id));
return id;
}
return {
load, add, remove, membership, rename, delete: deleteCollection,
get collections() { return collections; },
get status() { return status; },
find: id => collections.find(item => item.id === id) ?? null,
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ collections, status }),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
-8
View File
@@ -53,14 +53,6 @@ function queryFor(filters, batchSize) {
const favorites = filters.favorites ?? false;
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
if (favorites) query.set('favorites', '1');
// A collection page (#331 D137): the listing keeps only that collection's members; never together with favorites.
const collection = filters.collection ?? '';
if (collection !== '') {
if (favorites || !/^[1-9]\d{0,8}$/.test(typeof collection === 'number' ? String(collection) : collection)) {
throw new TypeError('Invalid collection filter');
}
query.set('collection', String(collection));
}
return query.toString();
}
+11 -37
View File
@@ -16,7 +16,6 @@ import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, resc
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
import { VIEW_COLLECTION_DELETED, isCollectionGone } from './collections.mjs';
import { IMAGE_CATEGORIES_URL, mergeCategories } from './images-category.mjs';
import { displayPreferences } from './preferences.mjs';
import { onImageTagsSaved } from './image-tagging.mjs';
@@ -65,8 +64,6 @@ const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name',
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
const RETRY_RESCAN = 'Rescan from Import to retry.';
// The concept's empty collection (app.js updateImages).
const NO_MATCHES = 'No matching images. Try another search or category.';
const RETRY_REFRESH = 'Refresh image collection to retry.';
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
function loadFailure(sourcesRetry, categoriesFailed) {
@@ -111,13 +108,10 @@ export function selectedImage() {
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
* Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is
* off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls
* `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a
* deleted collection, D140) the page lists nothing and says so.
* Images last visit.
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) {
favorites = false, onSaveView = () => {}, activity = null} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
@@ -133,13 +127,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scope.cleanup(() => detailInspector?.dispose());
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
// category, which becomes the Images return state like any plain visit (as Browse does for the Library).
const imagesPage = !embedded && !favorites && collectionId === null;
const handed = imagesPage && !savedState ? browseState : null;
if (imagesPage) browseState = null;
const handed = !embedded && !favorites && !savedState ? browseState : null;
if (!embedded && !favorites) browseState = null;
const saved = embedded || !savedState ? null : imagesFilters(savedState);
// The open collection: the route's, else a saved view's (D140); '' on every other page.
const openCollection = embedded ? '' : collectionId !== null ? String(collectionId) : saved?.collection || '';
const prior = embedded || saved || favorites || openCollection || handed ? null : lastVisit;
const prior = embedded || saved || favorites || handed ? null : lastVisit;
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
const visit = prior?.stale ? null : prior;
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
@@ -159,8 +150,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const itemIndexes = new Map();
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
...(handed ? {category: handed.category} : {}),
// A collection and favorites are never both the filter (DC10).
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
favorites: favorites || saved?.favorites === true };
filters.tags = [...filters.tags];
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
@@ -171,7 +161,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
@@ -266,7 +256,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
const showsEmpty = !embedded || typeof emptyText === 'string';
const empty = el('div', embedded ? emptyText : NO_MATCHES, 'empty');
const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty');
empty.hidden = true;
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
if (!embedded) collection.append(caption);
@@ -488,16 +478,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
});
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
/**
* The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection
* page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry,
* so a page whose shell cannot confirm the deletion is never left blank.
*/
const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error);
let reportedMissing = null;
function continuationText(next) {
if (recovering) return 'Images paused until sources and folders are available.';
if (collectionGone(next)) return '';
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
if (next.loading && !next.loaded) return 'Loading images…';
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
@@ -531,20 +513,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const added = next.loaded - previous.loaded;
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
}
const gone = collectionGone(next);
setText(message, continuationText(next));
setAttr(message, 'role', next.error && !gone ? 'alert' : 'status');
retry.hidden = !next.error || gone;
setAttr(message, 'role', next.error ? 'alert' : 'status');
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) {
reportedMissing = next.error;
queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); });
}
if (showsEmpty) {
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
}
if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
+21 -145
View File
@@ -1,6 +1,6 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
@@ -9,10 +9,8 @@ import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading, pageHeading } from './heading.mjs';
import { closeReviewDialog, closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK,
LIST_FAILED_TEXT } from './collections.mjs';
import { createActivityStore } from './activity-store.mjs';
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
import { mountActivityJobView } from './activity-job-view.mjs';
@@ -20,7 +18,7 @@ import { mountInspect } from './inspect.mjs';
import { mountReview } from './review.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
import { createRestoreReload, onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { mountLibraryBanner } from './library-location.mjs';
import { detectJxlSupport } from './thumbnail-support.mjs';
@@ -46,7 +44,6 @@ function mountWorkspace() {
const main = document.getElementById('workspace-main');
const primaryNav = document.getElementById('primary-nav');
const savedNav = document.getElementById('saved-nav');
const collectionsNav = document.getElementById('collections-nav');
const systemNav = document.getElementById('system-nav');
scope.listen(document.querySelector('.skip'), 'click', event => {
event.preventDefault();
@@ -86,9 +83,8 @@ function mountWorkspace() {
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
const sidebar = document.querySelector('aside.sidebar');
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`, a collection's `collection/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}`
: route.id === 'collection' ? `collection/${route.collectionId}` : route.id;
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
let currentKey = null;
const markCurrent = () => {
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
@@ -114,24 +110,6 @@ function mountWorkspace() {
markCurrent();
}));
savedViews.load();
// COLLECTIONS (#331 /* D136 */): the concept's collections-nav rows (folder icon, name, the listed-member count once
// read), without the "PROPOSED" badge. The label shows only while a collection is listed. A failed read keeps the
// last rows; a collection page says the list could not be loaded.
const collectionsLabel = collectionsNav.previousElementSibling;
const collections = createCollections(api, scope.signal);
scope.cleanup(collections.subscribe(({ collections: listed }) => {
collectionsLabel.hidden = listed.length === 0;
collectionsNav.replaceChildren(...listed.map(item => {
const link = element('a');
link.append(createWorkspaceIcon('folder'), document.createTextNode(item.name));
if (Number.isSafeInteger(item.count)) link.append(element('span', item.count.toLocaleString(), 'nav-count'));
link.href = collectionHash(item.id);
link.dataset.route = `collection/${item.id}`;
return link;
}));
markCurrent();
}));
collections.load();
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
onSave: name => savedViews.create(page, name, state) });
@@ -184,20 +162,12 @@ function mountWorkspace() {
refreshCounts(NAV_COUNT_ROUTES);
// S9: a finished restore replaced the library. The cached visits are dropped, the counts and saved views read
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
// #415: an open review closes (its plan no longer describes storage); while any other dialog is open, all of it
// waits until the last one has closed, so the dialog keeps what was typed.
const restoreReload = createRestoreReload(() => {
scope.cleanup(onRestoreFinished(activity, () => {
forgetImagesVisit();
forgetLibraryVisit();
refreshCounts(NAV_COUNT_ROUTES);
savedViews.load();
collections.load();
if (currentKey !== 'activity') navigateTo(window.location.hash);
});
scope.cleanup(() => restoreReload.dispose());
scope.cleanup(onRestoreFinished(activity, () => {
closeReviewDialog();
restoreReload.request();
}));
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
// it opens again over the page shown now, with Restore (again) and Discard; leaving it any other way discards it.
@@ -243,10 +213,6 @@ function mountWorkspace() {
openSavedView(route, view);
return;
}
if (route.id === 'collection') {
openCollectionPage(route, view);
return;
}
renderPage(route, view);
}
@@ -257,16 +223,14 @@ function mountWorkspace() {
if (view.signal.aborted) return;
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
// settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one.
savedViews.load();
await savedViews.settled();
await savedViews.load();
if (view.signal.aborted) return;
}
const saved = savedViews.find(route.savedId);
if (saved) {
const page = routes.find(item => item.id === saved.page);
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
mountSavedViewEdit(saved, view);
mountEditAction(saved, view);
return;
}
if (savedViews.status === 'ready') {
@@ -274,8 +238,7 @@ function mountWorkspace() {
window.location.replace('#/images');
return;
}
const {description, h1} = renderHeading(main, {title:route.label});
focusTitle(h1);
const {description} = renderHeading(main, {title:route.label});
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
description.setAttribute('role', 'alert');
const retry = createButton('Retry');
@@ -286,19 +249,13 @@ function mountWorkspace() {
main.append(retry);
}
// /* D51 */ An open sidebar item's Edit (maintainer ruling): first in the heading's actions; `open(trigger)` opens
// its dialog. Saved views and collections (D137) share it.
function mountEditAction(view, open) {
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
function mountEditAction(saved, view) {
// renderPage has just written the heading.
const edit = createButton('Edit');
pageHeading(main).actions.prepend(edit);
view.listen(edit, 'click', () => open(edit));
}
// A rename re-reads the list and retitles the open page; a delete re-reads it and replaces the dead route with the
// view's plain page.
function mountSavedViewEdit(saved, view) {
mountEditAction(view, trigger => openEditViewDialog({ scope: view, trigger, view: saved,
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
onRename: async name => {
const renamed = await savedViews.rename(saved.id, name);
if (view.signal.aborted) return;
@@ -311,106 +268,25 @@ function mountWorkspace() {
} }));
}
/** `name` becomes the h1; the breadcrumb and title too unless the page keeps its own (a collection's "Collection"). */
function retitle(name, title = name) {
function retitle(name) {
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
pageHeading(main).h1.textContent = name;
document.getElementById('workspace-breadcrumb').textContent = title;
document.title = `${title} · Prompt Manager`;
document.getElementById('workspace-breadcrumb').textContent = name;
document.title = `${name} · Prompt Manager`;
}
/**
* #331 D137: `#/collection/<id>` is Images filtered to the collection, its name the h1, "Collection" the breadcrumb
* and title (concept a1186–1189), with Edit and the note about members the sources do not list now. An unknown id
* is read once more (another tab may have made it), then shows the deleted-collection page.
*/
async function openCollectionPage(route, view) {
main.replaceChildren();
await collections.settled();
if (view.signal.aborted) return;
if (!collections.find(route.collectionId) && collections.status === 'ready') {
// settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one.
collections.load();
await collections.settled();
if (view.signal.aborted) return;
}
let collection = collections.find(route.collectionId);
const showAgain = () => { if (!view.signal.aborted) navigateTo(window.location.hash); };
if (collection) {
renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id,
// The listing found the collection gone meanwhile: the list is read again and, once it no longer lists
// the collection, the page is shown again (the deleted-collection page). A failed read, or a list that
// still holds it, leaves the listing's own error and Retry (images.mjs).
onCollectionMissing:async () => {
collections.load();
await collections.settled();
if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain();
}});
// MC3: the note follows every read of the list for the page's life; none while nothing is missing.
const note = element('p', '', 'small');
const showNote = () => {
const text = missingNote(collections.find(route.collectionId)?.missing);
note.textContent = text;
if (!text) note.remove();
else if (!note.isConnected) pageHeading(main)?.description.after(note);
};
showNote();
view.cleanup(collections.subscribe(showNote));
// The dialog's Delete question uses the latest stored count.
mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger,
collection: collection = {...collection, ...collections.find(collection.id), name:collection.name},
onRename: async name => {
const renamed = await collections.rename(collection.id, name);
if (view.signal.aborted) return;
collection = {...collection, ...renamed};
retitle(renamed.name, routeTitle(COLLECTION_ROUTE));
},
onDelete: async () => {
await collections.delete(collection.id);
window.location.replace('#/images');
},
onGone: () => collections.settled().then(showAgain) }));
return;
}
const {description, h1} = renderHeading(main, {title:route.label});
focusTitle(h1);
document.title = `${routeTitle(route)} · Prompt Manager`;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (collections.status === 'ready') {
// Deleted, or never existed: say so, with the way back to Images.
description.textContent = DELETED_PAGE_TEXT;
const link = element('a', OPEN_IMAGES_LINK, 'workspace-link');
link.href = '#/images';
main.append(link);
return;
}
description.textContent = LIST_FAILED_TEXT;
description.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', () => {
collections.load();
openCollectionPage(route, view);
});
main.append(retry);
}
/** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */
function focusTitle(h1) {
if (!firstRender) h1.focus();
firstRender = false;
}
function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) {
function renderPage(route, view, saved = null) {
document.title = `${routeTitle(route)} · Prompt Manager`;
// The concept's heading(); each view fills its description and actions.
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
const { description, h1 } = renderHeading(main, { title: route.label });
focusTitle(h1);
if (!firstRender) h1.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
if (route.id === 'images' || route.id === 'favorites') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
collection, onCollectionMissing, onSaveView:saveView('images', view), activity});
onSaveView:saveView('images', view), activity});
return;
}
if (route.id === 'library') {
+21 -74
View File
@@ -41,8 +41,6 @@ const FIELDS = Object.freeze({
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
// #331 D140: a view saved on a collection page reopens that collection ('' or 1 to 9 digits, no leading zero).
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
@@ -56,13 +54,10 @@ export function normalizeViewState(page, state) {
const fields = FIELDS[page];
if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`);
const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {};
const normalized = Object.fromEntries(Object.entries(fields).map(([key, field]) => {
return Object.fromEntries(Object.entries(fields).map(([key, field]) => {
const value = field.accepts(source[key]) ? source[key] : field.fallback;
return [key, Array.isArray(value) ? [...value] : value];
}));
// Favorites and a collection are never both a view's filter: the collection wins, as the listing does (D140).
if (normalized.favorites === true && normalized.collection) normalized.favorites = false;
return normalized;
}
/** True when the state's compact JSON fits the API's 4 KB cap (the server's encoding: no spaces, UTF-8). */
@@ -101,12 +96,11 @@ export const savedViewHash = id => `#/saved/${id}`;
export function imagesFilters(state) {
const view = normalizeViewState('images', state);
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
tags: view.tags, favorites: view.favorites, collection: view.collection };
tags: view.tags, favorites: view.favorites };
}
export function imagesViewState(filters) {
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true,
collection: filters.collection == null ? '' : String(filters.collection) });
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
}
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
@@ -221,20 +215,14 @@ export function openSaveViewDialog({ scope, trigger, q, onSave }) {
}
/**
* D51: an open sidebar item's Edit dialog, shared by saved views and collections (#331 D137) with their own nouns. The
* name field (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so
* nothing is removed by one press and no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the
* store's promise; the toast reports either outcome, and the shell navigates after a delete.
*
* `nouns`: `title`, `label`, `deleteTitle`, `deleteText(item)`, `renamed`, `renameFailed`, `deleted`, `deleteFailed`.
* By default the dialog closes before the rename is sent (saved views). `renameError(error)` instead keeps it open
* while the rename runs and maps a failure: `{field: text}` writes the message under the field and keeps the dialog,
* `{toast: text, close}` toasts it and closes the dialog only when `close` is true; null is `renameFailed`, kept open.
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so nothing is removed by one press and
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
* outcome, and the shell navigates after a delete.
*/
export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete, renameError = null }) {
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
const input = document.createElement('input');
input.value = item.name;
input.value = view.name;
input.maxLength = MAX_NAME_LENGTH;
const remove = createButton('Delete…', 'danger');
const cancel = createButton('Cancel');
@@ -242,69 +230,28 @@ export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete
const actions = document.createElement('div');
actions.className = 'dialog-actions';
actions.append(remove, cancel, save);
const message = document.createElement('p');
message.className = 'small';
message.setAttribute('role', 'alert');
message.hidden = true;
const say = text => { message.textContent = text; message.hidden = !text; };
cancel.addEventListener('click', () => closeWorkspaceDialog());
save.addEventListener('click', async () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
if (!renameError || name === item.name) closeWorkspaceDialog();
if (name === item.name) return;
if (!renameError) {
try {
await onRename(name);
showWorkspaceToast(nouns.renamed);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(nouns.renameFailed);
}
return;
}
// Kept open: one rename at a time, its outcome shown in place.
say('');
for (const button of [remove, cancel, save]) button.disabled = true;
// Closed meanwhile (Escape, Close): the outcome is only toasted, and a dialog opened since is left alone.
const stillOpen = () => input.isConnected;
closeWorkspaceDialog();
if (name === view.name) return;
try {
await onRename(name);
if (scope.signal.aborted) return;
if (stillOpen()) closeWorkspaceDialog();
showWorkspaceToast(nouns.renamed);
} catch (error) {
if (scope.signal.aborted) return;
for (const button of [remove, cancel, save]) button.disabled = false;
const outcome = renameError(error) ?? { toast: nouns.renameFailed };
if (!stillOpen()) { showWorkspaceToast(outcome.field ?? outcome.toast); return; }
if (outcome.field) { say(outcome.field); input.focus(); return; }
if (outcome.close) closeWorkspaceDialog();
showWorkspaceToast(outcome.toast);
showWorkspaceToast(RENAMED_TOAST);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
}
});
remove.addEventListener('click', () => confirmDelete({ scope, trigger, item, nouns, onDelete }));
// The message line exists only where a failure keeps the dialog open (the saved views' dialog is unchanged).
const nodes = renameError ? [field(nouns.label, input, 'action-input'), message, actions] : [field(nouns.label, input, 'action-input'), actions];
openContentDialog({ scope, trigger, title: nouns.title, nodes });
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] });
input.focus();
input.select();
}
/** The saved views' nouns for the shared Edit dialog (D51). */
export const SAVED_VIEW_NOUNS = Object.freeze({
title: 'Edit saved view', label: 'View name', deleteTitle: 'Delete saved view',
deleteText: view => `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`,
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
});
/** D51: the open view's Edit dialog. */
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
openEditDialog({ scope, trigger, item: view, nouns: SAVED_VIEW_NOUNS, onRename, onDelete });
}
function confirmDelete({ scope, trigger, item, nouns, onDelete }) {
function confirmDelete({ scope, trigger, view, onDelete }) {
const text = document.createElement('p');
text.textContent = nouns.deleteText(item);
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
const actions = document.createElement('div');
actions.className = 'dialog-actions';
const cancel = createButton('Cancel');
@@ -315,11 +262,11 @@ function confirmDelete({ scope, trigger, item, nouns, onDelete }) {
closeWorkspaceDialog();
try {
await onDelete();
showWorkspaceToast(nouns.deleted);
showWorkspaceToast(DELETED_TOAST);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(nouns.deleteFailed);
if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST);
}
});
openContentDialog({ scope, trigger, title: nouns.deleteTitle, nodes: [text, actions] });
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
cancel.focus();
}
+2 -13
View File
@@ -25,8 +25,7 @@ export const routes = [
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
];
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
'collection']);
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system']);
/**
* System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's
@@ -46,13 +45,6 @@ export function isSystemSection(id) {
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
/**
* A collection (#331 D137): `#/collection/<id>` is Images filtered to its members. Not a `routes` entry (no Overview
* card, no WORKSPACE link); the shell names the page after the collection, the breadcrumb and title stay "Collection"
* (concept render a1186–1189).
*/
export const COLLECTION_ROUTE = Object.freeze({ id: 'collection', label: 'Collection' });
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
export function routeTitle(route) {
return route.title || route.label;
@@ -66,9 +58,6 @@ export function isHandoff(route) {
export function resolveRoute(hash) {
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
// A collection id is the routes' 1 to 9 digits without a leading zero.
const collection = /^#\/collection\/([1-9]\d{0,8})$/.exec(hash);
if (collection) return {...COLLECTION_ROUTE, collectionId:Number(collection[1]), hash};
// One job of the Activity page (its id is the registry's uuid4 hex).
const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash);
if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash};
@@ -162,7 +151,7 @@ export function mountRouter(win, onRoute) {
const hash = route.hash || `#/${route.id}`;
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
current?.collectionId === route.collectionId && current?.jobId === route.jobId && current?.section === route.section) return;
current?.jobId === route.jobId && current?.section === route.section) return;
current = route;
onRoute(route);
};
-37
View File
@@ -326,43 +326,6 @@ export function onRestoreFinished(activity, onFinished) {
});
}
const OPEN_DIALOG = 'dialog[open]';
const DIALOG_MUTATIONS = Object.freeze({ subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
/**
* #415: the S9 reload after a finished restore never runs under an open dialog, which it would close with whatever
* was typed in it. `request()` runs `reload()` at once when no dialog is open; otherwise it runs once the last open
* dialog has closed (repeated requests meanwhile run it once). The dialog keeps its entries, and its next write is
* refused by the restored storage with its own note and Reload, as when this page never saw the restore. A
* MutationObserver sees every dialog close: removing `open` and removing the node are synchronous, while the dialog
* `close` event is a later task that never bubbles, and every dialog here is removed in the task that closes it.
* `dispose()` drops a waiting reload. Returns `{request, dispose}`.
*/
export function createRestoreReload(reload, { doc = globalThis.document, Observer = globalThis.MutationObserver } = {}) {
let observer = null;
const dialogOpen = () => doc.querySelector(OPEN_DIALOG) !== null;
const stop = () => {
observer?.disconnect();
observer = null;
};
return {
request() {
if (observer) return;
if (!dialogOpen()) {
reload();
return;
}
observer = new Observer(() => {
if (dialogOpen()) return;
stop();
reload();
});
observer.observe(doc.documentElement, DIALOG_MUTATIONS);
},
dispose: stop,
};
}
function surface(heading, ...nodes) {
const section = el('section', undefined, 'surface');
section.append(heading, ...nodes);
+3 -11
View File
@@ -112,13 +112,13 @@ function bindScope(scope) {
});
}
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null, review = false }) {
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) {
shell ??= createShell();
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
bindScope(scope);
// A swapped-out body is gone from the shell, so its owner releases it.
const replaced = session?.onClose;
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose, review };
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose };
replaced?.();
shell.title.textContent = heading;
shell.body.replaceChildren(...nodes);
@@ -133,14 +133,6 @@ export function closeWorkspaceDialog() {
shell = null;
}
/**
* #415: a finished restore ends an open record-changing review (`openReviewDialog`) as a dismissal, since the plan it
* reviewed no longer describes storage and it holds nothing typed. Any other dialog stays open.
*/
export function closeReviewDialog() {
if (session?.review) closeWorkspaceDialog();
}
function fillSelect(select, key, options) {
if (!Array.isArray(options) || options.length === 0 ||
options.some(pair => !Array.isArray(pair) || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')) {
@@ -505,7 +497,7 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
actions.append(cancel, start);
nodes.push(actions);
// Cancel, ×, Escape, a replacing dialog or the page leaving: everything but the primary is a dismissal.
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null, review: true });
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null });
return {
setValue(term, text) {
const dd = values.get(term);
-3
View File
@@ -12,9 +12,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */
@media (max-width: 950px) {
.app-shell { grid-template-columns: minmax(0, 1fr); /* D31 */ }
/* D136 (#331): the concept hides COLLECTIONS here, which would leave a phone no way to open a collection; its rows
wrap like every other sidebar nav (the concept's `.sidebar nav` flex row). Its label stays hidden, as all are. */
#collections-nav { display: flex; /* D136 */ }
}
@media (forced-colors: active) {
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
-3
View File
@@ -25,9 +25,6 @@
<!-- D49: the concept's SAVED VIEWS group without its "PROPOSED" badge (saved views are persisted, #249 D16). -->
<div class="sidebar-label">SAVED VIEWS</div>
<nav id="saved-nav" aria-label="Saved views"></nav>
<!-- D136: the concept's COLLECTIONS group without its "PROPOSED" badge; the label shows once a collection is listed. -->
<div class="sidebar-label" hidden>COLLECTIONS</div>
<nav id="collections-nav" aria-label="Collections"></nav>
<div class="sidebar-bottom">
<nav id="system-nav" aria-label="Tools and system"></nav>
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>