#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import shutil import sys from pathlib import Path, PurePosixPath from typing import Any, Dict, Iterable, List, Tuple class ReconstructionError(Exception): pass def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Rebuild a BrightSign project tree from local-sync.json by copying or moving " "hashed pool files back to their original paths." ) ) parser.add_argument( "root", nargs="?", default=".", help="Root folder of the BrightSign project/SD card. Defaults to the current directory.", ) parser.add_argument( "--json", dest="json_name", default="local-sync.json", help="Manifest filename or path. Defaults to local-sync.json.", ) parser.add_argument( "--output", default=None, help=( "Output folder for the reconstructed project. " "Defaults to /reconstructed_project." ), ) parser.add_argument( "--move", action="store_true", help="Move files instead of copying them. Safer default is copy.", ) parser.add_argument( "--overwrite", action="store_true", help="Overwrite destination files if they already exist.", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would happen without copying/moving anything.", ) parser.add_argument( "--strict", action="store_true", help="Exit with an error if any mapped source file is missing.", ) parser.add_argument( "--verbose", action="store_true", help="Print every file operation.", ) return parser.parse_args() def resolve_manifest_path(root: Path, json_name: str) -> Path: manifest = Path(json_name) if not manifest.is_absolute(): manifest = root / manifest return manifest def load_manifest(path: Path) -> Dict[str, Any]: if not path.exists(): raise ReconstructionError(f"Manifest not found: {path}") with path.open("r", encoding="utf-8") as f: return json.load(f) def get_download_entries(manifest: Dict[str, Any]) -> List[Dict[str, Any]]: files = manifest.get("files") if not isinstance(files, dict): raise ReconstructionError("Manifest is missing a valid 'files' object.") download = files.get("download") if not isinstance(download, list): raise ReconstructionError("Manifest is missing a valid files.download array.") return download def posix_rel_to_path(rel_path: str) -> Path: # BrightSign paths in local-sync.json use forward slashes. return Path(PurePosixPath(rel_path)) def safe_dest_path(output_root: Path, original_name: str) -> Path: rel = posix_rel_to_path(original_name) dest = output_root / rel # Prevent accidental traversal like ../../outside.txt. try: dest.resolve().relative_to(output_root.resolve()) except Exception as exc: # noqa: BLE001 raise ReconstructionError( f"Unsafe destination path in manifest entry: {original_name!r}" ) from exc return dest def ensure_parent(path: Path, dry_run: bool) -> None: if dry_run: return path.parent.mkdir(parents=True, exist_ok=True) def copy_or_move_file(src: Path, dst: Path, move: bool, overwrite: bool, dry_run: bool) -> str: if dst.exists(): if not overwrite: return "skipped_exists" if not dry_run: if dst.is_dir(): shutil.rmtree(dst) else: dst.unlink() ensure_parent(dst, dry_run) if dry_run: return "would_move" if move else "would_copy" if move: shutil.move(str(src), str(dst)) return "moved" shutil.copy2(src, dst) return "copied" def reconstruct( root: Path, manifest_path: Path, output_root: Path, move: bool, overwrite: bool, dry_run: bool, strict: bool, verbose: bool, ) -> int: manifest = load_manifest(manifest_path) entries = get_download_entries(manifest) if not dry_run: output_root.mkdir(parents=True, exist_ok=True) copied = 0 moved = 0 skipped_exists = 0 missing = 0 bad_entries = 0 for idx, entry in enumerate(entries, start=1): try: original_name = entry["name"] link_name = entry["link"] except KeyError: bad_entries += 1 if verbose: print(f"[{idx}] bad entry: missing 'name' or 'link'") continue src = root / posix_rel_to_path(link_name) try: dst = safe_dest_path(output_root, original_name) except ReconstructionError as exc: bad_entries += 1 print(f"[{idx}] {exc}", file=sys.stderr) continue if not src.exists(): missing += 1 if verbose or strict: print(f"[{idx}] missing source: {src}", file=sys.stderr) continue status = copy_or_move_file(src, dst, move=move, overwrite=overwrite, dry_run=dry_run) if verbose: action_label = { "copied": "COPY", "moved": "MOVE", "would_copy": "DRY-COPY", "would_move": "DRY-MOVE", "skipped_exists": "SKIP", }[status] print(f"[{idx}] {action_label} {src} -> {dst}") if status in {"copied", "would_copy"}: copied += 1 elif status in {"moved", "would_move"}: moved += 1 elif status == "skipped_exists": skipped_exists += 1 print("\nDone.") print(f"Manifest: {manifest_path}") print(f"Source root: {root}") print(f"Output root: {output_root}") print(f"Entries: {len(entries)}") print(f"Copied: {copied}") print(f"Moved: {moved}") print(f"Skipped: {skipped_exists}") print(f"Missing: {missing}") print(f"Bad entries: {bad_entries}") if strict and missing: return 2 return 0 def main() -> int: args = parse_args() root = Path(args.root).expanduser().resolve() manifest_path = resolve_manifest_path(root, args.json_name).resolve() output_root = Path(args.output).expanduser().resolve() if args.output else (root / "reconstructed_project").resolve() # Guard against writing into the exact same folder when copying unless user intentionally sets it. if output_root == root and not args.move: print( "Refusing to copy into the same root folder. Use --output or use --move if you really want to reorganize in place.", file=sys.stderr, ) return 2 try: return reconstruct( root=root, manifest_path=manifest_path, output_root=output_root, move=args.move, overwrite=args.overwrite, dry_run=args.dry_run, strict=args.strict, verbose=args.verbose, ) except ReconstructionError as exc: print(f"Error: {exc}", file=sys.stderr) return 2 except json.JSONDecodeError as exc: print(f"Error: invalid JSON in {manifest_path}: {exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())