git4subeditor 0.1.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git4subeditor/__init__.py +41 -0
- git4subeditor/__main__.py +6 -0
- git4subeditor/cli.py +683 -0
- git4subeditor/gitformat.py +427 -0
- git4subeditor/l10n.py +107 -0
- git4subeditor/locales/cn.xml +135 -0
- git4subeditor/locales/en.xml +137 -0
- git4subeditor/locales/ru.xml +137 -0
- git4subeditor/repl.py +256 -0
- git4subeditor/subfile.py +189 -0
- git4subeditor/utils.py +122 -0
- git4subeditor/workspace.py +843 -0
- git4subeditor-0.1.20.dist-info/METADATA +227 -0
- git4subeditor-0.1.20.dist-info/RECORD +17 -0
- git4subeditor-0.1.20.dist-info/WHEEL +4 -0
- git4subeditor-0.1.20.dist-info/entry_points.txt +3 -0
- git4subeditor-0.1.20.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Convert .sub files to and from a Git-friendly XML bundle.
|
|
2
|
+
|
|
3
|
+
The bundle contains a canonical, line-oriented XML file and, when present, the
|
|
4
|
+
submarine preview image extracted from the ``previewimage`` attribute. The XML
|
|
5
|
+
references the image through a relative ``preview`` attribute. Packing
|
|
6
|
+
re-embeds the preview image and writes a deterministic gzip-compressed .sub
|
|
7
|
+
file, backing up an existing target first.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import gzip
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from git4subeditor.l10n import t
|
|
20
|
+
from git4subeditor.subfile import (
|
|
21
|
+
GZIP_MAGIC,
|
|
22
|
+
SubFileError,
|
|
23
|
+
decode_xml,
|
|
24
|
+
decompress_sub,
|
|
25
|
+
read_sub_bytes,
|
|
26
|
+
)
|
|
27
|
+
from git4subeditor.utils import format_message
|
|
28
|
+
|
|
29
|
+
PREVIEW_ATTRIBUTE: str = "previewimage"
|
|
30
|
+
PREVIEW_LINK_ATTRIBUTE: str = "preview"
|
|
31
|
+
INDENT: str = " "
|
|
32
|
+
|
|
33
|
+
_IMAGE_SIGNATURES: tuple[tuple[bytes, str], ...] = (
|
|
34
|
+
(b"\x89PNG\r\n\x1a\n", ".png"),
|
|
35
|
+
(b"\xff\xd8\xff", ".jpg"),
|
|
36
|
+
(b"GIF87a", ".gif"),
|
|
37
|
+
(b"GIF89a", ".gif"),
|
|
38
|
+
(b"BM", ".bmp"),
|
|
39
|
+
)
|
|
40
|
+
# Barotrauma stores these comma-separated attributes as unordered sets. The
|
|
41
|
+
# game may write them in a different order every time a submarine is saved.
|
|
42
|
+
_UNORDERED_COMMA_ATTRIBUTES: frozenset[str] = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"Tags",
|
|
45
|
+
"items",
|
|
46
|
+
"excludedidentifiers",
|
|
47
|
+
"DisallowedUpgrades",
|
|
48
|
+
"DisallowAttachingOverTags",
|
|
49
|
+
"ContainableRestrictions",
|
|
50
|
+
"idcardtags",
|
|
51
|
+
"outposttags",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class ExportResult:
|
|
58
|
+
"""Paths written by :func:`export_sub`.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
xml_path: The canonical XML file.
|
|
62
|
+
preview_path: The extracted preview image, or None when the source has
|
|
63
|
+
no preview image.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
xml_path: Path
|
|
67
|
+
preview_path: Path | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_xml(text: str) -> ET.Element:
|
|
71
|
+
"""Parse XML text while preserving comments and processing instructions.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
text: XML text.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The root element.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
SubFileError: If the text is not well-formed XML.
|
|
81
|
+
"""
|
|
82
|
+
parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True, insert_pis=True))
|
|
83
|
+
try:
|
|
84
|
+
return ET.fromstring(text, parser=parser)
|
|
85
|
+
except ET.ParseError as exc:
|
|
86
|
+
raise SubFileError(t("err.invalid_xml", error=exc)) from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def serialize_xml(
|
|
90
|
+
root: ET.Element,
|
|
91
|
+
*,
|
|
92
|
+
sort_attributes: bool = False,
|
|
93
|
+
trailing_newline: bool = True,
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Serialize an XML tree in canonical, line-oriented form.
|
|
96
|
+
|
|
97
|
+
The tree is modified in place: elements are indented with two spaces and,
|
|
98
|
+
when requested, attributes are sorted alphabetically. Comma-separated
|
|
99
|
+
game set attributes such as ``Tags`` and ``items`` are always sorted so
|
|
100
|
+
that saving the same submarine again does not produce meaningless diffs.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
root: Root element to serialize.
|
|
104
|
+
sort_attributes: Whether to sort attributes of every element.
|
|
105
|
+
trailing_newline: Whether to end the result with a newline.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The serialized XML text using LF line endings.
|
|
109
|
+
"""
|
|
110
|
+
_canonicalize_unordered_attributes(root)
|
|
111
|
+
if sort_attributes:
|
|
112
|
+
_sort_attributes(root)
|
|
113
|
+
ET.indent(root, space=INDENT)
|
|
114
|
+
text = ET.tostring(root, encoding="unicode", short_empty_elements=True)
|
|
115
|
+
if trailing_newline and not text.endswith("\n"):
|
|
116
|
+
text += "\n"
|
|
117
|
+
return text
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_preview_image(root: ET.Element) -> tuple[bytes, str] | None:
|
|
121
|
+
"""Remove the preview image from the root and decode it.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
root: Root element of a submarine XML document. It is modified in
|
|
125
|
+
place.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A tuple of image bytes and a file suffix such as ``".png"``, or None
|
|
129
|
+
when the root has no preview image.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
SubFileError: If the ``previewimage`` attribute is not valid base64.
|
|
133
|
+
"""
|
|
134
|
+
encoded = root.attrib.get(PREVIEW_ATTRIBUTE)
|
|
135
|
+
if encoded is None:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
compact = "".join(encoded.split())
|
|
139
|
+
if not compact:
|
|
140
|
+
del root.attrib[PREVIEW_ATTRIBUTE]
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
image = base64.b64decode(compact, validate=True)
|
|
145
|
+
except (binascii.Error, ValueError) as exc:
|
|
146
|
+
raise SubFileError(t("err.invalid_base64", error=exc)) from exc
|
|
147
|
+
|
|
148
|
+
del root.attrib[PREVIEW_ATTRIBUTE]
|
|
149
|
+
return image, _guess_image_suffix(image)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def embed_preview_image(root: ET.Element, image: bytes) -> None:
|
|
153
|
+
"""Add a preview image to the root as a base64 attribute.
|
|
154
|
+
|
|
155
|
+
Existing ``previewimage`` attributes are left untouched.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
root: Root element of a submarine XML document. It is modified in
|
|
159
|
+
place.
|
|
160
|
+
image: The image file contents to embed.
|
|
161
|
+
"""
|
|
162
|
+
if PREVIEW_ATTRIBUTE in root.attrib:
|
|
163
|
+
return
|
|
164
|
+
root.set(PREVIEW_ATTRIBUTE, base64.b64encode(image).decode("ascii"))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def export_sub(
|
|
168
|
+
src: Path,
|
|
169
|
+
out_dir: Path | None = None,
|
|
170
|
+
*,
|
|
171
|
+
force: bool = False,
|
|
172
|
+
sort_attributes: bool = False,
|
|
173
|
+
) -> ExportResult:
|
|
174
|
+
"""Export a .sub file into a Git-friendly XML bundle.
|
|
175
|
+
|
|
176
|
+
The XML is written as ``<stem>.xml`` in the output directory. When the
|
|
177
|
+
source has a preview image, it is written as ``<stem>.<ext>`` and the root
|
|
178
|
+
element receives a relative ``preview`` attribute pointing to it.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
src: Path to the input .sub file.
|
|
182
|
+
out_dir: Output directory. Defaults to the input file's directory.
|
|
183
|
+
force: Whether to overwrite existing output files.
|
|
184
|
+
sort_attributes: Whether to sort XML attributes alphabetically.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The paths of the written XML file and preview image.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
SubFileError: If reading, decompressing, parsing, or writing fails, or
|
|
191
|
+
if an output file already exists and force is False.
|
|
192
|
+
"""
|
|
193
|
+
raw: bytes = read_sub_bytes(src)
|
|
194
|
+
text: str = decode_xml(decompress_sub(raw))
|
|
195
|
+
root: ET.Element = parse_xml(text)
|
|
196
|
+
_require_submarine_root(root)
|
|
197
|
+
|
|
198
|
+
preview: tuple[bytes, str] | None = extract_preview_image(root)
|
|
199
|
+
|
|
200
|
+
target_dir: Path = out_dir if out_dir is not None else src.parent
|
|
201
|
+
try:
|
|
202
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise SubFileError(t("err.create_dir", path=target_dir, error=exc)) from exc
|
|
205
|
+
|
|
206
|
+
xml_path = target_dir / f"{src.stem}.xml"
|
|
207
|
+
preview_path = (
|
|
208
|
+
target_dir / f"{src.stem}{preview[1]}" if preview is not None else None
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
root.attrib.pop(PREVIEW_LINK_ATTRIBUTE, None)
|
|
212
|
+
if preview_path is not None:
|
|
213
|
+
root.set(PREVIEW_LINK_ATTRIBUTE, preview_path.name)
|
|
214
|
+
|
|
215
|
+
canonical: str = serialize_xml(
|
|
216
|
+
root,
|
|
217
|
+
sort_attributes=sort_attributes,
|
|
218
|
+
trailing_newline=True,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
_ensure_writable(xml_path, force)
|
|
222
|
+
if preview_path is not None:
|
|
223
|
+
_ensure_writable(preview_path, force)
|
|
224
|
+
|
|
225
|
+
_write_text(xml_path, canonical)
|
|
226
|
+
if preview is not None and preview_path is not None:
|
|
227
|
+
_write_bytes(preview_path, preview[0])
|
|
228
|
+
|
|
229
|
+
return ExportResult(xml_path=xml_path, preview_path=preview_path)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def pack_sub(
|
|
233
|
+
xml_path: Path,
|
|
234
|
+
out: Path | None = None,
|
|
235
|
+
*,
|
|
236
|
+
preview_override: bytes | None = None,
|
|
237
|
+
sort_attributes: bool = False,
|
|
238
|
+
compresslevel: int = 9,
|
|
239
|
+
) -> Path:
|
|
240
|
+
"""Pack an exported XML file back into a .sub file.
|
|
241
|
+
|
|
242
|
+
The relative ``preview`` attribute is resolved against the XML directory
|
|
243
|
+
and embedded as base64 in ``previewimage``. If the linked image is missing,
|
|
244
|
+
a warning is printed and the preview is omitted. An existing target is
|
|
245
|
+
copied to ``<target>.bak`` before it is overwritten.
|
|
246
|
+
|
|
247
|
+
When ``preview_override`` is provided, those image bytes take priority
|
|
248
|
+
over the XML ``preview`` link and are embedded instead.
|
|
249
|
+
|
|
250
|
+
The output is gzip-compressed with a fixed timestamp, so packing the same
|
|
251
|
+
XML twice produces identical bytes.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
xml_path: Path to the exported XML file.
|
|
255
|
+
out: Output .sub path. Defaults to the XML file with a ``.sub`` suffix.
|
|
256
|
+
preview_override: Optional preview image bytes that override the XML
|
|
257
|
+
``preview`` link.
|
|
258
|
+
sort_attributes: Whether to sort XML attributes before packing.
|
|
259
|
+
compresslevel: gzip compression level from 0 to 9.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
The path of the written .sub file.
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
SubFileError: If reading, parsing, resolving the preview link, backing
|
|
266
|
+
up the target, or writing fails, or if the override image is
|
|
267
|
+
empty.
|
|
268
|
+
ValueError: If compresslevel is outside the range 0 to 9.
|
|
269
|
+
"""
|
|
270
|
+
if not 0 <= compresslevel <= 9:
|
|
271
|
+
raise ValueError(f"compresslevel must be between 0 and 9, got {compresslevel}")
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
raw: bytes = xml_path.read_bytes()
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
raise SubFileError(t("err.read_file", path=xml_path, error=exc)) from exc
|
|
277
|
+
|
|
278
|
+
if raw.startswith(GZIP_MAGIC):
|
|
279
|
+
raise SubFileError(t("err.pack_sub_input", path=xml_path))
|
|
280
|
+
|
|
281
|
+
root: ET.Element = parse_xml(decode_xml(raw))
|
|
282
|
+
_require_submarine_root(root)
|
|
283
|
+
|
|
284
|
+
link = root.attrib.pop(PREVIEW_LINK_ATTRIBUTE, None)
|
|
285
|
+
if PREVIEW_ATTRIBUTE not in root.attrib:
|
|
286
|
+
if preview_override is not None:
|
|
287
|
+
if not preview_override:
|
|
288
|
+
raise SubFileError(t("err.empty_preview", path=xml_path))
|
|
289
|
+
embed_preview_image(root, preview_override)
|
|
290
|
+
elif link is not None:
|
|
291
|
+
preview_path = _resolve_preview_link(xml_path, link)
|
|
292
|
+
try:
|
|
293
|
+
preview = preview_path.read_bytes()
|
|
294
|
+
except FileNotFoundError:
|
|
295
|
+
print(
|
|
296
|
+
format_message(
|
|
297
|
+
t("warning.preview_missing", path=preview_path),
|
|
298
|
+
label="warning",
|
|
299
|
+
),
|
|
300
|
+
file=sys.stderr,
|
|
301
|
+
)
|
|
302
|
+
preview = None
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
raise SubFileError(
|
|
305
|
+
t("err.read_preview", path=preview_path, error=exc)
|
|
306
|
+
) from exc
|
|
307
|
+
if preview is not None:
|
|
308
|
+
if not preview:
|
|
309
|
+
raise SubFileError(t("err.empty_preview", path=preview_path))
|
|
310
|
+
embed_preview_image(root, preview)
|
|
311
|
+
|
|
312
|
+
text: str = serialize_xml(
|
|
313
|
+
root,
|
|
314
|
+
sort_attributes=sort_attributes,
|
|
315
|
+
trailing_newline=False,
|
|
316
|
+
)
|
|
317
|
+
compressed: bytes = _compress_xml(text.encode("utf-8"), compresslevel)
|
|
318
|
+
|
|
319
|
+
dst = _resolve_pack_output(xml_path, out)
|
|
320
|
+
try:
|
|
321
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
raise SubFileError(t("err.create_dir", path=dst.parent, error=exc)) from exc
|
|
324
|
+
_backup_existing(dst)
|
|
325
|
+
_write_bytes(dst, compressed)
|
|
326
|
+
return dst
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _require_submarine_root(root: ET.Element) -> None:
|
|
330
|
+
"""Check that the root element is a Submarine element."""
|
|
331
|
+
if root.tag != "Submarine":
|
|
332
|
+
raise SubFileError(t("err.submarine_root", tag=root.tag))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _canonicalize_unordered_attributes(root: ET.Element) -> None:
|
|
336
|
+
"""Sort comma-separated set attributes of the root and its descendants."""
|
|
337
|
+
for element in root.iter():
|
|
338
|
+
for name in _UNORDERED_COMMA_ATTRIBUTES:
|
|
339
|
+
value = element.attrib.get(name)
|
|
340
|
+
if value is None or "," not in value:
|
|
341
|
+
continue
|
|
342
|
+
parts = [part.strip() for part in value.split(",")]
|
|
343
|
+
element.attrib[name] = ",".join(sorted(parts, key=str.casefold))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _sort_attributes(root: ET.Element) -> None:
|
|
347
|
+
"""Sort the attributes of the root and all its descendants."""
|
|
348
|
+
for element in root.iter():
|
|
349
|
+
element.attrib = dict(sorted(element.attrib.items()))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _guess_image_suffix(image: bytes) -> str:
|
|
353
|
+
"""Return a file suffix based on image magic bytes."""
|
|
354
|
+
for signature, suffix in _IMAGE_SIGNATURES:
|
|
355
|
+
if image.startswith(signature):
|
|
356
|
+
return suffix
|
|
357
|
+
if image[:4] == b"RIFF" and image[8:12] == b"WEBP":
|
|
358
|
+
return ".webp"
|
|
359
|
+
return ".bin"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _resolve_pack_output(xml_path: Path, out: Path | None) -> Path:
|
|
363
|
+
"""Resolve the output path for pack_sub."""
|
|
364
|
+
if out is None:
|
|
365
|
+
return xml_path.with_suffix(".sub")
|
|
366
|
+
if out.is_dir():
|
|
367
|
+
return out / f"{xml_path.stem}.sub"
|
|
368
|
+
return out
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _resolve_preview_link(xml_path: Path, link: str) -> Path:
|
|
372
|
+
"""Resolve a relative preview link against the XML file's directory."""
|
|
373
|
+
if not link.strip():
|
|
374
|
+
raise SubFileError(t("err.empty_preview_link"))
|
|
375
|
+
path = Path(link)
|
|
376
|
+
return path if path.is_absolute() else xml_path.parent / path
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _backup_existing(path: Path) -> Path | None:
|
|
380
|
+
"""Copy an existing target to ``<name>.bak`` before overwriting it."""
|
|
381
|
+
if not path.exists():
|
|
382
|
+
return None
|
|
383
|
+
if not path.is_file():
|
|
384
|
+
raise SubFileError(t("err.not_regular_file", path=path))
|
|
385
|
+
|
|
386
|
+
backup = path.with_name(f"{path.name}.bak")
|
|
387
|
+
try:
|
|
388
|
+
shutil.copy2(path, backup)
|
|
389
|
+
except OSError as exc:
|
|
390
|
+
raise SubFileError(
|
|
391
|
+
t("err.backup", path=path, backup=backup, error=exc)
|
|
392
|
+
) from exc
|
|
393
|
+
return backup
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _ensure_writable(path: Path, force: bool) -> None:
|
|
397
|
+
"""Raise when a target cannot be written."""
|
|
398
|
+
if not path.exists():
|
|
399
|
+
return
|
|
400
|
+
if not force:
|
|
401
|
+
raise SubFileError(t("err.output_exists", path=path))
|
|
402
|
+
if not path.is_file():
|
|
403
|
+
raise SubFileError(t("err.not_regular_file", path=path))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _write_text(path: Path, text: str) -> None:
|
|
407
|
+
"""Write UTF-8 text with LF line endings."""
|
|
408
|
+
try:
|
|
409
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
410
|
+
except OSError as exc:
|
|
411
|
+
raise SubFileError(t("err.write", path=path, error=exc)) from exc
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _write_bytes(path: Path, data: bytes) -> None:
|
|
415
|
+
"""Write raw bytes, wrapping OSError as SubFileError."""
|
|
416
|
+
try:
|
|
417
|
+
path.write_bytes(data)
|
|
418
|
+
except OSError as exc:
|
|
419
|
+
raise SubFileError(t("err.write", path=path, error=exc)) from exc
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _compress_xml(data: bytes, compresslevel: int) -> bytes:
|
|
423
|
+
"""Gzip-compress XML bytes with a stable timestamp."""
|
|
424
|
+
try:
|
|
425
|
+
return gzip.compress(data, compresslevel=compresslevel, mtime=0)
|
|
426
|
+
except OSError as exc:
|
|
427
|
+
raise SubFileError(t("err.gzip_compress", error=exc)) from exc
|
git4subeditor/l10n.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Localization support for command-line messages."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from functools import cache
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_LANGUAGE = "en"
|
|
9
|
+
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "cn", "ru")
|
|
10
|
+
|
|
11
|
+
_language: str = DEFAULT_LANGUAGE
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configured_language(directory: Path | None = None) -> str | None:
|
|
15
|
+
"""Return the language configured in the project file, if valid.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
directory: Directory containing ``git4subeditor.xml``. Defaults to the
|
|
19
|
+
current working directory.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A supported language code, or None when no valid language is set.
|
|
23
|
+
"""
|
|
24
|
+
base: Path = directory if directory is not None else Path.cwd()
|
|
25
|
+
try:
|
|
26
|
+
root: ET.Element = ET.parse(base / "git4subeditor.xml").getroot()
|
|
27
|
+
except (OSError, ET.ParseError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
element: ET.Element | None = root.find("language")
|
|
31
|
+
if element is None:
|
|
32
|
+
return None
|
|
33
|
+
language: str = (element.text or "").strip().lower()
|
|
34
|
+
if language in SUPPORTED_LANGUAGES:
|
|
35
|
+
return language
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_language(directory: Path | None = None) -> str:
|
|
40
|
+
"""Return the configured language, falling back to English.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
directory: Directory containing ``git4subeditor.xml``. Defaults to the
|
|
44
|
+
current working directory.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A supported language code.
|
|
48
|
+
"""
|
|
49
|
+
return configured_language(directory) or DEFAULT_LANGUAGE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_language(language: str) -> str:
|
|
53
|
+
"""Set the active language and return the effective language code.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
language: Requested language code.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The active supported language code.
|
|
60
|
+
"""
|
|
61
|
+
global _language
|
|
62
|
+
normalized: str = language.strip().lower()
|
|
63
|
+
_language = normalized if normalized in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE
|
|
64
|
+
return _language
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_language() -> str:
|
|
68
|
+
"""Return the active language code."""
|
|
69
|
+
return _language
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def t(key: str, **values: object) -> str:
|
|
73
|
+
"""Translate a message key and format its placeholders.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
key: Message key from a locale file.
|
|
77
|
+
**values: Values used by ``str.format`` placeholders.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The translated message, or the key when no translation exists.
|
|
81
|
+
"""
|
|
82
|
+
message: str | None = _catalog(_language).get(key)
|
|
83
|
+
if message is None:
|
|
84
|
+
message = _catalog(DEFAULT_LANGUAGE).get(key)
|
|
85
|
+
if message is None:
|
|
86
|
+
return key
|
|
87
|
+
if values:
|
|
88
|
+
return message.format(**values)
|
|
89
|
+
return message
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cache
|
|
93
|
+
def _catalog(language: str) -> dict[str, str]:
|
|
94
|
+
"""Load and cache one locale catalog."""
|
|
95
|
+
catalog: dict[str, str] = {}
|
|
96
|
+
try:
|
|
97
|
+
path = resources.files("git4subeditor").joinpath("locales", f"{language}.xml")
|
|
98
|
+
with path.open("rb") as handle:
|
|
99
|
+
root: ET.Element = ET.parse(handle).getroot()
|
|
100
|
+
except (OSError, ET.ParseError):
|
|
101
|
+
return catalog
|
|
102
|
+
|
|
103
|
+
for element in root.findall("message"):
|
|
104
|
+
key: str | None = element.get("key")
|
|
105
|
+
if key:
|
|
106
|
+
catalog[key] = element.text or ""
|
|
107
|
+
return catalog
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<?xml version='1.0' encoding='utf-8'?>
|
|
2
|
+
<locale language="cn">
|
|
3
|
+
<message key="app.description">在 Barotrauma .sub 文件与 Git 友好的 XML 之间转换.</message>
|
|
4
|
+
<message key="metavar.command">命令</message>
|
|
5
|
+
<message key="arg.version">显示程序版本并退出</message>
|
|
6
|
+
<message key="arg.help">显示帮助并退出</message>
|
|
7
|
+
<message key="help.usage">用法: </message>
|
|
8
|
+
<message key="help.positionals">位置参数</message>
|
|
9
|
+
<message key="help.options">选项</message>
|
|
10
|
+
<message key="repl.commands">命令</message>
|
|
11
|
+
<message key="repl.command.init">初始化/更新工作区</message>
|
|
12
|
+
<message key="repl.command.export">解压 .sub 并转为 Git 友好的 XML</message>
|
|
13
|
+
<message key="repl.command.pack">打包 XML 为 .sub</message>
|
|
14
|
+
<message key="repl.command.unpack">仅解压 .sub 为原始 XML</message>
|
|
15
|
+
<message key="repl.command.tree">递归搜索并显示仓库下所有潜艇</message>
|
|
16
|
+
<message key="repl.command.exit">退出交互模式</message>
|
|
17
|
+
<message key="repl.hint">Tab 补全 · ↑↓ 历史 · Ctrl-R 搜索 · Ctrl-D 退出 · ? 帮助</message>
|
|
18
|
+
<message key="repl.help"><![CDATA[<title>tree</title>
|
|
19
|
+
以目录树形式显示 Git 仓库下的潜艇 .sub 文件.
|
|
20
|
+
- 未注册文件末尾显示红色 (U)
|
|
21
|
+
- 已注册但哈希变化的文件末尾显示黄色 (M)
|
|
22
|
+
|
|
23
|
+
<title>export</title>
|
|
24
|
+
导出 .sub 为 Git 友好的 XML.
|
|
25
|
+
|
|
26
|
+
<cmd>export</cmd>
|
|
27
|
+
导出所有发生改动的已注册潜艇
|
|
28
|
+
<cmd>export</cmd> "test/My Sub1.sub" "test/My Sub2.sub" ...
|
|
29
|
+
导出一个或多个已注册潜艇
|
|
30
|
+
<cmd>export</cmd> "test/My Sub1.sub" -o ./mydir
|
|
31
|
+
导出潜艇到指定目录
|
|
32
|
+
|
|
33
|
+
注意: 若潜艇名或目录名包含空格, 路径必须加上引号, 否则会被识别成多个参数
|
|
34
|
+
|
|
35
|
+
<title>pack</title>
|
|
36
|
+
把导出的 XML 打包回 .sub.
|
|
37
|
+
|
|
38
|
+
<cmd>pack</cmd> "test/My Sub.xml"
|
|
39
|
+
<cmd>pack</cmd> "test/My Sub.xml" -o ./mydir
|
|
40
|
+
打包到指定目录
|
|
41
|
+
|
|
42
|
+
注意: 如果输出 .sub 已存在, 会先备份为 .sub.bak, 然后覆盖.
|
|
43
|
+
|
|
44
|
+
<title>unpack</title>
|
|
45
|
+
只解压潜艇 .sub 文件为原始 XML, 此命令比较少用
|
|
46
|
+
|
|
47
|
+
<cmd>unpack</cmd> "test/My Sub.sub"
|
|
48
|
+
]]></message>
|
|
49
|
+
<message key="repl.not_tty">交互模式需要终端; 请使用子命令</message>
|
|
50
|
+
<message key="repl.parse_error">无法解析命令行: {error}</message>
|
|
51
|
+
<message key="repl.workspace_missing">尚未初始化工作区</message>
|
|
52
|
+
<message key="repl.workspace">项目: {project} | Git 仓库: {git_repo} | 模组根目录: {mod_root}</message>
|
|
53
|
+
<message key="repl.cwd">CWD: {path}</message>
|
|
54
|
+
|
|
55
|
+
<message key="command.init">初始化/更新工作区</message>
|
|
56
|
+
<message key="command.unpack">从 .sub 文件中提取原始 XML</message>
|
|
57
|
+
<message key="command.export">将 .sub 文件导出为规范化 XML 和预览图</message>
|
|
58
|
+
<message key="command.pack">将导出的 XML 打包回 .sub 文件</message>
|
|
59
|
+
<message key="command.sub">列出已注册潜艇</message>
|
|
60
|
+
|
|
61
|
+
<message key="arg.inputs.sub">一个或多个 .sub 文件</message>
|
|
62
|
+
<message key="arg.inputs.xml">一个或多个导出的 XML 文件</message>
|
|
63
|
+
<message key="arg.output_dir">输出目录, 默认为每个输入文件所在目录</message>
|
|
64
|
+
<message key="arg.encoding">强制指定 XML 编码, 不自动检测 UTF-8/UTF-16</message>
|
|
65
|
+
<message key="arg.stdout">将 XML 输出到 stdout, 不写入文件</message>
|
|
66
|
+
<message key="arg.no_validate">跳过 XML 格式检查</message>
|
|
67
|
+
<message key="arg.force">直接覆盖已存在的输出文件, 不询问</message>
|
|
68
|
+
<message key="arg.sort_attributes">按字母顺序排序 XML 属性, 使 diff 更稳定</message>
|
|
69
|
+
<message key="arg.sort_attributes_pack">打包前按字母顺序排序 XML 属性</message>
|
|
70
|
+
<message key="arg.output">输出 .sub 文件, 或一个已存在的目录; 默认为 XML 文件所在目录</message>
|
|
71
|
+
<message key="arg.tree">以目录树形式显示已注册潜艇</message>
|
|
72
|
+
<message key="arg.force_init">自动同意所有初始化更改, 不询问</message>
|
|
73
|
+
|
|
74
|
+
<message key="err.stdout_single">--stdout 只能接受一个输入文件</message>
|
|
75
|
+
<message key="err.output_multi">打包多个文件时, --output 必须是已存在的目录</message>
|
|
76
|
+
<message key="prompt.overwrite">{path} 已存在. 是否覆盖? [y/N] </message>
|
|
77
|
+
<message key="prompt.submarines">是否添加/修改这些潜艇? [y/N] </message>
|
|
78
|
+
<message key="prompt.remove_submarines">是否移除这些潜艇? [y/N] </message>
|
|
79
|
+
<message key="prompt.mod_root_changed">MOD 根目录: {old} -> {new}, 是否更新? [y/N] </message>
|
|
80
|
+
<message key="prompt.git_repo_changed">Git 仓库: {old} -> {new}, 是否更新? [y/N] </message>
|
|
81
|
+
<message key="prompt.export_changed">是否导出这些潜艇? [y/N] </message>
|
|
82
|
+
<message key="status.skipped">已跳过: {path}</message>
|
|
83
|
+
<message key="status.no_changed_submarines">没有已改动的注册潜艇</message>
|
|
84
|
+
<message key="status.export_changed_header">已改动的已注册潜艇:</message>
|
|
85
|
+
<message key="status.export_declined">已取消导出</message>
|
|
86
|
+
<message key="status.export_preview">{src} -> {xml}, 预览图: {preview}</message>
|
|
87
|
+
<message key="status.export_no_preview">{src} -> {xml}, 无预览图</message>
|
|
88
|
+
<message key="status.pack">{src} -> {dst}</message>
|
|
89
|
+
<message key="status.unpack">{src} -> {dst} [{kind}, {bytes} 字节]</message>
|
|
90
|
+
<message key="kind.gzip">gzip</message>
|
|
91
|
+
<message key="kind.plain_xml">纯 XML</message>
|
|
92
|
+
<message key="status.git_repo">Git repo: {path}</message>
|
|
93
|
+
|
|
94
|
+
<message key="warning.current_not_repo">当前目录不是 Git 仓库; 使用 {path}</message>
|
|
95
|
+
<message key="warning.no_repo">在模组根目录范围内未找到 Git 仓库; 请运行 'git init' 初始化</message>
|
|
96
|
+
<message key="warning.upper_repo">本项目还存在上级 Git 仓库: {path}</message>
|
|
97
|
+
<message key="warning.preview_missing">找不到预览图: {path}; 预览将留空</message>
|
|
98
|
+
<message key="warning.registry_preview_failed">潜艇{name}预览图读取失败, 按照默认方式读取</message>
|
|
99
|
+
<message key="warning.decode_replace">输入不是有效的 UTF-8/UTF-16, 将使用替换字符按 UTF-8 解码</message>
|
|
100
|
+
|
|
101
|
+
<message key="init.created_gitignore">已创建 {name}; 添加 {patterns}</message>
|
|
102
|
+
<message key="init.updated_gitignore">已更新 {name}; 添加 {patterns}</message>
|
|
103
|
+
<message key="init.gitignore_ok">{name} 已忽略 {patterns}</message>
|
|
104
|
+
<message key="init.config_created">已创建 {name}</message>
|
|
105
|
+
<message key="init.config_updated">已更新 {name}</message>
|
|
106
|
+
<message key="init.config_exists">{name} 已存在</message>
|
|
107
|
+
<message key="init.registered_header">已注册潜艇:</message>
|
|
108
|
+
<message key="init.pending_header">需要添加或修改的潜艇:</message>
|
|
109
|
+
<message key="init.removed_header">需要移除的已注册潜艇:</message>
|
|
110
|
+
<message key="init.registered">已注册 {count} 个潜艇文件</message>
|
|
111
|
+
<message key="init.submarines_skipped">未更新潜艇注册信息</message>
|
|
112
|
+
<message key="init.language_set">语言设置为中文</message>
|
|
113
|
+
<message key="err.init">无法初始化项目: {error}</message>
|
|
114
|
+
<message key="err.not_mod">不是一个 Barotrauma MOD 项目: 在 {path} 及其上级目录中找不到 filelist.xml</message>
|
|
115
|
+
<message key="err.invalid_config">项目配置无效 {path}: {error}</message>
|
|
116
|
+
<message key="err.not_registered">潜艇未在 git4subeditor.xml 中注册: {path}</message>
|
|
117
|
+
<message key="err.invalid_xml">XML 无效: {error}</message>
|
|
118
|
+
<message key="err.invalid_base64">previewimage 不是有效的 base64: {error}</message>
|
|
119
|
+
<message key="err.read_file">无法读取文件 {path}: {error}</message>
|
|
120
|
+
<message key="err.pack_sub_input">{path} 看起来是 .sub 文件; 请先导出为 XML</message>
|
|
121
|
+
<message key="err.read_preview">无法读取预览图 {path}: {error}</message>
|
|
122
|
+
<message key="err.empty_preview">预览图为空: {path}</message>
|
|
123
|
+
<message key="err.create_dir">无法创建输出目录 {path}: {error}</message>
|
|
124
|
+
<message key="err.submarine_root">需要 <Submarine> 根节点, 实际为 <{tag}></message>
|
|
125
|
+
<message key="err.empty_preview_link">preview 链接为空</message>
|
|
126
|
+
<message key="err.not_regular_file">输出路径不是普通文件: {path}</message>
|
|
127
|
+
<message key="err.backup">无法将 {path} 备份到 {backup}: {error}</message>
|
|
128
|
+
<message key="err.output_exists">输出文件已存在: {path} (使用 --force)</message>
|
|
129
|
+
<message key="err.write">无法写入 {path}: {error}</message>
|
|
130
|
+
<message key="err.gzip_compress">gzip 压缩失败: {error}</message>
|
|
131
|
+
<message key="err.gzip_decompress">gzip 解压失败: {error}</message>
|
|
132
|
+
<message key="err.decode">无法按 {encoding} 解码: {error}</message>
|
|
133
|
+
<message key="err.decode_utf16">无法按 UTF-16 解码: {error}</message>
|
|
134
|
+
<message key="err.invalid_decompressed_xml">解压后的数据不是有效 XML: {error}</message>
|
|
135
|
+
</locale>
|