// 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." ) ); }