#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ sync_foundry_module.py (z dry-run, argumentami CLI i preflightem git/module.json) Kroki: [PRE] W katalogu źródłowym: - Wczytaj module.json, pobierz 'id' i 'version' - Zwiększ ostatnią liczbę w 'version' o 1, zapisz module.json - git add -A; git commit -m "sync: v"; git push (w dry-run wyłącznie plan, bez modyfikacji i bez gita) [2] Wyczyść zawartość docelowego modułu (z potwierdzeniem lub bez; w dry-run tylko lista) [3] Skopiuj zawartość źródła do celu (w dry-run: plan) Wymagania: Python 3.8+, git w PATH, uprawnienia do zapisu. """ from __future__ import annotations import os import sys import shutil import argparse import json import subprocess from pathlib import Path from typing import List, Optional, Tuple # ========================== # KONFIGURACJA (DOMYŚLNA) # ========================== TARGET_USER: str = "mtusz" # nazwa folderu w C:\Users\... APPDATA_KIND: Optional[str] = None # None | "Local" | "LocalLow" | "Roaming" TARGET_MODULES_DIR_OVERRIDE: Optional[str] = None # pełna ścieżka do ...\FoundryVTT\Data\modules MODULE_SUBDIR_NAME: str = "wg-voidships-builder" CONFIRM_DELETE: bool = True SOURCE_BASE_DIR: str = r"C:\vtt_work" RELATIVE_PATTERN: str = r"FoundryVTT\Data\modules" DRY_RUN: bool = False # globalny tryb symulacji (nic nie zmienia) # ========================== # KONIEC KONFIGURACJI # ========================== APPDATA_VARIANTS = ("Local", "LocalLow", "Roaming") class AmbiguityError(RuntimeError): pass def norm(p: Path) -> Path: return Path(os.path.normpath(str(p))) def find_modules_dir_for_user(user: str, relative_pattern: str, appdata_kind: Optional[str]) -> Path: base = Path(f"C:/Users/{user}/AppData") if not base.exists(): raise FileNotFoundError(f"Nie znaleziono katalogu użytkownika: {norm(base)}") candidates: List[Path] = [] if appdata_kind: if appdata_kind not in APPDATA_VARIANTS: raise ValueError(f"APPDATA_KIND musi być jednym z: {APPDATA_VARIANTS}, a nie '{appdata_kind}'") p = base / appdata_kind / Path(relative_pattern) if p.is_dir(): return norm(p) else: raise FileNotFoundError( f"Nie znaleziono katalogu: {norm(p)} (dla APPDATA_KIND={appdata_kind})." ) for kind in APPDATA_VARIANTS: p = base / kind / Path(relative_pattern) if p.is_dir(): candidates.append(p) if not candidates: raise FileNotFoundError( "Nie znaleziono żadnego katalogu zgodnego z wzorcem.\n" f"Sprawdź, czy istnieje któryś z poniższych:\n" + "\n".join(f" - {norm(base / kind / Path(relative_pattern))}" for kind in APPDATA_VARIANTS) ) if len(candidates) == 1: return norm(candidates[0]) suggestions = [] for p in candidates: try: kind = p.parts[p.parts.index("AppData") + 1] except Exception: kind = "??" suggestions.append(f'* Ustaw APPDATA_KIND="{kind}" # trafi w: {norm(p)}') suggestions.append(f'* Albo ustaw TARGET_MODULES_DIR_OVERRIDE="{norm(candidates[0])}" # pełny override') raise AmbiguityError( "Znaleziono wiele pasujących lokalizacji dla wzorca 'FoundryVTT\\Data\\modules':\n" + "\n".join(f" - {norm(c)}" for c in candidates) + "\n\nPropozycje ujednolicenia:\n" + "\n".join(suggestions) ) def ensure_dir(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) def iter_dir_contents(dir_path: Path) -> List[Path]: if not dir_path.exists(): return [] return list(dir_path.iterdir()) def list_recursive(dir_path: Path) -> List[Path]: items: List[Path] = [] if not dir_path.exists(): return items for root, dirs, files in os.walk(dir_path): root_p = Path(root) for d in dirs: items.append(root_p / d) for f in files: items.append(root_p / f) return items def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]: ensure_dir(dir_path) files = 0 dirs = 0 to_delete = iter_dir_contents(dir_path) if dry_run: print("[DRY-RUN] Zawartość do skasowania:") if not to_delete: print(" (pusto)") for child in to_delete: tag = "[plik]" if child.is_file() else "[katalog]" if child.is_dir() else "[inne]" print(f" {tag} {norm(child)}") for child in to_delete: if child.is_file() or child.is_symlink(): files += 1 elif child.is_dir(): dirs += 1 return files, dirs for child in to_delete: if child.is_file() or child.is_symlink(): files += 1 child.unlink(missing_ok=True) elif child.is_dir(): dirs += 1 shutil.rmtree(child, ignore_errors=True) return files, dirs def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]: plan: List[Tuple[Path, Path, str]] = [] if not src.is_dir(): raise FileNotFoundError(f"Katalog źródłowy nie istnieje: {norm(src)}") for root, dirs, files in os.walk(src): root_p = Path(root) rel_root = root_p.relative_to(src) for d in dirs: s = root_p / d t = dst / rel_root / d status = "overwrite" if t.exists() else "create" plan.append((s, t, status)) for f in files: s = root_p / f t = dst / rel_root / f status = "overwrite" if t.exists() else "create" plan.append((s, t, status)) return plan def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]: ensure_dir(dst) plan = plan_copy(src, dst) if dry_run: print("[DRY-RUN] Plan kopiowania:") if not plan: print(" (brak plików do skopiowania)") for s, t, status in plan: kind = "[katalog]" if s.is_dir() else "[plik]" print(f" {kind} {norm(s)} -> {norm(t)} ({status})") files = sum(1 for s, _, _ in plan if s.is_file()) dirs = sum(1 for s, _, _ in plan if s.is_dir()) return files, dirs files = 0 dirs = 0 for s, t, _ in plan: if s.is_dir(): shutil.copytree(s, t, dirs_exist_ok=True) dirs += 1 elif s.is_file(): ensure_dir(t.parent) shutil.copy2(s, t) files += 1 elif s.is_symlink(): target = s.resolve() ensure_dir(t.parent) if target.is_dir(): shutil.copytree(target, t, dirs_exist_ok=True) dirs += 1 else: shutil.copy2(target, t) files += 1 else: print(f"[OSTRZEŻENIE] Pominięto nieobsługiwany typ pliku: {norm(s)}") return files, dirs # ============ PRE-FLIGHT: module.json + git ============ def read_manifest(manifest_path: Path) -> dict: if not manifest_path.is_file(): raise FileNotFoundError(f"Brak pliku manifestu: {norm(manifest_path)}") try: with manifest_path.open("r", encoding="utf-8") as f: return json.load(f) except json.JSONDecodeError as e: raise ValueError(f"Niepoprawny JSON w {norm(manifest_path)}: {e}") from e def bump_version(last_version: str) -> str: """ Zwiększa o 1 ostatnią *liczbę* w wersji rozdzielanej kropkami. Przykłady: 1 -> 2, 1.2 -> 1.3, 1.2.9 -> 1.2.10 """ parts = last_version.strip().split(".") if not parts: raise ValueError(f"Niepoprawna wersja: '{last_version}'") try: n = int(parts[-1]) except ValueError: raise ValueError(f"Ostatni segment wersji nie jest liczbą: '{last_version}'") parts[-1] = str(n + 1) return ".".join(parts) def write_manifest(manifest_path: Path, data: dict) -> None: tmp = manifest_path.with_suffix(".json.tmp") with tmp.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") tmp.replace(manifest_path) # atomowa podmiana na tym samym FS def run_git_commands(repo_dir: Path, commit_msg: str, dry_run: bool) -> None: """ Wykonuje: git add -A; git commit -m ""; git push W dry_run — tylko wyświetla plan. """ if dry_run: print("[DRY-RUN] git add -A") print(f"[DRY-RUN] git commit -m {commit_msg!r}") print("[DRY-RUN] git push") return def run(cmd: List[str]) -> None: r = subprocess.run(cmd, cwd=str(repo_dir), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if r.returncode != 0: raise RuntimeError(f"Błąd podczas uruchamiania: {' '.join(cmd)}\nWyjście:\n{r.stdout}") else: if r.stdout.strip(): print(r.stdout) # add, commit (może nie utworzyć commita jeśli brak zmian), push run(["git", "add", "-A"]) # commit powinien się powieść — jeśli nie ma zmian, chcemy FAIL r = subprocess.run(["git", "commit", "-m", commit_msg], cwd=str(repo_dir), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if r.returncode != 0: # typowy komunikat: "nothing to commit, working tree clean" -> traktujemy jako błąd wg wymagań raise RuntimeError(f"Błąd podczas git commit:\n{r.stdout}") else: if r.stdout.strip(): print(r.stdout) run(["git", "push"]) def preflight_source(src_dir: Path, dry_run: bool) -> Tuple[str, str]: """ Wczytuje module.json, bumpuje wersję, zapisuje (jeśli nie dry_run), i wykonuje git add/commit/push. Zwraca (module_id, new_version). """ manifest_path = src_dir / "module.json" manifest = read_manifest(manifest_path) if "id" not in manifest or "version" not in manifest: raise KeyError(f"W {norm(manifest_path)} brakuje pola 'id' lub 'version'.") module_id = str(manifest["id"]) old_version = str(manifest["version"]) new_version = bump_version(old_version) print(f"[PRE] module.json: id={module_id}, version={old_version} -> {new_version}") if dry_run: print("[DRY-RUN] Nie zapisuję module.json i nie wykonuję git.") else: manifest["version"] = new_version write_manifest(manifest_path, manifest) print(f"[PRE] Zapisano zaktualizowany {norm(manifest_path)}") commit_msg = f"sync: {module_id} v{new_version}" run_git_commands(src_dir, commit_msg, dry_run=False) return module_id, new_version # ================= CLI / MAIN ================= def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Synchronizacja modułu FoundryVTT (preflight: module.json + git).") p.add_argument("--user", dest="user", default=TARGET_USER, help=f"Nazwa użytkownika Windows (domyślnie: {TARGET_USER})") p.add_argument("--appdata-kind", dest="appdata_kind", choices=APPDATA_VARIANTS + (None,), default=APPDATA_KIND, help=f"Wymuś AppData wariant: {APPDATA_VARIANTS} lub brak (None). Domyślnie: {APPDATA_KIND}") p.add_argument("--modules-dir-override", dest="modules_dir_override", default=TARGET_MODULES_DIR_OVERRIDE, help="Pełna ścieżka do ...\\FoundryVTT\\Data\\modules (pomija wyszukiwanie).") p.add_argument("--module-subdir-name", dest="module_subdir_name", default=MODULE_SUBDIR_NAME, help=f"Nazwa podkatalogu modułu (domyślnie: {MODULE_SUBDIR_NAME})") cd_group = p.add_mutually_exclusive_group() cd_group.add_argument("--confirm-delete", dest="confirm_delete", action="store_true", help="Włącz potwierdzenie kasowania (jeśli było wyłączone).") cd_group.add_argument("--no-confirm-delete", dest="confirm_delete", action="store_false", help="Wyłącz potwierdzenie kasowania (tryb bez pytania).") p.set_defaults(confirm_delete=CONFIRM_DELETE) p.add_argument("--source-base-dir", dest="source_base_dir", default=SOURCE_BASE_DIR, help=f"Katalog źródłowy (domyślnie: {SOURCE_BASE_DIR})") p.add_argument("--relative-pattern", dest="relative_pattern", default=RELATIVE_PATTERN, help=f"Wzorzec relatywny od AppData (domyślnie: {RELATIVE_PATTERN})") dr_group = p.add_mutually_exclusive_group() dr_group.add_argument("--dry-run", dest="dry_run", action="store_true", help="Tryb symulacji: tylko plan (bez modyfikacji i bez gita).") dr_group.add_argument("--no-dry-run", dest="dry_run", action="store_false", help="Tryb wykonawczy: wprowadza zmiany i robi git.") p.set_defaults(dry_run=DRY_RUN) return p.parse_args() def main() -> int: args = parse_args() user = args.user appdata_kind = args.appdata_kind if args.appdata_kind != "None" else None modules_override = args.modules_dir_override module_subdir = args.module_subdir_name confirm_delete = args.confirm_delete source_base = args.source_base_dir relative_pattern = args.relative_pattern dry_run = args.dry_run # Ścieżki src_dir = Path(source_base) / module_subdir # [PRE] module.json + git w katalogu źródłowym print(f"[PRE] Katalog źródłowy: {norm(src_dir)}") preflight_source(src_dir, dry_run=dry_run) # 1) Ustal katalog modules (cel) if modules_override: modules_dir = Path(modules_override) if not modules_dir.is_dir(): raise FileNotFoundError(f"TARGET_MODULES_DIR_OVERRIDE wskazuje na nieistniejący katalog: {norm(modules_dir)}") else: modules_dir = find_modules_dir_for_user(user, relative_pattern, appdata_kind) print(f"[INFO] Katalog modules: {norm(modules_dir)}") target_module_dir = modules_dir / module_subdir print(f"[INFO] Docelowy katalog modułu: {norm(target_module_dir)}") # 2) Kasowanie zawartości (albo plan w dry-run) if confirm_delete and not dry_run: ans = input(f"Czy na pewno wyczyścić zawartość '{norm(target_module_dir)}'? [y/N]: ").strip().lower() if ans not in ("y", "yes", "t", "tak"): print("[INFO] Przerwano na życzenie użytkownika.") return 0 elif confirm_delete and dry_run: print("[INFO] (dry-run) Pytanie o potwierdzenie pomijane — tryb symulacji.") print("[AKCJA] Czyszczenie zawartości katalogu modułu..." if not dry_run else "[SYMULACJA] Plan czyszczenia katalogu modułu...") del_files, del_dirs = clear_dir_contents(target_module_dir, dry_run=dry_run) print(f"[INFO] {'Do usunięcia' if dry_run else 'Usunięto'}: plików={del_files}, katalogów={del_dirs}") # 3) Kopiowanie (albo plan kopiowania) print("[AKCJA] Kopiowanie zawartości..." if not dry_run else "[SYMULACJA] Plan kopiowania...") cp_files, cp_dirs = copy_dir_contents(src_dir, target_module_dir, dry_run=dry_run) print(f"[INFO] {'Do skopiowania' if dry_run else 'Skopiowano'}: plików={cp_files}, katalogów={cp_dirs}") print("[SUKCES] " + ("Symulacja zakończona." if dry_run else "Zakończono synchronizację.")) print(f" Źródło: {norm(src_dir)}") print(f" Cel: {norm(target_module_dir)}") return 0 if __name__ == "__main__": try: sys.exit(main()) except AmbiguityError as e: print("\n[NIERÓWNOZNACZNOŚĆ]\n" + str(e), file=sys.stderr) sys.exit(2) except Exception as e: print("\n[BŁĄD]\n" + str(e), file=sys.stderr) sys.exit(1)