mirror of
https://github.com/drakmor/ShadowMountPlus.git
synced 2026-10-23 07:02:26 -05:00
668 lines
22 KiB
Python
668 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Export one web UI translation catalog from web/index.html to JSONC.
|
|
|
|
The web UI stores its translations in two shapes: ``en-US`` and ``ru-RU`` are
|
|
key/value objects inside ``translations``, while every other locale is a
|
|
positional array inside ``translationRows`` indexed by ``translationKeys``.
|
|
|
|
Both shapes are normalized into a single key/value JSONC file so translators
|
|
work with readable keys instead of array offsets, and each value is preceded by
|
|
a ``// English:`` comment taken from the ``en-US`` catalog.
|
|
|
|
Usage:
|
|
python tools/extract_web_l10n.py ar-SA
|
|
python tools/extract_web_l10n.py ru-RU --byte-units
|
|
python tools/extract_web_l10n.py --all
|
|
|
|
Run tools/merge_web_l10n.py to write the edited JSONC back into web/index.html.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import itertools
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_HTML = ROOT / "web" / "index.html"
|
|
DEFAULT_OUT_DIR = ROOT / "web" / "l10n"
|
|
|
|
BASE_LOCALE = "en-US"
|
|
BYTE_UNITS_SUFFIX = ".byte-units.jsonc"
|
|
BYTE_UNIT_KEYS = ("bytes", "kibibytes", "mebibytes", "gibibytes", "tebibytes")
|
|
BYTE_UNIT_BASE_VALUES = ("B", "KiB", "MiB", "GiB", "TiB")
|
|
|
|
LOCALE_RE = re.compile(r"^[a-z]{2}-[A-Z]{2}$")
|
|
STRING_RE = re.compile(r'"(?:\\.|[^"\\])*"')
|
|
OPTION_RE = re.compile(r'<option value="([a-z]{2}-[A-Z]{2})">([^<]*)</option>')
|
|
PAIRS = {"{": "}", "[": "]"}
|
|
IDENTIFIER_START = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_$")
|
|
IDENTIFIER_BODY = IDENTIFIER_START | set("0123456789")
|
|
SIMPLE_ESCAPES = {
|
|
"b": "\b",
|
|
"f": "\f",
|
|
"n": "\n",
|
|
"r": "\r",
|
|
"t": "\t",
|
|
"v": "\v",
|
|
"0": "\0",
|
|
}
|
|
|
|
|
|
class L10nError(Exception):
|
|
"""Raised when web/index.html cannot be parsed."""
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class JsEntry:
|
|
"""One ``key: value`` pair inside a JavaScript object literal."""
|
|
|
|
key: str
|
|
key_start: int
|
|
start: int
|
|
end: int
|
|
indent: str
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class JsBlock:
|
|
"""A locale entry whose value is itself an object or array literal."""
|
|
|
|
name: str
|
|
start: int
|
|
end: int
|
|
indent: str
|
|
entries: list[JsEntry]
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class Document:
|
|
"""The parsed localization state of web/index.html."""
|
|
|
|
text: str
|
|
order: list[str]
|
|
object_locales: dict[str, list[tuple[str, str]]]
|
|
object_indents: dict[str, tuple[str, str]]
|
|
row_locales: dict[str, list[str]]
|
|
row_indents: dict[str, str]
|
|
byte_units: dict[str, list[str]]
|
|
byte_unit_indents: dict[str, str]
|
|
row_separator: str
|
|
unit_separator: str
|
|
labels: dict[str, str]
|
|
spans: dict[str, tuple[int, int]]
|
|
|
|
@property
|
|
def locales(self) -> list[str]:
|
|
return [*self.object_locales, *self.row_locales]
|
|
|
|
@property
|
|
def orphan_keys(self) -> list[str]:
|
|
"""Keys present in the object-form locales but absent from translationKeys."""
|
|
base = self.object_locales.get(BASE_LOCALE, [])
|
|
return [key for key, _ in base if key not in self.order]
|
|
|
|
|
|
def read_source(path: Path) -> tuple[str, str]:
|
|
"""Return the file contents normalized to LF plus its original line ending."""
|
|
try:
|
|
raw = path.read_bytes()
|
|
except OSError as exc:
|
|
raise L10nError(f"cannot read {path}: {exc}") from exc
|
|
try:
|
|
if raw.startswith(b"\xef\xbb\xbf"):
|
|
raw = raw[3:]
|
|
text = raw.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise L10nError(f"{path} is not valid UTF-8: {exc}") from exc
|
|
newline = "\r\n" if "\r\n" in text else "\n"
|
|
return text.replace("\r\n", "\n"), newline
|
|
|
|
|
|
def write_text(path: Path, text: str, newline: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(text.replace("\n", newline).encode("utf-8"))
|
|
|
|
|
|
def _skip_string(text: str, index: int) -> int:
|
|
"""Return the offset just past the string literal starting at ``index``."""
|
|
quote = text[index]
|
|
cursor = index + 1
|
|
limit = len(text)
|
|
while cursor < limit:
|
|
char = text[cursor]
|
|
if char == "\\":
|
|
cursor += 2
|
|
continue
|
|
if char == quote:
|
|
return cursor + 1
|
|
cursor += 1
|
|
raise L10nError(f"unterminated {quote} string at offset {index}")
|
|
|
|
|
|
def _skip_comment(text: str, index: int) -> int:
|
|
if text[index : index + 2] == "//":
|
|
end = text.find("\n", index)
|
|
return len(text) if end < 0 else end + 1
|
|
end = text.find("*/", index + 2)
|
|
if end < 0:
|
|
raise L10nError(f"unterminated block comment at offset {index}")
|
|
return end + 2
|
|
|
|
|
|
def _skip_whitespace(text: str, index: int) -> int:
|
|
cursor = index
|
|
limit = len(text)
|
|
while cursor < limit:
|
|
if text[cursor].isspace():
|
|
cursor += 1
|
|
elif text[cursor : cursor + 2] in ("//", "/*"):
|
|
cursor = _skip_comment(text, cursor)
|
|
else:
|
|
break
|
|
return cursor
|
|
|
|
|
|
def find_block(text: str, start: int) -> int:
|
|
"""Return the offset of the delimiter closing the block at ``start``."""
|
|
if text[start] not in PAIRS:
|
|
raise L10nError(f"expected a block at offset {start}, found {text[start]!r}")
|
|
stack = [PAIRS[text[start]]]
|
|
cursor = start + 1
|
|
limit = len(text)
|
|
while cursor < limit and stack:
|
|
char = text[cursor]
|
|
if char in "\"'`":
|
|
cursor = _skip_string(text, cursor)
|
|
continue
|
|
if char == "/" and text[cursor : cursor + 2] in ("//", "/*"):
|
|
cursor = _skip_comment(text, cursor)
|
|
continue
|
|
if char in PAIRS:
|
|
stack.append(PAIRS[char])
|
|
elif char in ("}", "]"):
|
|
if char != stack[-1]:
|
|
raise L10nError(f"mismatched {char!r} at offset {cursor}")
|
|
stack.pop()
|
|
cursor += 1
|
|
if stack:
|
|
raise L10nError(f"unterminated block at offset {start}")
|
|
return cursor - 1
|
|
|
|
|
|
def decode_js_string(token: str) -> str:
|
|
"""Decode a JavaScript string literal into a Python string."""
|
|
quote = token[0]
|
|
if quote == '"':
|
|
try:
|
|
return json.loads(token)
|
|
except json.JSONDecodeError as exc:
|
|
raise L10nError(f"invalid string literal: {exc}") from exc
|
|
if quote not in ("'", "`"):
|
|
raise L10nError(f"expected a string literal, found {token[:32]!r}")
|
|
body = token[1:-1]
|
|
out: list[str] = []
|
|
cursor = 0
|
|
while cursor < len(body):
|
|
char = body[cursor]
|
|
if char != "\\":
|
|
out.append(char)
|
|
cursor += 1
|
|
continue
|
|
cursor += 1
|
|
if cursor >= len(body):
|
|
raise L10nError("dangling escape in string literal")
|
|
escape = body[cursor]
|
|
if escape == "u":
|
|
if body[cursor + 1 : cursor + 2] == "{":
|
|
end = body.index("}", cursor)
|
|
out.append(chr(int(body[cursor + 2 : end], 16)))
|
|
cursor = end + 1
|
|
else:
|
|
out.append(chr(int(body[cursor + 1 : cursor + 5], 16)))
|
|
cursor += 5
|
|
continue
|
|
if escape == "x":
|
|
out.append(chr(int(body[cursor + 1 : cursor + 3], 16)))
|
|
cursor += 3
|
|
continue
|
|
out.append(SIMPLE_ESCAPES.get(escape, escape))
|
|
cursor += 1
|
|
return "".join(out)
|
|
|
|
|
|
def string_separator(text: str, start: int, end: int) -> str:
|
|
"""Return the separator used between array items, "," or ", "."""
|
|
matches = list(STRING_RE.finditer(text, start, end))
|
|
for previous, current in itertools.pairwise(matches):
|
|
gap = text[previous.end() : current.start()]
|
|
if gap in (",", ", "):
|
|
return gap
|
|
return ","
|
|
|
|
|
|
def js_string(value: str) -> str:
|
|
"""Encode a Python string as a double-quoted JavaScript string literal."""
|
|
return json.dumps(value, ensure_ascii=False)
|
|
|
|
|
|
def line_indent(text: str, index: int) -> str:
|
|
"""Return the whitespace between the start of the line and ``index``."""
|
|
start = text.rfind("\n", 0, index) + 1
|
|
prefix = text[start:index]
|
|
return prefix if prefix.strip() == "" else ""
|
|
|
|
|
|
def closing_indent(text: str, close: int) -> str:
|
|
"""Return the indentation of the line holding the closing delimiter."""
|
|
start = text.rfind("\n", 0, close) + 1
|
|
prefix = text[start:close]
|
|
if prefix.strip():
|
|
raise L10nError(f"closing delimiter at offset {close} is not on its own line")
|
|
return prefix
|
|
|
|
|
|
def value_end(text: str, index: int) -> int:
|
|
"""Return the offset just past the value starting at ``index``."""
|
|
char = text[index]
|
|
if char in PAIRS:
|
|
return find_block(text, index) + 1
|
|
if char in "\"'`":
|
|
return _skip_string(text, index)
|
|
cursor = index
|
|
while cursor < len(text) and text[cursor] not in ",}]:\n \t":
|
|
cursor += 1
|
|
return cursor
|
|
|
|
|
|
def object_entries(text: str, open_index: int) -> tuple[list[JsEntry], int]:
|
|
"""Parse the top-level ``key: value`` pairs of an object literal."""
|
|
close = find_block(text, open_index)
|
|
entries: list[JsEntry] = []
|
|
cursor = open_index + 1
|
|
while cursor < close:
|
|
char = text[cursor]
|
|
if char.isspace() or char == ",":
|
|
cursor += 1
|
|
continue
|
|
if char == "/" and text[cursor : cursor + 2] in ("//", "/*"):
|
|
cursor = _skip_comment(text, cursor)
|
|
continue
|
|
if char in "\"'`" or char in IDENTIFIER_START:
|
|
key_start = cursor
|
|
if char in "\"'`":
|
|
key_end = _skip_string(text, cursor)
|
|
key = decode_js_string(text[key_start:key_end])
|
|
cursor = key_end
|
|
else:
|
|
cursor += 1
|
|
while cursor < close and text[cursor] in IDENTIFIER_BODY:
|
|
cursor += 1
|
|
key = text[key_start:cursor]
|
|
cursor = _skip_whitespace(text, cursor)
|
|
if cursor >= close or text[cursor] != ":":
|
|
raise L10nError(f"expected ':' after key {key!r} at offset {cursor}")
|
|
cursor = _skip_whitespace(text, cursor + 1)
|
|
start = cursor
|
|
end = value_end(text, cursor)
|
|
entries.append(
|
|
JsEntry(key, key_start, start, end, line_indent(text, key_start))
|
|
)
|
|
cursor = end
|
|
continue
|
|
raise L10nError(
|
|
f"unexpected {char!r} at offset {cursor} inside an object literal"
|
|
)
|
|
return entries, close
|
|
|
|
|
|
def const_value_offset(text: str, name: str) -> int:
|
|
"""Return the offset of the value assigned to ``const <name>``."""
|
|
match = re.search(rf"\bconst\s+{re.escape(name)}\s*=\s*", text)
|
|
if not match:
|
|
raise L10nError(f"const {name} not found in web/index.html")
|
|
offset = match.end()
|
|
if offset >= len(text) or text[offset] not in PAIRS:
|
|
raise L10nError(f"const {name} is not initialized with a block")
|
|
return offset
|
|
|
|
|
|
def parse_document(text: str) -> Document:
|
|
"""Parse every localization structure out of web/index.html."""
|
|
keys_open = const_value_offset(text, "translationKeys")
|
|
keys_close = find_block(text, keys_open)
|
|
order = [
|
|
decode_js_string(token)
|
|
for token in STRING_RE.findall(text[keys_open + 1 : keys_close])
|
|
]
|
|
if len(set(order)) != len(order):
|
|
raise L10nError("translationKeys contains duplicate entries")
|
|
|
|
translations_open = const_value_offset(text, "translations")
|
|
translations_close = find_block(text, translations_open)
|
|
translations, _ = object_entries(text, translations_open)
|
|
|
|
object_locales: dict[str, list[tuple[str, str]]] = {}
|
|
object_indents: dict[str, tuple[str, str]] = {}
|
|
for entry in translations:
|
|
if text[entry.start] != "{":
|
|
raise L10nError(
|
|
f"translations[{entry.key}] must be an object literal, found "
|
|
f"{text[entry.start]!r}"
|
|
)
|
|
inner, _ = object_entries(text, entry.start)
|
|
pairs: list[tuple[str, str]] = []
|
|
for item in inner:
|
|
if text[item.start] not in "\"'`":
|
|
raise L10nError(
|
|
f"translations[{entry.key}].{item.key} must be a string literal"
|
|
)
|
|
pairs.append((item.key, decode_js_string(text[item.start : item.end])))
|
|
duplicates = [key for key, _ in pairs if [k for k, _ in pairs].count(key) > 1]
|
|
if duplicates:
|
|
raise L10nError(
|
|
f"translations[{entry.key}] has duplicate keys: {sorted(set(duplicates))}"
|
|
)
|
|
key_indent = inner[0].indent if inner else entry.indent + " "
|
|
object_locales[entry.key] = pairs
|
|
object_indents[entry.key] = (entry.indent, key_indent)
|
|
|
|
rows_open = const_value_offset(text, "translationRows")
|
|
rows_close = find_block(text, rows_open)
|
|
rows, _ = object_entries(text, rows_open)
|
|
|
|
row_locales: dict[str, list[str]] = {}
|
|
row_indents: dict[str, str] = {}
|
|
for entry in rows:
|
|
if text[entry.start] != "[":
|
|
raise L10nError(
|
|
f"translationRows[{entry.key}] must be an array literal, found "
|
|
f"{text[entry.start]!r}"
|
|
)
|
|
values = [
|
|
decode_js_string(token)
|
|
for token in STRING_RE.findall(
|
|
text[entry.start + 1 : find_block(text, entry.start)]
|
|
)
|
|
]
|
|
if len(values) != len(order):
|
|
raise L10nError(
|
|
f"translationRows[{entry.key}] has {len(values)} values but "
|
|
f"translationKeys has {len(order)}"
|
|
)
|
|
row_locales[entry.key] = values
|
|
row_indents[entry.key] = entry.indent
|
|
|
|
units_open = const_value_offset(text, "byteUnits")
|
|
units_close = find_block(text, units_open)
|
|
units, _ = object_entries(text, units_open)
|
|
|
|
byte_units: dict[str, list[str]] = {}
|
|
byte_unit_indents: dict[str, str] = {}
|
|
for entry in units:
|
|
if text[entry.start] != "[":
|
|
raise L10nError(
|
|
f"byteUnits[{entry.key}] must be an array literal, found "
|
|
f"{text[entry.start]!r}"
|
|
)
|
|
values = [
|
|
decode_js_string(token)
|
|
for token in STRING_RE.findall(
|
|
text[entry.start + 1 : find_block(text, entry.start)]
|
|
)
|
|
]
|
|
if len(values) != len(BYTE_UNIT_KEYS):
|
|
raise L10nError(
|
|
f"byteUnits[{entry.key}] has {len(values)} values but "
|
|
f"{len(BYTE_UNIT_KEYS)} were expected"
|
|
)
|
|
byte_units[entry.key] = values
|
|
byte_unit_indents[entry.key] = entry.indent
|
|
|
|
base_units = byte_units.get(BASE_LOCALE)
|
|
if base_units is not None and tuple(base_units) != BYTE_UNIT_BASE_VALUES:
|
|
raise L10nError(
|
|
f"byteUnits[{BASE_LOCALE}] is {base_units}, expected "
|
|
f"{list(BYTE_UNIT_BASE_VALUES)}"
|
|
)
|
|
|
|
labels = dict(OPTION_RE.findall(text))
|
|
|
|
return Document(
|
|
text=text,
|
|
order=order,
|
|
object_locales=object_locales,
|
|
object_indents=object_indents,
|
|
row_locales=row_locales,
|
|
row_indents=row_indents,
|
|
byte_units=byte_units,
|
|
byte_unit_indents=byte_unit_indents,
|
|
row_separator=string_separator(text, rows_open, rows_close),
|
|
unit_separator=string_separator(text, units_open, units_close),
|
|
labels=labels,
|
|
spans={
|
|
"translations": (translations_open, translations_close + 1),
|
|
"translationRows": (rows_open, rows_close + 1),
|
|
"byteUnits": (units_open, units_close + 1),
|
|
},
|
|
)
|
|
|
|
|
|
def english_comment(value: str) -> str:
|
|
"""Escape a value so it is safe inside a single-line // comment."""
|
|
return (
|
|
value.replace("\\", "\\\\")
|
|
.replace("\n", "\\n")
|
|
.replace("\r", "\\r")
|
|
.replace("\t", "\\t")
|
|
)
|
|
|
|
|
|
def render_catalog(
|
|
document: Document,
|
|
locale: str,
|
|
pairs: list[tuple[str, str]],
|
|
english: dict[str, str],
|
|
title: str,
|
|
command: str,
|
|
) -> str:
|
|
"""Render one locale as JSONC with an English comment above every value."""
|
|
lines = [
|
|
f"// ShadowMount+ web UI {title} - {locale}",
|
|
f"// {document.labels.get(locale, 'Unknown language')}",
|
|
"//",
|
|
"// Generated by tools/extract_web_l10n.py from web/index.html.",
|
|
f"// Edit the values, then run: {command}",
|
|
"//",
|
|
f'// Each entry is preceded by its "{BASE_LOCALE}" reference.',
|
|
"// Keep placeholders such as {title} or {shown} exactly as written.",
|
|
"",
|
|
"{",
|
|
]
|
|
for key, value in pairs:
|
|
reference = english.get(key)
|
|
if reference is not None:
|
|
lines.append(f" // English: {english_comment(reference)}")
|
|
lines.append(f" {js_string(key)}: {js_string(value)},")
|
|
lines[-1] = lines[-1][:-1]
|
|
lines.append("}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def byte_unit_pairs(values: list[str] | None) -> list[tuple[str, str]]:
|
|
if values is None:
|
|
return [(key, "") for key in BYTE_UNIT_KEYS]
|
|
return list(zip(BYTE_UNIT_KEYS, values))
|
|
|
|
|
|
def catalog_pairs(document: Document, locale: str) -> list[tuple[str, str]]:
|
|
"""Return the key/value pairs of ``locale``.
|
|
|
|
Array locales deliberately omit the keys that exist only in the object-form
|
|
locales. ``translationKeys`` has no slot for them, so a value could never be
|
|
written back; emitting empty entries would only invite a translator to fill
|
|
in something that would then be rejected. They stay English-only.
|
|
"""
|
|
if locale in document.object_locales:
|
|
return list(document.object_locales[locale])
|
|
return list(zip(document.order, document.row_locales[locale]))
|
|
|
|
|
|
def english_reference(document: Document) -> dict[str, str]:
|
|
base = document.object_locales.get(BASE_LOCALE)
|
|
if base is None:
|
|
raise L10nError(f'translations["{BASE_LOCALE}"] is required as the reference')
|
|
return dict(base)
|
|
|
|
|
|
def byte_unit_reference(document: Document) -> dict[str, str]:
|
|
values = document.byte_units.get(BASE_LOCALE)
|
|
if values is None:
|
|
raise L10nError(f'byteUnits["{BASE_LOCALE}"] is required as the reference')
|
|
return dict(zip(BYTE_UNIT_KEYS, values))
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"locales",
|
|
nargs="*",
|
|
metavar="LOCALE",
|
|
help=f"locale to export, for example {BASE_LOCALE} or ar-SA",
|
|
)
|
|
parser.add_argument(
|
|
"--all",
|
|
action="store_true",
|
|
dest="all_locales",
|
|
help="export every locale found in web/index.html",
|
|
)
|
|
parser.add_argument(
|
|
"--byte-units",
|
|
action="store_true",
|
|
dest="byte_units",
|
|
help=f"also export the byte unit catalog as <LOCALE>{BYTE_UNITS_SUFFIX}; "
|
|
"with --all only locales that already define byte units are exported, "
|
|
"while naming a locale exports a blank template to fill in",
|
|
)
|
|
parser.add_argument(
|
|
"--html", type=Path, default=DEFAULT_HTML, help="source web/index.html"
|
|
)
|
|
parser.add_argument(
|
|
"--out-dir",
|
|
type=Path,
|
|
default=DEFAULT_OUT_DIR,
|
|
help="directory that receives the generated JSONC files",
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="do not write anything; exit 1 if any file is out of date",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
if args.all_locales and args.locales:
|
|
print("error: pass locales or --all, not both", file=sys.stderr)
|
|
return 1
|
|
if not args.all_locales and not args.locales:
|
|
print("error: no locale given; pass a locale or --all", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
text, newline = read_source(args.html)
|
|
document = parse_document(text)
|
|
except L10nError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
if args.all_locales:
|
|
locales = document.locales
|
|
else:
|
|
locales = list(dict.fromkeys(args.locales))
|
|
|
|
errors: list[str] = []
|
|
for locale in locales:
|
|
if not LOCALE_RE.match(locale):
|
|
errors.append(f"{locale}: not a locale such as ar-SA")
|
|
continue
|
|
if locale not in document.object_locales and locale not in document.row_locales:
|
|
available = ", ".join(document.locales)
|
|
errors.append(f"{locale}: unknown locale; available locales: {available}")
|
|
continue
|
|
|
|
try:
|
|
english = english_reference(document)
|
|
except L10nError as exc:
|
|
errors.append(str(exc))
|
|
break
|
|
|
|
path = args.out_dir / f"{locale}.jsonc"
|
|
content = render_catalog(
|
|
document,
|
|
locale,
|
|
catalog_pairs(document, locale),
|
|
english,
|
|
"translations",
|
|
f"python tools/merge_web_l10n.py {locale}",
|
|
)
|
|
if not emit(path, content, newline, args.check):
|
|
errors.append(f"{locale}: {path.name} is out of date")
|
|
|
|
if args.byte_units and (locale in document.byte_units or not args.all_locales):
|
|
units = byte_unit_pairs(document.byte_units.get(locale))
|
|
unit_path = args.out_dir / f"{locale}{BYTE_UNITS_SUFFIX}"
|
|
try:
|
|
unit_english = byte_unit_reference(document)
|
|
except L10nError as exc:
|
|
errors.append(str(exc))
|
|
continue
|
|
unit_content = render_catalog(
|
|
document,
|
|
locale,
|
|
units,
|
|
unit_english,
|
|
"byte units",
|
|
f"python tools/merge_web_l10n.py {locale} --byte-units",
|
|
)
|
|
if not emit(unit_path, unit_content, newline, args.check):
|
|
errors.append(f"{locale}: {unit_path.name} is out of date")
|
|
|
|
if errors:
|
|
print("Extraction failed:", file=sys.stderr)
|
|
for error in errors:
|
|
print(f" - {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
verb = "checked" if args.check else "wrote"
|
|
print(f"{verb} {len(locales)} locale(s) in {args.out_dir}")
|
|
return 0
|
|
|
|
|
|
def emit(path: Path, content: str, newline: str, check: bool) -> bool:
|
|
"""Write ``content`` unless --check was given; report whether it is current."""
|
|
if check:
|
|
try:
|
|
current, _ = read_source(path)
|
|
except L10nError:
|
|
return False
|
|
return current == content
|
|
write_text(path, content, newline)
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
raise SystemExit(main())
|