sync: wg-voidships-builder v1.4.9

This commit is contained in:
2025-09-19 15:59:30 +02:00
parent c11e029b92
commit 20345f1605
4 changed files with 288 additions and 130 deletions
+130 -45
View File
@@ -1,15 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sync_foundry_module.py (wersja z dry-run + argumentami CLI)
sync_foundry_module.py (z dry-run, argumentami CLI i preflightem git/module.json)
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).
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: <id> v<version>"; git push
(w dry-run wyłącznie plan, bez modyfikacji i bez gita)
Argumenty CLI nadpisują stałe z sekcji KONFIGURACJA.
[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
@@ -17,6 +21,8 @@ import os
import sys
import shutil
import argparse
import json
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
@@ -27,11 +33,11 @@ from typing import List, Optional, Tuple
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"
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 # <- nowa stała
DRY_RUN: bool = False # globalny tryb symulacji (nic nie zmienia)
# ==========================
# KONIEC KONFIGURACJI
@@ -104,14 +110,12 @@ def ensure_dir(path: Path) -> None:
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
@@ -125,15 +129,9 @@ def list_recursive(dir_path: Path) -> List[Path]:
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:
@@ -143,20 +141,13 @@ def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]:
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
@@ -168,10 +159,6 @@ def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]:
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)}")
@@ -179,13 +166,11 @@ def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]:
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
@@ -195,10 +180,6 @@ def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]:
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)
@@ -213,7 +194,6 @@ def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]:
dirs = sum(1 for s, _, _ in plan if s.is_dir())
return files, dirs
# wykonanie
files = 0
dirs = 0
for s, t, _ in plan:
@@ -238,8 +218,109 @@ def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]:
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 "<msg>"; 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 (z dry-run).")
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,),
@@ -251,7 +332,7 @@ def parse_args() -> argparse.Namespace:
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).")
@@ -266,12 +347,11 @@ def parse_args() -> argparse.Namespace:
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ł.")
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 na dysku.")
help="Tryb wykonawczy: wprowadza zmiany i robi git.")
p.set_defaults(dry_run=DRY_RUN)
return p.parse_args()
@@ -289,7 +369,14 @@ def main() -> int:
relative_pattern = args.relative_pattern
dry_run = args.dry_run
# 1) Ustal katalog modules
# Ś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():
@@ -302,7 +389,7 @@ def main() -> int:
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)
# 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"):
@@ -315,9 +402,7 @@ def main() -> int:
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)}")
# 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}")