Compare commits
3 commits
e504c5aae3
...
0a2b8c77b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a2b8c77b8 | |||
| a371223189 | |||
| 3b13575a74 |
9 changed files with 286 additions and 93 deletions
29
.agents/skills/shipped-data-assets/SKILL.md
Normal file
29
.agents/skills/shipped-data-assets/SKILL.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
name: shipped-data-assets
|
||||
description: Keep shipped data assets present in git branch archives and tool updates. Use when adding or changing defaults under data/, editing their .gitignore rules, changing the update installer, or updating bundled-data coverage in tests/test_bundled_updates.py.
|
||||
---
|
||||
|
||||
# Shipped Data Assets
|
||||
|
||||
Tool auto-update downloads a git branch archive. Files matched by `.gitignore` are omitted from that archive and never reach users.
|
||||
|
||||
## Maintain shipped defaults
|
||||
|
||||
- Add a `.gitignore` negation for every shipped path under `data/`, following existing patterns such as:
|
||||
- `!data/skills/**`
|
||||
- `!data/help/**`
|
||||
- `!data/vocab_base.txt`
|
||||
- `!data/translation_contexts.json`
|
||||
- Do not rely on `UpdateThread.should_install` alone; ignored files never appear in the downloaded archive.
|
||||
- Extend `_SHIPPED_DATA_FILES` or `ShippedDataTrackingTests` in `tests/test_bundled_updates.py` when adding a shipped path.
|
||||
- Keep user-local files ignored and protected, including `data/vocab.txt`, `data/last_update_sha.txt`, `data/wolf_*.json`, and `data/api_keys.json`.
|
||||
|
||||
## Verify tracking
|
||||
|
||||
Run `git check-ignore -- <shipped-path>` for each affected file. A shipped path must exit with status 1, indicating that it is not ignored.
|
||||
|
||||
Run the bundled-update tests after changing shipped paths, ignore rules, or installer behavior:
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_bundled_updates.ShippedDataTrackingTests
|
||||
```
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
description: Shipped data/ assets must be git-trackable so tool updates include them
|
||||
globs: "{.gitignore,data/**,gui/guide_tab.py,gui/main.py,tests/test_bundled_updates.py}"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Shipped data/ and tool updates
|
||||
|
||||
Tool auto-update downloads a **git branch archive**.
|
||||
Anything matched by `.gitignore` is omitted from that archive and never reaches users.
|
||||
|
||||
## When adding or changing shipped defaults under `data/`
|
||||
|
||||
- Add a negation under `.gitignore` (same pattern as skills/help):
|
||||
- `!data/skills/**`
|
||||
- `!data/help/**`
|
||||
- `!data/vocab_base.txt`
|
||||
- `!data/translation_contexts.json`
|
||||
- Do **not** rely on `UpdateThread.should_install` alone - ignored files never appear in the zip.
|
||||
- Extend `_SHIPPED_DATA_FILES` / `ShippedDataTrackingTests` in `tests/test_bundled_updates.py` when adding a new shipped path.
|
||||
- User-local files stay ignored/protected: `data/vocab.txt`, `data/last_update_sha.txt`, `data/wolf_*.json`, `data/api_keys.json`.
|
||||
|
||||
## Quick check
|
||||
|
||||
```bash
|
||||
git check-ignore -- data/help/index.json # must exit 1 (not ignored)
|
||||
```
|
||||
1
.git_archival.txt
Normal file
1
.git_archival.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
node: $Format:%H$
|
||||
12
.gitattributes
vendored
12
.gitattributes
vendored
|
|
@ -1,2 +1,14 @@
|
|||
# Cmd.exe parses LF-only .bat files incorrectly on Windows (joined tokens / mangled commands).
|
||||
*.bat text eol=crlf
|
||||
|
||||
# Keep development-only agent/editor metadata out of user-facing source and
|
||||
# update archives.
|
||||
.agents export-ignore
|
||||
.codex export-ignore
|
||||
.cursor export-ignore
|
||||
.github export-ignore
|
||||
.vscode export-ignore
|
||||
|
||||
# Expand the exact archived commit so a fresh source download can identify the
|
||||
# version it already contains without committing a self-referential literal SHA.
|
||||
.git_archival.txt export-subst
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,6 +19,7 @@ __pycache__
|
|||
!assets/*.png
|
||||
!assets/engine_icons/**
|
||||
!.pre-commit-config.yaml
|
||||
!.git_archival.txt
|
||||
!data/skills/**
|
||||
!data/help/**
|
||||
!data/vocab_base.txt
|
||||
|
|
|
|||
57
gui/main.py
57
gui/main.py
|
|
@ -53,8 +53,7 @@ def check_tool_update() -> str | None:
|
|||
"""Return latest commit SHA when a tool update is available, else None."""
|
||||
try:
|
||||
latest_sha = UpdateThread.fetch_latest_sha()
|
||||
sha_path = Path(UpdateThread.SHA_FILE)
|
||||
current_sha = sha_path.read_text().strip() if sha_path.is_file() else ""
|
||||
current_sha = UpdateThread.read_installed_sha()
|
||||
if latest_sha != current_sha:
|
||||
return latest_sha
|
||||
except Exception:
|
||||
|
|
@ -82,9 +81,21 @@ class UpdateThread(QThread):
|
|||
# "dazedtl"). Resolve dynamically so a rename cannot silently no-op again.
|
||||
ARCHIVE_ROOT = "dazedtl"
|
||||
SHA_FILE = str(LAST_UPDATE_SHA_PATH)
|
||||
ARCHIVE_SHA_FILE = str(PROJECT_ROOT / ".git_archival.txt")
|
||||
|
||||
# Top-level paths that should never be touched during update
|
||||
PROTECTED_TOP = {".env", "venv", "log", "files", "translated"}
|
||||
PROTECTED_TOP = {
|
||||
".agents",
|
||||
".codex",
|
||||
".cursor",
|
||||
".env",
|
||||
".github",
|
||||
".vscode",
|
||||
"venv",
|
||||
"log",
|
||||
"files",
|
||||
"translated",
|
||||
}
|
||||
|
||||
# User-local files under data/ that must not be overwritten.
|
||||
# Shipped defaults (translation_contexts.json, skills/*.md, help/*, vocab_base.txt, …)
|
||||
|
|
@ -185,14 +196,45 @@ class UpdateThread(QThread):
|
|||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())["commit"]["id"]
|
||||
|
||||
@staticmethod
|
||||
def _read_archive_sha(path: Path) -> str:
|
||||
"""Read an expanded git-archive commit ID, or return an empty string."""
|
||||
if not path.is_file():
|
||||
return ""
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
key, separator, value = line.partition(":")
|
||||
candidate = value.strip()
|
||||
if (
|
||||
separator
|
||||
and key.strip() == "node"
|
||||
and re.fullmatch(r"[0-9a-fA-F]{40,64}", candidate)
|
||||
):
|
||||
return candidate
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def read_installed_sha(cls) -> str:
|
||||
"""Return the updated SHA, falling back to source-archive metadata."""
|
||||
runtime_path = Path(cls.SHA_FILE)
|
||||
if runtime_path.is_file():
|
||||
try:
|
||||
runtime_sha = runtime_path.read_text(encoding="utf-8").strip()
|
||||
if runtime_sha:
|
||||
return runtime_sha
|
||||
except OSError:
|
||||
pass
|
||||
return cls._read_archive_sha(Path(cls.ARCHIVE_SHA_FILE))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _fetch_latest_sha(self):
|
||||
return self.fetch_latest_sha()
|
||||
|
||||
def _read_stored_sha(self):
|
||||
p = Path(self.SHA_FILE)
|
||||
return p.read_text().strip() if p.exists() else ""
|
||||
return self.read_installed_sha()
|
||||
|
||||
def _download_archive(self, zip_path: Path):
|
||||
req = urllib.request.Request(
|
||||
|
|
@ -493,10 +535,7 @@ class UpdateDialog(QDialog):
|
|||
|
||||
@classmethod
|
||||
def _installed_sha_display(cls) -> str:
|
||||
sha_path = Path(UpdateThread.SHA_FILE)
|
||||
if sha_path.is_file():
|
||||
return cls._short_sha(sha_path.read_text().strip())
|
||||
return "Not recorded"
|
||||
return cls._short_sha(UpdateThread.read_installed_sha())
|
||||
|
||||
def _set_stage_pills(self, active_stage: str | None = None):
|
||||
stage_order = list(UpdateThread.STAGES)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import traceback
|
|||
import signal
|
||||
import multiprocessing
|
||||
import re
|
||||
from importlib import import_module
|
||||
from colorama import Fore
|
||||
from tqdm import tqdm
|
||||
from dotenv import load_dotenv
|
||||
|
|
@ -75,6 +76,39 @@ BATCH_COLLECT_LIVE_CHARGE_NOTE = (
|
|||
)
|
||||
|
||||
|
||||
TRANSLATION_MODULE_SPECS = (
|
||||
("RPG Maker MV/MZ", (".json",), "modules.rpgmakermvmz", "handleMVMZ"),
|
||||
("CSV", (".csv",), "modules.csv", "handleCSV"),
|
||||
("Tyrano", (".ks",), "modules.tyrano", "handleTyrano"),
|
||||
("Kirikiri", (".ks",), "modules.kirikiri", "handleKirikiri"),
|
||||
("JSON", (".json",), "modules.json", "handleJSON"),
|
||||
("Lune", (".l",), "modules.lune", "handleLune"),
|
||||
("Yuris", (".json",), "modules.yuris", "handleYuris"),
|
||||
("NScript", (".nscript",), "modules.nscript", "handleOnscripter"),
|
||||
("Wolf RPG (WolfDawn)", (".json",), "modules.wolfdawn", "handleWolfDawn"),
|
||||
("Wolf RPG", (".json",), "modules.wolf", "handleWOLF"),
|
||||
("Wolf RPG 2", (".txt",), "modules.wolf2", "handleWOLF2"),
|
||||
("Regex", (".txt", ".json", ".script", ".csv"), "modules.regex", "handleRegex"),
|
||||
("Text", (".txt", ".srt"), "modules.text", "handleText"),
|
||||
("RenPy", (".rpy",), "modules.renpy", "handleRenpy"),
|
||||
("Unity", (".unity",), "modules.unity", "handleUnity"),
|
||||
("Images", (".png", ".jpg", ".jpeg"), "modules.images", "handleImages"),
|
||||
("RPG Maker Plugin", (".js",), "modules.rpgmakerplugin", "handlePlugin"),
|
||||
("Aquedi4 Prepared JSON", (".json",), "modules.aquedi4", "handleAquedi4"),
|
||||
("SRPG Studio", (".json",), "modules.srpg", "handleSRPG"),
|
||||
)
|
||||
|
||||
|
||||
def _lazy_module_handler(module_name, handler_name):
|
||||
"""Return a handler that imports its engine only when translation starts."""
|
||||
|
||||
def run(*args, **kwargs):
|
||||
module = import_module(module_name)
|
||||
return getattr(module, handler_name)(*args, **kwargs)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
class _ShimLabel:
|
||||
"""Plain stand-in for QLabel used by Files-tab row helpers (no real Qt widget)."""
|
||||
|
||||
|
|
@ -1647,63 +1681,25 @@ class TranslationTab(QWidget):
|
|||
|
||||
def setup_module_list(self):
|
||||
"""Set up the module selection list."""
|
||||
# Import modules to get the list
|
||||
try:
|
||||
sys.path.append(str(self.project_root))
|
||||
from modules.rpgmakermvmz import handleMVMZ
|
||||
from modules.csv import handleCSV
|
||||
from modules.tyrano import handleTyrano
|
||||
from modules.kirikiri import handleKirikiri
|
||||
from modules.json import handleJSON
|
||||
from modules.lune import handleLune
|
||||
from modules.yuris import handleYuris
|
||||
from modules.nscript import handleOnscripter
|
||||
from modules.wolf import handleWOLF
|
||||
from modules.wolf2 import handleWOLF2
|
||||
from modules.wolfdawn import handleWolfDawn
|
||||
from modules.regex import handleRegex
|
||||
from modules.text import handleText
|
||||
from modules.renpy import handleRenpy
|
||||
from modules.unity import handleUnity
|
||||
from modules.images import handleImages
|
||||
from modules.rpgmakerplugin import handlePlugin
|
||||
from modules.aquedi4 import handleAquedi4
|
||||
from modules.srpg import handleSRPG
|
||||
|
||||
self.modules = [
|
||||
["RPG Maker MV/MZ", [".json"], handleMVMZ],
|
||||
["CSV", [".csv"], handleCSV],
|
||||
["Tyrano", [".ks"], handleTyrano],
|
||||
["Kirikiri", [".ks"], handleKirikiri],
|
||||
["JSON", [".json"], handleJSON],
|
||||
["Lune", [".l"], handleLune],
|
||||
["Yuris", [".json"], handleYuris],
|
||||
["NScript", [".nscript"], handleOnscripter],
|
||||
["Wolf RPG (WolfDawn)", [".json"], handleWolfDawn],
|
||||
["Wolf RPG", [".json"], handleWOLF],
|
||||
["Wolf RPG 2", [".txt"], handleWOLF2],
|
||||
["Regex", [".txt", ".json", ".script", ".csv"], handleRegex],
|
||||
["Text", [".txt", ".srt"], handleText],
|
||||
["RenPy", [".rpy"], handleRenpy],
|
||||
["Unity", [".unity"], handleUnity],
|
||||
["Images", [".png", ".jpg", ".jpeg"], handleImages],
|
||||
["RPG Maker Plugin", [".js"], handlePlugin],
|
||||
["Aquedi4 Prepared JSON", [".json"], handleAquedi4],
|
||||
["SRPG Studio", [".json"], handleSRPG],
|
||||
# Engine modules read translation settings during import. A downloaded
|
||||
# source archive intentionally has no .env yet, so importing every
|
||||
# engine here used to abort discovery and leave only the fallback item.
|
||||
# Keep the UI registry independent and defer imports until a handler is
|
||||
# actually used.
|
||||
self.modules = [
|
||||
[
|
||||
display_name,
|
||||
list(extensions),
|
||||
_lazy_module_handler(module_name, handler_name),
|
||||
]
|
||||
|
||||
for module in self.modules:
|
||||
extensions = ", ".join(module[1])
|
||||
self.module_combo.addItem(f"{module[0]} ({extensions})")
|
||||
if self.module_combo.count():
|
||||
self._on_module_changed(self.module_combo.currentText())
|
||||
|
||||
except Exception as e:
|
||||
# Store error for later logging since log_display might not exist yet
|
||||
self.module_load_error = f"Warning: Could not load modules: {str(e)}"
|
||||
# Add a default option
|
||||
self.module_combo.addItem("RPG Maker MV/MZ (.json)")
|
||||
self.modules = [["RPG Maker MV/MZ", [".json"], None]]
|
||||
for display_name, extensions, module_name, handler_name
|
||||
in TRANSLATION_MODULE_SPECS
|
||||
]
|
||||
|
||||
for module in self.modules:
|
||||
extensions = ", ".join(module[1])
|
||||
self.module_combo.addItem(f"{module[0]} ({extensions})")
|
||||
if self.module_combo.count():
|
||||
self._on_module_changed(self.module_combo.currentText())
|
||||
|
||||
def _on_module_changed(self, text: str):
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
|
|||
|
||||
for rel in (
|
||||
*_SHIPPED_DATA_FILES,
|
||||
".git_archival.txt",
|
||||
"gui/main.py",
|
||||
"gameupdate/GameUpdate.bat",
|
||||
"gameupdate/gameupdate/patch.ps1",
|
||||
|
|
@ -58,6 +59,11 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
|
|||
"log/translations.txt",
|
||||
"files/Map001.json",
|
||||
"translated/Map001.json",
|
||||
".agents/skills/shipped-data-assets/SKILL.md",
|
||||
".codex/config.toml",
|
||||
".cursor/rules/project.mdc",
|
||||
".github/workflows/tests.yml",
|
||||
".vscode/settings.json",
|
||||
):
|
||||
with self.subTest(rel=rel):
|
||||
self.assertFalse(UpdateThread.should_install(Path(rel)))
|
||||
|
|
@ -180,8 +186,49 @@ class ShippedDataTrackingTests(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class SourceArchiveMetadataTests(unittest.TestCase):
|
||||
"""Source archives identify their commit and omit development metadata."""
|
||||
|
||||
def test_archival_info_is_configured_for_sha_expansion(self):
|
||||
archival_info = _REPO_ROOT / ".git_archival.txt"
|
||||
self.assertTrue(archival_info.is_file())
|
||||
self.assertIn("$Format:%H$", archival_info.read_text(encoding="utf-8"))
|
||||
|
||||
ignored = subprocess.run(
|
||||
["git", "check-ignore", "--", ".git_archival.txt"],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(ignored.returncode, 0)
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "check-attr", "export-subst", "--", ".git_archival.txt"],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("export-subst: set", result.stdout)
|
||||
|
||||
def test_development_directories_are_export_ignored(self):
|
||||
for rel in (".agents", ".codex", ".cursor", ".github", ".vscode"):
|
||||
with self.subTest(rel=rel):
|
||||
result = subprocess.run(
|
||||
["git", "check-attr", "export-ignore", "--", rel],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("export-ignore: set", result.stdout)
|
||||
|
||||
|
||||
class CheckToolUpdateTests(unittest.TestCase):
|
||||
"""Tool update check compares remote SHA to data/last_update_sha.txt only."""
|
||||
"""Tool update checks runtime state, then source-archive commit metadata."""
|
||||
|
||||
def test_returns_sha_when_remote_differs(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
|
|
@ -207,6 +254,43 @@ class CheckToolUpdateTests(unittest.TestCase):
|
|||
):
|
||||
self.assertIsNone(check_tool_update())
|
||||
|
||||
def test_archive_sha_prevents_false_update_on_first_launch(self):
|
||||
archived_sha = "a" * 40
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
base = Path(raw)
|
||||
sha_file = base / "missing-last-update-sha.txt"
|
||||
archive_file = base / ".git_archival.txt"
|
||||
archive_file.write_text(f"node: {archived_sha}\n", encoding="utf-8")
|
||||
from gui.main import UpdateThread, check_tool_update
|
||||
|
||||
with patch.multiple(
|
||||
UpdateThread,
|
||||
SHA_FILE=str(sha_file),
|
||||
ARCHIVE_SHA_FILE=str(archive_file),
|
||||
):
|
||||
with patch.object(
|
||||
UpdateThread, "fetch_latest_sha", return_value=archived_sha
|
||||
):
|
||||
self.assertIsNone(check_tool_update())
|
||||
|
||||
def test_unexpanded_archive_sha_is_ignored(self):
|
||||
latest_sha = "b" * 40
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
base = Path(raw)
|
||||
archive_file = base / ".git_archival.txt"
|
||||
archive_file.write_text("node: $Format:%H$\n", encoding="utf-8")
|
||||
from gui.main import UpdateThread, check_tool_update
|
||||
|
||||
with patch.multiple(
|
||||
UpdateThread,
|
||||
SHA_FILE=str(base / "missing-last-update-sha.txt"),
|
||||
ARCHIVE_SHA_FILE=str(archive_file),
|
||||
):
|
||||
with patch.object(
|
||||
UpdateThread, "fetch_latest_sha", return_value=latest_sha
|
||||
):
|
||||
self.assertEqual(check_tool_update(), latest_sha)
|
||||
|
||||
def test_returns_none_on_fetch_failure(self):
|
||||
from gui.main import UpdateThread, check_tool_update
|
||||
|
||||
|
|
|
|||
58
tests/test_translation_engine_dropdown.py
Normal file
58
tests/test_translation_engine_dropdown.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Regression tests for Translation-tab engine discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
try:
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from gui.translation_tab import TRANSLATION_MODULE_SPECS, TranslationTab
|
||||
|
||||
_HAS_QT = True
|
||||
except Exception: # pragma: no cover - PyQt5 not installed
|
||||
_HAS_QT = False
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_QT, "PyQt5 not available")
|
||||
class TranslationEngineDropdownTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls._app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_all_engines_are_listed_without_environment_configuration(self) -> None:
|
||||
required_settings = (
|
||||
"model",
|
||||
"language",
|
||||
"timeout",
|
||||
"width",
|
||||
"listWidth",
|
||||
"noteWidth",
|
||||
)
|
||||
without_settings = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if key not in required_settings
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, without_settings, clear=True):
|
||||
tab = TranslationTab()
|
||||
|
||||
expected = [
|
||||
f"{name} ({', '.join(extensions)})"
|
||||
for name, extensions, _module, _handler in TRANSLATION_MODULE_SPECS
|
||||
]
|
||||
actual = [
|
||||
tab.module_combo.itemText(index)
|
||||
for index in range(tab.module_combo.count())
|
||||
]
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertTrue(all(callable(module[2]) for module in tab.modules))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue