feat(release): add public release ZIP creation functionality
- Introduced a new button to create a player-ready ZIP of the complete game, excluding translator workspaces, version control files, documentation, backups, saves, and playtest plugins. - Implemented a background worker to handle ZIP creation without blocking the GUI. - Added user feedback for the completion and size of the generated ZIP file.
This commit is contained in:
parent
02671c5c24
commit
57379076bd
4 changed files with 679 additions and 1 deletions
|
|
@ -4283,6 +4283,19 @@ class WolfWorkflowTab(QWidget):
|
|||
"archive's encryption where possible (--like the backed-up original)."
|
||||
))
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Public release"))
|
||||
layout.addWidget(self._desc(
|
||||
"Create a player-ready ZIP of the complete game. Translator workspaces, version "
|
||||
"control, documentation, backups, saves, and other tool artifacts are omitted; "
|
||||
"GameUpdate files remain available to players. The game folder is not modified."
|
||||
))
|
||||
self._release_zip_btn = self._register(
|
||||
_make_btn("📦 Create Public Release ZIP", "#007acc")
|
||||
)
|
||||
self._release_zip_btn.clicked.connect(self._create_public_release)
|
||||
layout.addWidget(self._release_zip_btn)
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Update existing saves (optional)"))
|
||||
layout.addWidget(self._desc(
|
||||
|
|
@ -4356,6 +4369,43 @@ class WolfWorkflowTab(QWidget):
|
|||
|
||||
self._run_task(task)
|
||||
|
||||
def _create_public_release(self):
|
||||
if not self._require_root():
|
||||
return
|
||||
|
||||
from util.release_package import default_release_zip_path
|
||||
|
||||
suggested = str(default_release_zip_path(self._game_root))
|
||||
output, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Create Public Release ZIP",
|
||||
suggested,
|
||||
"ZIP archives (*.zip)",
|
||||
)
|
||||
if not output:
|
||||
return
|
||||
|
||||
game_root = self._game_root
|
||||
|
||||
def task(log, progress=None):
|
||||
from util.release_package import create_release_zip
|
||||
|
||||
log(f"Creating public release ZIP from {game_root} …")
|
||||
result = create_release_zip(game_root, output, progress=progress)
|
||||
size_mb = result.output_path.stat().st_size / (1024 * 1024)
|
||||
log(
|
||||
f" Added {result.files_added} file(s), omitted "
|
||||
f"{result.excluded_entries} tool/private item(s)."
|
||||
)
|
||||
if result.sanitized_plugin_lists:
|
||||
log(" Removed playtest plugins from the archived plugins.js.")
|
||||
return True, (
|
||||
f"Created {result.output_path.name} ({size_mb:.1f} MB) at "
|
||||
f"{result.output_path}."
|
||||
)
|
||||
|
||||
self._run_task(task)
|
||||
|
||||
def _browse_saves(self):
|
||||
start = self.save_edit.text() or (str(Path(self._game_root) / "Save") if self._game_root else str(Path.home()))
|
||||
folder = QFileDialog.getExistingDirectory(self, "Select Save Folder", start)
|
||||
|
|
|
|||
|
|
@ -371,6 +371,34 @@ class _FileCopyWorker(QThread):
|
|||
self.done.emit(copied, errors)
|
||||
|
||||
|
||||
class _ReleaseZipWorker(QThread):
|
||||
"""Build a sanitized public-release ZIP without blocking the GUI."""
|
||||
|
||||
done = pyqtSignal(object)
|
||||
error = pyqtSignal(str)
|
||||
progress = pyqtSignal(int, int, str)
|
||||
|
||||
def __init__(self, game_root: str, output_path: str):
|
||||
super().__init__()
|
||||
self.game_root = game_root
|
||||
self.output_path = output_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from util.release_package import create_release_zip
|
||||
|
||||
result = create_release_zip(
|
||||
self.game_root,
|
||||
self.output_path,
|
||||
progress=lambda current, total, label: self.progress.emit(
|
||||
current, total, label
|
||||
),
|
||||
)
|
||||
self.done.emit(result)
|
||||
except Exception as exc:
|
||||
self.error.emit(str(exc))
|
||||
|
||||
|
||||
class _JsFormatWorker(QThread):
|
||||
"""Format a JavaScript file using jsbeautifier (pure Python, no Node required)."""
|
||||
done = pyqtSignal(bool, str)
|
||||
|
|
@ -495,7 +523,10 @@ _STEP_HELP: dict[int, str] = {
|
|||
"<b>Export</b> - copy finished translations from <code>translated/</code> back "
|
||||
"into the game's data folder:<br>"
|
||||
"• <b>Export Active Files</b> - only names currently in <code>files/</code><br>"
|
||||
"• <b>Export ALL</b> - everything under <code>translated/</code>"
|
||||
"• <b>Export ALL</b> - everything under <code>translated/</code><br><br>"
|
||||
"<b>Create Public Release ZIP</b> packages the complete game beside its folder while "
|
||||
"omitting translator workspaces, VCS metadata, documentation, backups, saves, and "
|
||||
"playtest plugins. GameUpdate files are kept."
|
||||
),
|
||||
6: (
|
||||
"<b>Step 6 - Images (MV/MZ)</b><br><br>"
|
||||
|
|
@ -746,6 +777,7 @@ class WorkflowTab(QWidget):
|
|||
self._current_step_index: int = 0
|
||||
self._last_import_signature: tuple[str, ...] | None = None
|
||||
self._pending_import_signature: tuple[str, ...] | None = None
|
||||
self._release_zip_btn: QPushButton | None = None
|
||||
|
||||
self._init_ui()
|
||||
|
||||
|
|
@ -2191,6 +2223,16 @@ class WorkflowTab(QWidget):
|
|||
export_all_btn.clicked.connect(self._export_to_game)
|
||||
|
||||
inner.addLayout(_labeled_row(export_lbl, export_active_btn, export_all_btn))
|
||||
|
||||
release_lbl = QLabel("Release")
|
||||
self._release_zip_btn = _make_btn("📦 Create Public Release ZIP", "#007acc")
|
||||
self._release_zip_btn.setToolTip(
|
||||
"Archive the detected game folder for players. Excludes DazedTL workspaces, "
|
||||
"version-control files, documentation, backups, saves, and playtest plugins; "
|
||||
"keeps GameUpdate files. The source game folder is not changed."
|
||||
)
|
||||
self._release_zip_btn.clicked.connect(self._create_public_release)
|
||||
inner.addLayout(_labeled_row(release_lbl, self._release_zip_btn))
|
||||
layout.addWidget(box, 0, Qt.AlignLeft)
|
||||
|
||||
# ── Step 6: Editable images ─────────────────────────────────────────────
|
||||
|
|
@ -4115,6 +4157,69 @@ class WorkflowTab(QWidget):
|
|||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _create_public_release(self):
|
||||
game_root = self.folder_edit.text().strip()
|
||||
if not game_root or not Path(game_root).is_dir():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"No game folder",
|
||||
"Select and detect a game folder in Step 0 first.",
|
||||
)
|
||||
return
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
QMessageBox.information(self, "Busy", "A task is already running. Please wait.")
|
||||
return
|
||||
|
||||
from util.release_package import default_release_zip_path
|
||||
|
||||
suggested = str(default_release_zip_path(game_root))
|
||||
output, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Create Public Release ZIP",
|
||||
suggested,
|
||||
"ZIP archives (*.zip)",
|
||||
)
|
||||
if not output:
|
||||
return
|
||||
|
||||
self._log(f"Creating public release ZIP from {game_root} …")
|
||||
worker = _ReleaseZipWorker(game_root, output)
|
||||
self._worker = worker
|
||||
if self._release_zip_btn is not None:
|
||||
self._release_zip_btn.setEnabled(False)
|
||||
|
||||
def finished():
|
||||
if self._worker is worker:
|
||||
self._worker = None
|
||||
if self._release_zip_btn is not None:
|
||||
self._release_zip_btn.setEnabled(True)
|
||||
|
||||
def failed(message: str):
|
||||
self._log(f"❌ Public release ZIP: {message}")
|
||||
QMessageBox.warning(self, "Release ZIP failed", message)
|
||||
|
||||
worker.done.connect(self._on_public_release_done)
|
||||
worker.error.connect(failed)
|
||||
worker.finished.connect(finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
worker.start()
|
||||
|
||||
def _on_public_release_done(self, result):
|
||||
size_mb = result.output_path.stat().st_size / (1024 * 1024)
|
||||
message = (
|
||||
f"Created {result.output_path.name} ({size_mb:.1f} MB) with "
|
||||
f"{result.files_added} file(s); omitted {result.excluded_entries} "
|
||||
"tool/private item(s)."
|
||||
)
|
||||
if result.sanitized_plugin_lists:
|
||||
message += " Removed playtest plugins from the archived plugins.js."
|
||||
self._log(f"✅ {message}")
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Public Release ZIP created",
|
||||
f"{message}\n\nSaved to:\n{result.output_path}",
|
||||
)
|
||||
|
||||
def _resolve_export_path(self) -> str | None:
|
||||
"""Return the game data path, prompting if not yet set."""
|
||||
game_data = self._data_path
|
||||
|
|
|
|||
179
tests/test_release_package.py
Normal file
179
tests/test_release_package.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for player-ready public release archives and workflow buttons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PyQt5.QtCore import QSettings
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from util.release_package import ReleasePackageError, create_release_zip
|
||||
|
||||
|
||||
class ReleasePackageTests(unittest.TestCase):
|
||||
def _write(self, root: Path, relative: str, content: bytes = b"content") -> Path:
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
return path
|
||||
|
||||
def test_release_keeps_game_and_updater_but_omits_tool_artifacts(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
base = Path(raw)
|
||||
game = base / "Translated Game"
|
||||
game.mkdir()
|
||||
|
||||
self._write(game, "Game.exe", b"game")
|
||||
self._write(game, "Data.wolf", b"translated archive")
|
||||
self._write(game, "Data.wolf.bak", b"original backup")
|
||||
self._write(game, "Data/manual.txt", b"runtime text")
|
||||
self._write(game, "Save01.sav", b"translator save")
|
||||
self._write(game, "Save/Slot1.rpgsave", b"translator save")
|
||||
self._write(game, ".git/config")
|
||||
self._write(game, ".dazedtl/images/menu.png")
|
||||
self._write(game, "wolf_json/manifest.json")
|
||||
self._write(game, "ace_json/Actors.json")
|
||||
self._write(game, "scripts/release.py")
|
||||
self._write(game, "docs/translation-notes.md")
|
||||
self._write(game, "skills/game.md")
|
||||
self._write(game, "vocab.txt")
|
||||
self._write(game, "README.md")
|
||||
self._write(game, ".gitignore")
|
||||
self._write(game, ".env", b"key=secret")
|
||||
|
||||
self._write(game, "GameUpdate.bat", b"launcher")
|
||||
self._write(game, "GameUpdate_linux.sh", b"launcher")
|
||||
self._write(game, "UberWolfCli.exe", b"wolf updater")
|
||||
self._write(game, "UberWolfCli.LICENSE.txt", b"license")
|
||||
self._write(game, "gameupdate/patch.ps1", b"patch")
|
||||
self._write(game, "gameupdate/patch-config.txt", b"repo=game")
|
||||
self._write(game, "gameupdate/previous_patch_sha.txt", b"private state")
|
||||
|
||||
plugins_js = b"""var $plugins =
|
||||
[
|
||||
{"name":"CorePlugin","status":true,"parameters":{"label":"keep"}},
|
||||
{"name":"TLInspector","status":true,"parameters":{}},
|
||||
{"name":"Forge_MZ","status":true,"parameters":{}}
|
||||
];
|
||||
"""
|
||||
source_plugins = self._write(game, "js/plugins.js", plugins_js)
|
||||
self._write(game, "js/plugins/CorePlugin.js", b"core")
|
||||
self._write(game, "js/plugins/TLInspector.js", b"inspector")
|
||||
self._write(game, "js/plugins/Forge_MZ.js", b"forge")
|
||||
|
||||
output = base / "release.zip"
|
||||
progress = []
|
||||
result = create_release_zip(
|
||||
game,
|
||||
output,
|
||||
progress=lambda current, total, label: progress.append(
|
||||
(current, total, label)
|
||||
),
|
||||
)
|
||||
|
||||
prefix = "Translated Game/"
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
names = set(archive.namelist())
|
||||
archived_plugins = archive.read(prefix + "js/plugins.js").decode("utf-8")
|
||||
|
||||
for relative in (
|
||||
"Game.exe",
|
||||
"Data.wolf",
|
||||
"Data/manual.txt",
|
||||
"GameUpdate.bat",
|
||||
"GameUpdate_linux.sh",
|
||||
"UberWolfCli.exe",
|
||||
"UberWolfCli.LICENSE.txt",
|
||||
"gameupdate/patch.ps1",
|
||||
"gameupdate/patch-config.txt",
|
||||
"js/plugins/CorePlugin.js",
|
||||
):
|
||||
self.assertIn(prefix + relative, names)
|
||||
|
||||
for relative in (
|
||||
"Data.wolf.bak",
|
||||
"Save01.sav",
|
||||
"Save/Slot1.rpgsave",
|
||||
".git/config",
|
||||
".dazedtl/images/menu.png",
|
||||
"wolf_json/manifest.json",
|
||||
"ace_json/Actors.json",
|
||||
"scripts/release.py",
|
||||
"docs/translation-notes.md",
|
||||
"skills/game.md",
|
||||
"vocab.txt",
|
||||
"README.md",
|
||||
".gitignore",
|
||||
".env",
|
||||
"gameupdate/previous_patch_sha.txt",
|
||||
"js/plugins/TLInspector.js",
|
||||
"js/plugins/Forge_MZ.js",
|
||||
):
|
||||
self.assertNotIn(prefix + relative, names)
|
||||
|
||||
self.assertIn('"name":"CorePlugin"', archived_plugins)
|
||||
self.assertNotIn("TLInspector", archived_plugins)
|
||||
self.assertNotIn("Forge_MZ", archived_plugins)
|
||||
self.assertEqual(source_plugins.read_bytes(), plugins_js)
|
||||
self.assertEqual(result.output_path, output.resolve())
|
||||
self.assertEqual(result.sanitized_plugin_lists, 1)
|
||||
self.assertEqual(progress[-1][:2], (result.files_added, result.files_added))
|
||||
|
||||
def test_release_path_must_be_outside_game_folder(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
game = Path(raw) / "Game"
|
||||
game.mkdir()
|
||||
self._write(game, "Game.exe")
|
||||
output = game / "release.zip"
|
||||
|
||||
with self.assertRaisesRegex(ReleasePackageError, "outside the game folder"):
|
||||
create_release_zip(game, output)
|
||||
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_zip_suffix_is_added(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
base = Path(raw)
|
||||
game = base / "Game"
|
||||
game.mkdir()
|
||||
self._write(game, "Game.exe")
|
||||
|
||||
result = create_release_zip(game, base / "release")
|
||||
|
||||
self.assertEqual(result.output_path.name, "release.zip")
|
||||
self.assertTrue(result.output_path.is_file())
|
||||
|
||||
|
||||
class ReleaseWorkflowButtonTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_both_workflows_expose_public_release_button(self):
|
||||
from gui.workflow_tab import WorkflowTab
|
||||
from gui.wolf_workflow_tab import WolfWorkflowTab
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
settings = QSettings(str(Path(raw) / "settings.ini"), QSettings.IniFormat)
|
||||
with (
|
||||
patch("gui.workflow_tab.QSettings", return_value=settings),
|
||||
patch("gui.wolf_workflow_tab.QSettings", return_value=settings),
|
||||
):
|
||||
rpg = WorkflowTab()
|
||||
wolf = WolfWorkflowTab()
|
||||
try:
|
||||
self.assertEqual(rpg._release_zip_btn.text(), "📦 Create Public Release ZIP")
|
||||
self.assertEqual(wolf._release_zip_btn.text(), "📦 Create Public Release ZIP")
|
||||
self.assertIn("GameUpdate", rpg._release_zip_btn.toolTip())
|
||||
finally:
|
||||
rpg.close()
|
||||
wolf.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
344
util/release_package.py
Normal file
344
util/release_package.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""Build public game archives without translator and playtest artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
ProgressCallback = Callable[[int, int, str], None]
|
||||
|
||||
|
||||
class ReleasePackageError(RuntimeError):
|
||||
"""Raised when a safe public-release archive cannot be produced."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleasePackageResult:
|
||||
output_path: Path
|
||||
files_added: int
|
||||
bytes_added: int
|
||||
excluded_entries: int
|
||||
sanitized_plugin_lists: int
|
||||
|
||||
|
||||
# These are never game payloads and should be ignored wherever they occur.
|
||||
_EXCLUDED_DIR_NAMES = frozenset(
|
||||
{
|
||||
".dazedtl",
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
)
|
||||
|
||||
# DazedTL creates these at the game root while translating or playtesting.
|
||||
_EXCLUDED_ROOT_DIR_NAMES = frozenset(
|
||||
{
|
||||
".agents",
|
||||
".claude",
|
||||
".codex",
|
||||
".cursor",
|
||||
".github",
|
||||
".gitlab",
|
||||
".idea",
|
||||
".vscode",
|
||||
"ace_json",
|
||||
"docs",
|
||||
"documentation",
|
||||
"files",
|
||||
"log",
|
||||
"logs",
|
||||
"save",
|
||||
"saves",
|
||||
"scripts",
|
||||
"skills",
|
||||
"test",
|
||||
"tests",
|
||||
"translated",
|
||||
"wolf_json",
|
||||
}
|
||||
)
|
||||
|
||||
_EXCLUDED_ROOT_FILE_NAMES = frozenset(
|
||||
{
|
||||
".editorconfig",
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
".pre-commit-config.yaml",
|
||||
"readme.md",
|
||||
"requirements.txt",
|
||||
"todo.md",
|
||||
"translation_quirks.txt",
|
||||
"vocab.txt",
|
||||
}
|
||||
)
|
||||
|
||||
_EXCLUDED_FILE_NAMES = frozenset(
|
||||
{
|
||||
".ds_store",
|
||||
".editorconfig",
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
"desktop.ini",
|
||||
"forge_mv.bat",
|
||||
"forge_mv.js",
|
||||
"forge_mz.bat",
|
||||
"forge_mz.js",
|
||||
"patch2.ps1",
|
||||
"patch2.sh",
|
||||
"previous_patch_sha.txt",
|
||||
"thumbs.db",
|
||||
"tlinspector.bat",
|
||||
"tlinspector.js",
|
||||
}
|
||||
)
|
||||
|
||||
_UPDATER_ROOT_FILE_NAMES = frozenset(
|
||||
{
|
||||
"gameupdate.bat",
|
||||
"gameupdate_linux.sh",
|
||||
"uberwolfcli.exe",
|
||||
"uberwolfcli.license.txt",
|
||||
}
|
||||
)
|
||||
|
||||
_ROOT_DOCUMENT_SUFFIXES = frozenset({".doc", ".docx", ".markdown", ".md", ".pdf", ".rst"})
|
||||
_BACKUP_SUFFIXES = (".bak", ".backup", ".orig", ".tmp")
|
||||
_PLUGIN_LIST_PATHS = frozenset({"js/plugins.js", "www/js/plugins.js"})
|
||||
_PLAYTEST_PLUGIN_NAMES = ("TLInspector", "Forge_MV", "Forge_MZ")
|
||||
|
||||
|
||||
def default_release_zip_path(game_root: str | Path) -> Path:
|
||||
"""Return a release path beside *game_root*, never inside it."""
|
||||
root = Path(game_root).expanduser().resolve()
|
||||
name = root.name or "game"
|
||||
return root.parent / f"{name}-public.zip"
|
||||
|
||||
|
||||
def normalize_release_zip_path(output_path: str | Path) -> Path:
|
||||
output = Path(output_path).expanduser()
|
||||
if output.suffix.casefold() != ".zip":
|
||||
output = output.with_name(output.name + ".zip")
|
||||
return output.resolve()
|
||||
|
||||
|
||||
def _is_updater_path(relative: Path) -> bool:
|
||||
parts = tuple(part.casefold() for part in relative.parts)
|
||||
if not parts:
|
||||
return False
|
||||
if parts[0] == "gameupdate":
|
||||
return True
|
||||
return len(parts) == 1 and parts[0] in _UPDATER_ROOT_FILE_NAMES
|
||||
|
||||
|
||||
def exclusion_reason(relative: str | Path, *, is_dir: bool = False) -> str | None:
|
||||
"""Return why a game-relative path is omitted, or ``None`` when it ships."""
|
||||
relative = Path(relative)
|
||||
parts = tuple(part.casefold() for part in relative.parts)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
if any(part in _EXCLUDED_DIR_NAMES for part in parts):
|
||||
return "tool metadata or cache"
|
||||
|
||||
name = parts[-1]
|
||||
if name.startswith(".env"):
|
||||
return "private environment configuration"
|
||||
if name in _EXCLUDED_FILE_NAMES:
|
||||
return "tool-generated or temporary file"
|
||||
|
||||
# Player updater files are the explicit exception to translator-artifact
|
||||
# filtering. VCS/cache directories and updater runtime state were handled above.
|
||||
if _is_updater_path(relative):
|
||||
return None
|
||||
|
||||
if len(parts) == 1:
|
||||
if is_dir and name in _EXCLUDED_ROOT_DIR_NAMES:
|
||||
return "translator workspace"
|
||||
if not is_dir:
|
||||
if name in _EXCLUDED_ROOT_FILE_NAMES:
|
||||
return "translator configuration or documentation"
|
||||
if relative.suffix.casefold() in _ROOT_DOCUMENT_SUFFIXES:
|
||||
return "root documentation file"
|
||||
if name.startswith("save") or relative.suffix.casefold() in {".rpgsave", ".sav"}:
|
||||
return "local save data"
|
||||
|
||||
if not is_dir and name.endswith(_BACKUP_SUFFIXES):
|
||||
return "backup or temporary file"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _iter_release_files(root: Path) -> tuple[list[tuple[Path, Path]], int]:
|
||||
files: list[tuple[Path, Path]] = []
|
||||
excluded = 0
|
||||
for current, dir_names, file_names in os.walk(root, topdown=True, followlinks=False):
|
||||
current_path = Path(current)
|
||||
kept_dirs: list[str] = []
|
||||
for name in sorted(dir_names, key=str.casefold):
|
||||
path = current_path / name
|
||||
relative = path.relative_to(root)
|
||||
if path.is_symlink() or exclusion_reason(relative, is_dir=True):
|
||||
excluded += 1
|
||||
else:
|
||||
kept_dirs.append(name)
|
||||
dir_names[:] = kept_dirs
|
||||
|
||||
for name in sorted(file_names, key=str.casefold):
|
||||
path = current_path / name
|
||||
relative = path.relative_to(root)
|
||||
if path.is_symlink() or exclusion_reason(relative):
|
||||
excluded += 1
|
||||
continue
|
||||
files.append((path, relative))
|
||||
return files, excluded
|
||||
|
||||
|
||||
def _remove_plugin_object(content: str, plugin_name: str) -> str:
|
||||
"""Remove one object from RPG Maker's JavaScript plugin-array syntax."""
|
||||
marker = re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content)
|
||||
while marker:
|
||||
start = content.rfind("{", 0, marker.start())
|
||||
if start < 0:
|
||||
raise ReleasePackageError(f"Could not remove {plugin_name} from plugins.js")
|
||||
depth = 0
|
||||
end = None
|
||||
in_string = False
|
||||
escaped = False
|
||||
for index in range(start, len(content)):
|
||||
char = content[index]
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if char == '"':
|
||||
in_string = True
|
||||
elif char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = index + 1
|
||||
break
|
||||
if end is None:
|
||||
raise ReleasePackageError(f"Could not parse {plugin_name} in plugins.js")
|
||||
|
||||
before = content[:start].rstrip()
|
||||
after = content[end:].lstrip()
|
||||
if after.startswith(","):
|
||||
after = after[1:].lstrip()
|
||||
elif before.endswith(","):
|
||||
before = before[:-1].rstrip()
|
||||
separator = "\n" if before and after and not before.endswith("\n") else ""
|
||||
content = before + separator + after
|
||||
marker = re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content)
|
||||
return re.sub(r",(\s*)\];", r"\1];", content)
|
||||
|
||||
|
||||
def sanitize_plugins_js(raw: bytes) -> tuple[bytes, bool]:
|
||||
"""Remove DazedTL playtest entries while preserving the source file's BOM."""
|
||||
had_bom = raw.startswith(codecs.BOM_UTF8)
|
||||
try:
|
||||
content = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ReleasePackageError(f"plugins.js is not valid UTF-8: {exc}") from exc
|
||||
original = content
|
||||
for plugin_name in _PLAYTEST_PLUGIN_NAMES:
|
||||
content = _remove_plugin_object(content, plugin_name)
|
||||
if content == original:
|
||||
return raw, False
|
||||
encoded = content.encode("utf-8")
|
||||
return (codecs.BOM_UTF8 + encoded if had_bom else encoded), True
|
||||
|
||||
|
||||
def _archive_name(root: Path, relative: Path) -> str:
|
||||
return (Path(root.name or "game") / relative).as_posix()
|
||||
|
||||
|
||||
def create_release_zip(
|
||||
game_root: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
progress: ProgressCallback | None = None,
|
||||
) -> ReleasePackageResult:
|
||||
"""Create an atomic public-release ZIP and leave the source game untouched."""
|
||||
root = Path(game_root).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise ReleasePackageError(f"Game folder not found: {root}")
|
||||
if root.parent == root:
|
||||
raise ReleasePackageError("A filesystem root cannot be packaged as a game folder")
|
||||
|
||||
output = normalize_release_zip_path(output_path)
|
||||
try:
|
||||
output.relative_to(root)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise ReleasePackageError("Save the public ZIP outside the game folder")
|
||||
if output.exists() and output.is_dir():
|
||||
raise ReleasePackageError(f"Release path is a directory: {output}")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
files, excluded = _iter_release_files(root)
|
||||
total = len(files)
|
||||
if not total:
|
||||
raise ReleasePackageError("No releasable game files were found")
|
||||
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{output.name}.", suffix=".tmp", dir=output.parent
|
||||
)
|
||||
os.close(descriptor)
|
||||
temporary = Path(temporary_name)
|
||||
files_added = 0
|
||||
bytes_added = 0
|
||||
sanitized = 0
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
temporary,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
allowZip64=True,
|
||||
) as archive:
|
||||
for index, (source, relative) in enumerate(files, start=1):
|
||||
if progress:
|
||||
progress(index, total, relative.as_posix())
|
||||
arcname = _archive_name(root, relative)
|
||||
if relative.as_posix().casefold() in _PLUGIN_LIST_PATHS:
|
||||
raw = source.read_bytes()
|
||||
payload, changed = sanitize_plugins_js(raw)
|
||||
info = zipfile.ZipInfo.from_file(source, arcname=arcname)
|
||||
archive.writestr(info, payload, compress_type=zipfile.ZIP_DEFLATED)
|
||||
sanitized += int(changed)
|
||||
bytes_added += len(payload)
|
||||
else:
|
||||
archive.write(source, arcname=arcname)
|
||||
bytes_added += source.stat().st_size
|
||||
files_added += 1
|
||||
os.replace(temporary, output)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return ReleasePackageResult(
|
||||
output_path=output,
|
||||
files_added=files_added,
|
||||
bytes_added=bytes_added,
|
||||
excluded_entries=excluded,
|
||||
sanitized_plugin_lists=sanitized,
|
||||
)
|
||||
Loading…
Reference in a new issue