Implement i18n audit/localization cleanup and sync libstorm submodule
This commit is contained in:
Executable
+401
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ALLOWLIST_PATH = ROOT / "scripts" / "i18n_audit_allowlist.txt"
|
||||
|
||||
SKIP_DIR_NAMES = {".git", "bloodshed", "docs", "skills", "nvgt-git", "libstorm-nvgt"}
|
||||
SKIP_FILE_NAMES = {"crash.log"}
|
||||
|
||||
INSERT_LAST_CONTEXT_HINTS = (
|
||||
"option",
|
||||
"label",
|
||||
"line",
|
||||
"prompt",
|
||||
"instruction",
|
||||
"intro",
|
||||
"reward",
|
||||
"message",
|
||||
"title",
|
||||
"menu",
|
||||
)
|
||||
|
||||
TRANSLATION_WRAPPERS = (
|
||||
"tr(",
|
||||
"trf(",
|
||||
"trn(",
|
||||
"i18n_translate_speech_message(",
|
||||
"i18n_lookup_key_with_fallback(",
|
||||
"speech_history_transform_message(",
|
||||
"get_barricade_option_text(",
|
||||
"i18n_text(",
|
||||
)
|
||||
|
||||
# Function call checks for call arguments that must be translation-wrapped when they
|
||||
# contain literals. Keep this conservative and focused on user-facing text paths.
|
||||
ARG_CHECKS: Dict[str, List[int]] = {
|
||||
"screen_reader_speak": [0],
|
||||
"menu_run_simple": [0],
|
||||
"text_reader": [0, 1],
|
||||
"text_reader_lines": [1],
|
||||
"text_reader_file": [1],
|
||||
"file_viewer": [0, 1],
|
||||
"file_viewer_lines": [1],
|
||||
"file_viewer_file": [1],
|
||||
}
|
||||
|
||||
ASSIGNMENT_LHS_CHECKS = (
|
||||
"intro_text",
|
||||
)
|
||||
|
||||
|
||||
class Finding:
|
||||
def __init__(self, path: Path, line: int, context: str, expression: str):
|
||||
self.path = path
|
||||
self.line = line
|
||||
self.context = context
|
||||
self.expression = expression.strip()
|
||||
|
||||
def key(self) -> str:
|
||||
return f"{self.path.relative_to(ROOT).as_posix()}:{self.line}:{self.context}"
|
||||
|
||||
|
||||
def iter_nvgt_files() -> List[Path]:
|
||||
files: List[Path] = []
|
||||
|
||||
entrypoints = [ROOT / "draugnorak.nvgt", ROOT / "excluded_sounds.nvgt"]
|
||||
for entry in entrypoints:
|
||||
if entry.exists():
|
||||
files.append(entry)
|
||||
|
||||
source_roots = [ROOT / "src", ROOT / "libstorm-nvgt"]
|
||||
for source_root in source_roots:
|
||||
if not source_root.exists():
|
||||
continue
|
||||
for path in source_root.rglob("*.nvgt"):
|
||||
rel = path.relative_to(ROOT)
|
||||
if any(part in SKIP_DIR_NAMES for part in rel.parts):
|
||||
continue
|
||||
if path.name in SKIP_FILE_NAMES:
|
||||
continue
|
||||
files.append(path)
|
||||
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def load_allowlist() -> Set[str]:
|
||||
allowed: Set[str] = set()
|
||||
if not ALLOWLIST_PATH.exists():
|
||||
return allowed
|
||||
|
||||
for raw_line in ALLOWLIST_PATH.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
allowed.add(line)
|
||||
return allowed
|
||||
|
||||
|
||||
def is_identifier_char(ch: str) -> bool:
|
||||
return ch.isalnum() or ch == "_"
|
||||
|
||||
|
||||
def read_identifier_backward(text: str, before_index: int) -> str:
|
||||
i = before_index
|
||||
while i >= 0 and text[i].isspace():
|
||||
i -= 1
|
||||
end = i
|
||||
while i >= 0 and is_identifier_char(text[i]):
|
||||
i -= 1
|
||||
start = i + 1
|
||||
if end < start:
|
||||
return ""
|
||||
return text[start : end + 1]
|
||||
|
||||
|
||||
def find_matching_paren(text: str, open_index: int) -> int:
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
|
||||
for i in range(open_index, len(text)):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
continue
|
||||
if ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
continue
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def split_top_level(expr: str, delimiter: str) -> List[str]:
|
||||
parts: List[str] = []
|
||||
depth_paren = 0
|
||||
depth_bracket = 0
|
||||
depth_brace = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
start = 0
|
||||
|
||||
for i, ch in enumerate(expr):
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
if ch == "(":
|
||||
depth_paren += 1
|
||||
continue
|
||||
if ch == ")":
|
||||
depth_paren = max(0, depth_paren - 1)
|
||||
continue
|
||||
if ch == "[":
|
||||
depth_bracket += 1
|
||||
continue
|
||||
if ch == "]":
|
||||
depth_bracket = max(0, depth_bracket - 1)
|
||||
continue
|
||||
if ch == "{":
|
||||
depth_brace += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
depth_brace = max(0, depth_brace - 1)
|
||||
continue
|
||||
|
||||
if ch == delimiter and depth_paren == 0 and depth_bracket == 0 and depth_brace == 0:
|
||||
parts.append(expr[start:i])
|
||||
start = i + 1
|
||||
|
||||
parts.append(expr[start:])
|
||||
return parts
|
||||
|
||||
|
||||
def line_number_for_index(text: str, index: int) -> int:
|
||||
return text.count("\n", 0, index) + 1
|
||||
|
||||
|
||||
def has_string_literal(expr: str) -> bool:
|
||||
return len(extract_string_literals(expr)) > 0
|
||||
|
||||
|
||||
def extract_string_literals(expr: str) -> List[str]:
|
||||
literals: List[str] = []
|
||||
in_string = False
|
||||
escape = False
|
||||
current: List[str] = []
|
||||
|
||||
for ch in expr:
|
||||
if in_string:
|
||||
if escape:
|
||||
current.append(ch)
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
literals.append("".join(current))
|
||||
current = []
|
||||
continue
|
||||
current.append(ch)
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
|
||||
return literals
|
||||
|
||||
|
||||
def has_meaningful_literal(expr: str) -> bool:
|
||||
literals = extract_string_literals(expr)
|
||||
if not literals:
|
||||
return False
|
||||
|
||||
for literal in literals:
|
||||
if literal == "":
|
||||
continue
|
||||
if re.fullmatch(r"[\s:,.!?;()\-\[\]/+]*", literal):
|
||||
continue
|
||||
if re.fullmatch(r"[a-z0-9_.-]+", literal):
|
||||
# Translation keys and identifiers are not user-facing copy.
|
||||
continue
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_translated_expression(expr: str) -> bool:
|
||||
normalized = "".join(expr.split())
|
||||
for wrapper in TRANSLATION_WRAPPERS:
|
||||
if wrapper in normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_check_insert_last(receiver: str) -> bool:
|
||||
receiver_lower = receiver.lower()
|
||||
if not receiver_lower:
|
||||
return False
|
||||
return any(hint in receiver_lower for hint in INSERT_LAST_CONTEXT_HINTS)
|
||||
|
||||
|
||||
def add_finding(findings: List[Finding], path: Path, line: int, context: str, expr: str) -> None:
|
||||
findings.append(Finding(path, line, context, expr))
|
||||
|
||||
|
||||
def check_call_args(path: Path, line: int, function_name: str, receiver: str, args: List[str], findings: List[Finding],
|
||||
translated_arrays: Set[str]) -> None:
|
||||
if function_name == "insert_last":
|
||||
if not should_check_insert_last(receiver):
|
||||
return
|
||||
if receiver in translated_arrays:
|
||||
return
|
||||
if not args:
|
||||
return
|
||||
expr = args[0]
|
||||
if has_meaningful_literal(expr) and not is_translated_expression(expr):
|
||||
add_finding(findings, path, line, f"{receiver}.insert_last", expr)
|
||||
return
|
||||
|
||||
target_indexes = ARG_CHECKS.get(function_name)
|
||||
if not target_indexes:
|
||||
return
|
||||
|
||||
for arg_index in target_indexes:
|
||||
if arg_index >= len(args):
|
||||
continue
|
||||
expr = args[arg_index]
|
||||
if has_meaningful_literal(expr) and not is_translated_expression(expr):
|
||||
add_finding(findings, path, line, f"{function_name}[{arg_index}]", expr)
|
||||
|
||||
|
||||
def check_assignment_literals(path: Path, text: str, findings: List[Finding]) -> None:
|
||||
for lhs in ASSIGNMENT_LHS_CHECKS:
|
||||
pattern = re.compile(rf"\b{re.escape(lhs)}\s*=\s*(.+?);", re.MULTILINE)
|
||||
for match in pattern.finditer(text):
|
||||
expr = match.group(1)
|
||||
if not has_meaningful_literal(expr):
|
||||
continue
|
||||
if is_translated_expression(expr):
|
||||
continue
|
||||
line = line_number_for_index(text, match.start())
|
||||
add_finding(findings, path, line, f"assign:{lhs}", expr)
|
||||
|
||||
|
||||
def scan_file(path: Path) -> List[Finding]:
|
||||
findings: List[Finding] = []
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
translated_arrays = set(re.findall(r"i18n_translate_string_array_in_place\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)", text))
|
||||
|
||||
check_assignment_literals(path, text, findings)
|
||||
|
||||
i = 0
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if not is_identifier_char(ch):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
start = i
|
||||
while i < len(text) and is_identifier_char(text[i]):
|
||||
i += 1
|
||||
name = text[start:i]
|
||||
|
||||
j = i
|
||||
while j < len(text) and text[j].isspace():
|
||||
j += 1
|
||||
|
||||
if j >= len(text) or text[j] != "(":
|
||||
continue
|
||||
|
||||
receiver = ""
|
||||
k = start - 1
|
||||
while k >= 0 and text[k].isspace():
|
||||
k -= 1
|
||||
if k >= 0 and text[k] == ".":
|
||||
receiver = read_identifier_backward(text, k - 1)
|
||||
|
||||
close = find_matching_paren(text, j)
|
||||
if close < 0:
|
||||
break
|
||||
|
||||
arg_text = text[j + 1 : close]
|
||||
args = split_top_level(arg_text, ",")
|
||||
|
||||
tail = close + 1
|
||||
while tail < len(text) and text[tail].isspace():
|
||||
tail += 1
|
||||
if tail < len(text) and text[tail] == "{":
|
||||
# Function/method declaration, not a call site.
|
||||
i = close + 1
|
||||
continue
|
||||
|
||||
line = line_number_for_index(text, start)
|
||||
check_call_args(path, line, name, receiver, args, findings, translated_arrays)
|
||||
|
||||
i = close + 1
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def summarize_expression(expr: str) -> str:
|
||||
collapsed = " ".join(expr.split())
|
||||
if len(collapsed) > 120:
|
||||
return collapsed[:117] + "..."
|
||||
return collapsed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
allowlist = load_allowlist()
|
||||
|
||||
all_findings: List[Finding] = []
|
||||
for nvgt_file in iter_nvgt_files():
|
||||
all_findings.extend(scan_file(nvgt_file))
|
||||
|
||||
filtered = [f for f in all_findings if f.key() not in allowlist]
|
||||
filtered.sort(key=lambda item: (item.path.as_posix(), item.line, item.context))
|
||||
|
||||
if not filtered:
|
||||
print("No untranslated-string violations found.")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(filtered)} untranslated-string violations:")
|
||||
for finding in filtered:
|
||||
rel = finding.path.relative_to(ROOT).as_posix()
|
||||
print(f"{rel}:{finding.line}: {finding.context}: {summarize_expression(finding.expression)}")
|
||||
|
||||
print("\nAdd approved exceptions to scripts/i18n_audit_allowlist.txt if needed.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,660 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
TARGET_FUNCTION_ARGS = {
|
||||
"speak_with_history": [0],
|
||||
"screen_reader_speak": [0],
|
||||
"notify": [0],
|
||||
"learn_sounds_speak": [0],
|
||||
"learn_sounds_add_description": [1],
|
||||
"notifications_speak": [0],
|
||||
"ui_info_box": [0, 1, 2],
|
||||
"ui_question": [0, 1],
|
||||
"ui_input_box": [0, 1],
|
||||
"virtual_info_box": [0, 1, 2],
|
||||
"virtual_question": [0, 1],
|
||||
"virtual_input_box": [0, 1],
|
||||
"text_reader": [1],
|
||||
"text_reader_lines": [1],
|
||||
"text_reader_file": [1],
|
||||
}
|
||||
|
||||
INSERT_LAST_CONTEXT_HINTS = (
|
||||
"option",
|
||||
"label",
|
||||
"line",
|
||||
"prompt",
|
||||
"instruction",
|
||||
"intro",
|
||||
"reward",
|
||||
"message",
|
||||
"title",
|
||||
"menu",
|
||||
)
|
||||
|
||||
PASS_THROUGH_TEXT_FUNCTIONS = {
|
||||
"i18n_text",
|
||||
"i18n_translate_speech_message",
|
||||
}
|
||||
|
||||
SKIP_DIR_NAMES = {".git", "bloodshed", "docs", "skills", "nvgt-git"}
|
||||
SKIP_FILE_NAMES = {"crash.log"}
|
||||
|
||||
|
||||
def iter_nvgt_files() -> List[Path]:
|
||||
files: List[Path] = []
|
||||
|
||||
entrypoints = [ROOT / "draugnorak.nvgt", ROOT / "excluded_sounds.nvgt"]
|
||||
for entry in entrypoints:
|
||||
if entry.exists():
|
||||
files.append(entry)
|
||||
|
||||
source_roots = [ROOT / "src", ROOT / "libstorm-nvgt"]
|
||||
for source_root in source_roots:
|
||||
if not source_root.exists():
|
||||
continue
|
||||
for path in source_root.rglob("*.nvgt"):
|
||||
rel = path.relative_to(ROOT)
|
||||
if any(part in SKIP_DIR_NAMES for part in rel.parts):
|
||||
continue
|
||||
if path.name in SKIP_FILE_NAMES:
|
||||
continue
|
||||
files.append(path)
|
||||
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def is_identifier_char(ch: str) -> bool:
|
||||
return ch.isalnum() or ch == "_"
|
||||
|
||||
|
||||
def read_identifier_backward(text: str, before_index: int) -> str:
|
||||
i = before_index
|
||||
while i >= 0 and text[i].isspace():
|
||||
i -= 1
|
||||
end = i
|
||||
while i >= 0 and is_identifier_char(text[i]):
|
||||
i -= 1
|
||||
start = i + 1
|
||||
if end < start:
|
||||
return ""
|
||||
return text[start : end + 1]
|
||||
|
||||
|
||||
def find_matching_paren(text: str, open_index: int) -> int:
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
|
||||
for i in range(open_index, len(text)):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
continue
|
||||
if ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
continue
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def split_top_level(expr: str, delimiter: str) -> List[str]:
|
||||
parts: List[str] = []
|
||||
depth_paren = 0
|
||||
depth_bracket = 0
|
||||
depth_brace = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
start = 0
|
||||
|
||||
for i, ch in enumerate(expr):
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
if ch == "(":
|
||||
depth_paren += 1
|
||||
continue
|
||||
if ch == ")":
|
||||
depth_paren = max(0, depth_paren - 1)
|
||||
continue
|
||||
if ch == "[":
|
||||
depth_bracket += 1
|
||||
continue
|
||||
if ch == "]":
|
||||
depth_bracket = max(0, depth_bracket - 1)
|
||||
continue
|
||||
if ch == "{":
|
||||
depth_brace += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
depth_brace = max(0, depth_brace - 1)
|
||||
continue
|
||||
|
||||
if ch == delimiter and depth_paren == 0 and depth_bracket == 0 and depth_brace == 0:
|
||||
parts.append(expr[start:i])
|
||||
start = i + 1
|
||||
|
||||
parts.append(expr[start:])
|
||||
return parts
|
||||
|
||||
|
||||
def unescape_string_literal(literal_body: str) -> str:
|
||||
result: List[str] = []
|
||||
i = 0
|
||||
while i < len(literal_body):
|
||||
ch = literal_body[i]
|
||||
if ch != "\\":
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if i + 1 >= len(literal_body):
|
||||
result.append("\\")
|
||||
break
|
||||
|
||||
nxt = literal_body[i + 1]
|
||||
if nxt == "n":
|
||||
result.append("\n")
|
||||
elif nxt == "r":
|
||||
result.append("\r")
|
||||
elif nxt == "t":
|
||||
result.append("\t")
|
||||
else:
|
||||
result.append(nxt)
|
||||
i += 2
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def expression_to_template(expr: str) -> Optional[str]:
|
||||
expr = expr.strip()
|
||||
if not expr:
|
||||
return None
|
||||
|
||||
parts = split_top_level(expr, "+")
|
||||
template_parts: List[str] = []
|
||||
placeholder_count = 0
|
||||
|
||||
for raw_part in parts:
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
passthrough_expr = unwrap_passthrough_expression(part)
|
||||
if passthrough_expr is not None:
|
||||
passthrough_template = expression_to_template(passthrough_expr)
|
||||
if passthrough_template is not None:
|
||||
template_parts.append(passthrough_template)
|
||||
continue
|
||||
|
||||
if len(part) >= 2 and part[0] == '"' and part[-1] == '"':
|
||||
template_parts.append(unescape_string_literal(part[1:-1]))
|
||||
else:
|
||||
placeholder_count += 1
|
||||
template_parts.append(f"{{arg{placeholder_count}}}")
|
||||
|
||||
if not template_parts:
|
||||
return None
|
||||
|
||||
template = "".join(template_parts)
|
||||
if template.strip() == "":
|
||||
return None
|
||||
if template_literal_length(template) == 0:
|
||||
return None
|
||||
return template
|
||||
|
||||
|
||||
def unwrap_passthrough_expression(expr: str) -> Optional[str]:
|
||||
expr = expr.strip()
|
||||
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)$", expr, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
function_name = match.group(1)
|
||||
if function_name not in PASS_THROUGH_TEXT_FUNCTIONS:
|
||||
return None
|
||||
|
||||
arg_text = match.group(2)
|
||||
args = split_top_level(arg_text, ",")
|
||||
if not args:
|
||||
return None
|
||||
|
||||
return args[0].strip()
|
||||
|
||||
|
||||
def template_literal_length(template_text: str) -> int:
|
||||
literal_count = 0
|
||||
in_placeholder = False
|
||||
|
||||
for ch in template_text:
|
||||
if not in_placeholder and ch == "{":
|
||||
in_placeholder = True
|
||||
continue
|
||||
if in_placeholder and ch == "}":
|
||||
in_placeholder = False
|
||||
continue
|
||||
if not in_placeholder:
|
||||
literal_count += 1
|
||||
|
||||
return literal_count
|
||||
|
||||
|
||||
def line_number_for_index(text: str, index: int) -> int:
|
||||
return text.count("\n", 0, index) + 1
|
||||
|
||||
|
||||
def add_entry(entries: Dict[str, Dict[str, object]], template: str, source_ref: str) -> None:
|
||||
base_key = f"msg.{hashlib.sha1(template.encode('utf-8')).hexdigest()[:12]}"
|
||||
key = base_key
|
||||
suffix = 1
|
||||
while key in entries and entries[key]["value"] != template:
|
||||
key = f"{base_key}_{suffix}"
|
||||
suffix += 1
|
||||
|
||||
if key not in entries:
|
||||
entries[key] = {"value": template, "refs": [source_ref]}
|
||||
else:
|
||||
refs: List[str] = entries[key]["refs"] # type: ignore[assignment]
|
||||
if source_ref not in refs:
|
||||
refs.append(source_ref)
|
||||
|
||||
|
||||
def extract_from_call(
|
||||
entries: Dict[str, Dict[str, object]],
|
||||
receiver: str,
|
||||
function_name: str,
|
||||
args: List[str],
|
||||
source_ref_base: str,
|
||||
) -> None:
|
||||
target_arg_indexes: List[int] = []
|
||||
|
||||
if function_name in TARGET_FUNCTION_ARGS:
|
||||
target_arg_indexes = TARGET_FUNCTION_ARGS[function_name]
|
||||
elif function_name == "insert_last":
|
||||
receiver_lower = receiver.lower()
|
||||
if not any(hint in receiver_lower for hint in INSERT_LAST_CONTEXT_HINTS):
|
||||
return
|
||||
target_arg_indexes = [0]
|
||||
else:
|
||||
return
|
||||
|
||||
for arg_index in target_arg_indexes:
|
||||
if arg_index >= len(args):
|
||||
continue
|
||||
template = expression_to_template(args[arg_index])
|
||||
if not template:
|
||||
continue
|
||||
source_ref = f"{source_ref_base}:{function_name}[{arg_index}]"
|
||||
add_entry(entries, template, source_ref)
|
||||
|
||||
|
||||
def scan_file(path: Path, entries: Dict[str, Dict[str, object]]) -> None:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
i = 0
|
||||
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if not is_identifier_char(ch):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
start = i
|
||||
while i < len(text) and is_identifier_char(text[i]):
|
||||
i += 1
|
||||
name = text[start:i]
|
||||
|
||||
j = i
|
||||
while j < len(text) and text[j].isspace():
|
||||
j += 1
|
||||
|
||||
if j >= len(text) or text[j] != "(":
|
||||
continue
|
||||
|
||||
receiver = ""
|
||||
k = start - 1
|
||||
while k >= 0 and text[k].isspace():
|
||||
k -= 1
|
||||
if k >= 0 and text[k] == ".":
|
||||
receiver = read_identifier_backward(text, k - 1)
|
||||
|
||||
close = find_matching_paren(text, j)
|
||||
if close < 0:
|
||||
break
|
||||
|
||||
arg_text = text[j + 1 : close]
|
||||
args = split_top_level(arg_text, ",")
|
||||
|
||||
line = line_number_for_index(text, start)
|
||||
rel_path = path.relative_to(ROOT).as_posix()
|
||||
source_ref_base = f"{rel_path}:{line}"
|
||||
extract_from_call(entries, receiver, name, args, source_ref_base)
|
||||
|
||||
i = close + 1
|
||||
|
||||
|
||||
def slugify_fragment(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", value.lower())
|
||||
slug = slug.strip("_")
|
||||
return slug or "value"
|
||||
|
||||
|
||||
def add_item_registry_fragments(entries: Dict[str, Dict[str, object]]) -> None:
|
||||
item_registry_path = ROOT / "src" / "item_registry.nvgt"
|
||||
if not item_registry_path.exists():
|
||||
return
|
||||
|
||||
text = item_registry_path.read_text(encoding="utf-8", errors="replace")
|
||||
pattern = re.compile(
|
||||
r"ItemDefinition\([^,]+,\s*\"([^\"]+)\",\s*\"([^\"]+)\",\s*\"([^\"]+)\"",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
for match in pattern.finditer(text):
|
||||
plural = match.group(1)
|
||||
singular = match.group(2)
|
||||
display = match.group(3)
|
||||
|
||||
plural_slug = slugify_fragment(plural)
|
||||
singular_slug = slugify_fragment(singular)
|
||||
display_slug = slugify_fragment(display)
|
||||
|
||||
add_entry(entries, plural, f"src/item_registry.nvgt:item_plural:{plural_slug}")
|
||||
add_entry(entries, singular, f"src/item_registry.nvgt:item_singular:{singular_slug}")
|
||||
add_entry(entries, display, f"src/item_registry.nvgt:item_display:{display_slug}")
|
||||
|
||||
|
||||
def add_seed_messages(entries: Dict[str, Dict[str, object]]) -> None:
|
||||
seeds = [
|
||||
"Load Game (no saves found)",
|
||||
"No saves found.",
|
||||
"Unable to load save.",
|
||||
"Unhandled exception",
|
||||
"Really exit?",
|
||||
"Found a stone.",
|
||||
"Found a reed.",
|
||||
"Found clay.",
|
||||
]
|
||||
for value in seeds:
|
||||
add_entry(entries, value, "seed:manual")
|
||||
|
||||
|
||||
def normalize_learn_sounds_label(sound_path: Path) -> str:
|
||||
name = sound_path.stem.lower()
|
||||
name = name.replace("_", " ").replace("-", " ")
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
return name or "sound"
|
||||
|
||||
|
||||
def add_learn_sounds_label_seeds(entries: Dict[str, Dict[str, object]]) -> None:
|
||||
sounds_dir = ROOT / "sounds"
|
||||
if not sounds_dir.exists():
|
||||
return
|
||||
|
||||
for path in sorted(sounds_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.suffix.lower() not in {".ogg", ".wav"}:
|
||||
continue
|
||||
label = normalize_learn_sounds_label(path)
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
add_entry(entries, label, f"seed:learn_sounds_label:{rel}")
|
||||
|
||||
|
||||
def escape_for_ini(value: str) -> str:
|
||||
escaped = value.replace("\\", "\\\\")
|
||||
escaped = escaped.replace("\n", "\\n")
|
||||
escaped = escaped.replace("\r", "\\r")
|
||||
escaped = escaped.replace("\t", "\\t")
|
||||
return escaped
|
||||
|
||||
|
||||
def write_catalog(entries: Dict[str, Dict[str, object]], output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manual_entries: List[Tuple[str, str]] = [
|
||||
("meta.code", "en"),
|
||||
("meta.name", "English"),
|
||||
("meta.native_name", "English"),
|
||||
("system.language.select_prompt", "Select your language."),
|
||||
("system.language.selected", "Language set to {language}."),
|
||||
("system.language.english_label", "English (en)"),
|
||||
("system.ui.window_title", "Draugnorak"),
|
||||
("system.sex.male", "Male"),
|
||||
("system.sex.female", "Female"),
|
||||
("system.new_character.choose_sex", "Choose your sex."),
|
||||
("system.new_character.enter_name", "Enter your name or press Enter for random."),
|
||||
("system.new_character.save_exists_overwrite", "Save found for {name}. Overwrite?"),
|
||||
("system.load_game.option_with_metadata", "{name}, {sex}, day {day}"),
|
||||
("system.load_game.delete_confirm_base", "Are you sure you want to delete the character {name}?"),
|
||||
("system.load_game.delete_confirm_with_metadata",
|
||||
"Are you sure you want to delete the character {name} gender {sex} days {day}?"),
|
||||
("system.load_game.delete_save_heading", "Delete Save"),
|
||||
("system.load_game.delete_save_failed", "Unable to delete save."),
|
||||
("system.quick_slot.no_item_bound", "No item bound to slot {slot}."),
|
||||
("system.menu.closed", "Closed."),
|
||||
("system.menu.canceled", "Canceled."),
|
||||
("system.menu.no_options", "No options."),
|
||||
("system.menu.no_matches", "No matches for {arg1}."),
|
||||
("system.option.no", "No"),
|
||||
("system.option.yes", "Yes"),
|
||||
("system.inventory.cant_carry_any_more_item", "You can't carry any more {item}."),
|
||||
("system.inventory.menu.prompt", "Inventory menu. {option}"),
|
||||
("system.inventory.menu.no_options", "Inventory menu. No options."),
|
||||
("system.inventory.option.personal", "Personal inventory"),
|
||||
("system.inventory.option.base_storage", "Base storage"),
|
||||
("system.inventory.count_set_to_slot", "{name} count set to slot {slot}."),
|
||||
("system.inventory.need_quiver_for_arrows", "You need a quiver to carry arrows."),
|
||||
("system.search.found_item", "Found {item}."),
|
||||
("system.search.found_nothing", "Found nothing."),
|
||||
("system.pickup.item", "Picked up {item}."),
|
||||
("system.storage.window_title", "Inventory"),
|
||||
("system.storage.transfer_prompt", "{prompt} (max {max})"),
|
||||
("system.storage.deposit_how_many", "Deposit how many?"),
|
||||
("system.storage.withdraw_how_many", "Withdraw how many?"),
|
||||
("system.storage.nothing_to_deposit", "Nothing to deposit."),
|
||||
("system.storage.nothing_to_withdraw", "Nothing to withdraw."),
|
||||
("system.storage.runed_cannot_deposit", "Runed items cannot be deposited into storage."),
|
||||
("system.storage.item_full", "Storage for that item is full."),
|
||||
("system.storage.no_storage_built", "No storage built."),
|
||||
("system.storage.menu_title", "Base storage."),
|
||||
("system.storage.menu.prompt", "Base storage. {option}"),
|
||||
("system.storage.menu.no_options", "Base storage. No options."),
|
||||
("system.crafting.require.fire_within_three_clay_pot",
|
||||
"You need a fire within 3 tiles to craft a clay pot."),
|
||||
("system.crafting.require.fire_within_three_clay_pots",
|
||||
"You need a fire within 3 tiles to craft clay pots."),
|
||||
("system.crafting.require.fire_within_three_bowstring",
|
||||
"You need a fire within 3 tiles to make bowstring."),
|
||||
("system.crafting.require.altar_incense", "You need an altar to craft incense."),
|
||||
("system.crafting.require.fire_within_three_smoke_fish",
|
||||
"You need a fire within 3 tiles to smoke fish."),
|
||||
("system.crafting.require.fire_within_three_butcher", "You need a fire within 3 tiles to butcher."),
|
||||
("system.character.slot.head", "head"),
|
||||
("system.character.slot.torso", "torso"),
|
||||
("system.character.slot.arms", "arms"),
|
||||
("system.character.slot.hands", "hands"),
|
||||
("system.character.slot.legs", "legs"),
|
||||
("system.character.slot.feet", "feet"),
|
||||
("system.character.menu.prompt", "Character info. {option}"),
|
||||
("system.character.menu.no_options", "Character info. No options."),
|
||||
("system.character.pet.no_pet", "No pet."),
|
||||
("system.character.pet.abandon_confirm", "Really abandon your pet? {option}"),
|
||||
("system.character.pet.unconscious_cannot_abandon",
|
||||
"Your {pet} is unconscious. You can't abandon it right now."),
|
||||
("system.item.label.items", "items"),
|
||||
("system.item.label.item", "item"),
|
||||
("system.item.label.unknown", "Unknown"),
|
||||
("system.equipment.menu.equipped_suffix", " (equipped)"),
|
||||
("system.equipment.name.none", "None"),
|
||||
("system.equipment.name.unknown", "Unknown"),
|
||||
("system.equipment.name.spear", "Spear"),
|
||||
("system.equipment.name.stone_axe", "Stone Axe"),
|
||||
("system.equipment.name.sling", "Sling"),
|
||||
("system.equipment.name.bow", "Bow"),
|
||||
("system.equipment.name.skin_hat", "Skin Hat"),
|
||||
("system.equipment.name.skin_gloves", "Skin Gloves"),
|
||||
("system.equipment.name.skin_pants", "Skin Pants"),
|
||||
("system.equipment.name.skin_tunic", "Skin Tunic"),
|
||||
("system.equipment.name.moccasins", "Moccasins"),
|
||||
("system.equipment.name.skin_pouch", "Skin Pouch"),
|
||||
("system.equipment.name.backpack", "Backpack"),
|
||||
("system.equipment.name.fishing_pole", "Fishing Pole"),
|
||||
("system.equipment.name_plural.spears", "Spears"),
|
||||
("system.equipment.name_plural.stone_axes", "Stone Axes"),
|
||||
("system.equipment.name_plural.slings", "Slings"),
|
||||
("system.equipment.name_plural.bows", "Bows"),
|
||||
("system.equipment.name_plural.skin_hats", "Skin Hats"),
|
||||
("system.equipment.name_plural.skin_gloves", "Skin Gloves"),
|
||||
("system.equipment.name_plural.skin_pants", "Skin Pants"),
|
||||
("system.equipment.name_plural.skin_tunics", "Skin Tunics"),
|
||||
("system.equipment.name_plural.moccasins", "Moccasins"),
|
||||
("system.equipment.name_plural.skin_pouches", "Skin Pouches"),
|
||||
("system.equipment.name_plural.backpacks", "Backpacks"),
|
||||
("system.equipment.name_plural.fishing_poles", "Fishing Poles"),
|
||||
("system.equipment.name_plural.items", "Items"),
|
||||
("system.environment.tree.fell", "Tree fell!"),
|
||||
("system.environment.tree.fell_with_loot",
|
||||
"Tree fell! Got {sticks} {sticks_label}, {vines} {vines_label}, and {logs} {logs_label}."),
|
||||
("system.environment.tree.inventory_full", "Inventory full."),
|
||||
("system.crafting.menu.prompt", "Crafting menu. {option}"),
|
||||
("system.crafting.category.weapons", "Weapons"),
|
||||
("system.crafting.category.tools", "Tools"),
|
||||
("system.crafting.category.materials", "Materials"),
|
||||
("system.crafting.category.clothing", "Clothing"),
|
||||
("system.crafting.category.buildings", "Buildings"),
|
||||
("system.crafting.category.barricade", "Barricade"),
|
||||
("system.crafting.category.runes", "Runes"),
|
||||
("system.crafting.weapons.prompt", "Weapons. {option}"),
|
||||
("system.crafting.weapons.option.spear", "Spear (1 Stick, 1 Vine, 1 Stone) [Requires Knife]"),
|
||||
("system.crafting.weapons.option.sling", "Sling (1 Skin, 2 Vines)"),
|
||||
("system.crafting.weapons.option.bow", "Bow (1 Stick, 1 Bowstring)"),
|
||||
("system.crafting.tools.prompt", "Tools. {option}"),
|
||||
("system.crafting.tools.option.stone_knife", "Stone Knife (2 Stones)"),
|
||||
("system.crafting.tools.option.snare", "Snare (1 Stick, 2 Vines)"),
|
||||
("system.crafting.tools.option.stone_axe", "Stone Axe (1 Stick, 1 Vine, 2 Stones) [Requires Knife]"),
|
||||
("system.crafting.tools.option.fishing_pole", "Fishing Pole (1 Stick, 2 Vines)"),
|
||||
("system.crafting.tools.option.rope", "Rope (3 Vines)"),
|
||||
("system.crafting.tools.option.quiver", "Quiver (2 Skins, 2 Vines)"),
|
||||
("system.crafting.tools.option.canoe", "Canoe (4 Logs, 11 Sticks, 11 Vines, 6 Skins, 2 Rope, 6 Reeds)"),
|
||||
("system.crafting.tools.option.reed_basket", "Reed Basket (3 Reeds)"),
|
||||
("system.crafting.tools.option.clay_pot", "Clay Pot (3 Clay)"),
|
||||
("system.crafting.materials.prompt", "Materials. {option}"),
|
||||
("system.crafting.materials.option.butcher_game", "Butcher Game [Requires Game, Knife, Fire nearby]"),
|
||||
("system.crafting.materials.option.smoke_fish", "Smoke Fish (1 Fish, 1 Stick) [Requires Fire nearby]"),
|
||||
("system.crafting.materials.option.arrows", "Arrows (2 Sticks, 4 Feathers, 2 Stones) [Requires Quiver]"),
|
||||
("system.crafting.materials.option.bowstring", "Bowstring (3 Sinew) [Requires Fire nearby]"),
|
||||
("system.crafting.materials.option.incense", "Incense (6 Sticks, 2 Vines, 1 Reed) [Requires Altar]"),
|
||||
("system.crafting.clothing.prompt", "Clothing. {option}"),
|
||||
("system.crafting.clothing.option.skin_hat", "Skin Hat (1 Skin, 1 Vine)"),
|
||||
("system.crafting.clothing.option.skin_gloves", "Skin Gloves (1 Skin, 1 Vine)"),
|
||||
("system.crafting.clothing.option.skin_pants", "Skin Pants (6 Skins, 3 Vines)"),
|
||||
("system.crafting.clothing.option.skin_tunic", "Skin Tunic (4 Skins, 2 Vines)"),
|
||||
("system.crafting.clothing.option.moccasins", "Moccasins (2 Skins, 1 Vine)"),
|
||||
("system.crafting.clothing.option.skin_pouch", "Skin Pouch (2 Skins, 1 Vine)"),
|
||||
("system.crafting.clothing.option.backpack", "Backpack (11 Skins, 5 Vines, 4 Skin Pouches)"),
|
||||
("system.crafting.buildings.prompt", "Buildings. {option}"),
|
||||
("system.crafting.buildings.option.firepit", "Firepit (9 Stones)"),
|
||||
("system.crafting.buildings.option.fire", "Fire (2 Sticks, 1 Log) [Requires Firepit]"),
|
||||
("system.crafting.buildings.option.herb_garden", "Herb Garden (9 Stones, 3 Vines, 2 Logs) [Base Only]"),
|
||||
("system.crafting.buildings.option.storage_upgrade_1",
|
||||
"Upgrade Storage (6 Logs, 9 Stones, 8 Vines) [Base Only, 50 each]"),
|
||||
("system.crafting.buildings.option.storage_upgrade_2",
|
||||
"Upgrade Storage (12 Logs, 18 Stones, 16 Vines) [Base Only, 100 each]"),
|
||||
("system.crafting.buildings.option.pasture", "Pasture (8 Logs, 18 Ropes) [Base Only, Requires Storage Upgrade]"),
|
||||
("system.crafting.buildings.option.stable",
|
||||
"Stable (10 Logs, 15 Stones, 10 Vines) [Base Only, Requires Storage Upgrade]"),
|
||||
("system.crafting.buildings.option.altar", "Altar (9 Stones, 3 Sticks) [Base Only]"),
|
||||
("system.crafting.barricade.prompt", "Barricade. {option}"),
|
||||
("system.crafting.barricade.option.reinforce_sticks",
|
||||
"Reinforce with sticks ({cost} sticks, +{health} health)"),
|
||||
("system.crafting.barricade.option.reinforce_vines", "Reinforce with vines ({cost} vines, +{health} health)"),
|
||||
("system.crafting.barricade.option.reinforce_log", "Reinforce with log ({cost} log, +{health} health)"),
|
||||
("system.crafting.barricade.option.reinforce_stones",
|
||||
"Reinforce with stones ({cost} stones, +{health} health)"),
|
||||
("system.crafting.missing", "Missing: {requirements}"),
|
||||
("system.crafting.requirement.stone_knife", "Stone Knife"),
|
||||
("system.crafting.requirement.game", "Game"),
|
||||
("system.crafting.requirement.favor", "favor"),
|
||||
("system.crafting.requirement.resources", "resources"),
|
||||
]
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append("; Draugnorak localization catalog")
|
||||
lines.append("; Copy this file to lang/<code>.ini and translate only the right-hand values.")
|
||||
lines.append("; Keep keys unchanged. Use placeholders like {arg1}, {count}, {language} exactly as written.")
|
||||
lines.append("")
|
||||
|
||||
lines.append("[meta]")
|
||||
for key, value in manual_entries[:3]:
|
||||
lines.append(f"{key.split('.', 1)[1]}={escape_for_ini(value)}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("[system]")
|
||||
for key, value in manual_entries[3:]:
|
||||
lines.append(f"{key.split('.', 1)[1]}={escape_for_ini(value)}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("[messages]")
|
||||
|
||||
for key in sorted(entries.keys()):
|
||||
value = entries[key]["value"]
|
||||
refs: List[str] = entries[key]["refs"] # type: ignore[assignment]
|
||||
for ref in refs[:3]:
|
||||
lines.append(f"; {ref}")
|
||||
lines.append(f"{key}={escape_for_ini(str(value))}")
|
||||
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
entries: Dict[str, Dict[str, object]] = {}
|
||||
|
||||
for file_path in iter_nvgt_files():
|
||||
scan_file(file_path, entries)
|
||||
|
||||
add_item_registry_fragments(entries)
|
||||
add_seed_messages(entries)
|
||||
add_learn_sounds_label_seeds(entries)
|
||||
|
||||
en_path = ROOT / "lang" / "en.ini"
|
||||
template_path = ROOT / "lang" / "en.template.ini"
|
||||
|
||||
write_catalog(entries, en_path)
|
||||
write_catalog(entries, template_path)
|
||||
|
||||
print(f"Wrote {en_path.relative_to(ROOT)} with {len(entries)} generated entries")
|
||||
print(f"Wrote {template_path.relative_to(ROOT)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
# One entry per line: <relative-path>:<line>:<context>
|
||||
# Use this only for intentional non-translated user-facing literals.
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LANG_DIR = ROOT / "lang"
|
||||
BASE_FILE = LANG_DIR / "en.template.ini"
|
||||
PLACEHOLDER_PATTERN = re.compile(r"\{([a-zA-Z0-9_]+)\}")
|
||||
|
||||
|
||||
def parse_ini(path: Path) -> Dict[str, str]:
|
||||
section = ""
|
||||
result: Dict[str, str] = {}
|
||||
|
||||
for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(";") or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1].strip()
|
||||
continue
|
||||
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not key:
|
||||
continue
|
||||
|
||||
full_key = key if not section or "." in key else f"{section}.{key}"
|
||||
result[full_key] = unescape_ini_value(value)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def unescape_ini_value(value: str) -> str:
|
||||
out: List[str] = []
|
||||
i = 0
|
||||
while i < len(value):
|
||||
ch = value[i]
|
||||
if ch != "\\":
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if i + 1 >= len(value):
|
||||
out.append("\\")
|
||||
break
|
||||
|
||||
nxt = value[i + 1]
|
||||
if nxt == "n":
|
||||
out.append("\n")
|
||||
elif nxt == "r":
|
||||
out.append("\r")
|
||||
elif nxt == "t":
|
||||
out.append("\t")
|
||||
else:
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def placeholders(value: str) -> Set[str]:
|
||||
return set(PLACEHOLDER_PATTERN.findall(value))
|
||||
|
||||
|
||||
def validate_language(base: Dict[str, str], target_path: Path) -> Tuple[List[str], List[str], List[str]]:
|
||||
target = parse_ini(target_path)
|
||||
|
||||
missing = sorted(set(base.keys()) - set(target.keys()))
|
||||
extra = sorted(set(target.keys()) - set(base.keys()))
|
||||
placeholder_issues: List[str] = []
|
||||
|
||||
for key in sorted(set(base.keys()) & set(target.keys())):
|
||||
base_placeholders = placeholders(base[key])
|
||||
target_placeholders = placeholders(target[key])
|
||||
if base_placeholders != target_placeholders:
|
||||
placeholder_issues.append(
|
||||
f"{key}: expected {sorted(base_placeholders)}, found {sorted(target_placeholders)}"
|
||||
)
|
||||
|
||||
return missing, extra, placeholder_issues
|
||||
|
||||
|
||||
def main() -> int:
|
||||
warn_extra_only = "--warn-extra" in sys.argv[1:]
|
||||
|
||||
if not BASE_FILE.exists():
|
||||
print(f"Missing base template: {BASE_FILE}")
|
||||
return 2
|
||||
|
||||
base = parse_ini(BASE_FILE)
|
||||
|
||||
language_files = sorted(path for path in LANG_DIR.glob("*.ini") if path.name not in {"en.ini", "en.template.ini"})
|
||||
if not language_files:
|
||||
print("No translation files found (expected lang/<code>.ini).")
|
||||
return 0
|
||||
|
||||
failed = False
|
||||
|
||||
for path in language_files:
|
||||
missing, extra, placeholder_issues = validate_language(base, path)
|
||||
if not missing and not extra and not placeholder_issues:
|
||||
print(f"{path.name}: OK")
|
||||
continue
|
||||
|
||||
has_blocking_issues = bool(missing or placeholder_issues or (extra and not warn_extra_only))
|
||||
if has_blocking_issues:
|
||||
failed = True
|
||||
print(f"{path.name}: FAIL")
|
||||
else:
|
||||
print(f"{path.name}: WARN")
|
||||
if missing:
|
||||
print(f" Missing keys ({len(missing)}):")
|
||||
for key in missing[:20]:
|
||||
print(f" - {key}")
|
||||
if len(missing) > 20:
|
||||
print(f" ... and {len(missing) - 20} more")
|
||||
if extra:
|
||||
print(f" Extra keys ({len(extra)}):")
|
||||
for key in extra[:20]:
|
||||
print(f" - {key}")
|
||||
if len(extra) > 20:
|
||||
print(f" ... and {len(extra) - 20} more")
|
||||
if warn_extra_only and not missing and not placeholder_issues:
|
||||
print(" Note: extra keys are warnings and do not fail validation.")
|
||||
if placeholder_issues:
|
||||
print(f" Placeholder mismatches ({len(placeholder_issues)}):")
|
||||
for issue in placeholder_issues[:20]:
|
||||
print(f" - {issue}")
|
||||
if len(placeholder_issues) > 20:
|
||||
print(f" ... and {len(placeholder_issues) - 20} more")
|
||||
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user