207 lines
7.5 KiB
Python
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()
|