Files
photoanalyzer/frontend/js/renames.js

358 lines
12 KiB
JavaScript

// Renames view (US04-05): preview a rename plan, confirm it with the server-issued
// token, watch it run, and resolve anything an interruption left behind.
//
// Nothing here decides what is safe. Blocking issues, plan applicability, and
// recovery classifications all come from the server; the view only shows them and
// refuses to offer an action the server would reject.
import { api } from "./api.js";
import { el, errorBanner, setActiveNav } from "./dom.js";
// What the last confirm did, for this tab only. Deliberately not persisted: after a
// reload the page must show what the journal says, not what this page remembers.
let outcome = null;
let render = () => {};
export function setRenamesRender(fn) {
render = fn;
}
const BLOCKED_TITLE = "an unresolved rename must be resolved first";
export async function renderRenames(root, params = {}) {
setActiveNav("renames");
let plans, recovery;
try {
[plans, recovery] = await Promise.all([api.listPlans(), api.renameRecovery()]);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load renames: ${error.message}`));
return;
}
const selectedId = params.plan || (plans.items[0] && plans.items[0].id) || null;
let plan = null;
if (selectedId) {
try {
plan = await api.getPlan(selectedId);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load plan: ${error.message}`));
return;
}
}
const blocked = recovery.blocks_mutation;
root.replaceChildren(
el("h1", {}, "Renames"),
recoveryPanel(recovery),
el(
"div",
{ class: "toolbar" },
el(
"button",
{
class: "primary",
"data-testid": "build-plan",
disabled: blocked ? "disabled" : false,
title: blocked ? BLOCKED_TITLE : false,
onclick: () => run(() => api.buildPlan(), "Planning…"),
},
"Build plan from approved names"
)
),
outcomeBanner(),
plan
? planSection(plan, blocked)
: el(
"p",
{ class: "muted", "data-testid": "no-plan" },
"No rename plan yet — approve album names, then build a plan to preview the moves."
)
);
}
// ── preview ──────────────────────────────────────────────────────────────────
function planSection(plan, blocked) {
const blockingIssues = plan.operations.flatMap((operation) =>
operation.issues.filter((issue) => issue.blocking)
);
return el(
"div",
{ "data-testid": "plan" },
el(
"div",
{ class: "decision-bar" },
el("span", { class: `badge ${plan.state}`, "data-testid": "plan-state" }, plan.state),
el(
"span",
{ class: "badge", "data-testid": "operation-count" },
`${plan.operation_count} folder${plan.operation_count === 1 ? "" : "s"}`
),
el(
"span",
{ class: "badge", "data-testid": "affected-assets" },
`${plan.operations.reduce((sum, o) => sum + o.asset_count, 0)} photos`
),
blockingIssues.length
? el(
"span",
{ class: "badge attention", "data-testid": "blocking-count" },
`${blockingIssues.length} blocking issue${blockingIssues.length === 1 ? "" : "s"}`
)
: null
),
plan.blockers.length
? el(
"div",
{ class: "alert", role: "alert", "data-testid": "plan-blockers" },
`This plan cannot run: ${plan.blockers.join("; ")}`
)
: null,
operationsTable(plan),
confirmBlock(plan, blocked)
);
}
function operationType(operation) {
if (operation.case_only) return "case-only rename";
if (!operation.same_filesystem) return "cross-filesystem move";
return "rename";
}
function operationsTable(plan) {
if (!plan.operations.length) {
return el("p", { class: "muted", "data-testid": "no-operations" }, "This plan moves nothing.");
}
return el(
"table",
{ class: "grid", "data-testid": "operations" },
el(
"thead",
{},
el(
"tr",
{},
...["#", "Album", "From", "To", "Type", "Photos", "Issues", "State"].map((label) =>
el("th", { scope: "col" }, label)
)
)
),
el(
"tbody",
{},
...plan.operations.map((operation) =>
el(
"tr",
{ "data-testid": "operation-row", "data-album": operation.album },
el("td", {}, String(operation.sequence)),
el("td", { "data-testid": "op-album" }, operation.album),
// Full paths, not basenames: the whole point of the preview is that no
// move is a surprise.
el("td", { class: "path", "data-testid": "op-source" }, operation.source_path),
el("td", { class: "path", "data-testid": "op-destination" }, operation.destination_path),
el("td", { "data-testid": "op-type" }, operationType(operation)),
el("td", { "data-testid": "op-count" }, String(operation.asset_count)),
el(
"td",
{ "data-testid": "op-issues" },
operation.issues.length
? operation.issues.map((issue) =>
el(
"span",
{
class: `badge ${issue.blocking ? "attention" : ""}`,
"data-testid": "op-issue",
"data-code": issue.code,
title: issue.message,
},
`${issue.blocking ? "blocks" : "note"}: ${issue.code}`
)
)
: el("span", { class: "muted" }, "—")
),
el(
"td",
{},
el("span", { "data-testid": "op-state" }, operation.journal_state),
operation.verified_at
? el("span", { class: "badge complete", "data-testid": "op-verified" }, "verified")
: null,
operation.error_code
? el(
"div",
{ class: "blocker", "data-testid": "op-error" },
`${operation.error_code}: ${operation.error_message || ""}`
)
: null
)
)
)
)
);
}
// ── confirmation ─────────────────────────────────────────────────────────────
// Journal states whose content can still be moved back; `complete` is not one of
// them (photo_pipeline/services/rename_journal.py: complete is terminal).
const REVERSIBLE = new Set(["moved", "database_updated", "verified", "rollback_required"]);
function rollbackable(plan) {
return plan.operations.some((operation) => REVERSIBLE.has(operation.journal_state));
}
function confirmBlock(plan, blocked) {
const applyable = plan.applicable && !blocked;
return el(
"div",
{ class: "card", "data-testid": "confirm" },
el("h2", {}, "Confirm"),
// The token is shown, not just sent: a confirmation the user cannot see is a
// confirmation they cannot check against what the preview claimed.
el(
"p",
{ class: "muted", "data-testid": "confirm-token" },
`Plan ${plan.id} · version ${plan.version} · checksum ${plan.checksum.slice(0, 12)}`
),
el(
"p",
{ "data-testid": "cancel-note" },
"Once confirmed, the run cannot be cancelled part-way: each folder is moved, " +
"verified, and recorded before the next one starts. An interruption is " +
"recoverable from the journal — it is never left half-applied."
),
el(
"div",
{ class: "toolbar" },
el(
"button",
{
class: "primary",
"data-testid": "apply-plan",
disabled: applyable ? false : "disabled",
title: blocked
? BLOCKED_TITLE
: plan.applicable
? false
: `plan is ${plan.state} — it cannot be applied`,
onclick: () =>
run(
() =>
api.applyPlan(plan.id, {
expected_version: plan.version,
expected_checksum: plan.checksum,
}),
`Applying ${plan.operation_count} rename(s)…`
),
},
`Apply ${plan.operation_count} rename(s)`
),
// Rollback is recovery, not undo: a completed rename is terminal and stays
// that way. The button appears only while operations are still reversible.
rollbackable(plan)
? el(
"button",
{
"data-testid": "rollback-plan",
onclick: () => run(() => api.rollbackPlan(plan.id), "Rolling back…"),
},
"Roll back the unfinished moves"
)
: null
),
el("p", { role: "status", "aria-live": "polite", "data-testid": "apply-progress" }, "")
);
}
// Runs a mutating call, showing progress while it is in flight and leaving the
// result — including a stale-confirmation conflict — visible afterwards.
async function run(action, progressText) {
const progress = document.querySelector('[data-testid="apply-progress"]');
if (progress) progress.textContent = progressText;
try {
outcome = { kind: "ok", result: await action() };
} catch (error) {
outcome = error.status === 409 ? { kind: "conflict" } : { kind: "error", error };
}
render();
}
function outcomeBanner() {
if (!outcome) return null;
if (outcome.kind === "conflict") {
return el(
"div",
{ class: "alert", role: "alert", "data-testid": "conflict" },
"This plan changed on the server since it was loaded. Nothing was applied; " +
"the preview below is the current state — review it and confirm again."
);
}
if (outcome.kind === "error") {
return el(
"div",
{ class: "alert", role: "alert", "data-testid": "apply-error" },
`Failed: ${outcome.error.message}`
);
}
const result = outcome.result;
if (result.applied === undefined) return null; // a build or recovery call
return el(
"div",
{ class: "alert", role: "status", "data-testid": "apply-result" },
`Applied ${result.applied}, failed ${result.failed}, skipped ${result.skipped}. ` +
`The plan is now ${result.state}.`
);
}
// ── recovery ─────────────────────────────────────────────────────────────────
function recoveryPanel(recovery) {
if (!recovery.items.length) return null;
const recoverable = recovery.items.filter((item) => item.classification !== "manual");
return el(
"div",
{ class: "alert", role: "alert", "data-testid": "recovery-banner" },
el(
"strong",
{},
recovery.blocks_mutation
? "A rename was interrupted — other changes are paused"
: "A rename is unresolved"
),
el(
"p",
{ "data-testid": "recovery-explanation" },
recovery.blocks_mutation
? "Some folders on disk may disagree with the database. Planning, applying, and " +
"album-name changes stay blocked until this is resolved; browsing is unaffected."
: "These operations never reached a terminal state."
),
el(
"ul",
{},
...recovery.items.map((item) =>
el(
"li",
{ "data-testid": "recovery-item", "data-classification": item.classification },
`${item.album}: ${item.journal_state}${item.classification}${item.reason} ` +
`(${item.source_path}${item.destination_path})`
)
)
),
// Only evidence-safe work is offered. Ambiguous operations get no button at
// all — an action the server would refuse must not look available.
recoverable.length
? el(
"button",
{
"data-testid": "resolve-recovery",
onclick: () => run(() => api.resolveRecovery(), "Resolving…"),
},
`Resolve ${recoverable.length} recoverable rename(s) from evidence`
)
: el(
"p",
{ "data-testid": "manual-only" },
"No automatic action is safe here: source and destination both exist, or neither " +
"does. Inspect the paths above and resolve them by hand."
)
);
}