Skip to content

Commit b59efcd

Browse files
authored
feat(i18n): add Russian locale (#226)
* feat(i18n): add Russian locale * fix(i18n): localize remaining admin settings * fix(i18n): polish Russian interface copy * fix(i18n): refine Russian interface copy * fix(i18n): localize workspace navigation * fix(i18n): localize workspace members * fix(i18n): expand Russian UI coverage * fix(i18n): address localization review feedback * fix: preserve startup and backend error details
1 parent ae1af8a commit b59efcd

222 files changed

Lines changed: 11870 additions & 1963 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/index.html

Lines changed: 89 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -115,40 +115,103 @@
115115
}
116116
</style>
117117
<script>
118-
// Flash prevention: apply theme before CSS loads
119-
(() => {
120-
let stored = null;
121-
try {
122-
stored = localStorage.getItem('windshift-color-mode');
123-
} catch {
124-
// Storage can be unavailable in hardened/private browser contexts.
125-
}
126-
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
127-
const mode = stored === 'dark' || (stored === 'system' && prefersDark) || (!stored && prefersDark) ? 'dark' : 'light';
128-
document.documentElement.dataset.colorMode = mode;
118+
// Flash prevention: apply theme before CSS loads.
119+
(() => {
120+
let storedColorMode = null;
121+
let storedLocale = null;
122+
try {
123+
storedColorMode = localStorage.getItem('windshift-color-mode');
124+
storedLocale = localStorage.getItem('windshift-locale');
125+
} catch {
126+
// Storage can be unavailable in hardened/private browser contexts.
127+
}
128+
129+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
130+
const colorMode =
131+
storedColorMode === 'dark' ||
132+
(storedColorMode === 'system' && prefersDark) ||
133+
(!storedColorMode && prefersDark)
134+
? 'dark'
135+
: 'light';
136+
document.documentElement.dataset.colorMode = colorMode;
137+
138+
const bootTranslations = {
139+
en: {
140+
starting: 'Starting Windshift…',
141+
slow: 'This is taking longer than expected. Check your connection or server.',
142+
retry: 'Retry'
143+
},
144+
de: {
145+
starting: 'Windshift wird gestartet…',
146+
slow: 'Dies dauert länger als erwartet. Überprüfen Sie Ihre Verbindung oder den Server.',
147+
retry: 'Erneut versuchen'
148+
},
149+
es: {
150+
starting: 'Iniciando Windshift…',
151+
slow: 'Esto está tardando más de lo esperado. Comprueba tu conexión o el servidor.',
152+
retry: 'Reintentar'
153+
},
154+
ar: {
155+
starting: 'جارٍ تشغيل Windshift…',
156+
slow: 'يستغرق هذا وقتًا أطول من المتوقع. تحقق من اتصالك أو الخادم.',
157+
retry: 'إعادة المحاولة'
158+
},
159+
'pt-BR': {
160+
starting: 'Iniciando o Windshift…',
161+
slow: 'Isso está demorando mais do que o esperado. Verifique sua conexão ou o servidor.',
162+
retry: 'Tentar novamente'
163+
},
164+
ru: {
165+
starting: 'Запуск Windshift…',
166+
slow: 'Это занимает больше времени, чем ожидалось. Проверьте подключение или сервер.',
167+
retry: 'Повторить'
168+
},
169+
'zh-CN': {
170+
starting: '正在启动 Windshift…',
171+
slow: '所需时间超出预期。请检查网络连接或服务器。',
172+
retry: '重试'
173+
}
174+
};
175+
const supportedLocales = Object.keys(bootTranslations);
176+
const browserLocale = navigator.language || 'en';
177+
const locale =
178+
(storedLocale && supportedLocales.includes(storedLocale) && storedLocale) ||
179+
supportedLocales.find((candidate) => candidate.toLowerCase() === browserLocale.toLowerCase()) ||
180+
supportedLocales.find(
181+
(candidate) => candidate.split('-')[0] === browserLocale.toLowerCase().split('-')[0]
182+
) ||
183+
'en';
184+
const messages = bootTranslations[locale];
185+
document.documentElement.lang = locale;
186+
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
187+
188+
window.addEventListener('DOMContentLoaded', () => {
189+
const boot = document.getElementById('windshift-boot');
190+
const starting = document.getElementById('windshift-boot-starting');
191+
const retry = document.getElementById('windshift-boot-retry');
192+
const detail = document.getElementById('windshift-boot-detail');
193+
if (!boot || !starting || !retry || !detail) return;
129194

130-
window.addEventListener('DOMContentLoaded', () => {
131-
const boot = document.getElementById('windshift-boot');
132-
const retry = document.getElementById('windshift-boot-retry');
133-
const detail = document.getElementById('windshift-boot-detail');
134-
if (!boot || !retry || !detail) return;
135-
retry.addEventListener('click', () => location.reload());
136-
window.setTimeout(() => {
137-
// main.js removes the placeholder before mounting App.
138-
if (!document.getElementById('windshift-boot')) return;
139-
detail.hidden = false;
140-
retry.hidden = false;
141-
}, 10_000);
142-
});
143-
})();
195+
starting.textContent = messages.starting;
196+
detail.textContent = messages.slow;
197+
retry.textContent = messages.retry;
198+
retry.addEventListener('click', () => location.reload());
199+
window.setTimeout(() => {
200+
// main.js removes the placeholder before mounting App.
201+
if (!document.getElementById('windshift-boot')) return;
202+
detail.hidden = false;
203+
retry.hidden = false;
204+
}, 10_000);
205+
});
206+
})();
144207
</script>
145208
</head>
146209
<body>
147210
<div id="app">
148211
<div id="windshift-boot" role="status" aria-live="polite">
149212
<div>
150213
<img class="boot-logo" src="windshift-3.svg" alt="Windshift" width="64" height="64" />
151-
<p>Starting Windshift…</p>
214+
<p id="windshift-boot-starting">Starting Windshift…</p>
152215
<p id="windshift-boot-detail" class="boot-detail" hidden>
153216
This is taking longer than expected. Check your connection or server.
154217
</p>

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"format:check": "biome format .",
2121
"lint": "biome lint .",
2222
"lint:fix": "biome lint --write .",
23-
"check": "biome check . && node scripts/check-shortcuts.js && node scripts/check-i18n.js",
23+
"check": "biome check . && node scripts/check-shortcuts.js && node scripts/check-i18n.js && node scripts/check-hardcoded-i18n.js",
2424
"check:entry-assets": "node scripts/check-entry-assets.js",
2525
"check:fix": "biome check --write .",
2626
"test": "bun --bun vitest",
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { readFileSync } from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6+
7+
// This list is an incremental ratchet: once a user-facing screen has been
8+
// migrated, literal English UI copy must not return to it.
9+
const guardedFiles = [
10+
'src/lib/layout/DashboardCustomizationSidebar.svelte',
11+
'src/lib/pages/Homepage.svelte',
12+
'src/lib/features/workflows/WorkflowBuilder.svelte',
13+
'src/lib/pages/Screens.svelte',
14+
'src/lib/pickers/ConfigurationSetPicker.svelte',
15+
'src/lib/pickers/ScreenPicker.svelte',
16+
'src/lib/pickers/WorkflowPicker.svelte',
17+
'src/lib/settings/ConfigurationSetManager.svelte',
18+
'src/lib/settings/ConfigurationSetItemTypes.svelte',
19+
'src/lib/settings/ActionCapabilitiesManager.svelte',
20+
'src/lib/settings/ActionCredentialManager.svelte',
21+
'src/lib/settings/AgentTemplateManager.svelte',
22+
'src/lib/settings/CapabilityManager.svelte',
23+
'src/lib/settings/HierarchyLevelManager.svelte',
24+
'src/lib/settings/ItemTypeManager.svelte',
25+
'src/lib/settings/IntegrationProviderManager.svelte',
26+
'src/lib/settings/IntegrationsManager.svelte',
27+
'src/lib/settings/LLMConnectionManager.svelte',
28+
'src/lib/settings/LinkTypeManager.svelte',
29+
'src/lib/settings/OAuthClientManager.svelte',
30+
'src/lib/settings/PriorityManager.svelte',
31+
'src/lib/settings/StatusCategoryManager.svelte',
32+
'src/lib/settings/StatusManager.svelte',
33+
'src/lib/settings/ThemeManager.svelte',
34+
'src/lib/settings/RunnerPoolManager.svelte',
35+
'src/lib/workspaces/WorkspaceConfigurationAssigner.svelte',
36+
'src/lib/workspaces/WorkspaceConfigurationPreview.svelte',
37+
'src/lib/workspaces/WorkspaceMembers.svelte',
38+
'src/lib/workspaces/WorkspaceCustomizationSidebar.svelte',
39+
'src/lib/workspaces/WorkspaceNavigation.svelte',
40+
'src/lib/workspaces/WorkspaceWelcome.svelte',
41+
'src/lib/workspaces/Workspaces.svelte',
42+
'src/lib/widgets/CompletionChartWidget.svelte',
43+
'src/lib/widgets/CreatedChartWidget.svelte',
44+
'src/lib/widgets/IterationTimelineWidget.svelte',
45+
'src/lib/widgets/MilestoneProgressWidget.svelte',
46+
'src/lib/widgets/MyTasksWidget.svelte',
47+
'src/lib/widgets/OverdueItemsWidget.svelte',
48+
'src/lib/widgets/RecentItemsWidget.svelte',
49+
'src/lib/widgets/StatsCardWidget.svelte',
50+
'src/lib/widgets/TestCoverageWidget.svelte',
51+
'src/lib/widgets/UpcomingDeadlinesWidget.svelte',
52+
'src/lib/widgets/WidgetState.svelte',
53+
'src/lib/widgets/WidgetWrapper.svelte',
54+
'src/lib/widgets/dashboard/AssignedToMeWidget.svelte',
55+
'src/lib/widgets/dashboard/DailyBriefingWidget.svelte',
56+
'src/lib/widgets/dashboard/DashboardItemRow.svelte',
57+
'src/lib/widgets/dashboard/DashboardTaskList.svelte',
58+
'src/lib/widgets/dashboard/DueMark.svelte',
59+
'src/lib/widgets/dashboard/PersonalTasksWidget.svelte',
60+
'src/lib/widgets/dashboard/QuickAccessWidget.svelte',
61+
'src/lib/widgets/dashboard/RecentWorkspacesWidget.svelte',
62+
'src/lib/widgets/dashboard/SavedSearchWidget.svelte',
63+
'src/lib/widgets/dashboard/UpcomingMilestonesWidget.svelte',
64+
'src/lib/widgets/dashboard/WatchedItemsWidget.svelte',
65+
'src/lib/widgets/dashboard/WhatsNewWidget.svelte',
66+
'src/lib/widgets/dashboard/YourActivityWidget.svelte',
67+
];
68+
69+
const rules = [
70+
{
71+
name: 'visible text',
72+
pattern: />\s*([A-Z][^<{\n]*?)\s*</g,
73+
},
74+
{
75+
name: 'localizable attribute',
76+
pattern:
77+
/(?:placeholder|title|aria-label|emptyMessage|emptyDescription|confirmLabel|cancelLabel|subtitle)=["']([A-Z][^"']+)["']/g,
78+
},
79+
];
80+
81+
const intentionalLiterals = new Set(['Esc']);
82+
83+
const violations = [];
84+
85+
for (const relativeFile of guardedFiles) {
86+
// Script blocks contain operators such as `=>` that look like HTML text to
87+
// the lightweight regex guard. UI copy lives in the component markup.
88+
const source = readFileSync(path.join(root, relativeFile), 'utf8').replace(
89+
/<script\b[^>]*>[\s\S]*?<\/script>/g,
90+
(script) => '\n'.repeat(script.split('\n').length - 1)
91+
);
92+
for (const rule of rules) {
93+
for (const match of source.matchAll(rule.pattern)) {
94+
if (intentionalLiterals.has(match[1].trim())) continue;
95+
const line = source.slice(0, match.index).split('\n').length;
96+
violations.push(`${relativeFile}:${line} ${rule.name}: ${JSON.stringify(match[1].trim())}`);
97+
}
98+
}
99+
}
100+
101+
if (violations.length > 0) {
102+
console.error('Hardcoded i18n guard failed. Move this copy into the locale catalog:');
103+
for (const violation of violations) console.error(` ${violation}`);
104+
process.exit(1);
105+
}
106+
107+
console.log(`Hardcoded i18n guard passed (${guardedFiles.length} migrated screens).`);

0 commit comments

Comments
 (0)