i18n(client): add Spanish, French, Italian, and Polish locales (#811)

* i18n(client): add Spanish, French, Italian, and Polish locales

Add locale catalogs for Spanish, French, Italian, and Polish. Update locale types, Crowdin configuration, documentation, and test suites to support the expanded language options.

* i18n(client): sync Spanish, French, Italian, and Polish locale keys

Update Spanish, French, Italian, and Polish translation catalogs to include key additions for notification preferences, user management, and file naming settings.

* fix(client): correct expanded locale translations
This commit is contained in:
Chris Mansell
2026-07-28 11:37:43 -06:00
committed by GitHub
parent 515f7b89e9
commit 12ca966a79
18 changed files with 27329 additions and 36 deletions
@@ -62,7 +62,7 @@ function handlePeriodChange() {
function durationLabel(seconds: number): string {
const minutes = Math.round(seconds / 60)
if (minutes < 60) return t('adminFeature.sharedInsights.durationMinutes', { count: minutes })
return t('adminFeature.sharedInsights.durationHours', { count: formatNumber(Math.round((minutes / 60) * 10) / 10) })
return t('adminFeature.sharedInsights.durationHours', { count: Math.round((minutes / 60) * 10) / 10 })
}
function trendDayLabel(day: string): string {
@@ -71,7 +71,7 @@ function durationLabel(seconds: number): string {
const minutes = Math.round(seconds / 60)
if (minutes < 60) return t('settings.privacySharing.durationMinutes', { count: minutes })
const hours = Math.round((minutes / 60) * 10) / 10
return t('settings.privacySharing.durationHours', { count: formatNumber(hours) })
return t('settings.privacySharing.durationHours', { count: hours })
}
</script>
+90
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import de from '@/locales/de.json'
import en from '@/locales/en.json'
import spanish from '@/locales/es.json'
import french from '@/locales/fr.json'
import italian from '@/locales/it.json'
import nl from '@/locales/nl.json'
import polish from '@/locales/pl.json'
import pt from '@/locales/pt.json'
import sl from '@/locales/sl.json'
import { compileIcuCatalog, icuCountValues, isIcuPluralMessage, splitIcuCount } from './icu'
@@ -71,7 +75,11 @@ describe('ICU message compilation', () => {
it.each([
['en', en, [0, 1, 2, 1_234]],
['de', de, [0, 1, 2, 1_234]],
['es', spanish, [0, 1, 2, 1_000_000]],
['fr', french, [0, 1, 2, 1_000_000]],
['it', italian, [0, 1, 2, 1_000_000]],
['nl', nl, [0, 1, 2, 1_234]],
['pl', polish, [0, 1, 2, 5, 1_000_000]],
['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) => {
@@ -123,10 +131,75 @@ describe('ICU message compilation', () => {
expect(testI18n.global.t('books', { count: 5 })).toBe('5 knjig')
})
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') },
})
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')
expect(italianI18n.global.t('annotations.bulk.countSelected', { count: 1 })).toBe('1 annotazione selezionata')
expect(italianI18n.global.t('annotations.bulk.countSelected', { count: 2 })).toBe('2 annotazioni selezionate')
expect([1, 2, 5].map((count) => polishI18n.global.t('settings.privacySharing.durationHours', { count }))).toEqual([
'1 godzina',
'2 godziny',
'5 godzin',
])
expect([1, 2, 5].map((count) => polishI18n.global.t('settings.metadata.autoFetch.lastRun.daysAgo', { count }))).toEqual([
'1 dzień temu',
'2 dni temu',
'5 dni temu',
])
expect([1, 2, 5].map((count) => polishI18n.global.t('reader.audiobook.minutes', { count }))).toEqual(['1 minuta', '2 minuty', '5 minut'])
})
it('keeps reviewed technical terms in their software context', () => {
for (const catalog of [spanish, french, italian, polish]) {
expect(catalog.settings.oidc.form.slug).toBe('Slug')
expect(catalog.annotations.hub.exportMarkdown).toBe('Markdown')
}
expect(spanish.settings.metadata.autoFetch.runNow).toBe('Ejecutar ahora')
expect(french.settings.metadata.autoFetch.runNow).toBe('Exécuter maintenant')
expect(italian.settings.metadata.autoFetch.runNow).toBe('Esegui ora')
expect(polish.settings.metadata.autoFetch.runNow).toBe('Uruchom teraz')
expect(italian.settings.admin.migration.host).toBe('Host')
expect(italian.settings.admin.migration.port).toBe('Porta')
expect(polish.tools.bookDuplicates.selectKeeperFirst).toBe('Najpierw wybierz książkę do zachowania')
})
it('preserves spacing around collection names in reviewed Italian messages', () => {
const testI18n = createI18n({
legacy: false,
locale: 'it',
messages: { it: compileIcuCatalog(italian, 'it') },
})
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"')
})
it.each([
['en', en, [0, 1, 2], ['No duplicate groups', 'One duplicate group', '2 duplicate groups']],
['de', de, [0, 1, 2], ['Keine duplizierten Gruppen', 'Eine duplizierte Gruppe', '2 duplizierte Gruppen']],
['es', spanish, [0, 1, 1_000_000], ['No hay grupos duplicados', 'Un grupo duplicado', '1.000.000 grupos duplicados']],
['fr', french, [0, 1, 1_000_000], ['Pas de groupes en double', 'Un groupe en double', '1 000 000 groupes en double']],
['it', italian, [0, 1, 1_000_000], ['Nessun gruppo duplicato', 'Un gruppo duplicato', '1.000.000 gruppi duplicati']],
['nl', nl, [0, 1, 2], ['No duplicate groups', 'One duplicate group', '2 duplicate groups']],
[
'pl',
polish,
[0, 1, 2, 5, 1_000_000],
['Brak zduplikowanych grup', 'Jedna zduplikowana grupa', '2 zduplikowane grupy', '5 zduplikowanych grup', '1 000 000 zduplikowanych grup'],
],
['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) => {
@@ -155,7 +228,11 @@ describe('ICU message compilation', () => {
it.each([
['en', en, [1, 2], ['1 failure', '2 failures']],
['de', de, [1, 2], ['1 Fehler', '2 Fehler']],
['es', spanish, [1, 1_000_000], ['1 fracaso', '1.000.000 fracasos']],
['fr', french, [1, 1_000_000], ['1 échec', '1 000 000 échecs']],
['it', italian, [1, 1_000_000], ['1 fallimento', '1.000.000 fallimenti']],
['nl', nl, [1, 2], ['1 fout', '2 fouten']],
['pl', polish, [1, 2, 5, 1_000_000], ['1 porażka', '2 niepowodzenia', '5 niepowodzeń', '1 000 000 niepowodzeń']],
['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) => {
@@ -172,7 +249,16 @@ describe('ICU message compilation', () => {
it.each([
['en', en, [0, 1, 2], ['no files ready', '1 file ready', '2 files ready']],
['de', de, [0, 1, 2], ['keine Dateien bereit', '1 Datei bereit', '2 Dateien bereit']],
['es', spanish, [0, 1, 1_000_000], ['no hay archivos listos', '1 archivo listo', '1.000.000 archivos listos']],
['fr', french, [0, 1, 1_000_000], ["aucun fichier n'est prêt", '1 fichier prêt', '1 000 000 fichiers prêts']],
['it', italian, [0, 1, 1_000_000], ['nessun file pronto', '1 file pronto', '1.000.000 file pronti']],
['nl', nl, [0, 1, 2], ['geen bestanden gereed', '1 bestand gereed', '2 bestanden gereed']],
[
'pl',
polish,
[0, 1, 2, 5, 1_000_000],
['brak gotowych plików', '1 plik gotowy', '2 pliki gotowe', '5 plików gotowych', '1 000 000 plików gotowych'],
],
['pt', pt, [0, 1, 1_000_000], ['nenhum arquivo pronto', '1 arquivo pronto', '1.000.000 arquivos prontos']],
[
'sl',
@@ -208,7 +294,11 @@ describe('ICU message compilation', () => {
it.each([
['en', en, [0, 1, 2]],
['de', de, [0, 1, 2]],
['es', spanish, [0, 1, 2, 1_000_000]],
['fr', french, [0, 1, 2, 1_000_000]],
['it', italian, [0, 1, 2, 1_000_000]],
['nl', nl, [0, 1, 2]],
['pl', polish, [0, 1, 2, 5, 1_000_000]],
['pt', pt, [0, 1, 2, 1_000_000]],
['sl', sl, [0, 1, 2, 3, 5]],
] as const)('formats every ICU message for all relevant plural categories in %s', (locale, catalog, counts) => {
+6 -6
View File
@@ -71,7 +71,7 @@
"paginationLabel": "Seiten des Profilzugriffsverlaufs"
},
"durationMinutes": "{count, plural, one {# Minute} other {# Minuten}}",
"durationHours": "{count} Stunden",
"durationHours": "{count, plural, one {# Stunde} other {# Stunden}}",
"unknownTitle": "Buch ohne Titel",
"errors": {
"load": "Freigabeeinstellungen konnten nicht geladen werden.",
@@ -407,7 +407,7 @@
"newest": "Neueste",
"name": "Name"
},
"selectedCount": "{count} ausgewählt",
"selectedCount": "{count, plural, one {# ausgewählt} other {# ausgewählt}}",
"clear": "Leeren",
"deleteSelected": "Auswahl löschen",
"loading": "Symbole werden geladen...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "vor {count} Min.",
"hoursAgo": "vor {count} Std.",
"yesterday": "gestern",
"daysAgo": "vor {count} Tagen",
"daysAgo": "{count, plural, one {vor # Tag} other {vor # Tagen}}",
"label": "Letzter Lauf: {when}",
"labelQueued": "Letzter Lauf: {when} - {count} Bücher eingereiht",
"labelNoneEligible": "Letzter Lauf: {when} - keine berechtigten Bücher"
@@ -2362,7 +2362,7 @@
"period": "Berichtszeitraum",
"periodDays": "Letzte {count} Tage",
"durationMinutes": "{count, plural, one {# Minute} other {# Minuten}}",
"durationHours": "{count} Stunden",
"durationHours": "{count, plural, one {# Stunde} other {# Stunden}}",
"metrics": {
"sessions": "Sitzungen",
"readingTime": "Lesezeit",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Keine Bücher gefunden"
},
"bulk": {
"countSelected": "{count} ausgewählt",
"countSelected": "{count, plural, one {# ausgewählt} other {# ausgewählt}}",
"pageSelected": "Seite ausgewählt",
"selectPage": "Seite auswählen",
"clear": "Zurücksetzen",
@@ -5956,7 +5956,7 @@
"sleep": "Schlafmodus",
"chapterAbbrev": "Kap.",
"sleepTimer": "Einschlaftimer",
"minutes": "{count} Minuten",
"minutes": "{count, plural, one {# Minute} other {# Minuten}}",
"endOfChapter": "Ende des Kapitels",
"noChaptersAvailable": "Keine Kapitel verfügbar",
"extend15": "+ 15 Minuten",
+6 -6
View File
@@ -71,7 +71,7 @@
"paginationLabel": "Profile access history pages"
},
"durationMinutes": "{count, plural, one {# minute} other {# minutes}}",
"durationHours": "{count} hours",
"durationHours": "{count, plural, one {# hour} other {# hours}}",
"unknownTitle": "Untitled book",
"errors": {
"load": "Failed to load sharing settings.",
@@ -407,7 +407,7 @@
"newest": "Newest",
"name": "Name"
},
"selectedCount": "{count} selected",
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
"clear": "Clear",
"deleteSelected": "Delete selected",
"loading": "Loading icons...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"yesterday": "yesterday",
"daysAgo": "{count} days ago",
"daysAgo": "{count, plural, one {# day ago} other {# days ago}}",
"label": "Last run: {when}",
"labelQueued": "Last run: {when} - queued {count} books",
"labelNoneEligible": "Last run: {when} - no eligible books"
@@ -2362,7 +2362,7 @@
"period": "Reporting period",
"periodDays": "Last {count} days",
"durationMinutes": "{count, plural, one {# minute} other {# minutes}}",
"durationHours": "{count} hours",
"durationHours": "{count, plural, one {# hour} other {# hours}}",
"metrics": {
"sessions": "Sessions",
"readingTime": "Reading time",
@@ -2575,7 +2575,7 @@
"noBooksFound": "No books found"
},
"bulk": {
"countSelected": "{count} selected",
"countSelected": "{count, plural, one {# selected} other {# selected}}",
"pageSelected": "Page selected",
"selectPage": "Select page",
"clear": "Clear",
@@ -5956,7 +5956,7 @@
"sleep": "Sleep",
"chapterAbbrev": "Ch.",
"sleepTimer": "Sleep timer",
"minutes": "{count} minutes",
"minutes": "{count, plural, one {# minute} other {# minutes}}",
"endOfChapter": "End of chapter",
"noChaptersAvailable": "No chapters available",
"extend15": "+ 15 minutes",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -71,7 +71,7 @@
"paginationLabel": "Pagina's met profieltoegangsgeschiedenis"
},
"durationMinutes": "{count, plural, one {# minuut} other {# minuten}}",
"durationHours": "{count} uur",
"durationHours": "{count, plural, one {# uur} other {# uur}}",
"unknownTitle": "Boek zonder titel",
"errors": {
"load": "Deelinstellingen konden niet worden geladen.",
@@ -407,7 +407,7 @@
"newest": "Nieuwste",
"name": "Naam"
},
"selectedCount": "{count} geselecteerd",
"selectedCount": "{count, plural, one {# geselecteerd} other {# geselecteerd}}",
"clear": "Wissen",
"deleteSelected": "Selectie verwijderen",
"loading": "Pictogrammen laden...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "{count}m geleden",
"hoursAgo": "{count}u geleden",
"yesterday": "gisteren",
"daysAgo": "{count} dagen geleden",
"daysAgo": "{count, plural, one {# dag geleden} other {# dagen geleden}}",
"label": "Laatste uitvoering: {when}",
"labelQueued": "Laatste uitvoering: {when} - {count} boeken in wachtrij geplaatst",
"labelNoneEligible": "Laatste uitvoering: {when} - geen in aanmerking komende boeken"
@@ -2362,7 +2362,7 @@
"period": "Rapportageperiode",
"periodDays": "Afgelopen {count} dagen",
"durationMinutes": "{count, plural, one {# minuut} other {# minuten}}",
"durationHours": "{count} uur",
"durationHours": "{count, plural, one {# uur} other {# uur}}",
"metrics": {
"sessions": "Sessies",
"readingTime": "Leestijd",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Geen boeken gevonden"
},
"bulk": {
"countSelected": "{count} geselecteerd",
"countSelected": "{count, plural, one {# geselecteerd} other {# geselecteerd}}",
"pageSelected": "Pagina geselecteerd",
"selectPage": "Pagina selecteren",
"clear": "Wissen",
@@ -5956,7 +5956,7 @@
"sleep": "Slaap",
"chapterAbbrev": "Hfst.",
"sleepTimer": "Slaaptimer",
"minutes": "{count} minuten",
"minutes": "{count, plural, one {# minuut} other {# minuten}}",
"endOfChapter": "Einde van hoofdstuk",
"noChaptersAvailable": "Geen hoofdstukken beschikbaar",
"extend15": "+ 15 minuten",
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -71,7 +71,7 @@
"paginationLabel": "Páginas do histórico de acesso ao perfil"
},
"durationMinutes": "{count, plural, one {# minuto} many {# minutos} other {# minutos}}",
"durationHours": "{count} horas",
"durationHours": "{count, plural, one {# hora} other {# horas} many {# horas}}",
"unknownTitle": "Livro sem título",
"errors": {
"load": "Falha ao carregar configurações de compartilhamento.",
@@ -407,7 +407,7 @@
"newest": "Mais recentes",
"name": "Nome"
},
"selectedCount": "{count} selecionado(s)",
"selectedCount": "{count, plural, one {# ícone selecionado} other {# ícones selecionados} many {# ícones selecionados}}",
"clear": "Limpar",
"deleteSelected": "Excluir selecionados",
"loading": "Carregando ícones...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "há {count}m",
"hoursAgo": "há {count}h",
"yesterday": "ontem",
"daysAgo": "há {count} dias",
"daysAgo": "{count, plural, one {há # dia} other {há # dias} many {há # dias}}",
"label": "Última execução: {when}",
"labelQueued": "Última execução: {when} - {count} livro(s) na fila",
"labelNoneEligible": "Última execução: {when} - nenhum livro elegível"
@@ -2362,7 +2362,7 @@
"period": "Período do relatório",
"periodDays": "Últimos {count} dias",
"durationMinutes": "{count, plural, one {# minuto} many {# minutos} other {# minutos}}",
"durationHours": "{count} horas",
"durationHours": "{count, plural, one {# hora} other {# horas} many {# horas}}",
"metrics": {
"sessions": "Sessões",
"readingTime": "Tempo de leitura",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Nenhum livro encontrado"
},
"bulk": {
"countSelected": "{count} selecionado(s)",
"countSelected": "{count, plural, one {# anotação selecionada} other {# anotações selecionadas} many {# anotações selecionadas}}",
"pageSelected": "Página selecionada",
"selectPage": "Selecionar página",
"clear": "Limpar",
@@ -5956,7 +5956,7 @@
"sleep": "Pausa",
"chapterAbbrev": "Cap.",
"sleepTimer": "Temporizador de pausa",
"minutes": "{count} minutos",
"minutes": "{count, plural, one {# minuto} other {# minutos} many {# minutos}}",
"endOfChapter": "Fim do capítulo",
"noChaptersAvailable": "Nenhum capítulo disponível",
"extend15": "+ 15 minutos",
+6 -6
View File
@@ -71,7 +71,7 @@
"paginationLabel": "Strani zgodovine dostopov do profila"
},
"durationMinutes": "{count, plural, one {# minuta} two {# minuti} few {# minute} other {# minut}}",
"durationHours": "{count} ur",
"durationHours": "{count, plural, one {# ura} two {# uri} few {# ure} other {# ur}}",
"unknownTitle": "Knjiga brez naslova",
"errors": {
"load": "Nastavitev deljenja ni bilo mogoče naložiti.",
@@ -407,7 +407,7 @@
"newest": "Najnovejše",
"name": "Ime"
},
"selectedCount": "{count} izbranih",
"selectedCount": "{count, plural, one {# izbrana ikona} two {# izbrani ikoni} few {# izbrane ikone} other {# izbranih ikon}}",
"clear": "Počisti",
"deleteSelected": "Izbriši izbrano",
"loading": "Nalaganje ikon...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "pred {count} min",
"hoursAgo": "pred {count} h",
"yesterday": "včeraj",
"daysAgo": "pred {count} dnevi",
"daysAgo": "{count, plural, one {pred # dnevom} two {pred # dnevoma} few {pred # dnevi} other {pred # dnevi}}",
"label": "Zadnji zagon: {when}",
"labelQueued": "Zadnji zagon: {when} - v vrsto uvrščenih {count} knjig",
"labelNoneEligible": "Zadnji zagon: {when} - ni upravičenih knjig"
@@ -2362,7 +2362,7 @@
"period": "Obdobje poročila",
"periodDays": "Zadnjih {count} dni",
"durationMinutes": "{count, plural, one {# minuta} two {# minuti} few {# minute} other {# minut}}",
"durationHours": "{count} ur",
"durationHours": "{count, plural, one {# ura} two {# uri} few {# ure} other {# ur}}",
"metrics": {
"sessions": "Seje",
"readingTime": "Čas branja",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Ni najdenih knjig"
},
"bulk": {
"countSelected": "Izbranih: {count}",
"countSelected": "{count, plural, one {# izbrana opomba} two {# izbrani opombi} few {# izbrane opombe} other {# izbranih opomb}}",
"pageSelected": "Stran izbrana",
"selectPage": "Izberi stran",
"clear": "Počisti",
@@ -5956,7 +5956,7 @@
"sleep": "Spanje",
"chapterAbbrev": "Pogl.",
"sleepTimer": "Časovnik spanja",
"minutes": "{count} minut",
"minutes": "{count, plural, one {# minuta} two {# minuti} few {# minute} other {# minut}}",
"endOfChapter": "Konec poglavja",
"noChaptersAvailable": "Ni razpoložljivih poglavij",
"extend15": "+ 15 minut",
@@ -31,7 +31,11 @@ describe('locale store', () => {
expect(matchSupportedLocale(['nl-NL', 'en-US'])).toBe('nl')
expect(matchSupportedLocale(['de-DE', 'en-GB'])).toBe('de')
expect(matchSupportedLocale(['es-ES', 'en-GB'])).toBe('es')
expect(matchSupportedLocale(['fr-FR', 'en-GB'])).toBe('fr')
expect(matchSupportedLocale(['it-IT', 'en-GB'])).toBe('it')
expect(matchSupportedLocale(['nl-BE', 'en'])).toBe('nl')
expect(matchSupportedLocale(['pl-PL', 'en-GB'])).toBe('pl')
expect(matchSupportedLocale(['pt-BR'])).toBe('pt')
})
+4
View File
@@ -2,7 +2,11 @@ preserve_hierarchy: true
export_languages:
- de
- es-ES
- fr
- it
- nl
- pl
- pt-BR
- sl
+6 -2
View File
@@ -1,6 +1,6 @@
# Localization
BookOrbit uses Vue I18n catalogs under `client/src/locales/`. English is the source language, and Crowdin is the source of truth for German, Dutch, Brazilian Portuguese, and Slovenian translations.
BookOrbit uses Vue I18n catalogs under `client/src/locales/`. English is the source language, and Crowdin is the source of truth for Dutch, French, German, Italian, Polish, Brazilian Portuguese, Slovenian, and Spanish translations.
## Supported Catalogs
@@ -8,7 +8,11 @@ BookOrbit uses Vue I18n catalogs under `client/src/locales/`. English is the sou
| -------------------- | ------------------- | --------- |
| English | Source language | `en.json` |
| German | `de` | `de.json` |
| Spanish | `es-ES` | `es.json` |
| French | `fr` | `fr.json` |
| Italian | `it` | `it.json` |
| Dutch | `nl` | `nl.json` |
| Polish | `pl` | `pl.json` |
| Brazilian Portuguese | `pt-BR` | `pt.json` |
| Slovenian | `sl` | `sl.json` |
@@ -72,7 +76,7 @@ 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 four target catalogs and runs the normal client checks.
5. CI verifies that the pull request changes only the eight 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.
+9 -1
View File
@@ -1,4 +1,4 @@
export const SUPPORTED_LOCALES = ["en", "de", "nl", "pt", "sl"] as const;
export const SUPPORTED_LOCALES = ["en", "de", "es", "fr", "it", "nl", "pl", "pt", "sl"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
@@ -7,7 +7,11 @@ export const DEFAULT_LOCALE: Locale = "en";
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
de: "Deutsch",
es: "Español",
fr: "Français",
it: "Italiano",
nl: "Nederlands",
pl: "Polski",
pt: "Português",
sl: "Slovenščina",
};
@@ -15,7 +19,11 @@ export const LOCALE_LABELS: Record<Locale, string> = {
export const LOCALE_DIRECTIONS: Record<Locale, "ltr" | "rtl"> = {
en: "ltr",
de: "ltr",
es: "ltr",
fr: "ltr",
it: "ltr",
nl: "ltr",
pl: "ltr",
pt: "ltr",
sl: "ltr",
};
+4
View File
@@ -20,7 +20,11 @@ fi
allowed_paths=(
"client/src/locales/de.json"
"client/src/locales/es.json"
"client/src/locales/fr.json"
"client/src/locales/it.json"
"client/src/locales/nl.json"
"client/src/locales/pl.json"
"client/src/locales/pt.json"
"client/src/locales/sl.json"
)
@@ -77,7 +77,7 @@ const validDisplayPreferences: DisplayPreferences = {
};
const validLocalePreferences: LocalePreferences = {
locale: 'nl',
locale: 'it',
};
const repo = {
@@ -134,6 +134,13 @@ describe('UserPreferencesService', () => {
expect(repo.upsert).toHaveBeenCalledWith(11, 'locale', validLocalePreferences);
});
it.each(['es', 'fr', 'pl'] as const)('upsertLocalePreferences accepts the %s locale', async (locale) => {
const preferences: LocalePreferences = { locale };
await expect(service.upsertLocalePreferences(11, preferences)).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'locale', preferences);
});
it('upsertLocalePreferences rejects unsupported locale', async () => {
await expect(service.upsertLocalePreferences(11, { locale: 'unsupported' } as never)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();