US04-05: Operate Rename Plans in the Browser (#69)
This commit was merged in pull request #69.
This commit is contained in:
@@ -99,4 +99,19 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
...opts,
|
||||
}),
|
||||
|
||||
// ── Renames: plan, confirm, apply, recover ──────────────────────────────
|
||||
listPlans: (opts = {}) => request("/rename-plans", opts),
|
||||
buildPlan: (opts = {}) => request("/rename-plans", { method: "POST", ...opts }),
|
||||
getPlan: (id, opts = {}) => request(`/rename-plans/${encodeURIComponent(id)}`, opts),
|
||||
applyPlan: (id, payload, opts = {}) =>
|
||||
request(`/rename-plans/${encodeURIComponent(id)}/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
...opts,
|
||||
}),
|
||||
rollbackPlan: (id, opts = {}) =>
|
||||
request(`/rename-plans/${encodeURIComponent(id)}/rollback`, { method: "POST", ...opts }),
|
||||
renameRecovery: (opts = {}) => request("/rename-recovery", opts),
|
||||
resolveRecovery: (opts = {}) => request("/rename-recovery/resolve", { method: "POST", ...opts }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "./api.js";
|
||||
import { navigate, onRouteChange, parseHash } from "./router.js";
|
||||
import { renderRenames, setRenamesRender } from "./renames.js";
|
||||
import {
|
||||
renderAlbums,
|
||||
renderAnalyze,
|
||||
@@ -362,11 +363,13 @@ function render() {
|
||||
else if (path === "/library") renderLibrary(root, params);
|
||||
else if (path === "/analyze") renderAnalyze(root, params);
|
||||
else if (path === "/albums") renderAlbums(root, params);
|
||||
else if (path === "/renames") renderRenames(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);
|
||||
setRenamesRender(render);
|
||||
onRouteChange(render);
|
||||
render();
|
||||
|
||||
357
frontend/js/renames.js
Normal file
357
frontend/js/renames.js
Normal file
@@ -0,0 +1,357 @@
|
||||
// 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."
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -327,11 +327,15 @@ export async function renderStats(root) {
|
||||
// Approving never renames anything — it records the approved name only.
|
||||
export async function renderAlbums(root, params = {}) {
|
||||
setActiveNav("albums");
|
||||
let evidence, proposals;
|
||||
let evidence, proposals, recovery;
|
||||
try {
|
||||
[evidence, proposals] = await Promise.all([
|
||||
[evidence, proposals, recovery] = await Promise.all([
|
||||
api.albumEvidence({ limit: 500 }),
|
||||
api.albumProposals(),
|
||||
// An unresolved rename means folder paths on disk may disagree with the
|
||||
// database, so the server refuses name changes until it is resolved. Ask up
|
||||
// front rather than offering a button that is guaranteed to fail.
|
||||
api.renameRecovery(),
|
||||
]);
|
||||
} catch (error) {
|
||||
root.replaceChildren(errorBanner(`Failed to load albums: ${error.message}`));
|
||||
@@ -370,17 +374,27 @@ export async function renderAlbums(root, params = {}) {
|
||||
})
|
||||
);
|
||||
|
||||
const renameBlocked = recovery.blocks_mutation;
|
||||
const detail = selected
|
||||
? albumDetail(
|
||||
root,
|
||||
folders.find((folder) => folder.album === selected),
|
||||
byAlbum.get(selected),
|
||||
params
|
||||
params,
|
||||
renameBlocked
|
||||
)
|
||||
: el("p", { class: "muted" }, "No albums with evidence yet.");
|
||||
|
||||
root.replaceChildren(
|
||||
el("h1", {}, "Albums"),
|
||||
renameBlocked
|
||||
? el(
|
||||
"div",
|
||||
{ class: "alert", role: "alert", "data-testid": "rename-blocked" },
|
||||
"An interrupted rename is unresolved. Name changes are paused until it is " +
|
||||
"resolved on the Renames page; browsing stays available."
|
||||
)
|
||||
: null,
|
||||
el(
|
||||
"div",
|
||||
{ class: "toolbar" },
|
||||
@@ -389,6 +403,8 @@ export async function renderAlbums(root, params = {}) {
|
||||
{
|
||||
class: "primary",
|
||||
"data-testid": "generate-proposals",
|
||||
disabled: renameBlocked ? "disabled" : false,
|
||||
title: renameBlocked ? "an unresolved rename must be resolved first" : false,
|
||||
onclick: async () => {
|
||||
try {
|
||||
await api.generateProposals({});
|
||||
@@ -405,7 +421,7 @@ export async function renderAlbums(root, params = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function albumDetail(root, folder, proposal, params) {
|
||||
function albumDetail(root, folder, proposal, params, renameBlocked = false) {
|
||||
if (!folder) return el("p", { class: "muted" }, "Album not found.");
|
||||
|
||||
const evidenceBlock = el(
|
||||
@@ -473,8 +489,13 @@ function albumDetail(root, folder, proposal, params) {
|
||||
{
|
||||
class: "primary",
|
||||
"data-testid": "approve",
|
||||
disabled: proposal.stale || proposal.status === "error" ? "disabled" : false,
|
||||
title: proposal.stale ? "evidence changed — regenerate first" : false,
|
||||
disabled:
|
||||
renameBlocked || proposal.stale || proposal.status === "error" ? "disabled" : false,
|
||||
title: renameBlocked
|
||||
? "an unresolved rename must be resolved first"
|
||||
: proposal.stale
|
||||
? "evidence changed — regenerate first"
|
||||
: false,
|
||||
onclick: () => mutate(root, () => api.approveProposal(proposal.album, { expected_version: proposal.version }), params),
|
||||
},
|
||||
proposal.status === "approved" ? "Approved" : "Approve name"
|
||||
|
||||
Reference in New Issue
Block a user