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 { function durationLabel(seconds: number): string {
const minutes = Math.round(seconds / 60) const minutes = Math.round(seconds / 60)
if (minutes < 60) return t('adminFeature.sharedInsights.durationMinutes', { count: minutes }) 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 { function trendDayLabel(day: string): string {
@@ -71,7 +71,7 @@ function durationLabel(seconds: number): string {
const minutes = Math.round(seconds / 60) const minutes = Math.round(seconds / 60)
if (minutes < 60) return t('settings.privacySharing.durationMinutes', { count: minutes }) if (minutes < 60) return t('settings.privacySharing.durationMinutes', { count: minutes })
const hours = Math.round((minutes / 60) * 10) / 10 const hours = Math.round((minutes / 60) * 10) / 10
return t('settings.privacySharing.durationHours', { count: formatNumber(hours) }) return t('settings.privacySharing.durationHours', { count: hours })
} }
</script> </script>
+90
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import de from '@/locales/de.json' import de from '@/locales/de.json'
import en from '@/locales/en.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 nl from '@/locales/nl.json'
import polish from '@/locales/pl.json'
import pt from '@/locales/pt.json' import pt from '@/locales/pt.json'
import sl from '@/locales/sl.json' import sl from '@/locales/sl.json'
import { compileIcuCatalog, icuCountValues, isIcuPluralMessage, splitIcuCount } from './icu' import { compileIcuCatalog, icuCountValues, isIcuPluralMessage, splitIcuCount } from './icu'
@@ -71,7 +75,11 @@ describe('ICU message compilation', () => {
it.each([ it.each([
['en', en, [0, 1, 2, 1_234]], ['en', en, [0, 1, 2, 1_234]],
['de', de, [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]], ['nl', nl, [0, 1, 2, 1_234]],
['pl', polish, [0, 1, 2, 5, 1_000_000]],
['pt', pt, [0, 1, 2, 1_234]], ['pt', pt, [0, 1, 2, 1_234]],
['sl', sl, [0, 1, 2, 3, 5, 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) => { ] 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') 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([ it.each([
['en', en, [0, 1, 2], ['No duplicate groups', 'One duplicate group', '2 duplicate groups']], ['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']], ['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']], ['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']], ['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']], ['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) => { ] as const)('renders migrated Book Duplicates messages for %s', (locale, catalog, counts, expectedGroups) => {
@@ -155,7 +228,11 @@ describe('ICU message compilation', () => {
it.each([ it.each([
['en', en, [1, 2], ['1 failure', '2 failures']], ['en', en, [1, 2], ['1 failure', '2 failures']],
['de', de, [1, 2], ['1 Fehler', '2 Fehler']], ['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']], ['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']], ['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']], ['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) => { ] as const)('renders migrated Kobo activity plurals for %s', (locale, catalog, counts, expected) => {
@@ -172,7 +249,16 @@ describe('ICU message compilation', () => {
it.each([ it.each([
['en', en, [0, 1, 2], ['no files ready', '1 file ready', '2 files ready']], ['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']], ['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']], ['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']], ['pt', pt, [0, 1, 1_000_000], ['nenhum arquivo pronto', '1 arquivo pronto', '1.000.000 arquivos prontos']],
[ [
'sl', 'sl',
@@ -208,7 +294,11 @@ describe('ICU message compilation', () => {
it.each([ it.each([
['en', en, [0, 1, 2]], ['en', en, [0, 1, 2]],
['de', de, [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]], ['nl', nl, [0, 1, 2]],
['pl', polish, [0, 1, 2, 5, 1_000_000]],
['pt', pt, [0, 1, 2, 1_000_000]], ['pt', pt, [0, 1, 2, 1_000_000]],
['sl', sl, [0, 1, 2, 3, 5]], ['sl', sl, [0, 1, 2, 3, 5]],
] as const)('formats every ICU message for all relevant plural categories in %s', (locale, catalog, counts) => { ] 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" "paginationLabel": "Seiten des Profilzugriffsverlaufs"
}, },
"durationMinutes": "{count, plural, one {# Minute} other {# Minuten}}", "durationMinutes": "{count, plural, one {# Minute} other {# Minuten}}",
"durationHours": "{count} Stunden", "durationHours": "{count, plural, one {# Stunde} other {# Stunden}}",
"unknownTitle": "Buch ohne Titel", "unknownTitle": "Buch ohne Titel",
"errors": { "errors": {
"load": "Freigabeeinstellungen konnten nicht geladen werden.", "load": "Freigabeeinstellungen konnten nicht geladen werden.",
@@ -407,7 +407,7 @@
"newest": "Neueste", "newest": "Neueste",
"name": "Name" "name": "Name"
}, },
"selectedCount": "{count} ausgewählt", "selectedCount": "{count, plural, one {# ausgewählt} other {# ausgewählt}}",
"clear": "Leeren", "clear": "Leeren",
"deleteSelected": "Auswahl löschen", "deleteSelected": "Auswahl löschen",
"loading": "Symbole werden geladen...", "loading": "Symbole werden geladen...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "vor {count} Min.", "minutesAgo": "vor {count} Min.",
"hoursAgo": "vor {count} Std.", "hoursAgo": "vor {count} Std.",
"yesterday": "gestern", "yesterday": "gestern",
"daysAgo": "vor {count} Tagen", "daysAgo": "{count, plural, one {vor # Tag} other {vor # Tagen}}",
"label": "Letzter Lauf: {when}", "label": "Letzter Lauf: {when}",
"labelQueued": "Letzter Lauf: {when} - {count} Bücher eingereiht", "labelQueued": "Letzter Lauf: {when} - {count} Bücher eingereiht",
"labelNoneEligible": "Letzter Lauf: {when} - keine berechtigten Bücher" "labelNoneEligible": "Letzter Lauf: {when} - keine berechtigten Bücher"
@@ -2362,7 +2362,7 @@
"period": "Berichtszeitraum", "period": "Berichtszeitraum",
"periodDays": "Letzte {count} Tage", "periodDays": "Letzte {count} Tage",
"durationMinutes": "{count, plural, one {# Minute} other {# Minuten}}", "durationMinutes": "{count, plural, one {# Minute} other {# Minuten}}",
"durationHours": "{count} Stunden", "durationHours": "{count, plural, one {# Stunde} other {# Stunden}}",
"metrics": { "metrics": {
"sessions": "Sitzungen", "sessions": "Sitzungen",
"readingTime": "Lesezeit", "readingTime": "Lesezeit",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Keine Bücher gefunden" "noBooksFound": "Keine Bücher gefunden"
}, },
"bulk": { "bulk": {
"countSelected": "{count} ausgewählt", "countSelected": "{count, plural, one {# ausgewählt} other {# ausgewählt}}",
"pageSelected": "Seite ausgewählt", "pageSelected": "Seite ausgewählt",
"selectPage": "Seite auswählen", "selectPage": "Seite auswählen",
"clear": "Zurücksetzen", "clear": "Zurücksetzen",
@@ -5956,7 +5956,7 @@
"sleep": "Schlafmodus", "sleep": "Schlafmodus",
"chapterAbbrev": "Kap.", "chapterAbbrev": "Kap.",
"sleepTimer": "Einschlaftimer", "sleepTimer": "Einschlaftimer",
"minutes": "{count} Minuten", "minutes": "{count, plural, one {# Minute} other {# Minuten}}",
"endOfChapter": "Ende des Kapitels", "endOfChapter": "Ende des Kapitels",
"noChaptersAvailable": "Keine Kapitel verfügbar", "noChaptersAvailable": "Keine Kapitel verfügbar",
"extend15": "+ 15 Minuten", "extend15": "+ 15 Minuten",
+6 -6
View File
@@ -71,7 +71,7 @@
"paginationLabel": "Profile access history pages" "paginationLabel": "Profile access history pages"
}, },
"durationMinutes": "{count, plural, one {# minute} other {# minutes}}", "durationMinutes": "{count, plural, one {# minute} other {# minutes}}",
"durationHours": "{count} hours", "durationHours": "{count, plural, one {# hour} other {# hours}}",
"unknownTitle": "Untitled book", "unknownTitle": "Untitled book",
"errors": { "errors": {
"load": "Failed to load sharing settings.", "load": "Failed to load sharing settings.",
@@ -407,7 +407,7 @@
"newest": "Newest", "newest": "Newest",
"name": "Name" "name": "Name"
}, },
"selectedCount": "{count} selected", "selectedCount": "{count, plural, one {# selected} other {# selected}}",
"clear": "Clear", "clear": "Clear",
"deleteSelected": "Delete selected", "deleteSelected": "Delete selected",
"loading": "Loading icons...", "loading": "Loading icons...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "{count}m ago", "minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago", "hoursAgo": "{count}h ago",
"yesterday": "yesterday", "yesterday": "yesterday",
"daysAgo": "{count} days ago", "daysAgo": "{count, plural, one {# day ago} other {# days ago}}",
"label": "Last run: {when}", "label": "Last run: {when}",
"labelQueued": "Last run: {when} - queued {count} books", "labelQueued": "Last run: {when} - queued {count} books",
"labelNoneEligible": "Last run: {when} - no eligible books" "labelNoneEligible": "Last run: {when} - no eligible books"
@@ -2362,7 +2362,7 @@
"period": "Reporting period", "period": "Reporting period",
"periodDays": "Last {count} days", "periodDays": "Last {count} days",
"durationMinutes": "{count, plural, one {# minute} other {# minutes}}", "durationMinutes": "{count, plural, one {# minute} other {# minutes}}",
"durationHours": "{count} hours", "durationHours": "{count, plural, one {# hour} other {# hours}}",
"metrics": { "metrics": {
"sessions": "Sessions", "sessions": "Sessions",
"readingTime": "Reading time", "readingTime": "Reading time",
@@ -2575,7 +2575,7 @@
"noBooksFound": "No books found" "noBooksFound": "No books found"
}, },
"bulk": { "bulk": {
"countSelected": "{count} selected", "countSelected": "{count, plural, one {# selected} other {# selected}}",
"pageSelected": "Page selected", "pageSelected": "Page selected",
"selectPage": "Select page", "selectPage": "Select page",
"clear": "Clear", "clear": "Clear",
@@ -5956,7 +5956,7 @@
"sleep": "Sleep", "sleep": "Sleep",
"chapterAbbrev": "Ch.", "chapterAbbrev": "Ch.",
"sleepTimer": "Sleep timer", "sleepTimer": "Sleep timer",
"minutes": "{count} minutes", "minutes": "{count, plural, one {# minute} other {# minutes}}",
"endOfChapter": "End of chapter", "endOfChapter": "End of chapter",
"noChaptersAvailable": "No chapters available", "noChaptersAvailable": "No chapters available",
"extend15": "+ 15 minutes", "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" "paginationLabel": "Pagina's met profieltoegangsgeschiedenis"
}, },
"durationMinutes": "{count, plural, one {# minuut} other {# minuten}}", "durationMinutes": "{count, plural, one {# minuut} other {# minuten}}",
"durationHours": "{count} uur", "durationHours": "{count, plural, one {# uur} other {# uur}}",
"unknownTitle": "Boek zonder titel", "unknownTitle": "Boek zonder titel",
"errors": { "errors": {
"load": "Deelinstellingen konden niet worden geladen.", "load": "Deelinstellingen konden niet worden geladen.",
@@ -407,7 +407,7 @@
"newest": "Nieuwste", "newest": "Nieuwste",
"name": "Naam" "name": "Naam"
}, },
"selectedCount": "{count} geselecteerd", "selectedCount": "{count, plural, one {# geselecteerd} other {# geselecteerd}}",
"clear": "Wissen", "clear": "Wissen",
"deleteSelected": "Selectie verwijderen", "deleteSelected": "Selectie verwijderen",
"loading": "Pictogrammen laden...", "loading": "Pictogrammen laden...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "{count}m geleden", "minutesAgo": "{count}m geleden",
"hoursAgo": "{count}u geleden", "hoursAgo": "{count}u geleden",
"yesterday": "gisteren", "yesterday": "gisteren",
"daysAgo": "{count} dagen geleden", "daysAgo": "{count, plural, one {# dag geleden} other {# dagen geleden}}",
"label": "Laatste uitvoering: {when}", "label": "Laatste uitvoering: {when}",
"labelQueued": "Laatste uitvoering: {when} - {count} boeken in wachtrij geplaatst", "labelQueued": "Laatste uitvoering: {when} - {count} boeken in wachtrij geplaatst",
"labelNoneEligible": "Laatste uitvoering: {when} - geen in aanmerking komende boeken" "labelNoneEligible": "Laatste uitvoering: {when} - geen in aanmerking komende boeken"
@@ -2362,7 +2362,7 @@
"period": "Rapportageperiode", "period": "Rapportageperiode",
"periodDays": "Afgelopen {count} dagen", "periodDays": "Afgelopen {count} dagen",
"durationMinutes": "{count, plural, one {# minuut} other {# minuten}}", "durationMinutes": "{count, plural, one {# minuut} other {# minuten}}",
"durationHours": "{count} uur", "durationHours": "{count, plural, one {# uur} other {# uur}}",
"metrics": { "metrics": {
"sessions": "Sessies", "sessions": "Sessies",
"readingTime": "Leestijd", "readingTime": "Leestijd",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Geen boeken gevonden" "noBooksFound": "Geen boeken gevonden"
}, },
"bulk": { "bulk": {
"countSelected": "{count} geselecteerd", "countSelected": "{count, plural, one {# geselecteerd} other {# geselecteerd}}",
"pageSelected": "Pagina geselecteerd", "pageSelected": "Pagina geselecteerd",
"selectPage": "Pagina selecteren", "selectPage": "Pagina selecteren",
"clear": "Wissen", "clear": "Wissen",
@@ -5956,7 +5956,7 @@
"sleep": "Slaap", "sleep": "Slaap",
"chapterAbbrev": "Hfst.", "chapterAbbrev": "Hfst.",
"sleepTimer": "Slaaptimer", "sleepTimer": "Slaaptimer",
"minutes": "{count} minuten", "minutes": "{count, plural, one {# minuut} other {# minuten}}",
"endOfChapter": "Einde van hoofdstuk", "endOfChapter": "Einde van hoofdstuk",
"noChaptersAvailable": "Geen hoofdstukken beschikbaar", "noChaptersAvailable": "Geen hoofdstukken beschikbaar",
"extend15": "+ 15 minuten", "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" "paginationLabel": "Páginas do histórico de acesso ao perfil"
}, },
"durationMinutes": "{count, plural, one {# minuto} many {# minutos} other {# minutos}}", "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", "unknownTitle": "Livro sem título",
"errors": { "errors": {
"load": "Falha ao carregar configurações de compartilhamento.", "load": "Falha ao carregar configurações de compartilhamento.",
@@ -407,7 +407,7 @@
"newest": "Mais recentes", "newest": "Mais recentes",
"name": "Nome" "name": "Nome"
}, },
"selectedCount": "{count} selecionado(s)", "selectedCount": "{count, plural, one {# ícone selecionado} other {# ícones selecionados} many {# ícones selecionados}}",
"clear": "Limpar", "clear": "Limpar",
"deleteSelected": "Excluir selecionados", "deleteSelected": "Excluir selecionados",
"loading": "Carregando ícones...", "loading": "Carregando ícones...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "há {count}m", "minutesAgo": "há {count}m",
"hoursAgo": "há {count}h", "hoursAgo": "há {count}h",
"yesterday": "ontem", "yesterday": "ontem",
"daysAgo": "há {count} dias", "daysAgo": "{count, plural, one {há # dia} other {há # dias} many {há # dias}}",
"label": "Última execução: {when}", "label": "Última execução: {when}",
"labelQueued": "Última execução: {when} - {count} livro(s) na fila", "labelQueued": "Última execução: {when} - {count} livro(s) na fila",
"labelNoneEligible": "Última execução: {when} - nenhum livro elegível" "labelNoneEligible": "Última execução: {when} - nenhum livro elegível"
@@ -2362,7 +2362,7 @@
"period": "Período do relatório", "period": "Período do relatório",
"periodDays": "Últimos {count} dias", "periodDays": "Últimos {count} dias",
"durationMinutes": "{count, plural, one {# minuto} many {# minutos} other {# minutos}}", "durationMinutes": "{count, plural, one {# minuto} many {# minutos} other {# minutos}}",
"durationHours": "{count} horas", "durationHours": "{count, plural, one {# hora} other {# horas} many {# horas}}",
"metrics": { "metrics": {
"sessions": "Sessões", "sessions": "Sessões",
"readingTime": "Tempo de leitura", "readingTime": "Tempo de leitura",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Nenhum livro encontrado" "noBooksFound": "Nenhum livro encontrado"
}, },
"bulk": { "bulk": {
"countSelected": "{count} selecionado(s)", "countSelected": "{count, plural, one {# anotação selecionada} other {# anotações selecionadas} many {# anotações selecionadas}}",
"pageSelected": "Página selecionada", "pageSelected": "Página selecionada",
"selectPage": "Selecionar página", "selectPage": "Selecionar página",
"clear": "Limpar", "clear": "Limpar",
@@ -5956,7 +5956,7 @@
"sleep": "Pausa", "sleep": "Pausa",
"chapterAbbrev": "Cap.", "chapterAbbrev": "Cap.",
"sleepTimer": "Temporizador de pausa", "sleepTimer": "Temporizador de pausa",
"minutes": "{count} minutos", "minutes": "{count, plural, one {# minuto} other {# minutos} many {# minutos}}",
"endOfChapter": "Fim do capítulo", "endOfChapter": "Fim do capítulo",
"noChaptersAvailable": "Nenhum capítulo disponível", "noChaptersAvailable": "Nenhum capítulo disponível",
"extend15": "+ 15 minutos", "extend15": "+ 15 minutos",
+6 -6
View File
@@ -71,7 +71,7 @@
"paginationLabel": "Strani zgodovine dostopov do profila" "paginationLabel": "Strani zgodovine dostopov do profila"
}, },
"durationMinutes": "{count, plural, one {# minuta} two {# minuti} few {# minute} other {# minut}}", "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", "unknownTitle": "Knjiga brez naslova",
"errors": { "errors": {
"load": "Nastavitev deljenja ni bilo mogoče naložiti.", "load": "Nastavitev deljenja ni bilo mogoče naložiti.",
@@ -407,7 +407,7 @@
"newest": "Najnovejše", "newest": "Najnovejše",
"name": "Ime" "name": "Ime"
}, },
"selectedCount": "{count} izbranih", "selectedCount": "{count, plural, one {# izbrana ikona} two {# izbrani ikoni} few {# izbrane ikone} other {# izbranih ikon}}",
"clear": "Počisti", "clear": "Počisti",
"deleteSelected": "Izbriši izbrano", "deleteSelected": "Izbriši izbrano",
"loading": "Nalaganje ikon...", "loading": "Nalaganje ikon...",
@@ -1216,7 +1216,7 @@
"minutesAgo": "pred {count} min", "minutesAgo": "pred {count} min",
"hoursAgo": "pred {count} h", "hoursAgo": "pred {count} h",
"yesterday": "včeraj", "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}", "label": "Zadnji zagon: {when}",
"labelQueued": "Zadnji zagon: {when} - v vrsto uvrščenih {count} knjig", "labelQueued": "Zadnji zagon: {when} - v vrsto uvrščenih {count} knjig",
"labelNoneEligible": "Zadnji zagon: {when} - ni upravičenih knjig" "labelNoneEligible": "Zadnji zagon: {when} - ni upravičenih knjig"
@@ -2362,7 +2362,7 @@
"period": "Obdobje poročila", "period": "Obdobje poročila",
"periodDays": "Zadnjih {count} dni", "periodDays": "Zadnjih {count} dni",
"durationMinutes": "{count, plural, one {# minuta} two {# minuti} few {# minute} other {# minut}}", "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": { "metrics": {
"sessions": "Seje", "sessions": "Seje",
"readingTime": "Čas branja", "readingTime": "Čas branja",
@@ -2575,7 +2575,7 @@
"noBooksFound": "Ni najdenih knjig" "noBooksFound": "Ni najdenih knjig"
}, },
"bulk": { "bulk": {
"countSelected": "Izbranih: {count}", "countSelected": "{count, plural, one {# izbrana opomba} two {# izbrani opombi} few {# izbrane opombe} other {# izbranih opomb}}",
"pageSelected": "Stran izbrana", "pageSelected": "Stran izbrana",
"selectPage": "Izberi stran", "selectPage": "Izberi stran",
"clear": "Počisti", "clear": "Počisti",
@@ -5956,7 +5956,7 @@
"sleep": "Spanje", "sleep": "Spanje",
"chapterAbbrev": "Pogl.", "chapterAbbrev": "Pogl.",
"sleepTimer": "Časovnik spanja", "sleepTimer": "Časovnik spanja",
"minutes": "{count} minut", "minutes": "{count, plural, one {# minuta} two {# minuti} few {# minute} other {# minut}}",
"endOfChapter": "Konec poglavja", "endOfChapter": "Konec poglavja",
"noChaptersAvailable": "Ni razpoložljivih poglavij", "noChaptersAvailable": "Ni razpoložljivih poglavij",
"extend15": "+ 15 minut", "extend15": "+ 15 minut",
@@ -31,7 +31,11 @@ describe('locale store', () => {
expect(matchSupportedLocale(['nl-NL', 'en-US'])).toBe('nl') expect(matchSupportedLocale(['nl-NL', 'en-US'])).toBe('nl')
expect(matchSupportedLocale(['de-DE', 'en-GB'])).toBe('de') 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(['nl-BE', 'en'])).toBe('nl')
expect(matchSupportedLocale(['pl-PL', 'en-GB'])).toBe('pl')
expect(matchSupportedLocale(['pt-BR'])).toBe('pt') expect(matchSupportedLocale(['pt-BR'])).toBe('pt')
}) })
+4
View File
@@ -2,7 +2,11 @@ preserve_hierarchy: true
export_languages: export_languages:
- de - de
- es-ES
- fr
- it
- nl - nl
- pl
- pt-BR - pt-BR
- sl - sl
+6 -2
View File
@@ -1,6 +1,6 @@
# Localization # 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 ## Supported Catalogs
@@ -8,7 +8,11 @@ BookOrbit uses Vue I18n catalogs under `client/src/locales/`. English is the sou
| -------------------- | ------------------- | --------- | | -------------------- | ------------------- | --------- |
| English | Source language | `en.json` | | English | Source language | `en.json` |
| German | `de` | `de.json` | | German | `de` | `de.json` |
| Spanish | `es-ES` | `es.json` |
| French | `fr` | `fr.json` |
| Italian | `it` | `it.json` |
| Dutch | `nl` | `nl.json` | | Dutch | `nl` | `nl.json` |
| Polish | `pl` | `pl.json` |
| Brazilian Portuguese | `pt-BR` | `pt.json` | | Brazilian Portuguese | `pt-BR` | `pt.json` |
| Slovenian | `sl` | `sl.json` | | Slovenian | `sl` | `sl.json` |
@@ -72,7 +76,7 @@ After an English source change reaches `main`:
2. Translators update target strings in Crowdin. 2. Translators update target strings in Crowdin.
3. Crowdin exports the target catalogs to `l10n_main`. 3. Crowdin exports the target catalogs to `l10n_main`.
4. Crowdin opens a pull request to `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. 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 `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 type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en"; export const DEFAULT_LOCALE: Locale = "en";
@@ -7,7 +7,11 @@ export const DEFAULT_LOCALE: Locale = "en";
export const LOCALE_LABELS: Record<Locale, string> = { export const LOCALE_LABELS: Record<Locale, string> = {
en: "English", en: "English",
de: "Deutsch", de: "Deutsch",
es: "Español",
fr: "Français",
it: "Italiano",
nl: "Nederlands", nl: "Nederlands",
pl: "Polski",
pt: "Português", pt: "Português",
sl: "Slovenščina", sl: "Slovenščina",
}; };
@@ -15,7 +19,11 @@ export const LOCALE_LABELS: Record<Locale, string> = {
export const LOCALE_DIRECTIONS: Record<Locale, "ltr" | "rtl"> = { export const LOCALE_DIRECTIONS: Record<Locale, "ltr" | "rtl"> = {
en: "ltr", en: "ltr",
de: "ltr", de: "ltr",
es: "ltr",
fr: "ltr",
it: "ltr",
nl: "ltr", nl: "ltr",
pl: "ltr",
pt: "ltr", pt: "ltr",
sl: "ltr", sl: "ltr",
}; };
+4
View File
@@ -20,7 +20,11 @@ fi
allowed_paths=( allowed_paths=(
"client/src/locales/de.json" "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/nl.json"
"client/src/locales/pl.json"
"client/src/locales/pt.json" "client/src/locales/pt.json"
"client/src/locales/sl.json" "client/src/locales/sl.json"
) )
@@ -77,7 +77,7 @@ const validDisplayPreferences: DisplayPreferences = {
}; };
const validLocalePreferences: LocalePreferences = { const validLocalePreferences: LocalePreferences = {
locale: 'nl', locale: 'it',
}; };
const repo = { const repo = {
@@ -134,6 +134,13 @@ describe('UserPreferencesService', () => {
expect(repo.upsert).toHaveBeenCalledWith(11, 'locale', validLocalePreferences); 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 () => { it('upsertLocalePreferences rejects unsupported locale', async () => {
await expect(service.upsertLocalePreferences(11, { locale: 'unsupported' } as never)).rejects.toBeInstanceOf(BadRequestException); await expect(service.upsertLocalePreferences(11, { locale: 'unsupported' } as never)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled(); expect(repo.upsert).not.toHaveBeenCalled();