From c65712e3cb5182dc908eda266f22c140709a7229 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Mon, 27 Jul 2026 00:06:14 +0200 Subject: [PATCH 1/3] feat(i18n): localise the interface, with German as the first language (#337) Adds a UI-chrome localisation layer and a full German translation. English stays the source language; the architecture takes further locales without rework. Two independent i18next instances, one per runtime, over the same JSON files in app/locales/. They share no state and are coordinated by an explicit sequence in main.js: persist -> changeLanguage -> rebuild the app menu -> rebuild the tray -> broadcast to every WebContents. The two native menus are snapshots, so they need that explicit rebuild; buildAppMenu() was extracted from the inline block for exactly this. The renderer bootstraps from a launch argument (webPreferences .additionalArguments) rather than an IPC round trip, and initialises i18next at module scope before React mounts, so the first paint is already in the right language instead of flashing English. Migration semantics, the part worth reviewing: a fresh install follows the OS; an existing config.json without the key migrates to "en". The asymmetry is the point. Treating an absent key as "follow the system" would flip every existing German-OS user's interface to German on upgrade without them asking. Both runtimes implement this independently, because main must read config.json synchronously long before Python can be spawned, and they are verified to agree across the full matrix of file states including corrupt and malformed ones. Date and time formatting deliberately keeps following the OS locale rather than the chosen UI language, which is how macOS itself separates Language from Region. Only language-bearing output moves: plurals (now i18next `count`, no more `${n} item${n !== 1 ? 's' : ''}`), relative time, and spelled-out durations. This also means no existing user's dates change. The four markdown section headings stay English wherever they are stored or shared -- they are a parsing protocol consumed in four places -- and are localised only where they are rendered. The e2e suite is pinned to English via STENOAI_UI_LANGUAGE, gated on the E2E flag. Roughly 108 of its locators match on visible text, and refactoring them onto test ids is a large unrelated change that would make this diff unreviewable. Known limitations, both deliberate: Electron role-based menu items and Chromium's own context menus follow the OS rather than the app setting, and exports plus the macOS permission prompts are untouched -- those are content and build metadata respectively, not UI chrome. --- app/i18n.js | 164 +++ app/locale-completeness.test.js | 169 +++ app/locales/de.json | 1142 +++++++++++++++++ app/locales/en.json | 1142 +++++++++++++++++ app/main.js | 310 +++-- app/package-lock.json | 82 +- app/package.json | 4 +- app/preload.js | 22 + app/renderer/src/App.tsx | 2 + app/renderer/src/components/AskBar.tsx | 74 +- .../src/components/ChatHistoryRow.tsx | 12 +- .../src/components/CommandPalette.tsx | 131 +- .../src/components/FolderScopePicker.tsx | 18 +- app/renderer/src/components/IconPicker.tsx | 6 +- .../src/components/ImportDropZone.tsx | 4 +- app/renderer/src/components/LiveDock.tsx | 20 +- .../src/components/LiveTranscriptBar.tsx | 64 +- app/renderer/src/components/MainToolbar.tsx | 43 +- app/renderer/src/components/MeetingsShell.tsx | 38 +- .../src/components/NotificationToast.tsx | 12 +- .../src/components/PrivacyConsentModal.tsx | 25 +- app/renderer/src/components/QuitDialog.tsx | 18 +- app/renderer/src/components/Sidebar.tsx | 41 +- .../src/components/TranscriptPanel.tsx | 16 +- .../src/components/UndoDeleteToast.tsx | 8 +- app/renderer/src/components/UpdateToast.tsx | 13 +- .../src/components/home/PreviousRow.tsx | 30 +- .../src/components/home/UpcomingCard.tsx | 47 +- .../src/components/ui/confirm-dialog.tsx | 16 +- app/renderer/src/hooks/useSettings.ts | 31 + app/renderer/src/hooks/useUiLanguageSync.ts | 34 + app/renderer/src/lib/chat.ts | 38 +- app/renderer/src/lib/hero.ts | 58 +- app/renderer/src/lib/i18n.ts | 68 + app/renderer/src/lib/ipc.ts | 20 + app/renderer/src/main.tsx | 5 + app/renderer/src/routes/Chat.tsx | 56 +- app/renderer/src/routes/ChatConversation.tsx | 38 +- app/renderer/src/routes/FolderDetail.tsx | 39 +- app/renderer/src/routes/Home.tsx | 77 +- app/renderer/src/routes/MeetingDetail.tsx | 216 ++-- app/renderer/src/routes/OrgShared.tsx | 57 +- app/renderer/src/routes/Processing.tsx | 89 +- app/renderer/src/routes/Recording.tsx | 16 +- app/renderer/src/routes/Settings.tsx | 61 +- app/renderer/src/routes/Setup.tsx | 150 +-- app/renderer/src/routes/settings/AboutTab.tsx | 53 +- .../src/routes/settings/AdvancedTab.tsx | 37 +- app/renderer/src/routes/settings/AiTab.tsx | 197 +-- .../src/routes/settings/DeveloperTab.tsx | 25 +- .../src/routes/settings/GeneralTab.test.tsx | 5 + .../src/routes/settings/GeneralTab.tsx | 166 ++- .../src/routes/settings/OrganisationTab.tsx | 34 +- .../src/routes/settings/SettingsNav.tsx | 48 +- .../src/routes/settings/TemplatesTab.tsx | 72 +- .../src/routes/settings/model-card.tsx | 46 +- app/renderer/src/test-setup.ts | 13 + app/renderer/tsconfig.json | 5 +- app/settings-ipc.js | 49 +- app/settings-ipc.test.js | 4 +- app/ui-language.test.js | 166 +++ app/vite.config.ts | 9 + app/vitest.config.ts | 2 + e2e/fixtures/electron.ts | 6 + e2e/specs/ui-language.t2.spec.ts | 118 ++ simple_recorder.py | 35 + src/config.py | 69 + tests/test_config.py | 92 ++ 68 files changed, 4940 insertions(+), 1037 deletions(-) create mode 100644 app/i18n.js create mode 100644 app/locale-completeness.test.js create mode 100644 app/locales/de.json create mode 100644 app/locales/en.json create mode 100644 app/renderer/src/hooks/useUiLanguageSync.ts create mode 100644 app/renderer/src/lib/i18n.ts create mode 100644 app/renderer/src/test-setup.ts create mode 100644 app/ui-language.test.js create mode 100644 e2e/specs/ui-language.t2.spec.ts diff --git a/app/i18n.js b/app/i18n.js new file mode 100644 index 00000000..4906a217 --- /dev/null +++ b/app/i18n.js @@ -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, +}; diff --git a/app/locale-completeness.test.js b/app/locale-completeness.test.js new file mode 100644 index 00000000..1b510717 --- /dev/null +++ b/app/locale-completeness.test.js @@ -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); +}); diff --git a/app/locales/de.json b/app/locales/de.json new file mode 100644 index 00000000..179f1d06 --- /dev/null +++ b/app/locales/de.json @@ -0,0 +1,1142 @@ +{ + "app": { + "actions": "Aktionen", + "date": { + "today": "Heute", + "yesterday": "Gestern" + }, + "dropBlocked": "Aufnahme stoppen, um zu importieren", + "dropToImport": "Audio zum Importieren ablegen", + "loading": "Wird geladen…", + "rename": "Umbenennen" + }, + "chat": { + "askAbout": "Fragen zu {{name}}", + "askAcross": "Übergreifend fragen…", + "askAi": "KI fragen", + "askAria": "Fragen zu diesem Meeting", + "backToChat": "Zurück zum Chat", + "bucket": { + "last2Weeks": "Letzte 2 Wochen", + "thisMonth": "Diesen Monat", + "thisWeek": "Diese Woche", + "today": "Heute", + "yesterday": "Gestern" + }, + "chatActions": "Chat-Aktionen", + "chips": { + "actionItems": "Aufgaben", + "keyDecisions": "Wichtigste Entscheidungen zusammenfassen", + "mainTopics": "Hauptthemen" + }, + "collapse": "Einklappen", + "composerPlaceholder": "Frag einfach los /", + "copyTranscript": "Transkript kopieren", + "deleteChat": "Chat {{name}} löschen", + "emptyResponse": "(leere Antwort)", + "failedToSend": "Senden fehlgeschlagen", + "greeting": "Frag einfach los", + "greetingNamed": "Hi {{name}}, frag einfach los", + "hideTranscript": "Transkript ausblenden", + "history": "Verlauf", + "mayOmitOlderNotes": "· ältere Notizen können fehlen", + "model": { + "auto": "Auto", + "cloud": "Cloud", + "organisation": "Organisation", + "remoteOllama": "Remote-Ollama" + }, + "newChat": "Neuer Chat", + "noOtherChats": "Noch keine anderen Chats.", + "noRecents": "Deine bisherigen Chats erscheinen hier.", + "noSavedChats": "Noch keine gespeicherten Chats.", + "notFound": "Chat nicht gefunden.", + "notFoundHint": "Diese Unterhaltung wurde vielleicht gelöscht.", + "orgScopeHint": "Notizübergreifender Chat über die geteilten Notizen von {{org}}", + "placeholderAsk": "Frag etwas zu diesem Meeting…", + "placeholderContinue": "Chat fortsetzen…", + "placeholderRecording": "Chat nach der Aufnahme verfügbar", + "presets": "Vorschläge", + "providerRequired": "Dein KI-Anbieter ist noch nicht bereit für den Chat - hinterlege einen Cloud-API-Key, melde dich bei deiner Organisation an oder trage deine Remote-Ollama-URL unter Einstellungen → KI ein.", + "providerRequiredPlaceholder": "KI-Anbieter in den Einstellungen einrichten, um übergreifend zu fragen", + "queryFailed": "Abfrage fehlgeschlagen", + "recents": "Zuletzt", + "relative": { + "days": "{{n}}T", + "hours": "{{n}}Std", + "minutes": "{{n}}Min", + "months": "{{n}}Mon", + "now": "jetzt", + "weeks": "{{n}}W", + "years": "{{n}}J" + }, + "resume": "Fortsetzen", + "resumeAria": "Aufnahme für diese Notiz fortsetzen", + "resumeTitle": "Aufnahme fortsetzen - das neue Audio wird an diese Notiz angehängt", + "scopeAria": "Bereich: {{scope}}", + "seeAll": "Alle anzeigen", + "send": "Senden", + "showLess": "Weniger anzeigen", + "showTranscript": "Transkript anzeigen", + "skills": "Skills", + "stop": "Stopp", + "streamError": "Fehler: {{error}}", + "switchChat": "Chat wechseln", + "thinking": "Denkt nach", + "thinkingStream": "Denkt nach…", + "transcript": "Transkript", + "untitled": "Chat ohne Titel" + }, + "common": { + "appName": "Steno", + "cancel": "Abbrechen", + "close": "Schließen", + "confirm": "Bestätigen", + "delete": "Löschen", + "done": "Fertig", + "retry": "Erneut versuchen", + "save": "Sichern", + "working": "Einen Moment..." + }, + "dialog": { + "filter": { + "audio": "Audiodateien", + "markdown": "Markdown", + "pdf": "PDF", + "text": "Text" + } + }, + "dock": { + "changeLanguage": "Transkriptsprache ändern", + "copyTranscript": "Transkript kopieren", + "hideTranscript": "Transkript ausblenden", + "languageAria": "Sprache: {{language}}", + "languageMulti": "Multi", + "listening": "Hört zu…", + "listeningHint": "Sprich einfach los - fertige Sätze erscheinen hier.", + "liveUnavailable": "Live-Transkription nicht verfügbar", + "minimizeTranscript": "Transkript minimieren", + "noMatches": "Keine Treffer", + "noMatchesHint": "Nichts passt zu deinem Filter.", + "paused": "Pausiert", + "preparing": "Transkription wird vorbereitet…", + "preparingHint": "Parakeet wärmt sich auf. Audio wird bereits aufgenommen.", + "preparingShort": "Wird vorbereitet…", + "preparingSlow": "Wärmt sich noch auf - der erste Start kann einen Moment dauern. Audio wird bereits aufgenommen.", + "recording": "Aufnahme", + "resumeRecording": "Aufnahme fortsetzen", + "resumed": "Fortgesetzt", + "searchTranscript": "Transkript durchsuchen", + "showTranscript": "Transkript anzeigen", + "stage": "Phase: {{stage}}", + "stageWithMessage": "{{stage}}: {{message}}", + "stillPreparing": "Wird noch vorbereitet…", + "stop": "Stopp", + "stopRecording": "Aufnahme stoppen", + "transcript": "Transkript", + "transcriptHeader": "Transkript-Kopfzeile" + }, + "folders": { + "changeIcon": "Ordnersymbol ändern", + "create": "Ordner erstellen", + "deleteNoMeetings": "Es werden keine Aufnahmen oder Transkripte gelöscht.", + "deleteTitle": "Ordner „{{name}}“ löschen?", + "deleteWithMeetings_one": "{{count}} Meeting wird zurück nach „Alle Notizen“ verschoben. Es werden keine Aufnahmen oder Transkripte gelöscht.", + "deleteWithMeetings_other": "{{count}} Meetings werden zurück nach „Alle Notizen“ verschoben. Es werden keine Aufnahmen oder Transkripte gelöscht.", + "emptyHint": "Notizen, die du in diesem Ordner speicherst, erscheinen hier.", + "emptyTitle": "Noch nichts hier", + "loading": "Ordner wird geladen…", + "meetingCount_one": "{{count}} Meeting", + "meetingCount_other": "{{count}} Meetings", + "namePlaceholder": "z. B. Acme Corp", + "newFolder": "Neuer Ordner", + "newFolderDescription": "Fasse zusammengehörige Meetings zusammen. Ordnernamen sind nur für dich sichtbar.", + "noIconsMatch": "Keine Symbole passen zu „{{query}}“", + "notFound": "Ordner nicht gefunden.", + "notFoundHint": "Dieser Ordner wurde vielleicht gelöscht. Zurück zum Start.", + "notes": "Notizen", + "searchIcons": "Symbole suchen…" + }, + "hero": { + "headline": { + "clearDay": "Dein Tag ist frei", + "inMeeting": "Gerade in einem Meeting", + "nextInHours_one": "Nächstes Meeting in {{count}} Stunde", + "nextInHours_other": "Nächstes Meeting in {{count}} Stunden", + "nextInMinutes_one": "Nächstes Meeting in {{count}} Minute", + "nextInMinutes_other": "Nächstes Meeting in {{count}} Minuten", + "paused": "Aufnahme pausiert", + "processing": "Deine Notiz wird erstellt", + "ready": "Bereit für richtig gute Notizen", + "recording": "Aufnahme läuft" + }, + "recordingHint": "Die Aufnahme startest du oben rechts oder von überall mit {{shortcut}}.", + "subtitle": { + "inMeeting": "Mit {{shortcut}} startest du die Aufnahme, oder du klickst unten auf eine Meeting-Karte.", + "inProgressFallback": "Läuft gerade", + "nextSoon": "{{title}} um {{time}}. {{shortcut}}, wenn du bereit bist.", + "paused": "Aufnahme pausiert. Zum Weitermachen unten in der Leiste auf „Fortsetzen“ klicken.", + "processing": "Gleich ist deine Notiz fertig.", + "recording": "{{title}} · {{shortcut}} zum Stoppen", + "tomorrow": "Als Nächstes: {{title}} morgen um {{time}}." + } + }, + "home": { + "allDay": { + "toggle_one": "+ {{count}} ganztägiger Termin heute", + "toggle_other": "+ {{count}} ganztägige Termine heute" + }, + "calendarNudge": { + "connectLabel": "Verbinden:", + "connecting": "Verbinde mit {{provider}}…", + "dismiss": "Ausblenden", + "prompt": "Verbinde deinen Kalender, um die Meetings von heute zu sehen." + }, + "day": { + "earlier": "Früher", + "today": "Heute", + "tomorrow": "Morgen", + "yesterday": "Gestern" + }, + "duration": { + "hoursMinutes": "{{hours}} Std. {{minutes}} Min.", + "minutes": "{{minutes}} Min.", + "seconds": "{{seconds}} Sek." + }, + "empty": { + "consent": "Hol dir immer das Einverständnis, wenn du andere transkribierst.", + "newNote": "Neue Notiz", + "quickStartLabel": "Schnellstart:", + "quickStartSuffix": "von überall", + "stopRecording": "Aufnahme stoppen", + "tagline": "KI für deine vertraulichen Workflows.", + "title": "Willkommen bei Steno." + }, + "loading": "Meetings werden geladen…", + "previousRow": { + "participants_one": "{{count}} Person", + "participants_other": "{{count}} Personen", + "processingBadge": "Verarbeitung", + "recordingBadge": "Aufnahme", + "untitledNote": "Notiz ohne Titel" + }, + "relative": { + "days_one": "{{count}} Tag", + "days_other": "{{count}} Tage", + "hours_one": "{{count}} Std.", + "hours_other": "{{count}} Std.", + "minutes_one": "{{count}} Min.", + "minutes_other": "{{count}} Min.", + "now": "Jetzt" + }, + "search": { + "clear": "Suche zurücksetzen", + "noMatches": "Keine Treffer für „{{query}}“.", + "placeholder": "Notizen durchsuchen" + }, + "sections": { + "allNotes": "Alle Notizen", + "comingUp": "Demnächst", + "previous": "Frühere Notizen", + "today": "Heute", + "tomorrow": "Morgen" + }, + "upcoming": { + "nextPage": "Weiter", + "prevPage": "Zurück", + "refresh": "Nach neuen Kalendereinträgen suchen" + }, + "upcomingCard": { + "allDay": "Ganztägig", + "endsIn_one": "Endet in {{count}} Min.", + "endsIn_other": "Endet in {{count}} Min.", + "join": "Beitreten", + "justStarted": "Gerade gestartet", + "startNow": "Jetzt starten", + "startedAgo_one": "Vor {{count}} Min. gestartet", + "startedAgo_other": "Vor {{count}} Min. gestartet", + "untitled": "Meeting ohne Titel" + } + }, + "meeting": { + "action": { + "backToHome": "Zurück zur Startseite", + "copied": "Kopiert", + "copiedConfirm": "Kopiert!", + "copyNotes": "Notizen kopieren", + "copyTranscript": "Transkript kopieren", + "deleteNote": "Notiz löschen", + "generateNotes": "Notizen erzeugen", + "home": "Start", + "moreOptions": "Weitere Optionen", + "regenerateTitle": "Titel neu erzeugen", + "retranscribeRecording": "Aufnahme neu transkribieren", + "saveNotesPdf": "Notizen als PDF speichern…", + "saveTranscriptMarkdown": "Transkript als .md speichern…", + "viewContainingFolder": "Speicherort anzeigen" + }, + "backToMeetings": "Zurück zu den Meetings", + "backup": { + "backingUp": "Backup läuft…", + "failed": "Diese Notiz wurde nicht gesichert. Zum Wiederholen klicken.", + "failedWithReason": "Letztes Backup fehlgeschlagen: {{error}}. Zum Wiederholen klicken.", + "notBackedUp": "Nicht gesichert" + }, + "error": { + "copyTranscript": "Transkript konnte nicht kopiert werden: {{error}}", + "deleteFailed": "Löschen fehlgeschlagen: {{error}}", + "generic": "Da ist etwas schiefgelaufen.", + "saveNotes": "Notizen konnten nicht gespeichert werden: {{error}}", + "saveTranscript": "Transkript konnte nicht gespeichert werden: {{error}}", + "unknown": "unbekannter Fehler" + }, + "folder": { + "addToFolder": "Zu Ordner hinzufügen", + "namePlaceholder": "Ordnername...", + "new": "Neuer Ordner...", + "none": "Kein Ordner" + }, + "loadError": { + "body": "Beim Laden dieser Notiz ist ein Fehler aufgetreten.", + "title": "Notiz konnte nicht geladen werden." + }, + "loading": "Meeting wird geladen…", + "noNotesYet": { + "body": "Diese Aufnahme wurde transkribiert, Notizen wurden aber nicht automatisch erzeugt. Mit Notizen erzeugen unten kannst du sie jetzt erstellen, oder du kopierst bzw. speicherst das Transkript über die Aktionen oben.", + "title": "Noch keine Notizen" + }, + "noSummary": "Für dieses Meeting gibt es keine Zusammenfassung.", + "notFound": { + "body": "Diese Aufnahme wurde möglicherweise gelöscht. Wähle in der Seitenleiste eine andere aus.", + "title": "Notiz nicht gefunden." + }, + "notes": { + "placeholder": "Notizen schreiben…" + }, + "participantCount_one": "{{count}} Person", + "participantCount_other": "{{count}} Personen", + "processing": { + "body": "Dein Transkript ist gesichert. Im Hintergrund wird es verfeinert und es entstehen die Notizen. Meine Notizen kannst du jetzt schon lesen und bearbeiten.", + "title": "Fast fertig" + }, + "report": { + "deleteBody": "Dieser erzeugte Bericht wird endgültig gelöscht. Das Transkript und andere Berichte bleiben unberührt.", + "deleteLabel": "Bericht {{name}} löschen", + "deleteTitle": "Bericht „{{name}}“ löschen?" + }, + "reprocessFailed": { + "body": "Das hat diesmal nicht geklappt, versuch es noch einmal. Wenn es bei einem langen Meeting immer wieder fehlschlägt, wechsle in den Einstellungen zu einem kleineren Modell.", + "title": "Notizen wurden nicht erzeugt" + }, + "retranscribeDialog": { + "body": "Die Transkription läuft mit deinen aktuellen Transkriptionseinstellungen erneut. Das Transkript wird ersetzt und die Zusammenfassung neu erzeugt.", + "confirm": "Neu transkribieren", + "title": "Diese Aufnahme neu transkribieren?" + }, + "section": { + "actionItems": "Aufgaben", + "keyPoints": "Kernpunkte", + "keyTopics": "Kernthemen", + "participants": "Teilnehmende", + "summary": "Zusammenfassung" + }, + "share": { + "failed": "Teilen fehlgeschlagen: {{error}}", + "shareWith": "Mit {{org}} teilen", + "sharing": "Wird geteilt…", + "unshareFrom": "Nicht mehr mit {{org}} teilen" + }, + "stream": { + "analysing": "Transkript wird analysiert", + "generatingNotes": "Notizen werden erzeugt", + "summarisingPart": "Teil {{step}}/{{total}} wird zusammengefasst" + }, + "transcriptionFailed": { + "body": "Für diese Aufnahme konnten keine Notizen erzeugt werden. Dein Audio wurde behalten und nicht gelöscht, es ist also nichts verloren.", + "details": "Details: {{error}}", + "title": "Transkription fehlgeschlagen" + }, + "unshareDialog": { + "body": "Die geteilte Kopie wird aus deiner Organisation entfernt. Deine lokale Notiz bleibt auf diesem Gerät. Du kannst sie jederzeit wieder teilen.", + "confirm": "Nicht mehr teilen", + "pending": "Teilen wird beendet…", + "title": "Nicht mehr mit {{org}} teilen?", + "yourOrg": "deiner Organisation" + }, + "view": { + "chooseViewOrTemplate": "Ansicht oder Vorlage wählen", + "generateFromTemplate": "Aus Vorlage erzeugen", + "generating": "Wird erzeugt…", + "myNotes": "Meine Notizen", + "summary": "Zusammenfassung", + "tablistLabel": "Notizansicht" + } + }, + "menu": { + "file": "&Datei", + "learnMore": "Mehr erfahren", + "reportBug": "Fehler melden", + "settings": "Einstellungen…" + }, + "nav": { + "allNotes": "Alle Notizen", + "chat": "Chat", + "folders": "Ordner", + "help": "Hilfe", + "home": "Start", + "search": "Suchen", + "searchNotes": "Notizen suchen", + "settings": "Einstellungen", + "sharedAcross": "Geteilt in {{org}}", + "sharedNotes": "Geteilte Notizen" + }, + "notification": { + "meetingDetected": { + "action": "Notizen machen", + "title": "Meeting erkannt" + }, + "meetingEnded": { + "action": "Zusammenfassen", + "title": "Meeting beendet" + }, + "micOnly": { + "body": "Der Systemton konnte nicht aufgenommen werden. Prüfe in den Systemeinstellungen Stenos Zugriff auf Bildschirm- und Systemtonaufnahme.", + "title": "Aufnahme nur über Mikrofon" + }, + "noteReady": { + "bodyDone": "Deine Notiz ist fertig verarbeitet", + "bodyFailedTitled": "Steno konnte „{{title}}“ nicht verarbeiten.", + "bodyFailedUntitled": "Steno konnte deine Notiz nicht verarbeiten.", + "bodyPreserved": "Deine Aufnahme ist erhalten geblieben - öffne die Notiz für Details.", + "title": "Notiz fertig", + "titleProcessingFailed": "Verarbeitung fehlgeschlagen", + "titleTranscriptionFailed": "Transkription fehlgeschlagen" + }, + "preMeeting": { + "titleFallback": "Meeting beginnt" + }, + "recordingFailed": { + "body": "Aufnahme konnte nicht gestartet werden.", + "bodyWithReason": "Aufnahme konnte nicht gestartet werden: {{reason}}", + "title": "Steno" + }, + "shortcuts": { + "alreadyRecording": "Es läuft bereits eine Aufnahme", + "invalidUrl": "Ungültige Kurzbefehl-URL", + "title": "Steno Kurzbefehle" + }, + "silenceAutoStop": { + "bodyNamed_one": "{{sessionName}} - {{count}} Minute Stille", + "bodyNamed_other": "{{sessionName}} - {{count}} Minuten Stille", + "body_one": "{{count}} Minute Stille - deine Notiz wird verarbeitet.", + "body_other": "{{count}} Minuten Stille - deine Notiz wird verarbeitet.", + "title": "Aufnahme gestoppt" + }, + "sleepPaused": { + "action": "Fortsetzen", + "body": "Pausiert, während dein Rechner im Ruhezustand war. Fortsetzen, um weiter aufzunehmen.", + "title": "Aufnahme pausiert" + } + }, + "org": { + "connectDescription": "Melde dich bei deinem Steno-Enterprise-Adapter an, um von Kolleginnen und Kollegen geteilte Notizen zu sehen und übergreifend darüber zu chatten.", + "connectTitle": "Organisation verbinden", + "emptyState": "Noch keine geteilten Notizen - teile eines deiner Meetings mit {{org}}, damit es hier erscheint.", + "fromS3": "aus S3", + "fromS3Title": "Der Inhalt liegt im S3-Bucket deiner Organisation; der Adapter hat ihn serverseitig geladen. Er wird nie auf dieses Gerät geschrieben.", + "loadingNotes": "Notizen werden geladen…", + "noBody": "(kein Inhalt)", + "noteActions": "Notiz-Aktionen", + "noteCount_one": "{{count}} Notiz", + "noteCount_other": "{{count}} Notizen", + "openSettings": "Einstellungen → Organisation öffnen", + "orgLabel": "Org", + "sharedBy": "geteilt von {{email}}", + "sharedNotes": "Geteilte Notizen", + "signIn": "Bei Org anmelden", + "signInHint": "Melde dich an, um Notizen mit deiner Organisation zu teilen", + "signOut": "Abmelden", + "todayAt": "heute, {{time}}", + "unshare": "Freigabe aufheben", + "you": "du", + "yourOrg": "deine Org" + }, + "palette": { + "hintClose": "esc schließen", + "hintNavigate": "↑↓ navigieren", + "hintOpen": "↵ öffnen", + "noNotes": "Noch keine Notizen", + "noNotesMatch": "Keine Notizen passen zu „{{query}}“", + "noSettings": "Keine Einstellungen", + "noSettingsMatch": "Keine Einstellungen passen zu „{{query}}“", + "results": "Suchergebnisse", + "searchNotes": "Notizen suchen", + "searchNotesPlaceholder": "Notizen suchen…", + "searchSettings": "Einstellungen suchen", + "searchSettingsPlaceholder": "Einstellungen suchen…", + "settings": { + "about": { + "sub": "Version, Release Notes, nach Updates suchen", + "title": "Über" + }, + "aiProvider": { + "sub": "Lokal, privater Server, Cloud oder Organisation", + "title": "KI-Anbieter" + }, + "analytics": { + "sub": "Ein- oder ausschalten", + "title": "Anonyme Nutzungsdaten" + }, + "autoDetect": { + "sub": "Benachrichtigen, wenn eine andere App das Mikrofon nutzt", + "title": "Automatisch erkannte Meetings" + }, + "autoNotes": { + "sub": "Nach der Transkription zusammenfassen", + "title": "Notizen automatisch erzeugen" + }, + "calendar": { + "sub": "Google, Outlook", + "title": "Kalender verbinden" + }, + "clearRecordingState": { + "sub": "Hängende Aufnahme zurücksetzen", + "title": "Aufnahmestatus löschen" + }, + "developer": { + "sub": "Diagnose und Logs", + "title": "Entwickler" + }, + "discord": { + "sub": "Community beitreten, Fragen stellen, Feedback geben", + "title": "Discord" + }, + "dockIcon": { + "sub": "Nur Menüleisten-/Tray-Symbol", + "title": "Dock-Symbol ausblenden" + }, + "language": { + "sub": "Sprache für Transkription und Zusammenfassung", + "title": "Sprache" + }, + "launch": { + "sub": "Steno automatisch starten", + "title": "Beim Login starten" + }, + "menuBar": { + "sub": "Schnellzugriff in der Menüleiste oder im System-Tray", + "title": "In der Menüleiste anzeigen" + }, + "microphone": { + "sub": "Eingabegerät", + "title": "Mikrofon" + }, + "name": { + "sub": "Begrüßung in der App", + "title": "Dein Name" + }, + "notifications": { + "sub": "Desktop-Benachrichtigung, wenn Notizen fertig sind", + "title": "Benachrichtigungen nach Meetings" + }, + "organisation": { + "sub": "Anmelden und Notizen in deiner Org sichern", + "title": "Organisation" + }, + "saveRecordings": { + "sub": "Audiodateien nach der Transkription behalten", + "title": "Aufnahmen speichern" + }, + "scheduled": { + "sub": "Anstehende Kalendertermine", + "title": "Geplante Meetings" + }, + "setupWizard": { + "sub": "Ersteinrichtung erneut ausführen", + "title": "Einrichtungsassistent" + }, + "silence": { + "sub": "Aufnahme beenden, wenn es still wird", + "title": "Auto-Stopp bei Stille" + }, + "storage": { + "sub": "Wo Notizen und Aufnahmen gespeichert werden", + "title": "Speicherort" + }, + "systemAudio": { + "sub": "Andere Teilnehmende mit aufnehmen", + "title": "Systemaudio aufnehmen" + }, + "systemTray": { + "sub": "Schnellzugriff in der Menüleiste oder im System-Tray", + "title": "Im System-Tray anzeigen" + }, + "templates": { + "sub": "Eigene Notizformate", + "title": "Vorlagen" + }, + "theme": { + "sub": "Hell, dunkel oder Systemdesign", + "title": "Erscheinungsbild" + }, + "transcriptionModel": { + "sub": "Parakeet oder Whisper", + "title": "Transkriptionsmodell" + } + }, + "untitledMeeting": "Meeting ohne Titel" + }, + "privacy": { + "acknowledge": "Verstanden", + "description": "Damit Fehler gefunden und behoben werden können, sendet Steno anonyme Nutzungsdaten - niemals deine Aufnahmen, Transkripte oder Notizen. Außerdem startet Steno automatisch, wenn du dich anmeldest. Beides ist standardmäßig aktiv und du kannst beides jederzeit einzeln in den Einstellungen ändern.", + "launch": { + "description": "Steno startet automatisch, wenn du dich anmeldest (versteckt in der Menüleiste).", + "label": "Beim Anmelden starten" + }, + "telemetry": { + "description": "Nur Absturz- und Nutzungssignale. Meeting-Inhalte werden nie gesendet.", + "label": "Anonyme Nutzungsdaten" + }, + "title": "Kurz zum Datenschutz" + }, + "processing": { + "backToHome": "Zurück zur Startseite", + "chip": "Verarbeitung", + "duration": { + "hoursMinutes_one": "{{hours}} Std. {{count}} Min.", + "hoursMinutes_other": "{{hours}} Std. {{count}} Min.", + "hours_one": "{{count}} Std.", + "hours_other": "{{count}} Std.", + "minutes_one": "{{count}} Min.", + "minutes_other": "{{count}} Min.", + "seconds_one": "{{count}} Sek.", + "seconds_other": "{{count}} Sek." + }, + "error": { + "canRetry": "Über „Erneut versuchen“ läuft die Verarbeitung dieser Aufnahme noch einmal.", + "cannotRetry": "Diese Aufnahme konnte nicht automatisch wiederhergestellt werden. Importiere die Audiodatei am besten noch einmal über die Startseite.", + "restartFailed": "Die Verarbeitung konnte nicht neu gestartet werden. Bitte versuche es noch einmal.", + "retrying": "Neuer Versuch…", + "tryAgain": "Erneut versuchen" + }, + "home": "Start", + "myNotes": "Meine Notizen", + "progress": { + "merging": "Zusammenfassungen werden zusammengeführt…", + "part": "Teil {{step}} von {{total}} wird zusammengefasst…" + }, + "stage": { + "error": "Diese Aufnahme konnte nicht verarbeitet werden.", + "finalizing": "Fast fertig…", + "summarizing": "Notizen werden erstellt", + "transcribing": "Transkript wird analysiert" + }, + "untitledNote": "Notiz" + }, + "quit": { + "processing": { + "body_one": "{{count}} Aufnahme wird noch verarbeitet. Beim Beenden wird die Verarbeitung abgebrochen.", + "body_other": "{{count}} Aufnahmen werden noch verarbeitet. Beim Beenden wird die Verarbeitung abgebrochen.", + "confirm": "Trotzdem beenden", + "title": "Verarbeitung läuft" + }, + "recording": { + "body": "Beim Beenden wird die laufende Aufnahme gestoppt und gespeichert.", + "confirm": "Stoppen & beenden", + "title": "Aufnahme läuft" + } + }, + "recording": { + "addToFolder": "Zu Ordner hinzufügen", + "backToHome": "Zurück zur Startseite", + "home": "Start", + "myNotes": "Meine Notizen", + "notesPlaceholder": "Schreib auf, was du festhalten willst: Entscheidungen, Fragen, offene Punkte. Um das Transkript kümmert sich Steno.", + "startedAt": "Gestartet um {{time}}", + "titlePlaceholder": "Neue Notiz" + }, + "settings": { + "about": { + "checkFailed": "Prüfung fehlgeschlagen", + "checkForUpdates": "Nach Updates suchen", + "checking": "Suche nach Updates", + "discord": { + "description": "Der Community beitreten, Fragen stellen, Feedback geben" + }, + "downloadFailed": "Update-Download fehlgeschlagen: {{error}}", + "downloading": "Update wird geladen…", + "github": { + "description": "Steno ist Open Source - im Code stöbern, Issues melden" + }, + "join": "Beitreten", + "privacy": "Datenschutzerklärung", + "releaseNotes": { + "description": "Neuerungen ansehen", + "label": "Versionshinweise" + }, + "restartToUpdate": "Neu starten und aktualisieren (v{{version}})", + "terms": "Nutzungsbedingungen", + "upToDate": "Du hast die aktuelle Version", + "version": "Version {{version}}", + "versionUpdateAvailable": "Version {{version}} - Update verfügbar (v{{latest}})", + "versionUpdateBlocked": "Version {{version}} - v{{latest}} benötigt eine neuere macOS-Version", + "view": "Öffnen", + "viewRelease": "Release ansehen" + }, + "advanced": { + "anonymousId": { + "description": "Kennzeichnet diese Installation in der Nutzungsstatistik. Hilfreich, wenn du einen Fehler meldest.", + "label": "Anonyme ID" + }, + "clearState": { + "clear": "Zurücksetzen", + "clearing": "Wird zurückgesetzt…", + "description": "Hängende Aufnahmen oder Verarbeitungen beheben", + "label": "Aufnahmestatus zurücksetzen" + }, + "copied": "Kopiert", + "copy": "In die Zwischenablage kopieren", + "setupWizard": { + "description": "Abhängigkeiten neu installieren oder Konfiguration reparieren", + "label": "Einrichtungsassistent", + "run": "Starten" + }, + "storage": { + "choose": "Auswählen…", + "description": "Wo deine Notizen und Aufnahmen gespeichert werden", + "label": "Speicherort", + "reset": "Zurücksetzen" + }, + "telemetry": { + "description": "Hilf mit, Steno zu verbessern - Meeting-Inhalte werden nie gesendet", + "label": "Anonyme Nutzungsstatistik" + } + }, + "ai": { + "adapter": { + "signedIn": "Zusammenfassungen, Titel und Chat laufen über den Adapter deiner Organisation. Modell und API-Schlüssel legt deine Organisation fest - hier musst du nichts einrichten.", + "signedOut": "Du bist bei keiner Organisation angemeldet. Melde dich unter Einstellungen > Organisation an oder stelle diesen Anbieter zurück auf Lokal / Privater Server / Cloud-API." + }, + "autoSummarize": { + "description": "Fasst jede Aufnahme direkt nach der Transkription zusammen. Schalte das aus, um beim Transkript zu bleiben und Notizen nur bei Bedarf zu erzeugen.", + "label": "Notizen automatisch erzeugen" + }, + "cloud": { + "apiKeyLabel": "API-Schlüssel", + "apiKeyPlaceholderBedrock": "Bedrock-API-Schlüssel (Bearer-Token)", + "apiUrlLabel": "API-Basis-URL", + "custom": "Eigene (OpenAI-kompatibel)", + "customOption": "Eigene…", + "disclaimer": "Transkripte werden an einen Cloud-Dienst eines Drittanbieters gesendet. Audiodateien verlassen dein Gerät nicht.", + "inferenceProfileLabel": "Inferenzprofil (optional)", + "modelLabel": "Modell", + "modelsAvailable_one": "{{count}} Modell verfügbar", + "modelsAvailable_other": "{{count}} Modelle verfügbar", + "pickFromList": "Aus Liste wählen", + "regionLabel": "AWS-Region", + "selectModelPlaceholder": "Modell auswählen", + "serviceLabel": "Dienst", + "testToLoadModels": "Teste die Verbindung, um die Liste der verfügbaren Modelle zu laden." + }, + "connection": { + "connected": "Verbunden", + "failed": "Fehlgeschlagen", + "test": "Verbindung testen", + "testing": "Test läuft…" + }, + "engine": { + "parakeet": "Am schnellsten - Englisch und europäische Sprachen", + "whisper": "Am genauesten - 99 Sprachen" + }, + "keepRecordings": { + "description": "Speichert Audiodateien nach der Verarbeitung an deinem Speicherort (siehe Erweitert). Braucht je nach Aufnahmemodus 1-10 MB pro Minute.", + "label": "Aufnahmen speichern" + }, + "language": { + "description": "Wird standardmäßig automatisch erkannt. Wähle eine aus, um sie festzulegen.", + "label": "Sprache der Aufnahmen" + }, + "model": { + "description": "Welches Speech-to-Text-Modell deine Aufnahmen transkribiert.", + "label": "Modell", + "loadError": "Modelle konnten nicht geladen werden." + }, + "models": { + "deleteDescriptionBoth": "{{name}} und den schnelleren Build ({{size}}) löschen, um Speicherplatz freizugeben? Du kannst sie jederzeit erneut laden.", + "deleteDescriptionOne": "{{name}} ({{size}}) löschen, um Speicherplatz freizugeben? Du kannst es jederzeit erneut laden.", + "deleteFasterBuildDescription": "{{name}} ({{size}}) wird nicht mehr gebraucht, seit der schnellere Build aktiv ist. Löschen, um Speicherplatz freizugeben?", + "deleteTitle": "Modell löschen?", + "hideDeprecated": "Veraltete Modelle ausblenden", + "loading": "Modelle werden geladen…", + "none": "Keine Modelle verfügbar.", + "ollamaUnreachable": "Ollama ist nicht erreichbar. Starte den Einrichtungsassistenten.", + "qualityNote": "Qualität: {{value}}", + "showDeprecated": "Veraltete Modelle einblenden", + "speedNote": "Geschwindigkeit: {{value}}", + "unknownSize": "Größe unbekannt" + }, + "provider": { + "adapter": "Organisation", + "adapterDescription": "Nutzt den KI-Schlüssel deiner Organisation. Keine Einrichtung nötig.", + "adapterDisabledDescription": "Melde dich bei deiner Organisation an, um diese Option zu aktivieren.", + "cloud": "Cloud-API", + "cloudDescription": "Nutzt OpenAI, Anthropic oder eine kompatible API. Beste Qualität, erfordert einen kostenpflichtigen Schlüssel.", + "description": "Wo die Modelle laufen. Lokal bleiben alle Daten auf deinem Gerät.", + "label": "KI-Anbieter", + "local": "Lokal (auf dem Gerät)", + "localDescription": "Läuft vollständig auf deinem Gerät. Privat und kostenlos, ohne Internet.", + "orgManaged": "Wird von deiner Organisation verwaltet, solange du angemeldet bist. Melde dich unter Einstellungen > Organisation ab, um das zu ändern.", + "remote": "Privater Server", + "remoteDescription": "Verbindet sich mit deinem eigenen Ollama-Server. Die Daten bleiben in deinem Netzwerk." + }, + "remote": { + "urlLabel": "URL des Ollama-Servers" + }, + "summarisation": { + "heading": "Zusammenfassung und Chat", + "intro": "Macht aus deinem Transkript Notizen und beantwortet deine Fragen. Das ist der einzige Schritt, der lokal oder in der Cloud laufen kann - bei einem Cloud-Anbieter wird nur der Transkripttext gesendet, nie Audio." + }, + "summaryModel": { + "description": "Welches Modell deine Zusammenfassungen, Titel und Chat-Antworten erzeugt.", + "label": "Modell" + }, + "transcription": { + "heading": "Transkription", + "intro": "Die Spracherkennung läuft immer auf deinem Gerät - dein Audio verlässt deinen Computer nie." + } + }, + "developer": { + "clear": "Leeren", + "console": { + "description": "Live-Logausgabe der Backend-Prozesse.", + "label": "Debug-Konsole" + }, + "copy": "Kopieren", + "placeholder": "Steno Debug-Konsole\nSitzung gestartet - warte auf Aktivität…\n", + "save": "Speichern", + "saveFailed": "Diagnosedaten konnten nicht gespeichert werden: {{error}}", + "unknownError": "unbekannter Fehler" + }, + "general": { + "appearance": { + "dark": "Dunkel", + "description": "Hell, dunkel oder passend zum System", + "label": "Erscheinungsbild", + "light": "Hell", + "system": "System" + }, + "autoInstall": { + "description": "Lädt Updates im Hintergrund, wenn die App inaktiv ist und nicht aufnimmt, installiert sie und startet dann neu. Du wirst weiterhin benachrichtigt, sobald ein Update verfügbar ist.", + "label": "Updates automatisch installieren" + }, + "bothIconsHidden": "Damit sind Dock-Symbol und Menüleisten-Symbol beide ausgeblendet. Öffne Steno über „Programme“ oder Spotlight, um das Fenster zurückzuholen.", + "calendar": { + "connected": "Verbunden mit {{account}}", + "description": "Zeigt anstehende Meetings auf dem Startbildschirm", + "disconnect": "Trennen", + "heading": "Kalender", + "label": "Kalender verbinden" + }, + "dockIcon": { + "description": "Nur als Menüleisten-App laufen lassen", + "label": "Dock-Symbol ausblenden" + }, + "launchOnLogin": { + "description": "Startet Steno automatisch bei der Anmeldung, versteckt in der Menüleiste. Schalte das aus, um Steno von Hand zu starten.", + "label": "Bei der Anmeldung starten" + }, + "microphone": { + "description": "Von welchem Eingabegerät Steno aufnimmt. Deine Wahl wird festgehalten, damit ein Wechsel des System-Standards (z. B. wenn sich AirPods verbinden) nicht unbemerkt ändert, was aufgenommen wird. Gilt ab der nächsten Aufnahme.", + "label": "Mikrofon", + "numbered": "Mikrofon {{index}}", + "systemDefault": "Systemstandard", + "unknownDevice": "Unbekanntes Gerät (getrennt)" + }, + "name": { + "description": "Nur der Vorname - wird für Begrüßungen in der App genutzt. Wird lokal gespeichert.", + "label": "Dein Name", + "placeholder": "Dein Name" + }, + "notifications": { + "autoDetected": { + "description": "Achtet darauf, ob andere Apps dein Mikrofon nutzen, und benachrichtigt dich, wenn ein Anruf beginnt - inklusive Schaltfläche, die die Aufnahme mit einem Klick startet.", + "label": "Automatisch erkannte Meetings", + "unsupported": "Achtet darauf, ob andere Apps dein Mikrofon nutzen, und benachrichtigt dich, wenn ein Anruf beginnt. Erfordert macOS 14 (Sonoma) oder neuer.", + "unsupportedVersion": "Achtet darauf, ob andere Apps dein Mikrofon nutzen, und benachrichtigt dich, wenn ein Anruf beginnt. Erfordert macOS 14 (Sonoma) oder neuer, du nutzt {{version}}." + }, + "heading": "Meeting-Benachrichtigungen", + "post": { + "description": "Benachrichtigt dich, wenn deine Notizen fertig sind oder eine Aufnahme wegen Stille automatisch stoppt.", + "label": "Benachrichtigungen nach dem Meeting" + }, + "scheduled": { + "description": "Zeigt eine Benachrichtigung vor Beginn eines Meetings, basierend auf deinem Kalender.", + "label": "Geplante Meetings" + } + }, + "oauth": { + "connectingTitle": "Verbindung zu {{provider}}", + "errorFallback": "Die Autorisierung wurde nicht abgeschlossen.", + "errorTitle": "Verbindung zu {{provider}} fehlgeschlagen", + "pendingDescription": "Schließe die Autorisierung in deinem Browser ab. Dieses Fenster schließt sich automatisch, sobald der Zugriff erteilt ist.", + "tryAgain": "Erneut versuchen", + "waiting": "Warte auf Autorisierung…" + }, + "recording": { + "heading": "Aufnahme" + }, + "silence": { + "description": "Beendet die Aufnahme und startet die Verarbeitung, sobald Mikrofon und Systemton für die gewählte Dauer still waren. Praktisch, wenn du nach einem Meeting vergisst zu stoppen.", + "label": "Bei Stille automatisch stoppen", + "minutes_one": "{{count}} Minute", + "minutes_other": "{{count}} Minuten" + }, + "system": { + "heading": "System" + }, + "systemAudio": { + "description": "Nimmt beide Seiten eines Gesprächs auf. Schalte das aus, um nur dein Mikrofon aufzunehmen.", + "label": "Systemton aufnehmen", + "unknownVersion": "eine ältere Version", + "unsupported": "Nimmt beide Seiten eines Gesprächs auf (erfordert macOS 14.4+, du nutzt {{version}}). Aufnahmen nur mit Mikrofon funktionieren weiterhin." + }, + "trayIcon": { + "descriptionMac": "Zeigt ein Steno-Symbol für den schnellen Zugriff in der Menüleiste.", + "descriptionWindows": "Zeigt ein Steno-Symbol für den schnellen Zugriff im Infobereich.", + "labelMac": "In der Menüleiste anzeigen", + "labelWindows": "Im Infobereich anzeigen" + } + }, + "language": { + "de": "Deutsch", + "description": "Die Sprache, in der Stenos eigene Oberfläche erscheint. Die Sprache deiner Notizen und Transkripte ändert sich dadurch nicht.", + "en": "English", + "label": "Sprache der Oberfläche", + "partial": "teilweise übersetzt", + "system": "Systemsprache" + }, + "model": { + "default": "Standard", + "deleteAria": "Modell löschen", + "deleteTitle": "Dieses Modell löschen, um Speicherplatz freizugeben", + "deprecated": "Veraltet", + "downloading": "Wird geladen", + "fasterBuildAvailable": "Schnellerer Build verfügbar", + "fasterBuildBlocked": "Erst den laufenden Wechsel abschließen", + "fasterBuildRetry": "Erneut versuchen: zum schnelleren Build wechseln", + "fasterBuildSwitch": "Zum schnelleren Build wechseln", + "memoryWarningBadge": "Speicher könnte knapp werden", + "memoryWarningTitle": "Dieses Modell braucht womöglich mehr Speicher, als auf deinem Mac verfügbar ist, und läuft dann langsam oder schlägt fehl.", + "mlxBadge": "MLX-Modell", + "mlxTitleDirect": "Direkt als MLX-Build ({{tag}}) heruntergeladen - {{name}} wurde nie geladen", + "mlxTitleGguf": "Läuft als MLX-Build ({{tag}}) statt als {{name}}", + "part": "Teil {{part}}", + "select": "Auswählen", + "selected": "Ausgewählt", + "verifying": "Wird geprüft…" + }, + "nav": { + "about": "Über", + "advanced": "Erweitert", + "ai": "KI", + "back": "Zurück", + "developer": "Entwickler", + "general": "Allgemein", + "groupSystem": "System", + "groupWorkspace": "Arbeitsbereich", + "organisation": "Organisation", + "sections": "Einstellungsbereiche", + "templates": "Vorlagen", + "title": "Einstellungen" + }, + "organisation": { + "adapterUrl": "Adapter-URL", + "autoBackup": { + "aria": "Neue Notizen automatisch in der Organisation sichern", + "description": "Lädt jede neue Notiz nach der Zusammenfassung in das S3 deiner Organisation. Bei einzelnen Notizen kannst du die Freigabe in der Ansicht „Geteilte Notizen“ weiterhin aufheben.", + "label": "Neue Notizen automatisch sichern" + }, + "email": "E-Mail", + "intro": "Verbinde dich mit Steno Enterprise für deine Organisation.", + "or": "oder", + "org": "Org", + "password": "Passwort", + "signInWithGoogle": "Mit Google anmelden", + "signInWithPassword": "Mit Passwort anmelden", + "signOut": "Abmelden", + "signedInAs": "Angemeldet als {{name}}", + "signingIn": "Anmeldung läuft…", + "ssoHint": "Single Sign-on über das Google Workspace deiner Organisation.", + "waitingForBrowser": "Warte auf den Browser…" + }, + "templates": { + "backAria": "Zurück zu den Vorlagen", + "builtin": "Integriert", + "default": "Standard", + "defaultTitle": "Wird für neue Meetings automatisch genutzt, solange du keine andere wählst", + "deleteAria": "{{name}} löschen", + "deleteDescription": "Damit wird die Vorlage endgültig gelöscht. Bereits erzeugte Reports bleiben erhalten.", + "deleteFailed": "Vorlage konnte nicht gelöscht werden.", + "deleteTitle": "Vorlage „{{name}}“ löschen?", + "editTitle": "Vorlage bearbeiten", + "editorSubtitle": "Lege fest, wie deine Meetings zusammengefasst werden", + "intro": "Vorlagen sind die Anweisungen, denen deine KI folgt, wenn sie aus einem Transkript eine Zusammenfassung macht -", + "language": "Sprache", + "learnMore": "mehr erfahren", + "locked": "Gesperrt", + "lockedTitle": "Integrierte Vorlage - vor Bearbeitung und Löschen geschützt", + "makeDefault": "Als Standard festlegen", + "markdownSupported": "Markdown wird unterstützt", + "name": "Name", + "namePlaceholder": "z. B. Wochenmeeting, Management-Zusammenfassung...", + "new": "Neue Vorlage", + "newDescription": "Erstelle eigene Prompts, um zu bestimmen, wie deine Meetings zusammengefasst werden.", + "newTitle": "Neue Vorlage", + "noPrompt": "Kein Prompt hinterlegt.", + "promptPlaceholder": "Schreibe einen Prompt, der der KI vorgibt, wie sie die Zusammenfassung aufbauen soll...", + "reset": "Zurücksetzen", + "resetTitle": "Deine Änderungen verwerfen und zur mitgelieferten Version dieser Vorlage zurückkehren", + "save": "Vorlage speichern", + "saveFailed": "Speichern fehlgeschlagen", + "saving": "Wird gespeichert…", + "systemPrompt": "System-Prompt", + "usesStructuredFormat": "Nutzt strukturiertes Format" + } + }, + "setup": { + "actions": { + "begin": "Einrichtung starten", + "continueToApp": "Weiter zur App", + "needKeyHint": "Gib deinen API-Schlüssel ein, um fortzufahren.", + "needKeyTitle": "Zuerst den Cloud-API-Schlüssel eingeben", + "settingUp": "Einrichtung läuft..." + }, + "badge": { + "done": "Fertig", + "failed": "Fehlgeschlagen", + "running": "Läuft", + "waiting": "Wartet" + }, + "chooser": { + "cloud": "Cloud", + "cloudHint": "Schnell. Höhere Qualität. Eigener API-Schlüssel nötig.", + "local": "Lokal", + "localHint": "Privat. Kostenlos. ~2 GB Download.", + "title": "Wie soll Steno Meetings zusammenfassen?" + }, + "cloud": { + "apiKeyHint": "Wird lokal auf diesem Gerät gespeichert. Nie synchronisiert und nirgendwo hingesendet außer an den Anbieter, den du auswählst.", + "apiKeyLabel": "API-Schlüssel", + "apiUrlLabel": "API-Basis-URL", + "profileLabel": "Inferenzprofil (optional)", + "providerCustom": "Benutzerdefiniert (OpenAI-kompatibel)", + "providerLabel": "Anbieter", + "regionLabel": "AWS-Region" + }, + "debug": { + "empty": "Steno-Einrichtung\nBefehle und Ausgaben erscheinen hier...\n", + "toggle": "Debug-Konsole" + }, + "launch": { + "description": "Steno startet automatisch, wenn du dich anmeldest (versteckt in der Menüleiste). Du kannst das jederzeit in den Einstellungen ändern.", + "title": "Beim Anmelden starten" + }, + "name": { + "hint": "Nur der Vorname, für die Begrüßung in der App. Wird lokal gespeichert.", + "label": "Wie sollen wir dich nennen?", + "placeholder": "Dein Name" + }, + "progress": { + "ollamaAriaLabel": "Download-Fortschritt des Zusammenfassungsmodells", + "ollamaFallback": "Modell wird heruntergeladen...", + "transcriptionLabel": "Modell wird heruntergeladen und vorbereitet..." + }, + "reportIssue": "Problem melden", + "status": { + "checkingPermission": "Berechtigung wird geprüft...", + "checkingTranscriptionModel": "Transkriptionsmodell wird geprüft...", + "connectedTo": "Verbunden mit {{provider}}", + "downloadingModel": "Modell wird heruntergeladen (~2 GB)...", + "downloadingParakeet": "Parakeet TDT v3 wird heruntergeladen ({{size}})...", + "modelInstalled": "Modell installiert", + "permissionDeniedMac": "Berechtigung verweigert. Erteile sie in den Systemeinstellungen.", + "permissionDeniedWindows": "Berechtigung verweigert. Erteile sie unter Einstellungen > Datenschutz und Sicherheit > Mikrofon.", + "permissionGranted": "Berechtigung erteilt", + "requestingPermission": "Berechtigung wird angefragt...", + "savingCloudCredentials": "Cloud-Zugangsdaten werden gespeichert...", + "stepFailed": "Einrichtungsschritt fehlgeschlagen", + "testingConnection": "Verbindung wird getestet...", + "transcriptionModelReady": "Transkriptionsmodell bereit" + }, + "steps": { + "microphone": { + "description": "Nötig, um Meetings aufzunehmen", + "title": "Mikrofonzugriff" + }, + "summarization": { + "descriptionCloud": "Cloud-API: schnell, kein Download", + "descriptionLocal": "Lokales Modell (~2 GB): privat, läuft auf deinem Gerät", + "title": "Modell für Zusammenfassungen" + }, + "transcription": { + "description": "Wandelt Sprache lokal in Text um", + "title": "Transkriptionsmodell" + } + }, + "subtitle": "Wir richten zusammen alles ein, was Steno für deine Meetings braucht.", + "telemetry": { + "description": "Hilf mit, Steno zu verbessern - Meeting-Inhalte werden nie gesendet. Du kannst das jederzeit unter Einstellungen → Erweitert ändern.", + "title": "Anonyme Nutzungsanalyse" + }, + "title": "Willkommen bei Steno" + }, + "toast": { + "notification": { + "close": "Schließen", + "join": "Beitreten & mitschreiben" + }, + "undoDelete": { + "dismiss": "Ausblenden und endgültig löschen", + "title": "Notiz gelöscht", + "undo": "Rückgängig" + }, + "update": { + "dismiss": "Update-Hinweis ausblenden", + "ready": "Update v{{version}} bereit", + "restart": "Neu starten" + } + }, + "toolbar": { + "hideSidebar": "Seitenleiste ausblenden", + "importAudio": "Audiodatei importieren…", + "importAudioBlocked": "Stoppe die laufende Aufnahme, um eine Datei zu importieren.", + "importAudioHint": "Eine vorhandene Aufnahme transkribieren und zusammenfassen. Sie erscheint während der Verarbeitung in der Liste.", + "newChat": "Neuer Chat", + "newNote": "Neue Notiz", + "openRecording": "Laufende Aufnahme öffnen", + "paused": "Pausiert", + "recordSystemAudio": "Systemaudio aufnehmen", + "recordSystemAudioHint": "Beide Seiten von Anrufen aufnehmen. Ausschalten, um nur dein Mikrofon aufzunehmen.", + "recording": "Aufnahme", + "recordingOptions": "Aufnahmeoptionen", + "recordingOptionsHint": "Auch Deep Links und das Tray-Menü starten und stoppen Aufnahmen.", + "showSidebar": "Seitenleiste anzeigen" + }, + "transcript": { + "empty": "Kein Transkript verfügbar.", + "loading": "Wird geladen…", + "searchPlaceholder": "Transkript durchsuchen" + }, + "tray": { + "hide": "Steno ausblenden", + "open": "Steno öffnen", + "quit": "Steno beenden", + "reportBug": "Fehler melden", + "settings": "Einstellungen", + "startRecording": "Aufnahme starten", + "stopRecording": "Aufnahme stoppen", + "tooltip": "Steno", + "tooltipRecording": "Steno - Aufnahme läuft", + "version": "Steno v{{version}}" + } +} diff --git a/app/locales/en.json b/app/locales/en.json new file mode 100644 index 00000000..80b77b3d --- /dev/null +++ b/app/locales/en.json @@ -0,0 +1,1142 @@ +{ + "app": { + "actions": "Actions", + "date": { + "today": "Today", + "yesterday": "Yesterday" + }, + "dropBlocked": "Stop recording to import", + "dropToImport": "Drop audio to import", + "loading": "Loading…", + "rename": "Rename" + }, + "chat": { + "askAbout": "Ask about {{name}}", + "askAcross": "Ask across…", + "askAi": "Ask AI", + "askAria": "Ask about this meeting", + "backToChat": "Back to Chat", + "bucket": { + "last2Weeks": "Last 2 weeks", + "thisMonth": "This month", + "thisWeek": "This week", + "today": "Today", + "yesterday": "Yesterday" + }, + "chatActions": "Chat actions", + "chips": { + "actionItems": "Action items", + "keyDecisions": "Summarize key decisions", + "mainTopics": "Main topics" + }, + "collapse": "Collapse", + "composerPlaceholder": "Ask anything /", + "copyTranscript": "Copy transcript", + "deleteChat": "Delete chat {{name}}", + "emptyResponse": "(empty response)", + "failedToSend": "Failed to send", + "greeting": "Ask anything", + "greetingNamed": "Hi {{name}}, ask anything", + "hideTranscript": "Hide transcript", + "history": "History", + "mayOmitOlderNotes": "· may omit older notes", + "model": { + "auto": "Auto", + "cloud": "Cloud", + "organisation": "Organisation", + "remoteOllama": "Remote Ollama" + }, + "newChat": "New chat", + "noOtherChats": "No other chats yet.", + "noRecents": "Your past chats will show up here.", + "noSavedChats": "No saved chats yet.", + "notFound": "Chat not found.", + "notFoundHint": "This conversation may have been deleted.", + "orgScopeHint": "Cross-note chat against {{org}}'s shared notes", + "placeholderAsk": "Ask anything about this meeting…", + "placeholderContinue": "Continue chat…", + "placeholderRecording": "Chat available after recording", + "presets": "Presets", + "providerRequired": "Your AI provider isn't ready for chat yet — add a cloud API key, sign in to your Organisation, or set your remote Ollama URL in Settings → AI.", + "providerRequiredPlaceholder": "Set up an AI provider in Settings to ask across notes", + "queryFailed": "query failed", + "recents": "Recents", + "relative": { + "days": "{{n}}d", + "hours": "{{n}}h", + "minutes": "{{n}}m", + "months": "{{n}}mo", + "now": "now", + "weeks": "{{n}}w", + "years": "{{n}}y" + }, + "resume": "Resume", + "resumeAria": "Resume recording on this note", + "resumeTitle": "Resume recording — the new audio is appended to this note", + "scopeAria": "Scope: {{scope}}", + "seeAll": "See all", + "send": "Send", + "showLess": "Show less", + "showTranscript": "Show transcript", + "skills": "Skills", + "stop": "Stop", + "streamError": "Error: {{error}}", + "switchChat": "Switch chat", + "thinking": "Thinking", + "thinkingStream": "Thinking…", + "transcript": "Transcript", + "untitled": "Untitled chat" + }, + "common": { + "appName": "Steno", + "cancel": "Cancel", + "close": "Close", + "confirm": "Confirm", + "delete": "Delete", + "done": "Done", + "retry": "Retry", + "save": "Save", + "working": "Working..." + }, + "dialog": { + "filter": { + "audio": "Audio Files", + "markdown": "Markdown", + "pdf": "PDF", + "text": "Text" + } + }, + "dock": { + "changeLanguage": "Change transcript language", + "copyTranscript": "Copy transcript", + "hideTranscript": "Hide transcript", + "languageAria": "Language: {{language}}", + "languageMulti": "Multi", + "listening": "Listening…", + "listeningHint": "Start speaking — finalised sentences will appear here.", + "liveUnavailable": "Live transcription unavailable", + "minimizeTranscript": "Minimize transcript", + "noMatches": "No matches", + "noMatchesHint": "Nothing matches your filter yet.", + "paused": "Paused", + "preparing": "Preparing transcription…", + "preparingHint": "Parakeet is warming up. Audio is being captured.", + "preparingShort": "Preparing…", + "preparingSlow": "Still warming up — first launch can take a moment. Audio is being captured.", + "recording": "Recording", + "resumeRecording": "Resume recording", + "resumed": "Resumed", + "searchTranscript": "Search transcript", + "showTranscript": "Show transcript", + "stage": "Stage: {{stage}}", + "stageWithMessage": "{{stage}}: {{message}}", + "stillPreparing": "Still preparing…", + "stop": "Stop", + "stopRecording": "Stop recording", + "transcript": "Transcript", + "transcriptHeader": "Transcript header" + }, + "folders": { + "changeIcon": "Change folder icon", + "create": "Create folder", + "deleteNoMeetings": "No recordings or transcripts will be deleted.", + "deleteTitle": "Delete folder \"{{name}}\"?", + "deleteWithMeetings_one": "{{count}} meeting will be moved back to All Notes. No recordings or transcripts will be deleted.", + "deleteWithMeetings_other": "{{count}} meetings will be moved back to All Notes. No recordings or transcripts will be deleted.", + "emptyHint": "Notes you save to this folder will show up here.", + "emptyTitle": "Nothing here yet", + "loading": "Loading folder…", + "meetingCount_one": "{{count}} meeting", + "meetingCount_other": "{{count}} meetings", + "namePlaceholder": "e.g. Acme Corp", + "newFolder": "New folder", + "newFolderDescription": "Group related meetings together. Folder names are only visible to you.", + "noIconsMatch": "No icons match \"{{query}}\"", + "notFound": "Folder not found.", + "notFoundHint": "This folder may have been deleted. Back to Home.", + "notes": "Notes", + "searchIcons": "Search icons…" + }, + "hero": { + "headline": { + "clearDay": "Clear day ahead", + "inMeeting": "In a meeting now", + "nextInHours_one": "Next meeting in {{count}} hr", + "nextInHours_other": "Next meeting in {{count}} hrs", + "nextInMinutes_one": "Next meeting in {{count}} min", + "nextInMinutes_other": "Next meeting in {{count}} mins", + "paused": "Recording paused", + "processing": "Processing your note", + "ready": "Ready to capture beautiful notes", + "recording": "Recording" + }, + "recordingHint": "Start recording from the top-right, or from anywhere with {{shortcut}}.", + "subtitle": { + "inMeeting": "Press {{shortcut}} to start recording — or tap a meeting card below.", + "inProgressFallback": "In progress", + "nextSoon": "{{title}} at {{time}} — {{shortcut}} when you're ready.", + "paused": "Recording paused. Tap resume on the bar below to continue.", + "processing": "We'll have your note ready in a moment.", + "recording": "{{title}} · {{shortcut}} to stop", + "tomorrow": "Next up: {{title}} tomorrow at {{time}}." + } + }, + "home": { + "allDay": { + "toggle_one": "+ {{count}} all-day event today", + "toggle_other": "+ {{count}} all-day events today" + }, + "calendarNudge": { + "connectLabel": "Connect:", + "connecting": "Connecting to {{provider}}…", + "dismiss": "Dismiss", + "prompt": "Connect your calendar to see today's meetings." + }, + "day": { + "earlier": "Earlier", + "today": "Today", + "tomorrow": "Tomorrow", + "yesterday": "Yesterday" + }, + "duration": { + "hoursMinutes": "{{hours}}h {{minutes}}m", + "minutes": "{{minutes}}m", + "seconds": "{{seconds}}s" + }, + "empty": { + "consent": "Always get consent when transcribing others.", + "newNote": "New note", + "quickStartLabel": "Quick start:", + "quickStartSuffix": "from anywhere", + "stopRecording": "Stop recording", + "tagline": "AI for your confidential workflows.", + "title": "Welcome to Steno." + }, + "loading": "Loading meetings…", + "previousRow": { + "participants_one": "{{count}} person", + "participants_other": "{{count}} people", + "processingBadge": "Processing", + "recordingBadge": "Recording", + "untitledNote": "Untitled note" + }, + "relative": { + "days_one": "{{count}} day", + "days_other": "{{count}} days", + "hours_one": "{{count}} hr", + "hours_other": "{{count}} hrs", + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} mins", + "now": "Now" + }, + "search": { + "clear": "Clear search", + "noMatches": "No meetings match “{{query}}”.", + "placeholder": "Search notes" + }, + "sections": { + "allNotes": "All notes", + "comingUp": "Coming up", + "previous": "Previous", + "today": "Today", + "tomorrow": "Tomorrow" + }, + "upcoming": { + "nextPage": "Next", + "prevPage": "Previous", + "refresh": "Check for new calendar events" + }, + "upcomingCard": { + "allDay": "All day", + "endsIn_one": "Ends in {{count}} min", + "endsIn_other": "Ends in {{count}} mins", + "join": "Join", + "justStarted": "Just started", + "startNow": "Start now", + "startedAgo_one": "Started {{count}} min ago", + "startedAgo_other": "Started {{count}} mins ago", + "untitled": "Untitled meeting" + } + }, + "meeting": { + "action": { + "backToHome": "Back to home", + "copied": "Copied", + "copiedConfirm": "Copied!", + "copyNotes": "Copy notes", + "copyTranscript": "Copy transcript", + "deleteNote": "Delete note", + "generateNotes": "Generate notes", + "home": "Home", + "moreOptions": "More options", + "regenerateTitle": "Regenerate title", + "retranscribeRecording": "Re-transcribe recording", + "saveNotesPdf": "Save notes as PDF…", + "saveTranscriptMarkdown": "Save transcript as .md…", + "viewContainingFolder": "View containing folder" + }, + "backToMeetings": "Back to meetings", + "backup": { + "backingUp": "Backing up…", + "failed": "This note has not been backed up. Click to retry.", + "failedWithReason": "Last backup failed: {{error}}. Click to retry.", + "notBackedUp": "Not backed up" + }, + "error": { + "copyTranscript": "Couldn't copy transcript: {{error}}", + "deleteFailed": "Delete failed: {{error}}", + "generic": "Something went wrong.", + "saveNotes": "Couldn't save notes: {{error}}", + "saveTranscript": "Couldn't save transcript: {{error}}", + "unknown": "unknown error" + }, + "folder": { + "addToFolder": "Add to folder", + "namePlaceholder": "Folder name...", + "new": "New folder...", + "none": "No folder" + }, + "loadError": { + "body": "An error occurred loading this note.", + "title": "Couldn't load note." + }, + "loading": "Loading meeting…", + "noNotesYet": { + "body": "This recording was transcribed but notes were not generated automatically. Use the Generate notes button below to create them, or copy or save the transcript from the actions above.", + "title": "No notes yet" + }, + "noSummary": "No summary available for this meeting.", + "notFound": { + "body": "This recording may have been deleted. Pick another from the sidebar.", + "title": "Note not found." + }, + "notes": { + "placeholder": "Write notes…" + }, + "participantCount_one": "{{count}} person", + "participantCount_other": "{{count}} people", + "processing": { + "body": "Your transcript is captured — refining it and generating notes in the background. You can read and edit My notes now.", + "title": "Finishing up" + }, + "report": { + "deleteBody": "This permanently deletes this generated report. The transcript and other reports are not affected.", + "deleteLabel": "Delete report {{name}}", + "deleteTitle": "Delete report \"{{name}}\"?" + }, + "reprocessFailed": { + "body": "That didn’t work this time — give it another go. If it keeps failing on a long meeting, switch to a smaller model in Settings.", + "title": "Notes weren’t generated" + }, + "retranscribeDialog": { + "body": "This re-runs transcription with your current transcription settings, replacing the transcript and regenerating the summary.", + "confirm": "Re-transcribe", + "title": "Re-transcribe this recording?" + }, + "section": { + "actionItems": "Action items", + "keyPoints": "Key points", + "keyTopics": "Key topics", + "participants": "Participants", + "summary": "Summary" + }, + "share": { + "failed": "Share failed: {{error}}", + "shareWith": "Share with {{org}}", + "sharing": "Sharing…", + "unshareFrom": "Unshare from {{org}}" + }, + "stream": { + "analysing": "Analysing transcript", + "generatingNotes": "Generating notes", + "summarisingPart": "Summarising part {{step}}/{{total}}" + }, + "transcriptionFailed": { + "body": "No notes could be generated for this recording. Your audio was preserved (not deleted), so nothing was lost.", + "details": "Details: {{error}}", + "title": "Transcription failed" + }, + "unshareDialog": { + "body": "The shared copy will be removed from your organisation. Your local note stays on this device. You can re-share at any time.", + "confirm": "Unshare", + "pending": "Unsharing…", + "title": "Unshare from {{org}}?", + "yourOrg": "your org" + }, + "view": { + "chooseViewOrTemplate": "Choose view or template", + "generateFromTemplate": "Generate from template", + "generating": "Generating…", + "myNotes": "My notes", + "summary": "Summary", + "tablistLabel": "Note view" + } + }, + "menu": { + "file": "&File", + "learnMore": "Learn More", + "reportBug": "Report a Bug", + "settings": "Settings…" + }, + "nav": { + "allNotes": "All notes", + "chat": "Chat", + "folders": "Folders", + "help": "Help", + "home": "Home", + "search": "Search", + "searchNotes": "Search notes", + "settings": "Settings", + "sharedAcross": "Shared across {{org}}", + "sharedNotes": "Shared notes" + }, + "notification": { + "meetingDetected": { + "action": "Take Notes", + "title": "Meeting detected" + }, + "meetingEnded": { + "action": "Summarise", + "title": "Meeting ended" + }, + "micOnly": { + "body": "System audio could not be captured. Check Steno’s Screen & System Audio Recording access in System Settings.", + "title": "Recording mic-only" + }, + "noteReady": { + "bodyDone": "Your note has finished processing", + "bodyFailedTitled": "Steno couldn’t process \"{{title}}\".", + "bodyFailedUntitled": "Steno couldn’t process your note.", + "bodyPreserved": "Your recording was preserved — open the note for details.", + "title": "Note ready", + "titleProcessingFailed": "Processing failed", + "titleTranscriptionFailed": "Transcription failed" + }, + "preMeeting": { + "titleFallback": "Meeting starting" + }, + "recordingFailed": { + "body": "Recording couldn't start.", + "bodyWithReason": "Recording couldn’t start: {{reason}}", + "title": "Steno" + }, + "shortcuts": { + "alreadyRecording": "Recording already in progress", + "invalidUrl": "Invalid shortcut URL", + "title": "Steno Shortcuts" + }, + "silenceAutoStop": { + "bodyNamed_one": "{{sessionName}} — {{count}} minute of silence", + "bodyNamed_other": "{{sessionName}} — {{count}} minutes of silence", + "body_one": "{{count}} minute of silence — your note is being processed.", + "body_other": "{{count}} minutes of silence — your note is being processed.", + "title": "Recording stopped" + }, + "sleepPaused": { + "action": "Resume", + "body": "Paused while your computer was asleep. Resume to keep capturing.", + "title": "Recording paused" + } + }, + "org": { + "connectDescription": "Sign in to your Steno enterprise adapter to see notes shared by your colleagues and chat across them.", + "connectTitle": "Connect your organisation", + "emptyState": "No shared notes yet — share one of your meetings with {{org}} to see it here.", + "fromS3": "from S3", + "fromS3Title": "Body lives in your org's S3 bucket; the adapter fetched it server-side. Never written to this device.", + "loadingNotes": "Loading notes…", + "noBody": "(no body)", + "noteActions": "Note actions", + "noteCount_one": "{{count}} note", + "noteCount_other": "{{count}} notes", + "openSettings": "Open Settings → Organisation", + "orgLabel": "org", + "sharedBy": "shared by {{email}}", + "sharedNotes": "Shared notes", + "signIn": "Sign in to org", + "signInHint": "Sign in to share notes with your organisation", + "signOut": "Sign out", + "todayAt": "today, {{time}}", + "unshare": "Unshare", + "you": "you", + "yourOrg": "your org" + }, + "palette": { + "hintClose": "esc close", + "hintNavigate": "↑↓ navigate", + "hintOpen": "↵ open", + "noNotes": "No notes yet", + "noNotesMatch": "No notes match “{{query}}”", + "noSettings": "No settings", + "noSettingsMatch": "No settings match “{{query}}”", + "results": "Search results", + "searchNotes": "Search notes", + "searchNotesPlaceholder": "Search notes…", + "searchSettings": "Search settings", + "searchSettingsPlaceholder": "Search settings…", + "settings": { + "about": { + "sub": "Version, release notes, check for updates", + "title": "About" + }, + "aiProvider": { + "sub": "Local, private server, cloud, or organisation", + "title": "AI provider" + }, + "analytics": { + "sub": "Opt in or out", + "title": "Anonymous usage analytics" + }, + "autoDetect": { + "sub": "Notify when another app uses the microphone", + "title": "Auto-detected meetings" + }, + "autoNotes": { + "sub": "Summarise after transcription", + "title": "Generate notes automatically" + }, + "calendar": { + "sub": "Google, Outlook", + "title": "Connect calendar" + }, + "clearRecordingState": { + "sub": "Reset a stuck recording", + "title": "Clear recording state" + }, + "developer": { + "sub": "Diagnostics and logs", + "title": "Developer" + }, + "discord": { + "sub": "Join the community, ask questions, share feedback", + "title": "Discord" + }, + "dockIcon": { + "sub": "Menu bar / tray icon only", + "title": "Hide dock icon" + }, + "language": { + "sub": "Transcription and summary language", + "title": "Language" + }, + "launch": { + "sub": "Start Steno automatically", + "title": "Launch on login" + }, + "menuBar": { + "sub": "Quick-access icon in the menu bar or system tray", + "title": "Show in menu bar" + }, + "microphone": { + "sub": "Input device", + "title": "Microphone" + }, + "name": { + "sub": "In-app greeting", + "title": "Your name" + }, + "notifications": { + "sub": "Desktop notifications when notes are ready", + "title": "Post meeting notifications" + }, + "organisation": { + "sub": "Sign in and back up notes to your org", + "title": "Organisation" + }, + "saveRecordings": { + "sub": "Keep the audio files after transcription", + "title": "Save recordings" + }, + "scheduled": { + "sub": "Upcoming calendar events", + "title": "Scheduled meetings" + }, + "setupWizard": { + "sub": "Re-run first-time setup", + "title": "Setup wizard" + }, + "silence": { + "sub": "End a recording when it goes quiet", + "title": "Auto-stop on silence" + }, + "storage": { + "sub": "Where notes and recordings are saved", + "title": "Storage location" + }, + "systemAudio": { + "sub": "Capture other participants", + "title": "Record system audio" + }, + "systemTray": { + "sub": "Quick-access icon in the menu bar or system tray", + "title": "Show in system tray" + }, + "templates": { + "sub": "Custom note formats", + "title": "Templates" + }, + "theme": { + "sub": "Light, dark, or system theme", + "title": "Appearance" + }, + "transcriptionModel": { + "sub": "Parakeet or Whisper", + "title": "Transcription model" + } + }, + "untitledMeeting": "Untitled Meeting" + }, + "privacy": { + "acknowledge": "Got it", + "description": "To help find and fix failures, Steno sends anonymous usage data — never your recordings, transcripts, or notes. Steno also starts automatically when you log in. Both are on by default and you can change either one anytime in Settings.", + "launch": { + "description": "Start Steno automatically when you log in (hidden in the menu bar).", + "label": "Launch on login" + }, + "telemetry": { + "description": "Crash and usage signals only. Meeting content is never sent.", + "label": "Anonymous usage data" + }, + "title": "A quick note on privacy" + }, + "processing": { + "backToHome": "Back to home", + "chip": "Processing", + "duration": { + "hoursMinutes_one": "{{hours}} h {{count}} min", + "hoursMinutes_other": "{{hours}} h {{count}} min", + "hours_one": "{{count}} h", + "hours_other": "{{count}} h", + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "seconds_one": "{{count}} sec", + "seconds_other": "{{count}} sec" + }, + "error": { + "canRetry": "Try again to re-run processing on this recording.", + "cannotRetry": "This recording couldn’t be recovered automatically. Try importing the audio file again from Home.", + "restartFailed": "Couldn’t restart processing. Please try again.", + "retrying": "Retrying…", + "tryAgain": "Try again" + }, + "home": "Home", + "myNotes": "My notes", + "progress": { + "merging": "Merging summaries…", + "part": "Summarizing part {{step}} of {{total}}…" + }, + "stage": { + "error": "Couldn’t process this recording.", + "finalizing": "Almost done…", + "summarizing": "Generating notes", + "transcribing": "Analyzing transcript" + }, + "untitledNote": "Note" + }, + "quit": { + "processing": { + "body_one": "{{count}} recording is still being processed. Quitting will cancel processing.", + "body_other": "{{count}} recordings are still being processed. Quitting will cancel processing.", + "confirm": "Quit anyway", + "title": "Processing in progress" + }, + "recording": { + "body": "Quitting will stop and save the current recording.", + "confirm": "Stop & quit", + "title": "Recording in progress" + } + }, + "recording": { + "addToFolder": "Add to folder", + "backToHome": "Back to home", + "home": "Home", + "myNotes": "My notes", + "notesPlaceholder": "Type anything you want to capture — decisions, questions, follow-ups. Steno handles the transcript.", + "startedAt": "Started {{time}}", + "titlePlaceholder": "New note" + }, + "settings": { + "about": { + "checkFailed": "Check failed", + "checkForUpdates": "Check for Updates", + "checking": "Checking for Updates", + "discord": { + "description": "Join the community, ask questions, share feedback" + }, + "downloadFailed": "Update download failed: {{error}}", + "downloading": "Downloading update…", + "github": { + "description": "Steno is open source — browse the code, file issues" + }, + "join": "Join", + "privacy": "Privacy Policy", + "releaseNotes": { + "description": "See what's new", + "label": "Release notes" + }, + "restartToUpdate": "Restart to Update (v{{version}})", + "terms": "Terms of Service", + "upToDate": "You're on the latest version", + "version": "Version {{version}}", + "versionUpdateAvailable": "Version {{version}} — Update available (v{{latest}})", + "versionUpdateBlocked": "Version {{version}} — v{{latest}} requires a newer version of macOS", + "view": "View", + "viewRelease": "View release" + }, + "advanced": { + "anonymousId": { + "description": "Identifies this install in analytics. Useful when reporting bugs.", + "label": "Anonymous ID" + }, + "clearState": { + "clear": "Clear", + "clearing": "Clearing…", + "description": "Fix stuck recordings or processing", + "label": "Clear recording state" + }, + "copied": "Copied", + "copy": "Copy to clipboard", + "setupWizard": { + "description": "Reinstall dependencies or fix configuration", + "label": "Setup wizard", + "run": "Run" + }, + "storage": { + "choose": "Choose…", + "description": "Where your notes and recordings are saved", + "label": "Storage location", + "reset": "Reset" + }, + "telemetry": { + "description": "Help improve Steno — no meeting content is ever sent", + "label": "Anonymous usage analytics" + } + }, + "ai": { + "adapter": { + "signedIn": "Summaries, titles, and chat are routed through your organisation's adapter. The model and API key are configured by your organisation — no setup needed here.", + "signedOut": "You are not signed in to an organisation. Sign in under Settings > Organisation, or switch this provider back to Local / Private Server / Cloud API." + }, + "autoSummarize": { + "description": "Summarise each recording right after transcription. Turn off to stop at a transcript and generate notes on demand.", + "label": "Generate notes automatically" + }, + "cloud": { + "apiKeyLabel": "API key", + "apiKeyPlaceholderBedrock": "Bedrock API key (bearer token)", + "apiUrlLabel": "API base URL", + "custom": "Custom (OpenAI-compatible)", + "customOption": "Custom…", + "disclaimer": "Transcripts will be sent to a third-party cloud service. No audio files leave your device.", + "inferenceProfileLabel": "Inference profile (optional)", + "modelLabel": "Model", + "modelsAvailable_one": "{{count}} model available", + "modelsAvailable_other": "{{count}} models available", + "pickFromList": "Pick from list", + "regionLabel": "AWS region", + "selectModelPlaceholder": "Select a model", + "serviceLabel": "Service", + "testToLoadModels": "Test connection to load the list of available models." + }, + "connection": { + "connected": "Connected", + "failed": "Failed", + "test": "Test connection", + "testing": "Testing…" + }, + "engine": { + "parakeet": "Fastest — English + European languages", + "whisper": "Most accurate — 99 languages" + }, + "keepRecordings": { + "description": "Save audio files to your storage location (see Advanced) after processing. Uses 1–10 MB per minute depending on capture mode.", + "label": "Save recordings" + }, + "language": { + "description": "Auto-detects by default. Pick one to pin it.", + "label": "Language" + }, + "model": { + "description": "Which speech-to-text model transcribes your recordings.", + "label": "Model", + "loadError": "Could not load models." + }, + "models": { + "deleteDescriptionBoth": "Delete {{name}} and its faster build ({{size}}) to free up disk space? You can re-download them anytime.", + "deleteDescriptionOne": "Delete {{name}} ({{size}}) to free up disk space? You can re-download it anytime.", + "deleteFasterBuildDescription": "{{name}} ({{size}}) is no longer needed now that the faster build is active. Delete it to free up disk space?", + "deleteTitle": "Delete model?", + "hideDeprecated": "Hide deprecated models", + "loading": "Loading models…", + "none": "No models available.", + "ollamaUnreachable": "Could not reach Ollama. Run the setup wizard.", + "qualityNote": "{{value}} quality", + "showDeprecated": "Show deprecated models", + "speedNote": "{{value}} speed", + "unknownSize": "unknown size" + }, + "provider": { + "adapter": "Organisation", + "adapterDescription": "Uses your organisation's AI key. No setup needed.", + "adapterDisabledDescription": "Sign in to your organisation to enable this option.", + "cloud": "Cloud API", + "cloudDescription": "Use OpenAI, Anthropic, or a compatible API. Best quality, requires a paid key.", + "description": "Where models run. Local keeps all data on your device.", + "label": "AI provider", + "local": "Local (on-device)", + "localDescription": "Runs entirely on your device. Private and free, no internet required.", + "orgManaged": "Managed by your organisation while you're signed in. Sign out under Settings > Organisation to change it.", + "remote": "Private Server", + "remoteDescription": "Connect to your own Ollama server. Data stays within your network." + }, + "remote": { + "urlLabel": "Ollama server URL" + }, + "summarisation": { + "heading": "Summarisation & Chat", + "intro": "Turns your transcript into notes and answers your questions. This is the one step that can run locally or in the cloud — if you choose a cloud provider, only the text transcript is sent, never audio." + }, + "summaryModel": { + "description": "Which model generates your summaries, titles, and chat answers.", + "label": "Model" + }, + "transcription": { + "heading": "Transcription", + "intro": "Speech-to-text always runs on your device — your audio never leaves your computer." + } + }, + "developer": { + "clear": "Clear", + "console": { + "description": "Real-time log output from backend processes.", + "label": "Debug console" + }, + "copy": "Copy", + "placeholder": "Steno debug console\nSession started — waiting for activity…\n", + "save": "Save", + "saveFailed": "Couldn't save diagnostics: {{error}}", + "unknownError": "unknown error" + }, + "general": { + "appearance": { + "dark": "Dark", + "description": "Choose light, dark, or match your system", + "label": "Appearance", + "light": "Light", + "system": "System" + }, + "autoInstall": { + "description": "When the app is idle and not recording, download and install updates in the background, then restart. You'll still be notified when an update is available.", + "label": "Install updates automatically" + }, + "bothIconsHidden": "Both your dock icon and menu bar icon will be hidden. Reopen Steno from Applications or Spotlight to bring the window back.", + "calendar": { + "connected": "Connected to {{account}}", + "description": "Show upcoming meetings on the home screen", + "disconnect": "Disconnect", + "heading": "Calendar", + "label": "Connect calendar" + }, + "dockIcon": { + "description": "Run as menu bar app only", + "label": "Hide dock icon" + }, + "launchOnLogin": { + "description": "Start Steno automatically when you log in, hidden in the menu bar. Turn off to launch it manually.", + "label": "Launch on login" + }, + "microphone": { + "description": "Which input device Steno records from. Pins your choice so the OS switching its default (e.g. AirPods connecting) doesn't silently change what gets recorded. Applies the next time you start a recording.", + "label": "Microphone", + "numbered": "Microphone {{index}}", + "systemDefault": "System Default", + "unknownDevice": "Unknown device (disconnected)" + }, + "name": { + "description": "First name only — used for in-app greetings. Stored locally.", + "label": "Your name", + "placeholder": "Your name" + }, + "notifications": { + "autoDetected": { + "description": "Watch for other apps using your microphone and notify you when a call starts, with a one-click button to record.", + "label": "Auto-detected meetings", + "unsupported": "Watch for other apps using your microphone and notify you when a call starts. Requires macOS 14 (Sonoma) or later.", + "unsupportedVersion": "Watch for other apps using your microphone and notify you when a call starts. Requires macOS 14 (Sonoma) or later, you're on {{version}}." + }, + "heading": "Meeting notifications", + "post": { + "description": "Notify when your notes are ready or a recording auto-stops from silence.", + "label": "Post meeting notifications" + }, + "scheduled": { + "description": "Show a notification before meetings start, based on your calendar.", + "label": "Scheduled meetings" + } + }, + "oauth": { + "connectingTitle": "Connecting to {{provider}}", + "errorFallback": "The authorization flow did not complete.", + "errorTitle": "Couldn't connect to {{provider}}", + "pendingDescription": "Complete the authorization in your browser. This dialog will close automatically once access is granted.", + "tryAgain": "Try again", + "waiting": "Waiting for authorization…" + }, + "recording": { + "heading": "Recording" + }, + "silence": { + "description": "End the recording and start processing it once both the mic and system audio have been silent for the chosen duration. Useful when you forget to stop after a meeting ends.", + "label": "Auto-stop on silence", + "minutes_one": "{{count}} minute", + "minutes_other": "{{count}} minutes" + }, + "system": { + "heading": "System" + }, + "systemAudio": { + "description": "Capture both sides of a call. Turn off to record your mic only.", + "label": "Record system audio", + "unknownVersion": "an older version", + "unsupported": "Capture both sides of a call (requires macOS 14.4+, you're on {{version}}). Mic-only recording still works." + }, + "trayIcon": { + "descriptionMac": "Show a Steno icon in the menu bar for quick access.", + "descriptionWindows": "Show a Steno icon in the system tray for quick access.", + "labelMac": "Show in menu bar", + "labelWindows": "Show in system tray" + } + }, + "language": { + "de": "Deutsch", + "description": "The language Steno's own interface is shown in. This does not change the language of your notes or transcripts.", + "en": "English", + "label": "Interface language", + "partial": "partly translated", + "system": "System default" + }, + "model": { + "default": "Default", + "deleteAria": "Delete model", + "deleteTitle": "Delete this model to free up disk space", + "deprecated": "Deprecated", + "downloading": "Downloading", + "fasterBuildAvailable": "Faster build available", + "fasterBuildBlocked": "Finish the current switch first", + "fasterBuildRetry": "Retry: switch to faster build", + "fasterBuildSwitch": "Switch to faster build", + "memoryWarningBadge": "May exceed memory", + "memoryWarningTitle": "This model may exceed your Mac's available memory and could run slowly or fail.", + "mlxBadge": "MLX model", + "mlxTitleDirect": "Downloaded directly as the MLX build ({{tag}}) -- {{name}} was never pulled", + "mlxTitleGguf": "Running the MLX build ({{tag}}) instead of {{name}}", + "part": "Part {{part}}", + "select": "Select", + "selected": "Selected", + "verifying": "Verifying…" + }, + "nav": { + "about": "About", + "advanced": "Advanced", + "ai": "AI", + "back": "Back", + "developer": "Developer", + "general": "Preferences", + "groupSystem": "System", + "groupWorkspace": "Workspace", + "organisation": "Organisation", + "sections": "Settings sections", + "templates": "Templates", + "title": "Settings" + }, + "organisation": { + "adapterUrl": "Adapter URL", + "autoBackup": { + "aria": "Auto-back up new notes to org", + "description": "Push every new note to your org's S3 once summarisation finishes. You can still unshare individual notes from the Shared notes view.", + "label": "Auto-back up new notes" + }, + "email": "Email", + "intro": "Connect to Steno Enterprise for your organisation.", + "or": "or", + "org": "org", + "password": "Password", + "signInWithGoogle": "Sign in with Google", + "signInWithPassword": "Sign in with password", + "signOut": "Sign out", + "signedInAs": "Signed in as {{name}}", + "signingIn": "Signing in…", + "ssoHint": "Single sign-on via your organisation's Google Workspace.", + "waitingForBrowser": "Waiting for browser…" + }, + "templates": { + "backAria": "Back to templates", + "builtin": "Built-in", + "default": "Default", + "defaultTitle": "Used automatically for new meetings unless you pick a different one", + "deleteAria": "Delete {{name}}", + "deleteDescription": "This permanently deletes the template. Reports already generated from it are not affected.", + "deleteFailed": "Failed to delete template.", + "deleteTitle": "Delete template \"{{name}}\"?", + "editTitle": "Edit template", + "editorSubtitle": "Configure how your meetings should be summarized", + "intro": "Templates are the instructions your AI follows when turning a transcript into a summary —", + "language": "Language", + "learnMore": "learn more", + "locked": "Locked", + "lockedTitle": "Built-in template — protected from editing and deletion", + "makeDefault": "Make Default", + "markdownSupported": "Markdown supported", + "name": "Name", + "namePlaceholder": "e.g. Weekly Sync, Executive Summary...", + "new": "New Template", + "newDescription": "Create custom prompts to tailor how your meetings are summarised.", + "newTitle": "New template", + "noPrompt": "No prompt provided.", + "promptPlaceholder": "Write a prompt instructing the AI how to structure the meeting summary...", + "reset": "Reset", + "resetTitle": "Discard your edits and revert to Steno's shipped version of this template", + "save": "Save Template", + "saveFailed": "Save failed", + "saving": "Saving…", + "systemPrompt": "System Prompt", + "usesStructuredFormat": "Uses structured format" + } + }, + "setup": { + "actions": { + "begin": "Begin setup", + "continueToApp": "Continue to app", + "needKeyHint": "Enter your API key to continue.", + "needKeyTitle": "Enter your cloud API key first", + "settingUp": "Setting up..." + }, + "badge": { + "done": "Done", + "failed": "Failed", + "running": "Running", + "waiting": "Waiting" + }, + "chooser": { + "cloud": "Cloud", + "cloudHint": "Fast. Higher quality. Bring your own API key.", + "local": "Local", + "localHint": "Private. Free. ~2 GB download.", + "title": "How should Steno summarize meetings?" + }, + "cloud": { + "apiKeyHint": "Stored locally on this device. Never synced or sent anywhere except the provider you select.", + "apiKeyLabel": "API key", + "apiUrlLabel": "API base URL", + "profileLabel": "Inference profile (optional)", + "providerCustom": "Custom (OpenAI-compatible)", + "providerLabel": "Provider", + "regionLabel": "AWS region" + }, + "debug": { + "empty": "Steno Setup\nCommands and output will appear here...\n", + "toggle": "Debug console" + }, + "launch": { + "description": "Start Steno automatically when you log in (hidden in the menu bar). You can change this any time in Settings.", + "title": "Launch on login" + }, + "name": { + "hint": "First name only — used for in-app greetings. Stored locally.", + "label": "What should we call you?", + "placeholder": "Your name" + }, + "progress": { + "ollamaAriaLabel": "Summarization model download progress", + "ollamaFallback": "Downloading model...", + "transcriptionLabel": "Downloading and preparing model..." + }, + "reportIssue": "Report an issue", + "status": { + "checkingPermission": "Checking permission...", + "checkingTranscriptionModel": "Checking transcription model...", + "connectedTo": "Connected to {{provider}}", + "downloadingModel": "Downloading model (~2 GB)...", + "downloadingParakeet": "Downloading Parakeet TDT v3 ({{size}})...", + "modelInstalled": "Model installed", + "permissionDeniedMac": "Permission denied. Grant it in System Settings.", + "permissionDeniedWindows": "Permission denied. Grant it in Settings > Privacy & security > Microphone.", + "permissionGranted": "Permission granted", + "requestingPermission": "Requesting permission...", + "savingCloudCredentials": "Saving cloud credentials...", + "stepFailed": "Setup step failed", + "testingConnection": "Testing connection...", + "transcriptionModelReady": "Transcription model ready" + }, + "steps": { + "microphone": { + "description": "Required for recording meetings", + "title": "Microphone Access" + }, + "summarization": { + "descriptionCloud": "Cloud API — fast, no download", + "descriptionLocal": "Local model (~2 GB) — private, runs on your device", + "title": "Summarization Engine" + }, + "transcription": { + "description": "Converts speech to text locally", + "title": "Transcription Model" + } + }, + "subtitle": "We'll help you set up everything needed for meeting intelligence.", + "telemetry": { + "description": "Help improve Steno — meeting content is never sent. You can change this any time in Settings → Advanced.", + "title": "Anonymous usage analytics" + }, + "title": "Welcome to Steno" + }, + "toast": { + "notification": { + "close": "Close", + "join": "Join & take notes" + }, + "undoDelete": { + "dismiss": "Dismiss and delete permanently", + "title": "Note deleted", + "undo": "Undo" + }, + "update": { + "dismiss": "Dismiss update notification", + "ready": "Update v{{version}} ready", + "restart": "Restart" + } + }, + "toolbar": { + "hideSidebar": "Hide sidebar", + "importAudio": "Import audio file…", + "importAudioBlocked": "Stop the current recording to import a file.", + "importAudioHint": "Transcribe and summarise an existing recording. It will appear in the list while it processes.", + "newChat": "New chat", + "newNote": "New note", + "openRecording": "Open recording in progress", + "paused": "Paused", + "recordSystemAudio": "Record system audio", + "recordSystemAudioHint": "Capture both sides of calls. Turn off to record your mic only.", + "recording": "Recording", + "recordingOptions": "Recording options", + "recordingOptionsHint": "Deep links and the tray menu also start and stop recording.", + "showSidebar": "Show sidebar" + }, + "transcript": { + "empty": "No transcript available.", + "loading": "Loading…", + "searchPlaceholder": "Search transcript" + }, + "tray": { + "hide": "Hide Steno", + "open": "Open Steno", + "quit": "Quit Steno", + "reportBug": "Report a Bug", + "settings": "Settings", + "startRecording": "Start Recording", + "stopRecording": "Stop Recording", + "tooltip": "Steno", + "tooltipRecording": "Steno - Recording", + "version": "Steno v{{version}}" + } +} diff --git a/app/main.js b/app/main.js index f8b9df6b..893822df 100644 --- a/app/main.js +++ b/app/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, Tray, Menu, nativeImage, powerMonitor, net, session } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, Tray, Menu, nativeImage, powerMonitor, net, session, webContents } = require('electron'); // safeStorage is accessed lazily via getSafeStorage(), NOT destructured from the // require above. On macOS, merely retrieving the safeStorage binding at load @@ -102,6 +102,7 @@ const { EXPORT_CANCELED } = require('./ipc-sentinels'); const { PostHog } = require('posthog-node'); const { initMain } = require('electron-audio-loopback'); const { autoUpdater } = require('electron-updater'); +const i18n = require('./i18n'); // E2E test-harness hooks. Set via env vars; production sees none of these. // STENOAI_USER_DATA_DIR — per-test temp userData dir (must be set before app.whenReady) @@ -328,6 +329,9 @@ class Notification extends EventEmitter { contextIsolation: true, sandbox: true, preload: path.join(__dirname, 'preload.js'), + // Same bootstrap as the main window — the toast is its own renderer and + // would otherwise start in English regardless of the chosen language. + additionalArguments: [`--stenoai-ui-language=${resolvedUiLanguage}`], }, }); notificationWindow = win; @@ -574,7 +578,7 @@ async function showShortcutNotification(body) { } const notif = new Notification({ - title: 'Steno Shortcuts', + title: i18n.t('notification.shortcuts.title'), body }); trackNotificationLifecycle(notif, 'shortcut'); @@ -624,7 +628,7 @@ async function handleShortcutUrl(incomingUrl) { if (parsedAction.type === 'invalid') { sendDebugLog(`Ignored invalid shortcut URL (${parsedAction.reason}): ${safeShortcutUrl}`); - await showShortcutNotification('Invalid shortcut URL'); + await showShortcutNotification(i18n.t('notification.shortcuts.invalidUrl')); launchedByShortcut = false; return; } @@ -634,7 +638,7 @@ async function handleShortcutUrl(incomingUrl) { if (parsedAction.type === 'start') { if (recording) { - await showShortcutNotification('Recording already in progress'); + await showShortcutNotification(i18n.t('notification.shortcuts.alreadyRecording')); launchedByShortcut = false; return; } @@ -1511,6 +1515,12 @@ function createWindow(options = {}) { sandbox: true, preload: path.join(__dirname, 'preload.js'), scrollBounce: true, + // Hand the resolved UI language over as a launch argument rather than + // making the renderer ask for it. An async IPC round trip would land + // after the first paint, which is exactly the flash of English this + // avoids; process.argv is readable synchronously even in a sandboxed + // preload. + additionalArguments: [`--stenoai-ui-language=${resolvedUiLanguage}`], }, // Windows/Linux render the Electron application menu as an in-window menu // bar (File/Edit/View/…); macOS puts it in the global bar. Hide it off-mac @@ -1603,6 +1613,119 @@ function createWindow(options = {}) { }); } +// The concrete language tag in force ('en' | 'de'), as opposed to the stored +// preference, which may be the 'system' sentinel. Resolved once at startup and +// again on every switch; handed to each renderer as a launch argument. +let resolvedUiLanguage = i18n.FALLBACK_UI_LANGUAGE; + +/* + * The live-switch sequence, in this order for a reason: + * + * main changeLanguage → rebuild menu → rebuild tray → tell every renderer + * + * The two native menus are snapshots, so they need an explicit rebuild that + * changeLanguage() will not do for them. Renderers come last because they + * repaint fast and a stale menu next to a fresh UI is the more visible seam. + * Every open WebContents is notified, not just the main window — the + * notification toast lives in its own window and would otherwise keep the old + * language until it was next recreated. + * + * Persistence is the caller's job: it goes through the Python config so the + * atomic/locked write is not duplicated in JS. + */ +async function applyUiLanguage(storedPreference) { + // Takes the stored preference, not a concrete tag, so the 'system' sentinel + // is re-resolved against the OS here rather than by every caller. + resolvedUiLanguage = i18n.resolveUiLanguage( + storedPreference, + app.getPreferredSystemLanguages() + ); + await i18n.changeMainLanguage(resolvedUiLanguage); + buildAppMenu(); + updateTrayMenu(); + if (tray) { + const isRecording = currentRecordingProcess !== null || systemAudioRecordingActive; + tray.setToolTip(isRecording ? i18n.t('tray.tooltipRecording') : i18n.t('tray.tooltip')); + } + for (const wc of webContents.getAllWebContents()) { + try { + if (!wc.isDestroyed()) wc.send('ui-language-changed', resolvedUiLanguage); + } catch (_) { + // A window torn down mid-broadcast is not an error worth surfacing. + } + } + return resolvedUiLanguage; +} + +/* + * Application menu. macOS uses the global menu bar with mac-only roles + * (services/hide/unhide). Windows/Linux get a slimmer, platform-correct menu — + * kept (editing accelerators, Settings, Help) but hidden by default via + * autoHideMenuBar so it doesn't clash with the app's custom toolbar; Alt + * reveals it (standard Windows behaviour). + * + * A built menu is a snapshot, not a live view: changeLanguage() alone will not + * relabel it. So this is a function rather than the inline block it used to be, + * and applyUiLanguage() calls it again after a language switch. Role-based + * items are deliberately left on their roles — Electron supplies the + * platform-appropriate localised label for those itself, better than we could. + */ +function buildAppMenu() { + const settingsItem = { + label: i18n.t('menu.settings'), + accelerator: 'CmdOrCtrl+,', + click: () => { + showAndFocusWindow(); + if (mainWindow) { + mainWindow.webContents.send('tray-open-settings'); + } + } + }; + const helpSubmenu = { + role: 'help', + submenu: [ + { label: i18n.t('menu.learnMore'), click: () => shell.openExternal('https://github.com/ruzin/stenoai') }, + { label: i18n.t('menu.reportBug'), click: () => shell.openExternal('https://discord.gg/DZ6vcQnxxu') } + ] + }; + const appMenu = Menu.buildFromTemplate( + process.platform === 'darwin' + ? [ + { + // Custom appMenu to add Settings… with the conventional ⌘, + // shortcut (the default `{ role: 'appMenu' }` omits Settings). + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + settingsItem, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }, + { role: 'fileMenu' }, + { role: 'editMenu' }, + { role: 'viewMenu' }, + { role: 'windowMenu' }, + helpSubmenu + ] + : [ + { label: i18n.t('menu.file'), submenu: [settingsItem, { type: 'separator' }, { role: 'quit' }] }, + { role: 'editMenu' }, + { role: 'viewMenu' }, + { role: 'windowMenu' }, + helpSubmenu + ] + ); + Menu.setApplicationMenu(appMenu); +} + function getTrayIconPath(recording) { const iconName = recording ? 'trayIconRecordingTemplate' : 'trayIconTemplate'; if (app.isPackaged) { @@ -1615,7 +1738,7 @@ function createTray() { const icon = nativeImage.createFromPath(getTrayIconPath(false)); icon.setTemplateImage(true); tray = new Tray(icon); - tray.setToolTip('Steno'); + tray.setToolTip(i18n.t('tray.tooltip')); updateTrayMenu(); } @@ -1625,7 +1748,7 @@ function updateTrayIcon(recording) { const icon = nativeImage.createFromPath(getTrayIconPath(recording)); icon.setTemplateImage(true); tray.setImage(icon); - tray.setToolTip(recording ? 'Steno - Recording' : 'Steno'); + tray.setToolTip(recording ? i18n.t('tray.tooltipRecording') : i18n.t('tray.tooltip')); updateTrayMenu(); } @@ -1644,11 +1767,11 @@ function updateTrayMenu() { const contextMenu = Menu.buildFromTemplate([ { - label: 'Open Steno', + label: i18n.t('tray.open'), click: showAndFocusWindow }, { - label: isRecording ? 'Stop Recording' : 'Start Recording', + label: isRecording ? i18n.t('tray.stopRecording') : i18n.t('tray.startRecording'), click: () => { if (mainWindow) { mainWindow.webContents.send(isRecording ? 'tray-stop-recording' : 'tray-start-recording'); @@ -1656,7 +1779,7 @@ function updateTrayMenu() { } }, { - label: 'Settings', + label: i18n.t('tray.settings'), click: () => { showAndFocusWindow(); if (mainWindow) { @@ -1665,25 +1788,25 @@ function updateTrayMenu() { } }, { - label: 'Hide Steno', + label: i18n.t('tray.hide'), click: () => { if (mainWindow) mainWindow.hide(); } }, { type: 'separator' }, { - label: `Steno v${appVersion}`, + label: i18n.t('tray.version', { version: appVersion }), enabled: false }, { - label: 'Report a Bug', + label: i18n.t('tray.reportBug'), click: () => { shell.openExternal('https://discord.gg/DZ6vcQnxxu'); } }, { type: 'separator' }, { - label: 'Quit Steno', + label: i18n.t('tray.quit'), click: () => { app.quit(); } @@ -1891,64 +2014,45 @@ if (!gotSingleInstanceLock) { console.warn('processing-log init failed (non-fatal):', e?.message); } - // Application menu. macOS uses the global menu bar with mac-only roles - // (services/hide/unhide). Windows/Linux get a slimmer, platform-correct - // menu — kept (editing accelerators, Settings, Help) but hidden by default - // via autoHideMenuBar so it doesn't clash with the app's custom toolbar; - // Alt reveals it (standard Windows behaviour). - const settingsItem = { - label: 'Settings…', - accelerator: 'CmdOrCtrl+,', - click: () => { - showAndFocusWindow(); - if (mainWindow) { - mainWindow.webContents.send('tray-open-settings'); - } + // Resolve the UI language before anything user-visible is built. The menu + // below and the tray are snapshots taken in whatever language is active at + // build time, and the window created further down carries the resolved tag + // to the renderer as a launch argument — so this has to happen first, or + // the first paint is English and then flips. + // + // getPreferredSystemLanguages() honours the user's *ordered* language list + // rather than a regional-format locale, and is only meaningful after ready. + try { + // E2E pins the whole suite to English: ~108 Playwright locators match on + // visible text, so a German run would fail most of them for reasons that + // have nothing to do with what they test. Gated on IS_E2E so it cannot be + // reached in production, and set via env rather than config.json so it + // does not fight the specs that deliberately seed their own config. + const stored = + IS_E2E && process.env.STENOAI_UI_LANGUAGE + ? process.env.STENOAI_UI_LANGUAGE + : i18n.readStoredUiLanguage(getUserDataDir()); + resolvedUiLanguage = i18n.resolveUiLanguage(stored, app.getPreferredSystemLanguages()); + await i18n.initMainI18n(resolvedUiLanguage); + processingLog.logLine('app', `ui-language stored=${stored} resolved=${resolvedUiLanguage}`); + } catch (e) { + // Only reachable if en.json itself is missing or unparseable — a broken + // de.json degrades to English inside loadResources() and never lands here. + // + // Launching beats not launching, but be honest about the state: with no + // resources loaded, t() returns the key, so the menu and tray render as + // "menu.settings" / "tray.open". That is a build defect, not a runtime + // condition — locale-completeness.test.js fails the suite before such a + // build could ship — so it is logged loudly rather than papered over. + console.error('ui i18n init failed — native chrome will show raw keys:', e?.message); + try { + processingLog.logLine('app', `ui-language init FAILED: ${e?.message}`); + } catch (_) { + /* the diagnostic log is best-effort */ } - }; - const helpSubmenu = { - role: 'help', - submenu: [ - { label: 'Learn More', click: () => shell.openExternal('https://github.com/ruzin/stenoai') }, - { label: 'Report a Bug', click: () => shell.openExternal('https://discord.gg/DZ6vcQnxxu') } - ] - }; - const appMenu = Menu.buildFromTemplate( - process.platform === 'darwin' - ? [ - { - // Custom appMenu to add Settings… with the conventional ⌘, - // shortcut (the default `{ role: 'appMenu' }` omits Settings). - label: app.name, - submenu: [ - { role: 'about' }, - { type: 'separator' }, - settingsItem, - { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' } - ] - }, - { role: 'fileMenu' }, - { role: 'editMenu' }, - { role: 'viewMenu' }, - { role: 'windowMenu' }, - helpSubmenu - ] - : [ - { label: '&File', submenu: [settingsItem, { type: 'separator' }, { role: 'quit' }] }, - { role: 'editMenu' }, - { role: 'viewMenu' }, - { role: 'windowMenu' }, - helpSubmenu - ] - ); - Menu.setApplicationMenu(appMenu); + } + + buildAppMenu(); if (process.platform === 'darwin') { try { @@ -2484,7 +2588,7 @@ ipcMain.handle('select-audio-file', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: [ - { name: 'Audio Files', extensions: IMPORT_AUDIO_EXTENSIONS } + { name: i18n.t('dialog.filter.audio'), extensions: IMPORT_AUDIO_EXTENSIONS } ] }); @@ -3700,8 +3804,8 @@ ipcMain.handle('export-transcript', async (event, defaultFilename, content) => { const result = await dialog.showSaveDialog(mainWindow, { defaultPath: suggested, filters: [ - { name: 'Markdown', extensions: ['md'] }, - { name: 'Text', extensions: ['txt'] }, + { name: i18n.t('dialog.filter.markdown'), extensions: ['md'] }, + { name: i18n.t('dialog.filter.text'), extensions: ['txt'] }, ], }); if (result.canceled || !result.filePath) { @@ -3822,7 +3926,7 @@ ipcMain.handle('export-note-pdf', async (event, defaultFilename, html) => { : 'notes.pdf'; const result = await dialog.showSaveDialog(mainWindow, { defaultPath: suggested, - filters: [{ name: 'PDF', extensions: ['pdf'] }], + filters: [{ name: i18n.t('dialog.filter.pdf'), extensions: ['pdf'] }], }); if (result.canceled || !result.filePath) { return { success: false, error: EXPORT_CANCELED }; @@ -3877,7 +3981,7 @@ ipcMain.handle('save-diagnostics', async (event, defaultFilename, content) => { : 'stenoai-diagnostics.txt'; const result = await dialog.showSaveDialog(mainWindow, { defaultPath: suggested, - filters: [{ name: 'Text', extensions: ['txt'] }], + filters: [{ name: i18n.t('dialog.filter.text'), extensions: ['txt'] }], }); if (result.canceled || !result.filePath) { return { success: false, error: EXPORT_CANCELED }; @@ -5945,12 +6049,12 @@ function showSleepPausedNotification() { // convenience notifications (meeting detected, note ready), this one is // state-critical — the user believes they're capturing and they are not. const notif = new Notification({ - title: 'Recording paused', - body: 'Paused while your computer was asleep. Resume to keep capturing.', + title: i18n.t('notification.sleepPaused.title'), + body: i18n.t('notification.sleepPaused.body'), iconType: 'alert', // The Resume action button is always rendered by the custom toast (both // platforms); the click handler below covers a body tap as well. - actions: [{ type: 'button', text: 'Resume' }], + actions: [{ type: 'button', text: i18n.t('notification.sleepPaused.action') }], }); const resume = () => { sleepPausedNotif = null; @@ -6612,9 +6716,9 @@ function showMeetingDetectedNotification(appName, originatingEvt, calEvent) { // dropdown instead of showing the action inline. Leaving closeButtonText // unset gives us Granola's layout — single inline button to the right. const notif = new Notification({ - title: 'Meeting detected', + title: i18n.t('notification.meetingDetected.title'), body: calEvent?.title || appName, - actions: [{ type: 'button', text: 'Take Notes' }], + actions: [{ type: 'button', text: i18n.t('notification.meetingDetected.action') }], }); const trigger = () => requestAutoRecord(appName, originatingEvt, calEvent); notif.on('action', (_evt, _index) => trigger()); // shown when banner style = Alerts @@ -6625,9 +6729,9 @@ function showMeetingDetectedNotification(appName, originatingEvt, calEvent) { function showMeetingEndedNotification(appName) { const notif = new Notification({ - title: 'Meeting ended', + title: i18n.t('notification.meetingEnded.title'), body: appName, - actions: [{ type: 'button', text: 'Summarise' }], + actions: [{ type: 'button', text: i18n.t('notification.meetingEnded.action') }], }); // Only the explicit Summarise button commits — body click just opens // Steno so the user can decide (summarise / resume / leave paused) from @@ -7781,7 +7885,13 @@ ipcMain.handle('pull-parakeet-model', async (event, modelId) => { // behavior are identical to the inline handlers this replaces. Settings-shaped // handlers coupled to another domain (telemetry, models, mic-monitor, calendar, // tray) deliberately stay in main.js until that domain's own extraction. -registerSettingsIpc({ ipcMain, runPythonScript, sendDebugLog }); +registerSettingsIpc({ + ipcMain, + runPythonScript, + sendDebugLog, + applyUiLanguage, + currentUiLanguage: () => resolvedUiLanguage, +}); // Fired by the renderer's silence detector. The renderer has already // asked main to stop the recording via pause/stop; this just surfaces @@ -7802,10 +7912,10 @@ ipcMain.handle('show-silence-auto-stop-notification', async (_event, payload) => const minutes = typeof payload === 'number' ? payload : payload?.minutes; const sessionName = typeof payload === 'object' ? payload?.sessionName : null; const body = sessionName - ? `${sessionName} — ${minutes} minutes of silence` - : `${minutes} minutes of silence — your note is being processed.`; + ? i18n.t('notification.silenceAutoStop.bodyNamed', { count: minutes, sessionName }) + : i18n.t('notification.silenceAutoStop.body', { count: minutes }); const notif = new Notification({ - title: 'Recording stopped', + title: i18n.t('notification.silenceAutoStop.title'), body, iconType: 'recording', }); @@ -7833,8 +7943,8 @@ ipcMain.handle('show-system-audio-mic-only-notification', async () => { try { if (!(await notificationsEnabled())) return { success: true, shown: false }; const notif = new Notification({ - title: 'Recording mic-only', - body: 'System audio could not be captured. Check Steno’s Screen & System Audio Recording access in System Settings.', + title: i18n.t('notification.micOnly.title'), + body: i18n.t('notification.micOnly.body'), iconType: 'alert', }); notif.on('click', () => { @@ -7877,15 +7987,17 @@ ipcMain.handle('show-note-ready-notification', async (_event, payload) => { // - otherwise: the note is genuinely ready. const notif = new Notification({ title: hardFailure - ? 'Processing failed' + ? i18n.t('notification.noteReady.titleProcessingFailed') : failed - ? 'Transcription failed' - : 'Note ready', + ? i18n.t('notification.noteReady.titleTranscriptionFailed') + : i18n.t('notification.noteReady.title'), body: hardFailure - ? `Steno couldn't process ${title ? `"${title}"` : 'your note'}.` + ? (title + ? i18n.t('notification.noteReady.bodyFailedTitled', { title }) + : i18n.t('notification.noteReady.bodyFailedUntitled')) : failed - ? 'Your recording was preserved — open the note for details.' - : (title || 'Your note has finished processing'), + ? i18n.t('notification.noteReady.bodyPreserved') + : (title || i18n.t('notification.noteReady.bodyDone')), iconType: (hardFailure || failed) ? 'alert' : 'success', }); notif.on('click', () => { @@ -8902,8 +9014,8 @@ function showRecordingFailedNotification(body) { try { if (!Notification.isSupported()) return; new Notification({ - title: 'Steno', - body: body || "Recording couldn't start.", + title: i18n.t('notification.recordingFailed.title'), + body: body || i18n.t('notification.recordingFailed.body'), iconType: 'alert', }).show(); } catch (error) { @@ -8914,7 +9026,9 @@ function showRecordingFailedNotification(body) { ipcMain.on('recording-capture-error', (_event, message) => { sendDebugLog(`[sysaudio] capture error: ${message}`); showRecordingFailedNotification( - message ? `Recording couldn't start: ${message}` : "Recording couldn't start.", + message + ? i18n.t('notification.recordingFailed.bodyWithReason', { reason: message }) + : i18n.t('notification.recordingFailed.body'), ); }); @@ -10972,7 +11086,7 @@ async function firePreMeetingNotification(event) { return false; } - const notif = new Notification({ title: event.title || 'Meeting starting' }); + const notif = new Notification({ title: event.title || i18n.t('notification.preMeeting.titleFallback') }); // The pre-meeting toast carries a richer payload (time / meeting URL / // attendees) and keeps its legacy renderer-side handlers (Join & take notes, // focus-on-body-tap) plus its own click/dismiss analytics. `premeeting: true` diff --git a/app/package-lock.json b/app/package-lock.json index 5bab0517..4fb34cb6 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "stenoai", - "version": "0.6.3", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stenoai", - "version": "0.6.3", + "version": "0.6.5", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -24,10 +24,12 @@ "clsx": "^2.1.1", "electron-audio-loopback": "^1.0.6", "electron-updater": "^6.8.3", + "i18next": "^26.3.6", "lucide-react": "^1.8.0", "posthog-node": "^4.18.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "react-router-dom": "^7.14.2", "tailwind-merge": "^3.5.0", @@ -356,7 +358,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7399,6 +7400,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7458,6 +7468,34 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -10408,6 +10446,33 @@ "react": "^19.2.6" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -12198,7 +12263,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12424,6 +12489,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", diff --git a/app/package.json b/app/package.json index 46f3463d..fab7ba27 100644 --- a/app/package.json +++ b/app/package.json @@ -19,7 +19,7 @@ "typecheck:renderer": "tsc -p renderer/tsconfig.json --noEmit", "lint:renderer": "eslint --config renderer/eslint.config.mjs renderer/src", "format:renderer": "prettier --write renderer/src", - "test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js && vitest run", + "test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js ui-language.test.js locale-completeness.test.js && vitest run", "build": "npm run build:renderer && electron-builder", "pack:unsigned": "npm run build:renderer && electron-builder --dir --config electron-builder.ci.yml", "build-mac": "npm run build:renderer && electron-builder --mac", @@ -94,10 +94,12 @@ "clsx": "^2.1.1", "electron-audio-loopback": "^1.0.6", "electron-updater": "^6.8.3", + "i18next": "^26.3.6", "lucide-react": "^1.8.0", "posthog-node": "^4.18.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "react-router-dom": "^7.14.2", "tailwind-merge": "^3.5.0", diff --git a/app/preload.js b/app/preload.js index 9abee106..3bdd230f 100644 --- a/app/preload.js +++ b/app/preload.js @@ -53,8 +53,24 @@ const subscribeQueryStream = (queryId, { onChunk, onDone, onError } = {}) => { }; }; +/* + * The UI language main resolved for this window, delivered on process.argv via + * webPreferences.additionalArguments rather than fetched over IPC. + * + * It is a plain value, not a function, because the renderer needs it before its + * first paint: i18next is initialised at module scope in lib/i18n.ts, and any + * async hop there would either delay the ready-to-show signal or paint English + * first and then flip. Later changes arrive via events.uiLanguageChanged. + */ +const uiLanguageFromArgv = () => { + const prefix = '--stenoai-ui-language='; + const arg = process.argv.find((a) => a.startsWith(prefix)); + return arg ? arg.slice(prefix.length) : 'en'; +}; + const stenoai = { version: VERSION, + uiLanguage: uiLanguageFromArgv(), app: { getVersion: () => invoke('get-app-version'), @@ -264,8 +280,13 @@ const stenoai = { // Design-for-test seam: the production fire path is the main-side scheduler // timer; this lets e2e drive the gate + suppression deterministically. showPremeetingNotification: (payload) => invoke('show-premeeting-notification', payload), + // Transcription/content language — NOT the interface language. The pair + // below is the UI one; keeping both here makes the distinction visible at + // the only place a caller picks between them. getLanguage: () => invoke('get-language'), setLanguage: (code) => invoke('set-language', code), + getUiLanguage: () => invoke('get-ui-language'), + setUiLanguage: (code) => invoke('set-ui-language', code), getMicrophone: () => invoke('get-microphone'), setMicrophone: (deviceId, label) => invoke('set-microphone', deviceId, label), getUserName: () => invoke('get-user-name'), @@ -356,6 +377,7 @@ const stenoai = { // All main-driven events. Every subscribe returns an unsubscribe fn. on: { debugLog: (cb) => subscribe('debug-log', cb), + uiLanguageChanged: (cb) => subscribe('ui-language-changed', cb), setupFlowTriggered: (cb) => subscribe('trigger-setup-flow', cb), toggleRecordingHotkey: (cb) => subscribe('toggle-recording-hotkey', cb), summaryChunk: (cb) => subscribe('summary-chunk', cb), diff --git a/app/renderer/src/App.tsx b/app/renderer/src/App.tsx index 8ea30552..88b598a3 100644 --- a/app/renderer/src/App.tsx +++ b/app/renderer/src/App.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { useTheme } from '@/hooks/useTheme'; +import { useUiLanguageSync } from '@/hooks/useUiLanguageSync'; import { Sandbox } from '@/routes/Sandbox'; import { Settings } from '@/routes/Settings'; import { Setup } from '@/routes/Setup'; @@ -38,6 +39,7 @@ import { primeDebugLogs } from '@/lib/debugLogs'; export function App() { useTheme(); + useUiLanguageSync(); const route = useRoute(); // One-time privacy disclosure for upgraders. Show it only when the marker is diff --git a/app/renderer/src/components/AskBar.tsx b/app/renderer/src/components/AskBar.tsx index c021ded5..06d93001 100644 --- a/app/renderer/src/components/AskBar.tsx +++ b/app/renderer/src/components/AskBar.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import { ArrowUp, Check, @@ -30,6 +31,7 @@ import { buildTranscriptBundle } from '@/lib/transcriptBundle'; // --------------------------------------------------------------------------- export function TranscriptBar() { + const { t } = useTranslation(); const { activeSummaryFile, activeMeetingName, activeOrgMeeting, transcriptOpen, setTranscriptOpen } = useAskBar(); const meeting = useMeeting(activeSummaryFile ?? undefined); @@ -95,13 +97,13 @@ export function TranscriptBar() {