mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
`tools:` attributes are an Android-lint construct. They arrived with strings moved out of amethyst/src/main/res/, whose <resources> root declares xmlns:tools -- composeResources roots do not, so the prefix was unbound and the XML malformed: 97 occurrences across 49 locale files, none of them declaring the namespace. Nothing was visibly broken, because Compose parses namespace-unaware and drops the unknown attribute (no .cvr contains it). But nothing should rely on that, and Android lint never runs on composeResources, so the attribute carried no meaning there either. migrate.py now strips tools: attributes as it moves each element, so the remaining migration waves cannot reintroduce them. Also fixes a hazard in fix_escapes.py found while doing this: quote-unwrapping is NOT idempotent. Android wraps a value in quotes to protect whitespace, but once \" has been converted to ", a legitimately quoted value is indistinguishable from a wrapped one, and a second pass strips the real quotes -- it silently damaged 10 `import_follows_tips` translations before this was caught. Unwrapping is now opt-out via --no-unwrap-quotes for repair runs over already-migrated files, and documented as run-exactly-once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
119 lines
4.4 KiB
Python
Executable File
119 lines
4.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Move string keys from the Android app's res/ to commons Compose resources.
|
|
|
|
Usage: tools/strings-migrate/migrate.py key1 key2 ...
|
|
|
|
For the default locale and every values-* locale dir, each named
|
|
<string> or <plurals> element is removed from
|
|
amethyst/src/main/res/<values*>/strings.xml
|
|
and appended (before </resources>) to
|
|
commons/src/commonMain/composeResources/<values*>/strings.xml
|
|
creating the commons locale file when a translation exists that commons
|
|
doesn't carry yet. Elements are moved verbatim (raw XML slice), so
|
|
escaping, CDATA and comments inside values survive byte-for-byte and
|
|
Crowdin sees a pure move.
|
|
|
|
Fails loudly when a key's default-locale value uses non-positional format
|
|
specifiers (bare %s / %d): compose-resources only formats the positional
|
|
%1$s form, so those keys must be rewritten (in code AND all locales)
|
|
before migrating. Run from the repo root.
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
from fix_escapes import STRING_EL, fix_text, strip_android_only_attrs
|
|
|
|
APP_RES = "amethyst/src/main/res"
|
|
COMMONS_RES = "commons/src/commonMain/composeResources"
|
|
|
|
NEW_FILE_TEMPLATE = '<?xml version="1.0" encoding="utf-8"?>\n<resources>\n</resources>\n'
|
|
|
|
# %s, %d, %.2f ... without a position (%1$s). %% is a literal percent.
|
|
BARE_FORMAT = re.compile(r"%(?!%)(?!\d+\$)[-#+ 0,(]*\d*(?:\.\d+)?[a-zA-Z]")
|
|
|
|
|
|
def element_pattern(key: str) -> re.Pattern:
|
|
# `name` is not always the first attribute (Crowdin emits e.g.
|
|
# `<string xmlns:ns0="..." name="key" ns0:ignore="Typos">`), so allow
|
|
# any attributes before it.
|
|
return re.compile(
|
|
r"[ \t]*<(string|plurals)\b[^>]*?\sname=\"" + re.escape(key) + r"\"[^>]*?(?:/>|>.*?</\1>)[ \t]*\n?",
|
|
re.S,
|
|
)
|
|
|
|
|
|
def extract(path: str, key: str):
|
|
"""Remove key's element from path; return the element text or None."""
|
|
if not os.path.exists(path):
|
|
return None
|
|
src = open(path, encoding="utf-8").read()
|
|
m = element_pattern(key).search(src)
|
|
if not m:
|
|
return None
|
|
open(path, "w", encoding="utf-8").write(src[: m.start()] + src[m.end() :])
|
|
return m.group(0).strip("\n")
|
|
|
|
|
|
def convert_escaping(element: str) -> str:
|
|
"""Rewrite Android-only escaping that Compose resources do not understand.
|
|
|
|
Compose resolves \\uXXXX, \\n and \\t itself but leaves \\', \\", \\? and \\@
|
|
alone, and renders Android's quote-wrapping literally. Moving an element verbatim
|
|
therefore shipped `Don\\'t have a Nostr account?` to the login screen. See
|
|
fix_escapes.py, which repairs files already migrated.
|
|
"""
|
|
element = STRING_EL.sub(lambda m: m.group(1) + fix_text(m.group(2)) + m.group(3), element)
|
|
return strip_android_only_attrs(element)[0]
|
|
|
|
|
|
def append(path: str, elements: list):
|
|
if os.path.exists(path):
|
|
src = open(path, encoding="utf-8").read()
|
|
else:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
src = NEW_FILE_TEMPLATE
|
|
close = src.rindex("</resources>")
|
|
block = "".join(" " + convert_escaping(el.strip()) + "\n" for el in elements)
|
|
open(path, "w", encoding="utf-8").write(src[:close] + block + src[close:])
|
|
|
|
|
|
def main(keys):
|
|
default_src = os.path.join(APP_RES, "values", "strings.xml")
|
|
default_text = open(default_src, encoding="utf-8").read()
|
|
|
|
for key in keys:
|
|
m = element_pattern(key).search(default_text)
|
|
if not m:
|
|
sys.exit(f"ERROR: key '{key}' not found in {default_src}")
|
|
if BARE_FORMAT.search(m.group(0)):
|
|
sys.exit(
|
|
f"ERROR: key '{key}' uses a non-positional format specifier (bare %s/%d).\n"
|
|
"compose-resources only formats %1$s-style args - rewrite the key "
|
|
"(code + every locale) before migrating."
|
|
)
|
|
|
|
locale_dirs = ["values"] + sorted(
|
|
d for d in os.listdir(APP_RES) if d.startswith("values-") and os.path.exists(os.path.join(APP_RES, d, "strings.xml"))
|
|
)
|
|
|
|
moved_total = 0
|
|
for d in locale_dirs:
|
|
src_path = os.path.join(APP_RES, d, "strings.xml")
|
|
elements = []
|
|
for key in keys:
|
|
el = extract(src_path, key)
|
|
if el is not None:
|
|
elements.append(el)
|
|
if elements:
|
|
append(os.path.join(COMMONS_RES, d, "strings.xml"), elements)
|
|
moved_total += len(elements)
|
|
print(f"{d}: moved {len(elements)}")
|
|
print(f"done: {moved_total} elements across {len(locale_dirs)} locale dirs")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
sys.exit(__doc__)
|
|
main(sys.argv[1:])
|