#!/usr/bin/env python3 import html import re import xml.etree.ElementTree as ET from dataclasses import dataclass, field from datetime import date from pathlib import Path REPO = Path(__file__).resolve().parent.parent SKINS_ROOT = REPO / "indra/newview/skins" OUT_FILE = REPO / "_audit_result.html" TRANSLATABLE_ATTRS = frozenset({ "label", "tool_tip", "title", "text", "yestext", "notext", "canceltext", "message", "help_text", "short_desc", "long_desc", "value", }) NUMERIC_VALUE_RE = re.compile(r"^-?\d+(\.\d+)?$") PLACEHOLDER_TOKEN_RE = re.compile(r"\[[^\]]+\]|\$[A-Za-z_][A-Za-z0-9_]*") STRING_BODY_TAGS = frozenset({ "global", "notification", "text", "string", "ignore", "button", }) # Notification message text often follows a child, not elem.text. NOTIFICATION_INLINE_TAGS = frozenset({"tag", "ignore"}) ONLY_PLACEHOLDERS_RE = re.compile( r"^(\[[A-Za-z0-9_,.:+\-]+\]|\$[A-Za-z_][A-Za-z0-9_]*|[ \t\r\n]+)+$", ) PLACEHOLDER_WITH_PUNCT_RE = re.compile( r"^(\[[A-Za-z0-9_,.:+\-]+\][ \t]*)+[:.,;!?%°²³]*$", ) AGE_OLD_RE = re.compile( r"^;?\s*(\[[A-Za-z0-9_,]+\][ \t]*)+old$", re.IGNORECASE, ) DATE_FORMAT_RE = re.compile( r"^\[[^\]]+,datetime,[^\]]+\](/\[[^\]]+,datetime,[^\]]+\])+$", ) INTEGER_RE = re.compile(r"^-?\d+$") PUNCT_ONLY_RE = re.compile(r"^[\W_]+$") SINGLE_LETTER_RE = re.compile(r"^[A-Za-z]:?$") SLASH_COMMAND_RE = re.compile(r"^/[A-Za-z0-9!]+$") KEYBOARD_LABELS = frozenset({ "Alt", "Backsp", "CapsLock", "Ctrl", "Del", "Down", "End", "Enter", "Esc", "Home", "Ins", "Left", "Right", "Shift", "Up", "PgUp", "PgDn", "Space", "Tab", "KBCtrl", "KBShift", "AM", "PM", "Ok", "OK", "Mono", "uuid", "CPU", "Torus", "Metal", "Swap", "Start", "Stop", "Area", "Group", "Region", "Type", "Parcel", "Discord", "Flickr", "Marketplace", "Primfeed", "Chaplin", }) BRAND_STRINGS = frozenset({ "Firestorm", "FIRESTORM", "FS", "Second Life Grid", "APP_NAME", "APP_NAME_ABBR", "CAPITALIZED_APP_NAME", "CURRENT_GRID", "SECOND_LIFE", "DOWNLOAD_URL", }) SKIP_FILES = frozenset({"xui_version.xml"}) SKIP_FILE_PREFIXES: tuple[str, ...] = ("floater_test_",) TRANSLATE_FALSE_VALUES = frozenset({"false", "0", "no"}) # Common misspellings of real XUI translation attributes. ATTR_TYPO_HINTS: dict[str, str] = { "tooltip": "tool_tip", "toolTip": "tool_tip", "tool-tip": "tool_tip", "ToolTip": "tool_tip", "Tooltip": "tool_tip", "helptext": "help_text", "helpText": "help_text", "shortdesc": "short_desc", "longdesc": "long_desc", "shortDesc": "short_desc", "longDesc": "long_desc", "yes_text": "yestext", "no_text": "notext", "cancel_text": "canceltext", } TRANSLATION_ATTR_KEYWORDS = ( "tip", "label", "title", "text", "message", "desc", "help", ) ATTR_EN_ALTERNATES: dict[str, tuple[str, ...]] = { "label": ("value", "title"), "title": ("label", "value"), "value": ("label",), "text": ("label", "value"), "tool_tip": ("help_text",), "help_text": ("tool_tip",), } @dataclass class Entry: key: str value: str kind: str @dataclass class AttrIssue: element: str attr: str value: str hint: str = "" @dataclass class PlaceholderIssue: key: str en_value: str locale_value: str missing: list[str] = field(default_factory=list) extra: list[str] = field(default_factory=list) @dataclass class XmlFileIssue: skin: str locale: str rel: str kind: str message: str @dataclass class DuplicateNameRecord: rel: str name: str count: int @dataclass class FileReport: rel: str entire_file_missing: bool = False missing: list[Entry] = field(default_factory=list) redundant: list[Entry] = field(default_factory=list) extra_attrs: list[AttrIssue] = field(default_factory=list) unchanged: list[Entry] = field(default_factory=list) placeholder_issues: list[PlaceholderIssue] = field(default_factory=list) locale_only_elements: list[str] = field(default_factory=list) @property def has_issues(self) -> bool: return bool( self.entire_file_missing or self.missing or self.redundant or self.extra_attrs or self.unchanged or self.placeholder_issues or self.locale_only_elements, ) @dataclass class LocaleReport: locale: str en_count: int locale_count: int missing_files: list[str] = field(default_factory=list) redundant_files: list[str] = field(default_factory=list) file_reports: list[FileReport] = field(default_factory=list) @property def total_missing(self) -> int: return sum(len(r.missing) for r in self.file_reports) @property def total_redundant(self) -> int: return sum(len(r.redundant) for r in self.file_reports) @property def total_extra_attrs(self) -> int: return sum(len(r.extra_attrs) for r in self.file_reports) @property def total_unchanged(self) -> int: return sum(len(r.unchanged) for r in self.file_reports) @property def total_placeholder_issues(self) -> int: return sum(len(r.placeholder_issues) for r in self.file_reports) @property def total_locale_only_elements(self) -> int: return sum(len(r.locale_only_elements) for r in self.file_reports) @property def files_with_missing(self) -> int: return sum(1 for r in self.file_reports if r.missing) @property def files_with_redundant(self) -> int: return sum(1 for r in self.file_reports if r.redundant) @property def files_with_extra_attrs(self) -> int: return sum(1 for r in self.file_reports if r.extra_attrs) @property def has_issues(self) -> bool: return bool( self.missing_files or self.redundant_files or any(r.has_issues for r in self.file_reports), ) @dataclass class SkinReport: skin: str locale_reports: list[LocaleReport] = field(default_factory=list) duplicate_names_by_file: dict[str, list[DuplicateNameRecord]] = field( default_factory=dict, ) @property def en_count(self) -> int: return self.locale_reports[0].en_count if self.locale_reports else 0 @property def total_duplicate_names(self) -> int: return sum(len(v) for v in self.duplicate_names_by_file.values()) @dataclass class AuditReport: skin_reports: list[SkinReport] = field(default_factory=list) xml_issues: list[XmlFileIssue] = field(default_factory=list) @property def parse_error_count(self) -> int: return sum(1 for i in self.xml_issues if i.kind == "parse") @property def bom_count(self) -> int: return sum(1 for i in self.xml_issues if i.kind == "bom") def normalise_text(text: str) -> str: if text is None: return "" return " ".join(text.split()) def is_numeric_value(value: str) -> bool: v = normalise_text(value) return bool(v) and bool(NUMERIC_VALUE_RE.match(v)) def tag_name(elem: ET.Element) -> str: return elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag def tag_has_string_body(tag: str) -> bool: return tag in STRING_BODY_TAGS or tag.endswith(".string") def direct_element_text(elem: ET.Element) -> str: return normalise_text(elem.text or "") def notification_body_text(elem: ET.Element) -> str: parts: list[str] = [] def add(raw: str | None) -> None: if not raw: return text = normalise_text(raw) if text: parts.append(text) add(elem.text) for child in elem: child_tag = tag_name(child) if child_tag in NOTIFICATION_INLINE_TAGS: add(child.tail) elif child_tag == "form": add(child.tail) break else: add(child.tail) return " ".join(parts) def element_body_text(elem: ET.Element, tag: str) -> str: if tag in {"notification", "global"}: return notification_body_text(elem) return direct_element_text(elem) def string_content(elem: ET.Element) -> str: body = direct_element_text(elem) if body: return body return normalise_text(elem.attrib.get("value", "")) def extract_placeholders(text: str) -> frozenset[str]: return frozenset(PLACEHOLDER_TOKEN_RE.findall(text)) def is_translatable_attr_value(attr: str, value: str) -> bool: if attr == "value" and is_numeric_value(value): return False return True def is_trivial(value: str, key: str = "") -> bool: v = normalise_text(value) if not v: return True if INTEGER_RE.match(v): return True if ONLY_PLACEHOLDERS_RE.match(v): return True if PLACEHOLDER_WITH_PUNCT_RE.match(v): return True if AGE_OLD_RE.match(v): return True if DATE_FORMAT_RE.match(v): return True if len(v) == 1: return True if SINGLE_LETTER_RE.match(v): return True if PUNCT_ONLY_RE.match(v): return True if SLASH_COMMAND_RE.match(v): return True if re.match(r"^F\d{1,2}$", v): return True if v in KEYBOARD_LABELS: return True if v.startswith(("PAD_BUTTON", "http://", "https://", "secondlife://")): return True if re.match(r"^[\d.]+%$", v): return True if re.match(r"^[a-z][a-z0-9_]*_panel$", v): return True if "TestString PleaseIgnore" in v: return True if key.startswith("string:"): name = key[7:] if name in BRAND_STRINGS or v in BRAND_STRINGS: return True if name.startswith("#"): return True if v.startswith("#"): return True if v.startswith("PAD_"): return True if v in {"(online)", "mainland", "L$", "IM"}: return True if key.startswith("string:font_"): return True if v.endswith("+") and len(v) <= 6: return True return False def needs_translation_entry(value: str, key: str) -> bool: if is_trivial(value, key): return False if key.startswith("string:") and value == key[7:]: return False if key.endswith("::label"): name = key[1:].split("::", 1)[0] if value == name: return False if key.endswith("::value") and is_numeric_value(value): return False return True def find_duplicate_names(root: ET.Element) -> list[DuplicateNameRecord]: counts: dict[str, int] = {} for elem in root.iter(): name = elem.attrib.get("name", "").strip() if name: counts[name] = counts.get(name, 0) + 1 return [ DuplicateNameRecord(name=name, count=count, rel="") for name, count in sorted(counts.items()) if count > 1 ] def element_has_translatable_content( ekey: str, attrs: dict[str, str], entries: dict[str, Entry], ) -> bool: prefix = f"{ekey}::" for key, entry in entries.items(): if key.startswith(prefix) and needs_translation_entry(entry.value, key): return True for attr, value in attrs.items(): if not is_translatable_attr_value(attr, value): continue key = f"{ekey}::{attr}" if needs_translation_entry(value, key): return True return False def is_translate_disabled(elem: ET.Element) -> bool: return elem.attrib.get("translate", "").strip().lower() in TRANSLATE_FALSE_VALUES def collect_no_translate_targets(root: ET.Element, rel: str) -> frozenset[str]: targets: set[str] = set() if rel == "strings.xml": for child in root: tag = child.tag.split("}")[-1] if tag != "string": continue if not is_translate_disabled(child): continue name = child.attrib.get("name", "").strip() if name: targets.add(f"string:{name}") return frozenset(targets) def walk(elem: ET.Element) -> None: ekey = element_map_key(elem) if ekey and is_translate_disabled(elem): targets.add(ekey) for child in elem: walk(child) walk(root) return frozenset(targets) def entry_is_non_translatable(key: str, no_translate: frozenset[str]) -> bool: if key in no_translate: return True if key.startswith("@"): element = key.split("::", 1)[0] return element in no_translate return False def element_identity(elem: ET.Element) -> str: name = elem.attrib.get("name", "").strip() if name: return name value = elem.attrib.get("value", "").strip() if value: return value label = elem.attrib.get("label", "").strip() if label: return label return "" def entry_key(elem: ET.Element, attr: str) -> str: ident = element_identity(elem) if ident: return f"@{ident}::{attr}" tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag return f"{tag}::{attr}" def element_map_key(elem: ET.Element) -> str | None: ident = element_identity(elem) if ident: return f"@{ident}" return None def is_xml_meta_attr(attr: str) -> bool: return attr.startswith("{") or attr.startswith("xmlns") def looks_translation_attr(attr: str) -> bool: if attr in TRANSLATABLE_ATTRS or attr in ATTR_TYPO_HINTS: return True lower = attr.lower() return any(keyword in lower for keyword in TRANSLATION_ATTR_KEYWORDS) def should_report_extra_attr(attr: str, value: str, en_attrs: dict[str, str]) -> bool: if attr == "value" and is_numeric_value(value): return False if attr in ATTR_TYPO_HINTS: return True if attr in TRANSLATABLE_ATTRS: return True if looks_translation_attr(attr): return True key = f"@extra::{attr}" return not is_trivial(value, key) def extra_attr_hint(attr: str, en_attrs: dict[str, str]) -> str: canonical = ATTR_TYPO_HINTS.get(attr) if canonical and canonical in en_attrs: return f"EN uses {canonical} on this element" if canonical: return f"Did you mean {canonical}?" for alternate in ATTR_EN_ALTERNATES.get(attr, ()): if alternate in en_attrs: return f"EN uses {alternate} on this element, not {attr}" return "Attribute not present on EN element" def attr_entry_key(element: str, attr: str) -> str: return f"{element}::{attr}" def extract_element_attrs(root: ET.Element, rel: str) -> dict[str, dict[str, str]]: elements: dict[str, dict[str, str]] = {} if rel == "strings.xml": return elements def walk(elem: ET.Element) -> None: ekey = element_map_key(elem) if ekey: bucket = elements.setdefault(ekey, {}) for attr, value in elem.attrib.items(): if is_xml_meta_attr(attr): continue bucket[attr] = normalise_text(value) for child in elem: walk(child) walk(root) return elements def compare_extra_attributes( en_elements: dict[str, dict[str, str]], locale_elements: dict[str, dict[str, str]], skip_keys: frozenset[str] | None = None, skip_elements: frozenset[str] | None = None, ) -> list[AttrIssue]: issues: list[AttrIssue] = [] skip = skip_keys or frozenset() skip_elems = skip_elements or frozenset() for ekey, locale_attrs in locale_elements.items(): if ekey in skip_elems: continue en_attrs = en_elements.get(ekey) if en_attrs is None: continue for attr, value in locale_attrs.items(): key = attr_entry_key(ekey, attr) if key in skip: continue if attr in en_attrs: continue if not should_report_extra_attr(attr, value, en_attrs): continue issues.append(AttrIssue( element=ekey, attr=attr, value=value, hint=extra_attr_hint(attr, en_attrs), )) issues.sort(key=lambda i: (i.element, i.attr)) return issues def extract_entries(root: ET.Element, rel: str) -> dict[str, Entry]: entries: dict[str, Entry] = {} if rel == "strings.xml": for child in root: if tag_name(child) != "string": continue if is_translate_disabled(child): continue name = child.attrib.get("name", "").strip() if not name: continue value = string_content(child) key = f"string:{name}" entries[key] = Entry(key, value, "#text") return entries def walk(elem: ET.Element) -> None: tag = tag_name(elem) ident = element_identity(elem) if not is_translate_disabled(elem): for attr in TRANSLATABLE_ATTRS: if attr not in elem.attrib: continue value = normalise_text(elem.attrib[attr]) if not is_translatable_attr_value(attr, value): continue key = entry_key(elem, attr) entries[key] = Entry(key, value, attr) body = element_body_text(elem, tag) if ident and body and tag_has_string_body(tag): key = entry_key(elem, "#text") if key not in entries: entries[key] = Entry(key, body, "#text") for child in elem: walk(child) walk(root) return entries def detect_bom(path: Path) -> str | None: with path.open("rb") as handle: head = handle.read(4) if head[:3] == b"\xef\xbb\xbf": return "UTF-8 byte order mark (BOM) at start of file" if head[:2] == b"\xff\xfe": return "UTF-16 LE byte order mark (BOM) at start of file" if head[:2] == b"\xfe\xff": return "UTF-16 BE byte order mark (BOM) at start of file" return None def scan_skin_xml_issues(skin: str) -> list[XmlFileIssue]: issues: list[XmlFileIssue] = [] xui = SKINS_ROOT / skin / "xui" if not xui.is_dir(): return issues for locale_dir in sorted(xui.iterdir()): if not locale_dir.is_dir(): continue locale = locale_dir.name for path in locale_dir.rglob("*.xml"): rel = path.relative_to(locale_dir).as_posix() if rel in SKIP_FILES: continue bom_msg = detect_bom(path) if bom_msg: issues.append(XmlFileIssue( skin=skin, locale=locale, rel=rel, kind="bom", message=bom_msg, )) try: ET.parse(path) except ET.ParseError as exc: issues.append(XmlFileIssue( skin=skin, locale=locale, rel=rel, kind="parse", message=str(exc), )) except (OSError, UnicodeDecodeError) as exc: issues.append(XmlFileIssue( skin=skin, locale=locale, rel=rel, kind="parse", message=str(exc), )) return issues def parse_xml(path: Path) -> ET.Element | None: try: return ET.parse(path).getroot() except ET.ParseError as exc: print(f"Parse error in {path}: {exc}") return None def collect_files(base: Path) -> dict[str, Path]: files: dict[str, Path] = {} if not base.is_dir(): return files for path in base.rglob("*.xml"): rel = path.relative_to(base).as_posix() if rel not in SKIP_FILES: files[rel] = path return files def file_is_skipped_audit(rel: str) -> bool: name = Path(rel).name return name.startswith(SKIP_FILE_PREFIXES) def compare_file( rel: str, en_path: Path, locale_path: Path | None, skin: str = "", locale: str = "", ) -> FileReport: report = FileReport(rel=rel) if file_is_skipped_audit(rel): return report en_root = parse_xml(en_path) if en_root is None: return report en_all = extract_entries(en_root, rel) en_no_translate = collect_no_translate_targets(en_root, rel) en_entries = { k: v for k, v in en_all.items() if needs_translation_entry(v.value, k) and not entry_is_non_translatable(k, en_no_translate) } if locale_path is None or not locale_path.exists(): report.missing = list(en_entries.values()) return report locale_root = parse_xml(locale_path) if locale_root is None: report.missing = list(en_entries.values()) return report locale_all = extract_entries(locale_root, rel) locale_entries = { k: v for k, v in locale_all.items() if needs_translation_entry(v.value, k) } for key, entry in en_entries.items(): if key not in locale_all: report.missing.append(entry) continue loc_entry = locale_all[key] if loc_entry.value == entry.value: report.unchanged.append(entry) continue en_ph = extract_placeholders(entry.value) if en_ph: loc_ph = extract_placeholders(loc_entry.value) missing_ph = sorted(en_ph - loc_ph) extra_ph = sorted(loc_ph - en_ph) if missing_ph or extra_ph: report.placeholder_issues.append(PlaceholderIssue( key=key, en_value=entry.value, locale_value=loc_entry.value, missing=missing_ph, extra=extra_ph, )) for key, entry in locale_entries.items(): if key not in en_all: if entry_is_non_translatable(key, en_no_translate): continue report.redundant.append(entry) redundant_keys = frozenset(e.key for e in report.redundant) en_elements = extract_element_attrs(en_root, rel) locale_elements = extract_element_attrs(locale_root, rel) report.extra_attrs = compare_extra_attributes( en_elements, locale_elements, skip_keys=redundant_keys, skip_elements=en_no_translate, ) for ekey in sorted(locale_elements): if ekey in en_elements: continue if ekey in en_no_translate: continue if element_has_translatable_content( ekey, locale_elements[ekey], locale_all, ): report.locale_only_elements.append(ekey) return report def discover_skins() -> list[str]: skins: list[str] = [] if not SKINS_ROOT.is_dir(): return skins for path in sorted(SKINS_ROOT.iterdir()): if path.is_dir() and (path / "xui" / "en").is_dir(): skins.append(path.name) return skins def discover_locales(skin: str) -> list[str]: xui = SKINS_ROOT / skin / "xui" if not xui.is_dir(): return [] return sorted( d.name for d in xui.iterdir() if d.is_dir() and d.name != "en" ) def audit_locale(skin: str, locale: str) -> LocaleReport: en_dir = SKINS_ROOT / skin / "xui" / "en" locale_dir = SKINS_ROOT / skin / "xui" / locale en_files = collect_files(en_dir) locale_files = collect_files(locale_dir) en_rels = set(en_files) locale_rels = set(locale_files) report = LocaleReport( locale=locale, en_count=len(en_files), locale_count=len(locale_files), missing_files=sorted( rel for rel in en_rels - locale_rels if not file_is_skipped_audit(rel) ), redundant_files=sorted(locale_rels - en_rels), ) for rel in sorted(en_rels & locale_rels): if file_is_skipped_audit(rel): continue file_report = compare_file( rel, en_files[rel], locale_files.get(rel), skin=skin, locale=locale, ) if file_report.has_issues: report.file_reports.append(file_report) for rel in report.missing_files: if file_is_skipped_audit(rel): continue en_root = parse_xml(en_files[rel]) if en_root is None: continue en_all = extract_entries(en_root, rel) en_no_translate = collect_no_translate_targets(en_root, rel) missing = [ e for e in en_all.values() if needs_translation_entry(e.value, e.key) and not entry_is_non_translatable(e.key, en_no_translate) ] if missing: report.file_reports.append(FileReport( rel=rel, missing=missing, entire_file_missing=True, )) else: report.file_reports.append(FileReport( rel=rel, entire_file_missing=True, )) report.file_reports.sort(key=lambda r: r.rel) return report def scan_en_duplicate_names(skin: str) -> dict[str, list[DuplicateNameRecord]]: en_dir = SKINS_ROOT / skin / "xui" / "en" by_file: dict[str, list[DuplicateNameRecord]] = {} for rel, path in collect_files(en_dir).items(): root = parse_xml(path) if root is None: continue dupes = find_duplicate_names(root) if dupes: by_file[rel] = [ DuplicateNameRecord(rel=rel, name=d.name, count=d.count) for d in dupes ] return by_file def audit_skin(skin: str) -> SkinReport: locales = discover_locales(skin) return SkinReport( skin=skin, locale_reports=[audit_locale(skin, loc) for loc in locales], duplicate_names_by_file=scan_en_duplicate_names(skin), ) def badge(count: int, kind: str) -> str: if count == 0: return f"{count}" return f"{count}" def render_key_code(key: str) -> str: escaped = html.escape(key) if key.endswith("::value"): return f"{escaped}" return f"{escaped}" def render_file_report(report: FileReport, locale: str) -> str: parts = [ "
", "

", f"{html.escape(report.rel)}", ] if report.entire_file_missing: parts.append( "Entire file missing from locale", ) if report.missing: parts.append(badge(len(report.missing), "missing")) if report.redundant: parts.append(badge(len(report.redundant), "redundant")) if report.extra_attrs: parts.append(badge(len(report.extra_attrs), "extra-attr")) if report.unchanged: parts.append(badge(len(report.unchanged), "unchanged")) if report.placeholder_issues: parts.append(badge(len(report.placeholder_issues), "placeholder")) if report.locale_only_elements: parts.append(badge(len(report.locale_only_elements), "locale-only")) parts.append("

") if report.missing: parts.append("
") if report.entire_file_missing: parts.append( f"
Entire file absent from {html.escape(locale)} " f"(EN strings to translate)
", ) else: parts.append(f"
Missing in {html.escape(locale)}
") parts.append( "", ) for entry in sorted(report.missing, key=lambda e: e.key): parts.append( f"" f"", ) parts.append("
KeyEN value
{render_key_code(entry.key)}{html.escape(entry.value)}
") elif report.entire_file_missing: parts.append( "

No translatable EN strings were flagged for this file.

", ) if report.redundant: parts.append("
") parts.append(f"
Redundant in {html.escape(locale)} (not in EN)
") parts.append( "" f"", ) for entry in sorted(report.redundant, key=lambda e: e.key): parts.append( f"" f"", ) parts.append("
Key{html.escape(locale)} value
{render_key_code(entry.key)}{html.escape(entry.value)}
") if report.extra_attrs: parts.append("
") parts.append( f"
Attributes on {html.escape(locale)} elements not in EN " "(wrong name or typo)
", ) parts.append( "" f"", ) for issue in report.extra_attrs: display_value = issue.value if issue.value else "(empty)" parts.append( f"" f"" f"" f"", ) parts.append("
ElementAttribute{html.escape(locale)} valueNote
{html.escape(issue.element)}{html.escape(issue.attr)}{html.escape(display_value)}{html.escape(issue.hint)}
") if report.unchanged: parts.append("
") parts.append( f"
Identical to EN in {html.escape(locale)} (likely untranslated)
", ) parts.append( "", ) for entry in sorted(report.unchanged, key=lambda e: e.key): parts.append( f"" f"", ) parts.append("
KeyValue
{render_key_code(entry.key)}{html.escape(entry.value)}
") if report.placeholder_issues: parts.append("
") parts.append(f"
Placeholder mismatch in {html.escape(locale)}
") parts.append( "" f"", ) for issue in sorted(report.placeholder_issues, key=lambda i: i.key): parts.append( f"" f"" f"" f"" f"", ) parts.append("
KeyEN value{html.escape(locale)} valueMissingExtra
{render_key_code(issue.key)}{html.escape(issue.en_value)}{html.escape(issue.locale_value)}{html.escape(', '.join(issue.missing))}{html.escape(', '.join(issue.extra))}
") if report.locale_only_elements: parts.append("
") parts.append( f"
Elements in {html.escape(locale)} not present in EN file
", ) parts.append("
    ") for ekey in report.locale_only_elements: parts.append(f"
  • {html.escape(ekey)}
  • ") parts.append("
") parts.append("
") return "\n".join(parts) def render_locale_report(locale_report: LocaleReport) -> str: loc = locale_report.locale parts = [ "
", "", ] parts.append(f"{html.escape(loc)}") parts.append( f"{locale_report.locale_count} files vs " f"{locale_report.en_count} EN", ) if locale_report.missing_files: parts.append( f"missing files: " f"{badge(len(locale_report.missing_files), 'missing')}", ) if locale_report.redundant_files: parts.append( f"redundant files: " f"{badge(len(locale_report.redundant_files), 'redundant')}", ) parts.append( f"missing strings: " f"{badge(locale_report.total_missing, 'missing')}", ) parts.append( f"redundant strings: " f"{badge(locale_report.total_redundant, 'redundant')}", ) if locale_report.total_extra_attrs: parts.append( f"bad attributes: " f"{badge(locale_report.total_extra_attrs, 'extra-attr')}", ) if locale_report.total_unchanged: parts.append( f"unchanged: " f"{badge(locale_report.total_unchanged, 'unchanged')}", ) if locale_report.total_placeholder_issues: parts.append( f"placeholders: " f"{badge(locale_report.total_placeholder_issues, 'placeholder')}", ) if locale_report.total_locale_only_elements: parts.append( f"locale-only widgets: " f"{badge(locale_report.total_locale_only_elements, 'locale-only')}", ) parts.append("") parts.append("
") parts.append("") parts.append("") for label, value in ( ("EN XML files", locale_report.en_count), (f"{loc.upper()} XML files", locale_report.locale_count), ("Missing files", len(locale_report.missing_files)), ("Redundant files", len(locale_report.redundant_files)), ("Files with missing strings", locale_report.files_with_missing), ("Files with redundant strings", locale_report.files_with_redundant), ("Total missing entries", locale_report.total_missing), ("Total redundant entries", locale_report.total_redundant), ("Bad attributes (not on EN element)", locale_report.total_extra_attrs), ("Files with bad attributes", locale_report.files_with_extra_attrs), ("Unchanged (same as EN)", locale_report.total_unchanged), ("Placeholder mismatches", locale_report.total_placeholder_issues), ("Locale-only widgets", locale_report.total_locale_only_elements), ): parts.append( f"", ) parts.append("
{html.escape(label)}{value}
") if locale_report.missing_files: parts.append( f"

Missing translation files " f"({len(locale_report.missing_files)})

", ) parts.append("
    ") for rel in locale_report.missing_files: parts.append(f"
  • {html.escape(rel)}
  • ") parts.append("
") if locale_report.redundant_files: parts.append( f"

Redundant locale-only files " f"({len(locale_report.redundant_files)})

", ) parts.append("
    ") for rel in locale_report.redundant_files: parts.append(f"
  • {html.escape(rel)}
  • ") parts.append("
") if locale_report.file_reports: parts.append( f"

Per-file string differences " f"({len(locale_report.file_reports)})

", ) parts.append("
") for file_report in locale_report.file_reports: parts.append(render_file_report(file_report, loc)) parts.append("
") elif not locale_report.missing_files and not locale_report.redundant_files: parts.append("

No string differences found.

") parts.append("
") return "\n".join(parts) def render_skin_panel(skin_report: SkinReport, active: bool) -> str: skin = skin_report.skin visible = "" if active else " hidden" parts = [ f"
", f"

Skin: {html.escape(skin)}

", f"

Base: " f"indra/newview/skins/{html.escape(skin)}/xui/

", ] if not skin_report.locale_reports: parts.append( "

No non-EN locales found for this skin.

", ) else: if skin_report.duplicate_names_by_file: total_dupes = skin_report.total_duplicate_names parts.append( f"

Duplicate widget names in EN " f"({total_dupes} across {len(skin_report.duplicate_names_by_file)} files)

", ) parts.append( "

Multiple elements share the same name " "in one file; string matching may be unreliable for these.

", ) parts.append("") parts.append("") for rel in sorted(skin_report.duplicate_names_by_file): for dup in skin_report.duplicate_names_by_file[rel]: parts.append( f"" f"" f"", ) parts.append("
FileWidget nameCount
{html.escape(rel)}{html.escape(dup.name)}{dup.count}
") for locale_report in skin_report.locale_reports: parts.append(render_locale_report(locale_report)) parts.append("
") return "\n".join(parts) def render_matrix(skin_reports: list[SkinReport]) -> str: all_locales: list[str] = [] seen: set[str] = set() for skin_report in skin_reports: for locale_report in skin_report.locale_reports: if locale_report.locale not in seen: seen.add(locale_report.locale) all_locales.append(locale_report.locale) if not all_locales: return "" lookup: dict[tuple[str, str], LocaleReport] = {} for skin_report in skin_reports: for locale_report in skin_report.locale_reports: lookup[(skin_report.skin, locale_report.locale)] = locale_report parts = [ "

Overview matrix

", "

Missing string counts per skin and locale. " "Click a skin tab for detail.

", "
", "", ] for loc in all_locales: parts.append(f"") parts.append("") for skin_report in skin_reports: parts.append(f"") for loc in all_locales: locale_report = lookup.get((skin_report.skin, loc)) if locale_report is None: parts.append("") elif not locale_report.has_issues: parts.append("") else: missing = locale_report.total_missing extra = ( locale_report.total_redundant + locale_report.total_extra_attrs + locale_report.total_unchanged + locale_report.total_placeholder_issues + locale_report.total_locale_only_elements ) cell = f"{missing}" if extra: cell += f" (+{extra})" parts.append(f"") parts.append("") parts.append("
Skin{html.escape(loc)}
{html.escape(skin_report.skin)}-0{cell}
") return "\n".join(parts) def render_xml_issues(xml_issues: list[XmlFileIssue]) -> str: if not xml_issues: return "" parts = [ "

XML file issues

", "

Malformed XML cannot be audited. A leading BOM is " "undesirable in XUI files and may break other tooling even when Python " "parses the file successfully.

", "", "", ] for issue in xml_issues: kind_label = "BOM" if issue.kind == "bom" else "Parse error" kind_class = "issue-bom" if issue.kind == "bom" else "issue-parse" parts.append( f"" f"" f"" f"" f"", ) parts.append("
SkinLocaleFileKindDetail
{html.escape(issue.skin)}{html.escape(issue.locale)}{html.escape(issue.rel)}{kind_label}{html.escape(issue.message)}
") return "\n".join(parts) def locale_issue_total(locale_report: LocaleReport) -> int: return ( locale_report.total_missing + locale_report.total_redundant + locale_report.total_extra_attrs + locale_report.total_unchanged + locale_report.total_placeholder_issues + locale_report.total_locale_only_elements ) def skin_issue_total(skin_report: SkinReport) -> int: return ( sum(locale_issue_total(lr) for lr in skin_report.locale_reports) + skin_report.total_duplicate_names ) def render_html(audit: AuditReport) -> str: skin_reports = audit.skin_reports audit_date = date.today().isoformat() total_missing = sum( lr.total_missing for sr in skin_reports for lr in sr.locale_reports ) total_redundant = sum( lr.total_redundant for sr in skin_reports for lr in sr.locale_reports ) total_extra_attrs = sum( lr.total_extra_attrs for sr in skin_reports for lr in sr.locale_reports ) total_unchanged = sum( lr.total_unchanged for sr in skin_reports for lr in sr.locale_reports ) total_placeholder = sum( lr.total_placeholder_issues for sr in skin_reports for lr in sr.locale_reports ) total_locale_only = sum( lr.total_locale_only_elements for sr in skin_reports for lr in sr.locale_reports ) total_duplicate_names = sum(sr.total_duplicate_names for sr in skin_reports) locale_count = sum(len(sr.locale_reports) for sr in skin_reports) skins_with_locales = sum(1 for sr in skin_reports if sr.locale_reports) parts = [ "", "", "", "", "", "XUI translation audit", "", "", "", "", "
", "
", "

XUI translation audit

", f"

All skins under indra/newview/skins/*/xui/ compared " f"EN against every other locale present. Generated {audit_date}.

", "
", "

Entries are matched by widget name (viewer " "layered-XML rules). Audits value attributes and " "panel.string / floater.string bodies. Purely " "numeric value attributes (sizes, combo indices, padding) are " "ignored. Each locale element is also checked attribute-by-attribute against " "its EN counterpart. Elements with translate=\"false\" are skipped " "(direct content only, not children). Also flags unchanged EN copies, placeholder " "token mismatches, locale-only widgets, duplicate widget names, malformed " "XML, and BOM markers.

", render_xml_issues(audit.xml_issues), "
", f"
{len(skin_reports)}
" "
Skins scanned
", f"
{skins_with_locales}
" "
Skins with translations
", f"
{locale_count}
" "
Locale comparisons
", f"
{total_missing}
" "
Total missing strings
", f"
{total_redundant}
" "
Total redundant strings
", f"
{total_extra_attrs}
" "
Bad attributes (not on EN)
", f"
{total_unchanged}
" "
Unchanged (same as EN)
", f"
{total_placeholder}
" "
Placeholder mismatches
", f"
{total_locale_only}
" "
Locale-only widgets
", f"
{total_duplicate_names}
" "
Duplicate widget names
", f"
{audit.parse_error_count}
" "
XML parse errors
", f"
{audit.bom_count}
" "
Files with BOM
", "
", render_matrix(skin_reports), "

By skin

", "
", ] for index, skin_report in enumerate(skin_reports): skin = skin_report.skin issue_count = skin_issue_total(skin_report) active = " active" if index == 0 else "" parts.append( f"", ) parts.append("
") for index, skin_report in enumerate(skin_reports): parts.append(render_skin_panel(skin_report, active=index == 0)) parts.extend(["
", "", ""]) return "\n".join(parts) def main() -> None: skins = discover_skins() if not skins: print(f"No skins with xui/en found under {SKINS_ROOT}") return skin_reports = [audit_skin(skin) for skin in skins] xml_issues: list[XmlFileIssue] = [] for skin in skins: xml_issues.extend(scan_skin_xml_issues(skin)) xml_issues.sort(key=lambda i: (i.skin, i.locale, i.rel, i.kind)) audit = AuditReport(skin_reports=skin_reports, xml_issues=xml_issues) output = render_html(audit) OUT_FILE.write_text(output, encoding="utf-8") print( f"Wrote {OUT_FILE} ({len(skins)} skins, " f"{audit.parse_error_count} parse errors, {audit.bom_count} BOM files)", ) if __name__ == "__main__": main()