mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
bee500880e
Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text.
131 lines
5.1 KiB
TypeScript
131 lines
5.1 KiB
TypeScript
/**
|
|
* Plural forms for count-bearing strings.
|
|
*
|
|
* A string that depends on a number keeps its key as the general form and
|
|
* adds one key per CLDR category the language needs: `places.count` reads
|
|
* "{count} places", `places.count.one` reads "{count} place". Russian adds
|
|
* `.few` and `.many`, Arabic `.zero`, `.two`, `.few` and `.many`, Japanese
|
|
* none at all. Which category a number takes is the language's own rule, read
|
|
* from `Intl.PluralRules`, never a `count === 1` in the caller: 21 is "one" in
|
|
* Russian and 0 is "one" in French.
|
|
*/
|
|
|
|
export const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'] as const;
|
|
export type PluralCategory = (typeof PLURAL_CATEGORIES)[number];
|
|
|
|
/** The categories a key spells out as `key.<category>`; `other` is the key itself (or `key.other`). */
|
|
const FORM_CATEGORIES: readonly PluralCategory[] = ['zero', 'one', 'two', 'few', 'many'];
|
|
|
|
const rules = new Map<string, Intl.PluralRules>();
|
|
|
|
function rulesFor(intlLocale: string): Intl.PluralRules {
|
|
let r = rules.get(intlLocale);
|
|
if (!r) {
|
|
r = new Intl.PluralRules(intlLocale);
|
|
rules.set(intlLocale, r);
|
|
}
|
|
return r;
|
|
}
|
|
|
|
/** The CLDR category `count` takes in `intlLocale` (a BCP 47 tag, see `getIntlLanguage`). */
|
|
export function pluralCategory(count: number, intlLocale: string): PluralCategory {
|
|
return rulesFor(intlLocale).select(count) as PluralCategory;
|
|
}
|
|
|
|
/**
|
|
* The key a count-bearing string resolves to in `strings`: the form for the
|
|
* count's category, else the `.other` form, else the key itself. Undefined
|
|
* when `strings` has none of them, so the caller can fall back to another
|
|
* locale with that locale's own rules.
|
|
*/
|
|
export function resolvePluralKey(
|
|
strings: Readonly<Record<string, unknown>>,
|
|
key: string,
|
|
count: number,
|
|
intlLocale: string,
|
|
): string | undefined {
|
|
for (const candidate of [`${key}.${pluralCategory(count, intlLocale)}`, `${key}.other`, key]) {
|
|
if (typeof strings[candidate] === 'string') return candidate;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* The form of a text built in code rather than looked up by key (the server's
|
|
* mail and push texts): the form for the count's category, else `other`, the
|
|
* general form, as the bare key is in a catalogue. A count that is not a
|
|
* number (notification params travel as strings) takes the general form.
|
|
*/
|
|
export function pluralForm(
|
|
count: number | string | undefined,
|
|
intlLocale: string,
|
|
forms: Readonly<Partial<Record<PluralCategory, string>> & { other: string }>,
|
|
): string {
|
|
const n = typeof count === 'number' ? count : Number(count);
|
|
if (count === undefined || count === '' || !Number.isFinite(n)) return forms.other;
|
|
return forms[pluralCategory(n, intlLocale)] ?? forms.other;
|
|
}
|
|
|
|
/**
|
|
* The categories whole counts from 0 to 200 reach: the forms a translation
|
|
* has to spell out. Categories only fractions or millions reach (French
|
|
* `many`, Russian `other`) fall back to the general form.
|
|
*/
|
|
export function integerPluralCategories(intlLocale: string): PluralCategory[] {
|
|
const r = rulesFor(intlLocale);
|
|
const seen = new Set<PluralCategory>();
|
|
for (let n = 0; n <= 200; n++) seen.add(r.select(n) as PluralCategory);
|
|
return PLURAL_CATEGORIES.filter((c) => seen.has(c));
|
|
}
|
|
|
|
/** Every category the language has at all, for refusing a form it can never select. */
|
|
export function allPluralCategories(intlLocale: string): PluralCategory[] {
|
|
return rulesFor(intlLocale).resolvedOptions().pluralCategories as PluralCategory[];
|
|
}
|
|
|
|
/**
|
|
* The categories exactly one whole number from 0 to 200 takes (Arabic `zero`,
|
|
* `one` and `two`, German `one`). Only these forms may spell the number out
|
|
* ("ملف واحد") instead of carrying the count: French `one` also covers 0 and
|
|
* Russian `one` covers 21.
|
|
*/
|
|
export function singleNumberPluralCategories(intlLocale: string): PluralCategory[] {
|
|
const r = rulesFor(intlLocale);
|
|
const hits = new Map<PluralCategory, number>();
|
|
for (let n = 0; n <= 200; n++) {
|
|
const c = r.select(n) as PluralCategory;
|
|
hits.set(c, (hits.get(c) ?? 0) + 1);
|
|
}
|
|
return PLURAL_CATEGORIES.filter((c) => hits.get(c) === 1);
|
|
}
|
|
|
|
/**
|
|
* The plural groups a reference catalogue (English) defines: every key with a
|
|
* general form (`key` or `key.other`) and at least one category form.
|
|
*/
|
|
export function pluralGroups(referenceKeys: Iterable<string>): Set<string> {
|
|
const keys = new Set(referenceKeys);
|
|
const groups = new Set<string>();
|
|
for (const key of keys) {
|
|
const form = splitForm(key);
|
|
if (form && (keys.has(form.base) || keys.has(`${form.base}.other`))) groups.add(form.base);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
/** The group and category `key` spells out, or null when it is not a category form of one of `groups`. */
|
|
export function pluralFormOf(
|
|
key: string,
|
|
groups: ReadonlySet<string>,
|
|
): { base: string; category: PluralCategory } | null {
|
|
const form = splitForm(key);
|
|
return form && groups.has(form.base) ? form : null;
|
|
}
|
|
|
|
function splitForm(key: string): { base: string; category: PluralCategory } | null {
|
|
const dot = key.lastIndexOf('.');
|
|
if (dot < 0) return null;
|
|
const category = key.slice(dot + 1) as PluralCategory;
|
|
return FORM_CATEGORIES.includes(category) ? { base: key.slice(0, dot), category } : null;
|
|
}
|