Compare commits

...
Author SHA1 Message Date
Vito Sansevero cdf232dcf7 test(review): record the manual link route in the contract snapshot (#536) 2026-10-30 11:29:19 -07:00
Vito Sansevero 27c395ea0d fix(review): keep manual links out of the foreign repair and show dialog states
The repair tool could relink a manually linked row and drop its pair link.
It now skips those rows and refuses an apply when one became manual after the
plan. The picker's disabled action and chosen prompt are now visibly distinct.
2026-10-30 11:12:32 -07:00
Vito Sansevero 35c87d1ed5 feat(review): link an image to a library prompt from Needs review (#536)
Images whose sampler paths could not be traced offered nothing to choose.
Add Link to a prompt: search the Library and record the reviewer's choice
under Apply's capture guards and the one-prompt-per-file rules, with
provenance that names a manual choice and never reads as file evidence.

Disabled options name the server's reason, nothing selectable says why and
leads with the picker, Leave unresolved is its own action, and each decision
opens the next item instead of returning to Images (overturns ruling R7).
2026-10-30 10:01:59 -07:00
20 changed files with 1330 additions and 239 deletions
+19 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+66
View File
@@ -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
+6 -2
View File
@@ -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??''),
+85 -23
View File
@@ -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([]);
});
+42 -1
View File
@@ -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))
+1
View File
@@ -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",
+102 -24
View File
@@ -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');
+341
View File
@@ -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]
+45
View File
@@ -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()
+15
View File
@@ -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)
+10
View File
@@ -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":
+11
View File
@@ -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
View File
@@ -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
},
+183
View File
@@ -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();
}
+133 -66
View File
@@ -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
View File
@@ -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);
};
+7
View File
@@ -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`). */