Files
spoopyghosty0--frameport/scripts/scrub_library.py
T
spoopyghosty0 23a47880e0 Lines within 120 columns everywhere (CI lints without exceptions); community and licence files; docs updated
- reflowed long lines (texts unchanged; generated launcher/PowerShell/README texts byte-identical)
- CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, THIRD_PARTY_NOTICES; pyproject metadata (SPDX licence, authors, urls,
  classifiers, keywords)
- maintainer-only notes moved to a git-ignored CLAUDE.local.md; CLAUDE.md facts updated
- README: what kinds of apps run and how; INSTALL: PC VR and Files wording, new labels
2026-10-02 14:18:41 -04:00

95 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Make a screenshot-safe copy of a FramePort data folder (for docs/images):
python scripts/scrub_library.py <data dir> <new dir> [--sort size] [--status works,issues]
Copies library.json + artwork/ only (no Frame pairing, SSH key, tools, logs or builds). In the copy every game's
folder name becomes its store title, every local path becomes D:/Games/<Quest|PC VR>/<title>/..., settings other than
the Library view are dropped, and install/launch history is removed. Fails if any original path is left.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
PATHLIKE = re.compile(r"^(/|[A-Za-z]:[\\/]|\\\\)")
DEVICE = ("/data/", "/sdcard", "/storage/", "/system/", "/vendor/", "/apex/", "/proc/", "/dev/") # inside the headset
DROP_KEYS = {"installs", "log_path", "last_played", "played"}
def safe(title: str) -> str:
return re.sub(r'[<>:"/\\|?*]+', "", title).strip() or "Game"
def scrub_game(g: dict) -> dict:
title = g.get("title") or g.get("package")
base = f"D:/Games/{'PC VR' if g.get('kind') == 'rift' else 'Quest'}/{safe(title)}"
originals: set[str] = set()
def collect(o):
if isinstance(o, dict):
for v in o.values():
collect(v)
elif isinstance(o, list):
for v in o:
collect(v)
elif isinstance(o, str) and PATHLIKE.match(o) and not o.startswith(DEVICE):
originals.add(o)
collect(g)
roots = sorted({str(Path(p).parent) for p in originals} | originals, key=len, reverse=True)
def fix(o, key=""):
if isinstance(o, dict):
return {k: fix(v, k) for k, v in o.items() if k not in DROP_KEYS}
if isinstance(o, list):
return [fix(v, key) for v in o]
if isinstance(o, str):
if PATHLIKE.match(o) and not o.startswith(DEVICE):
name = Path(o.replace("\\", "/")).name
return base if key in ("origin", "folder", "game_dir", "data_dir", "base") else f"{base}/{name}"
for r in roots:
if len(r) > 3 and r in o:
o = o.replace(r, base)
o = re.sub(r"/(?:mnt|home|Users)/[^)\"]*", "D:/Games", o) # paths inside messages (e.g. a tool's folder)
return o
out = fix(g)
out["name"] = title
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("src", type=Path)
ap.add_argument("dest", type=Path)
ap.add_argument("--sort", default="size", choices=["name", "recent", "played", "size", "status"])
ap.add_argument("--status", default="", help="keep only games with these recipe statuses, e.g. works,issues")
args = ap.parse_args()
lib = json.loads((args.src / "library.json").read_text())
keep = {x for x in args.status.split(",") if x}
games = {pkg: scrub_game(g) for pkg, g in lib.get("games", {}).items()
if not keep or (g.get("recipe") or {}).get("status", "unknown") in keep}
view = {**(lib.get("settings", {}).get("ui.library") or {}), "sort": args.sort, "platform": "all",
"where": "all", "status": "all", "tags": []}
migrations = lib.get("settings", {}).get("migrations", [])
out = {"games": games, "settings": {"ui.library": view, "migrations": migrations}}
text = json.dumps(out, indent=1)
leftovers = [o for o in re.findall(r'"((?:/|[A-Za-z]:\\\\)[^"]*)"', text)
if not o.startswith(("D:/Games/",) + DEVICE)]
if leftovers:
print("not scrubbed:", leftovers[:10], file=sys.stderr)
return 1
args.dest.mkdir(parents=True, exist_ok=True)
(args.dest / "library.json").write_text(text)
if (args.src / "artwork").is_dir():
shutil.copytree(args.src / "artwork", args.dest / "artwork", dirs_exist_ok=True)
print(f"{len(games)} games -> {args.dest}")
return 0
if __name__ == "__main__":
sys.exit(main())