From adabcda1f8611eb45b6a1e705d60592f8814d1bc Mon Sep 17 00:00:00 2001 From: Kostenko Nikita <244571@niuitmo.ru> Date: Fri, 28 Aug 2026 21:07:53 +0300 Subject: [PATCH 1/9] feat(i18n): add Russian locale --- frontend/index.html | 115 +- frontend/package.json | 2 +- frontend/scripts/check-hardcoded-i18n.js | 59 + frontend/scripts/check-i18n.js | 117 +- frontend/src/App.svelte | 20 +- .../src/lib/components/BrandedLoader.svelte | 6 +- .../src/lib/components/LazyRootDialog.svelte | 11 +- .../src/lib/components/LazyRootView.svelte | 11 +- frontend/src/lib/components/Tabs.svelte | 23 +- .../features/workflows/WorkflowBuilder.svelte | 30 +- frontend/src/lib/layout/DropdownMenu.svelte | 7 +- frontend/src/lib/layout/MainSidebar.svelte | 6 +- frontend/src/lib/layout/NavLink.svelte | 4 +- frontend/src/lib/locales/ar/admin.js | 128 +- frontend/src/lib/locales/ar/auth.js | 33 + frontend/src/lib/locales/ar/common.js | 2 + frontend/src/lib/locales/ar/navigation.js | 2 + frontend/src/lib/locales/ar/workflows.js | 15 + frontend/src/lib/locales/ar/workspace.js | 4 + frontend/src/lib/locales/de/admin.js | 128 +- frontend/src/lib/locales/de/auth.js | 34 + frontend/src/lib/locales/de/common.js | 2 + frontend/src/lib/locales/de/navigation.js | 2 + frontend/src/lib/locales/de/workflows.js | 15 + frontend/src/lib/locales/de/workspace.js | 4 + frontend/src/lib/locales/en/admin.js | 127 +- frontend/src/lib/locales/en/auth.js | 33 + frontend/src/lib/locales/en/common.js | 2 + frontend/src/lib/locales/en/navigation.js | 2 + frontend/src/lib/locales/en/workflows.js | 15 + frontend/src/lib/locales/en/workspace.js | 4 + frontend/src/lib/locales/es/admin.js | 128 +- frontend/src/lib/locales/es/auth.js | 33 + frontend/src/lib/locales/es/common.js | 2 + frontend/src/lib/locales/es/navigation.js | 2 + frontend/src/lib/locales/es/workflows.js | 15 + frontend/src/lib/locales/es/workspace.js | 4 + frontend/src/lib/locales/pt-BR/admin.js | 128 +- frontend/src/lib/locales/pt-BR/auth.js | 33 + frontend/src/lib/locales/pt-BR/common.js | 2 + frontend/src/lib/locales/pt-BR/navigation.js | 2 + frontend/src/lib/locales/pt-BR/workflows.js | 15 + frontend/src/lib/locales/pt-BR/workspace.js | 4 + frontend/src/lib/locales/ru/actions.js | 290 +++++ frontend/src/lib/locales/ru/admin.js | 1117 +++++++++++++++++ frontend/src/lib/locales/ru/analytics.js | 138 ++ frontend/src/lib/locales/ru/auth.js | 249 ++++ frontend/src/lib/locales/ru/channels.js | 692 ++++++++++ frontend/src/lib/locales/ru/common.js | 320 +++++ frontend/src/lib/locales/ru/index.js | 40 + frontend/src/lib/locales/ru/logbook.js | 88 ++ frontend/src/lib/locales/ru/misc.js | 164 +++ frontend/src/lib/locales/ru/navigation.js | 348 +++++ frontend/src/lib/locales/ru/pages.js | 191 +++ frontend/src/lib/locales/ru/teams.js | 180 +++ frontend/src/lib/locales/ru/testing.js | 375 ++++++ frontend/src/lib/locales/ru/time.js | 253 ++++ frontend/src/lib/locales/ru/ui.js | 512 ++++++++ frontend/src/lib/locales/ru/workflows.js | 382 ++++++ frontend/src/lib/locales/ru/workspace.js | 73 ++ frontend/src/lib/locales/zh-CN/admin.js | 128 +- frontend/src/lib/locales/zh-CN/auth.js | 31 + frontend/src/lib/locales/zh-CN/common.js | 2 + frontend/src/lib/locales/zh-CN/navigation.js | 2 + frontend/src/lib/locales/zh-CN/workflows.js | 15 + frontend/src/lib/locales/zh-CN/workspace.js | 4 + frontend/src/lib/pages/Screens.svelte | 31 +- frontend/src/lib/pages/UserProfile.svelte | 166 +-- .../src/lib/pages/WelcomeAssistant.svelte | 11 +- .../lib/pickers/ConfigurationSetPicker.svelte | 19 +- frontend/src/lib/pickers/ScreenPicker.svelte | 21 +- .../src/lib/pickers/WorkflowPicker.svelte | 21 +- .../settings/ConfigurationSetItemTypes.svelte | 31 +- .../settings/ConfigurationSetManager.svelte | 125 +- .../lib/settings/HierarchyLevelManager.svelte | 102 +- .../src/lib/settings/ItemTypeManager.svelte | 159 ++- .../src/lib/settings/LinkTypeManager.svelte | 101 +- .../src/lib/settings/PriorityManager.svelte | 82 +- .../lib/settings/StatusCategoryManager.svelte | 66 +- .../src/lib/settings/StatusManager.svelte | 100 +- frontend/src/lib/settings/ThemeManager.svelte | 92 +- frontend/src/lib/stores/i18n-utils.spec.js | 60 + frontend/src/lib/stores/i18n.svelte.js | 1 + .../WorkspaceConfigurationAssigner.svelte | 14 +- .../WorkspaceConfigurationPreview.svelte | 62 +- frontend/src/lib/workspaces/Workspaces.svelte | 26 +- internal/handlers/setup.go | 11 +- internal/models/common.go | 1 + 88 files changed, 7829 insertions(+), 398 deletions(-) create mode 100644 frontend/scripts/check-hardcoded-i18n.js create mode 100644 frontend/src/lib/locales/ru/actions.js create mode 100644 frontend/src/lib/locales/ru/admin.js create mode 100644 frontend/src/lib/locales/ru/analytics.js create mode 100644 frontend/src/lib/locales/ru/auth.js create mode 100644 frontend/src/lib/locales/ru/channels.js create mode 100644 frontend/src/lib/locales/ru/common.js create mode 100644 frontend/src/lib/locales/ru/index.js create mode 100644 frontend/src/lib/locales/ru/logbook.js create mode 100644 frontend/src/lib/locales/ru/misc.js create mode 100644 frontend/src/lib/locales/ru/navigation.js create mode 100644 frontend/src/lib/locales/ru/pages.js create mode 100644 frontend/src/lib/locales/ru/teams.js create mode 100644 frontend/src/lib/locales/ru/testing.js create mode 100644 frontend/src/lib/locales/ru/time.js create mode 100644 frontend/src/lib/locales/ru/ui.js create mode 100644 frontend/src/lib/locales/ru/workflows.js create mode 100644 frontend/src/lib/locales/ru/workspace.js create mode 100644 frontend/src/lib/stores/i18n-utils.spec.js diff --git a/frontend/index.html b/frontend/index.html index a1e2c71ed..0de76cc68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -115,32 +115,95 @@ } @@ -148,7 +211,7 @@
-

Starting Windshift…

+

Starting Windshift…

diff --git a/frontend/package.json b/frontend/package.json index 680a0e98f..d445ba1bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "format:check": "biome format .", "lint": "biome lint .", "lint:fix": "biome lint --write .", - "check": "biome check . && node scripts/check-shortcuts.js && node scripts/check-i18n.js", + "check": "biome check . && node scripts/check-shortcuts.js && node scripts/check-i18n.js && node scripts/check-hardcoded-i18n.js", "check:entry-assets": "node scripts/check-entry-assets.js", "check:fix": "biome check --write .", "test": "bun --bun vitest", diff --git a/frontend/scripts/check-hardcoded-i18n.js b/frontend/scripts/check-hardcoded-i18n.js new file mode 100644 index 000000000..515f7ce51 --- /dev/null +++ b/frontend/scripts/check-hardcoded-i18n.js @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// This list is an incremental ratchet: once a user-facing screen has been +// migrated, literal English UI copy must not return to it. +const guardedFiles = [ + 'src/lib/features/workflows/WorkflowBuilder.svelte', + 'src/lib/pages/Screens.svelte', + 'src/lib/pickers/ConfigurationSetPicker.svelte', + 'src/lib/pickers/ScreenPicker.svelte', + 'src/lib/pickers/WorkflowPicker.svelte', + 'src/lib/settings/ConfigurationSetManager.svelte', + 'src/lib/settings/ConfigurationSetItemTypes.svelte', + 'src/lib/settings/HierarchyLevelManager.svelte', + 'src/lib/settings/ItemTypeManager.svelte', + 'src/lib/settings/LinkTypeManager.svelte', + 'src/lib/settings/PriorityManager.svelte', + 'src/lib/settings/StatusCategoryManager.svelte', + 'src/lib/settings/StatusManager.svelte', + 'src/lib/settings/ThemeManager.svelte', + 'src/lib/workspaces/WorkspaceConfigurationAssigner.svelte', + 'src/lib/workspaces/WorkspaceConfigurationPreview.svelte', + 'src/lib/workspaces/Workspaces.svelte', +]; + +const rules = [ + { + name: 'visible text', + pattern: />\s*([A-Z][^<{\n]*?)\s* 0) { + console.error('Hardcoded i18n guard failed. Move this copy into the locale catalog:'); + for (const violation of violations) console.error(` ${violation}`); + process.exit(1); +} + +console.log(`Hardcoded i18n guard passed (${guardedFiles.length} migrated screens).`); diff --git a/frontend/scripts/check-i18n.js b/frontend/scripts/check-i18n.js index 06b5d8f23..331da17e8 100644 --- a/frontend/scripts/check-i18n.js +++ b/frontend/scripts/check-i18n.js @@ -4,7 +4,8 @@ * i18n Validation Script * * Validates locale files against English (reference locale) and detects: - * - Missing or extra keys in non-English locales + * - Missing or extra keys in non-English locales (while allowing valid + * locale-specific CLDR plural variants) * - Source keys referenced in code but missing from English catalog * - Placeholder mismatches between English and other locales * - Untranslated English carryovers in non-English locales @@ -21,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const LOCALES_DIR = join(__dirname, '..', 'src', 'lib', 'locales'); const SRC_DIR = join(__dirname, '..', 'src'); const REFERENCE_LOCALE = 'en'; +const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/; // These values intentionally retain product names, code syntax, URLs, or sample identifiers. const INTENTIONAL_CARRYOVERS = new Set([ @@ -87,6 +89,65 @@ function extractPlaceholders(str) { return matches ? matches.map((m) => m.slice(1, -1)).sort() : []; } +function splitPluralKey(key) { + const match = key.match(PLURAL_SUFFIX_PATTERN); + if (!match) return null; + + return { + baseKey: key.slice(0, -match[0].length), + category: match[1], + }; +} + +function getPluralCategories(localeCode) { + return new Set(new Intl.PluralRules(localeCode).resolvedOptions().pluralCategories); +} + +function collectPluralBases(keys) { + const categoriesByBase = new Map(); + + for (const key of keys) { + const plural = splitPluralKey(key); + if (!plural) continue; + + const categories = categoriesByBase.get(plural.baseKey) ?? new Set(); + categories.add(plural.category); + categoriesByBase.set(plural.baseKey, categories); + } + + return new Set( + [...categoriesByBase] + .filter(([, categories]) => categories.has('other') && categories.size > 1) + .map(([baseKey]) => baseKey) + ); +} + +function isLocaleSpecificPluralKey(key, pluralBases, localePluralCategories) { + const plural = splitPluralKey(key); + return ( + plural !== null && + pluralBases.has(plural.baseKey) && + localePluralCategories.has(plural.category) + ); +} + +function findReferenceEntry(key, refEntries, pluralBases, localePluralCategories) { + if (Object.hasOwn(refEntries, key)) { + return { key, value: refEntries[key] }; + } + + if (!isLocaleSpecificPluralKey(key, pluralBases, localePluralCategories)) { + return null; + } + + const { baseKey } = splitPluralKey(key); + const fallbackKey = [`${baseKey}_other`, `${baseKey}_one`].find((candidate) => + Object.hasOwn(refEntries, candidate) + ); + + return fallbackKey ? { key: fallbackKey, value: refEntries[fallbackKey] } : null; +} + function findSourceFile(key, fileMap) { for (const [filename, keys] of Object.entries(fileMap)) { if (keys.has(key)) return filename; @@ -136,17 +197,21 @@ async function extractSourceKeys() { return keys; } -function detectCarryovers(english, other, _localeCode) { +function detectCarryovers(english, other, localeCode) { const carryovers = []; const enEntries = Object.fromEntries( flattenAll(english).filter(([, v]) => typeof v === 'string') ); + const pluralBases = collectPluralBases(Object.keys(enEntries)); + const localePluralCategories = getPluralCategories(localeCode); for (const [key, value] of flattenAll(other)) { if (INTENTIONAL_CARRYOVERS.has(key)) continue; if (typeof value !== 'string') continue; - const enValue = enEntries[key]; - if (!enValue) continue; + const reference = findReferenceEntry(key, enEntries, pluralBases, localePluralCategories); + if (!reference) continue; + + const enValue = reference.value; const words = value.split(/\s+/); const wordCount = words.length; @@ -195,6 +260,7 @@ async function main() { const refEntries = Object.fromEntries( flattenAll(ref.merged).filter(([, v]) => typeof v === 'string') ); + const refPluralBases = collectPluralBases(refLeafKeys); console.log(`\n Reference: ${REFERENCE_LOCALE} (${refLeafKeys.size} leaf keys)\n`); @@ -226,9 +292,23 @@ async function main() { for (const locale of otherLocales) { const loc = await loadLocaleFiles(locale); const locKeys = new Set(flattenKeys(loc.merged)); + const localePluralCategories = getPluralCategories(locale); const missing = [...refLeafKeys].filter((k) => !locKeys.has(k)).sort(); - const extra = [...locKeys].filter((k) => !refLeafKeys.has(k)).sort(); + const localePluralVariants = [...locKeys] + .filter( + (k) => + !refLeafKeys.has(k) && + isLocaleSpecificPluralKey(k, refPluralBases, localePluralCategories) + ) + .sort(); + const extra = [...locKeys] + .filter( + (k) => + !refLeafKeys.has(k) && + !isLocaleSpecificPluralKey(k, refPluralBases, localePluralCategories) + ) + .sort(); totalMissing += missing.length; totalExtra += extra.length; @@ -236,7 +316,11 @@ async function main() { const coverage = (((refLeafKeys.size - missing.length) / refLeafKeys.size) * 100).toFixed(1); if (missing.length === 0 && extra.length === 0) { - console.log(` ✓ ${locale} ${coverage}% coverage (${locKeys.size} keys)`); + const pluralSuffix = + localePluralVariants.length > 0 + ? `, ${localePluralVariants.length} locale plural variant(s)` + : ''; + console.log(` ✓ ${locale} ${coverage}% coverage (${locKeys.size} keys${pluralSuffix})`); } else { console.log( ` ✗ ${locale} ${coverage}% coverage (${locKeys.size} keys, ${missing.length} missing, ${extra.length} extra)` @@ -268,11 +352,14 @@ async function main() { const locEntries = Object.fromEntries( flattenAll(loc.merged).filter(([, v]) => typeof v === 'string') ); + const localePluralCategories = getPluralCategories(locale); const mismatches = []; - for (const [key, enValue] of Object.entries(refEntries)) { - const locValue = locEntries[key]; - if (!locValue) continue; + for (const [key, locValue] of Object.entries(locEntries)) { + const reference = findReferenceEntry(key, refEntries, refPluralBases, localePluralCategories); + if (!reference || !locValue) continue; + + const enValue = reference.value; const enPlaceholders = extractPlaceholders(enValue); const locPlaceholders = extractPlaceholders(locValue); @@ -286,7 +373,14 @@ async function main() { const extra = locPlaceholders.filter((p) => p !== 'plural' && !enSet.has(p)); if (missing.length > 0 || extra.length > 0) { - mismatches.push({ key, enValue, locValue, missing, extra }); + mismatches.push({ + key, + referenceKey: reference.key, + enValue, + locValue, + missing, + extra, + }); } } @@ -298,7 +392,8 @@ async function main() { if (m.extra.length > 0) details.push(`extra: {${m.extra.join('}, {')}}`); console.log(` ${m.key} — ${details.join(', ')}`); if (verbose) { - console.log(` EN: ${m.enValue}`); + const referenceLabel = m.referenceKey === m.key ? 'EN' : `EN (${m.referenceKey})`; + console.log(` ${referenceLabel}: ${m.enValue}`); console.log(` ${locale}: ${m.locValue}`); } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index cf84935a4..880073ed4 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -6,7 +6,7 @@ import { api } from './lib/api.js'; import { APP_NAME } from './lib/constants.js'; import { themeStore } from './lib/stores/theme.svelte.js'; - import { i18n, SUPPORTED_LOCALES } from './lib/stores/i18n.svelte.js'; + import { i18n, SUPPORTED_LOCALES, t } from './lib/stores/i18n.svelte.js'; import { safeLoginReturnPath } from './lib/utils/loginReturnPath.js'; import BrandedLoader from './lib/components/BrandedLoader.svelte'; import LazyRootDialog from './lib/components/LazyRootDialog.svelte'; @@ -114,8 +114,8 @@ appInitialized = false; startupError = error?.code === 'REQUEST_TIMEOUT' - ? 'The server took too long to respond.' - : 'Windshift could not connect to the server.'; + ? t('errors.TIMEOUT') + : t('errors.NETWORK_ERROR'); } finally { window.clearTimeout(slowTimer); } @@ -280,7 +280,7 @@ } - +
{APP_NAME} -

Unable to start Windshift

+

{t('errors.failedToLoad')} Windshift

{startupError}

-

Check your connection or server, then try again.

+

{t('errors.NETWORK_ERROR')}

+ >{t('common.retry')}
{:else if setupLoading} {:else if $currentRoute.view === 'public-board'} @@ -364,7 +364,7 @@
Windshift

Windshift

-

Work Management

+

{t('footer.platformName')}

{/if}
diff --git a/frontend/src/lib/components/BrandedLoader.svelte b/frontend/src/lib/components/BrandedLoader.svelte index 140ed49bb..227026687 100644 --- a/frontend/src/lib/components/BrandedLoader.svelte +++ b/frontend/src/lib/components/BrandedLoader.svelte @@ -1,7 +1,9 @@
-

{label}

+

{displayLabel}

{#if detail}

{detail}

{/if} diff --git a/frontend/src/lib/components/LazyRootDialog.svelte b/frontend/src/lib/components/LazyRootDialog.svelte index f05056a46..a68cdaeb5 100644 --- a/frontend/src/lib/components/LazyRootDialog.svelte +++ b/frontend/src/lib/components/LazyRootDialog.svelte @@ -2,6 +2,7 @@ import Button from './Button.svelte'; import ModalBackdrop from './ModalBackdrop.svelte'; import Spinner from './Spinner.svelte'; + import { t } from '../stores/i18n.svelte.js'; let { loader, @@ -31,10 +32,11 @@ style="background-color: var(--ds-surface-raised); color: var(--ds-text); box-shadow: var(--ds-shadow-raised);" role="status" data-testid="root-dialog-loading" + data-root-label={label} >

- Loading {label}… + {t('common.loading')}

@@ -55,12 +57,13 @@ style="background-color: var(--ds-surface-raised); color: var(--ds-text); box-shadow: var(--ds-shadow-raised);" role="alert" data-testid="root-dialog-error" + data-root-label={label} >

- Unable to load {label} + {t('errors.failedToLoad')}

- Check your connection, then try again. + {t('errors.NETWORK_ERROR')}

+ >{t('common.retry')}
{/await} diff --git a/frontend/src/lib/components/LazyRootView.svelte b/frontend/src/lib/components/LazyRootView.svelte index 76a39b3b8..f9aec93dc 100644 --- a/frontend/src/lib/components/LazyRootView.svelte +++ b/frontend/src/lib/components/LazyRootView.svelte @@ -1,6 +1,7 @@ -