Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ build/
!app/build/
app/build/*
!app/build/entitlements.mac.plist
# Localised macOS permission-prompt bodies (#337). app/build/* above is an
# allowlist, so these need an explicit exception - without it a clean CI
# checkout has no de.lproj, electron-builder only warns, and the signed
# release ships English prompts while local builds look correct.
!app/build/lproj/
!app/build/lproj/**
!app/build/icon.icns
!app/build/icon-dragonfly.icns
!app/build/icon.ico
Expand Down
3 changes: 3 additions & 0 deletions app/build/lproj/de.lproj/InfoPlist.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* Siehe en.lproj/InfoPlist.strings fuer die Herkunft dieser Datei. */
"NSMicrophoneUsageDescription" = "Steno braucht Zugriff auf das Mikrofon, um Meetings aufzunehmen und zu transkribieren.";
"NSAudioCaptureUsageDescription" = "Steno braucht Zugriff auf den Systemton, um den Ton aus Videokonferenzen aufzunehmen.";
164 changes: 164 additions & 0 deletions app/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* UI-chrome localisation for the MAIN process (issue #337).
*
* This is one of two independent i18next instances. The renderer runs its own
* (app/renderer/src/lib/i18n.ts). Nothing synchronises them automatically —
* they share these JSON files and are coordinated by an explicit protocol in
* main.js (persist → main changeLanguage → rebuild menu + tray → tell every
* renderer). Changing one without the other leaves the app half-translated.
*
* Not to be confused with the transcription/content language, which is the
* `language` config key and the get-language/set-language IPC pair. This
* module only ever touches `ui_language`.
*/

const fs = require('fs');
const path = require('path');
const i18next = require('i18next');

// Keep in sync with VALID_UI_LANGUAGES in src/config.py.
const SUPPORTED_UI_LANGUAGES = ['en', 'de'];
const FALLBACK_UI_LANGUAGE = 'en';
// The stored preference may also be this sentinel, which means "follow the OS".
const SYSTEM_SENTINEL = 'system';

// Resolved unconditionally from __dirname rather than through the packaged-
// resources branch other assets use: locales/ is inside the electron-builder
// `files` set, so it lands inside app.asar on macOS (Electron's patched fs
// reads straight through it) and as plain files under resources/app on
// Windows, where `win.asar` is false. Both are __dirname-relative.
const LOCALES_DIR = path.join(__dirname, 'locales');

function loadResources() {
const resources = {};
for (const lng of SUPPORTED_UI_LANGUAGES) {
try {
const raw = fs.readFileSync(path.join(LOCALES_DIR, `${lng}.json`), 'utf-8');
resources[lng] = { translation: JSON.parse(raw) };
} catch (err) {
// A missing or corrupt non-English file degrades to fallback text. A
// missing English file is a build defect, so let that one be loud.
if (lng === FALLBACK_UI_LANGUAGE) throw err;
resources[lng] = { translation: {} };
}
}
return resources;
}

/*
* Pick the best supported language for an ordered list of user preferences,
* e.g. Electron's app.getPreferredSystemLanguages() → ['de-DE', 'en-GB'].
*
* Ordered, not "first tag wins": a user whose list is ['fr-FR', 'de-DE'] has
* no French UI available here and should get German rather than falling
* through to English. Region subtags are dropped — we ship language-level
* translations only.
*/
function negotiateSystemLanguage(preferred, supported = SUPPORTED_UI_LANGUAGES) {
if (!Array.isArray(preferred)) return FALLBACK_UI_LANGUAGE;
for (const tag of preferred) {
if (typeof tag !== 'string' || !tag) continue;
const base = tag.toLowerCase().split(/[-_]/)[0];
if (supported.includes(base)) return base;
}
return FALLBACK_UI_LANGUAGE;
}

/*
* Read the stored preference straight from config.json.
*
* Same sync-JSON-read-at-startup pattern as loadShowMenuBarIconEnabled() and
* friends in main.js: the language has to be known before the application menu
* is built and before the first window loads, which is far too early to wait on
* a Python subprocess. The Python config remains the source of truth for
* writes.
*
* The absent-key branch mirrors _migrate_ui_language() in src/config.py and the
* asymmetry is deliberate: a config.json that exists but predates this key
* belongs to an install that has been running an English UI, and must keep it.
* Defaulting those to "system" would flip a German-OS user's interface to
* German without them ever asking. Only a genuinely fresh install follows the
* OS. If you change this, change the Python migration in the same commit.
*/
function readStoredUiLanguage(userDataDir) {
try {
const cfgPath = path.join(userDataDir, 'config.json');
if (!fs.existsSync(cfgPath)) return SYSTEM_SENTINEL; // fresh install
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const stored = cfg.ui_language;
if (stored === SYSTEM_SENTINEL || SUPPORTED_UI_LANGUAGES.includes(stored)) {
return stored;
}
return FALLBACK_UI_LANGUAGE; // existing install predating the key
} catch (_) {
return FALLBACK_UI_LANGUAGE;
}
}

/*
* Turn the stored preference into a concrete language tag.
* `preferredSystemLanguages` is injected so this stays testable without an
* Electron app object; main.js passes app.getPreferredSystemLanguages().
*/
function resolveUiLanguage(stored, preferredSystemLanguages) {
if (stored === SYSTEM_SENTINEL) {
return negotiateSystemLanguage(preferredSystemLanguages);
}
if (SUPPORTED_UI_LANGUAGES.includes(stored)) return stored;
return FALLBACK_UI_LANGUAGE;
}

let initialized = false;

async function initMainI18n(language) {
const lng = SUPPORTED_UI_LANGUAGES.includes(language) ? language : FALLBACK_UI_LANGUAGE;
if (initialized) {
await i18next.changeLanguage(lng);
return i18next;
}
await i18next.init({
lng,
fallbackLng: FALLBACK_UI_LANGUAGE,
resources: loadResources(),
interpolation: {
// Menu labels and notification bodies are plain strings handed to native
// APIs, never injected as HTML, so i18next's HTML escaping would only
// mangle characters like & in "&File".
escapeValue: false,
},
returnNull: false,
});
initialized = true;
return i18next;
}

// Bound at call time, not captured — changeLanguage() must be visible to every
// later t() without callers re-importing anything.
function t(key, options) {
if (!initialized) return key;
return i18next.t(key, options);
}

async function changeMainLanguage(language) {
const lng = SUPPORTED_UI_LANGUAGES.includes(language) ? language : FALLBACK_UI_LANGUAGE;
if (!initialized) return initMainI18n(lng);
await i18next.changeLanguage(lng);
return i18next;
}

function currentLanguage() {
return initialized ? i18next.language : FALLBACK_UI_LANGUAGE;
}

module.exports = {
SUPPORTED_UI_LANGUAGES,
FALLBACK_UI_LANGUAGE,
SYSTEM_SENTINEL,
negotiateSystemLanguage,
readStoredUiLanguage,
resolveUiLanguage,
initMainI18n,
changeMainLanguage,
currentLanguage,
t,
};
169 changes: 169 additions & 0 deletions app/locale-completeness.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use strict';

/**
* Locale completeness gate (#337).
*
* English is the source language, so the gate that matters is **English-source
* completeness**: every key the code asks for must exist in `en.json`. A missing
* English key renders as the raw key string ("settings.general.name.label") in
* the UI, which is a visible defect.
*
* German coverage is deliberately REPORTED, NOT ENFORCED. i18next falls back to
* English for a missing key, so a partially translated locale degrades to
* readable English rather than breaking — which is exactly what makes it safe
* for a locale to arrive incrementally from a community contributor. Making
* parity a hard gate would block every English copy change on a translator.
*
* What this canNOT prove: that every hardcoded string was extracted in the first
* place. It only proves the keys that ARE used resolve. Catching un-extracted
* strings needs a lint rule against raw user-facing literals, which is a
* separate piece of work.
*/

const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');

const LOCALES = path.join(__dirname, 'locales');
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;

function flatten(obj, prefix = '') {
const out = new Set();
for (const [k, v] of Object.entries(obj)) {
const key = `${prefix}${k}`;
if (v && typeof v === 'object' && !Array.isArray(v)) {
for (const nested of flatten(v, `${key}.`)) out.add(nested);
} else {
out.add(key);
}
}
return out;
}

function loadLocale(lng) {
return JSON.parse(fs.readFileSync(path.join(LOCALES, `${lng}.json`), 'utf-8'));
}

function sourceFiles() {
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
walk(full);
} else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.includes('.test.')) {
files.push(full);
}
}
};
walk(path.join(__dirname, 'renderer', 'src'));
// The main-process instance reads the same bundles.
files.push(path.join(__dirname, 'main.js'), path.join(__dirname, 'settings-ipc.js'));
return files;
}

/*
* Only literal keys are collectable. Keys built from a template
* (`t(`palette.settings.${row.key}.title`)`) are invisible to a text scan, so
* this is a floor on coverage, not a complete picture — which is why the
* reverse check (defined-but-unused) is reported rather than asserted.
*/
function usedKeys() {
const literal = /(?:\bt|i18n\.t)\(\s*['"]([a-zA-Z][\w.]*)['"]/g;
const transComponent = /i18nKey=\s*['"]([\w.]+)['"]/g;
const used = new Set();
for (const file of sourceFiles()) {
const src = fs.readFileSync(file, 'utf-8');
for (const m of src.matchAll(literal)) used.add(m[1]);
for (const m of src.matchAll(transComponent)) used.add(m[1]);
}
return used;
}

/** i18next resolves `key` against `key_one`/`key_other`, so both count as defined. */
function resolvableKeys(locale) {
const defined = flatten(locale);
const resolvable = new Set(defined);
for (const key of defined) {
if (PLURAL_SUFFIX.test(key)) resolvable.add(key.replace(PLURAL_SUFFIX, ''));
}
return resolvable;
}

test('every translation key used in the code exists in en.json', () => {
const resolvable = resolvableKeys(loadLocale('en'));
const missing = [...usedKeys()].filter((k) => !resolvable.has(k)).sort();
assert.deepStrictEqual(
missing,
[],
`keys used in code but absent from en.json (these render as raw key strings): ${missing.join(', ')}`,
);
});

test('German defines no key that English does not (English is the source)', () => {
// The direction that is a real bug: a German-only key can never resolve,
// because nothing in the code asks for a key that has no English source. It
// is almost always a typo in the German file.
const en = flatten(loadLocale('en'));
const orphans = [...flatten(loadLocale('de'))].filter((k) => !en.has(k)).sort();
assert.deepStrictEqual(orphans, [], `German keys with no English counterpart: ${orphans.join(', ')}`);
});

test('every plural key ships both forms in both locales', () => {
// A key with `_one` but no `_other` throws at the plural boundary rather than
// falling back, so this one IS enforced for German too.
for (const lng of ['en', 'de']) {
const keys = flatten(loadLocale(lng));
const broken = [];
for (const key of keys) {
if (!PLURAL_SUFFIX.test(key)) continue;
const base = key.replace(PLURAL_SUFFIX, '');
if (!keys.has(`${base}_one`) || !keys.has(`${base}_other`)) broken.push(key);
}
assert.deepStrictEqual(broken.sort(), [], `${lng}.json has half a plural pair: ${broken.join(', ')}`);
}
});

test('interpolation placeholders match between English and German', () => {
// A renamed or dropped placeholder is silent: i18next just leaves the literal
// {{name}} in the output, or interpolates nothing. Only a comparison catches it.
const en = loadLocale('en');
const de = loadLocale('de');
const flat = (obj, prefix = '', out = {}) => {
for (const [k, v] of Object.entries(obj)) {
const key = `${prefix}${k}`;
if (v && typeof v === 'object') flat(v, `${key}.`, out);
else out[key] = v;
}
return out;
};
const enFlat = flat(en);
const deFlat = flat(de);
const placeholders = (s) =>
new Set([...String(s).matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]));

const mismatches = [];
for (const [key, deValue] of Object.entries(deFlat)) {
const enValue = enFlat[key];
if (enValue === undefined) continue; // covered by the orphan test above
const a = placeholders(enValue);
const b = placeholders(deValue);
if (a.size !== b.size || [...a].some((p) => !b.has(p))) {
mismatches.push(`${key}: en{${[...a]}} de{${[...b]}}`);
}
}
assert.deepStrictEqual(mismatches.sort(), [], `placeholder drift:\n ${mismatches.join('\n ')}`);
});

test('German coverage is reported, not enforced', () => {
// Informational on purpose — see the file header. This prints a number so a
// reviewer can see coverage move; it must never fail a build.
const en = flatten(loadLocale('en'));
const de = flatten(loadLocale('de'));
const translated = [...en].filter((k) => de.has(k)).length;
const pct = ((translated / en.size) * 100).toFixed(1);
console.log(` German coverage: ${translated}/${en.size} keys (${pct}%)`);
assert.ok(en.size > 0);
});
Loading
Loading