fix(client): remove Hungarian and Korean locales

Both catalogs held nothing but English fallback values, so selecting either language produced a
fully English interface that reads as broken rather than untranslated. Crowdin Translate covers only
24 languages and includes neither, so neither locale had any machine translation route, and no human
translations existed yet.

Drop them from the shared locale list, the Crowdin export configuration, the pull request allowlist,
the catalogs, and the tests, leaving fifteen target languages. Remove both from the Crowdin project
as well, after confirming each export was byte-identical to the English source so no translation work
was discarded.

Re-adding either is cheap once a translation route exists, whether that is a machine translation
engine that supports them or a volunteer translator.

Give the locale tests the same fallback semantics the application runs with. Production always loads
the English catalog and points `fallbackLocale` at it, so a key a target catalog does not carry
renders English. The test instances omitted both, which resolves a missing key to its own name, and
several assertions compared a translated label against the English one without checking that the
language actually translates that key. Both patterns pass only while every catalog is complete, and
would fail confusingly the first time a language lags behind a new key.

Delete the Crowdin service branch automatically once its pull request closes or merges. Crowdin
appends every export to the same branch and never rebases it, so a branch that outlives its pull
request keeps its original merge base and each later export diverges further from main until every
catalog conflicts. Deleting it forces Crowdin to recreate the branch from the current main.

Ref #576
This commit is contained in:
neon
2026-07-31 21:45:06 +00:00
parent 1215a45f93
commit a0bc01ea50
11 changed files with 114 additions and 13928 deletions
@@ -0,0 +1,33 @@
name: Crowdin Branch Cleanup
on:
pull_request:
types: [closed]
permissions:
contents: write
jobs:
delete-service-branch:
# Crowdin appends every export to the same service branch and never rebases it.
# A branch that outlives its pull request keeps its original merge base, so each
# later export diverges further from main and eventually conflicts on every
# catalog. Deleting it forces Crowdin to recreate it from the current main.
# This runs on close as well as merge, because Crowdin reuses the branch either way.
if: github.event.pull_request.head.ref == 'l10n_main' && github.event.pull_request.head.repo.full_name == github.repository
name: Delete l10n_main
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Delete the Crowdin service branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: l10n_main
run: |
if gh api "repos/$REPO/git/refs/heads/$BRANCH" >/dev/null 2>&1; then
gh api -X DELETE "repos/$REPO/git/refs/heads/$BRANCH"
echo "Deleted $BRANCH; Crowdin will recreate it from main on the next export."
else
echo "$BRANCH is already absent; nothing to delete."
fi
@@ -7,6 +7,13 @@ import { setI18nLocale } from '@/i18n'
import en from '@/locales/en.json'
import pt from '@/locales/pt.json'
// Production falls back to English for any key a target catalog has not translated,
// so these are the labels Portuguese actually renders. Reading pt directly would
// assert `undefined` against rendered English the first time Portuguese lags
// behind a new dashboard key.
const PT_WIDGET_NAMES = { ...en.dashboard.settings.widgetNames, ...pt.dashboard.settings.widgetNames }
const PT_SHELF_NAMES = { ...en.dashboard.settings.shelfNames, ...pt.dashboard.settings.shelfNames }
type UseSmartScopesMock = () => {
smartScopes: Ref<unknown[]>
fetchSmartScopes: () => void
@@ -113,7 +120,7 @@ describe('DashboardSettingsSheet', () => {
await setI18nLocale('pt')
await nextTick()
expect(shelfOptionLabels(wrapper).sort()).toEqual(Object.values(pt.dashboard.settings.shelfNames).sort())
expect(shelfOptionLabels(wrapper).sort()).toEqual(Object.values(PT_SHELF_NAMES).sort())
})
it('renders every widget name from the catalog rather than a hardcoded map', async () => {
@@ -146,20 +153,27 @@ describe('DashboardSettingsSheet', () => {
const labels = widgetRowLabels(wrapper)
expect(labels).toEqual([
pt.dashboard.settings.widgetNames.readingStreak,
pt.dashboard.settings.widgetNames.currentlyReading,
pt.dashboard.settings.widgetNames.readingGoal,
pt.dashboard.settings.widgetNames.readingDna,
pt.dashboard.settings.widgetNames.monthlyChallenge,
pt.dashboard.settings.widgetNames.highlightOfTheDay,
pt.dashboard.settings.widgetNames.neglectedGems,
pt.dashboard.settings.widgetNames.readingRhythm,
pt.dashboard.settings.widgetNames.diversityScore,
pt.dashboard.settings.widgetNames.libraryOverview,
pt.dashboard.settings.widgetNames.yearProjection,
pt.dashboard.settings.widgetNames.longWait,
PT_WIDGET_NAMES.readingStreak,
PT_WIDGET_NAMES.currentlyReading,
PT_WIDGET_NAMES.readingGoal,
PT_WIDGET_NAMES.readingDna,
PT_WIDGET_NAMES.monthlyChallenge,
PT_WIDGET_NAMES.highlightOfTheDay,
PT_WIDGET_NAMES.neglectedGems,
PT_WIDGET_NAMES.readingRhythm,
PT_WIDGET_NAMES.diversityScore,
PT_WIDGET_NAMES.libraryOverview,
PT_WIDGET_NAMES.yearProjection,
PT_WIDGET_NAMES.longWait,
])
expect(labels).not.toContain(en.dashboard.settings.widgetNames.readingStreak)
expect(labels).not.toContain(en.dashboard.settings.widgetNames.currentlyReading)
// Only meaningful for names Portuguese actually translates. A name that falls
// back to English renders the English label legitimately, so asserting its
// absence would fail for a reason that is not a bug.
const englishNamesPortugueseReplaces = (['readingStreak', 'currentlyReading'] as const)
.filter((key) => PT_WIDGET_NAMES[key] !== en.dashboard.settings.widgetNames[key])
.map((key) => en.dashboard.settings.widgetNames[key])
expect(labels.filter((label) => englishNamesPortugueseReplaces.includes(label))).toEqual([])
})
})
@@ -24,9 +24,16 @@ function mountComposable(): ReturnType<typeof useDashboardLabels> {
type CatalogNames = { widgetNames: Record<string, string>; shelfNames: Record<string, string> }
// Production falls back to English for any key a target catalog has not translated,
// so the expected label is the Portuguese one where it exists and English otherwise.
// Reading pt directly would assert `undefined` against rendered English the first
// time Portuguese lags behind a new dashboard key.
const CATALOGS: Record<'en' | 'pt', CatalogNames> = {
en: en.dashboard.settings,
pt: pt.dashboard.settings,
pt: {
widgetNames: { ...en.dashboard.settings.widgetNames, ...pt.dashboard.settings.widgetNames },
shelfNames: { ...en.dashboard.settings.shelfNames, ...pt.dashboard.settings.shelfNames },
},
}
// Mirrors the kebab-case type to camel-case message key mapping the composable owns.
@@ -46,6 +53,13 @@ const WIDGET_KEY_BY_TYPE: Record<WidgetType, string> = {
'long-wait': 'longWait',
}
// A widget whose Portuguese name genuinely differs from English. Assertions that a
// translated label is not the English one only hold for such a widget: a name that
// falls back to English renders the English string for a legitimate reason.
const PT_TRANSLATED_WIDGET = (Object.keys(WIDGET_KEY_BY_TYPE) as WidgetType[]).find(
(type) => CATALOGS.pt.widgetNames[WIDGET_KEY_BY_TYPE[type]] !== CATALOGS.en.widgetNames[WIDGET_KEY_BY_TYPE[type]],
)
const SHELF_KEY_BY_TYPE: Record<ScrollerType, string> = {
'recently-added': 'recentlyAdded',
'continue-reading': 'continueReading',
@@ -107,19 +121,23 @@ describe('useDashboardLabels', () => {
const key = WIDGET_KEY_BY_TYPE[type]
expect(widgetName(type), `widget name for ${type}`).toBe(CATALOGS.pt.widgetNames[key])
}
expect(widgetName('reading-streak')).not.toBe(CATALOGS.en.widgetNames.readingStreak)
expect(widgetName('currently-reading')).not.toBe(CATALOGS.en.widgetNames.currentlyReading)
expect(PT_TRANSLATED_WIDGET, 'Portuguese should translate at least one widget name').toBeDefined()
const translatedKey = WIDGET_KEY_BY_TYPE[PT_TRANSLATED_WIDGET!]
expect(widgetName(PT_TRANSLATED_WIDGET!)).not.toBe(CATALOGS.en.widgetNames[translatedKey])
})
it('re-resolves when the locale changes back', async () => {
const { widgetName } = mountComposable()
expect(PT_TRANSLATED_WIDGET, 'Portuguese should translate at least one widget name').toBeDefined()
const type = PT_TRANSLATED_WIDGET!
await setI18nLocale('pt')
const translated = widgetName('reading-streak')
const translated = widgetName(type)
await setI18nLocale('en')
expect(translated).not.toBe(widgetName('reading-streak'))
expect(widgetName('reading-streak')).toBe(CATALOGS.en.widgetNames.readingStreak)
expect(translated).not.toBe(widgetName(type))
expect(widgetName(type)).toBe(CATALOGS.en.widgetNames[WIDGET_KEY_BY_TYPE[type]])
})
})
+23 -55
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { Locale } from '@bookorbit/types'
import cs from '@/locales/cs.json'
import da from '@/locales/da.json'
import de from '@/locales/de.json'
@@ -7,9 +8,7 @@ import en from '@/locales/en.json'
import fi from '@/locales/fi.json'
import spanish from '@/locales/es.json'
import french from '@/locales/fr.json'
import hu from '@/locales/hu.json'
import italian from '@/locales/it.json'
import ko from '@/locales/ko.json'
import nl from '@/locales/nl.json'
import polish from '@/locales/pl.json'
import pt from '@/locales/pt.json'
@@ -35,6 +34,19 @@ function messageValues(message: string, count: number): Record<string, string |
return Object.fromEntries(argumentNames.map((name) => [name, name === 'count' ? count : 2]))
}
// Mirrors the production setup in `@/i18n`, where the English catalog is always
// loaded and `fallbackLocale` points at it. Without that, a key absent from a
// target catalog resolves to the key name rather than English, so a catalog
// carrying only translated keys would fail these tests instead of falling back
// the way the running application does.
function createCatalogI18n(locale: Locale, catalog: typeof en) {
const messages: Record<string, typeof en> = {
en: compileIcuCatalog(en, 'en'),
[locale]: compileIcuCatalog(catalog, locale),
}
return createI18n({ legacy: false, locale, fallbackLocale: 'en', messages })
}
describe('ICU message compilation', () => {
it('detects ICU plurals without treating ordinary interpolation as ICU', () => {
expect(isIcuPluralMessage('{count, plural, one {One book} other {# books}}')).toBe(true)
@@ -92,12 +104,7 @@ describe('ICU message compilation', () => {
['pt', pt, [0, 1, 2, 1_234]],
['sl', sl, [0, 1, 2, 3, 5, 1_234]],
] as const)('isolates the styled count in both dialogs for %s', (locale, catalog, counts) => {
const messages = compileIcuCatalog(catalog, locale)
const testI18n = createI18n({
legacy: false,
locale,
messages: { [locale]: messages },
})
const testI18n = createCatalogI18n(locale, catalog)
for (const key of ['tools.bulkRename.confirmDialog.body', 'tools.entityManager.bulkDeleteModal.confirm']) {
for (const count of counts) {
@@ -141,16 +148,8 @@ describe('ICU message compilation', () => {
})
it('renders reviewed Italian and Polish count messages with the correct grammar', () => {
const italianI18n = createI18n({
legacy: false,
locale: 'it',
messages: { it: compileIcuCatalog(italian, 'it') },
})
const polishI18n = createI18n({
legacy: false,
locale: 'pl',
messages: { pl: compileIcuCatalog(polish, 'pl') },
})
const italianI18n = createCatalogI18n('it', italian)
const polishI18n = createCatalogI18n('pl', polish)
expect(italianI18n.global.t('settings.appearance.icons.selectedCount', { count: 1 })).toBe('1 icona selezionata')
expect(italianI18n.global.t('settings.appearance.icons.selectedCount', { count: 2 })).toBe('2 icone selezionate')
@@ -186,11 +185,7 @@ describe('ICU message compilation', () => {
})
it('preserves spacing around collection names in reviewed Italian messages', () => {
const testI18n = createI18n({
legacy: false,
locale: 'it',
messages: { it: compileIcuCatalog(italian, 'it') },
})
const testI18n = createCatalogI18n('it', italian)
expect(testI18n.global.t('collection.addToSheet.createdAndAdded', { count: 2, name: 'Preferiti' })).toBe('Creata "Preferiti" e aggiunti 2 libri')
expect(testI18n.global.t('collection.addToSheet.addedToCollection', { count: 2, name: 'Preferiti' })).toBe('Aggiunti 2 libri a "Preferiti"')
@@ -212,12 +207,7 @@ describe('ICU message compilation', () => {
['pt', pt, [0, 1, 1_000_000], ['Sem grupos duplicados', 'Um grupo duplicado', '1.000.000 grupos duplicados']],
['sl', sl, [0, 1, 2, 3, 5], ['No duplicate groups', 'One duplicate group', '2 duplicate groups', '3 duplicate groups', '5 duplicate groups']],
] as const)('renders migrated Book Duplicates messages for %s', (locale, catalog, counts, expectedGroups) => {
const messages = compileIcuCatalog(catalog, locale)
const testI18n = createI18n({
legacy: false,
locale,
messages: { [locale]: messages },
})
const testI18n = createCatalogI18n(locale, catalog)
expect(counts.map((count) => testI18n.global.t('tools.bookDuplicates.groupsFound', { count }))).toEqual(expectedGroups)
@@ -245,12 +235,7 @@ describe('ICU message compilation', () => {
['pt', pt, [1, 1_000_000], ['1 falha', '1.000.000 falhas']],
['sl', sl, [1, 2, 3, 5], ['1 napaka', '2 napaki', '3 napak', '5 napak']],
] as const)('renders migrated Kobo activity plurals for %s', (locale, catalog, counts, expected) => {
const messages = compileIcuCatalog(catalog, locale)
const testI18n = createI18n({
legacy: false,
locale,
messages: { [locale]: messages },
})
const testI18n = createCatalogI18n(locale, catalog)
expect(counts.map((count) => testI18n.global.t('settings.reader.kobo.activity.failureCount', { count }))).toEqual(expected)
})
@@ -276,23 +261,13 @@ describe('ICU message compilation', () => {
['0 datotek pripravljenih', '1 datoteka pripravljena', '2 datoteki pripravljeni', '3 datoteke pripravljene', '5 datotek pripravljenih'],
],
] as const)('renders migrated finalize-dialog counts for %s', (locale, catalog, counts, expected) => {
const messages = compileIcuCatalog(catalog, locale)
const testI18n = createI18n({
legacy: false,
locale,
messages: { [locale]: messages },
})
const testI18n = createCatalogI18n(locale, catalog)
expect(counts.map((count) => testI18n.global.t('bookDock.finalizeDialog.readyCount', { count }))).toEqual(expected)
})
it('uses the reviewed Slovenian zero/one/other mappings in the finalize dialog', () => {
const messages = compileIcuCatalog(sl, 'sl')
const testI18n = createI18n({
legacy: false,
locale: 'sl',
messages: { sl: messages },
})
const testI18n = createCatalogI18n('sl', sl)
expect(testI18n.global.t('bookDock.finalizeDialog.title', { count: 0 })).toBe('Zaključi brez datotek')
expect(testI18n.global.t('bookDock.finalizeDialog.title', { count: 1 })).toBe('Zaključi 1 datoteko')
@@ -308,10 +283,8 @@ describe('ICU message compilation', () => {
['de', de, [0, 1, 2]],
['es', spanish, [0, 1, 2, 1_000_000]],
['fi', fi, [0, 1, 2]],
['hu', hu, [0, 1, 2]],
['fr', french, [0, 1, 2, 1_000_000]],
['it', italian, [0, 1, 2, 1_000_000]],
['ko', ko, [0, 1, 2]],
['nl', nl, [0, 1, 2]],
// Polish reserves `other` for fractions, exactly as Czech does.
['pl', polish, [0, 1, 2, 5, 1.5, 1_000_000]],
@@ -323,12 +296,7 @@ describe('ICU message compilation', () => {
['uk', uk, [0, 1, 2, 5, 1.5]],
['zh', zh, [0, 1, 2]],
] as const)('formats every ICU message for all relevant plural categories in %s', (locale, catalog, counts) => {
const messages = compileIcuCatalog(catalog, locale)
const testI18n = createI18n({
legacy: false,
locale,
messages: { [locale]: messages },
})
const testI18n = createCatalogI18n(locale, catalog)
for (const [key, message] of flattenMessages(catalog)) {
if (!isIcuPluralMessage(message)) continue
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -42,8 +42,6 @@ describe('locale store', () => {
expect(matchSupportedLocale(['cs-CZ', 'en-GB'])).toBe('cs')
expect(matchSupportedLocale(['da-DK', 'en-GB'])).toBe('da')
expect(matchSupportedLocale(['fi-FI', 'en-GB'])).toBe('fi')
expect(matchSupportedLocale(['hu-HU', 'en-GB'])).toBe('hu')
expect(matchSupportedLocale(['ko-KR', 'en-GB'])).toBe('ko')
expect(matchSupportedLocale(['sv-SE', 'en-GB'])).toBe('sv')
expect(matchSupportedLocale(['sv-FI', 'en-GB'])).toBe('sv')
})
-2
View File
@@ -7,9 +7,7 @@ export_languages:
- es-ES
- fi
- fr
- hu
- it
- ko
- nl
- pl
- pt-BR
+4 -4
View File
@@ -13,9 +13,7 @@ BookOrbit uses Vue I18n catalogs under `client/src/locales/`. English is the sou
| Spanish | `es-ES` | `es.json` |
| Finnish | `fi` | `fi.json` |
| French | `fr` | `fr.json` |
| Hungarian | `hu` | `hu.json` |
| Italian | `it` | `it.json` |
| Korean | `ko` | `ko.json` |
| Dutch | `nl` | `nl.json` |
| Polish | `pl` | `pl.json` |
| Brazilian Portuguese | `pt-BR` | `pt.json` |
@@ -89,9 +87,11 @@ After an English source change reaches `main`:
2. Translators update target strings in Crowdin.
3. Crowdin exports the target catalogs to `l10n_main`.
4. Crowdin opens a pull request to `main`.
5. CI verifies that the pull request changes only the seventeen target catalogs and runs the normal client checks.
5. CI verifies that the pull request changes only the fifteen target catalogs and runs the normal client checks.
6. A maintainer reviews and squash-merges the pull request.
7. The `l10n_main` service branch is deleted. Crowdin recreates it for the next export.
7. The `.github/workflows/crowdin-branch-cleanup.yml` workflow deletes the `l10n_main` service branch. Crowdin recreates it from the current `main` for the next export.
Deleting that branch is required, not tidiness. Crowdin appends every export to the same service branch and never rebases it, so a branch that outlives its pull request keeps its original merge base. Each later export then diverges further from `main` and eventually conflicts on every catalog. The workflow runs when a pull request from `l10n_main` is closed as well as merged, because Crowdin reuses the branch either way.
Crowdin pull requests must retain the configured `i18n(client)` title and commit format. The `i18n` commit type produces a patch release and an Internationalization release-note section. Crowdin's default `[ci skip]` commit suffix is disabled so pull-request validation runs before merge.
+1 -24
View File
@@ -1,23 +1,4 @@
export const SUPPORTED_LOCALES = [
"en",
"cs",
"da",
"de",
"es",
"fi",
"fr",
"hu",
"it",
"ko",
"nl",
"pl",
"pt",
"ru",
"sl",
"sv",
"uk",
"zh",
] as const;
export const SUPPORTED_LOCALES = ["en", "cs", "da", "de", "es", "fi", "fr", "it", "nl", "pl", "pt", "ru", "sl", "sv", "uk", "zh"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
@@ -31,9 +12,7 @@ export const LOCALE_LABELS: Record<Locale, string> = {
es: "Español",
fi: "Suomi",
fr: "Français",
hu: "Magyar",
it: "Italiano",
ko: "한국어",
nl: "Nederlands",
pl: "Polski",
pt: "Português",
@@ -52,9 +31,7 @@ export const LOCALE_DIRECTIONS: Record<Locale, "ltr" | "rtl"> = {
es: "ltr",
fi: "ltr",
fr: "ltr",
hu: "ltr",
it: "ltr",
ko: "ltr",
nl: "ltr",
pl: "ltr",
pt: "ltr",
-2
View File
@@ -25,9 +25,7 @@ allowed_paths=(
"client/src/locales/es.json"
"client/src/locales/fi.json"
"client/src/locales/fr.json"
"client/src/locales/hu.json"
"client/src/locales/it.json"
"client/src/locales/ko.json"
"client/src/locales/nl.json"
"client/src/locales/pl.json"
"client/src/locales/pt.json"