mirror of
https://github.com/stenzek/duckstation.git
synced 2026-10-28 12:38:22 +01:00
573 lines
23 KiB
Python
573 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared Qt TS catalog parsing, identity, and validation helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import bisect
|
|
import collections
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from typing import Iterable, Iterator, Sequence
|
|
|
|
|
|
SKIPPED_TYPES = frozenset({"vanished", "obsolete"})
|
|
|
|
QT_PLACEHOLDER_RE = re.compile(r"%(?:L?\d+|Ln|n)")
|
|
PRINTF_PLACEHOLDER_RE = re.compile(
|
|
r"%(?!%)(?:\d+\$)?[-+0#]*(?:\d+|\*(?:\d+\$)?)?"
|
|
r"(?:\.(?:\d+|\*(?:\d+\$)?))?(?:hh|ll|[hljztL])?[diuoxXfFeEgGaAcsp]"
|
|
)
|
|
FMT_PLACEHOLDER_RE = re.compile(r"(?<!\{)\{(?:\d+)?(?::(?:[^{}]|\{\d*\})*)?\}(?!\})")
|
|
TEMPLATE_PLACEHOLDER_RE = re.compile(r"\$\{[^{}]+\}")
|
|
RICH_TAG_RE = re.compile(
|
|
r"<\s*(/?)\s*(html|head|body|p|div|span|strong|b|i|em|u|s|sub|sup|pre|code|font|br|hr|img|h[1-6]|a|table|tr|td|th|ul|ol|li)\b[^>]*>",
|
|
re.IGNORECASE,
|
|
)
|
|
FILE_DIALOG_FILTER_RE = re.compile(r"[^\r\n]*\(([^()]*)\)\s*")
|
|
|
|
CONTEXT_RE = re.compile(r"<context>.*?</context>", re.DOTALL)
|
|
MESSAGE_RE = re.compile(r"<message(?:\s[^>]*)?>.*?</message>", re.DOTALL)
|
|
TRANSLATION_RE = re.compile(
|
|
r"<translation(?P<attrs>[^>]*)>.*?</translation>|<translation(?P<self_attrs>[^>]*)/>", re.DOTALL
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MessageIdentity:
|
|
context: str
|
|
source: str
|
|
comment: str
|
|
extracomment: str
|
|
numerus: bool
|
|
occurrence: int
|
|
message_id: str = ""
|
|
|
|
def as_payload(self) -> dict[str, object]:
|
|
payload = {
|
|
"context": self.context,
|
|
"source": self.source,
|
|
"comment": self.comment,
|
|
"extracomment": self.extracomment,
|
|
"numerus": self.numerus,
|
|
"occurrence": self.occurrence,
|
|
}
|
|
if self.message_id:
|
|
payload["message_id"] = self.message_id
|
|
return payload
|
|
|
|
@property
|
|
def identifier(self) -> str:
|
|
encoded = json.dumps(self.as_payload(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:20]
|
|
|
|
|
|
@dataclass
|
|
class CatalogMessage:
|
|
identity: MessageIdentity
|
|
translation_type: str
|
|
translation: str | None
|
|
plural_translations: list[str]
|
|
locations: list[dict[str, str]]
|
|
catalog_line: int | None = None
|
|
message_line: int | None = None
|
|
translator_comment: str = ""
|
|
|
|
@property
|
|
def identifier(self) -> str:
|
|
return self.identity.identifier
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RawMessageSpan:
|
|
identifier: str
|
|
start: int
|
|
end: int
|
|
block: str
|
|
translation_type: str
|
|
numerus: bool
|
|
catalog_line: int
|
|
message_line: int
|
|
|
|
|
|
def element_text(element: ET.Element | None) -> str:
|
|
return "" if element is None else "".join(element.itertext())
|
|
|
|
|
|
def identity_base(context: str, message: ET.Element) -> tuple[str, str, str, str, bool, str]:
|
|
return (
|
|
context,
|
|
message.findtext("source") or "",
|
|
message.findtext("comment") or "",
|
|
message.findtext("extracomment") or "",
|
|
message.get("numerus") == "yes",
|
|
message.get("id", ""),
|
|
)
|
|
|
|
|
|
def iter_catalog_messages(root: ET.Element) -> Iterator[CatalogMessage]:
|
|
for context_element in root.findall("context"):
|
|
context = context_element.findtext("name") or ""
|
|
occurrences: collections.Counter[tuple] = collections.Counter()
|
|
for message in context_element.findall("message"):
|
|
base = identity_base(context, message)
|
|
occurrence = occurrences[base]
|
|
occurrences[base] += 1
|
|
identity = MessageIdentity(*base[:5], occurrence, message_id=base[5])
|
|
translation_element = message.find("translation")
|
|
translation_type = "missing" if translation_element is None else translation_element.get("type", "finished")
|
|
plural_elements = [] if translation_element is None else translation_element.findall("numerusform")
|
|
plural_translations = [element_text(form) for form in plural_elements]
|
|
translation = None if translation_element is None or plural_elements else element_text(translation_element)
|
|
locations = [dict(location.attrib) for location in message.findall("location")]
|
|
yield CatalogMessage(
|
|
identity=identity,
|
|
translation_type=translation_type,
|
|
translation=translation,
|
|
plural_translations=plural_translations,
|
|
locations=locations,
|
|
translator_comment=message.findtext("translatorcomment") or "",
|
|
)
|
|
|
|
|
|
def parse_catalog(path: Path | str) -> tuple[ET.ElementTree, list[CatalogMessage]]:
|
|
with Path(path).open("r", encoding="utf-8", newline="") as stream:
|
|
return parse_catalog_text(stream.read())
|
|
|
|
|
|
def parse_catalog_text(text: str) -> tuple[ET.ElementTree, list[CatalogMessage]]:
|
|
tree = ET.ElementTree(ET.fromstring(text))
|
|
if tree.getroot().tag != "TS":
|
|
raise ValueError("Expected a Qt TS catalog")
|
|
messages = list(iter_catalog_messages(tree.getroot()))
|
|
spans = scan_raw_messages(text)
|
|
for message in messages:
|
|
span = spans.get(message.identifier)
|
|
if span is not None:
|
|
message.catalog_line = span.catalog_line
|
|
message.message_line = span.message_line
|
|
return tree, messages
|
|
|
|
|
|
def catalog_fingerprint(messages: Iterable[CatalogMessage]) -> str:
|
|
identities = [message.identity.as_payload() for message in messages]
|
|
encoded = json.dumps(identities, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def extract_placeholders(text: str) -> collections.Counter[str]:
|
|
tokens: list[str] = []
|
|
occupied: list[tuple[int, int]] = [match.span() for match in re.finditer(r"%%", text)]
|
|
for kind, regex in (
|
|
("template", TEMPLATE_PLACEHOLDER_RE),
|
|
("fmt", FMT_PLACEHOLDER_RE),
|
|
("qt", QT_PLACEHOLDER_RE),
|
|
("printf", PRINTF_PLACEHOLDER_RE),
|
|
):
|
|
for match in regex.finditer(text):
|
|
if any(match.start() < end and match.end() > start for start, end in occupied):
|
|
continue
|
|
occupied.append(match.span())
|
|
placeholder = match.group(0)
|
|
if kind == "fmt" and ":" in placeholder:
|
|
field, specification = placeholder.split(":", 1)
|
|
tokens.extend(f"fmt:{nested.group()}" for nested in re.finditer(r"\{\d*\}", specification))
|
|
placeholder = f"{field}}}"
|
|
tokens.append(f"{kind}:{placeholder}")
|
|
return collections.Counter(tokens)
|
|
|
|
|
|
def placeholders_match(source: str, translation: str) -> bool:
|
|
source_tokens = extract_placeholders(source)
|
|
translated_tokens = extract_placeholders(translation)
|
|
if source_tokens == translated_tokens:
|
|
return True
|
|
|
|
source_fmt = collections.Counter(
|
|
token.removeprefix("fmt:") for token in source_tokens.elements() if token.startswith("fmt:")
|
|
)
|
|
translated_fmt = collections.Counter(
|
|
token.removeprefix("fmt:") for token in translated_tokens.elements() if token.startswith("fmt:")
|
|
)
|
|
source_non_fmt = collections.Counter(
|
|
token for token in source_tokens.elements() if not token.startswith("fmt:")
|
|
)
|
|
translated_non_fmt = collections.Counter(
|
|
token for token in translated_tokens.elements() if not token.startswith("fmt:")
|
|
)
|
|
if set(source_non_fmt) != set(translated_non_fmt):
|
|
return False
|
|
|
|
if set(source_fmt) == set(translated_fmt):
|
|
return True
|
|
|
|
# Qt's fmt usage permits translators to number anonymous plain placeholders
|
|
# when reordering arguments, e.g. "{} {}" -> "{1} {0}".
|
|
if set(source_fmt) == {"{}"}:
|
|
expected = collections.Counter(f"{{{index}}}" for index in range(source_fmt["{}"]))
|
|
return translated_fmt == expected
|
|
return False
|
|
|
|
|
|
def placeholder_counts_match(source: str, translation: str) -> bool:
|
|
"""Return whether compatible placeholders occur the same number of times."""
|
|
if not placeholders_match(source, translation):
|
|
return False
|
|
|
|
source_tokens = extract_placeholders(source)
|
|
translated_tokens = extract_placeholders(translation)
|
|
if source_tokens == translated_tokens:
|
|
return True
|
|
|
|
source_fmt = collections.Counter(
|
|
token.removeprefix("fmt:") for token in source_tokens.elements() if token.startswith("fmt:")
|
|
)
|
|
translated_fmt = collections.Counter(
|
|
token.removeprefix("fmt:") for token in translated_tokens.elements() if token.startswith("fmt:")
|
|
)
|
|
source_non_fmt = collections.Counter(
|
|
token for token in source_tokens.elements() if not token.startswith("fmt:")
|
|
)
|
|
translated_non_fmt = collections.Counter(
|
|
token for token in translated_tokens.elements() if not token.startswith("fmt:")
|
|
)
|
|
if set(source_fmt) == {"{}"}:
|
|
fmt_counts_match = sum(source_fmt.values()) == sum(translated_fmt.values())
|
|
else:
|
|
fmt_counts_match = source_fmt == translated_fmt
|
|
return source_non_fmt == translated_non_fmt and fmt_counts_match
|
|
|
|
|
|
def placeholders_are_subset(source: str, translation: str) -> bool:
|
|
"""Allow a plural form to omit redundant arguments without adding any."""
|
|
if placeholder_counts_match(source, translation):
|
|
return True
|
|
source_tokens = extract_placeholders(source)
|
|
translated_tokens = extract_placeholders(translation)
|
|
if translated_tokens <= source_tokens:
|
|
return True
|
|
|
|
source_fmt = collections.Counter(
|
|
token.removeprefix("fmt:") for token in source_tokens.elements() if token.startswith("fmt:")
|
|
)
|
|
translated_fmt = collections.Counter(
|
|
token.removeprefix("fmt:") for token in translated_tokens.elements() if token.startswith("fmt:")
|
|
)
|
|
source_non_fmt = collections.Counter(
|
|
token for token in source_tokens.elements() if not token.startswith("fmt:")
|
|
)
|
|
translated_non_fmt = collections.Counter(
|
|
token for token in translated_tokens.elements() if not token.startswith("fmt:")
|
|
)
|
|
if not translated_non_fmt <= source_non_fmt or set(source_fmt) != {"{}"}:
|
|
return False
|
|
allowed_numbered = {f"{{{index}}}" for index in range(source_fmt["{}"])}
|
|
return set(translated_fmt) <= allowed_numbered and all(count == 1 for count in translated_fmt.values())
|
|
|
|
|
|
def extract_rich_tags(text: str) -> collections.Counter[str]:
|
|
tags: list[str] = []
|
|
for match in RICH_TAG_RE.finditer(text):
|
|
closing = "/" if match.group(1) else ""
|
|
tags.append(f"{closing}{match.group(2).lower()}")
|
|
return collections.Counter(tags)
|
|
|
|
|
|
def unbalanced_rich_tags(text: str) -> dict[str, tuple[int, int]]:
|
|
"""Return paired rich-text elements with unequal opening and closing counts."""
|
|
opening: collections.Counter[str] = collections.Counter()
|
|
closing: collections.Counter[str] = collections.Counter()
|
|
for match in RICH_TAG_RE.finditer(text):
|
|
tag = match.group(2).lower()
|
|
if tag in {"br", "hr", "img"} or (not match.group(1) and match.group(0).rstrip().endswith("/>")):
|
|
continue
|
|
(closing if match.group(1) else opening)[tag] += 1
|
|
|
|
return {
|
|
tag: (opening[tag], closing[tag])
|
|
for tag in sorted(opening.keys() | closing.keys())
|
|
if opening[tag] != closing[tag]
|
|
}
|
|
|
|
|
|
class RichTextParser(HTMLParser):
|
|
"""Check recognized Qt rich text without treating literal angle labels as tags."""
|
|
|
|
def __init__(self, text: str):
|
|
super().__init__(convert_charrefs=True)
|
|
self.stack: list[str] = []
|
|
self.problems: list[str] = []
|
|
self.protected: collections.Counter[tuple[str, str, str]] = collections.Counter()
|
|
self.feed(text)
|
|
self.close()
|
|
if self.stack:
|
|
self.problems.append(f"unclosed rich-text tags: {self.stack}")
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
if RICH_TAG_RE.fullmatch(self.get_starttag_text() or "") is None:
|
|
return
|
|
for name, value in attrs:
|
|
if name in {"href", "src"}:
|
|
self.protected[(tag, name, value or "")] += 1
|
|
if tag not in {"br", "hr", "img"}:
|
|
self.stack.append(tag)
|
|
|
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
self.handle_starttag(tag, attrs)
|
|
if self.stack and self.stack[-1] == tag:
|
|
self.stack.pop()
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if RICH_TAG_RE.fullmatch(f"</{tag}>") is None or tag in {"br", "hr", "img"}:
|
|
return
|
|
if not self.stack or self.stack[-1] != tag:
|
|
self.problems.append(f"incorrect rich-text nesting at </{tag}>")
|
|
if tag in self.stack:
|
|
del self.stack[len(self.stack) - 1 - self.stack[::-1].index(tag):]
|
|
|
|
|
|
def rich_text_problems(source: str, translation: str) -> list[str]:
|
|
original, translated = RichTextParser(source), RichTextParser(translation)
|
|
problems = list(translated.problems)
|
|
if original.protected != translated.protected:
|
|
problems.append("rich-text link/resource attributes differ from the source")
|
|
return problems
|
|
|
|
|
|
def fmt_arguments(text: str) -> collections.Counter[tuple[int, str]]:
|
|
"""Keep argument identities and format specifications, allowing positional reordering."""
|
|
result: collections.Counter[tuple[int, str]] = collections.Counter()
|
|
automatic = 0
|
|
template_spans = [match.span() for match in TEMPLATE_PLACEHOLDER_RE.finditer(text)]
|
|
for match in FMT_PLACEHOLDER_RE.finditer(text):
|
|
if any(match.start() < end and match.end() > start for start, end in template_spans):
|
|
continue
|
|
field, separator, specification = match.group()[1:-1].partition(":")
|
|
index = int(field) if field else automatic
|
|
if not field:
|
|
automatic += 1
|
|
|
|
def normalize_nested(match: re.Match[str]) -> str:
|
|
nonlocal automatic
|
|
nested_field = match.group()[1:-1]
|
|
nested_index = int(nested_field) if nested_field else automatic
|
|
if not nested_field:
|
|
automatic += 1
|
|
result[(nested_index, "")] += 1
|
|
return f"{{{nested_index}}}"
|
|
|
|
specification = re.sub(r"\{\d*\}", normalize_nested, specification)
|
|
result[(index, specification if separator else "")] += 1
|
|
return result
|
|
|
|
|
|
def accelerator_count(text: str) -> int:
|
|
# HTML attributes and entities are not menu accelerators.
|
|
text = RICH_TAG_RE.sub("", text)
|
|
text = re.sub(r"&(?:#\d+|#x[0-9a-fA-F]+|[A-Za-z]+);", "", text)
|
|
return len(re.findall(r"(?<!&)&(?!&|\s|$)", text.replace("&&", "")))
|
|
|
|
|
|
def file_dialog_filter_patterns(text: str) -> list[collections.Counter[str]] | None:
|
|
"""Recognize labeled Qt name filters, ignoring their translatable descriptions."""
|
|
filters: list[collections.Counter[str]] = []
|
|
for part in text.split(";;"):
|
|
match = FILE_DIALOG_FILTER_RE.fullmatch(part.strip())
|
|
if match is None:
|
|
return None
|
|
patterns = match.group(1).split()
|
|
if not patterns or any(not any(wildcard in pattern for wildcard in "*?") for pattern in patterns):
|
|
return None
|
|
filters.append(collections.Counter(patterns))
|
|
return filters
|
|
|
|
|
|
def validate_translation(source: str, translation: str, allow_missing_placeholders: bool = False) -> list[str]:
|
|
problems: list[str] = []
|
|
source_placeholders = extract_placeholders(source)
|
|
translated_placeholders = extract_placeholders(translation)
|
|
placeholders_valid = (
|
|
placeholders_are_subset(source, translation)
|
|
if allow_missing_placeholders
|
|
else placeholders_match(source, translation)
|
|
)
|
|
if not placeholders_valid:
|
|
problems.append(
|
|
f"placeholder mismatch: source={dict(source_placeholders)} translation={dict(translated_placeholders)}"
|
|
)
|
|
original_formats, translated_formats = fmt_arguments(source), fmt_arguments(translation)
|
|
if not set(translated_formats) <= set(original_formats) or (
|
|
not allow_missing_placeholders and set(original_formats) != set(translated_formats)
|
|
):
|
|
problems.append("fmt argument format specifications differ from the source")
|
|
source_filters = file_dialog_filter_patterns(source)
|
|
if source_filters is not None and file_dialog_filter_patterns(translation) != source_filters:
|
|
problems.append(
|
|
"file-dialog filter mismatch: preserve ASCII parentheses, ';;' separators, "
|
|
"and wildcard patterns in each filter"
|
|
)
|
|
return problems
|
|
|
|
|
|
def extra_rich_tags(source: str, translation: str) -> collections.Counter[str]:
|
|
return extract_rich_tags(translation) - extract_rich_tags(source)
|
|
|
|
|
|
def missing_rich_tags(source: str, translation: str) -> collections.Counter[str]:
|
|
return extract_rich_tags(source) - extract_rich_tags(translation)
|
|
|
|
|
|
def message_to_task_record(message: CatalogMessage) -> dict[str, object]:
|
|
return {
|
|
"record_type": "message",
|
|
"id": message.identifier,
|
|
**message.identity.as_payload(),
|
|
"locations": message.locations,
|
|
"current_type": message.translation_type,
|
|
"current_translation": message.translation,
|
|
"current_plural_translations": message.plural_translations,
|
|
"plural_arity": len(message.plural_translations),
|
|
"catalog_line": message.catalog_line,
|
|
"message_line": message.message_line,
|
|
"translator_comment": message.translator_comment,
|
|
"suggestions": [],
|
|
}
|
|
|
|
|
|
def load_jsonl(paths: Sequence[Path | str]) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
|
metadata: list[dict[str, object]] = []
|
|
records: list[dict[str, object]] = []
|
|
for path_value in paths:
|
|
path = Path(path_value)
|
|
with path.open("r", encoding="utf-8") as stream:
|
|
for line_number, line in enumerate(stream, 1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
item = json.loads(line)
|
|
except json.JSONDecodeError as error:
|
|
raise ValueError(f"{path}:{line_number}: invalid JSON: {error}") from error
|
|
if item.get("record_type") == "metadata":
|
|
metadata.append(item)
|
|
elif item.get("record_type") == "message":
|
|
records.append(item)
|
|
else:
|
|
raise ValueError(f"{path}:{line_number}: unknown record_type")
|
|
return metadata, records
|
|
|
|
|
|
def atomic_write_text(
|
|
path: Path, text: str, mode: int | None = None, expected_sha256: str | None = None
|
|
) -> None:
|
|
"""Write text beside its destination and atomically replace the destination."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if mode is None and path.exists():
|
|
mode = path.stat().st_mode
|
|
temporary_name: str | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", encoding="utf-8", newline="", dir=path.parent, delete=False
|
|
) as stream:
|
|
temporary_name = stream.name
|
|
stream.write(text)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
if mode is not None:
|
|
os.chmod(temporary_name, mode)
|
|
if expected_sha256 is not None and hashlib.sha256(path.read_bytes()).hexdigest() != expected_sha256:
|
|
raise ValueError(f"{path}: file changed during atomic update")
|
|
os.replace(temporary_name, path)
|
|
finally:
|
|
if temporary_name and os.path.exists(temporary_name):
|
|
os.unlink(temporary_name)
|
|
|
|
|
|
def jsonl_text(metadata: dict[str, object], records: Sequence[dict[str, object]]) -> str:
|
|
lines = [json.dumps(metadata, ensure_ascii=False, sort_keys=True)]
|
|
lines.extend(json.dumps(record, ensure_ascii=False, sort_keys=True) for record in records)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def text_sha256(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def scan_raw_messages(text: str) -> dict[str, RawMessageSpan]:
|
|
spans: dict[str, RawMessageSpan] = {}
|
|
newline_offsets = [index for index, character in enumerate(text) if character == "\n"]
|
|
for context_match in CONTEXT_RE.finditer(text):
|
|
context_block = context_match.group(0)
|
|
context_name_match = re.search(r"<name>(.*?)</name>", context_block, re.DOTALL)
|
|
if context_name_match is None:
|
|
continue
|
|
context_name = element_text(ET.fromstring(f"<name>{context_name_match.group(1)}</name>"))
|
|
occurrences: collections.Counter[tuple] = collections.Counter()
|
|
for message_match in MESSAGE_RE.finditer(context_block):
|
|
block = message_match.group(0)
|
|
message_element = ET.fromstring(block)
|
|
base = identity_base(context_name, message_element)
|
|
occurrence = occurrences[base]
|
|
occurrences[base] += 1
|
|
identity = MessageIdentity(*base[:5], occurrence, message_id=base[5])
|
|
translation = message_element.find("translation")
|
|
translation_type = "missing" if translation is None else translation.get("type", "finished")
|
|
absolute_start = context_match.start() + message_match.start()
|
|
absolute_end = context_match.start() + message_match.end()
|
|
translation_match = TRANSLATION_RE.search(block)
|
|
diagnostic_offset = absolute_start + (translation_match.start() if translation_match else 0)
|
|
span = RawMessageSpan(
|
|
identifier=identity.identifier,
|
|
start=absolute_start,
|
|
end=absolute_end,
|
|
block=block,
|
|
translation_type=translation_type,
|
|
numerus=message_element.get("numerus") == "yes",
|
|
catalog_line=bisect.bisect_left(newline_offsets, diagnostic_offset) + 1,
|
|
message_line=bisect.bisect_left(newline_offsets, absolute_start) + 1,
|
|
)
|
|
if span.identifier in spans:
|
|
raise ValueError(f"duplicate stable message id: {span.identifier}")
|
|
spans[span.identifier] = span
|
|
return spans
|
|
|
|
|
|
def xml_escape_text(text: str) -> str:
|
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def remove_unfinished_attribute(attributes: str) -> str:
|
|
attributes = re.sub(r"\s+type=(?:\"unfinished\"|'unfinished')", "", attributes)
|
|
return attributes.rstrip()
|
|
|
|
|
|
def replace_translation_node(block: str, singular: str | None, plurals: Sequence[str] | None) -> str:
|
|
match = TRANSLATION_RE.search(block)
|
|
if match is None:
|
|
closing = block.rfind("</message>")
|
|
if closing < 0:
|
|
raise ValueError("message has no closing element")
|
|
line_start = block.rfind("\n", 0, closing) + 1
|
|
message_indent = block[line_start:closing]
|
|
indent = f"{message_indent} "
|
|
attributes = ""
|
|
else:
|
|
attributes = remove_unfinished_attribute(match.group("attrs") or match.group("self_attrs") or "")
|
|
line_start = block.rfind("\n", 0, match.start()) + 1
|
|
indent = block[line_start:match.start()]
|
|
if plurals is not None:
|
|
forms = "\n".join(f"{indent} <numerusform>{xml_escape_text(value)}</numerusform>" for value in plurals)
|
|
replacement = f"<translation{attributes}>\n{forms}\n{indent}</translation>"
|
|
else:
|
|
if singular is None:
|
|
raise ValueError("missing singular target translation")
|
|
replacement = f"<translation{attributes}>{xml_escape_text(singular)}</translation>"
|
|
if match is None:
|
|
return f"{block[:line_start]}{indent}{replacement}\n{block[line_start:]}"
|
|
return block[: match.start()] + replacement + block[match.end() :]
|