"""Rename plan API (US04-01): build, inspect, and export plans. Planning is read-only — it previews and validates but never touches the filesystem. Applying a plan is a later story, so there is deliberately no apply endpoint here. """ from __future__ import annotations from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from photo_pipeline.schemas import ApplyPlanRequest from photo_pipeline.services.rename_apply import ( ApplyConflict, ApplyError, RenameApplyService, ) from photo_pipeline.services.renames import RenameError, RenameService router = APIRouter(tags=["renames"]) def _service(request: Request) -> RenameService: return RenameService( request.app.state.session_factory, library_roots=tuple(request.app.state.config.library_roots), ) def _apply_service(request: Request) -> RenameApplyService: return RenameApplyService( request.app.state.session_factory, library_roots=tuple(request.app.state.config.library_roots), ) def _error(status: int, code: str, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}}) @router.post("/rename-plans") def build_plan(request: Request): try: return _service(request).build_plan() except RenameError as error: return _error(422, "nothing_to_plan", str(error)) @router.get("/rename-plans") def list_plans(request: Request) -> dict: return _service(request).list_plans() @router.get("/rename-plans/{plan_id}") def get_plan(plan_id: str, request: Request): plan = _service(request).get(plan_id) if plan is None: return _error(404, "not_found", f"unknown rename plan {plan_id}") return plan @router.get("/rename-plans/{plan_id}/export") def export_plan(plan_id: str, request: Request): try: return _service(request).export(plan_id) except RenameError as error: return _error(404, "not_found", str(error)) @router.post("/rename-plans/{plan_id}/apply") def apply_plan(plan_id: str, body: ApplyPlanRequest, request: Request): """Apply a confirmed plan. The confirmation must carry the current plan version (and may carry the checksum) so stale browser state cannot run a changed plan.""" try: return _apply_service(request).apply( plan_id, expected_version=body.expected_version, expected_checksum=body.expected_checksum, ) except ApplyConflict as error: return _error(409, "version_conflict", str(error)) except ApplyError as error: return _error(422, "cannot_apply", str(error)) @router.get("/rename-recovery") def recovery_status(request: Request) -> dict: """Every unresolved rename with its evidence-derived classification.""" journal = _apply_service(request).journal return { "blocks_mutation": journal.blocks_mutation(), "items": journal.classify_all(), } @router.post("/rename-recovery/resolve") def resolve_recovery(request: Request) -> dict: return _apply_service(request).recover() @router.post("/rename-plans/{plan_id}/rollback") def rollback_plan(plan_id: str, request: Request): try: return _apply_service(request).rollback_plan(plan_id) except ApplyError as error: return _error(422, "cannot_rollback", str(error))