Skip to content

Add Crowdin translations for all mods - #1213

Open
Deftu wants to merge 2 commits into
v1from
feat/crowdin
Open

Deftu wants to merge 2 commits into
v1from
feat/crowdin

Conversation

@Deftu

@Deftu Deftu commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Sets up Crowdin so we can translate every mod in OneClient, including third-party ones and OneConfig itself. Translations are downloaded at runtime, which means nothing gets committed back to the repo and mods don't need to ship anything new.

How it works

  • A workflow pulls the English strings and existing translations out of every mod jar in the OneClient bundles and uploads them to Crowdin. OneConfig's own en_us.json comes straight from the repo.
  • It runs weekly, whenever OneConfig's en_us.json changes on v1, or when you start it by hand
  • The mods' own translations go in as unapproved. Translators can start from them, but they don't override anything.
  • A second job publishes the OTA distribution every day
  • In game, OneConfig downloads the translations for your language and puts them over what the mods ship. Only approved strings get exported, and anything we haven't approved falls back to the mod's own translation.
  • English players don't download anything
  • Downloads are cached in oneconfig/translations. The manifest is checked at most every 6 hours, and files are only downloaded again after a new release.

Before it's live

  • Set CROWDIN_PROJECT_ID and CROWDIN_PERSONAL_TOKEN as repo secrets
  • Turn on "Skip untranslated strings" and "Export only approved" in the Crowdin project
  • Put the OTA distribution hash in DISTRIBUTION_HASH in CommunityTranslations.kt. It's empty for now, so the runtime part doesn't do anything yet.

Changes

  • extract_mod_sources.py (new): pulls the strings out of the bundle jars
  • crowdin.yml (new): Crowdin CLI config
  • .github/workflows/crowdin.yml (new): sync and release jobs
  • CommunityTranslations.kt (new): downloads, caches and applies the translations
  • Mixin_CommunityTranslations.java (new): hooks language loading on 1.21.1+
  • Mixin_LegacyCommunityTranslations.java (new): same for 1.8.9
  • OneConfigMixinInit.java: registers the mixins

Tested on 26.3, 1.21.1 and 1.8.9 with a local copy of the CDN, before the last round of fixes.

val released = previous != null && previous.timestamp != manifest.timestamp
if (failed) {
lastFailure = System.currentTimeMillis()
if (released) return false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If one file in a new release fails every time (a 404, invalid JSON, or something else that keeps failing), this returns before the new manifest is written. The client then stays on the old release. Every reload after RETRY_DELAY refetches the manifest, the same file fails again, and later releases are never adopted until someone fixes that file upstream.

Could we write the new manifest anyway and just skip the files that failed? Since they're cached per release, the next attempt would retry only the missing ones. Another option is to give up on a file after N failures.

def merge(found):
for namespace, language, entries in found:
if language == SOURCE_LANGUAGE:
sources.setdefault(namespace, {}).update(entries)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This merges en_us from every MC version into one source file per namespace, and the newest version wins. At runtime every key in that file is applied, whatever mod version is installed. So if a key's English text changes between versions, the translation can have the wrong placeholders for some installs.

For example, a mod has x.msg = "%s kills" on 1.8.9 and "%s killed %s" on 1.21. The translator writes "%1$s tötete %2$s". On 1.8.9, String.format gets one argument, throws, and Locale shows Format error: ....

We could split sources per MC version (or per mod version). A cheaper fix: at apply time, skip any community value whose placeholder count doesn't match the installed en_us value for that key.

if (!SAFE_NAME.matches(namespace)) continue
val languages = listOfNotNull(segments.getOrNull(segments.size - 2), key).map(::normalize)
for (language in languages.distinct()) {
if (LANGUAGE_NAME.matches(language)) files.getOrPut(language) { HashMap() }.putIfAbsent(namespace, path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes Crowdin's %locale_with_underscore% (and the content key) is the same as the Minecraft language code. That isn't always true. Crowdin's Norwegian Bokmål exports as nb_NO, while MC uses no_no, so a no_no player never gets anything. The upload side has the same problem: locale_directory("no_no") writes translations/no_NO/, which Crowdin ignores, so mods' existing Norwegian translations are dropped.

A languages_mapping block in crowdin.yml that maps Crowdin locales to MC codes would fix both directions, as long as the runtime uses the same mapping.

Comment thread .github/crowdin/extract_mod_sources.py Outdated
sha1, (path, urls) = item
data = fetch_verified(urls, sha1)
if data is None:
raise RuntimeError(f"failed to download {path}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If one jar still can't be downloaded after the retries, this kills pool.map and the whole sync fails. A single file pulled from Modrinth, or a short CDN outage, would block that week's upload, including OneConfig's own en_us changes.

Could this log a warning and skip the jar, and only fail the job if a large share of the jars couldn't be fetched?

@Deftu

Deftu commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fixed all four.

  • The cache is per file now, so one file that keeps failing doesn't hold back the rest. The new manifest always gets written, missing files get retried, and until a file downloads the old copy of it is used.
  • Community strings get skipped when their placeholder count doesn't match the installed value for that key.
  • Mapped nb to no_NO in languages_mapping, and the runtime does the same.
  • Jars that can't be downloaded get skipped now. The sync only fails if more than 5% of them couldn't be fetched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants