| 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 |
| commons (KMP Compose resources, shared by Android + Desktop) | commons/src/commonMain/composeResources/values/strings.xml | commons/src/commonMain/composeResources/values-<locale>/strings.xml |
The commons tree appeared when shared event-renderer composables were extracted out of amethyst/ into commons/ (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:
cdef=commons/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:
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml
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:
- 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.
- 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.
- 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.
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: commons/src/commonMain/composeResources/values/strings.xml
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
A convenient way to run the whole technique twice is to loop over the two base dirs:
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do
echo "########## tree: $base ##########"
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.).
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)
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":
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.
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))
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:
- 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.
- 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:
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.).
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:
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:
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/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):
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
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).
Quick scan over the missing keys:
while IFS= read -r key; do
line=$(grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml)
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):
<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 () and Welsh (). CLDR's category is integer-bearing only in those three languages. Android calls for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns , so the explicit is never picked at runtime and the user sees instead of the intended wording. Conversely, — there it is live. If the design calls for "no items" at count=0, model it as a separate and an branch at the call site:
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:
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))
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:
Common Mistakes
- Scanning only the amethyst tree — there are now two Crowdin-managed
strings.xml trees (amethyst/src/main/res and commons/src/commonMain/composeResources). A key extracted into commons/ 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, -style strings); don't translate them to an identical value.