#!/usr/bin/env python3 """ Generates a CSV you fill in (LibreOffice Calc or similar) to review many files at once, instead of one-by-one in the GUI. Usage: python3 batch/stage.py /path/to/folder python3 batch/stage.py /path/to/folder -o mybatch.csv --include-skipped Shares progress with the GUI: files already marked "done" (by either front end) are left out entirely, and anything already "skip"ped is left out unless you ask for it with --include-skipped. Columns you edit: status leave "pending" to do nothing with a row, set to "confirmed" for apply.py to act on it, or "skip" to mark it deliberately left alone date_override starts pre-filled with the filesystem guess — edit freely topic / subtopic / extra / more / version dest_folder starts pre-filled with the file's current folder — see the matching *_folders.txt for the full list of real destination folders to copy from keep_filename TRUE/FALSE — TRUE means "move only, don't rename". Starts TRUE for files that already match the naming convention, so you don't have to retype a name that was already fine just to move the file. Columns NOT meant to be edited (used to find the file again in apply.py): identity, current_path """ 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, discover_folders from core.config import STATE_FILENAME FIELDNAMES = [ "status", "current_path", "name_matches_convention", "fs_date", "date_override", "topic", "subtopic", "extra", "more", "version", "dest_folder", "keep_filename", "identity", ] 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 main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("root", help="Folder to scan") ap.add_argument("-o", "--output", default="staging.csv") ap.add_argument("--include-skipped", action="store_true", help="Also include files previously marked 'skip'") args = ap.parse_args() root = Path(args.root).expanduser().resolve() if not root.is_dir(): print(f"Not a directory: {root}") sys.exit(1) records = scan(root) saved = _load_state(root) rows = [] for rec in records: identity = rec.identity # one read per file, reused below — not recomputed saved_rec = saved.get(identity) if saved_rec: for k, v in saved_rec.items(): setattr(rec, k, v) if rec.status == "done": continue if rec.status == "skip" and not args.include_skipped: continue rows.append({ "status": rec.status, "current_path": rec.rel_path, "name_matches_convention": rec.name_matches_convention, "fs_date": rec.fs_date, "date_override": rec.date_override or rec.fs_date, "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, "identity": identity, }) out_path = Path(args.output) with open(out_path, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) writer.writeheader() writer.writerows(rows) folders = discover_folders(root) folders_path = out_path.with_name(out_path.stem + "_folders.txt") folders_path.write_text("\n".join(folders), encoding="utf-8") print(f"Wrote {len(rows)} rows to {out_path}") print(f"Wrote {len(folders)} known destination folders to {folders_path} " f"(reference list only — copy/paste into dest_folder)") if __name__ == "__main__": main()