Files
amethyst/tools/strings-migrate/migrate.py
T
Vitor PamplonaandClaude Opus 5 810e342bc1 fix(strings): convert Android escaping when moving strings to compose resources
Strings moved from res/values/ into composeResources/values/ kept Android's
escaping, which Compose does not interpret the same way, so the login screen
rendered `Don\'t have a Nostr account?` with a literal backslash and the terms
line showed stray quotes.

Compose 1.11.1 handleSpecialCharacters resolves only \uXXXX, \n and \t (and
collapses \\). It leaves \' \" \? \@ alone, and renders Android's quote-wrapping
-- used to preserve leading/trailing spaces, e.g. " Following" -- literally.

Convert those four escapes and unwrap the quotes, leaving \n, \t, \uXXXX and \\
untouched so Compose still resolves them. 3,717 entries across 56 locale files;
translations were hit far harder than English (Uzbek 964, French ~340 per
variant, Turkish ~208) because those languages use apostrophes heavily.

migrate.py now applies the same conversion as it moves each element, so the next
wave cannot reintroduce this; fix_escapes.py repairs what is already migrated and
is idempotent.

Verified on a Pixel 9 emulator: "Event is loading or can't be found in your relay
list" now renders with a real apostrophe, and no visible text node contains a
literal backslash escape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 11:46:35 -04:00

118 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
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.
"""
return STRING_EL.sub(lambda m: m.group(1) + fix_text(m.group(2)) + m.group(3), element)
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:])