Files
photoanalyzer/frontend/js/app.js
domverse 457dd5bd90 US02-06: Deliver Workflow, Safety, Library, Analysis, and Stats Views
Ports the safety review and the Photo Analyzer Library/Analyze/Stats
experiences onto the shared API + service layer, and adds the Workflow
home, enforcing the pipeline gates and the one-mutating-job policy.

Backend
- migration 0005 + models: safety_reviews (append-only, latest row is the
  current decision) and analysis_results (donor photos schema re-keyed to
  asset_id).
- SafetyService: persist scores/decisions, review queue with filters, and
  the EXIF safety checkpoint (mutually-exclusive sfw/nsfw keyword written,
  read back, current_sha256 refreshed) that upload eligibility depends on.
- AnalysisService: the privacy gate — the vision provider is called ONLY for
  canonical, confirmed-SFW assets; nsfw/undecided are recorded skipped without
  a request. Provider is an injected adapter (real OpenAI-compatible Gemini
  call extracted from photo_analyzer.analyze_image; a fake in tests).
- LibraryService: Library search + Stats read model ported from webapp/query.py
  (LIKE search in place of FTS5; facets, top tags, years, albums, people).
- WorkflowService + GET /api/v1/workflow: per-stage readiness derived from the
  source tables — counts, blockers, last-run, action, and an active_job that
  drives read-only-during-jobs. Safety scoring and analysis run as durable jobs
  under the library_write lock via new domain handlers, so a second mutating
  job is refused.
- routes: workflow, safety (queue/counts/decisions/jobs), analysis
  (counts/results/jobs), library (assets/facets/stats).

Frontend
- five views (frontend/js/views.js) on the US02-05 shell: Workflow stepper
  (status text+icon, not colour alone; actions disabled with a reason while a
  job runs), Safety review (filter tabs, decide, persists across reload),
  Library (search + cards), Analyze (counts + live job log via the SSE
  adapter), Stats. Shared DOM helpers extracted to dom.js; Workflow is the home
  route.

Tests
- integration: provider-call privacy (nsfw never reaches the provider),
  sfw→nsfw flip drops analysis eligibility, decision persistence, one-mutating-
  job rejection, workflow counts, and the exiftool safety-keyword write/verify.
- e2e: Workflow cards, actions disabled+explained during a job, safety
  decide-persists-across-reload, Library search, Stats, Analyze counts.
- traceability map updated for US02-05 and US02-06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:52:01 +02:00

371 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from "./api.js";
import { navigate, onRouteChange, parseHash } from "./router.js";
import {
renderAnalyze,
renderLibrary,
renderSafety,
renderStats,
renderWorkflow,
setRender,
} from "./views.js";
const root = document.getElementById("app");
// Tiny DOM helper — builds nodes without innerHTML injection.
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value == null || value === false) continue;
if (key === "class") node.className = value;
else if (key === "dataset") Object.assign(node.dataset, value);
else if (key.startsWith("on") && typeof value === "function")
node.addEventListener(key.slice(2).toLowerCase(), value);
else node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
}
return node;
}
function show(...nodes) {
root.replaceChildren(...nodes);
}
function setActiveNav(view) {
document.querySelectorAll("nav a[data-nav]").forEach((a) => {
if (a.dataset.nav === view) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
}
function errorBanner(message) {
return el("div", { class: "alert", role: "alert" }, message);
}
// ── Inventory view ──────────────────────────────────────────────────────────
async function renderInventory(params) {
setActiveNav("inventory");
const limit = Number(params.limit || 50);
const offset = Number(params.offset || 0);
let data;
try {
data = await api.listAssets({
limit,
offset,
availability: params.availability || "",
q: params.q || "",
});
} catch (error) {
show(errorBanner(`Failed to load inventory: ${error.message}`));
return;
}
const search = el("input", {
type: "search",
placeholder: "Filter by path…",
value: params.q || "",
"aria-label": "Filter by path",
onchange: (e) => navigate("/inventory", { ...params, q: e.target.value, offset: 0 }),
});
const availability = el(
"select",
{
"aria-label": "Availability",
onchange: (e) =>
navigate("/inventory", { ...params, availability: e.target.value, offset: 0 }),
},
el("option", { value: "" }, "All availability"),
...["active", "archived_offline", "archived_online"].map((v) =>
el("option", { value: v, selected: params.availability === v ? "selected" : false }, v)
)
);
const rows = data.items.map((asset, index) =>
el(
"tr",
{
"data-testid": "asset-row",
"data-asset-id": asset.id,
tabindex: "-1",
role: "row",
onclick: () => selectRow(index),
},
el("td", {}, asset.current_path || "(no path)"),
el("td", {}, asset.availability_state),
el("td", {}, asset.missing ? "missing" : "present"),
el("td", { class: "muted" }, asset.byte_size != null ? String(asset.byte_size) : "—")
)
);
const tbody = el("tbody", {}, ...rows);
let selected = -1;
function selectRow(index) {
if (index < 0 || index >= rows.length) return;
if (selected >= 0) rows[selected].setAttribute("aria-selected", "false");
selected = index;
rows[selected].setAttribute("aria-selected", "true");
rows[selected].focus();
}
tbody.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
selectRow(Math.min(selected + 1, rows.length - 1) || 0);
} else if (event.key === "ArrowUp") {
event.preventDefault();
selectRow(Math.max(selected - 1, 0));
}
});
const table = el(
"table",
{ "aria-label": "Inventory" },
el(
"thead",
{},
el("tr", {}, el("th", {}, "Path"), el("th", {}, "Availability"), el("th", {}, "State"), el("th", {}, "Bytes"))
),
tbody
);
const start = data.total === 0 ? 0 : offset + 1;
const end = Math.min(offset + limit, data.total);
const pager = el(
"div",
{ class: "pager" },
el(
"button",
{
"data-testid": "prev-page",
disabled: offset === 0 ? "disabled" : false,
onclick: () => navigate("/inventory", { ...params, offset: Math.max(offset - limit, 0) }),
},
"Previous"
),
el("span", { class: "muted", "data-testid": "page-info" }, `${start}–${end} of ${data.total}`),
el(
"button",
{
"data-testid": "next-page",
disabled: end >= data.total ? "disabled" : false,
onclick: () => navigate("/inventory", { ...params, offset: offset + limit }),
},
"Next"
)
);
show(
el("h1", {}, "Inventory"),
el("div", { class: "toolbar" }, search, availability),
table,
pager
);
if (rows.length) selectRow(0);
}
// ── Duplicate cluster list ───────────────────────────────────────────────────
async function renderClusters(params) {
setActiveNav("duplicates");
let data;
try {
data = await api.listClusters({ state: params.state || "", limit: 100 });
} catch (error) {
show(errorBanner(`Failed to load clusters: ${error.message}`));
return;
}
const items = data.items.map((cluster) =>
el(
"div",
{ class: "card", "data-testid": "cluster-card" },
el(
"div",
{},
el("a", { class: "link", href: `#/duplicates/${cluster.id}` }, `Cluster ${cluster.id.slice(0, 8)}`),
" ",
el("span", { class: `badge ${cluster.method}` }, cluster.method),
" ",
el("span", { class: `badge ${cluster.state}` }, cluster.state)
),
el("div", { class: "muted" }, `${cluster.members.length} members · confidence ${cluster.confidence}`)
)
);
show(
el("h1", {}, "Duplicate clusters"),
data.items.length ? el("div", { class: "cluster-grid" }, ...items) : el("p", { class: "muted" }, "No clusters detected.")
);
}
// ── Duplicate comparison ─────────────────────────────────────────────────────
async function renderClusterDetail(id, extra = {}) {
setActiveNav("duplicates");
let cluster;
try {
cluster = await api.getCluster(id);
} catch (error) {
show(errorBanner(`Failed to load cluster: ${error.message}`));
return;
}
const zoom = el("input", {
type: "range",
min: "1",
max: "3",
step: "0.1",
value: "1",
"aria-label": "Synchronized zoom",
"data-testid": "zoom",
});
const images = [];
const members = cluster.members.map((member) => {
const isCanonical = cluster.canonical_asset_id === member.asset_id;
const img = el("img", {
src: api.thumbnailUrl(member.asset_id, 512),
alt: member.current_path || member.asset_id,
loading: "lazy",
});
images.push(img);
const actions =
cluster.state === "decided" || cluster.state === "dismissed"
? []
: [
el(
"button",
{
"data-testid": "make-canonical",
onclick: () => decide(cluster, "canonical", member.asset_id),
},
"Keep this as canonical"
),
];
return el(
"div",
{ class: `member ${isCanonical ? "canonical" : ""}`, "data-testid": "member" },
img,
el(
"dl",
{},
el("dt", {}, "Path"),
el("dd", {}, member.current_path || "—"),
el("dt", {}, "Role"),
el("dd", {}, member.role),
el("dt", {}, "pHash distance"),
el("dd", {}, member.distance == null ? "—" : String(member.distance)),
el("dt", {}, "Bytes"),
el("dd", {}, member.byte_size == null ? "—" : String(member.byte_size))
),
...actions
);
});
zoom.addEventListener("input", () => {
const scale = zoom.value;
images.forEach((img) => (img.style.transform = `scale(${scale})`));
});
const decisionBar = el(
"div",
{ class: "decision-bar" },
el("span", { "data-testid": "cluster-state", class: `badge ${cluster.state}` }, `state: ${cluster.state}`),
el("span", { class: `badge ${cluster.confidence}` }, `confidence: ${cluster.confidence}`),
cluster.requires_confirmation ? el("span", { class: "muted" }, "fuzzy — confirmation required") : null,
el(
"button",
{ class: "danger", "data-testid": "not-duplicate", onclick: () => decide(cluster, "not_duplicate") },
"Not a duplicate"
),
el("button", { "data-testid": "defer", onclick: () => decide(cluster, "deferred") }, "Defer")
);
const nodes = [
el("p", {}, el("a", { class: "link", href: "#/duplicates" }, "← All clusters")),
el("h1", {}, `Compare cluster ${cluster.id.slice(0, 8)}`),
];
if (extra.conflict) {
nodes.push(
errorBanner("This cluster changed since you loaded it. Nothing was modified; showing the latest state.")
);
}
nodes.push(el("div", { class: "toolbar" }, el("label", {}, "Zoom "), zoom));
nodes.push(decisionBar);
if (extra.pending) nodes.push(confirmPanel(cluster, extra.pending));
nodes.push(el("div", { class: "cluster-grid" }, ...members));
show(...nodes);
}
function confirmPanel(cluster, pending) {
return el(
"div",
{ class: "confirm", role: "dialog", "aria-label": "Confirm decision", "data-testid": "confirm" },
el("p", {}, `Confirm "${pending.decision.replace("_", " ")}" for this fuzzy cluster?`),
el(
"div",
{ class: "decision-bar" },
el(
"button",
{
class: "primary",
"data-testid": "confirm-yes",
onclick: () => applyDecision(cluster, pending.decision, pending.canonicalId),
},
"Confirm"
),
el(
"button",
{ "data-testid": "confirm-no", onclick: () => renderClusterDetail(cluster.id) },
"Cancel"
)
)
);
}
// A fuzzy cluster needs explicit confirmation; exact/pixel apply immediately.
function decide(cluster, decision, canonicalId) {
if (cluster.requires_confirmation) {
renderClusterDetail(cluster.id, { pending: { decision, canonicalId } });
} else {
applyDecision(cluster, decision, canonicalId);
}
}
async function applyDecision(cluster, decision, canonicalId) {
try {
await api.decide(cluster.id, {
decision,
expected_version: cluster.version,
canonical_asset_id: canonicalId || null,
});
renderClusterDetail(cluster.id);
} catch (error) {
if (error.status === 409) {
renderClusterDetail(cluster.id, { conflict: true });
} else {
show(errorBanner(`Decision failed: ${error.message}`));
}
}
}
// ── Router ───────────────────────────────────────────────────────────────────
function render() {
const { path, params } = parseHash();
const clusterMatch = path.match(/^\/duplicates\/(.+)$/);
if (path === "/workflow" || path === "/") renderWorkflow(root, params);
else if (path === "/inventory") renderInventory(params);
else if (path === "/duplicates") renderClusters(params);
else if (clusterMatch) renderClusterDetail(clusterMatch[1]);
else if (path === "/safety") renderSafety(root, params);
else if (path === "/library") renderLibrary(root, params);
else if (path === "/analyze") renderAnalyze(root, params);
else if (path === "/stats") renderStats(root, params);
else show(errorBanner("Unknown view"));
}
// Let views re-render the current route after a mutation.
setRender(render);
onRouteChange(render);
render();