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

121 lines
4.1 KiB
Python

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