mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
The one real regression predates this branch: Compose resources draw a value's
XML whitespace verbatim, where aapt collapsed it. Values wrapped over indented
lines rendered with a leading line break and eight spaces, which turns the
account-backup tips' markdown heading into a code block, and Crowdin's stray
translator edge spaces (" miejsca zniknęły", Hindi's " दि॰" suffix) showed up.
fix_escapes.py now applies aapt's rule to line-break/tab runs and trims an
edge space a translation has but its source lacks. The Crowdin workflow
already runs it after each sync, and compose_escaping_check.py now fails on raw
line breaks. 598 values across 54 files repaired.
Also:
- rememberPresentation resolves only the labels its diff type writes, instead
of all 16 on every recomposition. A test pins the list against presentationOf.
- ScheduledPostStore: a failed stat is "no file" (okio posix throws on EACCES
where File.exists() did not), symlinked stores still count, no chmod against
an injected non-system FileSystem, the cleanup log keeps its throwable.
- ClickableEmail percent-encodes '%' in the mailto: URI.
- Dropped a misleading WatchScrollToTop import, a stray blank line, and a stale
doc path; noted the Base64 pad-bit difference beside the decoder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY
152 lines
6.3 KiB
Python
Executable File
152 lines
6.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Convert Android string-resource escaping to what Compose resources understand.
|
|
|
|
Android's aapt and Compose Multiplatform do NOT share escaping rules. Moving a
|
|
strings.xml from `res/values/` to `composeResources/values/` verbatim therefore
|
|
renders some strings wrong -- the login screen showed `Don\\'t have a Nostr account?`
|
|
with a literal backslash.
|
|
|
|
Compose 1.11.1 `handleSpecialCharacters` (compose-gradle-plugin) resolves ONLY:
|
|
|
|
\\uXXXX, \\n, \\t and collapses \\\\ -> \\
|
|
|
|
It does not handle these Android conventions, so this script applies them itself:
|
|
|
|
\\' -> ' \\" -> " \\? -> ? \\@ -> @
|
|
"…" -> … (Android wraps a value in quotes to preserve leading/trailing
|
|
spaces; Compose would render the quotes literally)
|
|
|
|
\\n, \\t, \\uXXXX and \\\\ are deliberately left alone -- Compose already resolves
|
|
them, and converting them here would double-process.
|
|
|
|
Whitespace is the other half. aapt collapses every whitespace run in an unquoted
|
|
value to one space and trims the ends; Compose keeps the XML text verbatim. A value
|
|
wrapped over several indented lines therefore showed on Android as one line, and in
|
|
Compose as a leading line break plus eight spaces -- which CommonMark renders as a
|
|
code block (`account_backup_tips2_md`). This script applies aapt's rule to runs that
|
|
contain a line break or tab, which only XML layout produces (a deliberate newline is
|
|
written \\n). In a translation it also trims a leading/trailing space the source
|
|
string does not have: Crowdin exports a translator's stray edge space, aapt used to
|
|
drop it, Compose would draw it (" miejsca zniknęły" beside "tematy zniknęły").
|
|
Deliberate edge spaces (" and ") exist in the source too, and are kept.
|
|
|
|
Usage: fix_escapes.py <file-or-dir> [...] (idempotent; safe to re-run)
|
|
"""
|
|
import re
|
|
import sys
|
|
import os
|
|
|
|
STRING_EL = re.compile(r"(<(?:string|item)\b[^>]*>)(.*?)(</(?:string|item)>)", re.S)
|
|
# `tools:` attributes are an Android-lint construct. They arrive with strings moved out of
|
|
# res/values/, whose <resources> root declares xmlns:tools -- composeResources roots do not,
|
|
# so the prefix is unbound and the XML is malformed. Compose parses namespace-unaware and
|
|
# drops them silently today, but nothing should rely on that, and Android lint never runs on
|
|
# composeResources so they carry no meaning there either.
|
|
TOOLS_ATTR = re.compile(r'\s+tools:[\w.-]+="[^"]*"')
|
|
TOOLS_NS = re.compile(r'\s+xmlns:tools="[^"]*"')
|
|
# a backslash escape NOT itself preceded by a backslash
|
|
ANDROID_ONLY = re.compile(r"(?<!\\)\\(['\"?@])")
|
|
# A whitespace run that holds a line break or a tab: XML layout, never content.
|
|
LAYOUT_WS = re.compile(r"[ \t]*[\r\n\t][ \t\r\n]*")
|
|
STRING_NAME = re.compile(r'<string\b[^>]*\bname="([^"]+)"')
|
|
|
|
|
|
def normalize_whitespace(text: str, source: str = None) -> str:
|
|
"""Apply the aapt whitespace handling that Compose lacks. Idempotent.
|
|
|
|
[source] is the default-locale value of the same key when [text] is a translation.
|
|
"""
|
|
if "<![CDATA[" in text:
|
|
return text
|
|
text = LAYOUT_WS.sub(lambda m: "" if m.start() == 0 or m.end() == len(text) else " ", text)
|
|
if source is not None:
|
|
if not source[:1].isspace():
|
|
text = text.lstrip(" ")
|
|
if not source[-1:].isspace():
|
|
text = text.rstrip(" ")
|
|
return text
|
|
|
|
|
|
def fix_text(text: str, unwrap_quotes: bool = True, source: str = None) -> str:
|
|
"""Convert one element's text.
|
|
|
|
NOTE: quote-unwrapping is NOT idempotent and must run exactly once per file, at
|
|
migration time. Android wraps a value in quotes to protect whitespace, but after
|
|
`\"` has been converted to `"` a legitimately quoted value is indistinguishable
|
|
from a wrapped one -- a second pass strips the real quotes. Repair runs over
|
|
already-migrated files must therefore pass unwrap_quotes=False.
|
|
"""
|
|
if unwrap_quotes and len(text) >= 2 and text.startswith('"') and text.endswith('"'):
|
|
# Quoted: aapt kept this whitespace exactly, so Compose keeps it too.
|
|
return ANDROID_ONLY.sub(r"\1", text[1:-1])
|
|
return normalize_whitespace(ANDROID_ONLY.sub(r"\1", text), source)
|
|
|
|
|
|
def strip_android_only_attrs(src: str) -> tuple:
|
|
"""Drop `tools:` attributes (and any xmlns:tools) that mean nothing here."""
|
|
out, n = TOOLS_ATTR.subn("", src)
|
|
out, m = TOOLS_NS.subn("", out)
|
|
return out, n + m
|
|
|
|
|
|
def source_values(path: str) -> dict:
|
|
"""The default-locale values beside a `values-<locale>/strings.xml`, else {}."""
|
|
parent = os.path.dirname(os.path.abspath(path))
|
|
if not os.path.basename(parent).startswith("values-"):
|
|
return {}
|
|
default = os.path.join(os.path.dirname(parent), "values", os.path.basename(path))
|
|
if not os.path.exists(default):
|
|
return {}
|
|
out = {}
|
|
for m in STRING_EL.finditer(open(default, encoding="utf-8").read()):
|
|
name = STRING_NAME.match(m.group(1))
|
|
if name:
|
|
out[name.group(1)] = m.group(2)
|
|
return out
|
|
|
|
|
|
def fix_file(path: str, unwrap_quotes: bool = True) -> int:
|
|
src = open(path, encoding="utf-8").read()
|
|
sources = source_values(path)
|
|
changed = 0
|
|
|
|
def repl(m):
|
|
nonlocal changed
|
|
name = STRING_NAME.match(m.group(1))
|
|
fixed = fix_text(m.group(2), unwrap_quotes, sources.get(name.group(1)) if name else None)
|
|
if fixed != m.group(2):
|
|
changed += 1
|
|
return m.group(1) + fixed + m.group(3)
|
|
|
|
out = STRING_EL.sub(repl, src)
|
|
out, stripped = strip_android_only_attrs(out)
|
|
changed += stripped
|
|
if changed:
|
|
open(path, "w", encoding="utf-8").write(out)
|
|
return changed
|
|
|
|
|
|
def main(paths, unwrap_quotes=True):
|
|
total_files = total_entries = 0
|
|
for p in paths:
|
|
files = []
|
|
if os.path.isdir(p):
|
|
for root, _, names in os.walk(p):
|
|
files += [os.path.join(root, n) for n in names if n.endswith(".xml")]
|
|
else:
|
|
files = [p]
|
|
for f in sorted(files):
|
|
n = fix_file(f, unwrap_quotes)
|
|
if n:
|
|
total_files += 1
|
|
total_entries += n
|
|
print(f" {f}: {n} entries")
|
|
print(f"\nfixed {total_entries} entries across {total_files} files")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
args = [a for a in sys.argv[1:] if a != "--no-unwrap-quotes"]
|
|
if len(args) < 1:
|
|
sys.exit(__doc__)
|
|
main(args, unwrap_quotes="--no-unwrap-quotes" not in sys.argv)
|