mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(client): add nine locales with English fallback catalogs
Register Czech, Danish, Finnish, Hungarian, Korean, Russian, Simplified Chinese, Swedish, and
Ukrainian so the language picker, browser detection, and server preference validation accept them.
Catalogs hold English fallback values; Crowdin supplies translations through the normal export flow.
Give each catalog exactly the CLDR plural categories its locale defines rather than copying the
English branches. Czech, Russian, and Ukrainian gain `few` and `many` seeded from `other` because
the source has no matching text. Korean and Chinese drop `one`, which they have no rule for and
Crowdin cannot store. Those synthesized branches all start from the same English string, so machine
translation returns identical forms where the grammar differs and a human has to resolve them.
Ship Chinese as Simplified only under the bare `zh` locale so every zh-* browser resolves to it.
Traditional Chinese, also requested in #86, would arrive later as a separate zh-Hant catalog.
Use `sv-SE` for Swedish in the Crowdin config, because Crowdin has no bare `sv` language and would
have skipped the export. Tolerate a wrapped SUPPORTED_LOCALES array in validate-locales, which
prettier now produces since the list outgrew the print width, and extend the exhaustive ICU test to
reach Polish `other`, a category its counts never selected.
Add the repository activity embed to the README alongside the existing translation progress chart.
Repair the KOReader plugin source assertions that 40e366a4 left stale after reshaping the
dashboard streak block and the downloaded badge icon lookup, and watch `koreader-plugin/**` in
the server CI path filter so plugin-only changes run the server suite that reads those files.
Closes #86
Closes #363
Closes #576
This commit is contained in:
@@ -242,6 +242,9 @@ jobs:
|
||||
filters: |
|
||||
server:
|
||||
- 'server/**'
|
||||
# Server tests assert against the plugin sources, so plugin-only
|
||||
# changes can break them.
|
||||
- 'koreader-plugin/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -8,7 +8,7 @@ A self-hosted library and reading platform for ebooks, PDFs, audiobooks, and com
|
||||
[](https://github.com/bookorbit/bookorbit/stargazers)
|
||||
[](https://github.com/bookorbit/bookorbit/actions/workflows/ci.yml)
|
||||
[](https://github.com/bookorbit/bookorbit/actions/workflows/release.yml)
|
||||
[](https://codecov.io/gh/bookorbit/bookorbit)
|
||||
[](https://codecov.io/gh/bookorbit/bookorbit)
|
||||
[](https://crowdin.com/project/bookorbit)
|
||||
|
||||
[](https://bookorbit.app)
|
||||
@@ -117,6 +117,10 @@ Full documentation is at **[bookorbit.app](https://bookorbit.app/what-is-bookorb
|
||||
|
||||
For local development, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To contribute, see [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for the full workflow: branch naming, test expectations, PR checklist, and commit format.
|
||||
|
||||
## Repository Activity
|
||||
|
||||

|
||||
|
||||
## Translations
|
||||
|
||||
Help translate BookOrbit into your language on [Crowdin](https://crowdin.com/project/bookorbit).
|
||||
|
||||
@@ -58,7 +58,9 @@ async function sourceMessageKeys() {
|
||||
const localeTypes = await readFile(localeTypesPath, 'utf8')
|
||||
const supportedMatch = localeTypes.match(/SUPPORTED_LOCALES\s*=\s*(\[[\s\S]*?\])\s+as const/)
|
||||
if (!supportedMatch) throw new Error('Unable to read SUPPORTED_LOCALES from packages/types/src/locale.ts')
|
||||
const supportedLocales = JSON.parse(supportedMatch[1])
|
||||
// Prettier wraps the array once the locale list outgrows the print width, which
|
||||
// adds a trailing comma that JSON.parse rejects.
|
||||
const supportedLocales = JSON.parse(supportedMatch[1].replace(/,(\s*])$/, '$1'))
|
||||
|
||||
const localeFiles = (await readdir(localesDirectory)).filter((file) => file.endsWith('.json')).sort()
|
||||
const expectedFiles = supportedLocales.map((locale) => `${locale}.json`).sort()
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import cs from '@/locales/cs.json'
|
||||
import da from '@/locales/da.json'
|
||||
import de from '@/locales/de.json'
|
||||
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'
|
||||
import ru from '@/locales/ru.json'
|
||||
import sl from '@/locales/sl.json'
|
||||
import sv from '@/locales/sv.json'
|
||||
import uk from '@/locales/uk.json'
|
||||
import zh from '@/locales/zh.json'
|
||||
import { compileIcuCatalog, icuCountValues, isIcuPluralMessage, splitIcuCount } from './icu'
|
||||
|
||||
function flattenMessages(value: unknown, prefix = '', output = new Map<string, string>()): Map<string, string> {
|
||||
@@ -293,14 +302,26 @@ describe('ICU message compilation', () => {
|
||||
|
||||
it.each([
|
||||
['en', en, [0, 1, 2]],
|
||||
// Czech inverts the Russian mapping: 5 is `other` and `many` is the fraction.
|
||||
['cs', cs, [0, 1, 2, 5, 1.5]],
|
||||
['da', da, [0, 1, 2]],
|
||||
['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]],
|
||||
['pl', polish, [0, 1, 2, 5, 1_000_000]],
|
||||
// Polish reserves `other` for fractions, exactly as Czech does.
|
||||
['pl', polish, [0, 1, 2, 5, 1.5, 1_000_000]],
|
||||
['pt', pt, [0, 1, 2, 1_000_000]],
|
||||
// Russian and Ukrainian reach `other` only through a fraction; 5 is `many`.
|
||||
['ru', ru, [0, 1, 2, 5, 1.5]],
|
||||
['sl', sl, [0, 1, 2, 3, 5]],
|
||||
['sv', sv, [0, 1, 2]],
|
||||
['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({
|
||||
|
||||
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
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
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
@@ -37,6 +37,30 @@ describe('locale store', () => {
|
||||
expect(matchSupportedLocale(['nl-BE', 'en'])).toBe('nl')
|
||||
expect(matchSupportedLocale(['pl-PL', 'en-GB'])).toBe('pl')
|
||||
expect(matchSupportedLocale(['pt-BR'])).toBe('pt')
|
||||
expect(matchSupportedLocale(['ru-RU', 'en-GB'])).toBe('ru')
|
||||
expect(matchSupportedLocale(['uk-UA', 'en-GB'])).toBe('uk')
|
||||
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')
|
||||
})
|
||||
|
||||
it('resolves every Chinese script and region variant to the Simplified catalog', async () => {
|
||||
const { matchSupportedLocale } = await import('../locale')
|
||||
|
||||
expect(matchSupportedLocale(['zh'])).toBe('zh')
|
||||
expect(matchSupportedLocale(['zh-CN'])).toBe('zh')
|
||||
expect(matchSupportedLocale(['zh-Hans-CN'])).toBe('zh')
|
||||
expect(matchSupportedLocale(['zh-SG'])).toBe('zh')
|
||||
|
||||
// Traditional readers intentionally land on Simplified until a zh-Hant
|
||||
// catalog exists. Adding one means matching by script before base language.
|
||||
expect(matchSupportedLocale(['zh-TW'])).toBe('zh')
|
||||
expect(matchSupportedLocale(['zh-Hant'])).toBe('zh')
|
||||
expect(matchSupportedLocale(['zh-HK'])).toBe('zh')
|
||||
})
|
||||
|
||||
it('prefers the stored locale over browser detection', async () => {
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
preserve_hierarchy: true
|
||||
|
||||
export_languages:
|
||||
- cs
|
||||
- da
|
||||
- de
|
||||
- es-ES
|
||||
- fi
|
||||
- fr
|
||||
- hu
|
||||
- it
|
||||
- ko
|
||||
- nl
|
||||
- pl
|
||||
- pt-BR
|
||||
- ru
|
||||
- sl
|
||||
- sv-SE
|
||||
- uk
|
||||
- zh-CN
|
||||
|
||||
pull_request_title: "i18n(client): sync translations from Crowdin"
|
||||
commit_message: |-
|
||||
|
||||
+24
-4
@@ -1,22 +1,35 @@
|
||||
# Localization
|
||||
|
||||
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.
|
||||
BookOrbit uses Vue I18n catalogs under `client/src/locales/`. English is the source language, and Crowdin is the source of truth for every other catalog listed below.
|
||||
|
||||
## Supported Catalogs
|
||||
|
||||
| Application locale | Crowdin language ID | Catalog |
|
||||
| -------------------- | ------------------- | --------- |
|
||||
| English | Source language | `en.json` |
|
||||
| Czech | `cs` | `cs.json` |
|
||||
| Danish | `da` | `da.json` |
|
||||
| German | `de` | `de.json` |
|
||||
| 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` |
|
||||
| Russian | `ru` | `ru.json` |
|
||||
| Slovenian | `sl` | `sl.json` |
|
||||
| Swedish | `sv-SE` | `sv.json` |
|
||||
| Ukrainian | `uk` | `uk.json` |
|
||||
| Simplified Chinese | `zh-CN` | `zh.json` |
|
||||
|
||||
Crowdin's `%two_letters_code%` placeholder maps Brazilian Portuguese to `pt`, which matches BookOrbit's existing application locale and filename.
|
||||
Crowdin's `%two_letters_code%` placeholder reduces the regional IDs above to BookOrbit's application locale and filename: `es-ES` to `es`, `pt-BR` to `pt`, `sv-SE` to `sv`, and `zh-CN` to `zh`.
|
||||
|
||||
Verify every ID against Crowdin's language list rather than assuming the two-letter form exists. Several languages have no bare two-letter ID at all: Swedish is `sv-SE`, and `sv-FI` reduces to the same `sv.json`, so enabling both would collide.
|
||||
|
||||
Chinese ships as Simplified only under the bare `zh` locale, so every `zh-*` browser resolves to it, including Traditional readers. Adding Traditional later means a new `zh-Hant` catalog with a `languages_mapping` entry for `zh-TW`; `zh` keeps its Simplified meaning, so no rename or stored-preference migration is needed. That addition also requires matching by script subtag in `client/src/stores/locale.ts`, because the current base-language fallback would send `zh-Hant` to `zh`.
|
||||
|
||||
## Adding User-Facing Copy
|
||||
|
||||
@@ -76,7 +89,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 eight target catalogs and runs the normal client checks.
|
||||
5. CI verifies that the pull request changes only the seventeen 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.
|
||||
|
||||
@@ -122,7 +135,14 @@ Create `client/src/locales/<locale>.json` with the exact key structure from `en.
|
||||
|
||||
A catalog may instead arrive already translated, for example from a bulk translation pass. That is allowed, but the translations then exist only in Git and Crowdin will overwrite them unless they are imported first. Follow "Seed existing translations into Crowdin" below before enabling export.
|
||||
|
||||
Review `client/src/stores/locale.ts` and add browser-locale matching coverage to `client/src/stores/__tests__/locale.spec.ts`, especially for regional variants. Add every CLDR plural category reported for the locale to each ICU plural message and extend the exhaustive ICU runtime tests in `client/src/i18n/icu.spec.ts`.
|
||||
Review `client/src/stores/locale.ts` and add browser-locale matching coverage to `client/src/stores/__tests__/locale.spec.ts`, especially for regional variants.
|
||||
|
||||
Each ICU plural message must carry exactly the CLDR categories reported for the locale, plus every exact selector such as `=0` from the English source. Both directions matter:
|
||||
|
||||
- A missing category fails `validate:locales`. Czech, Russian, and Ukrainian need `few` and `many` added to every plural message, seeded from the English `other` branch because the source has no corresponding text.
|
||||
- An extra category leaves Crowdin nowhere to store it, because the editor renders only the categories the locale defines. Chinese has just `other`, so an inherited `one` branch produces a permanent difference that `scripts/crowdin-verify/verify.mjs --reconcile` can never close.
|
||||
|
||||
Extend the exhaustive ICU runtime tests in `client/src/i18n/icu.spec.ts` with counts that reach every category. Derive them from `Intl.PluralRules` rather than by analogy, because languages that report the same category set do not map counts the same way: Russian and Ukrainian select `many` for 5 and reach `other` only through a fraction such as 1.5, while Czech selects `other` for 5 and reserves `many` for fractions.
|
||||
|
||||
### 4. Seed existing translations into Crowdin
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
export const SUPPORTED_LOCALES = ["en", "de", "es", "fr", "it", "nl", "pl", "pt", "sl"] as const;
|
||||
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 type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
@@ -6,26 +25,44 @@ export const DEFAULT_LOCALE: Locale = "en";
|
||||
/** Native display names for each supported locale, shown in the language picker. */
|
||||
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
cs: "Čeština",
|
||||
da: "Dansk",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
fi: "Suomi",
|
||||
fr: "Français",
|
||||
hu: "Magyar",
|
||||
it: "Italiano",
|
||||
ko: "한국어",
|
||||
nl: "Nederlands",
|
||||
pl: "Polski",
|
||||
pt: "Português",
|
||||
ru: "Русский",
|
||||
sl: "Slovenščina",
|
||||
sv: "Svenska",
|
||||
uk: "Українська",
|
||||
zh: "简体中文",
|
||||
};
|
||||
|
||||
export const LOCALE_DIRECTIONS: Record<Locale, "ltr" | "rtl"> = {
|
||||
en: "ltr",
|
||||
cs: "ltr",
|
||||
da: "ltr",
|
||||
de: "ltr",
|
||||
es: "ltr",
|
||||
fi: "ltr",
|
||||
fr: "ltr",
|
||||
hu: "ltr",
|
||||
it: "ltr",
|
||||
ko: "ltr",
|
||||
nl: "ltr",
|
||||
pl: "ltr",
|
||||
pt: "ltr",
|
||||
ru: "ltr",
|
||||
sl: "ltr",
|
||||
sv: "ltr",
|
||||
uk: "ltr",
|
||||
zh: "ltr",
|
||||
};
|
||||
|
||||
export interface LocalePreferences {
|
||||
|
||||
@@ -19,14 +19,23 @@ if [[ "$HEAD_REPO" != "$BASE_REPO" || "$HEAD_BRANCH" != "l10n_main" ]]; then
|
||||
fi
|
||||
|
||||
allowed_paths=(
|
||||
"client/src/locales/cs.json"
|
||||
"client/src/locales/da.json"
|
||||
"client/src/locales/de.json"
|
||||
"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"
|
||||
"client/src/locales/ru.json"
|
||||
"client/src/locales/sl.json"
|
||||
"client/src/locales/sv.json"
|
||||
"client/src/locales/uk.json"
|
||||
"client/src/locales/zh.json"
|
||||
)
|
||||
|
||||
is_allowed_path() {
|
||||
|
||||
@@ -95,8 +95,8 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(catalogUtil).toContain('dashboard and dashboard.readingStreak');
|
||||
expect(catalogUtil).toContain('reading_streak and tonumber(reading_streak.currentStreak)');
|
||||
expect(dashboard).toContain('local streak_days = readingStreakDays(dashboard, summary.streak_days)');
|
||||
expect(dashboard).toContain('{ value = tostring(streak_days), label = _("Day streak") }');
|
||||
expect(dashboard).not.toContain('{ value = tostring(summary.streak_days or 0), label = _("Day streak") }');
|
||||
expect(dashboard).toContain('value = tostring(streak_days),\n label = _("Day streak"),');
|
||||
expect(dashboard).not.toContain('tostring(summary.streak_days');
|
||||
});
|
||||
|
||||
it('uses tall detail space for measured related-book grids', async () => {
|
||||
@@ -277,7 +277,7 @@ describe('KOReader plugin update source wiring', () => {
|
||||
|
||||
expect(widgets).toContain('function CatalogWidgets.buildSelectionBadge(max_width)');
|
||||
expect(widgets).toContain('function CatalogWidgets.buildDownloadedBadge(max_width)');
|
||||
expect(widgets).toContain('icon = "appbar.filebrowser"');
|
||||
expect(widgets).toContain('local opts = iconOrFile("appbar.filebrowser", CatalogWidgets.assetIconFile("on_device"))');
|
||||
expect(widgets).toContain('function CatalogWidgets.buildCoverWithStateBadges');
|
||||
expect(widgets).toContain('local show_label = self.menu.mosaic_show_titles == true');
|
||||
expect(widgets).toContain('local label_text = shortText(book and book.title or _("Untitled"), 30)');
|
||||
|
||||
Reference in New Issue
Block a user