#!/usr/bin/env python3 """Inventory iOS Asset Catalogs (imagesets + appiconsets) for parity tracking. Scans both production asset catalogs: - iosApp/iosApp/Assets.xcassets/ - iosApp/HoneyDue/Assets.xcassets/ Skips build/DerivedData output (PostHog examples etc.). Output schema: { "image_sets": [ {"name": "outline", "path": "...", "files": ["outline.pdf"], "format": "pdf"}, ... ], "app_icons": [ {"name": "AppIcon", "path": "...", "sizes": ["1024x1024", ...]} ], "widget_assets": [ { ...same shape as image_sets... } ] } """ from __future__ import annotations import json from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parent.parent MAIN_XCASSETS = REPO_ROOT / "iosApp" / "iosApp" / "Assets.xcassets" WIDGET_XCASSETS = REPO_ROOT / "iosApp" / "HoneyDue" / "Assets.xcassets" OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "assets.json" _IMAGE_EXTS = {".pdf", ".png", ".jpg", ".jpeg", ".svg", ".heic"} def infer_format(files: list[str]) -> str: exts = {Path(f).suffix.lower().lstrip(".") for f in files} image_exts = exts & {"pdf", "png", "jpg", "jpeg", "svg", "heic"} if not image_exts: return "unknown" if len(image_exts) == 1: return next(iter(image_exts)) return "mixed(" + ",".join(sorted(image_exts)) + ")" def list_asset_files(dir_path: Path) -> list[str]: out: list[str] = [] for entry in sorted(dir_path.iterdir()): if entry.is_file() and entry.suffix.lower() in _IMAGE_EXTS: out.append(entry.name) return out def describe_imageset(imageset_dir: Path) -> dict[str, Any]: name = imageset_dir.name[: -len(".imageset")] files = list_asset_files(imageset_dir) return { "name": name, "path": str(imageset_dir.relative_to(REPO_ROOT)), "files": files, "format": infer_format(files), } def describe_appicon(appicon_dir: Path) -> dict[str, Any]: name = appicon_dir.name[: -len(".appiconset")] contents = appicon_dir / "Contents.json" sizes: list[str] = [] files = list_asset_files(appicon_dir) if contents.is_file(): try: data = json.loads(contents.read_text(encoding="utf-8")) except json.JSONDecodeError: data = {} for image in data.get("images", []): size = image.get("size") scale = image.get("scale") idiom = image.get("idiom") if size: label = size if scale: label = f"{label}@{scale}" if idiom: label = f"{label} ({idiom})" sizes.append(label) return { "name": name, "path": str(appicon_dir.relative_to(REPO_ROOT)), "sizes": sizes, "files": files, } def walk_catalog(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: imagesets: list[dict[str, Any]] = [] appicons: list[dict[str, Any]] = [] if not root.is_dir(): return imagesets, appicons for dirpath, dirnames, _ in root.walk() if hasattr(root, "walk") else _walk(root): p = Path(dirpath) if p.name.endswith(".imageset"): imagesets.append(describe_imageset(p)) dirnames[:] = [] # don't recurse inside elif p.name.endswith(".appiconset"): appicons.append(describe_appicon(p)) dirnames[:] = [] imagesets.sort(key=lambda x: x["name"]) appicons.sort(key=lambda x: x["name"]) return imagesets, appicons def _walk(root: Path): """Fallback walker for Python < 3.12 where Path.walk is unavailable.""" import os for dirpath, dirnames, filenames in os.walk(root): yield dirpath, dirnames, filenames def main() -> int: main_images, main_icons = walk_catalog(MAIN_XCASSETS) widget_images, widget_icons = walk_catalog(WIDGET_XCASSETS) output = { "image_sets": main_images, "app_icons": main_icons + widget_icons, "widget_assets": widget_images, } OUT_PATH.parent.mkdir(parents=True, exist_ok=True) with OUT_PATH.open("w", encoding="utf-8") as f: json.dump(output, f, indent=2) f.write("\n") print(f"[extract_ios_assets] wrote {OUT_PATH}") print( f" image_sets={len(output['image_sets'])} " f"app_icons={len(output['app_icons'])} " f"widget_assets={len(output['widget_assets'])}" ) return 0 if __name__ == "__main__": raise SystemExit(main())