Skip to content

Commit 41d92c8

Browse files
authored
Merge pull request CyberTimon#1390 from VailElla/codex/i18n-plural-sync
fix(i18n): synchronize locale plural forms
2 parents bc83d5c + 0e9fdf0 commit 41d92c8

16 files changed

Lines changed: 474 additions & 106 deletions

i18next.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defineConfig } from 'i18next-cli';
22

33
export default defineConfig({
4-
locales: ['en', 'de', 'pl', 'zh-CN', 'zh-TW', 'es', 'fr', 'it', 'pt', 'ja', 'ru'],
4+
locales: ['en', 'de', 'pl', 'zh-CN', 'zh-TW', 'es', 'fr', 'it', 'pt', 'ja', 'ko', 'ru'],
55
extract: {
66
input: ['src/**/*.{ts,tsx}'],
77
output: 'src/i18n/locales/{{language}}.json',

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"format": "prettier --write .",
1414
"format:check": "prettier --check .",
1515
"i18n:extract": "i18next-cli extract",
16-
"i18n:check": "i18next-cli extract --ci --dry-run",
16+
"i18n:check": "i18next-cli extract --ci --dry-run && npm run i18n:runtime-check",
17+
"i18n:runtime-check": "node src/i18n/check-runtime.mjs",
1718
"i18n:lint": "i18next-cli lint"
1819
},
1920
"dependencies": {

src/i18n/check-runtime.mjs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
import i18next from 'i18next';
6+
7+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
8+
const localeDir = path.resolve(scriptDir, 'locales');
9+
const pluralSuffix = /_(zero|one|two|few|many|other)$/;
10+
const countCandidates = [
11+
...Array.from({ length: 201 }, (_, count) => count),
12+
0.1,
13+
1.1,
14+
2.1,
15+
5.1,
16+
10.1,
17+
1_000,
18+
1_000_000,
19+
];
20+
21+
const flatten = (object, prefix = '', leaves = new Map()) => {
22+
for (const [key, value] of Object.entries(object)) {
23+
const fullKey = prefix ? `${prefix}.${key}` : key;
24+
if (value && typeof value === 'object' && !Array.isArray(value)) {
25+
flatten(value, fullKey, leaves);
26+
} else {
27+
leaves.set(fullKey, value);
28+
}
29+
}
30+
return leaves;
31+
};
32+
33+
const localeFiles = fs
34+
.readdirSync(localeDir)
35+
.filter((filename) => filename.endsWith('.json'))
36+
.sort();
37+
const resources = {};
38+
const pluralKeysByLocale = new Map();
39+
const failures = [];
40+
41+
for (const filename of localeFiles) {
42+
const locale = path.basename(filename, '.json');
43+
const translations = JSON.parse(fs.readFileSync(path.join(localeDir, filename), 'utf8'));
44+
const leaves = flatten(translations);
45+
const pluralKeys = new Set();
46+
47+
for (const [key, value] of leaves) {
48+
if (value === '') {
49+
failures.push(`${locale}:${key} is empty`);
50+
}
51+
if (pluralSuffix.test(key)) {
52+
pluralKeys.add(key.replace(pluralSuffix, ''));
53+
}
54+
}
55+
56+
resources[locale] = { translation: translations };
57+
pluralKeysByLocale.set(locale, pluralKeys);
58+
}
59+
60+
const i18n = i18next.createInstance();
61+
await i18n.init({
62+
resources,
63+
lng: 'en',
64+
fallbackLng: 'en',
65+
returnEmptyString: false,
66+
interpolation: {
67+
escapeValue: false,
68+
},
69+
});
70+
71+
let checkedResolutions = 0;
72+
73+
for (const filename of localeFiles) {
74+
const locale = path.basename(filename, '.json');
75+
const pluralRules = new Intl.PluralRules(locale);
76+
const sampleByCategory = new Map();
77+
78+
for (const count of countCandidates) {
79+
const category = pluralRules.select(count);
80+
if (!sampleByCategory.has(category)) {
81+
sampleByCategory.set(category, count);
82+
}
83+
}
84+
85+
for (const category of pluralRules.resolvedOptions().pluralCategories) {
86+
if (!sampleByCategory.has(category)) {
87+
failures.push(`${locale}: no test count found for plural category ${category}`);
88+
}
89+
}
90+
91+
for (const key of pluralKeysByLocale.get(locale)) {
92+
for (const [category, count] of sampleByCategory) {
93+
const details = i18n.t(key, { lng: locale, count, returnDetails: true });
94+
const expectedKey = `${key}_${category}`;
95+
checkedResolutions += 1;
96+
97+
if (details.usedLng !== locale) {
98+
failures.push(`${locale}:${expectedKey} resolved through ${details.usedLng}`);
99+
}
100+
if (details.exactUsedKey !== expectedKey) {
101+
failures.push(`${locale}:${expectedKey} resolved as ${details.exactUsedKey}`);
102+
}
103+
if (typeof details.res !== 'string' || details.res.trim() === '') {
104+
failures.push(`${locale}:${expectedKey} resolved to an empty value`);
105+
}
106+
}
107+
}
108+
}
109+
110+
if (failures.length > 0) {
111+
console.error(`i18n runtime validation failed with ${failures.length} issue(s):`);
112+
failures.forEach((failure) => console.error(`- ${failure}`));
113+
process.exitCode = 1;
114+
} else {
115+
console.log(`Validated ${checkedResolutions} plural resolutions across ${localeFiles.length} locales.`);
116+
}

src/i18n/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ i18n.use(initReactI18next).init({
3131
},
3232
lng: 'en',
3333
fallbackLng: 'en',
34+
returnEmptyString: false,
3435
interpolation: {
3536
escapeValue: false,
3637
},

src/i18n/locales/de.json

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,13 @@
6666
"whiteBalance": "Weißabgleich"
6767
},
6868
"curves": {
69-
"channelTitle": "{{channel}}-Kanal",
7069
"channels": {
7170
"blue": "Blau",
7271
"green": "Grün",
7372
"luma": "Luma",
7473
"red": "Rot"
7574
},
75+
"channelTitle": "{{channel}}-Kanal",
7676
"copyParametric": "Parametrische {{channel}}-Kurve kopieren",
7777
"copyPoint": "Punktkurve ({{channel}}) kopieren",
7878
"curveDataUnavailable": "Kurvendaten nicht verfügbar.",
@@ -365,7 +365,11 @@
365365
"aiEdit_one": "KI-Bearbeitung {{count}}",
366366
"aiEdit_other": "KI-Bearbeitung {{count}}",
367367
"clone": "Klonen {{count}}",
368+
"clone_one": "Klonen {{count}}",
369+
"clone_other": "Klonen {{count}}",
368370
"heal": "Reparieren {{count}}",
371+
"heal_one": "Reparieren {{count}}",
372+
"heal_other": "Reparieren {{count}}",
369373
"invertedName": "{{name}} umgekehrt",
370374
"quickErase": "Schnelles Löschen {{count}}",
371375
"quickErase_one": "Schnelles Löschen {{count}}",
@@ -636,8 +640,8 @@
636640
"organization": {
637641
"addTagPlaceholder": "Tag hinzufügen...",
638642
"colorLabel": "Farbmarkierung",
639-
"noTags": "Keine Tags",
640643
"none": "Keine",
644+
"noTags": "Keine Tags",
641645
"rating": "Bewertung",
642646
"ratingLabels": "Bewertung & Markierungen",
643647
"tags": "Tags",
@@ -780,12 +784,12 @@
780784
"estimatedSize": "Geschätzte Dateigröße: ~{{size}}",
781785
"estimatedTotalSize": "Geschätzte Gesamtgröße: ~{{size}}",
782786
"estimatingSize": "Größe wird geschätzt...",
787+
"exporting": "Wird exportiert…",
788+
"exportingProgress": "Wird exportiert… ({{current}}/{{total}})",
783789
"exportMultiple": "Exportiere {{count}} {{label}}",
784790
"exportMultiple_one": "Exportiere {{count}} {{label}}",
785791
"exportMultiple_other": "Exportiere {{count}} {{label}}",
786792
"exportSingle": "{{label}} exportieren",
787-
"exporting": "Wird exportiert…",
788-
"exportingProgress": "Wird exportiert… ({{current}}/{{total}})",
789793
"failed": "Export fehlgeschlagen",
790794
"noImageSelected": "Kein Bild zum Exportieren ausgewählt.",
791795
"noImagesSelected": "Keine Bilder ausgewählt.",
@@ -1126,9 +1130,9 @@
11261130
"applyButton_one": "Auf 1 Bild anwenden",
11271131
"applyButton_other": "Auf {{count}} Bilder anwenden",
11281132
"bestImage": "Bestes Bild",
1133+
"blurryImagesTab": "Unscharfe Bilder",
11291134
"blurThreshold": "Unschärfe-Schwellenwert",
11301135
"blurThresholdDesc": "Bilder mit einem Schärfewert unter diesem Wert werden markiert. Höher ist strenger.",
1131-
"blurryImagesTab": "Unscharfe Bilder",
11321136
"cancel": "Abbrechen",
11331137
"close": "Schließen",
11341138
"cullingFailed": "Aussortieren fehlgeschlagen",
@@ -1649,8 +1653,8 @@
16491653
"tagging": {
16501654
"addCustomPlaceholder": "Benutzerdefinierte KI-Tags hinzufügen (kommagetrennt)...",
16511655
"addCustomTooltip": "KI-Tag hinzufügen",
1652-
"addShortcutTooltip": "Shortcut hinzufügen",
16531656
"addShortcutsPlaceholder": "Shortcuts hinzufügen (kommagetrennt)...",
1657+
"addShortcutTooltip": "Shortcut hinzufügen",
16541658
"aiTagging": "KI-Tagging",
16551659
"aiTaggingDesc": "Aktiviert automatisches Bild-Tagging mit einem KI-Modell (CLIP). Dies lädt ein zusätzliches Modell herunter und beeinträchtigt die Leistung beim Durchsuchen.",
16561660
"amount": "Betrag",

src/i18n/locales/en.json

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,13 @@
6666
"whiteBalance": "White Balance"
6767
},
6868
"curves": {
69-
"channelTitle": "{{channel}} Channel",
7069
"channels": {
7170
"blue": "Blue",
7271
"green": "Green",
7372
"luma": "Luma",
7473
"red": "Red"
7574
},
75+
"channelTitle": "{{channel}} Channel",
7676
"copyParametric": "Copy {{channel}} Parametric Curve",
7777
"copyPoint": "Copy {{channel}} Point Curve",
7878
"curveDataUnavailable": "Curve data not available.",
@@ -365,7 +365,11 @@
365365
"aiEdit_one": "AI Edit {{count}}",
366366
"aiEdit_other": "AI Edit {{count}}",
367367
"clone": "Clone {{count}}",
368+
"clone_one": "Clone {{count}}",
369+
"clone_other": "Clone {{count}}",
368370
"heal": "Heal {{count}}",
371+
"heal_one": "Heal {{count}}",
372+
"heal_other": "Heal {{count}}",
369373
"invertedName": "{{name}} Inverted",
370374
"quickErase": "Quick Erase {{count}}",
371375
"quickErase_one": "Quick Erase {{count}}",
@@ -636,8 +640,8 @@
636640
"organization": {
637641
"addTagPlaceholder": "Add tag...",
638642
"colorLabel": "Color Label",
639-
"noTags": "No tags",
640643
"none": "None",
644+
"noTags": "No tags",
641645
"rating": "Rating",
642646
"ratingLabels": "Rating & Labels",
643647
"tags": "Tags",
@@ -780,12 +784,12 @@
780784
"estimatedSize": "Estimated file size: ~{{size}}",
781785
"estimatedTotalSize": "Estimated total size: ~{{size}}",
782786
"estimatingSize": "Estimating size...",
787+
"exporting": "Exporting…",
788+
"exportingProgress": "Exporting… ({{current}}/{{total}})",
783789
"exportMultiple": "Export {{count}} {{label}}",
784790
"exportMultiple_one": "Export {{count}} {{label}}",
785791
"exportMultiple_other": "Export {{count}} {{label}}",
786792
"exportSingle": "Export {{label}}",
787-
"exporting": "Exporting…",
788-
"exportingProgress": "Exporting… ({{current}}/{{total}})",
789793
"failed": "Export failed",
790794
"noImageSelected": "No image selected for export.",
791795
"noImagesSelected": "No images selected.",
@@ -1126,9 +1130,9 @@
11261130
"applyButton_one": "Apply to 1 image",
11271131
"applyButton_other": "Apply to {{count}} images",
11281132
"bestImage": "Best Image",
1133+
"blurryImagesTab": "Blurry Images",
11291134
"blurThreshold": "Blur Threshold",
11301135
"blurThresholdDesc": "Images with a sharpness score below this value are flagged. Higher is stricter.",
1131-
"blurryImagesTab": "Blurry Images",
11321136
"cancel": "Cancel",
11331137
"close": "Close",
11341138
"cullingFailed": "Culling Failed",
@@ -1649,8 +1653,8 @@
16491653
"tagging": {
16501654
"addCustomPlaceholder": "Add custom AI tags (comma separated)...",
16511655
"addCustomTooltip": "Add AI tag",
1652-
"addShortcutTooltip": "Add Shortcut",
16531656
"addShortcutsPlaceholder": "Add shortcuts (comma separated)...",
1657+
"addShortcutTooltip": "Add Shortcut",
16541658
"aiTagging": "AI Tagging",
16551659
"aiTaggingDesc": "Enables automatic image tagging using an AI (CLIP) model. This will download an additional model and impact performance while browsing folders. Tags are used for searching a folder.",
16561660
"amount": "Amount",

0 commit comments

Comments
 (0)