diff --git a/README.md b/README.md index a54853a..e5e2c6a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,19 @@ restart your shell. Upgrade after pulling changes: nothing to do (editable). To uninstall: `uv tool uninstall fstools`. +### Shell completion + +`fs` can complete subcommands, options, and file arguments. Install it once for +your shell, then restart the shell (or `source` your rc file): + +```sh +fs --install-completion # detects your shell (bash/zsh/fish/PowerShell) +``` + +This appends a small snippet to your shell config (e.g. `~/.bashrc`, +`~/.zshrc`, `~/.config/fish/completions/`). Prefer to wire it up yourself? +`fs --show-completion` prints the script to stdout instead of installing it. + ## Commands ```sh @@ -25,7 +38,6 @@ cd MyMod && fs pack # pack the current folder -> ./FS25_MyMod.zip fs pack -o build # -> build/FS25_MyMod.zip fs pack -d # also copy into the FS25 mods folder fs pack -p # deploy, then launch FS25 via Steam -fs pack --keep-images # keep ALL png/psd/tga (icon is kept regardless) fs pack -n # dry run — list files, write nothing fs pack ../OtherMod # a path still works if you'd rather not cd @@ -37,6 +49,10 @@ fs testrunner # show installed TestRunner + whether a newer one exi fs testrunner --update # install the newest TestRunner*.zip found fs testrunner -s FILE.zip # install a specific TestRunner archive/exe +fs edit scene.i3d # open an .i3d in the GIANTS Editor +fs register-editor # make .i3d files open with the editor from any file manager +fs register-editor --remove # undo that association + fs log # follow log.txt live (errors red, warnings yellow) fs log -e # only Error/Warning lines fs log -n 50 # last 50 lines, then follow @@ -57,14 +73,62 @@ lands at the zip root (what FS requires), using the stdlib `zipfile` — no `7z`/`zip` binary needed. It refuses to pack a folder without a `modDesc.xml`. Excluded by default: source/DCC files (`*.blend *.obj *.fbx *.mel *.mb *.ma`), -scripts (`*.cmd *.sh *.py`), docs (`*.txt *.md`), VCS/IDE dirs -(`.git .svn .idea .vscode …`), `$data` / `substance` folders, and image sources -(`*.png *.psd *.tga *.pdn *.gim`). +image-editor sources (`*.psd *.pdn`), scripts (`*.cmd *.sh *.py`), docs +(`*.txt *.md`), VCS/IDE dirs (`.git .svn .idea .vscode …`), and `$data` / +`substance` folders. + +> Rendered images (`.png`, `.dds`, `.tga`, …) are **kept** — a mod's textures and +> icon ship as-is (only the editor sources `.psd`/`.pdn` are dropped). If your +> repo holds image (or other) files that aren't part of the mod, list them in a +> `.fsignore` (see below). Run `fs test` before publishing: the GIANTS TestRunner +> flags a `.png` used where a `.dds` is expected, so you don't need the packer to +> guess. + +### Ignoring files (`.fsignore`) + +Drop a `.fsignore` in the mod folder to keep repo-only files out of the zip. It's +gitignore-flavoured — one glob per line, `#` comments and blank lines ignored: + +```gitignore +# working files that live in the repo but aren't part of the mod +*.psd +*.xcf +textures/_wip/ # a whole subfolder +docs/preview.png # a specific file +``` + +A pattern **without** a `/` matches that name at any depth (`*.psd`, `_wip/`); a +pattern **with** a `/` is anchored to the mod-relative path (`textures/_wip/`). +`*` spans path separators. The `.fsignore` itself is never packed. + +### Per-mod config (`fstools.toml`) -> Images are stripped by default (matching the original packer) — **except the -> mod's icon**: the file named in `` is always kept, even when it -> ships as a `.png` that `modDesc` references as `.dds` (FS converts it at load). -> Use `--keep-images` if a mod ships *other* textures as `.png` rather than `.dds`. +Drop an optional `fstools.toml` next to `modDesc.xml` to drive the packed mod's +name and metadata **without editing `modDesc.xml` or renaming the folder**: + +```toml +[mod] +zip_name = "FS25_MyMod_dev" # packed .zip stem (FS25_ prefix added if missing) +version = "1.0.0.0-dev" # overrides +author = "Me" # overrides +title = "My Mod (dev)" # overrides every language entry + +# …or set titles per language instead of one string: +# [mod.title] +# en = "My Mod (dev)" +# de = "Mein Mod (dev)" +``` + +Every key is optional. `zip_name` changes only the output filename; `version`, +`author`, and `title` are rewritten **inside the packed zip's `modDesc.xml`** — +the file on disk is untouched, and `fstools.toml` itself is never packed. A +string `title` sets every existing `<title>` language entry; a `[mod.title]` +table sets (or creates) each language you list. + +This lets a **dev build coexist with your stable mod in the same savegame**: give +it a different `zip_name` (FS keys mods by zip filename) and a `title` so you can +tell them apart in the mod list. Delete or `.gitignore` the file to go back to a +normal build. `fs test` honours the same config. ### ModHub TestRunner (`fs test`) @@ -94,6 +158,27 @@ args shows the installed version and warns if a newer archive is available. > results into one shared dir, only one `fs test` may run at a time — a second run > refuses to start (lock) rather than corrupt results. +### Opening i3d files (`fs edit` / `fs register-editor`) + +`fs edit scene.i3d` opens an `.i3d` scene in the GIANTS Editor. Like `fs test`, +the editor is a Windows program living inside the FS25 Proton prefix, so it's +launched via `protontricks-launch` with the file mapped to its `Z:\` prefix +path — no manual "add non-Steam game" dance. The editor is auto-detected under +`.../drive_c/Program Files/` (e.g. `GIANTS Software/GIANTS_Editor_10.0.11/editor.exe`, +newest version wins); set `FS25_EDITOR` if yours lives elsewhere. Pass `-v` to +run it in the foreground and see the editor's console (its "could not load +file …" warnings are handy for spotting missing or mis-cased references). + +`fs register-editor` wires `.i3d` into your desktop so you can **double-click an +`.i3d` in any file manager** (or run `xdg-open scene.i3d`) and it opens in the +editor. It installs a freedesktop desktop entry (`~/.local/share/applications/`) +and a `application/x-i3d` MIME type for `*.i3d` (`~/.local/share/mime/`), then +makes it the default handler. `fs register-editor --remove` undoes both. Run it +once; it points at the `fs` on your PATH, so editable source changes keep working. + +Requirements: `protontricks` (`protontricks-launch` on PATH) and the GIANTS +Editor installed in the FS25 prefix — the same setup `fs test` needs. + ## Configuration Paths auto-detect for Steam app `2300320`. Override via env vars: @@ -105,6 +190,7 @@ Paths auto-detect for Steam app `2300320`. Override via env vars: | `FS25_APPID` | Steam app id (default `2300320`) | | `FS25_LOG` | path to `log.txt` (else auto-detected) | | `FS25_TESTRUNNER`| path to `TestRunner_public.exe` | +| `FS25_EDITOR` | path to the GIANTS Editor `editor.exe` | ## Project layout @@ -116,6 +202,7 @@ src/fstools/ pack.py # zip packing + exclusion rules validate.py # modDesc.xml checks testrunner.py # GIANTS ModHub TestRunner runner (via Proton) + editor.py # open .i3d in the GIANTS Editor + .i3d file association logtail.py # log.txt follower console.py # coloured output helpers ``` diff --git a/src/fstools/cli.py b/src/fstools/cli.py index 608da89..1b310b1 100644 --- a/src/fstools/cli.py +++ b/src/fstools/cli.py @@ -4,10 +4,13 @@ fs pack MOD pack a mod folder into a .zip (optionally deploy + launch) fs log follow the game log.txt (errors in red, warnings yellow) fs validate MOD sanity-check a mod's modDesc.xml + fs edit FILE.i3d open an .i3d scene in the GIANTS Editor + fs register-editor associate .i3d files with the GIANTS Editor fs paths show the detected FS25 folders """ from __future__ import annotations +import contextlib import shutil import subprocess import tempfile @@ -16,13 +19,15 @@ import typer -from . import config, logtail, pack as packmod, testrunner, validate as validatemod +from . import ( + config, editor as editormod, logtail, pack as packmod, packconfig, + testrunner, validate as validatemod, +) from .console import die, info, ok, warn app = typer.Typer( help="Farming Simulator 25 modding helpers.", no_args_is_help=True, - add_completion=False, ) @@ -36,8 +41,6 @@ def pack( False, "-d", "--deploy", help="also copy the .zip into the FS25 mods folder"), play: bool = typer.Option( False, "-p", "--play", help="deploy, then launch FS25 via Steam (implies --deploy)"), - keep_images: bool = typer.Option( - False, "--keep-images", help="do NOT strip png/psd/tga/pdn/gim"), dry_run: bool = typer.Option( False, "-n", "--dry-run", help="show what would happen without writing anything"), ) -> None: @@ -48,16 +51,28 @@ def pack( if not (mod_dir / "modDesc.xml").is_file(): raise die(f"'{mod_dir.name}' has no modDesc.xml — packing canceled!") + try: + cfg = packconfig.load(mod_dir) + except packconfig.PackConfigError as exc: + raise die(str(exc)) + out_dir = output.expanduser().resolve() if output else mod_dir - zip_path = out_dir / f"{packmod.zip_stem(mod_dir)}.zip" + zip_path = out_dir / f"{packmod.zip_stem(mod_dir, cfg.zip_name)}.zip" info(f"Packing mod {typer.style(mod_dir.name, bold=True)}") info(f" source : {mod_dir}") info(f" output : {zip_path}") - if keep_images: - info(" images : included (--keep-images)") - - entries = packmod.collect_files(mod_dir, zip_path.name, keep_images) + src = f"(from {packconfig.CONFIG_NAME})" + if cfg.zip_name: + info(f" zip : {zip_path.stem} {src}") + if cfg.title: + info(f" title : {cfg.title_display()} {src}") + if cfg.version: + info(f" version: {cfg.version} {src}") + if cfg.author: + info(f" author : {cfg.author} {src}") + + entries = packmod.collect_files(mod_dir, zip_path.name) if dry_run: warn(f"dry-run: would pack {len(entries)} files:") @@ -66,7 +81,8 @@ def pack( else: if zip_path.exists(): warn(f"Old {zip_path.name} will be replaced") - size = packmod.write_zip(mod_dir, zip_path, entries) + size = packmod.write_zip(mod_dir, zip_path, entries, + title=cfg.title, version=cfg.version, author=cfg.author) ok(f"Mod '{mod_dir.name}' packed successfully! " f"({len(entries)} files, {size / 1_048_576:.1f} MiB)") @@ -138,8 +154,6 @@ def validate( def test( mod: Path = typer.Argument( Path("."), help="mod folder or .zip to test (default: current folder)"), - keep_images: bool = typer.Option( - False, "--keep-images", help="when packing a folder, keep png/psd/tga"), verbose: bool = typer.Option( False, "--verbose", help="pass --verbose to the TestRunner"), ) -> None: @@ -166,10 +180,15 @@ def test( if mod.is_dir(): if not (mod / "modDesc.xml").is_file(): raise die(f"'{mod.name}' has no modDesc.xml") + try: + cfg = packconfig.load(mod) + except packconfig.PackConfigError as exc: + raise die(str(exc)) tmp = tempfile.TemporaryDirectory(prefix="fstest-") - zip_path = Path(tmp.name) / f"{packmod.zip_stem(mod)}.zip" - entries = packmod.collect_files(mod, zip_path.name, keep_images) - packmod.write_zip(mod, zip_path, entries) + zip_path = Path(tmp.name) / f"{packmod.zip_stem(mod, cfg.zip_name)}.zip" + entries = packmod.collect_files(mod, zip_path.name) + packmod.write_zip(mod, zip_path, entries, + title=cfg.title, version=cfg.version, author=cfg.author) info(f"Packed {mod.name} -> {zip_path.name} ({len(entries)} files)") output_dir = mod.parent elif mod.suffix.lower() == ".zip" and mod.is_file(): @@ -244,12 +263,60 @@ def testrunner_cmd( ok(f"TestRunner updated -> {testrunner.INSTALL_DIR / testrunner.EXE_NAME}") +@app.command() +def edit( + scene: Path = typer.Argument( + ..., help="the .i3d scene to open in the GIANTS Editor"), + debug: bool = typer.Option( + False, "-v", "--debug", help="run in the foreground and print the editor's " + "console (shows 'could not load file' warnings for missing/mis-cased refs)"), +) -> None: + """Open an .i3d scene in the GIANTS Editor (via the FS25 Proton prefix).""" + path = scene.expanduser().resolve() + if not path.is_file(): + raise die(f"File not found: {scene}") + if path.suffix.lower() != ".i3d": + warn(f"{path.name} is not an .i3d file — opening it anyway") + # In debug mode the editor inherits our stdio; Wine leaves the tty in raw + # mode on exit, so snapshot/restore the terminal around the whole run. + with (editormod.preserve_terminal() if debug else contextlib.nullcontext()): + try: + proc = editormod.launch(path, quiet=not debug) + except FileNotFoundError as exc: + raise die(str(exc)) + ok(f"Opening {path.name} in the GIANTS Editor…") + if debug: + info("Editor console follows (close the editor to return):") + proc.wait() + + +@app.command("register-editor") +def register_editor( + remove: bool = typer.Option( + False, "--remove", help="undo the association instead of installing it"), +) -> None: + """Associate .i3d files with the GIANTS Editor so you can open them from any + file manager (double-click) or with `xdg-open FILE.i3d`.""" + if remove: + editormod.unregister() + ok("Removed the .i3d → GIANTS Editor file association.") + return + if config.editor_exe() is None: + warn("GIANTS Editor not detected in the FS25 prefix — installing the " + "association anyway. Set FS25_EDITOR or install the editor in the " + "prefix so `fs edit` can find editor.exe.") + editormod.register() + ok("Registered .i3d files to open with the GIANTS Editor (via `fs edit`).") + info("Double-click any .i3d in your file manager, or run: xdg-open FILE.i3d") + + @app.command() def paths() -> None: """Show the detected FS25 folders (useful for debugging config).""" info(f"app id : {config.APPID}") info(f"game data : {config.game_data_dir() or '(not found)'}") info(f"game install: {config.game_install_dir() or '(not found)'}") + info(f"editor : {config.editor_exe() or '(not found)'}") info(f"mods dir : {config.mods_dir() or '(not found)'}") info(f"log.txt : {config.log_path() or '(not found)'}") info(f"testrunner : {testrunner.installed_exe() or '(not installed)'}") diff --git a/src/fstools/config.py b/src/fstools/config.py index ad3370a..7f58ee1 100644 --- a/src/fstools/config.py +++ b/src/fstools/config.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import re from pathlib import Path APPID = os.environ.get("FS25_APPID", "2300320") @@ -9,9 +10,12 @@ # Steam library roots to probe, in priority order. _STEAM_ROOTS = (".local/share/Steam", ".steam/steam", ".steam/root") +# Path of the Proton prefix relative to a Steam root. +_PREFIX_REL = f"steamapps/compatdata/{APPID}/pfx" + # Path of the game-data dir relative to a Steam root (inside the Proton prefix). _GAMEDATA_REL = ( - f"steamapps/compatdata/{APPID}/pfx/drive_c/users/steamuser/" + f"{_PREFIX_REL}/drive_c/users/steamuser/" "Documents/My Games/FarmingSimulator2025" ) @@ -38,6 +42,51 @@ def mods_dir() -> Path | None: return mods if mods.is_dir() else None +def prefix_dir() -> Path | None: + """The FS25 Proton prefix ('.../pfx', holding drive_c).""" + home = Path.home() + for root in _STEAM_ROOTS: + candidate = home / root / _PREFIX_REL + if candidate.is_dir(): + return candidate + return None + + +def _version_key(p: Path) -> tuple[int, ...]: + """Version tuple parsed from an editor folder name, for picking the newest. + + 'GIANTS_Editor_10.0.11' -> (10, 0, 11); () if no version found. + """ + m = re.search(r"(\d+(?:[._]\d+)+)", p.parent.name) + return tuple(int(x) for x in re.split(r"[._]", m.group(1))) if m else () + + +def editor_exe() -> Path | None: + """Locate the GIANTS Editor's editor.exe inside the FS25 Proton prefix. + + Checks FS25_EDITOR first, else the default install location under + Program Files (e.g. 'GIANTS Software/GIANTS_Editor_10.0.11/editor.exe', or + an older 'GIANTS Editor 64bit 10.x/editor.exe'), newest version wins. + """ + override = os.environ.get("FS25_EDITOR") + if override: + p = Path(override) + return p if p.is_file() else None + prefix = prefix_dir() + if prefix is None: + return None + drive_c = prefix / "drive_c" + matches: list[Path] = [] + for pf in ("Program Files", "Program Files (x86)"): + base = drive_c / pf + # editor.exe sits either directly in an editor folder or one vendor + # folder deeper ('GIANTS Software/GIANTS_Editor_.../editor.exe'). + for exe in (*base.glob("*/editor.exe"), *base.glob("*/*/editor.exe")): + if "editor" in exe.parent.name.lower(): + matches.append(exe) + return max(matches, default=None, key=_version_key) + + def game_install_dir() -> Path | None: """The 'Farming Simulator 25' install folder (holds FarmingSimulator2025.exe).""" override = os.environ.get("FS25_GAME_DIR") diff --git a/src/fstools/editor.py b/src/fstools/editor.py new file mode 100644 index 0000000..1252294 --- /dev/null +++ b/src/fstools/editor.py @@ -0,0 +1,148 @@ +"""Open .i3d scenes in the GIANTS Editor and register that as the Linux +file-association for .i3d. + +The GIANTS Editor is a Windows program living inside the FS25 Proton prefix, so +we launch it via `protontricks-launch` (same mechanism as the TestRunner) with +the i3d file mapped to its `Z:\\` prefix path. The registration side drops a +freedesktop `.desktop` handler plus a `application/x-i3d` MIME type so any file +manager (or `xdg-open`) opens `*.i3d` with `fs edit`. +""" +from __future__ import annotations + +import contextlib +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Iterator + +try: + import termios +except ImportError: # non-POSIX; the tool is Linux-only, but stay defensive. + termios = None # type: ignore[assignment] + +from . import config + +MIME_TYPE = "application/x-i3d" +DESKTOP_ID = "fs-i3d-editor.desktop" + +_MIME_HOME = Path.home() / ".local/share/mime" +_APPS_DIR = Path.home() / ".local/share/applications" +MIME_PKG_FILE = _MIME_HOME / "packages" / "fs-i3d.xml" +DESKTOP_FILE = _APPS_DIR / DESKTOP_ID + +_MIME_XML = """<?xml version="1.0" encoding="UTF-8"?> +<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info"> + <mime-type type="application/x-i3d"> + <comment>GIANTS i3d scene</comment> + <glob pattern="*.i3d"/> + </mime-type> +</mime-info> +""" + + +def launch(i3d: Path, quiet: bool = True) -> subprocess.Popen: + """Open an .i3d in the GIANTS Editor. Raises FileNotFoundError if the editor + or protontricks-launch is missing. + + quiet=True detaches and discards the editor's console output (normal use). + quiet=False inherits the terminal's stdio so the editor's warnings — e.g. + 'Could not load file ...' for a missing/mis-cased reference — are visible; + the caller should then wait() for it. + """ + exe = config.editor_exe() + if exe is None: + raise FileNotFoundError( + "GIANTS Editor not found in the FS25 prefix. Install it in the prefix, " + "or set FS25_EDITOR to editor.exe.") + if shutil.which("protontricks-launch") is None: + raise FileNotFoundError("protontricks-launch not found (install protontricks)") + + env = {**os.environ, "PROTONTRICKS_NO_TERM": "1"} + cmd = ["protontricks-launch", "--appid", config.APPID, str(exe), + config.to_wine_path(i3d)] + if not quiet: + # Inherit stdio so the editor's console (missing-file warnings) is shown. + return subprocess.Popen(cmd, env=env) + # Redirect stdin too: otherwise the editor inherits our terminal on fd 0 and + # Wine flips it into raw mode (broken arrow keys / no echo) while it runs in + # the background — and, being detached, there is no exit we can wait for to + # restore it. Detaching stdin prevents the corruption entirely. + return subprocess.Popen( + cmd, start_new_session=True, env=env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +@contextlib.contextmanager +def preserve_terminal() -> Iterator[None]: + """Snapshot the controlling terminal's mode and restore it on exit. + + The GIANTS Editor runs under Wine, which switches the tty into raw mode and + does not reset it when it quits. Without this, running `fs edit -v` (which + inherits our stdio and waits) leaves the shell unusable afterwards — arrow + keys emit raw escape codes, input is not echoed. We save termios before the + editor starts and restore it once it is gone. + """ + saved = None + fd = -1 + if termios is not None and sys.stdin.isatty(): + try: + fd = sys.stdin.fileno() + saved = termios.tcgetattr(fd) + except (termios.error, OSError, ValueError): + saved = None + try: + yield + finally: + if saved is not None: + try: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + except (termios.error, OSError, ValueError): + pass + + +def _fs_bin() -> str: + """Absolute path to the installed `fs` executable (for the .desktop Exec).""" + return shutil.which("fs") or str(Path(sys.argv[0]).resolve()) + + +def _desktop_entry() -> str: + return ( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=GIANTS Editor (fsTools)\n" + "Comment=Open i3d scenes in the GIANTS Editor via the FS25 Proton prefix\n" + f"Exec={_fs_bin()} edit %f\n" + "Terminal=false\n" + "NoDisplay=true\n" + f"MimeType={MIME_TYPE};\n" + "Categories=Graphics;3DGraphics;\n" + ) + + +def _refresh_databases() -> None: + if shutil.which("update-mime-database"): + subprocess.run(["update-mime-database", str(_MIME_HOME)], check=False) + if shutil.which("update-desktop-database"): + subprocess.run(["update-desktop-database", str(_APPS_DIR)], check=False) + + +def register() -> None: + """Install the .i3d MIME type + desktop handler and make it the default.""" + MIME_PKG_FILE.parent.mkdir(parents=True, exist_ok=True) + _APPS_DIR.mkdir(parents=True, exist_ok=True) + MIME_PKG_FILE.write_text(_MIME_XML) + DESKTOP_FILE.write_text(_desktop_entry()) + _refresh_databases() + if shutil.which("xdg-mime"): + subprocess.run(["xdg-mime", "default", DESKTOP_ID, MIME_TYPE], check=False) + + +def unregister() -> None: + """Remove the desktop handler and MIME type installed by register().""" + MIME_PKG_FILE.unlink(missing_ok=True) + DESKTOP_FILE.unlink(missing_ok=True) + _refresh_databases() diff --git a/src/fstools/logtail.py b/src/fstools/logtail.py index 085056c..3f64588 100644 --- a/src/fstools/logtail.py +++ b/src/fstools/logtail.py @@ -1,7 +1,7 @@ """Following the FS25 log.txt with error/warning highlighting.""" from __future__ import annotations -import sys +import os import time from pathlib import Path @@ -27,7 +27,8 @@ def _emit(line: str, errors_only: bool) -> None: def tail(log: Path, errors_only: bool, lines: int, follow: bool) -> None: - with log.open("r", errors="replace") as fh: + fh = log.open("r", errors="replace") + try: existing = fh.readlines() head = existing if not follow else existing[-lines:] for line in head: @@ -36,16 +37,27 @@ def tail(log: Path, errors_only: bool, lines: int, follow: bool) -> None: if not follow: return - try: - while True: - where = fh.tell() - line = fh.readline() - if line: - _emit(line, errors_only) - continue - if log.stat().st_size < where: # truncated on relaunch - fh.seek(0) - else: - time.sleep(0.4) - except KeyboardInterrupt: - typer.echo("", err=True) + inode = os.fstat(fh.fileno()).st_ino + while True: + line = fh.readline() + if line: + _emit(line, errors_only) + continue + + # EOF — check whether the game rotated (relaunch: new file) or + # truncated (in-place) log.txt, and if so start reading it afresh. + try: + st = log.stat() + except FileNotFoundError: + time.sleep(0.4) # brief gap while the game recreates it + continue + if st.st_ino != inode or st.st_size < fh.tell(): + fh.close() + fh = log.open("r", errors="replace") + inode = os.fstat(fh.fileno()).st_ino + continue # re-emit the fresh log from its start + time.sleep(0.4) + except KeyboardInterrupt: + typer.echo("", err=True) + finally: + fh.close() diff --git a/src/fstools/pack.py b/src/fstools/pack.py index 18bed43..373a678 100644 --- a/src/fstools/pack.py +++ b/src/fstools/pack.py @@ -6,21 +6,26 @@ import zipfile from pathlib import Path +from .packconfig import CONFIG_NAME + MOD_PREFIX = "FS25_" +# gitignore-style file listing extra paths to leave out of the zip (per mod). +IGNORE_NAME = ".fsignore" + # File globs matched against each file's name (case-insensitive). EXCLUDE_FILES = [ "*.cmd", "*.sh", "*.py", "*.zip", "*.yml", "*.yaml", "*.blend", "*.obj", "*.fbx", "*.mel", "*.mb", "*.ma", + "*.psd", "*.pdn", # image editor sources — never part of a mod zip "*.txt", "*.md", ".gitattributes", ".gitignore", ".editorconfig", ".DS_Store", "Thumbs.db", + CONFIG_NAME, # our own fstools.toml — never ship it + IGNORE_NAME, # nor the .fsignore ] -# Image sources — excluded by default, kept with keep_images=True. -EXCLUDE_IMAGES = ["*.png", "*.psd", "*.tga", "*.pdn", "*.gim"] - # Whole directories to skip (matched against any path component). EXCLUDE_DIRS = { ".git", ".svn", ".idea", ".vscode", ".mayaSwatches", @@ -28,50 +33,67 @@ } -def zip_stem(mod_dir: Path) -> str: - """Mod folder name normalized to the required 'FS25_' + lowercase-char form - (e.g. 'MyMod' or 'FS25_MyMod' -> 'FS25_myMod').""" - name = mod_dir.name +def zip_stem(mod_dir: Path, override: str | None = None) -> str: + """The zip name (without .zip), normalized to the required 'FS25_' prefix. + + Uses ``override`` (from fspack.toml) when given, else the mod folder name. + The 'FS25_' prefix is added if missing (any existing prefix, any case, is + normalized to 'FS25_'); the rest of the name is left as-is. + """ + name = override if override else mod_dir.name if name[: len(MOD_PREFIX)].upper() == MOD_PREFIX: # strip existing prefix, any case name = name[len(MOD_PREFIX):] - if name: # force the first char after the prefix to lower case - name = name[0].lower() + name[1:] return f"{MOD_PREFIX}{name}" -def icon_stems(mod_dir: Path) -> set[str]: - """Lowercased stem(s) of iconFilename in modDesc.xml, so we never strip the - mod's store icon even when it ships as a .png (FS converts png->dds).""" - desc = mod_dir / "modDesc.xml" - try: - root = ET.parse(desc).getroot() - except (ET.ParseError, OSError): - return set() - icon = (root.findtext("iconFilename") or "").strip() - return {Path(icon).stem.lower()} if icon else set() - - -def is_excluded(rel: Path, keep_images: bool, keep_stems: set[str]) -> bool: +def is_excluded(rel: Path) -> bool: if any(part in EXCLUDE_DIRS for part in rel.parts): return True name = rel.name.lower() - if any(fnmatch.fnmatch(name, p.lower()) for p in EXCLUDE_FILES): - return True - if not keep_images and any(fnmatch.fnmatch(name, p.lower()) for p in EXCLUDE_IMAGES): - # keep it anyway if it's the mod's icon - return rel.stem.lower() not in keep_stems + return any(fnmatch.fnmatch(name, p.lower()) for p in EXCLUDE_FILES) + + +def load_ignore(mod_dir: Path) -> list[str]: + """Read .fsignore patterns from the mod folder (blank lines and # comments + skipped). Absent file -> no extra patterns.""" + path = mod_dir / IGNORE_NAME + if not path.is_file(): + return [] + patterns: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#"): + patterns.append(line) + return patterns + + +def is_ignored(rel: Path, patterns: list[str]) -> bool: + """gitignore-flavoured match against a mod-relative path. A pattern with a + '/' is matched against the whole path (so it can target a subfolder); one + without is matched against every path component (so 'wip/' or '*.psd' hits + at any depth). '*' spans path separators.""" + if not patterns: + return False + posix = rel.as_posix() + for pat in patterns: + p = pat.rstrip("/") + if "/" in p: + if fnmatch.fnmatch(posix, p) or fnmatch.fnmatch(posix, f"{p}/*"): + return True + elif any(fnmatch.fnmatch(part, p) for part in rel.parts): + return True return False -def collect_files(mod_dir: Path, zip_name: str, keep_images: bool) -> list[Path]: +def collect_files(mod_dir: Path, zip_name: str) -> list[Path]: """Return the mod-relative paths that would be packed, in stable order.""" - keep_stems = icon_stems(mod_dir) + ignore = load_ignore(mod_dir) entries: list[Path] = [] for path in sorted(mod_dir.rglob("*")): if not path.is_file(): continue rel = path.relative_to(mod_dir) - if is_excluded(rel, keep_images, keep_stems): + if is_excluded(rel) or is_ignored(rel, ignore): continue if rel.name == zip_name: # a previous build sitting in the folder continue @@ -79,12 +101,66 @@ def collect_files(mod_dir: Path, zip_name: str, keep_images: bool) -> list[Path] return entries -def write_zip(mod_dir: Path, zip_path: Path, entries: list[Path]) -> int: - """Write the zip (contents at root so modDesc.xml is top-level). Returns byte size.""" +def _set_child_text(root: ET.Element, tag: str, text: str) -> None: + """Set <tag>text</tag> under root, creating the element if it's missing.""" + el = root.find(tag) + if el is None: + el = ET.SubElement(root, tag) + el.text = text + + +def _apply_title(root: ET.Element, title: str | dict[str, str]) -> None: + el = root.find("title") + if el is None: + el = ET.SubElement(root, "title") + if isinstance(title, str): # one string -> every existing language entry + langs = list(el) + if langs: + for lang in langs: # <en>…</en>, <de>…</de>, … + lang.text = title + else: + el.text = title + else: # per-language table -> set/create each listed language + for lang, text in title.items(): + child = el.find(lang) + if child is None: + child = ET.SubElement(el, lang) + child.text = text + + +def rewrite_moddesc(mod_dir: Path, *, title: str | dict[str, str] | None = None, + version: str | None = None, author: str | None = None) -> bytes: + """modDesc.xml bytes with the given fields overridden. + + Lets fstools.toml drive the mod's metadata without editing the file on disk. + """ + root = ET.parse(mod_dir / "modDesc.xml").getroot() + if version is not None: + _set_child_text(root, "version", version) + if author is not None: + _set_child_text(root, "author", author) + if title is not None: + _apply_title(root, title) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def write_zip(mod_dir: Path, zip_path: Path, entries: list[Path], *, + title: str | dict[str, str] | None = None, + version: str | None = None, author: str | None = None) -> int: + """Write the zip (contents at root so modDesc.xml is top-level). Returns byte size. + + When any of title/version/author is given, modDesc.xml is rewritten in the + zip with those values (the file on disk is untouched).""" if zip_path.exists(): zip_path.unlink() zip_path.parent.mkdir(parents=True, exist_ok=True) + moddesc_bytes = None + if title is not None or version is not None or author is not None: + moddesc_bytes = rewrite_moddesc(mod_dir, title=title, version=version, author=author) with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf: for rel in entries: - zf.write(mod_dir / rel, arcname=str(rel)) + if moddesc_bytes is not None and rel.as_posix() == "modDesc.xml": + zf.writestr("modDesc.xml", moddesc_bytes) + else: + zf.write(mod_dir / rel, arcname=str(rel)) return zip_path.stat().st_size diff --git a/src/fstools/packconfig.py b/src/fstools/packconfig.py new file mode 100644 index 0000000..843703d --- /dev/null +++ b/src/fstools/packconfig.py @@ -0,0 +1,104 @@ +"""Optional per-mod fsTools config (``fstools.toml``). + +Currently holds a ``[mod]`` section to drive the packed mod's metadata without +editing modDesc.xml or renaming the folder. Drop an ``fstools.toml`` next to +modDesc.xml to build a differently-named variant (e.g. a dev build that coexists +with your stable mod in the same savegame): + + [mod] + zip_name = "FS25_MyMod_dev" # packed .zip stem (FS25_ prefix added if missing) + version = "1.0.0.0-dev" # overrides <version> + author = "Me" # overrides <author> + title = "My Mod (dev)" # overrides every <title> language entry + + # …or set titles per language instead of one string: + # [mod.title] + # en = "My Mod (dev)" + # de = "Mein Mod (dev)" + +Every key is optional; overrides are written into the packed zip's modDesc.xml +(the file on disk is untouched). The file is never packed into the zip. Other +tools may add their own top-level sections here in the future. +""" +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path + +CONFIG_NAME = "fstools.toml" + + +class PackConfigError(Exception): + """Raised when fstools.toml exists but cannot be parsed or is malformed.""" + + +@dataclass +class PackConfig: + zip_name: str | None = None # override the packed zip stem (no .zip) + title: str | dict[str, str] | None = None # str: all langs; dict: per-language + version: str | None = None # override <version> + author: str | None = None # override <author> + + @property + def overrides_moddesc(self) -> bool: + """True if any field rewrites modDesc.xml (title/version/author).""" + return any(v is not None for v in (self.title, self.version, self.author)) + + def title_display(self) -> str: + """Human-readable title for log lines (handles the per-language table).""" + if isinstance(self.title, dict): + return ", ".join(f"{lang}={text}" for lang, text in self.title.items()) + return self.title or "" + + +def config_path(mod_dir: Path) -> Path: + return mod_dir / CONFIG_NAME + + +def _clean_str(value: object, key: str) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise PackConfigError(f"'{key}' must be a string in {CONFIG_NAME}") + value = value.strip() + return value or None + + +def _load_title(value: object) -> str | dict[str, str] | None: + if value is None or isinstance(value, str): + return _clean_str(value, "title") + if isinstance(value, dict): + out: dict[str, str] = {} + for lang, text in value.items(): + clean = _clean_str(text, f"title.{lang}") + if clean: + out[lang] = clean + return out or None + raise PackConfigError(f"'title' must be a string or a table in {CONFIG_NAME}") + + +def load(mod_dir: Path) -> PackConfig: + """Read the [mod] section of fstools.toml. Missing file -> empty config.""" + path = config_path(mod_dir) + if not path.is_file(): + return PackConfig() + try: + with path.open("rb") as fh: + data = tomllib.load(fh) + except tomllib.TOMLDecodeError as exc: + raise PackConfigError(f"{CONFIG_NAME} is not valid TOML: {exc}") from exc + + mod = data.get("mod", {}) + if not isinstance(mod, dict): + raise PackConfigError(f"'[mod]' must be a table in {CONFIG_NAME}") + + zip_name = _clean_str(mod.get("zip_name"), "zip_name") + if zip_name and zip_name.lower().endswith(".zip"): # tolerate a stray .zip + zip_name = zip_name[:-4] or None + return PackConfig( + zip_name=zip_name, + title=_load_title(mod.get("title")), + version=_clean_str(mod.get("version"), "version"), + author=_clean_str(mod.get("author"), "author"), + )