mirror of
https://github.com/migatu/vtt_work.git
synced 2026-07-14 21:38:39 +00:00
653 lines
25 KiB
Python
653 lines
25 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
sync_foundry_module.py
|
||
Rozszerzony:
|
||
- Tryb AUTO: wykrywanie katalogów ze zmianami (git), obsługa modułów i niemodułów.
|
||
- Każdy katalog -> osobny commit i push.
|
||
- Root version.nfo: opcjonalny bump globalnej wersji (0.x) jeśli są zmiany i HEAD ma tag z obecną wersją.
|
||
- Zipowanie po commit/push, przed podmianą plików (moduły) lub tuż po commicie (niemoduły):
|
||
ZIP_MODE = "ALL" | "MODULE_ONLY" | "OFF"; CLI: --zip-mode; --zip-output-dir.
|
||
|
||
Dwa tryby:
|
||
- AUTO (domyślny): wykryj wszystkie ZMIENIONE podkatalogi w SOURCE_BASE_DIR:
|
||
* jeśli folder zawiera module.json -> procedura "moduł"
|
||
* jeśli nie -> procedura "niemoduł" (version.nfo 0.x, inkrementacja)
|
||
- SINGLE: przetwórz wyłącznie katalog wskazany w MODULE_SUBDIR_NAME (jak wcześniej)
|
||
|
||
Dry-run:
|
||
- nic NIE modyfikuje (ani plików, ani repo), nie czyści i nie kopiuje – tylko plan.
|
||
|
||
Wymagania:
|
||
- Python 3.8+
|
||
- git w PATH (dla commit/push i wykrywania zmian)
|
||
- uprawnienia do zapisu w repo i w katalogu docelowym modułów
|
||
"""
|
||
|
||
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, Set
|
||
|
||
# ==========================
|
||
# KONFIGURACJA (DOMYŚLNA)
|
||
# ==========================
|
||
|
||
# Tryb: "auto" (wykryj zmienione katalogi) lub "single" (tylko MODULE_SUBDIR_NAME)
|
||
MODE: str = "auto" # "auto" | "single"
|
||
|
||
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" # używane w trybie SINGLE
|
||
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)
|
||
|
||
# --- ZIP settings ---
|
||
# ALL -> zipuj WSZYSTKIE katalogi (moduły i niemoduły)
|
||
# MODULE_ONLY -> zipuj TYLKO katalogi z module.json
|
||
# OFF -> nie twórz zipów
|
||
ZIP_MODE: str = "MODULE_ONLY" # "ALL" | "MODULE_ONLY" | "OFF"
|
||
# Katalog wyjściowy na archiwa .zip; jeśli None, użyje SOURCE_BASE_DIR/_zips
|
||
ZIP_OUTPUT_DIR: Optional[str] = None
|
||
|
||
# ==========================
|
||
# KONIEC KONFIGURACJI
|
||
# ==========================
|
||
|
||
APPDATA_VARIANTS = ("Local", "LocalLow", "Roaming")
|
||
ARGS = None # wypełniane w main()
|
||
|
||
class AmbiguityError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def norm(p: Path) -> Path:
|
||
return Path(os.path.normpath(str(p)))
|
||
|
||
|
||
def sh(cmd: List[str], cwd: Path) -> Tuple[int, str]:
|
||
"""Uruchom polecenie i zwróć (rc, out)."""
|
||
r = subprocess.run(cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||
return r.returncode, r.stdout
|
||
|
||
|
||
def git_head_tag(repo_dir: Path) -> Optional[str]:
|
||
"""Zwraca tag dokładnie na HEAD (jeśli istnieje), inaczej None."""
|
||
rc, out = sh(["git", "describe", "--tags", "--exact-match", "HEAD"], repo_dir)
|
||
if rc != 0:
|
||
return None
|
||
return out.strip() if out.strip() else None
|
||
|
||
|
||
def ensure_git_repo(path: Path) -> None:
|
||
rc, out = sh(["git", "rev-parse", "--is-inside-work-tree"], path)
|
||
if rc != 0 or "true" not in out.strip().lower():
|
||
raise RuntimeError(f"Katalog {norm(path)} nie wygląda na repozytorium git.\n{out}")
|
||
|
||
|
||
def git_changed_top_level_dirs(repo_dir: Path) -> List[str]:
|
||
"""
|
||
Zwraca posortowaną listę unikalnych NAZW PODKATALOGÓW (1. poziom) w repo,
|
||
które mają zmiany wg `git status --porcelain` (staged/unstaged/untracked).
|
||
Zmiany w plikach w root repo są ignorowane (brak katalogu).
|
||
"""
|
||
rc, out = sh(["git", "status", "--porcelain"], repo_dir)
|
||
if rc != 0:
|
||
raise RuntimeError(f"Błąd podczas `git status --porcelain` w {norm(repo_dir)}:\n{out}")
|
||
changed: Set[str] = set()
|
||
for line in out.splitlines():
|
||
if not line.strip():
|
||
continue
|
||
# format: XY <path> lub "?? <path>"
|
||
try:
|
||
path = line[3:].strip()
|
||
except Exception:
|
||
continue
|
||
# ignoruj rename "R a -> b": wówczas path ma "a -> b"
|
||
if "->" in path:
|
||
path = path.split("->", 1)[1].strip()
|
||
parts = Path(path).parts
|
||
if len(parts) >= 1:
|
||
top = parts[0]
|
||
# bierz tylko katalogi (jeśli top to plik w root, pomiń)
|
||
if (repo_dir / top).is_dir():
|
||
changed.add(top)
|
||
return sorted(changed)
|
||
|
||
|
||
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 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:
|
||
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_json_atomic(path: Path, data: dict) -> None:
|
||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||
with tmp.open("w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
f.write("\n")
|
||
tmp.replace(path)
|
||
|
||
|
||
def run_git_commands(repo_dir: Path, commit_msg: str, dry_run: bool) -> None:
|
||
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:
|
||
rc, out = sh(cmd, repo_dir)
|
||
if rc != 0:
|
||
raise RuntimeError(f"Błąd podczas uruchamiania: {' '.join(cmd)}\nWyjście:\n{out}")
|
||
if out.strip():
|
||
print(out)
|
||
|
||
run(["git", "add", "-A"])
|
||
rc, out = sh(["git", "commit", "-m", commit_msg], repo_dir)
|
||
if rc != 0:
|
||
raise RuntimeError(f"Błąd podczas git commit:\n{out}")
|
||
if out.strip():
|
||
print(out)
|
||
run(["git", "push"])
|
||
|
||
|
||
def preflight_module(src_dir: Path, dry_run: bool) -> Tuple[str, str]:
|
||
"""
|
||
Moduł: odczyt module.json, bump, zapis (jeśli nie dry_run), commit i 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] {src_dir.name}: 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_json_atomic(manifest_path, manifest)
|
||
commit_msg = f"sync: {module_id} v{new_version}"
|
||
run_git_commands(src_dir, commit_msg, dry_run=False)
|
||
|
||
return module_id, new_version
|
||
|
||
|
||
def preflight_non_module(src_dir: Path, dry_run: bool) -> str:
|
||
"""
|
||
Niemoduł: version.nfo w formacie '0.x' (x int).
|
||
- jeśli brak -> '0.1'
|
||
- jeśli jest -> inkrementuj x
|
||
Zapis (nie dry-run) oraz osobny commit/push.
|
||
Zwraca new_version (np. '0.4').
|
||
"""
|
||
nfo = src_dir / "version.nfo"
|
||
if not nfo.exists():
|
||
new_version = "0.1"
|
||
print(f"[PRE][FOLDER] {src_dir.name}: version.nfo brak -> {new_version}")
|
||
if not dry_run:
|
||
with nfo.open("w", encoding="utf-8") as f:
|
||
f.write(new_version + "\n")
|
||
commit_msg = f"sync: {src_dir.name} v{new_version}"
|
||
run_git_commands(src_dir, commit_msg, dry_run=False)
|
||
return new_version
|
||
|
||
# istnieje -> wczytaj i bumpnij
|
||
content = nfo.read_text(encoding="utf-8").strip()
|
||
if not content.startswith("0."):
|
||
raise ValueError(f"{norm(nfo)}: oczekiwano formatu '0.x', otrzymano '{content}'")
|
||
tail = content.split(".", 1)[1]
|
||
try:
|
||
x = int(tail)
|
||
except ValueError:
|
||
raise ValueError(f"{norm(nfo)}: część po '0.' nie jest liczbą: '{tail}'")
|
||
new_version = f"0.{x+1}"
|
||
print(f"[PRE][FOLDER] {src_dir.name}: version {content} -> {new_version}")
|
||
|
||
if not dry_run:
|
||
with nfo.open("w", encoding="utf-8") as f:
|
||
f.write(new_version + "\n")
|
||
commit_msg = f"sync: {src_dir.name} v{new_version}"
|
||
run_git_commands(src_dir, commit_msg, dry_run=False)
|
||
|
||
return new_version
|
||
|
||
|
||
# ====== ROOT version.nfo (globalna wersja repo) ======
|
||
def maybe_bump_root_version(repo_dir: Path, changed_any: bool, dry_run: bool) -> Optional[str]:
|
||
"""
|
||
Jeśli:
|
||
- istnieją zmiany do przetworzenia (changed_any == True)
|
||
- HEAD ma tag zawierający aktualną wersję globalną z version.nfo (0.x)
|
||
to bumpnij version.nfo w roocie (0.x -> 0.(x+1)), zrób osobny commit i push.
|
||
Zwraca nową wersję (str) lub None, jeśli pominięto.
|
||
"""
|
||
if not changed_any:
|
||
print("[ROOT] Pomijam bump wersji globalnej: brak zmian do przetworzenia.")
|
||
return None
|
||
|
||
nfo = repo_dir / "version.nfo"
|
||
if not nfo.exists():
|
||
print("[ROOT] Brak version.nfo w roocie; pomijam bump (wymagany istniejący plik).")
|
||
return None
|
||
|
||
current = nfo.read_text(encoding="utf-8").strip()
|
||
if not current.startswith("0."):
|
||
raise ValueError(f"{nfo}: oczekiwano formatu '0.x', otrzymano '{current}'")
|
||
# HEAD musi mieć tag zawierający 'current'
|
||
tag = git_head_tag(repo_dir)
|
||
if not tag or current not in tag:
|
||
print(f"[ROOT] Pomijam bump: {'brak taga na HEAD' if not tag else f'tag \"{tag}\" nie zawiera wersji {current}'}")
|
||
return None
|
||
|
||
# Bump
|
||
try:
|
||
tail = int(current.split(".", 1)[1])
|
||
except ValueError:
|
||
raise ValueError(f"{nfo}: część po '0.' nie jest liczbą: '{current}'")
|
||
new_version = f"0.{tail+1}"
|
||
print(f"[ROOT] Globalna wersja: {current} -> {new_version}")
|
||
|
||
if dry_run:
|
||
print("[DRY-RUN][ROOT] Zapis version.nfo i commit/push zostaną pominięte.")
|
||
return new_version
|
||
|
||
nfo.write_text(new_version + "\n", encoding="utf-8")
|
||
run_git_commands(repo_dir, f"sync: root v{new_version}", dry_run=False)
|
||
return new_version
|
||
|
||
|
||
# ====== ZIP utilities ======
|
||
def should_zip(is_module: bool, zip_mode: str) -> bool:
|
||
if zip_mode == "OFF":
|
||
return False
|
||
if zip_mode == "ALL":
|
||
return True
|
||
# MODULE_ONLY
|
||
return is_module
|
||
|
||
|
||
def ensure_zip_output_dir(source_base: Path, override: Optional[str]) -> Path:
|
||
out = Path(override) if override else (source_base / "_zips")
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
return out
|
||
|
||
|
||
def make_zip_for_folder(src_dir: Path, version: str, out_dir: Path, dry_run: bool) -> Path:
|
||
"""
|
||
Tworzy .zip z zawartością src_dir (bez nadkatalogów) o nazwie NAME-vVERSION.zip
|
||
Zwraca ścieżkę do archiwum.
|
||
"""
|
||
base_name = f"{src_dir.name}-v{version}"
|
||
target = out_dir / (base_name + ".zip")
|
||
if dry_run:
|
||
print(f"[DRY-RUN][ZIP] {src_dir} -> {target}")
|
||
return target
|
||
# shutil.make_archive potrzebuje base_name BEZ .zip i root_dir+base_dir
|
||
base_name_path = out_dir / base_name
|
||
shutil.make_archive(str(base_name_path), "zip", root_dir=str(src_dir), base_dir=".")
|
||
print(f"[ZIP] Utworzono {target}")
|
||
return target
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
p = argparse.ArgumentParser(description="Synchronizacja FoundryVTT: AUTO wykrywanie zmian + obsługa modułów/niemodułów.")
|
||
p.add_argument("--mode", choices=["auto", "single"], default=MODE,
|
||
help=f"Tryb działania (domyślnie: {MODE}).")
|
||
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 (tylko w --mode single). 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.")
|
||
cd_group.add_argument("--no-confirm-delete", dest="confirm_delete", action="store_false",
|
||
help="Wyłącz potwierdzenie kasowania.")
|
||
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 repo (domyślnie: {SOURCE_BASE_DIR})")
|
||
p.add_argument("--relative-pattern", dest="relative_pattern",
|
||
default=RELATIVE_PATTERN,
|
||
help=f"Wzorzec relatywny od AppData dla katalogu modules (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: modyfikacje + git.")
|
||
p.set_defaults(dry_run=DRY_RUN)
|
||
|
||
# ZIP options
|
||
p.add_argument("--zip-mode", dest="zip_mode",
|
||
choices=["ALL", "MODULE_ONLY", "OFF"],
|
||
default=ZIP_MODE,
|
||
help=f"Zipowanie: ALL | MODULE_ONLY | OFF (domyślnie: {ZIP_MODE})")
|
||
p.add_argument("--zip-output-dir", dest="zip_output_dir",
|
||
default=ZIP_OUTPUT_DIR,
|
||
help="Katalog wyjściowy na zipy (domyślnie: SOURCE_BASE_DIR/_zips)")
|
||
|
||
return p.parse_args()
|
||
|
||
|
||
def resolve_modules_base(user: str, relative_pattern: str, appdata_kind: Optional[str],
|
||
override: Optional[str]) -> Path:
|
||
if override:
|
||
modules_dir = Path(override)
|
||
if not modules_dir.is_dir():
|
||
raise FileNotFoundError(f"TARGET_MODULES_DIR_OVERRIDE wskazuje na nieistniejący katalog: {norm(modules_dir)}")
|
||
return modules_dir
|
||
return find_modules_dir_for_user(user, relative_pattern, appdata_kind)
|
||
|
||
|
||
def process_module(src_dir: Path, modules_base: Path, confirm_delete: bool, dry_run: bool) -> None:
|
||
module_id, new_ver = preflight_module(src_dir, dry_run=dry_run)
|
||
|
||
target_dir = modules_base / src_dir.name
|
||
print(f"[INFO][MODULE] Cel modułu: {norm(target_dir)}")
|
||
|
||
if confirm_delete and not dry_run:
|
||
ans = input(f"Czy na pewno wyczyścić zawartość '{norm(target_dir)}'? [y/N]: ").strip().lower()
|
||
if ans not in ("y", "yes", "t", "tak"):
|
||
raise RuntimeError("Przerwano na życzenie użytkownika.")
|
||
|
||
if confirm_delete and dry_run:
|
||
print("[INFO] (dry-run) Pytanie o potwierdzenie pominięte.")
|
||
|
||
# ZIP po commicie, przed podmianą
|
||
if should_zip(is_module=True, zip_mode=ARGS.zip_mode):
|
||
out_dir = ensure_zip_output_dir(Path(ARGS.source_base_dir), ARGS.zip_output_dir)
|
||
try:
|
||
make_zip_for_folder(src_dir, new_ver, out_dir, dry_run)
|
||
except Exception as e:
|
||
raise RuntimeError(f"Błąd zipowania modułu {src_dir.name}: {e}") from e
|
||
|
||
print("[AKCJA] Czyszczenie..." if not dry_run else "[SYMULACJA] Plan czyszczenia...")
|
||
del_files, del_dirs = clear_dir_contents(target_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}")
|
||
|
||
print("[AKCJA] Kopiowanie..." if not dry_run else "[SYMULACJA] Plan kopiowania...")
|
||
cp_files, cp_dirs = copy_dir_contents(src_dir, target_dir, dry_run=dry_run)
|
||
print(f"[INFO] {'Do skopiowania' if dry_run else 'Skopiowano'}: plików={cp_files}, katalogów={cp_dirs}")
|
||
|
||
print(f"[DONE][MODULE] {src_dir.name} -> commit 'sync: {module_id} v{new_ver}' wykonany." if not dry_run
|
||
else f"[DRY-RUN][MODULE] {src_dir.name} -> commit planowany.")
|
||
|
||
|
||
def process_non_module(src_dir: Path, dry_run: bool) -> None:
|
||
new_ver = preflight_non_module(src_dir, dry_run=dry_run)
|
||
# ZIP po commicie (jeśli ustawienie pozwala)
|
||
if should_zip(is_module=False, zip_mode=ARGS.zip_mode):
|
||
out_dir = ensure_zip_output_dir(Path(ARGS.source_base_dir), ARGS.zip_output_dir)
|
||
try:
|
||
make_zip_for_folder(src_dir, new_ver, out_dir, dry_run)
|
||
except Exception as e:
|
||
raise RuntimeError(f"Błąd zipowania folderu {src_dir.name}: {e}") from e
|
||
print(f"[DONE][FOLDER] {src_dir.name} -> version.nfo = {new_ver}; commit wykonany." if not dry_run
|
||
else f"[DRY-RUN][FOLDER] {src_dir.name} -> version.nfo będzie {new_ver}; commit planowany.")
|
||
|
||
|
||
def main() -> int:
|
||
global ARGS
|
||
ARGS = parse_args()
|
||
|
||
mode = ARGS.mode
|
||
user = ARGS.user
|
||
appdata_kind = ARGS.appdata_kind if ARGS.appdata_kind != "None" else None
|
||
modules_override = ARGS.modules_dir_override
|
||
module_subdir_single = ARGS.module_subdir_name
|
||
confirm_delete = ARGS.confirm_delete
|
||
source_base = Path(ARGS.source_base_dir)
|
||
relative_pattern = ARGS.relative_pattern
|
||
dry_run = ARGS.dry_run
|
||
|
||
if not source_base.is_dir():
|
||
raise FileNotFoundError(f"Katalog źródłowy nie istnieje: {norm(source_base)}")
|
||
|
||
ensure_git_repo(source_base)
|
||
|
||
# Ustal bazę modules (tylko raz)
|
||
modules_base = resolve_modules_base(user, relative_pattern, appdata_kind, modules_override)
|
||
print(f"[INFO] Katalog modules base: {norm(modules_base)}")
|
||
|
||
if mode == "single":
|
||
src_dir = source_base / module_subdir_single
|
||
if not src_dir.is_dir():
|
||
raise FileNotFoundError(f"[SINGLE] Brak katalogu: {norm(src_dir)}")
|
||
# Root bump (globalna wersja) – tylko jeśli "są zmiany do przetworzenia" (single -> True)
|
||
maybe_bump_root_version(source_base, changed_any=True, dry_run=dry_run)
|
||
if (src_dir / "module.json").is_file():
|
||
print(f"[MODE=SINGLE][MODULE] {src_dir.name}")
|
||
process_module(src_dir, modules_base, confirm_delete, dry_run)
|
||
else:
|
||
print(f"[MODE=SINGLE][FOLDER] {src_dir.name}")
|
||
process_non_module(src_dir, dry_run)
|
||
print("[SUKCES] Tryb SINGLE zakończony.")
|
||
return 0
|
||
|
||
# AUTO: wykryj zmienione katalogi
|
||
changed = git_changed_top_level_dirs(source_base)
|
||
print(f"[AUTO] Zmienione katalogi w {norm(source_base)}: {changed if changed else '(brak)'}")
|
||
# Root bump (globalna wersja) – tylko jeśli są jakiekolwiek zmiany do przetworzenia
|
||
maybe_bump_root_version(source_base, changed_any=bool(changed), dry_run=dry_run)
|
||
if not changed:
|
||
print("[INFO] Brak zmian do przetworzenia.")
|
||
return 0
|
||
|
||
# Przetwarzaj w stałej kolejności
|
||
for name in changed:
|
||
src_dir = source_base / name
|
||
if (src_dir / "module.json").is_file():
|
||
print(f"\n[AUTO][MODULE] {name}")
|
||
process_module(src_dir, modules_base, confirm_delete, dry_run)
|
||
else:
|
||
print(f"\n[AUTO][FOLDER] {name}")
|
||
process_non_module(src_dir, dry_run)
|
||
|
||
print("\n[SUKCES] Tryb AUTO zakończony.")
|
||
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)
|