← Files TidyGuardianARCHIVED FILE

skills/tidyguardian/scripts/tidyguardian.py

10.5 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env python3
"""TidyGuardian v2: review-first, permission-gated, reversible file organization."""
from __future__ import annotations

import argparse
import csv
import json
from pathlib import Path
import sys

# Permit direct script execution, importlib loading, and python -m invocation.
HERE = str(Path(__file__).resolve().parent)
if HERE not in sys.path:
    sys.path.insert(0, HERE)

import media_setup
from version import VERSION

from guard import Root, SafetyError, absolute, canonical, new_report_dir, read_json, write_new
from discovery import (cleanup_plan, duplicate_plan, export_csv, move_plan, scan, thumbnails)
from review import save_plan
from workflow import apply, make_plan, restore_plan


def read_csv(path):
    p = absolute(path, directory=False)
    if not p.is_file() or p.stat().st_size > 20 * 1024 * 1024:
        raise SafetyError("Invalid or oversized CSV")
    with p.open(newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        if not reader.fieldnames or len(reader.fieldnames) != len(set(reader.fieldnames)):
            raise SafetyError("Missing or duplicate CSV columns")
        rows = list(reader)
        if any(None in r or any(v is None for v in r.values()) for r in rows):
            raise SafetyError("Malformed CSV row")
        return rows


def catalog_input(path, root):
    if Path(path).suffix.lower() == ".json":
        data = read_json(path)
        if data.get("root") != str(root.path) or data.get("root_identity") != root.id:
            raise SafetyError("Catalog root does not match")
        return data
    rows = read_csv(path)
    for row in rows:
        row["size"] = int(row.get("size", 0))
        # Legacy CSVs have no trustworthy eligibility flags: apply current policy.
        root.protect_project(row["rel_path"])
        row["eligible"] = row.get("entry_type", "file") == "file"
    return {"entries": rows}


def command(args):
    if args.command == "apply-plan":
        plan = read_json(args.plan)
        selection = read_json(args.selection) if args.selection else None
        run = apply(plan, args.output_dir, execute=args.execute, selection=selection)
        print(run)
        return 0
    if args.command == "restore-plan":
        plan = restore_plan(args.journal)
        folder = new_report_dir(args.output_dir, Root(plan["root"]))
        save_plan(folder, plan)
        print(folder / "plan.json")
        return 0
    # Fail BEFORE even scanning or writing a candidate when an obsolete destructive
    # invocation is attempted. --execute alone no longer grants file permission.
    if getattr(args, "execute", False):
        raise SafetyError("Legacy --execute is disabled. Generate a frozen plan, review it, then run apply-plan with live confirmation. Permanent deletion is unavailable.")
    if not args.root:
        raise SafetyError("--root is required; a CSV must never choose your filesystem scope")
    root = Root(args.root)
    folder = new_report_dir(args.output_dir, root)
    if args.command == "catalog":
        data = scan(root, inspect_media=args.media_dates)
        write_new(folder / "catalog.json", canonical(data) + "\n")
        export_csv(folder / "catalog.csv", data["entries"], ["rel_path", "kind", "size", "date_iso", "year", "month", "date_basis", "eligible", "reason"])
        export_csv(folder / "skipped.csv", data["skipped"], ["path", "reason"])
        write_new(folder / "receipt.md", f"# Inventory\n\n{len(data['entries'])} files cataloged; {len(data['skipped'])} skipped entries/errors. Source files unchanged.\n\nCSV is a formula-neutralized human report. Use catalog.json for planning. No content is uploaded.\n")
        print(folder / "catalog.json")
        return 0
    if args.command == "thumbnails":
        thumbnails(root, catalog_input(args.catalog, root), folder, args.limit)
        print(folder / "review.html")
        return 0
    if args.command == "plan-move":
        plan = move_plan(root, catalog_input(args.catalog, root), args.organized_root, args.limit)
    elif args.command == "apply-move":
        rows = read_csv(args.plan)
        if any(r.get("action") != "MOVE" for r in rows):
            raise SafetyError("Move CSV contains an unknown action")
        plan = make_plan(root, [{"action": "MOVE", "src": r["src_rel"], "dest": r["dest_rel"],
                                "reason": "Imported legacy CSV; requires fresh review"} for r in rows])
    elif args.command in {"duplicates", "duplicate-audit", "verify-duplicates"}:
        data = scan(root)
        if args.command == "duplicate-audit":
            from collections import defaultdict
            groups = defaultdict(list)
            for row in data["entries"]:
                if row["eligible"] and row["size"] >= args.min_size:
                    groups[row["size"]].append(row)
            rows = [{"group_id": str(size), "size": size, "rel_path": r["rel_path"]}
                    for size, group in groups.items() if len(group) > 1 for r in group]
            export_csv(folder / "duplicate_size_groups.csv", rows, ["group_id", "size", "rel_path"])
            write_new(folder / "catalog.json", canonical(data) + "\n")
            write_new(folder / "receipt.md", "# Size-only candidates\n\nSize is NOT duplicate proof and does NOT authorize any move or deletion. Use duplicates to generate a verified quarantine plan.\n")
            print(folder / "catalog.json")
            return 0
        if args.command == "verify-duplicates" and args.audit:
            # Old audits are only a scope filter, never trusted duplicate proof.
            if Path(args.audit).suffix.lower() == ".json":
                wanted = {r["rel_path"] for r in catalog_input(args.audit, root)["entries"]}
            else:
                wanted = {r["rel_path"] for r in read_csv(args.audit)}
            for rel in wanted:
                root.check(rel)
            data["entries"] = [r for r in data["entries"] if r["rel_path"] in wanted]
        plan = duplicate_plan(root, data, preferred=args.keep, min_size=args.min_size, max_bytes=args.max_bytes)
    elif args.command == "delete-verified":
        rows = read_csv(args.verified)
        requests = []
        for row in rows:
            if row.get("exact_verified") != "true":
                raise SafetyError("Unverified CSV row refused")
            for rel in (row["keep_rel"], row["delete_rel"]):
                if root.snapshot(rel)["sha256"] != row.get("sha256"):
                    raise SafetyError("CSV hash is stale or incorrect")
            requests.append({"action": "QUARANTINE", "src": row["delete_rel"], "keep": row["keep_rel"],
                             "reason": "Legacy delete proposal converted to reversible quarantine; new approval required"})
        plan = make_plan(root, requests)
    elif args.command == "cleanup-metadata":
        plan = cleanup_plan(root)
    else:
        raise SafetyError("Unknown command")
    save_plan(folder, plan)
    print(folder / "plan.json")
    return 0


def positive(value):
    number = int(value)
    if number < 1 or number > 1000:
        raise argparse.ArgumentTypeError("Choose a limit between 1 and 1000")
    return number


def nonnegative(value):
    number = int(value)
    if number < 0:
        raise argparse.ArgumentTypeError("Must be nonnegative")
    return number


def build_parser():
    parser = argparse.ArgumentParser(description="Local, review-first file organization. No permanent deletion.")
    parser.add_argument("--version", action="version", version=f"TidyGuardian {VERSION}")
    sub = parser.add_subparsers(dest="command", required=True)
    media_setup.add_parser(sub)
    names = ["catalog", "thumbnails", "plan-move", "apply-move", "duplicates", "duplicate-audit",
             "verify-duplicates", "delete-verified", "cleanup-metadata", "apply-plan", "restore-plan"]
    for name in names:
        p = sub.add_parser(name)
        p.add_argument("--output-dir", required=True, help="Existing directory outside the source. A unique private run folder is created.")
        if name not in {"apply-plan", "restore-plan"}:
            p.add_argument("--root", required=True)
        if name in {"apply-move", "delete-verified", "cleanup-metadata", "apply-plan"}:
            p.add_argument("--execute", action="store_true", help="Only apply-plan supports execution; live confirmation is still mandatory.")
        if name in {"thumbnails", "plan-move"}:
            p.add_argument("--catalog", required=True)
            p.add_argument("--limit", type=positive, default=100 if name == "thumbnails" else 1000)
        if name == "catalog":
            p.add_argument("--media-dates", action="store_true", help="Opt into local ffprobe/Pillow metadata reads; never upload files")
        if name == "plan-move":
            p.add_argument("--organized-root", default="_TidyGuardian_Organized")
        if name in {"apply-move", "apply-plan"}:
            p.add_argument("--plan", required=True)
        if name == "apply-plan":
            p.add_argument("--selection", help="Selection JSON from the offline review page; not execution authorization")
        if name in {"duplicates", "duplicate-audit", "verify-duplicates"}:
            p.add_argument("--min-size", type=nonnegative, default=1)
            p.add_argument("--max-bytes", type=nonnegative, default=0)
            p.add_argument("--keep", action="append", default=[], help="Relative path to retain; may be repeated")
        if name == "verify-duplicates":
            p.add_argument("--audit")
        if name == "delete-verified":
            p.add_argument("--verified", required=True)
        if name == "restore-plan":
            p.add_argument("--journal", required=True)
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    try:
        if args.command in {"setup", "doctor"}:
            return media_setup.setup_command(args)
        # Preserve immediate refusal of obsolete destructive commands. A dependency
        # setup choice must never weaken the existing file-permission policy.
        if getattr(args, "execute", False) and args.command != "apply-plan":
            return command(args)
        status = media_setup.preflight(
            require_media=args.command == "thumbnails" or getattr(args, "media_dates", False))
        if status:
            return status
        return command(args)
    except KeyboardInterrupt:
        print("Cancelled. Inspect the journal before retrying an interrupted execution.", file=sys.stderr)
        return 130
    except (SafetyError, OSError, ValueError, KeyError, TypeError) as exc:
        print("Stopped safely: " + str(exc), file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 59cd0aa1b55203c0d6ed52cf8b4176ae3745c19b6a1dca5498e7b1251a27febc