Files
vtt_work/wg-voidships-builder/sync_foundry_module.py
T
2025-09-19 15:31:22 +02:00

340 lines
13 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sync_foundry_module.py (wersja z dry-run + argumentami CLI)
Funkcje:
1) Znajduje ...\AppData\(Local|LocalLow|Roaming)\FoundryVTT\Data\modules dla podanego usera
(lub używa pełnego override).
2) Czyści zawartość wskazanego modułu (z potwierdzeniem lub bez; w dry-run tylko listuje).
3) Kopiuje zawartość z SOURCE_BASE_DIR\<MODULE_SUBDIR_NAME> (w dry-run tylko plan).
Argumenty CLI nadpisują stałe z sekcji KONFIGURACJA.
"""
from __future__ import annotations
import os
import sys
import shutil
import argparse
from pathlib import Path
from typing import List, Optional, Tuple
# ==========================
# KONFIGURACJA (DOMYŚLNA)
# ==========================
TARGET_USER: str = "mtuszowski" # 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"
CONFIRM_DELETE: bool = True
SOURCE_BASE_DIR: str = r"C:\vtt_work"
RELATIVE_PATTERN: str = r"FoundryVTT\Data\modules"
DRY_RUN: bool = False # <- nowa stała
# ==========================
# 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]:
"""Zwraca listę *bez* samego katalogu: wszystkie pliki/katalogi pierwszego poziomu."""
if not dir_path.exists():
return []
return list(dir_path.iterdir())
def list_recursive(dir_path: Path) -> List[Path]:
"""Rekurencyjna lista wszystkich elementów wewnątrz katalogu (głębiej niż 1)."""
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]:
"""
Usuwa zawartość katalogu (pozostawia go). W dry_run tylko listuje.
Zwraca (liczba_plików, liczba_katalogów) do usunięcia/usuniętych.
"""
ensure_dir(dir_path)
files = 0
dirs = 0
# Zbierz listę do raportu
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)}")
# policz rekurencyjnie dla katalogów
for child in to_delete:
if child.is_file() or child.is_symlink():
files += 1
elif child.is_dir():
# policz wszystkie elementy w środku informacyjnie
nested = list_recursive(child)
dirs += 1
for n in nested:
# nie drukujemy każdego pliku zagnieżdżonego — wystarczy powyższa lista top-level
pass
return files, dirs
# tryb wykonawczy
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]]:
"""
Tworzy plan (źródło, cel, status) dla wszystkich elementów w src.
Status: "overwrite" jeśli cel istnieje (plik/katalog), inaczej "create".
"""
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)
# katalogi
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))
# pliki
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]:
"""
Kopiuje zawartość katalogu src do dst. W dry_run tylko listuje plan.
Zwraca (liczba_plików, liczba_katalogów) skopiowanych/planowanych.
"""
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
# wykonanie
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
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Synchronizacja modułu FoundryVTT (z dry-run).")
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})")
# confirm-delete jako przełącznik dwu-stanowy
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})")
# dry-run jako przełącznik dwu-stanowy
dr_group = p.add_mutually_exclusive_group()
dr_group.add_argument("--dry-run", dest="dry_run", action="store_true",
help="Tryb symulacji: tylko listuje co by zrobił.")
dr_group.add_argument("--no-dry-run", dest="dry_run", action="store_false",
help="Tryb wykonawczy: wprowadza zmiany na dysku.")
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
# 1) Ustal katalog modules
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 kasowania 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 w dry-run)
src_dir = Path(source_base) / module_subdir
print(f"[INFO] Katalog źródłowy: {norm(src_dir)}")
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)