cleanup/batch/apply.py
2026-09-05 15:24:52 +02:00

207 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""
Reads a CSV produced by stage.py (after you've filled it in) and applies
it: moves/renames every row marked status=confirmed, records status=skip
into the shared progress file, and leaves status=pending rows completely
untouched.
Defaults to a DRY RUN — prints what it would do and logs it, but touches
nothing on disk. Add --apply to actually do it.
Usage:
python3 batch/apply.py /path/to/folder staging.csv # dry run
python3 batch/apply.py /path/to/folder staging.csv --apply # for real
"""
import argparse
import csv
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core import scan, apply_batch, find_duplicate_identities
from core.config import STATE_FILENAME, LOG_FILENAME
REQUIRED_COLUMNS = {
"status", "current_path", "date_override", "topic", "subtopic",
"extra", "more", "version", "dest_folder", "keep_filename", "identity",
}
def _read_rows(csv_path: Path) -> list:
"""Reads the CSV defensively: strips a UTF-8 BOM if LibreOffice added
one, and normalises header casing — a header accidentally typed or
autocorrected as 'Status' instead of 'status' shouldn't crash the
whole run."""
with open(csv_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
rows = [{k.strip().lower(): v for k, v in row.items()} for row in reader]
if not rows:
print(f"{csv_path} has no data rows.")
sys.exit(1)
missing = REQUIRED_COLUMNS - set(rows[0].keys())
if missing:
print(f"{csv_path} is missing expected column(s): {', '.join(sorted(missing))}")
print(f"Columns actually found: {', '.join(rows[0].keys())}")
print(f"This usually means the wrong file got opened/saved in the spreadsheet "
f"(e.g. {LOG_FILENAME} instead of the staging CSV), or a header got "
f"renamed by accident. Check the file and try again.")
sys.exit(1)
return rows
return str(value).strip().lower() in ("true", "yes", "y", "1")
def _load_state(root: Path) -> dict:
p = root / STATE_FILENAME
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def _save_state(root: Path, state: dict):
(root / STATE_FILENAME).write_text(
json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8"
)
def _state_entry(rec, status: str) -> dict:
return {
"status": status,
"date_override": rec.date_override,
"topic": rec.topic,
"subtopic": rec.subtopic,
"extra": rec.extra,
"more": rec.more,
"version": rec.version,
"dest_folder": rec.dest_folder,
"keep_filename": rec.keep_filename,
}
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("root", help="Folder that was scanned")
ap.add_argument("csv_path", help="The filled-in CSV from stage.py")
ap.add_argument("--apply", action="store_true",
help="Actually move/rename files. Without this: dry-run only.")
args = ap.parse_args()
root = Path(args.root).expanduser().resolve()
if not root.is_dir():
print(f"Not a directory: {root}")
sys.exit(1)
csv_path = Path(args.csv_path)
if not csv_path.is_file():
print(f"Not a file: {csv_path}")
sys.exit(1)
rows = _read_rows(csv_path)
print(f"Read {len(rows)} rows from {csv_path}")
records = scan(root)
duplicates = find_duplicate_identities(records)
if duplicates:
print(f"\nWARNING: {len(duplicates)} group(s) of byte-identical files found — "
f"a CSV row matching one of these may resolve to the wrong twin, since "
f"they can't be told apart by content:")
for recs in duplicates.values():
print(" " + ", ".join(r.rel_path for r in recs))
by_identity = {rec.identity: rec for rec in records}
to_confirm, to_skip, not_found = [], [], []
still_pending = 0
for row in rows:
rec = by_identity.get(row["identity"])
if rec is None:
not_found.append(row["current_path"])
continue
status = row["status"].strip().lower()
if status == "confirmed":
rec.date_override = row["date_override"]
rec.topic = row["topic"]
rec.subtopic = row["subtopic"]
rec.extra = row["extra"]
rec.more = row["more"]
rec.version = row["version"]
rec.dest_folder = row["dest_folder"]
rec.keep_filename = _parse_bool(row["keep_filename"])
rec.status = "confirmed"
to_confirm.append(rec)
elif status == "skip":
rec.status = "skip"
to_skip.append(rec)
else:
still_pending += 1
# "pending" (or anything else/blank): left completely alone
dry_run = not args.apply
if not_found:
print(f"\nWARNING: {len(not_found)} row(s) couldn't be matched to a current file — "
f"probably already moved by another tool since this CSV was made. Skipped:")
for p in not_found:
print(f" - {p}")
print(f"\nMatched to real files: {len(rows) - len(not_found)} / {len(rows)}")
print(f" status=confirmed : {len(to_confirm)}")
print(f" status=skip : {len(to_skip)}")
print(f" status=pending : {still_pending} (left completely alone — not an error)")
if len(to_confirm) == 0 and len(to_skip) == 0 and still_pending > 0:
print(f"\nNothing to do: every matched row is still status=pending.")
print(f"apply.py only acts on rows you've explicitly set to 'confirmed' (or 'skip').")
print(f"Open {csv_path.name} and check the status column actually got changed — "
f"a common slip is editing the topic/folder cells but leaving status untouched.")
return
log_rows = apply_batch(to_confirm, dry_run=dry_run, log_path=None)
log_path = root / LOG_FILENAME
if log_rows:
write_header = not log_path.exists()
with open(log_path, "a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(log_rows[0].keys()))
if write_header:
writer.writeheader()
writer.writerows(log_rows)
moved = sum(1 for r in log_rows if r["result"] in ("moved", "would move"))
unchanged = sum(1 for r in log_rows if r["result"] == "unchanged")
collisions = sum(1 for r in log_rows if r["result"].startswith("SKIPPED"))
print(f"\n{'Would move' if dry_run else 'Moved'}: {moved}")
if unchanged:
print(f"Unchanged (new name+folder computed identical to current): {unchanged}")
print(f"Skipped — target already exists: {collisions}")
print(f"Marked as deliberately skipped: {len(to_skip)}")
if dry_run:
print(f"\nThis was a DRY RUN — nothing was touched, nothing was saved. "
f"Review {LOG_FILENAME}, then re-run with --apply to actually do it.")
return
state = _load_state(root)
for rec in to_confirm:
if rec.status == "done":
state[rec.identity] = _state_entry(rec, "done")
for rec in to_skip:
state[rec.identity] = _state_entry(rec, "skip")
_save_state(root, state)
print(f"Progress saved to {STATE_FILENAME} — the GUI will see these as done/skipped too.")
if __name__ == "__main__":
main()