#!/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()