| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Icon Sync |
| 4 | |
| 5 | Copy chosen library icons into `<project>/icons/<lib>/` when selected. Missing |
| 6 | names exit non-zero before export. Known basenames need no separate existence |
| 7 | check; search the chosen library only for unresolved concepts. |
| 8 | |
| 9 | Project-local custom icons count as satisfied. In one resource-selection batch, |
| 10 | `simple-icons` may accompany one of the four stylistic libraries for |
| 11 | real brand marks. |
| 12 | |
| 13 | Usage: |
| 14 | python3 scripts/icon_sync.py <project_path> <lib/name> [<lib/name> ...] |
| 15 | |
| 16 | Examples: |
| 17 | python3 scripts/icon_sync.py projects/deck tabler-outline/home tabler-outline/chart |
| 18 | python3 scripts/icon_sync.py projects/deck tabler-outline/home simple-icons/github |
| 19 | |
| 20 | Dependencies: |
| 21 | None (standard library only). |
| 22 | |
| 23 | See references/executor-base.md §4 and templates/icons/README.md. |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import argparse |
| 29 | import shutil |
| 30 | import sys |
| 31 | from pathlib import Path |
| 32 | from typing import Optional |
| 33 | |
| 34 | from console_encoding import configure_utf8_stdio |
| 35 | |
| 36 | configure_utf8_stdio() |
| 37 | |
| 38 | _LIB_ALIASES = {"chunk": "chunk-filled"} |
| 39 | _STYLISTIC_LIBRARIES = { |
| 40 | "chunk-filled", |
| 41 | "phosphor-duotone", |
| 42 | "tabler-filled", |
| 43 | "tabler-outline", |
| 44 | } |
| 45 | _GLOBAL_ICONS_DIR = Path(__file__).resolve().parent.parent / "templates" / "icons" |
| 46 | |
| 47 | |
| 48 | def _split_name(icon_name: str) -> tuple[str, str]: |
| 49 | """`lib/name` -> (lib, name), applying the chunk→chunk-filled alias.""" |
| 50 | if "/" not in icon_name: |
| 51 | # legacy un-prefixed names live in chunk-filled/ |
| 52 | return "chunk-filled", icon_name |
| 53 | lib, name = icon_name.split("/", 1) |
| 54 | return _LIB_ALIASES.get(lib, lib), name |
| 55 | |
| 56 | |
| 57 | def sync_icons(project_path: Path, icon_names: list[str], global_dir: Path = _GLOBAL_ICONS_DIR) -> tuple[list[str], list[str]]: |
| 58 | """Copy each `lib/name` from the global library into `<project>/icons/`. |
| 59 | |
| 60 | Returns (copied, missing). A name already present in the project (e.g. a |
| 61 | custom icon) counts as satisfied, not missing. |
| 62 | """ |
| 63 | project_icons = project_path / "icons" |
| 64 | copied: list[str] = [] |
| 65 | missing: list[str] = [] |
| 66 | |
| 67 | for raw in icon_names: |
| 68 | lib, name = _split_name(raw) |
| 69 | src = global_dir / lib / f"{name}.svg" |
| 70 | dst = project_icons / lib / f"{name}.svg" |
| 71 | if src.is_file(): |
| 72 | dst.parent.mkdir(parents=True, exist_ok=True) |
| 73 | shutil.copy2(src, dst) |
| 74 | copied.append(f"{lib}/{name}") |
| 75 | elif dst.is_file(): |
| 76 | copied.append(f"{lib}/{name} (already in project)") |
| 77 | else: |
| 78 | missing.append(f"{lib}/{name}") |
| 79 | |
| 80 | return copied, missing |
| 81 | |
| 82 | |
| 83 | def build_parser() -> argparse.ArgumentParser: |
| 84 | parser = argparse.ArgumentParser( |
| 85 | description="Copy chosen library icons into a project's icons/ folder; report missing ones.", |
| 86 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 87 | ) |
| 88 | parser.add_argument("project_path", help="Project directory") |
| 89 | parser.add_argument("icons", nargs="+", help="Icon names to copy, e.g. chunk-filled/home") |
| 90 | return parser |
| 91 | |
| 92 | |
| 93 | def main(argv: Optional[list[str]] = None) -> int: |
| 94 | args = build_parser().parse_args(argv) |
| 95 | project = Path(args.project_path) |
| 96 | if not project.is_dir(): |
| 97 | print(f"[ERROR] project not found: {project}", file=sys.stderr) |
| 98 | return 1 |
| 99 | |
| 100 | requested_libraries = {_split_name(raw)[0] for raw in args.icons} |
| 101 | stylistic_libraries = sorted(requested_libraries & _STYLISTIC_LIBRARIES) |
| 102 | if len(stylistic_libraries) > 1: |
| 103 | print( |
| 104 | f"[ERROR] mixed stylistic icon libraries: {', '.join(stylistic_libraries)}", |
| 105 | file=sys.stderr, |
| 106 | ) |
| 107 | print( |
| 108 | "Choose one of the four stylistic libraries per selection batch; " |
| 109 | "simple-icons may coexist for real brand marks.", |
| 110 | file=sys.stderr, |
| 111 | ) |
| 112 | return 1 |
| 113 | |
| 114 | copied, missing = sync_icons(project, args.icons) |
| 115 | |
| 116 | if copied: |
| 117 | print(f"[OK] {len(copied)} icon(s) in {project / 'icons'}:", file=sys.stderr) |
| 118 | for c in copied: |
| 119 | print(f" + {c}", file=sys.stderr) |
| 120 | |
| 121 | if missing: |
| 122 | print(f"\n[MISSING] {len(missing)} icon(s) not in the library — re-pick before continuing:", file=sys.stderr) |
| 123 | for m in missing: |
| 124 | lib = m.split("/", 1)[0] |
| 125 | print( |
| 126 | f' ✗ {m} (search: rg --files "{_GLOBAL_ICONS_DIR / lib}" -g \'*<keyword>*.svg\')', |
| 127 | file=sys.stderr, |
| 128 | ) |
| 129 | return 1 |
| 130 | |
| 131 | return 0 |
| 132 | |
| 133 | |
| 134 | if __name__ == "__main__": |
| 135 | raise SystemExit(main()) |
| 136 |