Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf232dcf7 | ||
|
|
27c395ea0d | ||
|
|
35c87d1ed5 |
+19
-6
@@ -3396,15 +3396,20 @@ document title "Review" (the concept's), heading "Metadata review" with its line
|
||||
concept's sentence, then `.gallery-layout`: the queue's concept media cards
|
||||
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
|
||||
the item, Tag opens the shared Tag image dialog with the review scope note) and
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Sampler context,
|
||||
the chosen path's prompt blocks, Leave for later and primary Apply association). The
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Link to a prompt…,
|
||||
Sampler context, the chosen path's prompt blocks, Leave for later, Leave unresolved and
|
||||
primary Apply association). The
|
||||
first item is selected on load, as the concept's first image is. Sampler context lists
|
||||
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
|
||||
unusable ones, preselects the first workable one and ends with "Leave unresolved"
|
||||
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
|
||||
unusable ones with the server's reason and preselects the first workable one
|
||||
(rulings R2/R3). Apply
|
||||
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
|
||||
under no current root posts without a capture. Both actions return to Images (R7); a
|
||||
recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
under no current root posts without a capture. Each decision and Leave for later opens
|
||||
the next item and the page stays (#536 overturns ruling R7); Leave for later writes
|
||||
nothing. Link to a prompt… (#536) records a Library prompt the reviewer chose,
|
||||
`POST /prompt_manager/review/items/{id}/link`: the capture guards of Apply, the same
|
||||
one-prompt-per-file rules, and link provenance `kind: "manual-link"` with no prompt
|
||||
sides, so it never reads as file evidence. A recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
refused decision keeps the selection and shows the server's sentence (the
|
||||
association conflict's explanation included) as `p.warning-note` in the panel; a
|
||||
restored storage or a changed queue reads the queue again; a request with no answer
|
||||
@@ -3419,6 +3424,14 @@ pending count (`GET /prompt_manager/review/items?limit=1`, its `total`).
|
||||
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
|
||||
item after the item's line, and in its job view's heading after Download report. The
|
||||
item's chip still names its state (D54), where the concept's says "Needs attention".
|
||||
- **D179** (#536, maintainer decision 2026-10-30): "Leave unresolved" is a button beside
|
||||
Leave for later, not the Sampler context select's last option; a disabled option names
|
||||
the server's reason.
|
||||
- **D180** (#536): with nothing to apply, the select, the prompt blocks and Apply are
|
||||
hidden; the panel gives the path count and each reason, and Link to a prompt… is primary.
|
||||
- **D181** (#536): the concept has no picker. Link to a prompt… opens the shared content
|
||||
dialog: Library search, the prompt's recipes as Negative prompt (one preselected,
|
||||
several must be chosen, always "No negative"), Cancel and Link image.
|
||||
- **D119**: the queue cards have no selection checkbox and
|
||||
no star (the page has no selection bar and the queue carries no favorite state); a
|
||||
file under no current root has an empty, `aria-disabled` image button and Tag; more
|
||||
|
||||
+126
-20
@@ -5,8 +5,12 @@ A decision is one row of ``media_review_decisions`` for one item revision.
|
||||
ingester's compatible-only rules (``_ingest_resolved_pair_in``) in the same
|
||||
``_pair_transaction`` as the decision row, the item's new state and revision
|
||||
and the review index revision; any conflict or later failure rolls all of it
|
||||
back. ``unresolved`` records the decision and links nothing. No decision
|
||||
counts a run, touches usage or calls an execution writer.
|
||||
back. ``manual`` (#536) is the same but for a Library prompt the reviewer chose:
|
||||
its link's provenance names a manual choice and carries no prompt sides, so it
|
||||
cannot pose as file evidence, and the decision's ``candidate_hash`` (the table
|
||||
requires one) is the digest of that choice. ``unresolved`` records the decision
|
||||
and links nothing. No decision counts a run, touches usage or calls an execution
|
||||
writer.
|
||||
|
||||
Callers verify the file first (the capture must be the item's bytes and the
|
||||
current file must still hash to them); nothing here reads the filesystem.
|
||||
@@ -17,6 +21,7 @@ import sqlite3
|
||||
|
||||
from .annotation_schema import BYTES_TABLE
|
||||
from .annotations import _new_id
|
||||
from .library import _ELIGIBLE, _FROM, SHELVED
|
||||
from .library_commands import LibraryCommandError
|
||||
from .models import utc_now_iso
|
||||
from .review import (
|
||||
@@ -30,9 +35,28 @@ from .review import (
|
||||
same_name_rows,
|
||||
)
|
||||
from .review_changes import notify_review_change
|
||||
from .review_schema import DECISIONS_TABLE, ITEMS_TABLE, canonical_evidence
|
||||
from .review_schema import (
|
||||
DECISIONS_TABLE,
|
||||
ITEMS_TABLE,
|
||||
canonical_evidence,
|
||||
evidence_digest,
|
||||
)
|
||||
|
||||
DECISION_KIND = "user-selection"
|
||||
MANUAL_KIND = "manual-link"
|
||||
_MANUAL_POSITIVE = (
|
||||
f"SELECT p.id,p.text FROM {_FROM} " # nosec B608
|
||||
f"WHERE p.id=? AND {_ELIGIBLE} AND {SHELVED}"
|
||||
)
|
||||
_MANUAL_PAIR = (
|
||||
"SELECT COALESCE(n.text,'') AS negative FROM prompt_pairs pp "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
||||
"WHERE pp.id=? AND pp.positive_prompt_id=? AND pp.archived=0"
|
||||
)
|
||||
_ARCHIVED_BARE_PAIR = (
|
||||
"SELECT 1 FROM prompt_pairs WHERE positive_prompt_id=? "
|
||||
"AND negative_prompt_id IS NULL AND archived=1"
|
||||
)
|
||||
_INSERT_DECISION = (
|
||||
f"INSERT INTO {DECISIONS_TABLE}(id,item_id,decision_revision," # nosec B608
|
||||
"created_at,outcome,candidate_hash,evidence,pair_id,image_id) "
|
||||
@@ -59,6 +83,8 @@ class ReviewCommandError(LibraryCommandError):
|
||||
``fingerprint`` the current dependency fingerprint, when known;
|
||||
``changed`` marks an association conflict caused by links that changed
|
||||
since the reviewer read them (rather than an incompatible choice).
|
||||
``prompt-missing``: the Library prompt or recipe of a manual link is gone,
|
||||
archived or not that prompt's.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -93,6 +119,7 @@ class ReviewDecisionOperations:
|
||||
sha256: str,
|
||||
outcome: str,
|
||||
candidate: dict | None = None,
|
||||
manual: dict | None = None,
|
||||
image_path: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
expected_fingerprint: str | None = None,
|
||||
@@ -101,7 +128,9 @@ class ReviewDecisionOperations:
|
||||
) -> dict:
|
||||
"""Record the decision; return ``{item, total, pair}``.
|
||||
|
||||
``selected`` needs the evidence ``candidate`` (with its ``hash``), the
|
||||
``selected`` needs the evidence ``candidate`` (with its ``hash``) or
|
||||
``manual`` (``{positive_id, pair_id}``; a null ``pair_id`` is no
|
||||
negative), the
|
||||
``image_path`` the scan and the monitor link under, the captured
|
||||
``metadata`` and the ``expected_fingerprint`` the reviewer saw.
|
||||
``aliases`` are the other spellings legacy rows of the same physical
|
||||
@@ -115,9 +144,12 @@ class ReviewDecisionOperations:
|
||||
if outcome not in ("selected", "unresolved"):
|
||||
raise ValueError("outcome must be selected or unresolved")
|
||||
if outcome == "selected" and (
|
||||
not isinstance(candidate, dict) or not image_path
|
||||
(not isinstance(candidate, dict)) == (not isinstance(manual, dict))
|
||||
or not image_path
|
||||
):
|
||||
raise ValueError("A selected decision needs a candidate and a file")
|
||||
raise ValueError(
|
||||
"A selected decision needs one candidate or manual choice and a file"
|
||||
)
|
||||
try:
|
||||
with self._pair_transaction() as conn:
|
||||
decided = self._decide_in(
|
||||
@@ -128,6 +160,7 @@ class ReviewDecisionOperations:
|
||||
sha256=sha256,
|
||||
outcome=outcome,
|
||||
candidate=candidate,
|
||||
manual=manual,
|
||||
image_path=image_path,
|
||||
metadata=metadata or {},
|
||||
expected_fingerprint=expected_fingerprint,
|
||||
@@ -158,13 +191,24 @@ class ReviewDecisionOperations:
|
||||
raise _Rollback(ReviewCommandError("file-changed", epoch))
|
||||
decision_id = _new_id()
|
||||
pair = None
|
||||
candidate = command["candidate"] if command["outcome"] == "selected" else None
|
||||
selected = command["outcome"] == "selected"
|
||||
candidate = command["candidate"] if selected else None
|
||||
manual = command["manual"] if selected else None
|
||||
candidate_hash = candidate["hash"] if candidate is not None else None
|
||||
if candidate is not None:
|
||||
pair = self._apply_candidate(
|
||||
conn, epoch, current, decision_id, total, **command
|
||||
)
|
||||
elif manual is not None:
|
||||
pair = self._apply_manual(
|
||||
conn, epoch, current, decision_id, total, **command
|
||||
)
|
||||
manual = {"positive_id": manual["positive_id"], "pair_id": pair["id"]}
|
||||
candidate_hash = evidence_digest(
|
||||
canonical_evidence({"kind": MANUAL_KIND, **manual})
|
||||
)
|
||||
evidence = {
|
||||
"kind": DECISION_KIND,
|
||||
"kind": MANUAL_KIND if manual is not None else DECISION_KIND,
|
||||
"decision_id": decision_id,
|
||||
"outcome": command["outcome"],
|
||||
"reviewed_digest": command["sha256"],
|
||||
@@ -174,6 +218,8 @@ class ReviewDecisionOperations:
|
||||
"dependency_fingerprint": command["expected_fingerprint"],
|
||||
"pair": pair,
|
||||
}
|
||||
if manual is not None:
|
||||
evidence["manual"] = manual
|
||||
now = utc_now_iso()
|
||||
self._insert_review_decision(
|
||||
conn,
|
||||
@@ -183,7 +229,7 @@ class ReviewDecisionOperations:
|
||||
current["revision"] + 1,
|
||||
now,
|
||||
command["outcome"],
|
||||
candidate["hash"] if candidate is not None else None,
|
||||
candidate_hash,
|
||||
canonical_evidence(evidence),
|
||||
pair["id"] if pair else None,
|
||||
pair["image_id"] if pair else None,
|
||||
@@ -204,6 +250,77 @@ class ReviewDecisionOperations:
|
||||
self, conn, epoch, current, decision_id, total, **command
|
||||
) -> dict:
|
||||
"""Link the candidate's pair, compatible-only; raise on any conflict."""
|
||||
candidate = command["candidate"]
|
||||
positive, negative = candidate["positive"], candidate["negative"]
|
||||
provenance = {
|
||||
"kind": DECISION_KIND,
|
||||
"decision_id": decision_id,
|
||||
"reviewed_digest": command["sha256"],
|
||||
"candidate_hash": candidate["hash"],
|
||||
"resolver_version": current["resolver_version"],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
}
|
||||
return self._link_compatible(
|
||||
conn, epoch, current, total, positive, negative, provenance, command
|
||||
)
|
||||
|
||||
def _apply_manual(self, conn, epoch, current, decision_id, total, **command):
|
||||
"""Link the chosen Library prompt, compatible-only; raise otherwise."""
|
||||
missing = _Rollback(
|
||||
ReviewCommandError("prompt-missing", epoch, item=current, total=total)
|
||||
)
|
||||
positive_id = command["manual"]["positive_id"]
|
||||
pair_id = command["manual"]["pair_id"]
|
||||
row = conn.execute(_MANUAL_POSITIVE, (positive_id,)).fetchone()
|
||||
if row is None:
|
||||
raise missing
|
||||
positive_text, negative_text = row[1], ""
|
||||
if pair_id is not None:
|
||||
chosen = conn.execute(_MANUAL_PAIR, (pair_id, positive_id)).fetchone()
|
||||
if chosen is None:
|
||||
raise missing
|
||||
negative_text = chosen[0]
|
||||
elif conn.execute(_ARCHIVED_BARE_PAIR, (positive_id,)).fetchone():
|
||||
raise missing
|
||||
provenance = {
|
||||
"kind": MANUAL_KIND,
|
||||
"decision_id": decision_id,
|
||||
"reviewed_digest": command["sha256"],
|
||||
"resolver_version": current["resolver_version"],
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
}
|
||||
# The sides name no provenance: only the ingester reads them.
|
||||
pair = self._link_compatible(
|
||||
conn,
|
||||
epoch,
|
||||
current,
|
||||
total,
|
||||
{"text": positive_text},
|
||||
{"text": negative_text},
|
||||
provenance,
|
||||
command,
|
||||
)
|
||||
# A prompt whose stored hash is stale reaches another row: never link that.
|
||||
reached = conn.execute(
|
||||
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair["id"],)
|
||||
).fetchone()[0]
|
||||
if reached != positive_id or pair_id not in (None, pair["id"]):
|
||||
raise missing
|
||||
return pair
|
||||
|
||||
def _link_compatible(
|
||||
self,
|
||||
conn,
|
||||
epoch,
|
||||
current,
|
||||
total,
|
||||
positive,
|
||||
negative,
|
||||
provenance,
|
||||
command,
|
||||
) -> dict:
|
||||
# Imported here: pair_ingestion imports operations, which composes this.
|
||||
from .pair_ingestion import _ingest_resolved_pair_in
|
||||
|
||||
@@ -242,17 +359,6 @@ class ReviewDecisionOperations:
|
||||
# Enrich the one row of this file under the spelling it was
|
||||
# linked with, so the ingester's compatibility rules see it.
|
||||
image_path = rows[0][1]
|
||||
candidate = command["candidate"]
|
||||
positive, negative = candidate["positive"], candidate["negative"]
|
||||
provenance = {
|
||||
"kind": DECISION_KIND,
|
||||
"decision_id": decision_id,
|
||||
"reviewed_digest": command["sha256"],
|
||||
"candidate_hash": candidate["hash"],
|
||||
"resolver_version": current["resolver_version"],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
}
|
||||
result = _ingest_resolved_pair_in(
|
||||
conn,
|
||||
self,
|
||||
|
||||
+59
-50
@@ -9,7 +9,10 @@ candidates and the dependency fingerprint. ``POST .../items/{id}/decision``
|
||||
records one explicit decision: Apply links the chosen sampler's pair only
|
||||
when the capture is the item's bytes, the current file still hashes to them,
|
||||
the evidence they yield is unchanged and the ingester's compatible-only rules
|
||||
allow it; Leave unresolved links nothing.
|
||||
allow it; Leave unresolved links nothing. ``POST .../items/{id}/link`` (#536)
|
||||
links a Library prompt the reviewer chose, under the same capture and
|
||||
compatibility guards; it is their choice, so what the bytes resolve to is not
|
||||
asked.
|
||||
|
||||
Targets are formatted from the recorded spelling against the current roots
|
||||
(no file access beyond the roots' real paths); a file under no current root
|
||||
@@ -28,12 +31,13 @@ import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .annotations import _HEX64, _TOKEN, _UUID, _capture_error, _error
|
||||
from .annotations import _capture_error, _error
|
||||
from .images import media_thumbnail_url
|
||||
from .inspection_service import VIDEO_EXTENSIONS
|
||||
from .job_routes import busy_response
|
||||
from .jobs import JobBusy
|
||||
from .prompts import bad_request
|
||||
from .review_guards import UNRESOLVED, _decision_guards, _link_guards
|
||||
|
||||
try:
|
||||
from ...database.library import display_title
|
||||
@@ -59,17 +63,6 @@ MAX_CURSOR = 4096
|
||||
MAX_BODY_BYTES = 64 * 1024
|
||||
# The serialized detail stays under this; past it the detail is ``limited``.
|
||||
MAX_DETAIL_BYTES = 8 * 1024 * 1024
|
||||
UNRESOLVED = "unresolved"
|
||||
_DECISION_FIELDS = frozenset(
|
||||
{
|
||||
"expected_epoch",
|
||||
"expected_revision",
|
||||
"capture_token",
|
||||
"sha256",
|
||||
"dependency_fingerprint",
|
||||
"choice",
|
||||
}
|
||||
)
|
||||
_CURSOR_SECRET = os.urandom(32)
|
||||
# The real path of a spelling; module-level so a test can fake a junction.
|
||||
_realpath = os.path.realpath
|
||||
@@ -84,6 +77,7 @@ _STATUS = {
|
||||
"storage-changed": 409,
|
||||
"source-changed": 409,
|
||||
"capture-expired": 409,
|
||||
"prompt-missing": 409,
|
||||
}
|
||||
_MESSAGES = {
|
||||
"review-missing": "This review item no longer exists. Reload the queue.",
|
||||
@@ -95,6 +89,9 @@ _MESSAGES = {
|
||||
"sampler path cannot be attached safely. The existing links were "
|
||||
"preserved."
|
||||
),
|
||||
"prompt-missing": (
|
||||
"That prompt or recipe is no longer in the Library. Search again."
|
||||
),
|
||||
"dependencies-changed": (
|
||||
"The image's saved links changed since this item was opened. Reopen it "
|
||||
"to see the current association."
|
||||
@@ -251,6 +248,7 @@ class ReviewRoutesMixin:
|
||||
routes.get(ITEMS_URL)(self.list_review_items)
|
||||
routes.get(ITEMS_URL + "/{item_id}")(self.get_review_item)
|
||||
routes.post(ITEMS_URL + "/{item_id}/decision")(self.decide_review_item)
|
||||
routes.post(ITEMS_URL + "/{item_id}/link")(self.link_review_item)
|
||||
|
||||
# -- shared ----------------------------------------------------------------
|
||||
|
||||
@@ -464,6 +462,43 @@ class ReviewRoutesMixin:
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def link_review_item(self, request: web.Request) -> web.Response:
|
||||
"""POST the Library prompt the reviewer chose for one pending item.
|
||||
|
||||
Under the same lease as a decision. The capture must be the item's
|
||||
current bytes; the choice and the file's links are checked in the
|
||||
decision's transaction.
|
||||
"""
|
||||
try:
|
||||
async with self._jobs.lease(REVIEW_DECISION_LEASE):
|
||||
return await self._link_review_item(request)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def _link_review_item(self, request: web.Request) -> web.Response:
|
||||
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||
if error is not None:
|
||||
return error
|
||||
guards, error = _link_guards(data)
|
||||
if error is not None:
|
||||
return error
|
||||
try:
|
||||
item = await self._run_in_executor(
|
||||
self.db.review_item, request.match_info["item_id"]
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Review item read failed")
|
||||
return _failed()
|
||||
if item is None:
|
||||
return _coded("review-missing")
|
||||
verified, error = await self._verified_review_capture(
|
||||
item, guards, need_metadata=True
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
manual = {"positive_id": guards["positive_id"], "pair_id": guards["pair_id"]}
|
||||
return await self._record_decision(item, guards, None, verified, manual)
|
||||
|
||||
async def _decide_review_item(self, request: web.Request) -> web.Response:
|
||||
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||
if error is not None:
|
||||
@@ -549,22 +584,25 @@ class ReviewRoutesMixin:
|
||||
return None, _coded("file-changed")
|
||||
metadata = None
|
||||
if need_metadata:
|
||||
if captured is None or not _candidate_still_offered(
|
||||
captured[1], guards["choice"]
|
||||
# A manual link has no ``choice``: it rests on the bytes alone.
|
||||
if captured is None or (
|
||||
"choice" in guards
|
||||
and not _candidate_still_offered(captured[1], guards["choice"])
|
||||
):
|
||||
return None, _coded("review-conflict", "evidence-changed")
|
||||
metadata = captured[0]
|
||||
image_path = os.path.join(str(root), *relative.split("/"))
|
||||
return (capture, facts, image_path, metadata), None
|
||||
|
||||
def _decide_sync(self, item, guards, candidate, verified):
|
||||
def _decide_sync(self, item, guards, candidate, verified, manual=None):
|
||||
"""Worker thread: resolve the file's spellings, decide, build the view.
|
||||
|
||||
Returns ``(result, view)``; a refusal raises with ``exc.view`` set.
|
||||
"""
|
||||
_capture, facts, image_path, metadata = verified or (None, None, None, None)
|
||||
aliases, known = (), None
|
||||
if candidate is not None:
|
||||
selected = candidate is not None or manual is not None
|
||||
if selected:
|
||||
aliases, known = physical_spellings(self.db, image_path, item["locator"])
|
||||
try:
|
||||
result = self.db.decide_review_item(
|
||||
@@ -573,8 +611,9 @@ class ReviewRoutesMixin:
|
||||
expected_revision=guards["expected_revision"],
|
||||
# The bytes the re-hash proved, or the body's for Leave unresolved.
|
||||
sha256=facts.sha256 if facts is not None else guards["sha256"],
|
||||
outcome="selected" if candidate is not None else UNRESOLVED,
|
||||
outcome="selected" if selected else UNRESOLVED,
|
||||
candidate=candidate,
|
||||
manual=manual,
|
||||
image_path=image_path,
|
||||
metadata=metadata,
|
||||
expected_fingerprint=guards["dependency_fingerprint"],
|
||||
@@ -589,11 +628,11 @@ class ReviewRoutesMixin:
|
||||
roots, real_roots = self._review_roots()
|
||||
return result, self._review_view(result["item"], roots, real_roots)
|
||||
|
||||
async def _record_decision(self, item, guards, candidate, verified):
|
||||
async def _record_decision(self, item, guards, candidate, verified, manual=None):
|
||||
capture = verified[0] if verified else None
|
||||
try:
|
||||
result, view = await self._run_in_executor(
|
||||
self._decide_sync, item, guards, candidate, verified
|
||||
self._decide_sync, item, guards, candidate, verified, manual
|
||||
)
|
||||
except ReviewCommandError as exc:
|
||||
return self._decision_refused(exc)
|
||||
@@ -629,36 +668,6 @@ class ReviewRoutesMixin:
|
||||
return _coded(exc.code, message, **extra)
|
||||
|
||||
|
||||
def _decision_guards(data: dict):
|
||||
"""The validated decision fields, or ``(None, error)``."""
|
||||
if set(data) != _DECISION_FIELDS:
|
||||
return None, bad_request(
|
||||
"Request must contain exactly " + ", ".join(sorted(_DECISION_FIELDS))
|
||||
)
|
||||
epoch, revision = data["expected_epoch"], data["expected_revision"]
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return None, bad_request("expected_epoch must be a canonical UUID")
|
||||
if type(revision) is not int or revision < 0:
|
||||
return None, bad_request("expected_revision must be a non-negative integer")
|
||||
token = data["capture_token"]
|
||||
if token is not None and (
|
||||
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
|
||||
):
|
||||
return None, bad_request("Invalid capture token")
|
||||
for name in ("sha256", "dependency_fingerprint"):
|
||||
value = data[name]
|
||||
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
|
||||
return None, bad_request(
|
||||
f"{name} must be 64 lowercase hexadecimal characters"
|
||||
)
|
||||
choice = data["choice"]
|
||||
if not isinstance(choice, str) or (
|
||||
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
|
||||
):
|
||||
return None, bad_request("choice must be a candidate hash or unresolved")
|
||||
return {**data}, None
|
||||
|
||||
|
||||
def _verify_and_read(store, capture, item, root, need_metadata: bool):
|
||||
"""Worker thread: ``(relative, facts, captured)``, or None off the root.
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Body validation of Needs review decisions and manual links (#249 E3, #536)."""
|
||||
|
||||
from .annotations import _HEX64, _TOKEN, _UUID
|
||||
from .prompts import bad_request
|
||||
|
||||
UNRESOLVED = "unresolved"
|
||||
_GUARD_FIELDS = frozenset(
|
||||
{
|
||||
"expected_epoch",
|
||||
"expected_revision",
|
||||
"capture_token",
|
||||
"sha256",
|
||||
"dependency_fingerprint",
|
||||
}
|
||||
)
|
||||
_DECISION_FIELDS = _GUARD_FIELDS | {"choice"}
|
||||
_LINK_FIELDS = _GUARD_FIELDS | {"positive_id", "pair_id"}
|
||||
|
||||
|
||||
def _guard_error(data: dict, fields: frozenset):
|
||||
"""The first problem with a decision's shared fields, as a response."""
|
||||
if set(data) != fields:
|
||||
return bad_request("Request must contain exactly " + ", ".join(sorted(fields)))
|
||||
epoch, revision = data["expected_epoch"], data["expected_revision"]
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return bad_request("expected_epoch must be a canonical UUID")
|
||||
if type(revision) is not int or revision < 0:
|
||||
return bad_request("expected_revision must be a non-negative integer")
|
||||
token = data["capture_token"]
|
||||
if token is not None and (
|
||||
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
|
||||
):
|
||||
return bad_request("Invalid capture token")
|
||||
for name in ("sha256", "dependency_fingerprint"):
|
||||
value = data[name]
|
||||
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
|
||||
return bad_request(f"{name} must be 64 lowercase hexadecimal characters")
|
||||
return None
|
||||
|
||||
|
||||
def _decision_guards(data: dict):
|
||||
"""The validated decision fields, or ``(None, error)``."""
|
||||
error = _guard_error(data, _DECISION_FIELDS)
|
||||
if error is not None:
|
||||
return None, error
|
||||
choice = data["choice"]
|
||||
if not isinstance(choice, str) or (
|
||||
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
|
||||
):
|
||||
return None, bad_request("choice must be a candidate hash or unresolved")
|
||||
return {**data}, None
|
||||
|
||||
|
||||
def _link_guards(data: dict):
|
||||
"""The validated manual link fields, or ``(None, error)``."""
|
||||
error = _guard_error(data, _LINK_FIELDS)
|
||||
if error is not None:
|
||||
return None, error
|
||||
if data["capture_token"] is None:
|
||||
return None, bad_request("A link needs a capture of the file")
|
||||
positive, pair = data["positive_id"], data["pair_id"]
|
||||
if type(positive) is not int or positive < 1:
|
||||
return None, bad_request("positive_id must be a positive integer")
|
||||
if pair is not None and (type(pair) is not int or pair < 1):
|
||||
return None, bad_request("pair_id must be a positive integer or null")
|
||||
return {**data}, None
|
||||
@@ -26,6 +26,10 @@ async function openReview(page, theme) {
|
||||
await openProduction(page, 'review', theme);
|
||||
await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
|
||||
await settle(page);
|
||||
// D179, D181: the two buttons the concept lacks are pinned here, then left out of the comparison.
|
||||
await expect(panel(page).getByRole('button', { name: 'Leave unresolved', exact: true })).toBeVisible();
|
||||
await expect(panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true })).toBeVisible();
|
||||
await page.addStyleTag({ content: '.review-panel .review-leave-unresolved, .review-panel .review-link-open { display: none !important; }' });
|
||||
}
|
||||
async function settle(page) {
|
||||
await page.mouse.move(0, 0);
|
||||
@@ -90,7 +94,7 @@ for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
expect(surface.children[1].text).toBe(CONCEPT_FILE);
|
||||
const actions = surface.children.at(-1);
|
||||
expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]);
|
||||
expect(node(surface, 'select').options.at(-1)).toEqual(['Leave unresolved', false]);
|
||||
expect(node(surface, 'select').options.map(option => option[0])).not.toContain('Leave unresolved'); // D179
|
||||
expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text))
|
||||
.toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']);
|
||||
expect(actual.noPageScroll).toBe(true);
|
||||
@@ -169,7 +173,7 @@ test('the comparison notices a dropped notice border and reordered actions', asy
|
||||
const reordered = await productionInventory(page);
|
||||
expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([
|
||||
'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary']));
|
||||
await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild));
|
||||
await panel(page).locator('.dialog-actions').evaluate(row => row.append(row.firstElementChild));
|
||||
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ export function mapConceptReview(reference,actual) {
|
||||
});
|
||||
main=editNodes(main,'select',select=>{
|
||||
const twin=findNode(actual.main,'select');
|
||||
return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select;
|
||||
return select.id==='review-context'?{...select,options:twin.options}:select; // D179: no Leave unresolved option
|
||||
});
|
||||
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
|
||||
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
|
||||
|
||||
@@ -15,8 +15,11 @@ const cards = page => main(page).locator('.review-queue article.media-card');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const NOTICE = 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.';
|
||||
const EMPTY = 'Ambiguous images are added when a metadata scan or a new generation finds more than one sampler path.';
|
||||
const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur',
|
||||
'Leave unresolved'];
|
||||
const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur'];
|
||||
|
||||
const leaveUnresolved = page => panel(page).getByRole('button', { name: 'Leave unresolved', exact: true });
|
||||
const linkButton = page => panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true });
|
||||
const linkDialog = page => page.getByRole('dialog', { name: 'Link to a prompt', exact: true });
|
||||
|
||||
async function seed(request, items = 3, special = {}) {
|
||||
const response = await request.post('/__test__/review/reset', { data: { items, ...special } });
|
||||
@@ -44,7 +47,7 @@ const warning = page => panel(page).locator('.warning-note');
|
||||
/** The page with its first item's sampler paths loaded. */
|
||||
async function open(page) {
|
||||
await page.goto(review);
|
||||
await expect(context(page)).toBeEnabled();
|
||||
await expect(leaveUnresolved(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
@@ -80,8 +83,6 @@ test('queue shows persisted items and the nav count', async ({ page, request })
|
||||
await expect(panel(page).locator('.prompt-section.negative .prompt-text')).toHaveText('Soft negative, blur');
|
||||
await context(page).selectOption({ label: OPTIONS[1] });
|
||||
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Harbor lights at night');
|
||||
await context(page).selectOption({ label: 'Leave unresolved' });
|
||||
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Multiple possible prompts. Source could not be determined.');
|
||||
// Info selects another item.
|
||||
await cards(page).nth(1).getByRole('button', { name: 'Inspect review_0001.png', exact: true }).click();
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
@@ -105,7 +106,9 @@ test('apply candidate links the pair and the item leaves the queue and the count
|
||||
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
|
||||
expect((await decided).status()).toBe(200);
|
||||
await expect(toast(page)).toHaveText('Association accepted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
// #536: the page stays and the next item opens.
|
||||
await expect(page).toHaveURL(/#\/review$/);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
await expect(reviewRow(page)).toHaveText('Needs review1');
|
||||
expect(await pending(request)).toBe(1);
|
||||
// The first sampler path's pair is linked to that file, and only to it.
|
||||
@@ -135,20 +138,20 @@ test('conflict leaves links unchanged and explains', async ({ page, request }) =
|
||||
expect(await pending(request)).toBe(1);
|
||||
await expect(reviewRow(page)).toHaveText('Needs review1');
|
||||
// The file can still be left unresolved, which links nothing either.
|
||||
await context(page).selectOption({ label: 'Leave unresolved' });
|
||||
await apply(page).click();
|
||||
await leaveUnresolved(page).click();
|
||||
await expect(toast(page)).toHaveText('Left unresolved.');
|
||||
expect(await links(request)).toEqual(before);
|
||||
expect(await pending(request)).toBe(0);
|
||||
});
|
||||
|
||||
test('a limited item and an unavailable file can only be left unresolved', async ({ page, request }) => {
|
||||
test('a limited item can be linked or left unresolved; an unavailable file only left unresolved', async ({ page, request }) => {
|
||||
await seed(request, 0, { limited: true, unavailable: true });
|
||||
await open(page);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('limited.png');
|
||||
await expect(context(page).locator('option')).toHaveText(['Leave unresolved']);
|
||||
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. You can leave it unresolved.');
|
||||
await expect(context(page)).toBeHidden();
|
||||
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. Link it to a prompt or leave it unresolved.');
|
||||
await expect(linkButton(page)).toHaveClass(/ca-primary/);
|
||||
// The unavailable file: no current root lists it, so its media and Tag are inert.
|
||||
const unavailable = cards(page).nth(1);
|
||||
await expect(unavailable.locator('.image-open')).toHaveAttribute('aria-disabled', 'true');
|
||||
@@ -166,7 +169,8 @@ test('a limited item and an unavailable file can only be left unresolved', async
|
||||
await expect(panel(page).getByRole('button', { name: 'Tag image', exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
const observes = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/observe')) observes.push(item.url()); });
|
||||
await apply(page).click();
|
||||
await expect(linkButton(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await leaveUnresolved(page).click();
|
||||
await expect(toast(page)).toHaveText('Left unresolved.');
|
||||
expect(observes).toEqual([]);
|
||||
expect(await pending(request)).toBe(1);
|
||||
@@ -175,20 +179,20 @@ test('a limited item and an unavailable file can only be left unresolved', async
|
||||
test('leave unresolved removes the item from pending', async ({ page, request }) => {
|
||||
await seed(request, 2);
|
||||
await open(page);
|
||||
await context(page).selectOption({ label: 'Leave unresolved' });
|
||||
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
|
||||
await leaveUnresolved(page).click();
|
||||
await expect(toast(page)).toHaveText('Left unresolved.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(page).toHaveURL(/#\/review$/);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
expect(await pending(request)).toBe(1);
|
||||
});
|
||||
|
||||
test('leave for later writes nothing and goes to Images', async ({ page, request }) => {
|
||||
test('leave for later writes nothing and opens the next item', async ({ page, request }) => {
|
||||
await seed(request, 2);
|
||||
await open(page);
|
||||
const writes = [];
|
||||
page.on('request', item => { if (item.method() !== 'GET') writes.push(item.url()); });
|
||||
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
expect(writes).toEqual([]);
|
||||
expect(await pending(request)).toBe(2);
|
||||
});
|
||||
@@ -332,7 +336,7 @@ test('keyboard focus labels and touch targets', async ({ page, request }) => {
|
||||
const order = await panel(page).evaluate(node => [...node.querySelectorAll('button, select')]
|
||||
.filter(control => control.offsetParent !== null).map(control => control.getAttribute('aria-label') || control.textContent.trim()));
|
||||
expect(order[0]).toBe('Tag image');
|
||||
expect(order.slice(-2)).toEqual(['Leave for later', 'Apply association']);
|
||||
expect(order.slice(-3)).toEqual(['Leave for later', 'Leave unresolved', 'Apply association']);
|
||||
// Targets: the panel's controls are 40px on a coarse pointer (the concept's coarse buttons) and 24px otherwise (the
|
||||
// WCAG 2.2 minimum); the card's buttons keep the concept's compact size, never under 24px (its ≤700px rule).
|
||||
const coarse = await page.evaluate(() => matchMedia('(pointer: coarse)').matches);
|
||||
@@ -350,10 +354,10 @@ test('keyboard focus labels and touch targets', async ({ page, request }) => {
|
||||
expect(box.height, label).toBeGreaterThanOrEqual(minimum);
|
||||
expect(box.width, label).toBeGreaterThanOrEqual(24);
|
||||
}
|
||||
// Enter on Leave for later returns to Images.
|
||||
// Enter on Leave for later stays on the page; the only item stays open.
|
||||
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(page).toHaveURL(/#\/review$/);
|
||||
});
|
||||
|
||||
test('an open page shows items a scan adds and keeps the open choice', async ({ page, request }) => {
|
||||
@@ -391,9 +395,8 @@ test('a decision in another tab leaves the open page and opens the next image',
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
const other = await browserContext.newPage();
|
||||
await open(other);
|
||||
await context(other).selectOption({ label: 'Leave unresolved' });
|
||||
await apply(other).click();
|
||||
await expect(other).toHaveURL(/#\/images$/);
|
||||
await leaveUnresolved(other).click();
|
||||
await expect(toast(other)).toHaveText('Left unresolved.');
|
||||
await other.close();
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
@@ -613,3 +616,62 @@ test('teardown releases captures listeners and requests', async ({ page, request
|
||||
await closedStreamsDropped(request);
|
||||
await expect.poll(() => inFlight.size).toBe(0);
|
||||
});
|
||||
|
||||
test('#536: nothing selectable says why and links through the picker, then the next item opens', async ({ page, request }) => {
|
||||
await seed(request, 1, { unreadable: true });
|
||||
await open(page);
|
||||
await cards(page).nth(1).getByRole('button', { name: 'Inspect unreadable.png', exact: true }).click();
|
||||
await expect(panel(page)).toContainText("None of this file's 2 sampler paths could be traced to a complete prompt");
|
||||
await expect(panel(page)).toContainText('Unsupported latent ancestry (2 paths)');
|
||||
await expect(context(page)).toBeHidden();
|
||||
await expect(panel(page).getByRole('button', { name: 'Apply association', exact: true })).toBeHidden();
|
||||
await expect(panel(page)).not.toContainText('Multiple possible prompts');
|
||||
await expect(linkButton(page)).toHaveClass(/ca-primary/);
|
||||
|
||||
await linkButton(page).click();
|
||||
const dialog = linkDialog(page);
|
||||
await expect(dialog).toContainText("This records your choice; the file's metadata does not confirm it.");
|
||||
const submit = dialog.getByRole('button', { name: 'Link image', exact: true });
|
||||
await expect(submit).toHaveAttribute('aria-disabled', 'true');
|
||||
await dialog.getByRole('searchbox').fill('harbor');
|
||||
const harbor = dialog.getByRole('button', { name: 'Quiet harbor', exact: true });
|
||||
await expect(harbor).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Golden dunes at dusk', exact: true })).toHaveCount(0);
|
||||
await harbor.click();
|
||||
// Several recipes: the reviewer must choose.
|
||||
const negative = dialog.getByRole('combobox', { name: 'Negative prompt', exact: true });
|
||||
await expect(negative.locator('option')).toHaveText(['Choose a negative…', 'washed out', 'grainy', 'No negative']);
|
||||
await expect(submit).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(submit).toHaveCSS('opacity', '0.55');
|
||||
await expect(harbor).toHaveAttribute('aria-pressed', 'true');
|
||||
await negative.selectOption({ label: 'grainy' });
|
||||
const linked = page.waitForResponse(item => /\/link$/.test(item.url()));
|
||||
await submit.click();
|
||||
expect((await linked).status()).toBe(200);
|
||||
await expect(toast(page)).toHaveText('Linked to Quiet harbor.');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/#\/review$/);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0000.png');
|
||||
expect(await pending(request)).toBe(1);
|
||||
const link = (await links(request)).find(row => row.filename === 'unreadable.png');
|
||||
expect([link.positive, link.negative]).toEqual(['Quiet harbor', 'grainy']);
|
||||
});
|
||||
|
||||
test('#536: a refused link stays in the dialog with the server\'s sentence', async ({ page, request }) => {
|
||||
await seed(request, 0, { unreadable: true });
|
||||
await open(page);
|
||||
await linkButton(page).click();
|
||||
const dialog = linkDialog(page);
|
||||
await dialog.getByRole('button', { name: 'Golden dunes at dusk', exact: true }).click();
|
||||
// One recipe: preselected and visible.
|
||||
const negative = dialog.getByRole('combobox', { name: 'Negative prompt', exact: true });
|
||||
await expect(negative).toHaveValue(/^[0-9]+$/);
|
||||
await page.route(/\/link$/, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, code: 'association-conflict', error: 'This image is already linked.' }) }));
|
||||
await dialog.getByRole('button', { name: 'Link image', exact: true }).click();
|
||||
await expect(dialog.locator('.warning-note')).toHaveText('This image is already linked.');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await pending(request)).toBe(1);
|
||||
expect(await links(request)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,10 @@ after the plain ones in this order:
|
||||
recorder stores ``limited`` (more than 16 candidates, so none are stored).
|
||||
An A1111 block with two negative lines is not queued any more: it offers
|
||||
no candidate to choose (#284).
|
||||
- ``unreadable`` (#536): an item whose stored candidates are all
|
||||
``unresolved``, as items recorded before the choice rule are, written directly
|
||||
(the recorder no longer stores one). The Library holds "Golden dunes at
|
||||
dusk" with its negative, and "Quiet harbor" with two.
|
||||
- ``unavailable``: a file recorded under the ``temp`` directory while it was a
|
||||
gallery root; the root is then removed again, so no current root lists it.
|
||||
|
||||
@@ -41,6 +45,7 @@ page closed is dropped at such a write.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
@@ -58,10 +63,11 @@ if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
POSITIVES = ("Golden dunes at dusk", "Harbor lights at night")
|
||||
UNREADABLE_REASON = "Unsupported latent ancestry"
|
||||
NEGATIVE = "Soft negative, blur"
|
||||
# The prompt the conflict item's file is linked to after it was queued.
|
||||
FALLBACK = "Misty forest path"
|
||||
SPECIAL = ("conflict", "limited", "unavailable")
|
||||
SPECIAL = ("conflict", "limited", "unavailable", "unreadable")
|
||||
# The concept's first three images (concept app.js `images`) and first prompt.
|
||||
CONCEPT_IMAGES = (
|
||||
("alpine_00001.png", (1536, 1152)),
|
||||
@@ -215,6 +221,38 @@ def _unavailable(api: PromptManagerAPI, root: Path, path: Path) -> None:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots
|
||||
|
||||
|
||||
def _unreadable(api: PromptManagerAPI, path: Path) -> None:
|
||||
"""Queue ``path`` with candidates no reviewer can apply, and Library prompts."""
|
||||
facts = path.stat()
|
||||
spelled = os.path.join(os.path.realpath(path.parent), path.name)
|
||||
candidates = [
|
||||
{
|
||||
"hash": hashlib.sha256(f"unreadable-{sampler}".encode()).hexdigest(),
|
||||
"sampler_id": sampler,
|
||||
"state": "unresolved",
|
||||
"reason": UNREADABLE_REASON,
|
||||
"positive": None,
|
||||
"negative": None,
|
||||
}
|
||||
for sampler in ("11", "13")
|
||||
]
|
||||
api.db.record_review_item(
|
||||
locator=os.path.normcase(spelled),
|
||||
spelled_path=spelled,
|
||||
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
size=facts.st_size,
|
||||
mtime_ns=facts.st_mtime_ns,
|
||||
epoch=api.db.get_execution_epoch(),
|
||||
resolver_version=1,
|
||||
evidence={"reason": "", "resolver_version": 1, "candidates": candidates},
|
||||
candidate_count=len(candidates),
|
||||
limited=False,
|
||||
)
|
||||
api.db.save_prompt_pair(POSITIVES[0], NEGATIVE)
|
||||
api.db.save_prompt_pair("Quiet harbor", "grainy")
|
||||
api.db.save_prompt_pair("Quiet harbor", "washed out")
|
||||
|
||||
|
||||
def seed_review(
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
@@ -254,6 +292,9 @@ def seed_review(
|
||||
path = (root / "temp" if kind == "unavailable" else output) / relative
|
||||
if kind == "conflict":
|
||||
_linked_after_queueing(api, path)
|
||||
elif kind == "unreadable":
|
||||
_png(path, (70, 40, 120))
|
||||
_unreadable(api, path)
|
||||
elif kind == "limited":
|
||||
many = tuple(f"{POSITIVES[0]} {n}" for n in range(LIMITED_SAMPLERS))
|
||||
_png(path, (90, 90, 90), graph=_graph(positives=many))
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"POST /prompt_manager/restore/apply",
|
||||
"POST /prompt_manager/restore/preview",
|
||||
"POST /prompt_manager/review/items/{item_id}/decision",
|
||||
"POST /prompt_manager/review/items/{item_id}/link",
|
||||
"POST /prompt_manager/save",
|
||||
"POST /prompt_manager/saved-views",
|
||||
"POST /prompt_manager/scan",
|
||||
|
||||
@@ -128,9 +128,7 @@ test('selecting a card loads detail and option labels, first workable preselecte
|
||||
['Sampler 9 · No positive / Soft negative', true],
|
||||
['Sampler 12 · Dunes / Soft negative', false],
|
||||
['Sampler 28 · Dunes / Harsh negative', false],
|
||||
['Leave unresolved', false],
|
||||
]);
|
||||
assert.equal(c.state.options.at(-1).value, 'unresolved');
|
||||
assert.equal(c.state.choice, HASH_A);
|
||||
assert.deepEqual(c.state.prompts, { positive: { status: 'resolved', text: 'golden dunes' }, negative: { status: 'resolved', text: 'blur' } });
|
||||
c.choose(HASH_C);
|
||||
@@ -138,16 +136,17 @@ test('selecting a card loads detail and option labels, first workable preselecte
|
||||
c.choose(HASH_B);
|
||||
assert.equal(c.state.prompts.negative.text, 'noise');
|
||||
c.choose('unresolved');
|
||||
assert.equal(c.state.prompts.positive.status, 'ambiguous');
|
||||
assert.equal(c.state.choice, HASH_B, 'Leave unresolved is its own action');
|
||||
await c.select('two');
|
||||
await settle();
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.deepEqual(c.state.options.map(option => option.label), ['Leave unresolved']);
|
||||
assert.equal(c.state.choice, 'unresolved');
|
||||
assert.deepEqual(c.state.options, []);
|
||||
assert.equal(c.state.choice, null);
|
||||
assert.equal(c.state.unreadable, null, 'a limited file keeps its own warning');
|
||||
assert.match(c.state.warning, /too many sampler paths/i);
|
||||
});
|
||||
|
||||
test('apply sends the selected candidate hash or unresolved with a fresh capture', async () => {
|
||||
test('apply sends the selected candidate hash with a fresh capture and the next item opens', async () => {
|
||||
const { c, h } = await loaded();
|
||||
c.choose(HASH_B);
|
||||
const done = await c.apply();
|
||||
@@ -157,31 +156,105 @@ test('apply sends the selected candidate hash or unresolved with a fresh capture
|
||||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.deepEqual(decision.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
|
||||
dependency_fingerprint: PRINT, choice: HASH_B });
|
||||
assert.equal(c.state.phase, 'done');
|
||||
assert.equal(c.state.total, 1);
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.toast, 'Association accepted.');
|
||||
assert.equal(c.state.decisions, 1);
|
||||
// The server releases a used capture itself.
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0);
|
||||
|
||||
const again = await loaded();
|
||||
again.c.choose('unresolved');
|
||||
assert.equal(await again.c.apply(), true);
|
||||
const [unresolved] = again.h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(unresolved.body.choice, 'unresolved');
|
||||
assert.equal(unresolved.body.capture_token, 'capture-1');
|
||||
assert.equal(again.c.state.toast, 'Left unresolved.');
|
||||
});
|
||||
|
||||
test('an unavailable file can only be left unresolved, without a capture', async () => {
|
||||
test('leave unresolved is its own action', async () => {
|
||||
const { c, h } = await loaded();
|
||||
assert.equal(await c.leaveUnresolved(), true);
|
||||
const [unresolved] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(unresolved.body.choice, 'unresolved');
|
||||
assert.equal(unresolved.body.capture_token, 'capture-1');
|
||||
assert.equal(c.state.toast, 'Left unresolved.');
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
});
|
||||
|
||||
const unreadable = () => detailOf(QUEUE[0], { candidates: [
|
||||
{ ...candidate(HASH_A, '9', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
|
||||
{ ...candidate(HASH_B, '12', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
|
||||
{ ...candidate(HASH_C, '14', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported node' },
|
||||
] });
|
||||
|
||||
test('nothing selectable: the reasons are summed, there is no choice and apply sends nothing', async () => {
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
|
||||
assert.equal(c.state.choice, null);
|
||||
assert.equal(c.state.prompts, null);
|
||||
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
|
||||
['Sampler 9 · No positive / No negative (Unsupported latent ancestry)', true],
|
||||
['Sampler 12 · No positive / No negative (Unsupported latent ancestry)', true],
|
||||
['Sampler 14 · No positive / No negative (Unsupported node)', true],
|
||||
]);
|
||||
assert.deepEqual(c.state.unreadable, { count: 3, reasons: [{ reason: 'Unsupported latent ancestry', count: 2 }, { reason: 'Unsupported node', count: 1 }] });
|
||||
assert.equal(await c.apply(), false);
|
||||
assert.equal(h.of('POST', '/').length, 0);
|
||||
});
|
||||
|
||||
const linkRoute = (body = {}) => ({ [`POST ${ITEMS}/one/link`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
|
||||
item: { ...QUEUE[0], state: 'selected', revision: 1 }, pair: { id: call.body.pair_id ?? 7, image_id: 3, status: 'added' }, ...body }) });
|
||||
|
||||
test('link posts the chosen prompt and recipe with a fresh capture, then the next item opens', async () => {
|
||||
const { c, h } = await loaded({ ...linkRoute(), [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
|
||||
const [link] = h.of('POST', `${ITEMS}/one/link`);
|
||||
assert.deepEqual(link.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
|
||||
dependency_fingerprint: PRINT, positive_id: 4, pair_id: 9 });
|
||||
assert.equal(c.state.toast, 'Linked to Harbor.');
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.decisions, 1);
|
||||
});
|
||||
|
||||
test('a refused link keeps the item, releases the capture and names the reason', async () => {
|
||||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => fail(409, 'association-conflict', 'This image is already linked') });
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: null, title: 'Harbor' }), false);
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.equal(c.state.warning, 'This image is already linked');
|
||||
assert.equal(c.state.decisions, 0);
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||||
});
|
||||
|
||||
test('an unanswered link resends the same body once; another choice takes a new capture', async () => {
|
||||
let answers = 0;
|
||||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => {
|
||||
answers += 1;
|
||||
return answers === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, total: 1 });
|
||||
} });
|
||||
assert.equal(await c.link({ itemId: 'two', positiveId: 4, pairId: 9, title: 'Harbor' }), false, 'not the open item');
|
||||
assert.equal(h.of('POST', OBSERVE).length, 0);
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), false);
|
||||
assert.match(c.state.warning, /Link image again/);
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
|
||||
const [first, second] = h.of('POST', `${ITEMS}/one/link`);
|
||||
assert.deepEqual(first.body, second.body);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
});
|
||||
|
||||
test('the last decision empties the page and a later page still comes back from the re-read', async () => {
|
||||
let queue = [QUEUE[0]];
|
||||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
|
||||
queue = [];
|
||||
assert.equal(await c.leaveUnresolved(), true);
|
||||
await settle();
|
||||
assert.equal(c.state.selectedId, null);
|
||||
assert.deepEqual(c.state.items, []);
|
||||
assert.equal(c.state.warning, '');
|
||||
});
|
||||
|
||||
test('an unavailable file can only be left unresolved, without a capture or a link', async () => {
|
||||
const gone = item('one', { target: null, unavailable: true, url: null });
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page([gone])),
|
||||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(gone)),
|
||||
});
|
||||
assert.ok(c.state.options.slice(0, -1).every(option => option.disabled));
|
||||
assert.equal(c.state.choice, 'unresolved');
|
||||
assert.ok(c.state.options.every(option => option.disabled));
|
||||
assert.equal(c.state.choice, null);
|
||||
assert.match(c.state.warning, /not under a current image source/i);
|
||||
assert.equal(await c.apply(), true);
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 1, pairId: null, title: 'x' }), false);
|
||||
assert.equal(await c.leaveUnresolved(), true);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 0);
|
||||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(decision.body.capture_token, null);
|
||||
@@ -275,11 +348,14 @@ test('a review-conflict on a still pending item reads its detail again', async (
|
||||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).at(-1).body.expected_revision, 1);
|
||||
});
|
||||
|
||||
test('leave for later writes nothing', async () => {
|
||||
test('leave for later writes nothing, opens the next item and wraps', async () => {
|
||||
const { c, h } = await loaded();
|
||||
c.leave();
|
||||
assert.equal(c.state.phase, 'done');
|
||||
await c.leave();
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.toast, null);
|
||||
assert.equal(c.state.decisions, 0);
|
||||
await c.leave();
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.equal(h.of('POST', '/').length, 0);
|
||||
assert.equal(h.of('DELETE', '/').length, 0);
|
||||
});
|
||||
@@ -781,7 +857,9 @@ test('M5: an unanswered decision that landed finishes as recorded, not as decide
|
||||
queue = [QUEUE[1]]; // the server did record it: its change arrives
|
||||
await c.refresh();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'done');
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
assert.equal(c.state.decisions, 1);
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.toast, 'Association accepted.');
|
||||
assert.doesNotMatch(c.state.warning, /decided elsewhere/);
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the server used the capture');
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Needs review: link an image to a library prompt by hand (#536).
|
||||
|
||||
The link is the reviewer's choice, never file evidence: it needs the same
|
||||
capture of the same bytes as Apply, keeps the one-prompt-per-file rules, and
|
||||
records provenance that cannot pose as what the file says.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
from tests.test_api_review import ITEMS_URL, ReviewRoutesTestCase
|
||||
from tests.test_review import digest, unlinkable_graph, write_png
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_api_review import ITEMS_URL, ReviewRoutesTestCase
|
||||
from test_review import digest, unlinkable_graph, write_png
|
||||
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class LinkTestCase(ReviewRoutesTestCase):
|
||||
def _pair(self, positive="harbor", negative="blurry"):
|
||||
pair_id = self.api.db.save_prompt_pair(positive, negative)
|
||||
pair = self.api.db.get_prompt_pair(pair_id)
|
||||
return pair["positive_prompt_id"], pair_id
|
||||
|
||||
def _link_body(self, detail, observed, positive_id, pair_id):
|
||||
return {
|
||||
"expected_epoch": observed["library_epoch"],
|
||||
"expected_revision": detail["revision"],
|
||||
"capture_token": observed["capture"]["token"],
|
||||
"sha256": observed["capture"]["sha256"],
|
||||
"dependency_fingerprint": detail["dependency_fingerprint"],
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
}
|
||||
|
||||
async def _link(self, item_id, body):
|
||||
return await self._json("POST", f"{ITEMS_URL}/{item_id}/link", json=body)
|
||||
|
||||
async def _queued(self):
|
||||
self._record(self._ambiguous("two.png"))
|
||||
return await self._open()
|
||||
|
||||
async def _refused(self, item, body, status, code):
|
||||
before = self._snapshot()
|
||||
got, data = await self._link(item["id"], body)
|
||||
self.assertEqual(got, status, data)
|
||||
self.assertEqual(data.get("code"), code, data)
|
||||
self.assertEqual(self._snapshot(), before)
|
||||
return data
|
||||
|
||||
|
||||
class TestLink(LinkTestCase):
|
||||
async def test_links_the_chosen_recipe_and_records_a_manual_decision(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
path = self.output_dir / "two.png"
|
||||
item, detail, observed = await self._queued()
|
||||
revision = self._review_revision()
|
||||
status, data = await self._link(
|
||||
item["id"], self._link_body(detail, observed, positive_id, pair_id)
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["total"], 0)
|
||||
self.assertEqual(data["item"]["state"], "selected")
|
||||
self.assertEqual(data["pair"]["id"], pair_id)
|
||||
conn = self.api.db.model.get_connection()
|
||||
image = conn.execute("SELECT id, prompt_id FROM generated_images").fetchone()
|
||||
self.assertEqual(image[1], positive_id)
|
||||
(link,) = self.api.db.get_image_prompt_pairs(image[0])
|
||||
(decision,) = self._decisions()
|
||||
self.assertEqual(link["pair_id"], pair_id)
|
||||
self.assertEqual(
|
||||
link["provenance"],
|
||||
{
|
||||
"kind": "manual-link",
|
||||
"decision_id": decision["id"],
|
||||
"reviewed_digest": digest(path),
|
||||
"resolver_version": detail_version(self, item),
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(decision["outcome"], "selected")
|
||||
self.assertRegex(decision["candidate_hash"], r"^[0-9a-f]{64}$")
|
||||
self.assertNotIn(
|
||||
decision["candidate_hash"], [c["hash"] for c in detail["candidates"]]
|
||||
)
|
||||
self.assertEqual(decision["pair_id"], pair_id)
|
||||
evidence = json.loads(decision["evidence"])
|
||||
self.assertEqual(evidence["kind"], "manual-link")
|
||||
self.assertIsNone(evidence["candidate"])
|
||||
self.assertEqual(
|
||||
evidence["manual"], {"positive_id": positive_id, "pair_id": pair_id}
|
||||
)
|
||||
self.assertEqual(self._review_revision(), revision + 1)
|
||||
self._no_paths()
|
||||
|
||||
async def test_a_manual_link_does_not_pose_as_file_evidence(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
path = self.output_dir / "two.png"
|
||||
item, detail, observed = await self._queued()
|
||||
status, data = await self._link(
|
||||
item["id"], self._link_body(detail, observed, positive_id, pair_id)
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
saved = self.api.db.get_image_prompt_info(str(path))
|
||||
self.assertEqual(saved["text"], "harbor")
|
||||
self.assertIsNone(saved.get("pair_context"))
|
||||
|
||||
async def test_no_negative_links_a_pair_without_one(self):
|
||||
positive_id, _pair_id = self._pair()
|
||||
item, detail, observed = await self._queued()
|
||||
status, data = await self._link(
|
||||
item["id"], self._link_body(detail, observed, positive_id, None)
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
pair = self.api.db.get_prompt_pair(data["pair"]["id"])
|
||||
self.assertEqual(pair["positive_prompt_id"], positive_id)
|
||||
self.assertIsNone(pair["negative_prompt_id"])
|
||||
decision = self._decisions()[0]
|
||||
self.assertEqual(
|
||||
json.loads(decision["evidence"])["manual"]["pair_id"], data["pair"]["id"]
|
||||
)
|
||||
|
||||
async def test_a_prompt_without_recipes_can_be_linked(self):
|
||||
prompt_id = self.api.db.save_prompt(
|
||||
"lone", prompt_hash=generate_prompt_hash("lone")
|
||||
)
|
||||
item, detail, observed = await self._queued()
|
||||
status, data = await self._link(
|
||||
item["id"], self._link_body(detail, observed, prompt_id, None)
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
|
||||
async def test_an_existing_row_of_the_same_prompt_is_reused(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
path = self._ambiguous("two.png")
|
||||
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
|
||||
self._record(path)
|
||||
item, detail, observed = await self._open()
|
||||
status, data = await self._link(
|
||||
item["id"], self._link_body(detail, observed, positive_id, pair_id)
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["pair"]["image_id"], image_id)
|
||||
|
||||
async def test_a_candidates_own_item_can_still_be_linked_after_a_refusal(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
item, detail, observed = await self._queued()
|
||||
body = self._link_body(detail, observed, positive_id, pair_id)
|
||||
await self._refused(
|
||||
item, {**body, "pair_id": pair_id + 99}, 409, "prompt-missing"
|
||||
)
|
||||
status, data = await self._link(item["id"], body)
|
||||
self.assertEqual(status, 200, data)
|
||||
|
||||
async def test_a_later_scan_leaves_the_link_and_does_not_queue_it(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
path = self.output_dir / "two.png"
|
||||
item, detail, observed = await self._queued()
|
||||
status, data = await self._link(
|
||||
item["id"], self._link_body(detail, observed, positive_id, pair_id)
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
before = self._snapshot()
|
||||
self.assertIn(self._record_raw(path), ("decided", "duplicate"))
|
||||
self.assertEqual(self._snapshot(), before)
|
||||
self.assertEqual((await self._list())["total"], 0)
|
||||
|
||||
|
||||
class TestLinkRefusals(LinkTestCase):
|
||||
async def test_another_positive_on_the_file_is_an_association_conflict(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
path = self._ambiguous("two.png")
|
||||
other = self.api.db.save_prompt(
|
||||
"other", prompt_hash=generate_prompt_hash("other")
|
||||
)
|
||||
self.api.db.link_image_to_prompt(other, str(path))
|
||||
self._record(path)
|
||||
item, detail, observed = await self._open()
|
||||
body = self._link_body(detail, observed, positive_id, pair_id)
|
||||
data = await self._refused(item, body, 409, "association-conflict")
|
||||
self.assertIn("linked", data["error"])
|
||||
|
||||
async def test_a_prior_link_with_another_negative_is_an_association_conflict(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
_same, other_pair = self._pair(negative="grainy")
|
||||
path = self._ambiguous("two.png")
|
||||
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
|
||||
self.api.db.link_image_to_pair(other_pair, image_id, {"kind": "test"})
|
||||
self._record(path)
|
||||
item, detail, observed = await self._open()
|
||||
body = self._link_body(detail, observed, positive_id, pair_id)
|
||||
await self._refused(item, body, 409, "association-conflict")
|
||||
|
||||
async def test_changed_links_since_opening_are_refused(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
path = self.output_dir / "two.png"
|
||||
item, detail, observed = await self._queued()
|
||||
other = self.api.db.save_prompt(
|
||||
"other", prompt_hash=generate_prompt_hash("other")
|
||||
)
|
||||
self.api.db.link_image_to_prompt(other, str(path))
|
||||
body = self._link_body(detail, observed, positive_id, pair_id)
|
||||
await self._refused(item, body, 409, "association-conflict")
|
||||
|
||||
async def test_unusable_choices_are_refused_with_zero_writes(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
foreign_positive, foreign_pair = self._pair("elsewhere", "bad")
|
||||
negative_only = self.api.db.get_prompt_pair(pair_id)["negative_prompt_id"]
|
||||
archived_positive, archived_pair = self._pair("old", "stale")
|
||||
self.api.db.archive_prompt_pair(archived_pair)
|
||||
item, detail, observed = await self._queued()
|
||||
for chosen_positive, chosen_pair in (
|
||||
(positive_id, foreign_pair),
|
||||
(positive_id, pair_id + 99),
|
||||
(positive_id + 99, None),
|
||||
(negative_only, None),
|
||||
(archived_positive, archived_pair),
|
||||
):
|
||||
body = self._link_body(detail, observed, chosen_positive, chosen_pair)
|
||||
await self._refused(item, body, 409, "prompt-missing")
|
||||
self.assertTrue(foreign_positive)
|
||||
|
||||
async def test_an_archived_prompt_is_refused(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
conn = self.api.db.model.get_connection()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_archive(prompt_id,archived_at) "
|
||||
"VALUES(?,'2026-01-01T00:00:00Z')",
|
||||
(positive_id,),
|
||||
)
|
||||
item, detail, observed = await self._queued()
|
||||
body = self._link_body(detail, observed, positive_id, pair_id)
|
||||
await self._refused(item, body, 409, "prompt-missing")
|
||||
|
||||
async def test_a_stale_stored_hash_never_links_another_row(self):
|
||||
positive_id, _pair_id = self._pair()
|
||||
other = self.api.db.save_prompt(
|
||||
"other", prompt_hash=generate_prompt_hash("other")
|
||||
)
|
||||
conn = self.api.db.model.get_connection()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE prompts SET hash=? WHERE id=?",
|
||||
(generate_prompt_hash("elsewhere"), other),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE prompts SET hash=? WHERE id=?",
|
||||
(generate_prompt_hash("other"), positive_id),
|
||||
)
|
||||
item, detail, observed = await self._queued()
|
||||
body = self._link_body(detail, observed, other, None)
|
||||
await self._refused(item, body, 409, "prompt-missing")
|
||||
|
||||
async def test_an_archived_bare_pair_is_refused(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
bare = self.api.db.save_prompt_pair("harbor", "")
|
||||
self.api.db.archive_prompt_pair(bare)
|
||||
item, detail, observed = await self._queued()
|
||||
body = self._link_body(detail, observed, positive_id, None)
|
||||
await self._refused(item, body, 409, "prompt-missing")
|
||||
self.assertTrue(pair_id)
|
||||
|
||||
async def test_body_validation(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
item, detail, observed = await self._queued()
|
||||
good = self._link_body(detail, observed, positive_id, pair_id)
|
||||
before = self._snapshot()
|
||||
for bad in (
|
||||
{**good, "extra": 1},
|
||||
{k: v for k, v in good.items() if k != "pair_id"},
|
||||
{**good, "positive_id": "1"},
|
||||
{**good, "positive_id": True},
|
||||
{**good, "positive_id": 0},
|
||||
{**good, "pair_id": -1},
|
||||
{**good, "capture_token": None},
|
||||
{**good, "expected_revision": -1},
|
||||
{**good, "sha256": "x"},
|
||||
):
|
||||
status, data = await self._link(item["id"], bad)
|
||||
self.assertEqual(status, 400, (bad, data))
|
||||
self.assertEqual(self._snapshot(), before)
|
||||
|
||||
async def test_stale_revision_and_decided_item_are_refused(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
item, detail, observed = await self._queued()
|
||||
good = self._link_body(detail, observed, positive_id, pair_id)
|
||||
await self._refused(
|
||||
item, {**good, "expected_revision": 5}, 409, "review-conflict"
|
||||
)
|
||||
status, data = await self._link(item["id"], good)
|
||||
self.assertEqual(status, 200, data)
|
||||
again = await self._observe(item["target"])
|
||||
await self._refused(
|
||||
item,
|
||||
{**good, "capture_token": again["capture"]["token"]},
|
||||
409,
|
||||
"review-conflict",
|
||||
)
|
||||
|
||||
async def test_missing_item_expired_capture_and_changed_file_are_refused(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
item, detail, observed = await self._queued()
|
||||
good = self._link_body(detail, observed, positive_id, pair_id)
|
||||
status, data = await self._link("00000000-0000-4000-8000-000000000000", good)
|
||||
self.assertEqual((status, data["code"]), (404, "review-missing"))
|
||||
await self._refused(
|
||||
item, {**good, "capture_token": "a" * 43}, 409, "capture-expired"
|
||||
)
|
||||
write_png(
|
||||
self.output_dir / "two.png", prompt=unlinkable_graph(), color=(1, 2, 3)
|
||||
)
|
||||
with_fresh = await self._observe(item["target"])
|
||||
await self._refused(
|
||||
item,
|
||||
{
|
||||
**good,
|
||||
"capture_token": with_fresh["capture"]["token"],
|
||||
"sha256": with_fresh["capture"]["sha256"],
|
||||
},
|
||||
409,
|
||||
"file-changed",
|
||||
)
|
||||
|
||||
async def test_restored_storage_is_refused(self):
|
||||
positive_id, pair_id = self._pair()
|
||||
item, detail, observed = await self._queued()
|
||||
body = {
|
||||
**self._link_body(detail, observed, positive_id, pair_id),
|
||||
"expected_epoch": "00000000-0000-4000-8000-000000000000",
|
||||
}
|
||||
await self._refused(item, body, 409, "storage-changed")
|
||||
|
||||
|
||||
def detail_version(case, item):
|
||||
conn = case.api.db.model.get_connection()
|
||||
return conn.execute(
|
||||
"SELECT resolver_version FROM media_review_items WHERE id=?", (item["id"],)
|
||||
).fetchone()[0]
|
||||
@@ -285,6 +285,51 @@ class DryRunTests(RepairFixture):
|
||||
self.assertIn("would lose every link", markdown)
|
||||
|
||||
|
||||
class ManualLinkTests(RepairFixture):
|
||||
"""A prompt the reviewer named in Needs review (#536) is not ours to undo."""
|
||||
|
||||
def mark_manual(self, key):
|
||||
row = self.rows[key]
|
||||
((prompt,),) = self.query(
|
||||
"SELECT prompt_id FROM generated_images WHERE id=?", (row,)
|
||||
)
|
||||
self.query_write(
|
||||
"INSERT OR IGNORE INTO prompt_pairs(positive_prompt_id,created_at,"
|
||||
"updated_at) VALUES(?,'2026-10-30T00:00:00Z','2026-10-30T00:00:00Z')",
|
||||
(prompt,),
|
||||
)
|
||||
((pair,),) = self.query(
|
||||
"SELECT id FROM prompt_pairs WHERE positive_prompt_id=? "
|
||||
"AND negative_prompt_id IS NULL",
|
||||
(prompt,),
|
||||
)
|
||||
self.query_write(
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
|
||||
"VALUES(?,?,?,'2026-10-30T00:00:00Z')",
|
||||
(pair, row, '{"kind":"manual-link"}'),
|
||||
)
|
||||
|
||||
def test_a_manual_link_is_skipped_in_the_plan(self):
|
||||
self.mark_manual("A")
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
items = {item["row_id"]: item for item in self.plan()["rows"]}
|
||||
item = items[self.rows["A"]]
|
||||
self.assertEqual(item["action"], "skip")
|
||||
self.assertEqual(item["reason"], "linked by hand in Needs review")
|
||||
self.assertEqual(items[self.rows["B"]]["action"], "relink")
|
||||
|
||||
def test_a_manual_link_made_after_the_plan_refuses_the_apply(self):
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
self.mark_manual("A")
|
||||
before = self.links()
|
||||
code, out = self.apply()
|
||||
self.assertNotEqual(code, 0, out)
|
||||
self.assertIn("action changed", out)
|
||||
self.assertEqual(self.links(), before)
|
||||
|
||||
|
||||
class DryRunReviewTests(RepairFixture):
|
||||
def test_plan_records_its_library_and_fingerprint(self):
|
||||
self.dry_run()
|
||||
|
||||
@@ -104,6 +104,21 @@ class ReviewToolTests(ToolFixture):
|
||||
sorted(os.listdir(self.tmp)), ["output", "prompts.db"], "no side files"
|
||||
)
|
||||
|
||||
def test_a_manual_link_is_not_ours_to_undo(self):
|
||||
db = PromptDatabase(self.db_path)
|
||||
try:
|
||||
pair = db.save_prompt_pair("a woman holding a coffee cup", "")
|
||||
db.link_image_to_pair(pair, self.guessed[0], {"kind": "manual-link"})
|
||||
finally:
|
||||
db.close_all()
|
||||
before = self.links()
|
||||
code, out = self.run_tool("--ids", str(self.guessed[0]), "--json")
|
||||
self.assertEqual(code, 0)
|
||||
(verdict,) = json.loads(out.split("--apply, for")[0])
|
||||
self.assertEqual(verdict["verdict"], "manual")
|
||||
code, _out = self.run_tool("--ids", str(self.guessed[0]), "--apply")
|
||||
self.assertEqual(self.links(), before)
|
||||
|
||||
def test_prompt_selection_and_text_report(self):
|
||||
code, out = self.run_tool("--prompt", str(self.coffee))
|
||||
self.assertEqual(code, 0)
|
||||
|
||||
@@ -112,6 +112,7 @@ COUNTED = (
|
||||
# The ladder steps whose answer is certain enough to keep a link.
|
||||
KEEP_SOURCES = ("recorded-run", "inspector")
|
||||
# What must be unchanged since the dry run for a row to be written.
|
||||
MANUAL_REASON = "linked by hand in Needs review"
|
||||
DRIFT_KEYS = (
|
||||
"action",
|
||||
"file",
|
||||
@@ -181,6 +182,13 @@ class Judge:
|
||||
)
|
||||
return self.decisions[path]
|
||||
|
||||
def _manually_linked(self, image_id):
|
||||
"""True when the reviewer named this row's prompt in Needs review (#536)."""
|
||||
return any(
|
||||
link["provenance"].get("kind") == "manual-link"
|
||||
for link in self.db.get_image_prompt_pairs(image_id)
|
||||
)
|
||||
|
||||
def row(self, row):
|
||||
"""The plan entry of one ``generated_images`` row."""
|
||||
conn = self.db.model.get_connection()
|
||||
@@ -200,6 +208,8 @@ class Judge:
|
||||
except OSError:
|
||||
return {**item, "action": "skip", "reason": "file gone"}
|
||||
item.update(file_size=stat.st_size, file_mtime_ns=stat.st_mtime_ns)
|
||||
if self._manually_linked(row["id"]):
|
||||
return {**item, "action": "skip", "reason": MANUAL_REASON}
|
||||
decision = self.decide(path, stat.st_size)
|
||||
action = action_of(decision, prompt_hash)
|
||||
if action == "skip":
|
||||
|
||||
@@ -142,6 +142,14 @@ def certain_prompts(db, handler, path, metadata, output_dir):
|
||||
return found
|
||||
|
||||
|
||||
def _manually_linked(db, image_id):
|
||||
"""True when a link of this image records a reviewer's manual choice."""
|
||||
return any(
|
||||
link["provenance"].get("kind") == "manual-link"
|
||||
for link in db.get_image_prompt_pairs(image_id)
|
||||
)
|
||||
|
||||
|
||||
def judge(db, handler, row, output_dir):
|
||||
"""One image's verdict dict."""
|
||||
path = row["image_path"]
|
||||
@@ -152,6 +160,9 @@ def judge(db, handler, row, output_dir):
|
||||
"sources": {},
|
||||
"target": None,
|
||||
}
|
||||
if _manually_linked(db, row["id"]):
|
||||
# The reviewer named this prompt in Needs review (#536): not ours to undo.
|
||||
return {**verdict, "verdict": "manual", "target": row["prompt_id"]}
|
||||
if not os.path.isfile(path):
|
||||
return {**verdict, "verdict": "missing"}
|
||||
try:
|
||||
|
||||
+30
-24
@@ -43,14 +43,14 @@
|
||||
},
|
||||
"gallery-settings.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 54,
|
||||
"branches": 72,
|
||||
"functions": 57,
|
||||
"functions": 58,
|
||||
"statements": 54
|
||||
},
|
||||
"html-escape.js": {
|
||||
@@ -210,10 +210,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 96,
|
||||
"lines": 97,
|
||||
"branches": 93,
|
||||
"functions": 93,
|
||||
"statements": 96
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/heading.mjs": {
|
||||
"lines": 98,
|
||||
@@ -282,16 +282,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-delete.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
},
|
||||
"workspace/images-return.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
"lines": 95,
|
||||
"branches": 81,
|
||||
"functions": 92,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/images-export.mjs": {
|
||||
"lines": 98,
|
||||
@@ -311,6 +305,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-return.mjs": {
|
||||
"lines": 93,
|
||||
"branches": 86,
|
||||
"functions": 98,
|
||||
"statements": 93
|
||||
},
|
||||
"workspace/images-selection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
@@ -349,7 +349,7 @@
|
||||
},
|
||||
"workspace/images.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 89,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
@@ -362,7 +362,7 @@
|
||||
"workspace/inspect.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 93,
|
||||
"functions": 94,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/inspection-state.mjs": {
|
||||
@@ -373,7 +373,7 @@
|
||||
},
|
||||
"workspace/inspector-collections.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -391,7 +391,7 @@
|
||||
},
|
||||
"workspace/library-editor.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"branches": 94,
|
||||
"functions": 92,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -492,10 +492,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/more-menu.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
"lines": 98,
|
||||
"branches": 86,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/plural.mjs": {
|
||||
"lines": 98,
|
||||
@@ -539,9 +539,15 @@
|
||||
"functions": 80,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/review-link.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 70,
|
||||
"functions": 91,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/review-state.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 84,
|
||||
"branches": 85,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -607,7 +613,7 @@
|
||||
},
|
||||
"workspace/system-sources.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 88,
|
||||
"branches": 89,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Link to a prompt (#536, D181; the concept has no picker): search the Library, choose the prompt that made this image
|
||||
* and, when the prompt has recipes, its negative. The server's refusals stay in the dialog. No negative is taken from
|
||||
* the file: one recipe is preselected and visible, several must be chosen between, none means no negative.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
|
||||
|
||||
export const SEARCH_DELAY_MS = 250;
|
||||
export const RESULT_LIMIT = 20;
|
||||
export const VARIANT_LIMIT = 40;
|
||||
export const NO_NEGATIVE = 'none';
|
||||
const LIBRARY_URL = '/prompt_manager/library';
|
||||
const CHOOSE = '';
|
||||
|
||||
export const LINK_COPY = Object.freeze({
|
||||
title: 'Link to a prompt',
|
||||
sentence: name => `Choose the prompt that made ${name}. This records your choice; the file's metadata does not confirm it.`,
|
||||
search: 'Search the Library',
|
||||
searching: 'Searching…',
|
||||
empty: 'No prompt in the Library matches.',
|
||||
failed: 'The Library could not be searched. Try again.',
|
||||
negative: 'Negative prompt',
|
||||
chooseNegative: 'Choose a negative…',
|
||||
noNegative: 'No negative',
|
||||
negativeFailed: "This prompt's negatives could not be read. Choose it again.",
|
||||
action: 'Link image',
|
||||
cancel: 'Cancel',
|
||||
});
|
||||
|
||||
const titleOf = prompt => prompt.title?.trim() || prompt.text;
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The Negative options of a prompt's recipes: its value is the recipe's pair id, or `NO_NEGATIVE`. */
|
||||
export function negativeOptions(variants) {
|
||||
const recipes = variants.filter(variant => !variant.explicit_empty)
|
||||
.map(variant => ({ value: String(variant.id), label: variant.title?.trim() || variant.negative_text }));
|
||||
const bare = variants.find(variant => variant.explicit_empty);
|
||||
const none = { value: bare ? String(bare.id) : NO_NEGATIVE, label: LINK_COPY.noNegative };
|
||||
if (recipes.length > 1) return [{ value: CHOOSE, label: LINK_COPY.chooseNegative }, ...recipes, none];
|
||||
return [...recipes, none];
|
||||
}
|
||||
|
||||
/** The body fields of the chosen Negative value. */
|
||||
export const pairIdOf = value => (value === NO_NEGATIVE ? null : Number(value));
|
||||
|
||||
/** Open the dialog; `controller.link` records the choice and the dialog closes once it is recorded. */
|
||||
export function openPromptLink({ api, scope, controller, filename, trigger }) {
|
||||
// A re-read may open another item meanwhile (decided elsewhere): the dialog names this one, so it closes.
|
||||
const itemId = controller.state.selectedId;
|
||||
const stopWatching = controller.subscribe(state => { if (state.selectedId !== itemId && !closed) closeWorkspaceDialog(); });
|
||||
let timer = null, search = null, variantsRead = null, chosen = null, closed = false;
|
||||
|
||||
const sentence = el('p', LINK_COPY.sentence(filename), 'small');
|
||||
const input = el('input');
|
||||
input.type = 'search';
|
||||
const results = el('div', undefined, 'review-link-results');
|
||||
results.setAttribute('role', 'group');
|
||||
results.setAttribute('aria-label', 'Matching prompts');
|
||||
const status = el('p', '', 'small');
|
||||
status.setAttribute('role', 'status');
|
||||
const select = el('select');
|
||||
const negative = field(LINK_COPY.negative, select, 'review-link-negative');
|
||||
negative.hidden = true;
|
||||
const warning = el('p', '', 'warning-note');
|
||||
warning.setAttribute('role', 'status');
|
||||
warning.hidden = true;
|
||||
const actions = el('div', undefined, 'dialog-actions review-link-actions');
|
||||
const cancel = createButton(LINK_COPY.cancel);
|
||||
const submit = createButton(LINK_COPY.action, 'ca-primary');
|
||||
actions.append(cancel, submit);
|
||||
|
||||
const busy = () => controller.state.phase !== 'idle';
|
||||
const ready = () => chosen !== null && !negative.hidden && select.value !== CHOOSE;
|
||||
function syncSubmit() {
|
||||
if (ready() && !busy()) submit.removeAttribute('aria-disabled');
|
||||
else submit.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
function warn(text) {
|
||||
warning.textContent = text;
|
||||
warning.hidden = !text;
|
||||
}
|
||||
|
||||
/** Every active recipe of the prompt, page by page. */
|
||||
async function readVariants(id, signal) {
|
||||
const variants = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const query = new URLSearchParams({ variant_limit: String(VARIANT_LIMIT) });
|
||||
if (cursor) query.set('variant_cursor', cursor);
|
||||
const detail = await api.get(`${LIBRARY_URL}/${encodeURIComponent(id)}?${query}`, { signal, cache: 'no-store' });
|
||||
variants.push(...(detail.variants ?? []));
|
||||
cursor = detail.variants_next_cursor ?? null;
|
||||
} while (cursor);
|
||||
return variants;
|
||||
}
|
||||
|
||||
async function choose(prompt, button) {
|
||||
variantsRead?.abort();
|
||||
const read = variantsRead = new AbortController();
|
||||
chosen = prompt;
|
||||
for (const node of results.children) node.setAttribute('aria-pressed', String(node === button));
|
||||
negative.hidden = true;
|
||||
warn('');
|
||||
syncSubmit();
|
||||
try {
|
||||
const variants = await readVariants(prompt.id, read.signal);
|
||||
if (read !== variantsRead || closed) return;
|
||||
const options = negativeOptions(variants);
|
||||
select.replaceChildren(...options.map(option => {
|
||||
const node = el('option', option.label);
|
||||
node.value = option.value;
|
||||
return node;
|
||||
}));
|
||||
select.value = options[0].value;
|
||||
negative.hidden = false;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || read !== variantsRead || closed) return;
|
||||
warn(LINK_COPY.negativeFailed);
|
||||
}
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
function show(items) {
|
||||
status.textContent = items.length ? '' : LINK_COPY.empty;
|
||||
results.replaceChildren(...items.map(prompt => {
|
||||
const button = createButton(titleOf(prompt));
|
||||
button.title = prompt.text;
|
||||
button.setAttribute('aria-pressed', String(prompt.id === chosen?.id));
|
||||
button.addEventListener('click', () => choose(prompt, button));
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
|
||||
async function find() {
|
||||
search?.abort();
|
||||
const read = search = new AbortController();
|
||||
status.textContent = LINK_COPY.searching;
|
||||
try {
|
||||
const query = new URLSearchParams({ text: input.value.trim(), limit: String(RESULT_LIMIT) });
|
||||
const page = await api.get(`${LIBRARY_URL}?${query}`, { signal: read.signal, cache: 'no-store' });
|
||||
if (read === search && !closed) show(page.items ?? []);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError' && read === search && !closed) status.textContent = LINK_COPY.failed;
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(find, SEARCH_DELAY_MS);
|
||||
});
|
||||
select.addEventListener('change', syncSubmit);
|
||||
cancel.addEventListener('click', () => { if (!busy()) closeWorkspaceDialog(); });
|
||||
submit.addEventListener('click', async () => {
|
||||
if (submit.getAttribute('aria-disabled') === 'true') return;
|
||||
warn('');
|
||||
submit.setAttribute('aria-disabled', 'true');
|
||||
const done = await controller.link({ itemId, positiveId: chosen.id, pairId: pairIdOf(select.value), title: titleOf(chosen) });
|
||||
if (closed) return;
|
||||
if (done) { closeWorkspaceDialog(); return; }
|
||||
warn(controller.state.warning);
|
||||
syncSubmit();
|
||||
});
|
||||
|
||||
openContentDialog({ scope, title: LINK_COPY.title, trigger, focusTarget: input,
|
||||
nodes: [sentence, field(LINK_COPY.search, input, 'review-link-search'), status, results, negative, warning, actions],
|
||||
canClose: () => !busy(),
|
||||
onClose: () => {
|
||||
closed = true;
|
||||
stopWatching();
|
||||
clearTimeout(timer);
|
||||
search?.abort();
|
||||
variantsRead?.abort();
|
||||
} });
|
||||
syncSubmit();
|
||||
find();
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
* - The queue is read 40 at a time, oldest first; `total` is the exact pending count. The cursor is bound to the
|
||||
* storage epoch only, so Load more keeps working while new items arrive.
|
||||
* - Selecting an item reads its detail: one "Sampler <id> · <positive> / <negative>" option per candidate, unusable
|
||||
* ones disabled, the first workable one chosen, then "Leave unresolved" (rulings R2/R3).
|
||||
* ones disabled with the server's reason, the first workable one chosen (rulings R2/R3). "Leave unresolved" is its
|
||||
* own action (#536 D179, `leaveUnresolved()`). With nothing workable `unreadable` says why (D180).
|
||||
* - `link(choice)` (#536) records the Library prompt the reviewer chose, under the same capture and retry rules.
|
||||
* - A recorded decision opens the item after it (else the one before) and re-reads the queue quietly: the page stays
|
||||
* (#536, overturning ruling R7). `decisions` counts them, for the page's toast.
|
||||
* - Apply takes a fresh capture of the file (`observe`), then posts the decision; the server releases a capture it
|
||||
* used. A refused decision releases the capture and keeps the selection with the server's sentence as the warning;
|
||||
* a restored storage or a changed queue reads the queue again. A request that got no answer keeps its capture and
|
||||
* its exact body, and the next Apply of the same choice (`retry()`) resends that body once.
|
||||
* - Leave for later writes nothing. Tagging the image (the shared Tag dialog) never changes the item.
|
||||
* - Leave for later writes nothing and opens the next item, wrapping. Tagging the image (the shared Tag dialog) never changes the item.
|
||||
* - `refresh()` re-reads the queue quietly when the server says the queue changed (F2: the review change stream:
|
||||
* a scan or the monitor added items, another tab decided one). The open item, its choice and an unanswered decision
|
||||
* stay; when the open item has left the queue the next one opens with a note. Refreshes asked for while one runs
|
||||
@@ -47,7 +51,10 @@ export const COPY = Object.freeze({
|
||||
detailLoading: 'Loading sampler paths…',
|
||||
detailFailed: 'This item could not be read. Select it again to retry.',
|
||||
moreFailed: 'More items could not be loaded. Try again.',
|
||||
limited: 'This file has too many sampler paths to list. You can leave it unresolved.',
|
||||
limited: 'This file has too many sampler paths to list. Link it to a prompt or leave it unresolved.',
|
||||
// #536 D180: nothing to apply.
|
||||
unreadable: count => `${count === 1 ? "This file's sampler path" : `None of this file's ${count} sampler paths`} could be traced to a complete prompt, so there is nothing to apply. Choose the prompt yourself, or leave it unresolved.`,
|
||||
lostLink: 'The link may not have been saved. Press Link image again to send the same choice once more.',
|
||||
unavailable: 'This file is not under a current image source, so it cannot be opened. You can leave it unresolved.',
|
||||
lost: 'The decision may not have been saved. Apply again to send the same decision once more.',
|
||||
storageChanged: 'Prompt storage was restored; reload the review queue.',
|
||||
@@ -57,6 +64,7 @@ export const COPY = Object.freeze({
|
||||
failed: 'The decision could not be saved. Try again.',
|
||||
applied: 'Association accepted.',
|
||||
leftUnresolved: 'Left unresolved.',
|
||||
linked: title => `Linked to ${title}.`,
|
||||
});
|
||||
|
||||
// Answers after which the queue itself is out of date: read it again.
|
||||
@@ -76,19 +84,34 @@ export function optionLabel(candidate) {
|
||||
const workable = candidate => candidate?.state === 'resolved' && typeof candidate.positive?.text === 'string' &&
|
||||
typeof candidate.negative?.text === 'string';
|
||||
|
||||
/** The Sampler context options of a detail: its candidates (unusable ones disabled), then Leave unresolved. */
|
||||
/** The Sampler context options of a detail: its candidates, unusable ones disabled with the server's reason. */
|
||||
export function contextOptions(detail) {
|
||||
const usable = detail.target !== null && !detail.unavailable;
|
||||
const options = (Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => Object.freeze({
|
||||
value: candidate.hash, label: optionLabel(candidate), disabled: !usable || !workable(candidate), candidate,
|
||||
return Object.freeze((Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => {
|
||||
const readable = workable(candidate);
|
||||
return Object.freeze({
|
||||
value: candidate.hash, candidate, disabled: !usable || !readable,
|
||||
label: readable || !candidate.reason ? optionLabel(candidate) : `${optionLabel(candidate)} (${candidate.reason})`,
|
||||
});
|
||||
}));
|
||||
options.push(Object.freeze({ value: UNRESOLVED, label: COPY.leaveUnresolved, disabled: false, candidate: null }));
|
||||
return Object.freeze(options);
|
||||
}
|
||||
|
||||
/** The prompt blocks of the chosen option: its two role texts, or the ambiguous status for Leave unresolved. */
|
||||
/**
|
||||
* When no candidate of an available, listed file can be applied: their count and each distinct reason with its count
|
||||
* (first seen first). Null otherwise.
|
||||
*/
|
||||
export function unreadableOf(detail) {
|
||||
const candidates = Array.isArray(detail.candidates) ? detail.candidates : [];
|
||||
if (detail.limited || detail.unavailable || detail.target === null || candidates.some(workable)) return null;
|
||||
const reasons = new Map();
|
||||
for (const { reason } of candidates) if (reason) reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
|
||||
return Object.freeze({ count: candidates.length,
|
||||
reasons: Object.freeze([...reasons].map(([reason, count]) => Object.freeze({ reason, count }))) });
|
||||
}
|
||||
|
||||
/** The prompt blocks of the chosen option: its two role texts, or nothing without a choice. */
|
||||
function promptsOf(option) {
|
||||
if (!option?.candidate) return { positive: { status: 'ambiguous' }, negative: { status: 'ambiguous' } };
|
||||
if (!option?.candidate) return null;
|
||||
const { positive, negative } = option.candidate;
|
||||
return { positive: { status: 'resolved', text: positive.text }, negative: { status: 'resolved', text: negative.text } };
|
||||
}
|
||||
@@ -106,13 +129,13 @@ const isLost = error => !isAbort(error) && !error?.code && !(error?.status >= 40
|
||||
const initial = () => ({
|
||||
status: 'idle', epoch: null, total: 0, items: Object.freeze([]), nextCursor: null, more: 'idle',
|
||||
selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
|
||||
phase: 'idle', warning: '', canRetry: false, toast: null,
|
||||
unreadable: null, phase: 'idle', warning: '', canRetry: false, toast: null, decisions: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{api: {get: Function, post: Function, del: Function}}} options
|
||||
* @returns the controller: `state`, `subscribe(listener)`, `load()`, `refresh()`, `loadMore()`, `select(id)`, `choose(value)`,
|
||||
* `sync(version)`, `apply()`, `retry()`, `leave()`, `dispose()`.
|
||||
* `sync(version)`, `apply()`, `leaveUnresolved()`, `link(choice)`, `retry()`, `leave()`, `dispose()`.
|
||||
*/
|
||||
export function createReviewController({ api }) {
|
||||
let state = Object.freeze(initial());
|
||||
@@ -218,15 +241,16 @@ export function createReviewController({ api }) {
|
||||
if (keep && !stale && (quiet || state.detail)) return;
|
||||
if (quiet && left && !keep && pending?.id === left.id) {
|
||||
// M5: the decision that got no answer was recorded (the server used its capture).
|
||||
const choice = pending.body.choice;
|
||||
const { toast } = pending;
|
||||
pending = null;
|
||||
set({ phase: 'done', warning: '', canRetry: false,
|
||||
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
|
||||
set({ warning: '', canRetry: false, toast, decisions: state.decisions + 1 });
|
||||
if (items.length) await select(items[0].id);
|
||||
else clearSelection();
|
||||
} else if (quiet && left && !keep && items.length) {
|
||||
set({ warning: COPY.decidedElsewhere(left.filename) });
|
||||
await select(items[0].id, { keepWarning: true });
|
||||
} else if (items.length) await select((keep ?? items[0]).id, { keepWarning });
|
||||
else set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null });
|
||||
else clearSelection();
|
||||
} catch (error) {
|
||||
if (read !== listRead || disposed || isAbort(error)) return;
|
||||
// M7: a click this read took over can be made again.
|
||||
@@ -244,6 +268,11 @@ export function createReviewController({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
|
||||
unreadable: null });
|
||||
}
|
||||
|
||||
/** The stream's `ready`: re-read only when the queue shown is older than `version`. */
|
||||
function sync(version) {
|
||||
if (disposed) return Promise.resolve();
|
||||
@@ -320,14 +349,14 @@ export function createReviewController({ api }) {
|
||||
dropPending();
|
||||
const read = detailRead = new AbortController();
|
||||
set({ selectedId: id, detail: null, detailStatus: 'loading', options: Object.freeze([]), choice: null, prompts: null,
|
||||
canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
|
||||
unreadable: null, canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
|
||||
try {
|
||||
const detail = await api.get(`${REVIEW_URL}/${encodeURIComponent(id)}`, { signal: signalOf(read), cache: 'no-store' });
|
||||
if (read !== detailRead || disposed) return;
|
||||
const options = contextOptions(detail);
|
||||
const chosen = options.find(option => !option.disabled);
|
||||
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen.value, prompts: promptsOf(chosen),
|
||||
warning: (keepWarning && state.warning) || warningOf(detail) });
|
||||
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen?.value ?? null,
|
||||
prompts: promptsOf(chosen), unreadable: unreadableOf(detail), warning: (keepWarning && state.warning) || warningOf(detail) });
|
||||
} catch (error) {
|
||||
if (read !== detailRead || disposed || isAbort(error)) return;
|
||||
if (RELOAD.has(error?.code)) { set({ warning: error.message }); await load({ keepWarning: true, stale: true }); return; }
|
||||
@@ -339,7 +368,7 @@ export function createReviewController({ api }) {
|
||||
const option = state.options.find(entry => entry.value === value);
|
||||
if (!option || option.disabled || state.phase === 'applying') return;
|
||||
// Another choice is another decision: an unanswered one is no longer resent.
|
||||
if (pending && pending.body.choice !== value) { dropPending(); set({ canRetry: false }); }
|
||||
if (pending && pending.key !== value) { dropPending(); set({ canRetry: false }); }
|
||||
set({ choice: value, prompts: promptsOf(option) });
|
||||
}
|
||||
|
||||
@@ -350,24 +379,35 @@ export function createReviewController({ api }) {
|
||||
if (owed && !reloading && !disposed) refresh();
|
||||
}
|
||||
|
||||
/** Send `body`; true once the decision is recorded. */
|
||||
async function send(body) {
|
||||
/** The item recorded: it leaves the page's queue, the one after it opens and the queue is read again. */
|
||||
async function advance(id, total, toast) {
|
||||
const index = state.items.findIndex(entry => entry.id === id);
|
||||
const items = Object.freeze(state.items.filter(entry => entry.id !== id));
|
||||
const next = items[index] ?? items[index - 1] ?? null;
|
||||
set({ phase: 'idle', items, total, warning: '', toast, decisions: state.decisions + 1 });
|
||||
owedRefresh = false;
|
||||
if (next) await select(next.id);
|
||||
else clearSelection();
|
||||
// Not refresh(): an answer already on its way may predate the decision, and this read aborts it.
|
||||
await load({ keepWarning: true, quiet: true });
|
||||
}
|
||||
|
||||
/** Send one decision; true once it is recorded. */
|
||||
async function send({ path, body, key, toast, lost = COPY.lost }) {
|
||||
const id = state.selectedId;
|
||||
const choice = body.choice;
|
||||
set({ phase: 'applying', canRetry: false });
|
||||
try {
|
||||
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/decision`, body, { signal: lifetime.signal });
|
||||
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/${path}`, body, { signal: lifetime.signal });
|
||||
pending = null;
|
||||
set({ phase: 'done', total: result.total, warning: '',
|
||||
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
|
||||
await advance(id, result.total, toast);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Leaving the page mid-request: the server may never see it, so the capture is released here (a release
|
||||
// of a capture the server already used is a harmless 404).
|
||||
if (disposed || isAbort(error)) { release(body.capture_token); return false; }
|
||||
if (isLost(error)) {
|
||||
pending = { id, body };
|
||||
set({ phase: 'idle', warning: COPY.lost, canRetry: true });
|
||||
pending = { id, path, body, key, toast, lost };
|
||||
set({ phase: 'idle', warning: lost, canRetry: true });
|
||||
idleAgain(false);
|
||||
return false;
|
||||
}
|
||||
@@ -380,55 +420,82 @@ export function createReviewController({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
const { detail, choice } = state;
|
||||
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail || !choice) return false;
|
||||
// After an unanswered request, Apply resends that same decision once (its capture and body).
|
||||
if (pending?.id === state.selectedId && pending.body.choice === choice) return retry();
|
||||
/** A fresh capture of the open file, or null when there is no target; `false` when the decision must stop. */
|
||||
async function capture(detail) {
|
||||
if (!detail.target) return null;
|
||||
let observed;
|
||||
try {
|
||||
// M4: not aborted on leave, so the capture it answers can be released.
|
||||
observed = await api.post(OBSERVE_URL, { target: detail.target });
|
||||
} catch (error) {
|
||||
if (disposed || isAbort(error)) return false;
|
||||
set({ phase: 'idle', warning: error?.message || COPY.failed });
|
||||
idleAgain(RELOAD.has(error?.code));
|
||||
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
const taken = observed.capture;
|
||||
if (disposed) { release(taken?.token); return false; }
|
||||
// M3: the file was observed under another storage than the detail was read from.
|
||||
if (observed.library_epoch !== detail.library_epoch) {
|
||||
release(taken?.token);
|
||||
set({ phase: 'idle', warning: COPY.storageChanged });
|
||||
idleAgain(true);
|
||||
await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
return taken ?? null;
|
||||
}
|
||||
|
||||
/** One decision from the idle page: resend an unanswered same one, else capture and send. */
|
||||
async function decide({ key, path, fields, toast, lost }) {
|
||||
const { detail } = state;
|
||||
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail) return false;
|
||||
// After an unanswered request, the same decision resends that body once (its capture and body).
|
||||
if (pending?.id === state.selectedId && pending.key === key) return retry();
|
||||
dropPending();
|
||||
set({ phase: 'applying', warning: '', canRetry: false });
|
||||
let capture = null;
|
||||
if (detail.target) {
|
||||
let observed;
|
||||
try {
|
||||
// M4: not aborted on leave, so the capture it answers can be released.
|
||||
observed = await api.post(OBSERVE_URL, { target: detail.target });
|
||||
} catch (error) {
|
||||
if (disposed || isAbort(error)) return false;
|
||||
set({ phase: 'idle', warning: error?.message || COPY.failed });
|
||||
idleAgain(RELOAD.has(error?.code));
|
||||
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
capture = observed.capture;
|
||||
if (disposed) { release(capture?.token); return false; }
|
||||
// M3: the file was observed under another storage than the detail was read from.
|
||||
if (observed.library_epoch !== detail.library_epoch) {
|
||||
release(capture?.token);
|
||||
set({ phase: 'idle', warning: COPY.storageChanged });
|
||||
idleAgain(true);
|
||||
await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return send({
|
||||
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: capture?.token ?? null,
|
||||
sha256: capture?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, choice,
|
||||
});
|
||||
const taken = await capture(detail);
|
||||
if (taken === false) return false;
|
||||
return send({ path, key, toast, lost, body: {
|
||||
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: taken?.token ?? null,
|
||||
sha256: taken?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, ...fields,
|
||||
} });
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const { choice } = state;
|
||||
if (!choice) return Promise.resolve(false);
|
||||
return decide({ key: choice, path: 'decision', fields: { choice }, toast: COPY.applied });
|
||||
}
|
||||
|
||||
function leaveUnresolved() {
|
||||
return decide({ key: UNRESOLVED, path: 'decision', fields: { choice: UNRESOLVED }, toast: COPY.leftUnresolved });
|
||||
}
|
||||
|
||||
/** Link item `itemId`, which must still be the open one, to prompt `positiveId` with recipe `pairId` (null: no negative). */
|
||||
function link({ itemId, positiveId, pairId, title }) {
|
||||
if (state.selectedId !== itemId || !state.detail?.target) return Promise.resolve(false);
|
||||
return decide({ key: `link:${positiveId}:${pairId}`, path: 'link', toast: COPY.linked(title), lost: COPY.lostLink,
|
||||
fields: { positive_id: positiveId, pair_id: pairId } });
|
||||
}
|
||||
|
||||
/** Resend the unanswered decision once, with its own capture; false when there is nothing to resend. */
|
||||
async function retry() {
|
||||
if (!pending || state.phase !== 'idle' || pending.id !== state.selectedId) return false;
|
||||
const { body } = pending;
|
||||
const decision = pending;
|
||||
pending = null;
|
||||
return send(body);
|
||||
return send(decision);
|
||||
}
|
||||
|
||||
/** Leave for later: nothing is written; the next item opens, wrapping. */
|
||||
function leave() {
|
||||
if (state.phase === 'applying') return;
|
||||
if (state.phase === 'applying') return Promise.resolve();
|
||||
dropPending();
|
||||
set({ phase: 'done', toast: null, canRetry: false });
|
||||
const index = state.items.findIndex(entry => entry.id === state.selectedId);
|
||||
const next = state.items[index + 1] ?? state.items[0];
|
||||
if (!next || next.id === state.selectedId) { set({ canRetry: false, warning: '' }); return Promise.resolve(); }
|
||||
return select(next.id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -437,7 +504,7 @@ export function createReviewController({ api }) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, retry, leave,
|
||||
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, leaveUnresolved, link, retry, leave,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
dropPending();
|
||||
|
||||
+48
-22
@@ -7,8 +7,11 @@
|
||||
* "No images need a decision" and the scan/new-generation sentence, and nothing else.
|
||||
* - `.gallery-layout`: the queue's concept media cards (image-card.mjs; the image opens the shared viewer, Info selects
|
||||
* the item, Tag opens the shared Tag image dialog with the review scope note), then `section.surface` "Resolve the
|
||||
* association": filename, Tag image, Sampler context, the chosen path's prompt blocks and the actions Leave for later
|
||||
* and Apply association. Both return to Images (ruling R7).
|
||||
* association": filename, Tag image, Link to a prompt…, Sampler context, the chosen path's prompt blocks and the
|
||||
* actions Leave for later, Leave unresolved and Apply association. Each opens the next item (#536, overturning R7).
|
||||
* - D179: Leave unresolved is a button, not the select's last option; a disabled option names the server's reason.
|
||||
* - D180: with nothing to apply the select, the prompt blocks and Apply are gone: the panel says why and Link to a
|
||||
* prompt… is the primary action.
|
||||
* - States the concept lacks (loading, a failed read, a refused or unanswered decision, a limited or unavailable file)
|
||||
* are text: `p.warning-note` in the panel, or the page's status line (D119).
|
||||
* - The open page re-reads its queue on each `change` of the review change stream (`/prompt_manager/review/events`,
|
||||
@@ -29,6 +32,7 @@
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createReviewController, COPY, noticeTitle } from './review-state.mjs';
|
||||
import { createImageCard } from './image-card.mjs';
|
||||
import { openPromptLink } from './review-link.mjs';
|
||||
import { isTagButton, openImageTagging } from './image-tagging.mjs';
|
||||
import { mountPromptBlocks } from './prompt-blocks.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
@@ -38,6 +42,7 @@ import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { field } from './workspace-dialogs.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imageKey } from './images-state.mjs';
|
||||
|
||||
@@ -123,17 +128,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
const tagRow = el('div', undefined, 'row-actions spaced-15');
|
||||
const tagButton = createButton('');
|
||||
tagButton.append(createWorkspaceIcon('tagImage'), ' Tag image');
|
||||
tagRow.append(tagButton);
|
||||
const linkButton = createButton('Link to a prompt…');
|
||||
linkButton.classList.add('review-link-open');
|
||||
tagRow.append(tagButton, linkButton);
|
||||
const select = el('select');
|
||||
const contextField = field('Sampler context', select, 'review-context');
|
||||
const unreadable = el('div', undefined, 'review-unreadable'); // D180
|
||||
const warning = el('p', '', 'warning-note'); // D119: the states the concept lacks
|
||||
warning.setAttribute('role', 'status');
|
||||
const blocks = el('div');
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const later = createButton('Leave for later');
|
||||
const leaveUnresolved = createButton('Leave unresolved');
|
||||
leaveUnresolved.classList.add('review-leave-unresolved');
|
||||
const apply = createButton('Apply association', 'ca-primary');
|
||||
actions.append(later, apply);
|
||||
panel.append(panelTitle, filename, tagRow, contextField, warning, blocks, actions);
|
||||
actions.append(later, leaveUnresolved, apply);
|
||||
panel.append(panelTitle, filename, tagRow, contextField, unreadable, warning, blocks, actions);
|
||||
layout.append(grid, panel);
|
||||
|
||||
const live = el('p', NOT_LIVE, 'small review-live'); // D120: the change stream is down
|
||||
@@ -155,7 +165,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
sender, onSelect: image => inspection.select(image) });
|
||||
|
||||
const cards = new Map();
|
||||
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail;
|
||||
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail, shownUnreadable = null, shownDecisions = 0;
|
||||
|
||||
function createCard(item) {
|
||||
// D119: no selection checkbox and no star on a queue card.
|
||||
@@ -192,10 +202,26 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
}
|
||||
}
|
||||
|
||||
/** D180: nothing to apply, so say why and lead with the picker. */
|
||||
function renderUnreadable(state) {
|
||||
const none = state.detailStatus === 'ready' && !state.choice;
|
||||
contextField.hidden = blocks.hidden = apply.hidden = none;
|
||||
linkButton.classList.toggle('ca-primary', none && Boolean(state.detail?.target));
|
||||
unreadable.hidden = !state.unreadable;
|
||||
if (state.unreadable === shownUnreadable) return;
|
||||
shownUnreadable = state.unreadable;
|
||||
if (!state.unreadable) { unreadable.replaceChildren(); return; }
|
||||
const { count, reasons } = state.unreadable;
|
||||
unreadable.replaceChildren(el('p', COPY.unreadable(count)),
|
||||
...reasons.map(({ reason, count: paths }) => el('p', `${reason} (${paths} ${noun(paths, 'path')})`, 'small')));
|
||||
}
|
||||
|
||||
function renderPanel(state) {
|
||||
const item = state.items.find(entry => entry.id === state.selectedId);
|
||||
setText(filename, item?.filename ?? '');
|
||||
setDisabled(tagButton, !item?.target || state.phase === 'applying');
|
||||
setDisabled(linkButton, !item?.target || state.detailStatus !== 'ready' || state.phase !== 'idle');
|
||||
renderUnreadable(state);
|
||||
if (state.options !== shownOptions) {
|
||||
shownOptions = state.options;
|
||||
select.replaceChildren(...state.options.map(option => {
|
||||
@@ -212,16 +238,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
if (state.prompts !== shownPrompts || state.detailStatus !== shownDetail) {
|
||||
shownPrompts = state.prompts;
|
||||
shownDetail = state.detailStatus;
|
||||
if (state.detailStatus === 'ready') prompts.setState({ prompts: state.prompts });
|
||||
if (state.detailStatus === 'ready' && state.prompts) prompts.setState({ prompts: state.prompts });
|
||||
else if (state.detailStatus === 'ready') prompts.setState({ prompts: null });
|
||||
else prompts.setState({ loading: state.detailStatus === 'loading', error: state.detailStatus === 'error' });
|
||||
}
|
||||
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle');
|
||||
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle' || !state.choice);
|
||||
setDisabled(leaveUnresolved, state.detailStatus !== 'ready' || state.phase !== 'idle');
|
||||
setDisabled(later, state.phase === 'applying');
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
if (state.phase === 'done') return finish(state);
|
||||
if (state.decisions !== shownDecisions) {
|
||||
shownDecisions = state.decisions;
|
||||
showWorkspaceToast(state.toast);
|
||||
onDecision();
|
||||
}
|
||||
const ready = state.status === 'ready';
|
||||
if (!ready) {
|
||||
setText(status, state.status === 'error' ? COPY.loadFailed : COPY.loading);
|
||||
@@ -249,23 +281,17 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
} else continuation.remove();
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
function finish(state) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (state.toast) {
|
||||
showWorkspaceToast(state.toast);
|
||||
onDecision();
|
||||
}
|
||||
// Ruling R7: Apply association and Leave for later both return to Images.
|
||||
navigateTo('#/images');
|
||||
}
|
||||
|
||||
scope.listen(retryLoad, 'click', () => controller.load());
|
||||
scope.listen(more, 'click', () => { if (!isDisabled(more)) controller.loadMore(); });
|
||||
scope.listen(select, 'change', () => controller.choose(select.value));
|
||||
scope.listen(apply, 'click', () => { if (!isDisabled(apply)) controller.apply(); });
|
||||
scope.listen(later, 'click', () => { if (!isDisabled(later)) controller.leave(); });
|
||||
scope.listen(leaveUnresolved, 'click', () => { if (!isDisabled(leaveUnresolved)) controller.leaveUnresolved(); });
|
||||
scope.listen(linkButton, 'click', () => {
|
||||
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
|
||||
if (isDisabled(linkButton) || !item?.target) return;
|
||||
openPromptLink({ api, scope, controller, filename: item.filename, trigger: linkButton });
|
||||
});
|
||||
scope.listen(tagButton, 'click', () => {
|
||||
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
|
||||
if (isDisabled(tagButton) || !item?.target) return;
|
||||
@@ -292,7 +318,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
|
||||
let shownTotal = null;
|
||||
const reportTotal = state => {
|
||||
if (state.status !== 'ready' || state.phase === 'done' || state.total === shownTotal) return;
|
||||
if (state.status !== 'ready' || state.total === shownTotal) return;
|
||||
shownTotal = state.total;
|
||||
onTotal?.(state.total);
|
||||
};
|
||||
|
||||
@@ -433,6 +433,13 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.review-queue .image-open[aria-disabled="true"] { cursor: not-allowed; aspect-ratio: 1.33; /* D119 */ }
|
||||
/* D119: a long file name in the panel wraps inside the panel, where the concept's sample name never overflows. */
|
||||
.review-panel > p.small { min-inline-size: 0; overflow-wrap: anywhere; /* D119 */ }
|
||||
/* #536: the concept's rows hidden when there is nothing to apply (D180), and the picker (D181). */
|
||||
.review-panel > [hidden], .review-panel .dialog-actions > [hidden], #modal .field[hidden] { display: none; }
|
||||
.review-unreadable { display: grid; gap: 6px; margin-block: 12px; overflow-wrap: anywhere; }
|
||||
.review-link-results { display: grid; gap: 6px; max-block-size: 40vh; overflow-y: auto; }
|
||||
.review-link-results > button { justify-content: flex-start; inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.review-link-results > button[aria-pressed="true"] { border-color: var(--ca-accent); background: var(--soft-blue); }
|
||||
.review-panel button[aria-disabled="true"], .review-link-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
|
||||
/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
|
||||
in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
|
||||
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
|
||||
|
||||
Reference in New Issue
Block a user