121 lines
4.1 KiB
Python
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()
|