From 20345f1605044ce16d1cbbf14e43e276d3d2d22a Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Fri, 19 Sep 2025 15:59:30 +0200 Subject: [PATCH] sync: wg-voidships-builder v1.4.9 --- wg-voidships-builder/module.json | 69 +++++-- .../packs/wg-voidship-items.db | 6 +- .../scripts/voidship-actor.js | 168 ++++++++++------- wg-voidships-builder/sync_foundry_module.py | 175 +++++++++++++----- 4 files changed, 288 insertions(+), 130 deletions(-) diff --git a/wg-voidships-builder/module.json b/wg-voidships-builder/module.json index e3e382a..b0d7f28 100644 --- a/wg-voidships-builder/module.json +++ b/wg-voidships-builder/module.json @@ -2,9 +2,16 @@ "id": "wg-voidships-builder", "title": "Wrath & Glory: Voidships — Builder", "description": "Składanie okrętów jako Actorów przez dodawanie wyposażenia (gear) z kompendium.", - "version": "1.4.8", - "compatibility": { "minimum": "12", "verified": "13" }, - "authors": [{ "name": "Gizmo & GPT-5 Thinking" }], + "version": "1.4.9", + "compatibility": { + "minimum": "12", + "verified": "13" + }, + "authors": [ + { + "name": "Gizmo & GPT-5 Thinking" + } + ], "scripts": [ "scripts/voidships.js", "scripts/voidship-actor.js" @@ -12,15 +19,47 @@ "templates": [ "templates/voidship-sheet.hbs" ], - "packs": [ - { "label": "W&G Voidship Hulls (Empty)", "name": "wg-voidship-hulls", "path": "packs/wg-voidship-hulls.db", "type": "Actor", "system": "wrath-and-glory" }, - { "label": "W&G Voidship Items", "name": "wg-voidship-items", "path": "packs/wg-voidship-items.db", "type": "Item", "system": "wrath-and-glory" }, - { "label": "W&G Voidships — Tables", "name": "wg-voidships-tables", "path": "packs/wg-voidships-tables.db", "type": "RollTable" }, - { "label": "W&G Voidship Macros", "name": "wg-voidship-macros", "path": "packs/wg-voidship-macros.db", "type": "Macro" }, - - { "label": "W&G Voidship Archeotech", "name": "wg-voidship-archeotech", "path": "packs/wg-voidship-archeotech.db", "type": "Item", "system": "wrath-and-glory" }, - { "label": "W&G Voidship Xenos", "name": "wg-voidship-xenos", "path": "packs/wg-voidship-xenos.db", "type": "Item", "system": "wrath-and-glory" } + { + "label": "W&G Voidship Hulls (Empty)", + "name": "wg-voidship-hulls", + "path": "packs/wg-voidship-hulls.db", + "type": "Actor", + "system": "wrath-and-glory" + }, + { + "label": "W&G Voidship Items", + "name": "wg-voidship-items", + "path": "packs/wg-voidship-items.db", + "type": "Item", + "system": "wrath-and-glory" + }, + { + "label": "W&G Voidships — Tables", + "name": "wg-voidships-tables", + "path": "packs/wg-voidships-tables.db", + "type": "RollTable" + }, + { + "label": "W&G Voidship Macros", + "name": "wg-voidship-macros", + "path": "packs/wg-voidship-macros.db", + "type": "Macro" + }, + { + "label": "W&G Voidship Archeotech", + "name": "wg-voidship-archeotech", + "path": "packs/wg-voidship-archeotech.db", + "type": "Item", + "system": "wrath-and-glory" + }, + { + "label": "W&G Voidship Xenos", + "name": "wg-voidship-xenos", + "path": "packs/wg-voidship-xenos.db", + "type": "Item", + "system": "wrath-and-glory" + } ], "packFolders": [ { @@ -37,7 +76,13 @@ "folders": [] } ], - "relationships": { "systems": [{ "id": "wrath-and-glory" }] }, + "relationships": { + "systems": [ + { + "id": "wrath-and-glory" + } + ] + }, "readme": "journal/README.md", "license": "LICENSE" } diff --git a/wg-voidships-builder/packs/wg-voidship-items.db b/wg-voidships-builder/packs/wg-voidship-items.db index 96229b7..0d09ffc 100644 --- a/wg-voidships-builder/packs/wg-voidship-items.db +++ b/wg-voidships-builder/packs/wg-voidship-items.db @@ -6,9 +6,11 @@ {"_id":"HULLPLATE0000001","name":"Reinforced Hull Plating","type":"gear","img":"icons/svg/armor.svg","flags":{"wg-voidships-builder":{"component":{"kind":"armour","subtype":"plating","tags":["hull","armour","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":1,"power":0},"mods":{"armour":2,"integrity":4,"manoeuvre":-1,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Additional armour plates and bracing.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]} {"_id":"CARGOHOLD0000001","name":"Cargo Hold & Lighter Bay","type":"gear","img":"icons/svg/crate.svg","flags":{"wg-voidships-builder":{"component":{"kind":"cargo-bay","subtype":"standard","tags":["cargo-bay","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":-5,"power":1},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":-5,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"General-purpose cargo hold with lighter handling.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]} {"_id":"AUXPLASMA0000001","name":"Auxiliary Plasma Generator","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"component":{"kind":"plasma","subtype":"aux-generator","tags":["aux-plasma","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":-6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":6}}}},"system":{"quantity":1,"rarity":"Common","description":"Auxiliary plasma reactor increasing available power.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{"power":0},"mods":{}}},"effects":[]} -{"_id":"MACROPROW0000001","name":"Macrobattery (Prow)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","prow"]},"voidOnly":true,"voidship":{"slot":"Prow","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Prow macrobattery.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]} -{"_id":"MACRODORS0000001","name":"Macrobattery (Dorsal)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","dorsal"]},"voidOnly":true,"voidship":{"slot":"Dorsal","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Dorsal macrobattery.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]} {"_id":"ITEMREACTCRU0001","name":"Plasma Reactor (Cruiser) — Mars-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"cruiser","tags":["reactor","plasma"]}}},"system":{"description":"Standardowy reaktor plazmowy klasy krążownik (Mars-Pattern). Generuje moc dla wszystkich systemów okrętu.","voidship":{"slot":"Internal","use":{"space":20,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":60}}},"effects":[]} {"_id":"ITEMREACTLCR0001","name":"Plasma Reactor (Light Cruiser) — Mars-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"light-cruiser","tags":["reactor","plasma"]}}},"system":{"description":"Reaktor plazmowy dla lekkich krążowników (Mars-Pattern).","voidship":{"slot":"Internal","use":{"space":18,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":55}}},"effects":[]} {"_id":"ITEMREACTFRI0001","name":"Plasma Reactor (Frigate) — Jovian-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"frigate","tags":["reactor","plasma"]}}},"system":{"description":"Reaktor plazmowy okrętów klasy fregata (Jovian-Pattern).","voidship":{"slot":"Internal","use":{"space":15,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":45}}},"effects":[]} {"_id":"ITEMREACTESC0001","name":"Plasma Reactor (Escort) — Jovian-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"escort","tags":["reactor","plasma"]}}},"system":{"description":"Lżejszy reaktor plazmowy dla eskort.","voidship":{"slot":"Internal","use":{"space":12,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":40}}},"effects":[]} +{"_id":"ITEMMACROUNI0001","name":"Macrobattery (Universal) — Mars-Pattern","type":"gear","img":"icons/svg/dice-target.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"macro","tags":["weapon","macro"]}}},"system":{"description":"Uniwersalna makrobateria. Może być montowana na dowolnym stanowisku broni.","voidship":{"allowedSlots":["prow","dorsal","port","starboard"],"use":{"space":8,"power":4},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]} +{"_id":"ITEMWPMACBRD0001","name":"Macrobattery (Broadside) — Mars-Pattern","type":"gear","img":"icons/svg/dice-target.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"macro-broadside","tags":["weapon","macro"]}}},"system":{"description":"Makrobateria burtowa. Montaż wyłącznie na burtach.","voidship":{"allowedSlots":["port","starboard"],"use":{"space":9,"power":4},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]} +{"_id":"ITEMTORPPROW0001","name":"Torpedo Tubes — Jovian-Pattern","type":"gear","img":"icons/svg/anchor.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"torpedo","tags":["weapon","torpedo"]}}},"system":{"description":"Wyrzutnie torpedowe montowane w dziobie.","voidship":{"allowedSlots":["prow"],"use":{"space":10,"power":5},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]} +{"_id":"ITEMLANCEDSL0001","name":"Lance Battery — Starflare-Pattern","type":"gear","img":"icons/svg/sun.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"lance","tags":["weapon","lance"]}}},"system":{"description":"Bateria lanc. Preferowane stanowiska: dorsal/port/starboard.","voidship":{"allowedSlots":["dorsal","port","starboard"],"use":{"space":10,"power":6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]} diff --git a/wg-voidships-builder/scripts/voidship-actor.js b/wg-voidships-builder/scripts/voidship-actor.js index a180433..e0c2c03 100644 --- a/wg-voidships-builder/scripts/voidship-actor.js +++ b/wg-voidships-builder/scripts/voidship-actor.js @@ -4,6 +4,47 @@ const VB_MOD = "wg-voidships-builder"; const VB_DEBUG = true; // Debug on function dbg(...args) { if (VB_DEBUG) console.debug("[VB]", ...args); } +// --- SLOT HELPERS: normalizacja i przydział "pierwszy wolny pasujący" --- +const VB_WEAPON_SLOTS = ["prow", "dorsal", "port", "starboard", "keel"]; + +function normalizeSlotName(s) { + const v = String(s || "").trim().toLowerCase(); + return VB_WEAPON_SLOTS.includes(v) ? v : null; +} + +function normalizeAllowedSlots(allowed) { + if (!allowed) return []; + if (Array.isArray(allowed)) return allowed.map(normalizeSlotName).filter(Boolean); + // string "prow|dorsal|port" albo "prow,dorsal" + return String(allowed) + .split(/[|,; ]+/g) + .map(normalizeSlotName) + .filter(Boolean); +} + +function countUsedWeaponSlots(actor) { + const used = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 }; + for (const it of actor.items ?? []) { + const vs = it.system?.voidship; + if (!vs) continue; + const slot = normalizeSlotName(vs.slot); + const eq = (it.system?.equipped ?? true); + if (eq && slot && (slot in used)) used[slot] += 1; + } + return used; +} + +function pickFirstFreeSlot(allowed, hullSlots, used) { + // Kolejność zgodnie z wymaganiem: "pierwszy wolny pasujący" + const order = ["prow", "dorsal", "port", "starboard", "keel"]; + for (const s of order) { + if (!allowed.includes(s)) continue; + const cap = Number(hullSlots?.[s] ?? 0); + const u = Number(used?.[s] ?? 0); + if (u < cap) return s; + } + return null; +} // ---------------- Handlebars helpers ---------------- Hooks.once("init", () => { @@ -311,88 +352,73 @@ async _onDrop(event) { if (raw?.type === "Item") return this._onDropItem(event, raw); dbg("drop.ignored", raw); } - +/** Drop itemów na arkusz: tylko komponenty voidship. + * - Jeżeli item ma allowedSlots (broń): przydziel "pierwszy wolny pasujący" i zapisz do system.voidship.slot + * - Jeżeli nie ma allowedSlots: zachowaj istniejący system.voidship.slot (albo slot "Internal" dla nie-broni) + */ async _onDropItem(event, data) { - const VB_MOD = "wg-voidships-builder"; - dbg("drop.item.raw", foundry.utils.deepClone(data)); + event.preventDefault(); - // 1) spróbuj fromUuid - let itemDoc = null; - if (data?.uuid) { - try { itemDoc = await fromUuid(data.uuid); } catch (e) { dbg("fromUuid.error", e); } - } - // 2) inline data - let itemData = itemDoc?.toObject?.() ?? data?.data ?? null; - // 3) ostatecznie spróbuj Foundry helpera - if (!itemData) { - try { - const i = await Item.implementation.fromDropData(data); - itemData = i?.toObject?.() ?? null; - } catch (e) { - ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA)."); - dbg("fromDropData.error", e, data); return; + // 1) Rozwiąż źródło dropu (UUID/pack/data) możliwie bezpiecznie + let doc = null; + try { + if (data?.uuid) doc = await fromUuid(data.uuid); + else if (data?.pack && data?._id) { + const pack = game.packs.get(data.pack); + if (pack) doc = await pack.getDocument(data._id); + } else if (data?.data || data?.type) { + // Foundry potrafi zrzucać raw "data" + doc = await Item.implementation.fromDropData(data); } - } - if (!itemData) { ui.notifications.error("Drop failed: no item data."); return; } + } catch (e) { /* fall-through */ } - // 4) Czy to komponent? – akceptujemy po flagach lub po system.voidship - const fmod = itemData.flags?.[VB_MOD] || {}; - const meta = fmod.component || {}; - const vflag = fmod.voidship || null; - const vsys = itemData.system?.voidship || null; - const tags = Array.isArray(meta.tags) ? meta.tags : []; - const isComponent = !!(vflag || vsys || tags.length); + // 2) Wyprowadź "raw" do stworzenia osadzonego itemu + const raw = doc?.toObject?.(false) ?? data?.data ?? data; + if (!raw?.system) { + ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA)."); + return false; + } + + // 3) Sprawdź, czy to komponent voidship (wymagamy system.voidship) + const isComponent = !!raw.system?.voidship || !!raw.flags?.["wg-voidships-builder"]?.component; if (!isComponent) { ui.notifications.warn("This item is not a voidship component."); - dbg("notComponent", { name: itemData.name, flags: itemData.flags }); - return; + return false; } - // 5) wyznacz slot + liczby (preferuj to co już jest w flagach; jeśli było tylko w system.voidship – przenieś do flag) - const inferSlotFromTags = (tags=[]) => { - const low = (Array.isArray(tags)?tags:[]).map(t => String(t).toLowerCase()); - const pref = ["prow","dorsal","port","starboard","keel","aetherics","internal"]; - const hit = pref.find(p => low.includes(p)); - return hit ? (hit[0].toUpperCase()+hit.slice(1)) : "Internal"; - }; + // 4) Upewnij się, że istnieje sekcja voidship + raw.system.voidship = raw.system.voidship || {}; + const vsh = raw.system.voidship; - const vf = vflag || vsys || {}; - const slot = vf.slot || meta.slot || inferSlotFromTags(meta.tags); - const vfixed = { - slot, - use: { - space: Number(vf?.use?.space ?? 0), - power: Number(vf?.use?.power ?? 0) - }, - mods: { - armour: Number(vf?.mods?.armour ?? 0), - integrity: Number(vf?.mods?.integrity ?? 0), - manoeuvre: Number(vf?.mods?.manoeuvre ?? 0), - detection: Number(vf?.mods?.detection ?? 0), - speed: Number(vf?.mods?.speed ?? 0), - space: Number(vf?.mods?.space ?? 0), - power: Number(vf?.mods?.power ?? 0) + // 5) Wyciągnij allowedSlots (preferuj z system.voidship.allowedSlots; ewentualnie z flags.component.allowedSlots) + const fromFlags = foundry.utils.getProperty(raw, "flags.wg-voidships-builder.component.allowedSlots"); + const allowed = normalizeAllowedSlots(vsh.allowedSlots ?? fromFlags); + + // 6) Jeżeli to broń (ma ograniczenia slotów), wybierz pierwszy wolny pasujący i wpisz do vsh.slot. + if (allowed.length > 0) { + const hull = this.actor.getFlag("wg-voidships-builder", "hull") || {}; + const hullSlots = hull.slots || {}; + const used = countUsedWeaponSlots(this.actor); + + const chosen = pickFirstFreeSlot(allowed, hullSlots, used); + if (!chosen) { + ui.notifications.warn("No free matching weapon slot for this component."); + return false; } - }; - - // 6) Nie dotykamy schema W&G – zapisujemy w FLAGS - itemData.flags = foundry.utils.mergeObject(itemData.flags || {}, { - [VB_MOD]: foundry.utils.mergeObject(fmod, { voidship: vfixed }, { inplace: false }) - }, { inplace: false }); - - // 7) dopnij equipped (żeby mody się naliczały od razu) - itemData.system = foundry.utils.mergeObject(itemData.system || {}, { equipped: true }, { inplace: false }); - - dbg("drop.finalized", { name: itemData.name, vfixed }); - - try { - const created = await this.actor.createEmbeddedDocuments("Item", [itemData]); - dbg("drop.created", created?.map(c => ({ id: c.id, name: c.name }))); - this.render(true); - } catch (e) { - ui.notifications.error("Failed to add component to this voidship (see console)."); - dbg("drop.create.error", e); + vsh.slot = chosen; // przypisz ostateczny slot montażu + } else { + // Nie-broń: zostaw istniejący slot albo nadaj "Internal" + vsh.slot = vsh.slot || "Internal"; } + + // 7) Domyślnie traktuj jako "equipped" (zamontowane), jeśli schema na to pozwala + if (raw.system.equipped === undefined) raw.system.equipped = true; + + // 8) Utwórz item na aktorze + await this.actor.createEmbeddedDocuments("Item", [raw]); + // 9) Przelicz i odśwież + this.render(true); + return true; } diff --git a/wg-voidships-builder/sync_foundry_module.py b/wg-voidships-builder/sync_foundry_module.py index 9ad86f1..82d228c 100644 --- a/wg-voidships-builder/sync_foundry_module.py +++ b/wg-voidships-builder/sync_foundry_module.py @@ -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\ (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: v"; 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 ""; 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}")