mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose resources, markdown and desktop Compose as dependencies, dragging ~40 MB of UI/Skiko jars into every `amy` distribution. This moves every Compose-dependent file into a new KMP module, `:commonsUI`, that `api`-depends on `:commons`; `:commons` keeps only the Compose runtime (stability annotations + snapshot state) and lifecycle-viewmodel. Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split is a build-graph boundary and no consumer import changed. 236 files were `git mv`'d (composables, icons, robohash, theme, Coil fetchers, the `@Composable` relay-client entry points, `composeResources`, and the tests that exercise them). Two headless files needed surgery instead of a move: `GalleryParser` lost a vestigial foundation `@OptIn`, and the `LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of `PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds` and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels depend on them. `amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`, `geode` and `marmotBench` do not. commons' androidMain gains an explicit androidx.core KTX dep it previously got transitively through Compose UI. CI, crowdin, the icon-font tools and the escaping hook point at the new composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills document the boundary. A plan doc under commons/plans records the classification method and follow-ups. Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites; both verifyKmpPurity gates; the cli runtime classpath no longer resolves Compose UI, material3, Skiko or Coil. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
552 lines
41 KiB
Markdown
552 lines
41 KiB
Markdown
---
|
||
name: find-missing-translations
|
||
description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
|
||
---
|
||
|
||
# Find Missing Translations
|
||
|
||
## Overview
|
||
|
||
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
|
||
|
||
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
|
||
|
||
## When to Use
|
||
|
||
- Need to find untranslated strings for a specific locale
|
||
- Preparing a batch of strings for a translator
|
||
- Checking translation coverage after adding new features
|
||
|
||
## Resource trees (scan BOTH)
|
||
|
||
There are two separate `strings.xml` trees, each with its own default `values/` and per-locale `values-<locale>/` files, each wired into `crowdin.yml` independently:
|
||
|
||
| Tree | Default file | Per-locale file |
|
||
|------|--------------|-----------------|
|
||
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
|
||
| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` |
|
||
|
||
The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
|
||
|
||
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
|
||
|
||
**Overlap (copy — but only after checking the English matches):** a few `commons` keys share a *name* with a key in the amethyst tree. For such a key already translated in the amethyst locale file you may **copy the existing approved translation verbatim** — but **only if the two English source values are byte-identical.** A shared key name does **not** guarantee a shared meaning.
|
||
|
||
> ⚠️ **Mistake we actually made (2026-07-18):** `napplet_card_permissions` exists in *both* trees with the *same key name* but *different English* — commons = `"What it can access"`, amethyst = `"Permissions:"`. Copying the amethyst translation by key name produced the wrong string in commons (it said "Permissions:" where the UI reads "What it can access"). **Always diff the English values, not just the key names.** When the English differs, translate the commons value fresh — or, better, find the amethyst key whose *value* matches (here `favorite_app_access_show` = "What it can access") and copy *that* approved translation.
|
||
|
||
Detect name-overlap **and flag value mismatches** in one pass:
|
||
|
||
```bash
|
||
cdef=commonsUI/src/commonMain/composeResources/values/strings.xml
|
||
adef=amethyst/src/main/res/values/strings.xml
|
||
comm -12 \
|
||
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
|
||
<(grep '<string name=' "$adef" | grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
|
||
| while read -r k; do
|
||
cv=$(grep -m1 "name=\"$k\"" "$cdef" | sed 's/.*>\(.*\)<\/string>/\1/')
|
||
av=$(grep -m1 "name=\"$k\"" "$adef" | sed 's/.*>\(.*\)<\/string>/\1/')
|
||
[ "$cv" = "$av" ] && echo "SAFE-COPY $k" || echo "VALUE-DIFFERS $k commons=\"$cv\" amethyst=\"$av\""
|
||
done
|
||
```
|
||
|
||
Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the commons English fresh (or copy from the amethyst key that has the *matching value*).
|
||
|
||
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
|
||
|
||
```bash
|
||
grep -nE '<string name="[^"]*">"' commonsUI/src/commonMain/composeResources/values-*/strings.xml
|
||
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
|
||
```
|
||
|
||
**Why two catalogs exist — the duplication is NOT a bug to "fix" (don't ask again).** You will see the same English text (`Cancel`, `Save`, `Delete`, `Open`, …) defined *many* times across the amethyst tree under per-feature keys **and** once more in commons under generic keys (`action_cancel`, `action_save`, …). This is **required architecture, not an error:**
|
||
|
||
- The two trees are **different resource systems**: amethyst uses Android `R.string`; commons uses Compose-Multiplatform `Res.string` (`com.vitorpamplona.amethyst.commons.resources.Res`).
|
||
- **`commons` cannot depend on `amethyst`** (amethyst depends on commons — the reverse would be circular). So a composable extracted *into* commons physically cannot reference `R.string.cancel`; it needs its own string, hence the generic `action_*` keys. That is the only way an extracted shared composable can render "Cancel."
|
||
- The scattered amethyst per-feature duplicates (`nip46_signer_cancel`, `nest_create_cancel`, …) are **pre-existing tech debt**; the commons keys did not create them.
|
||
- Both catalogs are Crowdin-managed **independently**, and Crowdin's translation memory pre-fills repeats, so translating the same word in both trees is **not** wasted effort.
|
||
|
||
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
|
||
|
||
## Background: Crowdin strip-identical behavior
|
||
|
||
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
|
||
|
||
What this means for this skill:
|
||
|
||
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
|
||
2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
|
||
3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
|
||
|
||
4. **A repo-side edit to a translated value only sticks where Crowdin's database
|
||
doesn't contradict it.** Download replaces file content with Crowdin's current
|
||
export; it does not diff or merge. So a hand fix to a locale file survives only
|
||
if Crowdin happens to hold the same value (or holds nothing for that key). If
|
||
Crowdin holds a *different* value — including an **empty** one — the next sync
|
||
silently reverts you.
|
||
|
||
Observed 2026-08-13/14 in one pass, which is what makes the rule concrete:
|
||
`pow_estimate_minutes[few]` (pl) **survived** the sync because Crowdin's
|
||
approved value matched the fix, while `nest_listener_count[many]` (pl) was
|
||
**reverted to empty** two commits later because Crowdin stores an empty string
|
||
there. Same file, same commit, opposite outcomes.
|
||
|
||
Consequences: fixing a *value* durably means entering it in the Crowdin web UI
|
||
— no repo commit will hold it. Changes to the **source** file are different and
|
||
do stick, because that file is Crowdin's input, not its output: deleting a key
|
||
from `values/strings.xml` removes it project-wide, and attributes declared
|
||
there propagate into every export.
|
||
|
||
> **Historical note:** an earlier version of this skill tried to auto-filter the
|
||
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
|
||
> the last `New Crowdin translations` commit). It was **dropped** because it
|
||
> produced false negatives: a key added shortly before an export that translators
|
||
> simply hadn't reached yet is genuinely missing, but the heuristic classified it
|
||
> as "Crowdin already decided." Trust the raw diff + the Crowdin UI instead.
|
||
|
||
## Target Locales
|
||
|
||
The default set of locales (unless the user specifies otherwise):
|
||
|
||
| Locale | Language | Directory |
|
||
|--------|----------|-----------|
|
||
| `cs` | Czech | `values-cs` |
|
||
| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` |
|
||
| `sv-rSE` | Swedish | `values-sv-rSE` |
|
||
| `de-rDE` | German | `values-de-rDE` |
|
||
|
||
> Czech was consolidated onto the base qualifier (PR #3461, 2026-07-03): a
|
||
> `cs: cs` `languages_mapping` entry in `crowdin.yml` makes Crowdin export to
|
||
> `values-cs`, and `values-cs-rCZ` no longer exists. The other locales still
|
||
> use Crowdin's default region-qualified `androidCode` until they are
|
||
> consolidated the same way — update this table as each one moves.
|
||
|
||
## Technique
|
||
|
||
### 1. Identify files
|
||
|
||
Do this for **each** resource tree (see "Resource trees" above). The examples below use the amethyst base path; repeat every step with the commons base path swapped in.
|
||
|
||
```
|
||
# amethyst tree
|
||
Default: amethyst/src/main/res/values/strings.xml
|
||
Target: amethyst/src/main/res/values-<locale>/strings.xml
|
||
|
||
# commons tree
|
||
Default: commonsUI/src/commonMain/composeResources/values/strings.xml
|
||
Target: commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml
|
||
```
|
||
|
||
A convenient way to run the whole technique twice is to loop over the two base dirs:
|
||
|
||
```bash
|
||
for base in amethyst/src/main/res commonsUI/src/commonMain/composeResources; do
|
||
echo "########## tree: $base ##########"
|
||
# ... run the diff/count/value-extraction commands with $base/values[...] ...
|
||
done
|
||
```
|
||
|
||
### 2. Find missing keys using cs as reference
|
||
|
||
Always diff against `cs` first — it is the most complete locale and serves as the reference. Any keys missing in `cs` will also be missing in the other target locales.
|
||
|
||
You MUST diff **both** `<string name=` AND `<plurals name=` — these are independent resource types and a key that is a `<plurals>` in the source will never appear in a `<string>` diff. Forgetting `<plurals>` is the most common silent failure of this skill (it misses things like `music_playlist_track_count`, `notification_count_more`, etc.).
|
||
|
||
```bash
|
||
# Strings: extract translatable keys from default (exclude translatable="false")
|
||
echo "=== missing <string> ==="
|
||
comm -23 \
|
||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||
| grep -v 'translatable="false"' \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
|
||
|
||
# Plurals: a separate resource type — MUST be diffed independently
|
||
echo "=== missing <plurals> ==="
|
||
comm -23 \
|
||
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<plurals name=' amethyst/src/main/res/values-cs/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
|
||
```
|
||
|
||
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
|
||
|
||
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
|
||
|
||
```bash
|
||
for locale in cs de-rDE sv-rSE pt-rBR; do
|
||
ns=$(comm -23 \
|
||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||
| grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<string name=' amethyst/src/main/res/values-$locale/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) | wc -l)
|
||
np=$(comm -23 \
|
||
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<plurals name=' amethyst/src/main/res/values-$locale/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) | wc -l)
|
||
echo "$locale: strings=$ns plurals=$np total=$((ns+np))"
|
||
done
|
||
```
|
||
|
||
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
|
||
|
||
### 3. Get English values for missing keys
|
||
|
||
For each missing key, extract its English value. `<string>` is a single line; `<plurals>` is a multi-line block — handle each appropriately.
|
||
|
||
```bash
|
||
# Missing <string>: full line from default strings.xml
|
||
while IFS= read -r key; do
|
||
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
|
||
done < <(comm -23 \
|
||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||
| grep -v 'translatable="false"' \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||
|
||
# Missing <plurals>: extract the multi-line block (opening tag through </plurals>)
|
||
while IFS= read -r key; do
|
||
awk -v key="$key" '
|
||
$0 ~ "<plurals name=\"" key "\"" { in_p = 1 }
|
||
in_p { print }
|
||
in_p && /<\/plurals>/ { in_p = 0 }
|
||
' amethyst/src/main/res/values/strings.xml
|
||
done < <(comm -23 \
|
||
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<plurals name=' amethyst/src/main/res/values-cs/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||
```
|
||
|
||
### 4. Audit missing strings for plural-shaped patterns
|
||
|
||
Before presenting results, **scan the missing English strings** for two red-flag patterns and warn the user about each match:
|
||
|
||
1. **Hardcoded `"1"` next to a noun.** A new English string like `"1 reply"`, `"1 follower"`, or `"1 minute ago"` almost always belongs in a `<plurals>` resource — not a `<string>`. Hardcoding `1` in English forces every translator to either also hardcode `1` (breaking languages where the `one` category covers other numbers, e.g. some Slavic languages) or to silently change the meaning.
|
||
2. **A `%d` / `%1$d` placeholder in a clearly singular/plural sentence** (e.g. `"%1$d reply"`, `"%d follower"`). Even though the placeholder is parameterised, English-only `one`/`other` agreement won't survive translation into languages that need `few`/`many`.
|
||
|
||
Also **audit existing `<plurals>` resources** for two anti-patterns:
|
||
|
||
1. **`quantity="one"` items that hardcode the literal `1`** (instead of using a `%d` / `%1$d` placeholder) — broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.).
|
||
2. **`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. everything except **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and most of the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
|
||
|
||
> ⚠️ **Latvian is the trap here — do NOT strip its `zero` items** (we nearly did, 2026-07-22). `lv` has an integer-bearing `zero` category that covers far more than 0: `select(0)`, `select(10)` and `select(11)` all return `zero` (the rule is `n % 10 = 0` or `n % 100 = 11..19`). So a Latvian `<item quantity="zero">` is *live code on the majority of counts*, and it must read as a normal plural form ("%1$d minūšu"), **not** as "no items" wording. An earlier version of this skill claimed only `ar` and `cy` had `zero`, which flagged all ~40 correct Latvian entries as dead and would have deleted working translations.
|
||
|
||
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item. (This is why `zero` is the wrong tool even where it exists: in `lv` it does not mean "zero".)
|
||
|
||
**Verify, don't recall.** Before asserting any locale's category set, check it against CLDR rather than memory:
|
||
|
||
```bash
|
||
python3 -m venv /tmp/cldr && /tmp/cldr/bin/pip -q install babel
|
||
/tmp/cldr/bin/python -c "
|
||
from babel import Locale
|
||
for c in ['en','lv','ar','cy','cs','de','sv','pt_BR','ru','pl']:
|
||
r = Locale.parse(c).plural_form
|
||
print(c, sorted({r(n) for n in range(0,10001)}), 'select(0)=', r(0), 'select(10)=', r(10))
|
||
"
|
||
```
|
||
|
||
Across the 56 locale dirs this repo ships, **only `ar-rSA` and `lv-rLV`** have an integer-bearing `zero`.
|
||
|
||
Flag and offer to fix:
|
||
|
||
```bash
|
||
# Scan every locale's strings.xml for <item quantity="one"> entries that
|
||
# hardcode "1" (or other literal digits) instead of using a placeholder.
|
||
# Looks at default + all values-* locales, in BOTH resource trees.
|
||
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
|
||
commonsUI/src/commonMain/composeResources/values/strings.xml \
|
||
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
|
||
awk -v file="$f" '
|
||
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
|
||
in_plurals && /quantity="one"/ {
|
||
# Extract item text (between > and <)
|
||
text = $0; sub(/^[^>]*>/, "", text); sub(/<.*$/, "", text)
|
||
# Flag if it contains a digit AND no %d / %1$d placeholder
|
||
if (text ~ /[0-9]/ && text !~ /%[0-9]*\$?d/) {
|
||
print file ": <plurals name=\"" name "\"> one=\"" text "\""
|
||
}
|
||
}
|
||
/<\/plurals>/ { in_plurals = 0 }
|
||
' "$f"
|
||
done
|
||
```
|
||
|
||
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)** — those three are skipped below, so a hit is a genuine bug. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
|
||
|
||
```bash
|
||
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
|
||
commonsUI/src/commonMain/composeResources/values/strings.xml \
|
||
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
|
||
# Skip Arabic, Latvian and Welsh — they natively use the zero category.
|
||
# (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.)
|
||
case "$f" in
|
||
*values-ar*|*values-cy*|*values-lv*) continue ;;
|
||
esac
|
||
awk -v file="$f" '
|
||
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
|
||
in_plurals && /quantity="zero"/ {
|
||
text = $0; sub(/^[^>]*>/, "", text); sub(/<.*$/, "", text)
|
||
print file ": <plurals name=\"" name "\"> zero=\"" text "\""
|
||
}
|
||
/<\/plurals>/ { in_plurals = 0 }
|
||
' "$f"
|
||
done
|
||
```
|
||
|
||
For each hit, warn the user that the entry is unreachable in that locale. The fix is to **remove the `<item quantity="zero">`** and, if the UX wanted distinct wording for count=0, add a separate `<string>` plus an `if (count == 0)` branch at the call site (see "Plurals: handle with care" below).
|
||
|
||
Also audit **format-specifier parity and empty items** across the locales you
|
||
touched. These are a different defect class from a missing key — the key is
|
||
present and looks translated, but the placeholder was dropped, escaped, or the
|
||
item left blank, so the number never reaches the user:
|
||
|
||
```bash
|
||
python3 - <<'PY'
|
||
import re, io, glob
|
||
keyre = re.compile(r'<string name="([^"]+)"[^>]*>(.*?)</string>', re.S)
|
||
plre = re.compile(r'<plurals name="([^"]+)"[^>]*>(.*?)</plurals>', re.S)
|
||
itre = re.compile(r'<item quantity="([^"]+)"[^>]*>(.*?)</item>', re.S)
|
||
# (?<!\\) is REQUIRED: \%2$d is an escaped literal, not a placeholder.
|
||
phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])')
|
||
sig = lambda t: sorted(m.group(0) for m in phre.finditer(t))
|
||
for base in ['amethyst/src/main/res', 'commonsUI/src/commonMain/composeResources']:
|
||
d = io.open(f'{base}/values/strings.xml', encoding='utf-8').read()
|
||
dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)}
|
||
dpl = {}
|
||
for m in plre.finditer(d):
|
||
s = set()
|
||
for it in itre.finditer(m.group(2)): s.update(sig(it.group(2)))
|
||
dpl[m.group(1)] = sorted(s)
|
||
for p in sorted(glob.glob(f'{base}/values-*/strings.xml')):
|
||
if '/values-ar' in p: continue # see caveat below
|
||
s = io.open(p, encoding='utf-8').read()
|
||
for m in keyre.finditer(s):
|
||
k, v = m.group(1), m.group(2)
|
||
if k in dstr and sig(v) != dstr[k]:
|
||
print(f'{p}\n {k} base={dstr[k]} loc={sig(v)}')
|
||
for m in plre.finditer(s):
|
||
k = m.group(1)
|
||
if k not in dpl: continue
|
||
for it in itre.finditer(m.group(2)):
|
||
if sig(it.group(2)) != dpl[k]:
|
||
print(f'{p}\n {k}[{it.group(1)}] base={dpl[k]} loc={sig(it.group(2))}')
|
||
PY
|
||
|
||
# Empty plural items render as nothing at runtime — always a bug.
|
||
grep -rn '<item quantity="[a-z]*"></item>' \
|
||
amethyst/src/main/res/values*/strings.xml \
|
||
commonsUI/src/commonMain/composeResources/values*/strings.xml
|
||
```
|
||
|
||
Three things this scan taught us, all of which it now encodes:
|
||
|
||
- **The `(?<!\\)` lookbehind is not optional.** Without it the scan matches
|
||
`%2$d` *inside* `\%2$d` and scores a broken string clean. `\%` is not a
|
||
recognised Android escape, but lint reads it as one, so the placeholder is
|
||
reported missing. (2026-08-13: `nip46_signer_relays_some_down` in sl-rSI
|
||
survived a "clean" sweep exactly this way.)
|
||
- **Skip Arabic.** Its `zero`/`one`/`two` forms omit the numeral idiomatically
|
||
("دقيقتان" = "two minutes"), so ~30 hits there are correct translations, not
|
||
defects. Everything else is worth reading.
|
||
- **Repeated indices are legitimate.** `%1$s` appearing twice in a translation
|
||
where the base uses it once is normal — German repeats the name where English
|
||
says "They". Compare *sets*, and treat an arity difference as a question, not
|
||
a verdict.
|
||
|
||
Quick scan over the missing keys:
|
||
|
||
```bash
|
||
# Flag missing English values that look like they should be <plurals>
|
||
while IFS= read -r key; do
|
||
line=$(grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml)
|
||
# Hardcoded standalone "1" (word-boundary), or a count placeholder followed by a likely-countable noun
|
||
if echo "$line" | grep -qE '>([^<]*\b1\b[^<]*|[^<]*%[0-9]*\$?d[^<]*)<'; then
|
||
echo "PLURAL CANDIDATE: $line"
|
||
fi
|
||
done < <(comm -23 \
|
||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||
| grep -v 'translatable="false"' \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||
```
|
||
|
||
The regex is intentionally noisy — review each hit by hand. Many `%d` strings (e.g. `"Limits for kind %1$d"`, `"Max event size (bytes)"`) are *not* plural-bearing. Only flag the ones whose surrounding noun changes form with the count.
|
||
|
||
For each genuine match, **stop and warn the user before translating**, e.g.:
|
||
|
||
> ⚠️ `notification_count` is `"1 new reply"` — this hardcodes `"1"` and should likely be a `<plurals>` resource (e.g. `quantity="one"` → `"%d new reply"`, `quantity="other"` → `"%d new replies"`). Convert before translating?
|
||
|
||
Do not silently translate plural-shaped `<string>` entries; the wrong shape will then need to be fixed in every locale.
|
||
|
||
### 5. Present results and ask to translate
|
||
|
||
Output the missing entries as raw XML resource lines (copy-paste ready):
|
||
|
||
```xml
|
||
<string name="attestation_valid">Valid</string>
|
||
<string name="attestation_valid_from">Valid from %1$s</string>
|
||
<string name="feed_group_lists">Lists</string>
|
||
```
|
||
|
||
Also check `<string-array>` and `<plurals>` tags using the same approach if the project uses them.
|
||
|
||
#### Plurals: handle with care
|
||
|
||
When adding or proposing **`<plurals>`** entries, follow these rules:
|
||
|
||
- **Never hardcode `"1"`** in the English text of a `quantity="one"` item. Use the format placeholder (e.g. `%1$d` / `%d`) so the runtime substitutes the actual count. Hardcoding `"1"` breaks every language whose `one` category covers numbers other than 1 (e.g. some Slavic languages).
|
||
- **Don't assume `one` + `other` is enough.** CLDR plural categories vary by language: `zero`, `one`, `two`, `few`, `many`, `other`. Always include **every category the target language uses**, not just the categories present in English. Examples:
|
||
- English (`en`): `one`, `other`
|
||
- Czech (`cs`): `one`, `few`, `many`, `other`
|
||
- Polish (`pl`): `one`, `few`, `many`, `other`
|
||
- Russian (`ru`): `one`, `few`, `many`, `other`
|
||
- Arabic (`ar`): `zero`, `one`, `two`, `few`, `many`, `other`
|
||
- Latvian (`lv`): `zero`, `one`, `other` — its `zero` is **not** "no items"; it covers 0, 10, 11–19, 20, 30, …
|
||
- German / Swedish / Brazilian Portuguese: `one`, `other`
|
||
- When a missing string contains a count placeholder and is conceptually a singular/plural pair, **flag it before translating** — it may belong as a `<plurals>` resource rather than a single `<string>`. Surface this to the user before proposing translations.
|
||
- **Do not use `quantity="zero"` outside Arabic (`ar`), Latvian (`lv`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those three languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. Conversely, **never delete an existing `zero` item from `ar`/`lv`/`cy`** — there it is live. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
|
||
```kotlin
|
||
val label = if (count == 0) {
|
||
stringRes(R.string.foo_no_items, dateLabel)
|
||
} else {
|
||
pluralStringResource(R.plurals.foo_items, count, dateLabel, count)
|
||
}
|
||
```
|
||
- **Converting an existing `<string>` to `<plurals>`: give every locale its FULL
|
||
category set, not just `other`.** You must convert it in every locale that
|
||
already had the `<string>` (aapt2 rejects a resource-type mismatch across
|
||
locales, and an orphaned locale `<string>` trips `ExtraTranslation`) — but
|
||
carrying the old text across as an `other`-only block, on the theory that
|
||
Crowdin backfills the rest, **fails `MissingQuantity` and breaks CI before
|
||
Crowdin ever gets a turn.** Supply `one`/`few`/`many` for pl, `one` for hu, and
|
||
so on, at conversion time.
|
||
|
||
Note this contradicts `amethyst/src/main/res/CLAUDE.md` step 3, which still
|
||
advises the `other`-only shortcut. That advice is wrong; prefer this.
|
||
|
||
Watch the declension when you do it: the retained text is usually the *plural*
|
||
form, so reusing it verbatim for `one` produces "1 odpowiedzi". (2026-08-13:
|
||
converting `poll_results_selections` with `other` only errored on both hu and
|
||
pl, and the retained pl text was the few/many form.)
|
||
|
||
- **A `tools:ignore` suppression must go on the SOURCE entry in
|
||
`values/strings.xml`, never on a locale file.** Crowdin propagates attributes
|
||
declared on the source into every translation it exports; an attribute you add
|
||
to `values-xx/strings.xml` alone is simply absent from the next export. That is
|
||
why the existing `tools:ignore="Typos"` entries survive — they are declared on
|
||
the source, and the copies in cs/de/ar/eo/bn are the *result* of propagation,
|
||
not evidence that locale-file attributes stick. (2026-08-13: an
|
||
`ImpliedQuantity` suppression added only to `values-pt-rBR` was stripped by the
|
||
next sync and took `main`'s CI red.)
|
||
|
||
Before reaching for a suppression at all, check whether the key is even used —
|
||
a `grep -rn "<key>" --include='*.kt'` that returns nothing means deleting the
|
||
key is the better fix than muting the rule that objects to it.
|
||
|
||
- Reference: [Android `<plurals>` docs](https://developer.android.com/guide/topics/resources/string-resource#Plurals) and [CLDR plural rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html).
|
||
|
||
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
|
||
|
||
### 6. Adding translations (if approved)
|
||
|
||
When adding translated strings to locale files:
|
||
|
||
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
|
||
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
|
||
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
|
||
|
||
```bash
|
||
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
|
||
for l in cs de-rDE sv-rSE pt-rBR; do
|
||
missing=$(comm -23 \
|
||
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||
<(grep '<string name=' $base/values-$l/strings.xml \
|
||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
|
||
done
|
||
```
|
||
|
||
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
|
||
|
||
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
|
||
|
||
```bash
|
||
for f in <every edited strings.xml>; do
|
||
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
|
||
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
|
||
[ -n "$dups" ] && echo "DUP in $f: $dups"
|
||
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|
||
|| echo "MALFORMED $f"
|
||
done
|
||
# For a commons change, also run the build task that enforces this:
|
||
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
|
||
```
|
||
|
||
- **Then run Android lint. This is the gate that actually matches CI, and the
|
||
checks above do NOT substitute for it.** Duplicate-key + well-formedness +
|
||
`convertXmlValueResourcesForCommonMain` can all pass on a change that still
|
||
takes CI red, because the plural rules live in lint, not in the resource
|
||
compiler:
|
||
|
||
```bash
|
||
./gradlew :amethyst:lintPlayBenchmark # the task CI runs (.github/workflows/build.yml)
|
||
```
|
||
|
||
There is no `lint-baseline.xml` in this repo and only `MissingTranslation` is
|
||
disabled (`amethyst/build.gradle.kts`), so `abortOnError` bites on the first
|
||
error. Three rules matter for a translation pass:
|
||
|
||
| Rule | Fires when | Severity |
|
||
|------|-----------|----------|
|
||
| `MissingQuantity` | a locale's `<plurals>` omits a CLDR category that locale uses | **error** for core categories — gates CI |
|
||
| `ImpliedQuantity` | a `quantity` item has no format argument in a locale where that category spans more than one number | **error** — gates CI |
|
||
| `StringFormatCount` / `StringFormatMatches` | a translation's placeholder count/type disagrees with the base entry | warning |
|
||
|
||
(2026-08-13: a pass that cleared the duplicate/XML gate above still failed
|
||
`lintPlayBenchmark` with 3 errors. Compiling is not evidence — `compileDebugKotlin`
|
||
passed on the same change.)
|
||
|
||
- **Confirm the report says zero errors, don't just trust BUILD SUCCESSFUL** of a
|
||
wider invocation:
|
||
|
||
```bash
|
||
python3 -c "
|
||
import json,io,collections
|
||
d=json.load(io.open('amethyst/build/reports/lint-results-playBenchmark.sarif',encoding='utf-8'))
|
||
r=d['runs'][0]['results']
|
||
print(dict(collections.Counter(x.get('level','warning') for x in r)))
|
||
for x in r:
|
||
if x.get('level')=='error': print('ERROR', x['ruleId'], x['locations'][0]['physicalLocation']['artifactLocation']['uri'])
|
||
"
|
||
```
|
||
|
||
## Common Mistakes
|
||
|
||
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commonsUI/src/commonMain/composeResources`). A key extracted into `commonsUI/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
|
||
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
|
||
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
|
||
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
|
||
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
|
||
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
|
||
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
|
||
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
|
||
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
|
||
- **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.)
|
||
- **Converting a `<string>` to `<plurals>` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`.
|
||
- **Renaming or removing a key in `values/strings.xml` without deleting it from every locale in the same commit** — the surviving locale entries become orphans, and `ExtraTranslation` is an error. "Crowdin drops retired keys on its next sync" is the same false belief as the `other`-only shortcut above: lint runs on the tree you push. Worse, it's *partly* true — the sync cleans some locales and silently leaves others, so the files you happen to open look fine. Scan with the sub-second `.claude/hooks/orphan_strings_check.py` instead of the ~19-minute lint; see `amethyst/src/main/res/CLAUDE.md`, "Renaming or removing a string key". (Happened 2026-08-31: `route_video`/`new_short` left in 15 of 47 locales, 30 errors, red `main`.)
|
||
- **Putting `tools:ignore` on a locale file** — Crowdin strips it on the next export. Suppressions belong on the source entry in `values/strings.xml`, which propagates. The `tools:ignore="Typos"` copies visible in cs/de/ar/eo/bn are the *result* of that propagation, not proof that locale-file attributes survive. (Happened 2026-08-13; it broke `main`.)
|
||
- **Suppressing a lint rule on a key nothing references** — check `grep -rn "<key>" --include='*.kt'` first. `poll_results_voters` was a bare noun with no count, zero call sites, and an unlocalizable shape; deleting it retired the problem outright where a suppression would only have muted it.
|
||
- **Comparing placeholders without a `(?<!\\)` guard** — `\%2$d` is an escaped literal to lint, but a naive `%\d+\$[sd]` regex matches the placeholder inside it and reports the string clean. A parity sweep missing this guard will certify a broken translation. Also treat a *repeated* index (`%1$s` twice where the base has it once) as legitimate — German does this where English says "They".
|
||
- **Reading an empty `<item quantity="…"></item>` as merely "untranslated"** — it renders as nothing at runtime, and for a category like Polish `many` (5–21, 25–31, …) that is the common case, not an edge case. Grep for them explicitly; the missing-key diff will never surface one because the key is present.
|
||
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
|
||
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
|
||
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
|
||
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic, Latvian and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
|
||
- **Reporting Latvian `quantity="zero"` entries as dead code** — `lv` has a real, integer-bearing `zero` category covering 0, 10, 11–19, 20, 30, … so those entries fire on *most* counts. An earlier version of this skill excluded only `ar`/`cy` from the zero audit and flagged all ~40 correct `values-lv-rLV` entries; acting on that would have deleted working translations. Confirm any locale's category set against CLDR (the babel snippet in Step 4) before calling a `zero` item dead. |