mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-14 22:07:47 +00:00
* Translated using Weblate (Italian) Currently translated at 100.0% (2727 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Portuguese) Currently translated at 6.8% (188 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pt/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2727 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (3070 of 3070 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (3070 of 3070 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Portuguese) Currently translated at 100.0% (3070 of 3070 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt/ * Translated using Weblate (Portuguese) Currently translated at 12.3% (337 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pt/ * translations: fix hu ios placeholders * translations: import/export ios * android: translate badges, names, what's new * ios: translate badges, names, what's new * android: remove stale v7_1_invest_learn_more * translations: import/export ios * translations: fix fr ios file size string * translations: fix errors in ca, de, es, fr, in, it, nl, pl, pt, ro, vi, zh translations (#8031) Corrects mistranslations, typos, broken grammar and inconsistent terms found in the review of #8030: badge names inserted mid-sentence in es, ledger labels in ca/fr/in/vi, corrupted pt iOS strings, pt "dístico" vs the locale's "distintivo", and zh-rCN strings that do not read with the countdown. * android: fix stale supporter badges title * android: fix flagged tr, uk, zh, lv strings * ios: fix zh-Hans months interval * ios: warn about outdated and mismatched strings * android: align es, tr, zh strings with ios * ios: align uk invested string with android --------- Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org> Co-authored-by: Fábio Pereira (Serzedo) <fpereira-84@users.noreply.hosted.weblate.org> Co-authored-by: summoner001 <summoner@disroot.org> Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com>
1323 lines
53 KiB
Python
1323 lines
53 KiB
Python
#!/usr/bin/env python3
|
||
"""Round-trip the iOS translations between Weblate's XLIFF files and the app, and check them.
|
||
|
||
update import Weblate's XLIFF files into the app and export them back with Xcode,
|
||
then lint and check the result (macOS with Xcode)
|
||
check lint and check the iOS XLIFF and .strings files (any platform)
|
||
check-kotlin check the Android and desktop strings.xml files with the rules of
|
||
adjustFormatting in apps/multiplatform/common/build.gradle.kts, and warn about
|
||
translations older than their English text (any platform)
|
||
--since REV with check or check-kotlin: also warn where a translation changed since REV
|
||
differs from the other platform's translation of the same English text
|
||
update-kotlin rewrite the strings.xml files as adjustFormatting does, then check them (any platform)
|
||
|
||
Run from the repository root. Exits with 1 when problems are found, 2 on errors.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import bisect
|
||
import contextlib
|
||
import functools
|
||
import html
|
||
import os
|
||
import platform
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import xml.etree.ElementTree as ET
|
||
from collections.abc import Iterator
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Union
|
||
|
||
IOS_DIR = Path("apps/ios")
|
||
PROJECT = IOS_DIR / "SimpleX.xcodeproj"
|
||
PBXPROJ = PROJECT / "project.pbxproj"
|
||
LOCALIZATIONS_DIR = IOS_DIR / "SimpleX Localizations"
|
||
UPDATE_PBXPROJ = Path("scripts/ios/update-pbxproj.sh")
|
||
SOURCE_LANGUAGE = "en"
|
||
XLIFF_NS = "{urn:oasis:names:tc:xliff:document:1.2}"
|
||
LOG_TAIL_LINES = 40
|
||
GROUP_EXAMPLES = 3
|
||
KEY_PREVIEW_LENGTH = 100
|
||
SUMMARY_FIXED_COLUMNS = ("language", "in app", "translated")
|
||
|
||
UnitKey = tuple[str, str]
|
||
|
||
UNIT_START = re.compile(r"^\s*<trans-unit[\s>]")
|
||
UNIT_END = "</trans-unit>"
|
||
TARGET_START = re.compile(r"<target[\s>/]")
|
||
TARGET_END = re.compile(r"</target>|<target[^>]*/>")
|
||
EMPTY_TARGET = re.compile(r"^\s*<target(?:\s[^>]*)?(?:/>|></target>)\s*$")
|
||
SOURCE_END = "</source>"
|
||
|
||
SPECIFIER = re.compile(
|
||
r"%(?:(?P<position>[1-9][0-9]*)\$)?"
|
||
r"(?P<spec>[-+ #0']*[0-9]*(?:\.[0-9]+)?(?:hh|h|ll|l|q|L|z|t|j)?[@dDiuUxXoOfFeEgGaAcCsSp%])"
|
||
)
|
||
ESCAPED_PERCENT = "%"
|
||
# Link targets are percent-encoded, so they are compared verbatim instead of parsed for placeholders.
|
||
LINK_TARGET = re.compile(r"\]\(([^)]*)\)")
|
||
# Valid URLs are ASCII, so a bare URL ends before full-width punctuation such as ")".
|
||
BARE_URL = re.compile(r"\b(?:https?|simplex):/(?:(?![)\]])[!-~])*")
|
||
LINK_MASK = "<link>"
|
||
# Every markdown link target contains "](" and every bare URL ":/".
|
||
LINK_MARKERS = ("](", ":/")
|
||
LINK_PREVIEW_LENGTH = 60
|
||
STRINGS_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\", "'": "'", "0": "\0"}
|
||
UNICODE_ESCAPE = re.compile(r"[uU]([0-9a-fA-F]{4})")
|
||
SWIFT_LITERAL = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
|
||
SWIFT_ESCAPE = re.compile(r"\\(u\{[0-9a-fA-F]{1,8}\}|.)")
|
||
SWIFT_INTERPOLATION = "\\("
|
||
NEWLINE = re.compile("\n")
|
||
|
||
KOTLIN_RESOURCES = Path("apps/multiplatform/common/src/commonMain/moko-resources")
|
||
KOTLIN_BASE = "base"
|
||
KOTLIN_BUILD_SCRIPT = Path("apps/multiplatform/common/build.gradle.kts")
|
||
# The fileRegex of adjustFormatting; lv/strings.xml, which adjustFormatting skips, is checked too.
|
||
KOTLIN_LOCALE = re.compile(r"^(?:..|..-...|..-..)$")
|
||
POSSIBLE_FORMAT = re.compile(r"val possibleFormat = listOf\(([^)]*)\)")
|
||
KOTLIN_STRING = re.compile(r'<string\s+name="(?P<name>[^"]*)"[^>]*>(?P<value>.*?)</string>', re.S)
|
||
KOTLIN_ESCAPE = re.compile(r"\\(u[0-9a-fA-F]{4}|.)")
|
||
CDATA = re.compile(r"<!\[CDATA\[(.*?)\]\]>", re.S)
|
||
UNESCAPED_APOSTROPHE = re.compile(r"(?<!\\)'")
|
||
# The HTML rules of addCDATA in adjustFormatting; "." does not cross lines there either.
|
||
ANY_HTML = re.compile(r"[^>]*>.*(<|>).*</string>|[^>]*>.*(<|>).*</string>")
|
||
FONT_LT_GT = re.compile(r"[^>]*>.*<font[^>]*>.*</font>.*</string>")
|
||
UNBRACKETED_COLOR = re.compile(r"color=#[abcdefABCDEF0-9]{3,6}")
|
||
CORRECT_HTML = re.compile(
|
||
r"[^>]*>.*<b>.*</b>.*</string>|[^>]*>.*<i>.*</i>.*</string>|[^>]*>.*<u>.*</u>.*</string>|[^>]*>.*<font[^>]*>.*</font>.*</string>"
|
||
)
|
||
KOTLIN_POSITIONS = ("1$", "2$", "3$", "4$")
|
||
# The line handling of adjustFormatting: stringRegex, startStringRegex and endStringRegex.
|
||
KOTLIN_STRING_LINE = re.compile(r".*<string .*</string>.*")
|
||
KOTLIN_START_STRING = re.compile(r"<string [^>]*>")
|
||
KOTLIN_END_STRING = re.compile(r"</string>[ ]*")
|
||
# Kotlin's useLines splits lines like BufferedReader.readLine.
|
||
KOTLIN_LINE_BREAK = re.compile(r"\r\n|\r|\n")
|
||
KOTLIN_UNADJUSTED_LOCALES = {"lv"}
|
||
# iOS and Android name these locales differently; others map "pt-BR" to "pt-rBR".
|
||
IOS_ANDROID_LOCALES = {"zh-Hans": "zh-rCN", "zh-Hant": "zh-rTW", "he": "iw", "id": "in"}
|
||
UNCOMMITTED = "0" * 40
|
||
BLAME_HEADER = re.compile(r"^[0-9a-f]{40} \d+ \d+")
|
||
BLAME_STRING = re.compile(r'<string\s+name="([^"]*)"')
|
||
KOTLIN_STRING_TAG_BRACKETS = 4
|
||
KOTLIN_HTML_ERROR = "HTML other than balanced <b>, <i>, <u> or <font> tags, adjustFormatting fails the build"
|
||
|
||
PLACEHOLDER = "placeholder"
|
||
BLANK = "blank"
|
||
LOST = "lost"
|
||
LINK = "link"
|
||
HTML = "html"
|
||
IN_CODE = "in code"
|
||
UNKNOWN = "unknown"
|
||
CHANGED = "changed"
|
||
SYNTAX = "syntax"
|
||
DUPLICATE = "duplicate"
|
||
STALE = "stale"
|
||
OUTDATED = "outdated"
|
||
MISMATCH = "mismatch"
|
||
PROBLEM_KINDS = [PLACEHOLDER, LINK, HTML, BLANK, LOST, IN_CODE, UNKNOWN, CHANGED, SYNTAX, DUPLICATE, STALE, OUTDATED, MISMATCH]
|
||
WARNING_KINDS = {DUPLICATE, STALE, IN_CODE, OUTDATED, MISMATCH}
|
||
KOTLIN_BUILD_FAILURES = {PLACEHOLDER, HTML, UNKNOWN}
|
||
GROUPED_KINDS = {BLANK, STALE}
|
||
|
||
BOLD = "1"
|
||
DIM = "2"
|
||
RED = "31"
|
||
GREEN = "32"
|
||
YELLOW = "33"
|
||
CYAN = "36"
|
||
KIND_COLORS = {
|
||
PLACEHOLDER: RED,
|
||
LINK: RED,
|
||
HTML: RED,
|
||
BLANK: RED,
|
||
IN_CODE: YELLOW,
|
||
UNKNOWN: RED,
|
||
LOST: RED,
|
||
CHANGED: YELLOW,
|
||
SYNTAX: RED,
|
||
DUPLICATE: YELLOW,
|
||
STALE: YELLOW,
|
||
OUTDATED: YELLOW,
|
||
MISMATCH: YELLOW,
|
||
}
|
||
# https://no-color.org
|
||
USE_COLOR = sys.stdout.isatty() and "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
|
||
|
||
|
||
def paint(text: str, *codes: str) -> str:
|
||
return f"\033[{';'.join(codes)}m{text}\033[0m" if USE_COLOR and codes else text
|
||
|
||
|
||
class UpdateError(Exception):
|
||
pass
|
||
|
||
|
||
class StringsSyntaxError(ValueError):
|
||
pass
|
||
|
||
|
||
class KotlinFormatError(ValueError):
|
||
pass
|
||
|
||
|
||
@dataclass
|
||
class Unit:
|
||
source: str
|
||
target: str | None
|
||
|
||
@property
|
||
def translated(self) -> bool:
|
||
return bool(self.target)
|
||
|
||
|
||
@dataclass
|
||
class UnitBlock:
|
||
"""One <trans-unit>: its lines kept verbatim, so edits leave the rest of the file intact."""
|
||
|
||
key: UnitKey
|
||
unit: Unit
|
||
lines: list[str]
|
||
|
||
def target_lines(self) -> list[str]:
|
||
start = next(i for i, line in enumerate(self.lines) if TARGET_START.search(line))
|
||
end = next(i for i in range(start, len(self.lines)) if TARGET_END.search(self.lines[i]))
|
||
return self.lines[start : end + 1]
|
||
|
||
|
||
Segment = Union[str, UnitBlock]
|
||
|
||
|
||
@dataclass
|
||
class SavedTranslation:
|
||
source: str
|
||
target: str
|
||
lines: list[str]
|
||
|
||
|
||
@dataclass
|
||
class Placeholders:
|
||
arguments: dict[int, str] = field(default_factory=dict)
|
||
invalid: list[str] = field(default_factory=list)
|
||
mixed: bool = False
|
||
escaped_percent: bool = False
|
||
|
||
@property
|
||
def is_format(self) -> bool:
|
||
return bool(self.arguments) or self.escaped_percent
|
||
|
||
|
||
@dataclass
|
||
class Problem:
|
||
kind: str
|
||
path: Path
|
||
key: str
|
||
detail: str
|
||
text: str = ""
|
||
|
||
@property
|
||
def language(self) -> str:
|
||
return language_of(self.path)
|
||
|
||
|
||
@dataclass
|
||
class LanguageStats:
|
||
translated: int = 0
|
||
restored: int = 0
|
||
removed: int = 0
|
||
|
||
|
||
def read_file(path: Path) -> str:
|
||
with path.open(encoding="utf-8", newline="") as file:
|
||
return file.read()
|
||
|
||
|
||
def write_file(path: Path, text: str) -> None:
|
||
with path.open("w", encoding="utf-8", newline="") as file:
|
||
file.write(text)
|
||
|
||
|
||
def element_text(element: ET.Element | None) -> str | None:
|
||
return None if element is None else "".join(element.itertext())
|
||
|
||
|
||
def parse_units(text: str) -> list[tuple[UnitKey, Unit]]:
|
||
units = []
|
||
for file in ET.fromstring(text).iter(f"{XLIFF_NS}file"):
|
||
original = file.get("original", "")
|
||
for element in file.iter(f"{XLIFF_NS}trans-unit"):
|
||
units.append(
|
||
(
|
||
(original, element.get("id", "")),
|
||
Unit(
|
||
source=element_text(element.find(f"{XLIFF_NS}source")) or "",
|
||
target=element_text(element.find(f"{XLIFF_NS}target")),
|
||
),
|
||
)
|
||
)
|
||
return units
|
||
|
||
|
||
def split_xliff(text: str) -> list[Segment]:
|
||
"""Split into verbatim lines and unit blocks; the XML is parsed once and matched to the blocks in order."""
|
||
units = iter(parse_units(text))
|
||
segments: list[Segment] = []
|
||
block_lines: list[str] | None = None
|
||
for line in text.splitlines(keepends=True):
|
||
if block_lines is None:
|
||
if not UNIT_START.match(line):
|
||
segments.append(line)
|
||
continue
|
||
block_lines = []
|
||
block_lines.append(line)
|
||
if UNIT_END in line:
|
||
key, unit = next(units)
|
||
segments.append(UnitBlock(key, unit, block_lines))
|
||
block_lines = None
|
||
if block_lines is not None or next(units, None) is not None:
|
||
raise ValueError("<trans-unit> elements must start and end on their own lines")
|
||
return segments
|
||
|
||
|
||
def join_xliff(segments: list[Segment]) -> str:
|
||
return "".join(s if isinstance(s, str) else "".join(s.lines) for s in segments)
|
||
|
||
|
||
def unit_blocks(segments: list[Segment]) -> Iterator[UnitBlock]:
|
||
return (s for s in segments if isinstance(s, UnitBlock))
|
||
|
||
|
||
def strip_empty_targets(segments: list[Segment]) -> int:
|
||
stripped = 0
|
||
for block in unit_blocks(segments):
|
||
lines = [line for line in block.lines if not EMPTY_TARGET.match(line)]
|
||
if len(lines) != len(block.lines):
|
||
stripped += 1
|
||
block.lines = lines
|
||
block.unit.target = None
|
||
return stripped
|
||
|
||
|
||
def drop_untranslated_units(segments: list[Segment]) -> list[Segment]:
|
||
"""Remove units without a translation, as Weblate's "Remove blank strings" add-on does."""
|
||
return [s for s in segments if isinstance(s, str) or s.unit.translated]
|
||
|
||
|
||
def saved_translations(segments: list[Segment]) -> dict[UnitKey, SavedTranslation]:
|
||
saved: dict[UnitKey, SavedTranslation] = {}
|
||
for block in unit_blocks(segments):
|
||
if block.unit.translated:
|
||
saved.setdefault(block.key, SavedTranslation(block.unit.source, block.unit.target or "", block.target_lines()))
|
||
return saved
|
||
|
||
|
||
def restore_translations(segments: list[Segment], saved: dict[UnitKey, SavedTranslation]) -> int:
|
||
"""Put back translations that Xcode dropped, such as drafts, unless their source text changed."""
|
||
blocks = list(unit_blocks(segments))
|
||
translated = {block.key for block in blocks if block.unit.translated}
|
||
restored = 0
|
||
for block in blocks:
|
||
translation = saved.get(block.key)
|
||
if block.key in translated or translation is None or translation.source != block.unit.source:
|
||
continue
|
||
lines = [line for line in block.lines if not EMPTY_TARGET.match(line)]
|
||
source_end = next(i for i, line in enumerate(lines) if SOURCE_END in line)
|
||
block.lines = lines[: source_end + 1] + translation.lines + lines[source_end + 1 :]
|
||
block.unit.target = translation.target
|
||
translated.add(block.key)
|
||
restored += 1
|
||
return restored
|
||
|
||
|
||
def parse_placeholders(text: str) -> Placeholders:
|
||
result = Placeholders()
|
||
positional = sequential = False
|
||
next_position = 1
|
||
index = text.find("%")
|
||
while index != -1:
|
||
match = SPECIFIER.match(text, index)
|
||
if match is None:
|
||
result.invalid.append(text[index : index + 2])
|
||
index = text.find("%", index + 1)
|
||
continue
|
||
spec = match["spec"]
|
||
if spec == ESCAPED_PERCENT:
|
||
result.escaped_percent = True
|
||
elif spec.endswith(ESCAPED_PERCENT):
|
||
result.invalid.append(match.group(0))
|
||
else:
|
||
if match["position"]:
|
||
positional = True
|
||
position = int(match["position"])
|
||
else:
|
||
sequential = True
|
||
position = next_position
|
||
next_position += 1
|
||
if result.arguments.setdefault(position, spec) != spec:
|
||
result.invalid.append(match.group(0))
|
||
index = text.find("%", match.end())
|
||
result.mixed = positional and sequential
|
||
return result
|
||
|
||
|
||
def describe_arguments(arguments: dict[int, str]) -> str:
|
||
return ", ".join(f"%{position}${spec}" for position, spec in sorted(arguments.items())) or "none"
|
||
|
||
|
||
@functools.cache
|
||
def links(text: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||
"""Markdown link targets, and bare URLs outside them."""
|
||
return tuple(LINK_TARGET.findall(text)), tuple(BARE_URL.findall(LINK_TARGET.sub("", text)))
|
||
|
||
|
||
def mask_links(text: str) -> str:
|
||
return BARE_URL.sub(LINK_MASK, LINK_TARGET.sub(f"]({LINK_MASK})", text))
|
||
|
||
|
||
def link_problem(source: str, target: str) -> str | None:
|
||
if not any(marker in text for text in (source, target) for marker in LINK_MARKERS):
|
||
return None
|
||
for kind, expected, found in zip(("markdown link", "URL"), links(source), links(target)):
|
||
missing = [link for link in expected if link not in found]
|
||
if missing:
|
||
return f"{kind} changed or missing: {missing[0][:LINK_PREVIEW_LENGTH]!r}"
|
||
extra = [link for link in found if link not in expected]
|
||
if extra or sorted(expected) != sorted(found):
|
||
return f"{kind} not in the source: {(extra or found)[0][:LINK_PREVIEW_LENGTH]!r}"
|
||
return None
|
||
|
||
|
||
def placeholder_problem(source: str, target: str) -> str | None:
|
||
"""Each placeholder must be spelled as in the source; only its argument number may differ."""
|
||
expected = parse_placeholders(mask_links(source))
|
||
found = parse_placeholders(mask_links(target))
|
||
if not expected.is_format:
|
||
if found.arguments:
|
||
return f"placeholders not in the source: {describe_arguments(found.arguments)}"
|
||
return None
|
||
if found.invalid:
|
||
return "invalid placeholders " + ", ".join(repr(s) for s in found.invalid)
|
||
if found.mixed:
|
||
return "positional and non-positional placeholders mixed"
|
||
if found.arguments != expected.arguments:
|
||
return f"expected {describe_arguments(expected.arguments)}, found {describe_arguments(found.arguments)}"
|
||
return None
|
||
|
||
|
||
def skip_strings_trivia(text: str, index: int) -> int:
|
||
while index < len(text):
|
||
if text[index].isspace():
|
||
index += 1
|
||
elif text.startswith("/*", index):
|
||
end = text.find("*/", index + 2)
|
||
if end == -1:
|
||
raise StringsSyntaxError(f"unterminated comment at offset {index}")
|
||
index = end + 2
|
||
elif text.startswith("//", index):
|
||
end = text.find("\n", index)
|
||
index = len(text) if end == -1 else end + 1
|
||
else:
|
||
break
|
||
return index
|
||
|
||
|
||
def read_strings_literal(text: str, index: int) -> tuple[str, int]:
|
||
if not text.startswith('"', index):
|
||
raise StringsSyntaxError(f'expected " at offset {index}')
|
||
chars = []
|
||
index += 1
|
||
while index < len(text):
|
||
char = text[index]
|
||
if char == '"':
|
||
return "".join(chars), index + 1
|
||
if char == "\\" and index + 1 < len(text):
|
||
unicode = UNICODE_ESCAPE.match(text, index + 1)
|
||
if unicode:
|
||
chars.append(chr(int(unicode.group(1), 16)))
|
||
index = unicode.end()
|
||
else:
|
||
chars.append(STRINGS_ESCAPES.get(text[index + 1], text[index + 1]))
|
||
index += 2
|
||
continue
|
||
chars.append(char)
|
||
index += 1
|
||
raise StringsSyntaxError("unterminated string literal")
|
||
|
||
|
||
def expect_strings_token(text: str, index: int, token: str) -> int:
|
||
index = skip_strings_trivia(text, index)
|
||
if not text.startswith(token, index):
|
||
raise StringsSyntaxError(f"expected {token!r} at offset {index}")
|
||
return index + len(token)
|
||
|
||
|
||
def parse_strings(text: str) -> list[tuple[str, str]]:
|
||
entries = []
|
||
index = skip_strings_trivia(text, 0)
|
||
while index < len(text):
|
||
key, index = read_strings_literal(text, index)
|
||
index = expect_strings_token(text, index, "=")
|
||
value, index = read_strings_literal(text, skip_strings_trivia(text, index))
|
||
index = expect_strings_token(text, index, ";")
|
||
entries.append((key, value))
|
||
index = skip_strings_trivia(text, index)
|
||
return entries
|
||
|
||
|
||
def app_languages() -> list[str]:
|
||
names = sorted(p.name[: -len(".lproj")] for p in IOS_DIR.glob("*.lproj") if p.is_dir())
|
||
return sorted(names, key=lambda name: name != SOURCE_LANGUAGE)
|
||
|
||
|
||
def language_of(path: Path) -> str:
|
||
if path.suffix == ".strings":
|
||
return path.parent.name[: -len(".lproj")]
|
||
return path.parent.name if path.suffix == ".xml" else path.stem
|
||
|
||
|
||
def xcloc_path(language: str) -> Path:
|
||
return LOCALIZATIONS_DIR / f"{language}.xcloc"
|
||
|
||
|
||
def xliff_path(language: str) -> Path:
|
||
return xcloc_path(language) / "Localized Contents" / f"{language}.xliff"
|
||
|
||
|
||
def translation_xliffs() -> list[Path]:
|
||
return [
|
||
path
|
||
for path in sorted(LOCALIZATIONS_DIR.glob("*.xcloc/Localized Contents/*.xliff"))
|
||
if path.stem != SOURCE_LANGUAGE
|
||
]
|
||
|
||
|
||
def translation_strings() -> list[Path]:
|
||
return [
|
||
path
|
||
for path in sorted(IOS_DIR.glob("**/*.lproj/*.strings"))
|
||
if language_of(path) != SOURCE_LANGUAGE and LOCALIZATIONS_DIR not in path.parents
|
||
]
|
||
|
||
|
||
def lint_with_plutil(paths: list[Path]) -> list[Problem]:
|
||
"""Apple's own parser, as the app build uses; only on macOS."""
|
||
if shutil.which("plutil") is None or not paths:
|
||
return []
|
||
result = subprocess.run(["plutil", "-lint", *map(str, paths)], capture_output=True, text=True)
|
||
problems = []
|
||
for line in result.stdout.splitlines() + result.stderr.splitlines():
|
||
name, _, message = line.partition(": ")
|
||
if message and message != "OK":
|
||
problems.append(Problem(SYNTAX, Path(name), "", f"plutil: {message}"))
|
||
return problems
|
||
|
||
|
||
def translation_problems(path: Path, key: str, source: str, target: str) -> list[Problem]:
|
||
link = link_problem(source, target)
|
||
if link:
|
||
# A broken link leaves its percent-encoded URL unmasked, which would also read as placeholders.
|
||
return [Problem(LINK, path, key, link, target)]
|
||
placeholder = placeholder_problem(source, target)
|
||
return [Problem(PLACEHOLDER, path, key, placeholder, target)] if placeholder else []
|
||
|
||
|
||
def unescape_swift(literal: str) -> str:
|
||
def replace(match: re.Match[str]) -> str:
|
||
escaped = match.group(1)
|
||
if escaped.startswith("u{"):
|
||
return chr(int(escaped[2:-1], 16))
|
||
return STRINGS_ESCAPES.get(escaped, escaped)
|
||
|
||
return SWIFT_ESCAPE.sub(replace, literal)
|
||
|
||
|
||
def swift_string_literals() -> dict[str, str]:
|
||
"""Text of every string literal in the app's Swift code, with the first place it appears."""
|
||
literals: dict[str, str] = {}
|
||
for path in sorted(IOS_DIR.rglob("*.swift")):
|
||
text = read_file(path)
|
||
line_starts = [match.start() for match in NEWLINE.finditer(text)]
|
||
for match in SWIFT_LITERAL.finditer(text):
|
||
if SWIFT_INTERPOLATION in match.group(1):
|
||
continue
|
||
literal = unescape_swift(match.group(1))
|
||
# Literals without letters, such as " (" or "\n", are punctuation rather than translatable text.
|
||
if not any(char.isalpha() for char in literal):
|
||
continue
|
||
line = bisect.bisect_left(line_starts, match.start()) + 1
|
||
literals.setdefault(literal, f"{path}:{line}")
|
||
return literals
|
||
|
||
|
||
def removed_but_used(path: Path, key: str, literals: dict[str, str]) -> Problem | None:
|
||
location = literals.get(key)
|
||
if location is None:
|
||
return None
|
||
return Problem(IN_CODE, path, key, f"not in {SOURCE_LANGUAGE}.xliff, but the Swift code has this string at {location}")
|
||
|
||
|
||
def check_xliff(path: Path, source_keys: set[UnitKey], literals: dict[str, str]) -> tuple[list[Problem], int]:
|
||
try:
|
||
units = parse_units(read_file(path))
|
||
except ET.ParseError as error:
|
||
return [Problem(SYNTAX, path, "", f"invalid XML: {error}")], 0
|
||
problems = []
|
||
translated = 0
|
||
occurrences: dict[UnitKey, int] = {}
|
||
for unit_key, _unit in units:
|
||
occurrences[unit_key] = occurrences.get(unit_key, 0) + 1
|
||
for unit_key, count in occurrences.items():
|
||
if count > 1:
|
||
problems.append(Problem(DUPLICATE, path, unit_key[1], f"{count} units with this id in {unit_key[0]}"))
|
||
if source_keys and unit_key not in source_keys:
|
||
used = removed_but_used(path, unit_key[1], literals)
|
||
problems.append(used or Problem(STALE, path, unit_key[1], f"not in {SOURCE_LANGUAGE}.xliff, Xcode's export removes it"))
|
||
for (_file, key), unit in units:
|
||
if unit.target == "":
|
||
problems.append(Problem(BLANK, path, key, "empty target, the import would show it blank"))
|
||
elif unit.target is not None:
|
||
translated += 1
|
||
problems += translation_problems(path, key, unit.source, unit.target)
|
||
return problems, translated
|
||
|
||
|
||
def check_strings(path: Path) -> list[Problem]:
|
||
try:
|
||
entries = parse_strings(read_file(path))
|
||
except StringsSyntaxError as error:
|
||
return [Problem(SYNTAX, path, "", str(error))]
|
||
problems = []
|
||
for key, value in entries:
|
||
if value == "":
|
||
problems.append(Problem(BLANK, path, key, "empty value, shown blank in the app"))
|
||
else:
|
||
problems += translation_problems(path, key, key, value)
|
||
return problems
|
||
|
||
|
||
def check_files(stats: dict[str, LanguageStats], literals: dict[str, str]) -> list[Problem]:
|
||
problems = []
|
||
source_xliff = xliff_path(SOURCE_LANGUAGE)
|
||
source_keys = {key for key, _ in parse_units(read_file(source_xliff))} if source_xliff.is_file() else set()
|
||
for path in translation_xliffs():
|
||
xliff_problems, translated = check_xliff(path, source_keys, literals)
|
||
stats.setdefault(path.stem, LanguageStats()).translated = translated
|
||
problems += xliff_problems
|
||
reported = {(p.language, p.key, p.kind, p.detail) for p in problems}
|
||
strings = translation_strings()
|
||
for path in strings:
|
||
# The import copies XLIFF translations into .strings, so only problems of their own are listed.
|
||
problems += [p for p in check_strings(path) if (p.language, p.key, p.kind, p.detail) not in reported]
|
||
return problems + lint_with_plutil(strings)
|
||
|
||
|
||
def translation_changes(
|
||
path: Path,
|
||
saved: dict[UnitKey, SavedTranslation],
|
||
after: list[Segment],
|
||
source_keys: set[UnitKey],
|
||
literals: dict[str, str],
|
||
) -> tuple[list[Problem], int]:
|
||
"""Translations lost or changed by the round trip, and the count removed together with their source."""
|
||
translated_after: dict[UnitKey, Unit] = {}
|
||
for block in unit_blocks(after):
|
||
if block.unit.translated:
|
||
translated_after.setdefault(block.key, block.unit)
|
||
problems = []
|
||
removed = 0
|
||
for key, translation in saved.items():
|
||
result = translated_after.get(key)
|
||
if result is None:
|
||
if key in source_keys:
|
||
problems.append(Problem(LOST, path, key[1], "translation lost", translation.target))
|
||
else:
|
||
removed += 1
|
||
used = removed_but_used(path, key[1], literals)
|
||
if used:
|
||
used.text = translation.target
|
||
problems.append(used)
|
||
elif result.target != translation.target:
|
||
problems.append(Problem(CHANGED, path, key[1], f"was {translation.target!r}", result.target or ""))
|
||
return problems, removed
|
||
|
||
|
||
def kotlin_possible_formats() -> list[str]:
|
||
"""The placeholders adjustFormatting accepts, read from the build script it is defined in."""
|
||
match = POSSIBLE_FORMAT.search(read_file(KOTLIN_BUILD_SCRIPT))
|
||
if match is None:
|
||
raise UpdateError(f"possibleFormat not found in {KOTLIN_BUILD_SCRIPT}")
|
||
return [item.replace("\\$", "$") for item in re.findall(r'"([^"]*)"', match.group(1))]
|
||
|
||
|
||
def substring_after(text: str, delimiter: str) -> str:
|
||
"""Kotlin's substringAfter: the whole text when the delimiter is missing."""
|
||
_, found, rest = text.partition(delimiter)
|
||
return rest if found else text
|
||
|
||
|
||
def remove_cdata(element: str) -> str:
|
||
if "<![CDATA" not in element:
|
||
return element
|
||
return element.replace("<![CDATA[", "").replace("]]></string>", "</string>")
|
||
|
||
|
||
def kotlin_formats(element: str, possible: list[str]) -> list[str]:
|
||
"""Port of formatting() in adjustFormatting, so the result decides the build the same way."""
|
||
if "%" not in element:
|
||
return []
|
||
value = substring_after(element, '">').rpartition("</string>")[0]
|
||
formats = []
|
||
rest = substring_after(value, "%")
|
||
while True:
|
||
found = next((candidate for candidate in possible if rest.startswith(candidate)), None)
|
||
if found is None:
|
||
raise KotlinFormatError(f"unknown placeholder {'%' + rest[:3]!r}, adjustFormatting fails the build")
|
||
formats.append(found)
|
||
previous = rest
|
||
rest = substring_after(rest, "%")
|
||
if len(previous) == len(rest):
|
||
break
|
||
if not any(item.startswith(KOTLIN_POSITIONS) for item in formats):
|
||
return formats
|
||
if not all(item.partition("$")[0].isdigit() for item in formats):
|
||
raise KotlinFormatError("positional and non-positional placeholders mixed, adjustFormatting fails the build")
|
||
return sorted(formats, key=lambda item: int(item.partition("$")[0]))
|
||
|
||
|
||
def kotlin_add_cdata(line: str) -> str:
|
||
"""Port of addCDATA in adjustFormatting: wraps HTML strings in CDATA, raises for HTML it rejects."""
|
||
# ANY_HTML needs a "<", ">", "<" or ">" besides the two tags of the string itself.
|
||
if line.count("<") + line.count(">") <= KOTLIN_STRING_TAG_BRACKETS and "<" not in line and ">" not in line:
|
||
return line
|
||
if not ANY_HTML.fullmatch(line):
|
||
return line
|
||
prepared = line
|
||
if FONT_LT_GT.fullmatch(line) or UNBRACKETED_COLOR.search(line):
|
||
prepared = line.replace("<", "<").replace(">", ">")
|
||
prepared = UNBRACKETED_COLOR.sub(lambda m: m.group(0).replace("color=#", 'color="#') + '"', prepared)
|
||
opening = prepared.count("<")
|
||
if opening != prepared.count(">") or opening != prepared.count("</") * 2 or not CORRECT_HTML.fullmatch(prepared):
|
||
raise KotlinFormatError(KOTLIN_HTML_ERROR)
|
||
wrapped = KOTLIN_START_STRING.sub(lambda m: m.group(0) + "<![CDATA[", prepared)
|
||
return KOTLIN_END_STRING.sub(lambda m: "]]>" + m.group(0), wrapped)
|
||
|
||
|
||
@functools.cache
|
||
def kotlin_text(raw_value: str) -> str:
|
||
"""The text Android shows: CDATA unwrapped, XML entities and Android escapes resolved."""
|
||
unwrapped = CDATA.sub(lambda m: m.group(1), raw_value)
|
||
text = html.unescape(unwrapped)
|
||
|
||
def replace(match: re.Match[str]) -> str:
|
||
escaped = match.group(1)
|
||
return chr(int(escaped[1:], 16)) if escaped.startswith("u") else STRINGS_ESCAPES.get(escaped, escaped)
|
||
|
||
return KOTLIN_ESCAPE.sub(replace, text)
|
||
|
||
|
||
def describe_kotlin_formats(formats: list[str]) -> str:
|
||
return ", ".join(f"%{item}" for item in formats) or "none"
|
||
|
||
|
||
def kotlin_string_files() -> list[Path]:
|
||
return [
|
||
path
|
||
for path in sorted(KOTLIN_RESOURCES.glob("*/strings.xml"))
|
||
if path.parent.name == KOTLIN_BASE or KOTLIN_LOCALE.match(path.parent.name)
|
||
]
|
||
|
||
|
||
@dataclass
|
||
class KotlinString:
|
||
name: str
|
||
# The string as formatting() in adjustFormatting sees it: after removeCDATA and addCDATA, lines joined.
|
||
line: str
|
||
raw_value: str
|
||
html_error: str | None
|
||
|
||
|
||
@dataclass
|
||
class KotlinFile:
|
||
lines: list[str]
|
||
changed: bool
|
||
strings: list[KotlinString]
|
||
unclosed: str | None
|
||
|
||
|
||
def kotlin_lines(text: str) -> list[str]:
|
||
lines = KOTLIN_LINE_BREAK.split(text)
|
||
if lines and lines[-1] == "":
|
||
lines.pop()
|
||
return lines
|
||
|
||
|
||
def gradle_string_id(line: str) -> str:
|
||
return line.replace('<string name="', "").strip().partition('"')[0]
|
||
|
||
|
||
def adjust_kotlin(text: str) -> KotlinFile:
|
||
"""Port of the line loop of adjustFormatting: the adjusted lines and the strings it compares."""
|
||
initial = kotlin_lines(text)
|
||
final: list[str] = []
|
||
strings: list[KotlinString] = []
|
||
multiline: list[str] = []
|
||
|
||
def adjust(original: list[str]) -> list[str]:
|
||
joined = "\n".join(original)
|
||
error = None
|
||
try:
|
||
fixed = kotlin_add_cdata(remove_cdata(joined))
|
||
except KotlinFormatError as format_error:
|
||
fixed, error = joined, str(format_error)
|
||
fixed_lines = fixed.split("\n")
|
||
flat = "".join(fixed_lines)
|
||
match = KOTLIN_STRING.search(joined)
|
||
strings.append(KotlinString(gradle_string_id(flat), flat, match["value"] if match else "", error))
|
||
return fixed_lines
|
||
|
||
for line in initial:
|
||
if KOTLIN_STRING_LINE.fullmatch(line):
|
||
final += adjust([line])
|
||
elif not multiline and KOTLIN_START_STRING.search(line):
|
||
multiline.append(line)
|
||
elif multiline and KOTLIN_END_STRING.search(line):
|
||
multiline.append(line)
|
||
final += adjust(multiline)
|
||
multiline = []
|
||
elif multiline:
|
||
multiline.append(line)
|
||
else:
|
||
final.append(line)
|
||
unclosed = "\n".join(multiline) if multiline else None
|
||
return KotlinFile(final, final != initial, strings, unclosed)
|
||
|
||
|
||
def kotlin_string_problems(
|
||
path: Path, string: KotlinString, base: KotlinString | None, base_formats: list[str] | None, possible: list[str]
|
||
) -> list[Problem]:
|
||
name = string.name
|
||
text = kotlin_text(string.raw_value)
|
||
if base is None:
|
||
return [Problem(UNKNOWN, path, name, "not in base/strings.xml, adjustFormatting fails the build", text)]
|
||
if text == "":
|
||
return [Problem(BLANK, path, name, "empty string, shown blank in the app")]
|
||
problems = []
|
||
try:
|
||
formats = kotlin_formats(string.line, possible)
|
||
if base_formats is not None and formats != base_formats:
|
||
detail = f"expected {describe_kotlin_formats(base_formats)}, found {describe_kotlin_formats(formats)}"
|
||
problems.append(Problem(PLACEHOLDER, path, name, detail, text))
|
||
except KotlinFormatError as error:
|
||
problems.append(Problem(PLACEHOLDER, path, name, str(error), text))
|
||
if string.html_error:
|
||
problems.append(Problem(HTML, path, name, string.html_error, text))
|
||
outside_cdata = CDATA.sub("", string.raw_value)
|
||
if UNESCAPED_APOSTROPHE.search(outside_cdata) and not (outside_cdata.startswith('"') and outside_cdata.endswith('"')):
|
||
problems.append(Problem(SYNTAX, path, name, "unescaped apostrophe, Android needs \\'", text))
|
||
link = link_problem(kotlin_text(base.raw_value), text)
|
||
if link:
|
||
problems.append(Problem(LINK, path, name, link, text))
|
||
return problems
|
||
|
||
|
||
def check_kotlin_file(
|
||
path: Path, adjusted: KotlinFile, base: dict[str, KotlinString], base_formats: dict[str, list[str]], possible: list[str]
|
||
) -> list[Problem]:
|
||
problems = []
|
||
if adjusted.unclosed:
|
||
problems.append(Problem(SYNTAX, path, "", "unclosed string tag, adjustFormatting fails the build", adjusted.unclosed))
|
||
seen: set[str] = set()
|
||
for string in adjusted.strings:
|
||
if string.name in seen:
|
||
problems.append(Problem(DUPLICATE, path, string.name, "duplicate string name, the app uses only one of them"))
|
||
seen.add(string.name)
|
||
problems += kotlin_string_problems(path, string, base.get(string.name), base_formats.get(string.name), possible)
|
||
return problems
|
||
|
||
|
||
def invalid_xml(path: Path, text: str) -> Problem | None:
|
||
try:
|
||
ET.fromstring(text)
|
||
except ET.ParseError as error:
|
||
return Problem(SYNTAX, path, "", f"invalid XML: {error}")
|
||
return None
|
||
|
||
|
||
def check_kotlin_files(stats: dict[str, LanguageStats], write: bool = False) -> tuple[list[Problem], list[Path]]:
|
||
"""Problems of all strings.xml files and, when writing, the files rewritten as adjustFormatting does."""
|
||
possible = kotlin_possible_formats()
|
||
files = kotlin_string_files()
|
||
base_path = next((path for path in files if path.parent.name == KOTLIN_BASE), None)
|
||
if base_path is None:
|
||
raise UpdateError(f"{KOTLIN_RESOURCES}/{KOTLIN_BASE}/strings.xml not found, run from the repository root")
|
||
texts = {path: read_file(path) for path in files}
|
||
adjusted = {path: adjust_kotlin(text) for path, text in texts.items()}
|
||
problems: list[Problem] = []
|
||
base = {string.name: string for string in adjusted[base_path].strings}
|
||
base_formats: dict[str, list[str]] = {}
|
||
for string in base.values():
|
||
try:
|
||
base_formats[string.name] = kotlin_formats(string.line, possible)
|
||
except KotlinFormatError as error:
|
||
problems.append(Problem(PLACEHOLDER, base_path, string.name, str(error), kotlin_text(string.raw_value)))
|
||
if string.html_error:
|
||
problems.append(Problem(HTML, base_path, string.name, string.html_error, kotlin_text(string.raw_value)))
|
||
base_fails = bool(problems)
|
||
base_texts = {name: kotlin_text(string.raw_value) for name, string in base.items()}
|
||
history = in_git_work_tree()
|
||
rewritten = []
|
||
for path in files:
|
||
xml_problem = invalid_xml(path, texts[path])
|
||
if xml_problem:
|
||
problems.append(xml_problem)
|
||
if path != base_path:
|
||
stats.setdefault(path.parent.name, LanguageStats()).translated = 0
|
||
continue
|
||
file_problems = [] if path == base_path else check_kotlin_file(path, adjusted[path], base, base_formats, possible)
|
||
if path != base_path:
|
||
stats.setdefault(path.parent.name, LanguageStats()).translated = len(adjusted[path].strings)
|
||
problems += file_problems
|
||
if path != base_path and history:
|
||
# Blamed before the rewrite below, which would make its lines uncommitted.
|
||
problems += outdated_translations(path, base_path, base_texts, texts[path])
|
||
builds = not base_fails and not adjusted[path].unclosed and not any(p.kind in KOTLIN_BUILD_FAILURES for p in file_problems)
|
||
if write and builds and adjusted[path].changed and path.parent.name not in KOTLIN_UNADJUSTED_LOCALES:
|
||
# adjustFormatting writes the lines back without a final line break.
|
||
write_file(path, "\n".join(adjusted[path].lines))
|
||
rewritten.append(path)
|
||
return problems, rewritten
|
||
|
||
|
||
def git(*arguments: str) -> str | None:
|
||
"""Output of a git command, or None when git is missing or the command fails."""
|
||
try:
|
||
result = subprocess.run(["git", *arguments], capture_output=True, text=True, encoding="utf-8")
|
||
except FileNotFoundError:
|
||
return None
|
||
return result.stdout if result.returncode == 0 else None
|
||
|
||
|
||
def in_git_work_tree() -> bool:
|
||
return git("rev-parse", "--is-inside-work-tree") is not None
|
||
|
||
|
||
def commit_of(revision: str) -> str:
|
||
"""The commit hash, so cached files read at a revision stay valid when a branch name moves."""
|
||
commit = git("rev-parse", "--verify", "--quiet", f"{revision}^{{commit}}")
|
||
if commit is None:
|
||
raise UpdateError(f"{revision!r} is not a git commit, --since needs one")
|
||
return commit.strip()
|
||
|
||
|
||
def git_file(revision: str, path: Path) -> str:
|
||
"""The file at a revision, empty when it did not exist there."""
|
||
return git("show", f"{revision}:{path.as_posix()}") or ""
|
||
|
||
|
||
def kotlin_values(text: str) -> dict[str, str]:
|
||
return {string.name: kotlin_text(string.raw_value) for string in adjust_kotlin(text).strings}
|
||
|
||
|
||
@functools.cache
|
||
def kotlin_values_at(revision: str, path: Path) -> dict[str, str]:
|
||
return kotlin_values(git_file(revision, path))
|
||
|
||
|
||
def blamed_commits(porcelain: str) -> dict[str, str]:
|
||
"""The commit that last changed the line where each string starts, from git blame --porcelain."""
|
||
commits: dict[str, str] = {}
|
||
commit = ""
|
||
for line in porcelain.splitlines():
|
||
if line.startswith("\t"):
|
||
match = BLAME_STRING.search(line)
|
||
if match:
|
||
commits.setdefault(match.group(1), commit)
|
||
elif BLAME_HEADER.match(line):
|
||
commit = line.split(" ", 1)[0]
|
||
return commits
|
||
|
||
|
||
def outdated_translations(path: Path, base_path: Path, base_now: dict[str, str], text: str) -> list[Problem]:
|
||
"""Translations last changed in a commit whose English text differs from the current one."""
|
||
porcelain = git("blame", "--porcelain", "--", path.as_posix())
|
||
if porcelain is None:
|
||
return []
|
||
translations = kotlin_values(text)
|
||
problems = []
|
||
for key, commit in blamed_commits(porcelain).items():
|
||
if commit == UNCOMMITTED or key not in base_now or key not in translations:
|
||
continue
|
||
then = kotlin_values_at(commit, base_path).get(key)
|
||
if then is not None and then != base_now[key]:
|
||
detail = f"English changed after this translation, from {preview(then)!r} to {preview(base_now[key])!r}"
|
||
problems.append(Problem(OUTDATED, path, key, detail, translations[key]))
|
||
return problems
|
||
|
||
|
||
def normalized_placeholders(text: str) -> str:
|
||
"""Placeholders as {position}, so Android %1$s and iOS %@ of the same argument compare equal."""
|
||
sequence = 0
|
||
|
||
def replace(match: re.Match[str]) -> str:
|
||
nonlocal sequence
|
||
if match["spec"] == ESCAPED_PERCENT:
|
||
return match.group(0)
|
||
sequence += 1
|
||
return "{" + (match["position"] or str(sequence)) + "}"
|
||
|
||
return SPECIFIER.sub(replace, text)
|
||
|
||
|
||
def android_locale(ios_language: str) -> str:
|
||
if ios_language in IOS_ANDROID_LOCALES:
|
||
return IOS_ANDROID_LOCALES[ios_language]
|
||
language, _, region = ios_language.partition("-")
|
||
return f"{language}-r{region}" if region else language
|
||
|
||
|
||
@dataclass
|
||
class Mismatch:
|
||
android_key: str
|
||
android_text: str
|
||
ios_key: UnitKey
|
||
ios_text: str
|
||
|
||
|
||
def mismatched_translations(
|
||
english: dict[str, str],
|
||
android_now: dict[str, str],
|
||
android_then: dict[str, str],
|
||
ios_now: dict[UnitKey, Unit],
|
||
ios_then: dict[UnitKey, Unit],
|
||
) -> list[Mismatch]:
|
||
"""English texts translated differently on Android and iOS, where either side changed since the base revision.
|
||
|
||
Texts with several different translations on one platform are skipped: each is right in its own context.
|
||
"""
|
||
ios_by_english: dict[str, list[tuple[UnitKey, Unit]]] = {}
|
||
for key, unit in ios_now.items():
|
||
if unit.translated:
|
||
ios_by_english.setdefault(normalized_placeholders(unit.source), []).append((key, unit))
|
||
android_by_english: dict[str, list[str]] = {}
|
||
for key, text in english.items():
|
||
if android_now.get(key, "").strip():
|
||
android_by_english.setdefault(normalized_placeholders(text), []).append(key)
|
||
mismatches = []
|
||
for text, android_keys in android_by_english.items():
|
||
ios_units = ios_by_english.get(text)
|
||
if not ios_units:
|
||
continue
|
||
android_texts = {normalized_placeholders(android_now[key]) for key in android_keys}
|
||
ios_texts = {normalized_placeholders(unit.target or "") for _, unit in ios_units}
|
||
if len(android_texts) != 1 or len(ios_texts) != 1 or android_texts == ios_texts:
|
||
continue
|
||
android_changed = any(android_then.get(key) != android_now[key] for key in android_keys)
|
||
ios_changed = any(key not in ios_then or ios_then[key].target != unit.target for key, unit in ios_units)
|
||
if android_changed or ios_changed:
|
||
ios_key, ios_unit = ios_units[0]
|
||
mismatches.append(Mismatch(android_keys[0], android_now[android_keys[0]], ios_key, ios_unit.target or ""))
|
||
return mismatches
|
||
|
||
|
||
def cross_platform_mismatches(revision: str) -> list[tuple[Path, Path, Mismatch]]:
|
||
"""(Android strings.xml, iOS XLIFF, mismatch) for each iOS language that has an Android translation."""
|
||
base_path = KOTLIN_RESOURCES / KOTLIN_BASE / "strings.xml"
|
||
english = kotlin_values(read_file(base_path))
|
||
result = []
|
||
for xliff in translation_xliffs():
|
||
android_path = KOTLIN_RESOURCES / android_locale(language_of(xliff)) / "strings.xml"
|
||
if not android_path.exists():
|
||
continue
|
||
ios_now = dict(parse_units(read_file(xliff)))
|
||
then_text = git_file(revision, xliff)
|
||
ios_then = dict(parse_units(then_text)) if then_text else {}
|
||
android_now = kotlin_values(read_file(android_path))
|
||
android_then = kotlin_values_at(revision, android_path)
|
||
for mismatch in mismatched_translations(english, android_now, android_then, ios_now, ios_then):
|
||
result.append((android_path, xliff, mismatch))
|
||
return result
|
||
|
||
|
||
class Steps:
|
||
def __init__(self, total: int) -> None:
|
||
self.total = total
|
||
self.current = 0
|
||
|
||
@contextlib.contextmanager
|
||
def step(self, title: str) -> Iterator[None]:
|
||
self.current += 1
|
||
print(paint(f"[{self.current}/{self.total}] ", BOLD, CYAN) + paint(title, BOLD), flush=True)
|
||
started = time.monotonic()
|
||
yield
|
||
print(paint(f" done in {format_duration(time.monotonic() - started)}", GREEN), flush=True)
|
||
|
||
|
||
def format_duration(seconds: float) -> str:
|
||
minutes, seconds = divmod(int(round(seconds)), 60)
|
||
return f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s"
|
||
|
||
|
||
def print_summary(stats: dict[str, LanguageStats], problems: list[Problem], in_app: set[str]) -> None:
|
||
counts: dict[str, dict[str, int]] = {}
|
||
for problem in problems:
|
||
language_counts = counts.setdefault(problem.language, {})
|
||
language_counts[problem.kind] = language_counts.get(problem.kind, 0) + 1
|
||
headers = [*SUMMARY_FIXED_COLUMNS, "restored", "removed", *PROBLEM_KINDS]
|
||
rows = []
|
||
for language in sorted(set(stats) | set(counts)):
|
||
entry = stats.get(language, LanguageStats())
|
||
language_counts = counts.get(language, {})
|
||
cells = [
|
||
(language, BOLD if language_counts else ""),
|
||
("yes", "") if language in in_app else ("no", DIM),
|
||
(str(entry.translated), ""),
|
||
(str(entry.restored or ""), YELLOW),
|
||
(str(entry.removed or ""), DIM),
|
||
]
|
||
cells += [(str(language_counts.get(kind, "")), KIND_COLORS[kind]) for kind in PROBLEM_KINDS]
|
||
rows.append(cells)
|
||
always_shown = range(len(SUMMARY_FIXED_COLUMNS))
|
||
shown = [i for i in range(len(headers)) if i in always_shown or any(row[i][0] for row in rows)]
|
||
headers = [headers[i] for i in shown]
|
||
rows = [[row[i] for i in shown] for row in rows]
|
||
widths = [max([len(headers[i]), *(len(row[i][0]) for row in rows)]) for i in range(len(headers))]
|
||
print()
|
||
print(" " + " ".join(paint(h.ljust(w) if i == 0 else h.rjust(w), DIM) for i, (h, w) in enumerate(zip(headers, widths))))
|
||
for row in rows:
|
||
padded = [(text.ljust(w) if i == 0 else text.rjust(w), color) for i, ((text, color), w) in enumerate(zip(row, widths))]
|
||
print(" " + " ".join(paint(text, color) if text.strip() and color else text for text, color in padded))
|
||
|
||
|
||
def preview(text: str) -> str:
|
||
return text if len(text) <= KEY_PREVIEW_LENGTH else text[:KEY_PREVIEW_LENGTH] + "…"
|
||
|
||
|
||
def print_problems(problems: list[Problem]) -> None:
|
||
by_path: dict[Path, list[Problem]] = {}
|
||
for problem in sorted(problems, key=lambda p: (p.language, str(p.path))):
|
||
by_path.setdefault(problem.path, []).append(problem)
|
||
for path, path_problems in by_path.items():
|
||
print("\n " + paint(f"[{path_problems[0].language}]", BOLD, CYAN) + " " + paint(str(path), BOLD))
|
||
for grouped_kind in sorted(GROUPED_KINDS, key=PROBLEM_KINDS.index):
|
||
grouped = [p for p in path_problems if p.kind == grouped_kind]
|
||
if not grouped:
|
||
continue
|
||
examples = ", ".join(repr(p.key) for p in grouped[:GROUP_EXAMPLES])
|
||
more = f" and {len(grouped) - GROUP_EXAMPLES} more" if len(grouped) > GROUP_EXAMPLES else ""
|
||
print(f" {paint(f'{grouped_kind:<11}', KIND_COLORS[grouped_kind])} {len(grouped)} x {grouped[0].detail}")
|
||
print(paint(f" {examples}{more}", DIM))
|
||
for problem in path_problems:
|
||
if problem.kind in GROUPED_KINDS:
|
||
continue
|
||
kind = paint(f"{problem.kind:<11}", KIND_COLORS[problem.kind])
|
||
print(f" {kind} {preview(problem.key)!r}" if problem.key else f" {kind}")
|
||
print(f" {problem.detail}")
|
||
if problem.text:
|
||
print(paint(f" -> {problem.text!r}", DIM))
|
||
|
||
|
||
def count_line(label: str, problems: list[Problem]) -> str:
|
||
kinds: dict[str, int] = {}
|
||
for problem in problems:
|
||
kinds[problem.kind] = kinds.get(problem.kind, 0) + 1
|
||
counts = ", ".join(paint(f"{kinds[kind]} {kind}", KIND_COLORS[kind]) for kind in PROBLEM_KINDS if kind in kinds)
|
||
return paint(f"{len(problems)} {label}: ", BOLD) + counts
|
||
|
||
|
||
def report(stats: dict[str, LanguageStats], problems: list[Problem], in_app: set[str]) -> int:
|
||
"""Exit code 1 for problems; warnings are listed but do not fail."""
|
||
print_summary(stats, problems, in_app)
|
||
print_problems(problems)
|
||
errors = [p for p in problems if p.kind not in WARNING_KINDS]
|
||
warnings = [p for p in problems if p.kind in WARNING_KINDS]
|
||
print()
|
||
if warnings:
|
||
print(count_line("warning(s)", warnings))
|
||
if not errors:
|
||
print(paint("No problems found.", BOLD, GREEN))
|
||
return 0
|
||
print(count_line("problem(s)", errors))
|
||
print("Fix them in Weblate, so the next Weblate commit does not bring them back.")
|
||
return 1
|
||
|
||
|
||
def require_update_environment() -> None:
|
||
if platform.system() != "Darwin" or shutil.which("xcodebuild") is None:
|
||
raise UpdateError("update needs macOS with Xcode; check runs on any platform")
|
||
if not PROJECT.is_dir():
|
||
raise UpdateError(f"{PROJECT} not found, run from the repository root")
|
||
status = subprocess.run(["git", "status", "--porcelain", "--", str(IOS_DIR)], capture_output=True, text=True, check=True)
|
||
if status.stdout.strip():
|
||
raise UpdateError(f"uncommitted changes under {IOS_DIR}, commit or stash them first")
|
||
|
||
|
||
def xcodebuild(arguments: list[str], log: Path) -> None:
|
||
with log.open("w", encoding="utf-8") as output:
|
||
result = subprocess.run(["xcodebuild", *arguments], stdout=output, stderr=subprocess.STDOUT)
|
||
if result.returncode != 0:
|
||
tail = log.read_text(encoding="utf-8", errors="replace").splitlines()[-LOG_TAIL_LINES:]
|
||
raise UpdateError(f"xcodebuild {arguments[0]} failed, full log: {log}\n" + "\n".join(tail))
|
||
|
||
|
||
def update_pbxproj_libraries() -> None:
|
||
subprocess.run(["sh", str(UPDATE_PBXPROJ)], check=True, stdout=subprocess.DEVNULL)
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def local_core_libraries() -> Iterator[None]:
|
||
"""Xcode links the app to extract strings, so the project temporarily references the local core libraries."""
|
||
pbxproj = PBXPROJ.read_bytes()
|
||
try:
|
||
update_pbxproj_libraries()
|
||
yield
|
||
finally:
|
||
PBXPROJ.write_bytes(pbxproj)
|
||
|
||
|
||
def import_languages(languages: list[str], log_dir: Path) -> None:
|
||
for language in languages:
|
||
started = time.monotonic()
|
||
xcodebuild(
|
||
["-importLocalizations", "-project", str(PROJECT), "-localizationPath", str(xcloc_path(language)), "-skipPackageUpdates"],
|
||
log_dir / f"import-{language}.log",
|
||
)
|
||
print(f" {language:<8} " + paint(format_duration(time.monotonic() - started), DIM), flush=True)
|
||
|
||
|
||
def export_languages(languages: list[str], log_dir: Path) -> None:
|
||
export_arguments = [argument for language in languages for argument in ("-exportLanguage", language)]
|
||
xcodebuild(
|
||
["-exportLocalizations", "-project", str(PROJECT), "-localizationPath", str(LOCALIZATIONS_DIR), *export_arguments],
|
||
log_dir / "export.log",
|
||
)
|
||
|
||
|
||
def read_xliff(path: Path) -> list[Segment]:
|
||
try:
|
||
return split_xliff(read_file(path))
|
||
except (ET.ParseError, ValueError) as error:
|
||
raise UpdateError(f"{path}: {error}") from error
|
||
|
||
|
||
def update() -> int:
|
||
require_update_environment()
|
||
languages = app_languages()
|
||
translated = [language for language in languages if language != SOURCE_LANGUAGE]
|
||
stats = {language: LanguageStats() for language in translated}
|
||
steps = Steps(5)
|
||
|
||
with steps.step(f"Preparing {len(translated)} translation files"):
|
||
weblate: dict[str, dict[UnitKey, SavedTranslation]] = {}
|
||
stripped_total = 0
|
||
for language in translated:
|
||
segments = read_xliff(xliff_path(language))
|
||
weblate[language] = saved_translations(segments)
|
||
count = strip_empty_targets(segments)
|
||
if count:
|
||
write_file(xliff_path(language), join_xliff(segments))
|
||
stripped_total += count
|
||
print(paint(f" removed {stripped_total} empty targets that the import would show blank", DIM))
|
||
|
||
log_dir = Path(tempfile.mkdtemp(prefix="ios-localizations-"))
|
||
try:
|
||
with local_core_libraries():
|
||
with steps.step(f"Importing {len(languages)} languages with Xcode (logs: {log_dir})"):
|
||
import_languages(languages, log_dir)
|
||
with steps.step(f"Exporting {len(languages)} languages with Xcode"):
|
||
export_languages(languages, log_dir)
|
||
except (UpdateError, subprocess.CalledProcessError) as error:
|
||
raise UpdateError(f"{error}\nRestore the files with: git checkout -- {IOS_DIR}") from error
|
||
|
||
problems: list[Problem] = []
|
||
with steps.step("Restoring translations dropped by Xcode and removing untranslated units"):
|
||
literals = swift_string_literals()
|
||
source_keys = {key for key, _ in parse_units(read_file(xliff_path(SOURCE_LANGUAGE)))}
|
||
for language in translated:
|
||
path = xliff_path(language)
|
||
exported = read_xliff(path)
|
||
stats[language].restored = restore_translations(exported, weblate[language])
|
||
result = drop_untranslated_units(exported)
|
||
write_file(path, join_xliff(result))
|
||
changes, stats[language].removed = translation_changes(path, weblate[language], result, source_keys, literals)
|
||
problems += changes
|
||
|
||
with steps.step("Linting .strings files and checking translations"):
|
||
problems += check_files(stats, literals)
|
||
|
||
return report(stats, problems, set(languages))
|
||
|
||
|
||
def check(since: str | None = None) -> int:
|
||
stats: dict[str, LanguageStats] = {}
|
||
steps = Steps(2 if since else 1)
|
||
with steps.step("Linting .strings files and checking translations"):
|
||
problems = check_files(stats, swift_string_literals())
|
||
if since:
|
||
with steps.step(f"Comparing translations changed since {since} with Android"):
|
||
for _, xliff, mismatch in cross_platform_mismatches(commit_of(since)):
|
||
detail = f"Android {mismatch.android_key} has {mismatch.android_text!r}"
|
||
problems.append(Problem(MISMATCH, xliff, mismatch.ios_key[1], detail, mismatch.ios_text))
|
||
return report(stats, problems, set(app_languages()))
|
||
|
||
|
||
def check_kotlin(since: str | None = None) -> int:
|
||
stats: dict[str, LanguageStats] = {}
|
||
steps = Steps(2 if since else 1)
|
||
with steps.step("Checking Android and desktop strings.xml files"):
|
||
problems, _ = check_kotlin_files(stats)
|
||
if since:
|
||
with steps.step(f"Comparing translations changed since {since} with iOS"):
|
||
for android_path, _, mismatch in cross_platform_mismatches(commit_of(since)):
|
||
detail = f"iOS has {mismatch.ios_text!r}"
|
||
problems.append(Problem(MISMATCH, android_path, mismatch.android_key, detail, mismatch.android_text))
|
||
return report(stats, problems, set(stats))
|
||
|
||
|
||
def update_kotlin() -> int:
|
||
stats: dict[str, LanguageStats] = {}
|
||
steps = Steps(1)
|
||
with steps.step("Applying adjustFormatting to strings.xml files and checking them"):
|
||
problems, rewritten = check_kotlin_files(stats, write=True)
|
||
names = ", ".join(path.parent.name for path in rewritten) or "none"
|
||
print(paint(f" rewrote {len(rewritten)} files as adjustFormatting does: {names}", DIM))
|
||
return report(stats, problems, set(stats))
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
commands = parser.add_subparsers(dest="command", required=True)
|
||
commands.add_parser("update", help="import, export, lint and check (macOS with Xcode)")
|
||
since_help = "also compare translations changed since this git revision with the other platform"
|
||
commands.add_parser("check", help="lint and check the iOS files only (any platform)").add_argument(
|
||
"--since", metavar="REVISION", help=since_help
|
||
)
|
||
commands.add_parser("check-kotlin", help="check the Android and desktop strings.xml files (any platform)").add_argument(
|
||
"--since", metavar="REVISION", help=since_help
|
||
)
|
||
commands.add_parser(
|
||
"update-kotlin", help="rewrite the strings.xml files as adjustFormatting does, then check them (any platform)"
|
||
)
|
||
arguments = parser.parse_args(argv)
|
||
run = {
|
||
"update": update,
|
||
"check": lambda: check(arguments.since),
|
||
"check-kotlin": lambda: check_kotlin(arguments.since),
|
||
"update-kotlin": update_kotlin,
|
||
}[arguments.command]
|
||
try:
|
||
return run()
|
||
except UpdateError as error:
|
||
print(paint("error: ", BOLD, RED) + str(error), file=sys.stderr)
|
||
return 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|