436 lines
18 KiB
Python
436 lines
18 KiB
Python
"""RenameService — build, validate, and export guarded rename plans (US04-01).
|
|
|
|
A plan turns approved album proposals into an itemized, reviewable preview: for every
|
|
album, the source folder, the destination folder, the assets that move with it, their
|
|
expected hashes, and every validation issue found. **Nothing here touches the
|
|
filesystem** — it only reads to validate. Applying a plan is US04-03.
|
|
|
|
Validation is the point of the story, so every refusal is a structured issue rather
|
|
than an exception (concept §7 "guarded rename plan"):
|
|
|
|
- ``source_missing`` — the registered folder is gone
|
|
- ``source_changed`` — an asset's bytes changed since inventory
|
|
- ``duplicate_target`` — two operations claim the same destination
|
|
- ``destination_exists`` — an unexpected path already occupies the destination
|
|
- ``case_only`` — informational: needs the staged procedure on
|
|
case-insensitive filesystems (not a blocker)
|
|
- ``unicode_collision`` — two destinations differ only by Unicode normalization
|
|
- ``root_escape`` — a path resolves outside the configured library
|
|
- ``excluded_path`` — a path lies under ``_IGNORE/``
|
|
- ``symlink`` — source or destination is a symlink
|
|
- ``cross_filesystem`` — destination is on another device (copy-verify-delete)
|
|
|
|
A plan with any blocking issue is ``invalid`` and can never be applied. Issues that
|
|
are purely informational (``case_only``, ``cross_filesystem``) are recorded on the
|
|
operation so the apply stage picks the right procedure.
|
|
|
|
The JSON export is portable evidence: schema version, checksum, and the full operation
|
|
list, with no credentials and no absolute host paths beyond the library itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import unicodedata
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from photo_pipeline import path_policy
|
|
from photo_pipeline.models import AlbumProposal, Asset, RenameOperation, RenamePlan
|
|
from photo_pipeline.services.albums import album_label
|
|
|
|
EXPORT_SCHEMA_VERSION = 1
|
|
MOVE_FOLDER = "move_folder"
|
|
|
|
# Issues that make a plan unapplyable. Everything else is advisory.
|
|
BLOCKING_CODES = frozenset(
|
|
{
|
|
"source_missing",
|
|
"source_changed",
|
|
"duplicate_target",
|
|
"destination_exists",
|
|
"unicode_collision",
|
|
"root_escape",
|
|
"excluded_path",
|
|
"symlink",
|
|
"no_library_root",
|
|
}
|
|
)
|
|
|
|
|
|
class RenameError(RuntimeError):
|
|
"""Invalid request against a plan (unknown id, nothing to plan)."""
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _issue(code: str, message: str) -> dict:
|
|
return {"code": code, "message": message}
|
|
|
|
|
|
def _nfc(value: str) -> str:
|
|
return unicodedata.normalize("NFC", value)
|
|
|
|
|
|
def plan_checksum(operations: list[dict]) -> str:
|
|
"""Content hash over the operations that define a plan. Two plans with the same
|
|
moves have the same checksum, so a stale confirmation is detectable."""
|
|
payload = json.dumps(
|
|
[
|
|
{
|
|
"sequence": op["sequence"],
|
|
"source_path": op["source_path"],
|
|
"destination_path": op["destination_path"],
|
|
"asset_ids": sorted(op["asset_ids"]),
|
|
}
|
|
for op in sorted(operations, key=lambda op: op["sequence"])
|
|
],
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
class RenameService:
|
|
def __init__(self, session_factory: sessionmaker, *, library_roots: tuple = ()) -> None:
|
|
self._session_factory = session_factory
|
|
self._roots = tuple(Path(root) for root in library_roots)
|
|
|
|
# ── planning ──────────────────────────────────────────────────────────────
|
|
|
|
def build_plan(self) -> dict:
|
|
"""Build a plan from every approved album proposal. Read-only."""
|
|
with self._session_factory() as session:
|
|
approved = list(
|
|
session.scalars(
|
|
select(AlbumProposal)
|
|
.where(AlbumProposal.status == "approved")
|
|
.order_by(AlbumProposal.album)
|
|
)
|
|
)
|
|
if not approved:
|
|
raise RenameError("no approved album proposals to plan")
|
|
assets = list(
|
|
session.scalars(
|
|
select(Asset).where(
|
|
Asset.canonical_asset_id.is_(None),
|
|
Asset.availability_state == "active",
|
|
Asset.current_path.is_not(None),
|
|
)
|
|
)
|
|
)
|
|
|
|
by_album: dict[str, list[Asset]] = {}
|
|
for asset in assets:
|
|
by_album.setdefault(album_label(asset.current_path, self._roots), []).append(asset)
|
|
|
|
operations: list[dict] = []
|
|
for sequence, proposal in enumerate(approved):
|
|
members = sorted(by_album.get(proposal.album, []), key=lambda a: a.current_path)
|
|
operations.append(self._operation(sequence, proposal, members))
|
|
|
|
self._detect_cross_operation_issues(operations)
|
|
return self._persist(operations)
|
|
|
|
def _operation(self, sequence: int, proposal: AlbumProposal, members: list[Asset]) -> dict:
|
|
final_name = proposal.final_name or proposal.proposed_name or ""
|
|
issues: list[dict] = []
|
|
|
|
source = self._source_folder(members)
|
|
destination = source.parent / final_name if source else None
|
|
|
|
operation = {
|
|
"sequence": sequence,
|
|
"operation": MOVE_FOLDER,
|
|
"album": proposal.album,
|
|
"source_path": str(source) if source else "",
|
|
"destination_path": str(destination) if destination else "",
|
|
"asset_ids": [asset.id for asset in members],
|
|
"expected_sha256": {
|
|
asset.id: asset.current_sha256 for asset in members if asset.current_sha256
|
|
},
|
|
"asset_count": len(members),
|
|
"same_filesystem": None,
|
|
"case_only": False,
|
|
"issues": issues,
|
|
}
|
|
|
|
if source is None:
|
|
issues.append(
|
|
_issue("source_missing", f"no active assets remain in {proposal.album!r}")
|
|
)
|
|
return operation
|
|
if not final_name:
|
|
issues.append(_issue("empty_name", f"proposal for {proposal.album!r} has no name"))
|
|
return operation
|
|
|
|
self._validate_paths(operation, source, destination, issues)
|
|
self._validate_sources(operation, members, issues)
|
|
return operation
|
|
|
|
def _source_folder(self, members: list[Asset]) -> Path | None:
|
|
if not members:
|
|
return None
|
|
return Path(members[0].current_path).parent
|
|
|
|
def _validate_paths(
|
|
self, operation: dict, source: Path, destination: Path, issues: list[dict]
|
|
) -> None:
|
|
if not self._roots:
|
|
issues.append(_issue("no_library_root", "no library root is configured"))
|
|
return
|
|
|
|
for label, path in (("source", source), ("destination", destination)):
|
|
if path_policy.is_excluded(path):
|
|
issues.append(_issue("excluded_path", f"{label} is under an excluded directory"))
|
|
root = self._root_for(path)
|
|
if root is None:
|
|
issues.append(
|
|
_issue("root_escape", f"{label} {path} is outside every library root")
|
|
)
|
|
|
|
if source.is_symlink() or destination.is_symlink():
|
|
issues.append(_issue("symlink", "refusing to rename through a symlink"))
|
|
|
|
if not source.exists():
|
|
issues.append(_issue("source_missing", f"source folder {source} does not exist"))
|
|
return
|
|
|
|
# A symlinked source could still resolve outside the root even when its
|
|
# literal path looks fine; check the resolved location too.
|
|
root = self._root_for(source)
|
|
if root is not None:
|
|
try:
|
|
path_policy.resolve_within(root, source)
|
|
except path_policy.PathPolicyError as error:
|
|
issues.append(_issue("root_escape", str(error)))
|
|
|
|
operation["case_only"] = (
|
|
source != destination and source.as_posix().lower() == destination.as_posix().lower()
|
|
)
|
|
operation["same_filesystem"] = self._same_filesystem(source, destination)
|
|
if operation["same_filesystem"] is False:
|
|
issues.append(
|
|
_issue(
|
|
"cross_filesystem", "destination is on another filesystem; copy-verify-delete"
|
|
)
|
|
)
|
|
|
|
# A destination that already exists is only acceptable for a case-only
|
|
# rename, where source and destination are the same directory entry.
|
|
if destination.exists() and not operation["case_only"]:
|
|
issues.append(_issue("destination_exists", f"destination {destination} already exists"))
|
|
|
|
def _validate_sources(self, operation: dict, members: list[Asset], issues: list[dict]) -> None:
|
|
"""Every asset must still be present with the bytes inventory recorded."""
|
|
for asset in members:
|
|
path = Path(asset.current_path)
|
|
if not path.exists():
|
|
issues.append(_issue("source_missing", f"asset file {path} is missing"))
|
|
continue
|
|
expected = asset.byte_size
|
|
if expected is not None and path.stat().st_size != expected:
|
|
issues.append(
|
|
_issue("source_changed", f"asset file {path} changed since inventory")
|
|
)
|
|
|
|
def _root_for(self, path: Path) -> Path | None:
|
|
for root in self._roots:
|
|
resolved_root = Path(root)
|
|
if path == resolved_root or resolved_root in path.parents:
|
|
return resolved_root
|
|
return None
|
|
|
|
def _same_filesystem(self, source: Path, destination: Path) -> bool | None:
|
|
anchor = destination.parent
|
|
if not source.exists() or not anchor.exists():
|
|
return None
|
|
try:
|
|
return os.stat(source).st_dev == os.stat(anchor).st_dev
|
|
except OSError:
|
|
return None
|
|
|
|
def _detect_cross_operation_issues(self, operations: list[dict]) -> None:
|
|
"""Two operations must never target the same destination — including when the
|
|
names differ only by case or by Unicode normalization."""
|
|
exact: dict[str, int] = {}
|
|
folded: dict[str, int] = {}
|
|
for operation in operations:
|
|
destination = operation["destination_path"]
|
|
if not destination:
|
|
continue
|
|
if destination in exact:
|
|
operation["issues"].append(
|
|
_issue("duplicate_target", f"destination {destination} is claimed twice")
|
|
)
|
|
else:
|
|
exact[destination] = operation["sequence"]
|
|
|
|
key = _nfc(destination).casefold()
|
|
if key in folded and folded[key] != operation["sequence"]:
|
|
operation["issues"].append(
|
|
_issue(
|
|
"unicode_collision",
|
|
f"destination {destination} collides with another target after "
|
|
"case/Unicode normalization",
|
|
)
|
|
)
|
|
else:
|
|
folded.setdefault(key, operation["sequence"])
|
|
|
|
def _persist(self, operations: list[dict]) -> dict:
|
|
blockers = sorted(
|
|
{
|
|
issue["code"]
|
|
for operation in operations
|
|
for issue in operation["issues"]
|
|
if issue["code"] in BLOCKING_CODES
|
|
}
|
|
)
|
|
now = _now()
|
|
plan_id = str(uuid.uuid4())
|
|
with self._session_factory() as session:
|
|
session.add(
|
|
RenamePlan(
|
|
id=plan_id,
|
|
state="invalid" if blockers else "validated",
|
|
schema_version=EXPORT_SCHEMA_VERSION,
|
|
checksum=plan_checksum(operations),
|
|
blockers=json.dumps(blockers),
|
|
operation_count=len(operations),
|
|
version=1,
|
|
validated_at=now,
|
|
)
|
|
)
|
|
# Flush the parent before its operations so the foreign key resolves.
|
|
session.flush()
|
|
for operation in operations:
|
|
session.add(
|
|
RenameOperation(
|
|
id=str(uuid.uuid4()),
|
|
plan_id=plan_id,
|
|
sequence=operation["sequence"],
|
|
operation=operation["operation"],
|
|
album=operation["album"],
|
|
source_path=operation["source_path"],
|
|
destination_path=operation["destination_path"],
|
|
asset_ids=json.dumps(operation["asset_ids"]),
|
|
expected_sha256=json.dumps(operation["expected_sha256"]),
|
|
asset_count=operation["asset_count"],
|
|
same_filesystem=operation["same_filesystem"],
|
|
case_only=operation["case_only"],
|
|
issues=json.dumps(operation["issues"], ensure_ascii=False),
|
|
)
|
|
)
|
|
session.commit()
|
|
return self.get(plan_id)
|
|
|
|
# ── reads ─────────────────────────────────────────────────────────────────
|
|
|
|
def get(self, plan_id: str) -> dict | None:
|
|
with self._session_factory() as session:
|
|
plan = session.get(RenamePlan, plan_id)
|
|
if plan is None:
|
|
return None
|
|
operations = list(
|
|
session.scalars(
|
|
select(RenameOperation)
|
|
.where(RenameOperation.plan_id == plan_id)
|
|
.order_by(RenameOperation.sequence)
|
|
)
|
|
)
|
|
return _plan_dict(plan, operations)
|
|
|
|
def list_plans(self) -> dict:
|
|
with self._session_factory() as session:
|
|
plans = list(session.scalars(select(RenamePlan).order_by(RenamePlan.created_at.desc())))
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": plan.id,
|
|
"state": plan.state,
|
|
"operation_count": plan.operation_count,
|
|
"blockers": json.loads(plan.blockers or "[]"),
|
|
"checksum": plan.checksum,
|
|
"version": plan.version,
|
|
}
|
|
for plan in plans
|
|
],
|
|
"total": len(plans),
|
|
}
|
|
|
|
def export(self, plan_id: str) -> dict:
|
|
"""Portable JSON evidence for a plan. Contains no credentials."""
|
|
plan = self.get(plan_id)
|
|
if plan is None:
|
|
raise RenameError(f"unknown rename plan {plan_id!r}")
|
|
return {
|
|
"schema_version": plan["schema_version"],
|
|
"plan_id": plan["id"],
|
|
"state": plan["state"],
|
|
"checksum": plan["checksum"],
|
|
"blockers": plan["blockers"],
|
|
"operations": [
|
|
{
|
|
"sequence": operation["sequence"],
|
|
"operation": operation["operation"],
|
|
"album": operation["album"],
|
|
"source_path": operation["source_path"],
|
|
"destination_path": operation["destination_path"],
|
|
"asset_ids": operation["asset_ids"],
|
|
"asset_count": operation["asset_count"],
|
|
"case_only": operation["case_only"],
|
|
"same_filesystem": operation["same_filesystem"],
|
|
"issues": operation["issues"],
|
|
}
|
|
for operation in plan["operations"]
|
|
],
|
|
}
|
|
|
|
|
|
def _plan_dict(plan: RenamePlan, operations: list[RenameOperation]) -> dict:
|
|
return {
|
|
"id": plan.id,
|
|
"state": plan.state,
|
|
"schema_version": plan.schema_version,
|
|
"checksum": plan.checksum,
|
|
"blockers": json.loads(plan.blockers or "[]"),
|
|
"operation_count": plan.operation_count,
|
|
"version": plan.version,
|
|
"applicable": plan.state == "validated",
|
|
"operations": [
|
|
{
|
|
"sequence": operation.sequence,
|
|
"operation": operation.operation,
|
|
"album": operation.album,
|
|
"source_path": operation.source_path,
|
|
"destination_path": operation.destination_path,
|
|
"asset_ids": json.loads(operation.asset_ids or "[]"),
|
|
"expected_sha256": json.loads(operation.expected_sha256 or "{}"),
|
|
"asset_count": operation.asset_count,
|
|
"same_filesystem": operation.same_filesystem,
|
|
"case_only": operation.case_only,
|
|
# Severity is decided here, not in the browser: a client that has to
|
|
# keep its own copy of BLOCKING_CODES will eventually disagree with
|
|
# the validator about whether a plan can run.
|
|
"issues": [
|
|
{**issue, "blocking": issue["code"] in BLOCKING_CODES}
|
|
for issue in json.loads(operation.issues or "[]")
|
|
],
|
|
"journal_state": operation.journal_state,
|
|
"verified_at": operation.verified_at.isoformat() if operation.verified_at else None,
|
|
"error_code": operation.error_code,
|
|
"error_message": operation.error_message,
|
|
}
|
|
for operation in operations
|
|
],
|
|
}
|