diff --git a/.gitignore b/.gitignore index 2cf45d00..dca48d0a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ build/ !app/build/ app/build/* !app/build/entitlements.mac.plist +# Localised macOS permission-prompt bodies (#337). app/build/* above is an +# allowlist, so these need an explicit exception - without it a clean CI +# checkout has no de.lproj, electron-builder only warns, and the signed +# release ships English prompts while local builds look correct. +!app/build/lproj/ +!app/build/lproj/** !app/build/icon.icns !app/build/icon-dragonfly.icns !app/build/icon.ico diff --git a/app/build/lproj/de.lproj/InfoPlist.strings b/app/build/lproj/de.lproj/InfoPlist.strings new file mode 100644 index 00000000..60c56e6a --- /dev/null +++ b/app/build/lproj/de.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* Siehe en.lproj/InfoPlist.strings fuer die Herkunft dieser Datei. */ +"NSMicrophoneUsageDescription" = "Steno braucht Zugriff auf das Mikrofon, um Meetings aufzunehmen und zu transkribieren."; +"NSAudioCaptureUsageDescription" = "Steno braucht Zugriff auf den Systemton, um den Ton aus Videokonferenzen aufzunehmen."; 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..c9fad977 --- /dev/null +++ b/app/locales/de.json @@ -0,0 +1,1208 @@ +{ + "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", + "placeholderTitle": { + "meeting": "Meeting", + "note": "Notiz" + }, + "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": { + "about": "Über {{app}}", + "close": "Fenster schließen", + "copy": "Kopieren", + "cut": "Ausschneiden", + "delete": "Löschen", + "edit": "Bearbeiten", + "file": "&Datei", + "fileMac": "Ablage", + "forceReload": "Neu laden erzwingen", + "front": "Alle nach vorne bringen", + "help": "Hilfe", + "hide": "{{app}} ausblenden", + "hideOthers": "Andere ausblenden", + "learnMore": "Mehr erfahren", + "minimize": "Im Dock ablegen", + "paste": "Einsetzen", + "pasteAndMatchStyle": "Einsetzen und Stil anpassen", + "quit": "{{app}} beenden", + "redo": "Wiederholen", + "reload": "Neu laden", + "reportBug": "Fehler melden", + "resetZoom": "Originalgröße", + "selectAll": "Alles auswählen", + "services": "Dienste", + "settings": "Einstellungen…", + "speech": "Sprachausgabe", + "startSpeaking": "Sprachausgabe starten", + "stopSpeaking": "Sprachausgabe stoppen", + "substitutions": "Ersetzungen", + "toggleDevTools": "Entwicklerwerkzeuge ein- oder ausblenden", + "toggleFullScreen": "Vollbild ein oder aus", + "undo": "Widerrufen", + "unhide": "Alle einblenden", + "view": "Darstellung", + "window": "Fenster", + "zoom": "Zoomen", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern" + }, + "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" + }, + "languages": { + "ar": "Arabisch", + "auto": "Automatisch", + "autoMulti": "Mehrsprachig", + "de": "Deutsch", + "en": "Englisch", + "es": "Spanisch", + "fr": "Französisch", + "hi": "Hindi", + "hint": { + "auto": "Erkennt pro Aufnahme automatisch (europäische Sprachen)", + "de": "Auf Deutsch transkribieren und zusammenfassen", + "en": "Beste Genauigkeit, wenn Meetings immer auf Englisch sind", + "es": "Auf Spanisch transkribieren und zusammenfassen", + "fr": "Auf Französisch transkribieren und zusammenfassen", + "nl": "Auf Niederländisch transkribieren und zusammenfassen", + "pt": "Auf Portugiesisch transkribieren und zusammenfassen" + }, + "ja": "Japanisch", + "ko": "Koreanisch", + "nl": "Niederländisch", + "pt": "Portugiesisch", + "zh-Hans": "Chinesisch (vereinfacht)", + "zh-Hant": "Chinesisch (traditionell)" + }, + "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": "RAM knapp", + "memoryWarningTitle": "Dieses Modell braucht womöglich mehr Arbeitsspeicher, 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…", + "seeded": { + "shareableSummary": "Weitergabe-Zusammenfassung" + }, + "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..8969c3aa --- /dev/null +++ b/app/locales/en.json @@ -0,0 +1,1208 @@ +{ + "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", + "placeholderTitle": { + "meeting": "Meeting", + "note": "Note" + }, + "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": { + "about": "About {{app}}", + "close": "Close Window", + "copy": "Copy", + "cut": "Cut", + "delete": "Delete", + "edit": "Edit", + "file": "&File", + "fileMac": "File", + "forceReload": "Force Reload", + "front": "Bring All to Front", + "help": "Help", + "hide": "Hide {{app}}", + "hideOthers": "Hide Others", + "learnMore": "Learn More", + "minimize": "Minimize", + "paste": "Paste", + "pasteAndMatchStyle": "Paste and Match Style", + "quit": "Quit {{app}}", + "redo": "Redo", + "reload": "Reload", + "reportBug": "Report a Bug", + "resetZoom": "Actual Size", + "selectAll": "Select All", + "services": "Services", + "settings": "Settings…", + "speech": "Speech", + "startSpeaking": "Start Speaking", + "stopSpeaking": "Stop Speaking", + "substitutions": "Substitutions", + "toggleDevTools": "Toggle Developer Tools", + "toggleFullScreen": "Toggle Full Screen", + "undo": "Undo", + "unhide": "Show All", + "view": "View", + "window": "Window", + "zoom": "Zoom", + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out" + }, + "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" + }, + "languages": { + "ar": "Arabic", + "auto": "Auto (detect)", + "autoMulti": "Multi-language", + "de": "German", + "en": "English", + "es": "Spanish", + "fr": "French", + "hi": "Hindi", + "hint": { + "auto": "Auto-detect per recording (European languages)", + "de": "Transcribe and summarise in German", + "en": "Best accuracy when meetings are always in English", + "es": "Transcribe and summarise in Spanish", + "fr": "Transcribe and summarise in French", + "nl": "Transcribe and summarise in Dutch", + "pt": "Transcribe and summarise in Portuguese" + }, + "ja": "Japanese", + "ko": "Korean", + "nl": "Dutch", + "pt": "Portuguese", + "zh-Hans": "Chinese (Simplified)", + "zh-Hant": "Chinese (Traditional)" + }, + "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…", + "seeded": { + "shareableSummary": "Shareable summary" + }, + "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..a1e91e15 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,192 @@ 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 = { + // A plain submenu rather than { role: 'help' }: the role's own label does + // not follow the chosen language (same measured behaviour as the other + // roles), and `help` is documented as macOS-specific while this object is + // also used in the Windows/Linux template. + label: i18n.t('menu.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') } + ] + }; + /* + * The composite roles (fileMenu/editMenu/viewMenu/windowMenu) are expanded + * into their items so each can carry a translated label. + * + * This looks like it should be unnecessary — the assumption when this landed + * was Electron's, that a role supplies a platform-appropriate localised + * label. Measured on a fully German Mac (getLocale=de, systemLocale=de-DE), + * it does not: bare roles render "Undo", "Cut", "Select All" in English. So + * a German UI showed one German "Einstellungen…" in an otherwise English + * menu, which reads more broken than either language on its own. + * + * Every item keeps its `role`, so behaviour and the platform accelerator + * still come from Electron — only the text is ours. Verified that overriding + * a role's label leaves its accelerator intact (quit still binds ⌘Q). + * + * The German wording is Apple's own macOS terminology: "Ablage" rather than + * "Datei" for File, "Widerrufen" for Undo, "Einsetzen" for Paste, "Im Dock + * ablegen" for Minimize. Windows/Linux use "Datei", which is correct there. + */ + const appName = app.name; + const editSubmenu = [ + { role: 'undo', label: i18n.t('menu.undo') }, + { role: 'redo', label: i18n.t('menu.redo') }, + { type: 'separator' }, + { role: 'cut', label: i18n.t('menu.cut') }, + { role: 'copy', label: i18n.t('menu.copy') }, + { role: 'paste', label: i18n.t('menu.paste') }, + ...(process.platform === 'darwin' + ? [ + { role: 'pasteAndMatchStyle', label: i18n.t('menu.pasteAndMatchStyle') }, + { role: 'delete', label: i18n.t('menu.delete') }, + { role: 'selectAll', label: i18n.t('menu.selectAll') }, + { type: 'separator' }, + { + label: i18n.t('menu.speech'), + submenu: [ + { role: 'startSpeaking', label: i18n.t('menu.startSpeaking') }, + { role: 'stopSpeaking', label: i18n.t('menu.stopSpeaking') } + ] + } + ] + : [ + { role: 'delete', label: i18n.t('menu.delete') }, + { type: 'separator' }, + { role: 'selectAll', label: i18n.t('menu.selectAll') } + ]) + ]; + const viewSubmenu = [ + { role: 'reload', label: i18n.t('menu.reload') }, + { role: 'forceReload', label: i18n.t('menu.forceReload') }, + { role: 'toggleDevTools', label: i18n.t('menu.toggleDevTools') }, + { type: 'separator' }, + { role: 'resetZoom', label: i18n.t('menu.resetZoom') }, + { role: 'zoomIn', label: i18n.t('menu.zoomIn') }, + { role: 'zoomOut', label: i18n.t('menu.zoomOut') }, + { type: 'separator' }, + { role: 'togglefullscreen', label: i18n.t('menu.toggleFullScreen') } + ]; + const windowSubmenu = [ + { role: 'minimize', label: i18n.t('menu.minimize') }, + ...(process.platform === 'darwin' + ? [ + { role: 'zoom', label: i18n.t('menu.zoom') }, + { type: 'separator' }, + { role: 'front', label: i18n.t('menu.front') } + ] + : [{ role: 'close', label: i18n.t('menu.close') }]) + ]; + + const appMenu = Menu.buildFromTemplate( + process.platform === 'darwin' + ? [ + { + // Custom appMenu to add Settings… with the conventional ⌘, + // shortcut (the default `{ role: 'appMenu' }` omits Settings). + label: appName, + submenu: [ + { role: 'about', label: i18n.t('menu.about', { app: appName }) }, + { type: 'separator' }, + settingsItem, + { type: 'separator' }, + { role: 'services', label: i18n.t('menu.services') }, + { type: 'separator' }, + { role: 'hide', label: i18n.t('menu.hide', { app: appName }) }, + { role: 'hideOthers', label: i18n.t('menu.hideOthers') }, + { role: 'unhide', label: i18n.t('menu.unhide') }, + { type: 'separator' }, + { role: 'quit', label: i18n.t('menu.quit', { app: appName }) } + ] + }, + { label: i18n.t('menu.fileMac'), submenu: [{ role: 'close', label: i18n.t('menu.close') }] }, + { label: i18n.t('menu.edit'), submenu: editSubmenu }, + { label: i18n.t('menu.view'), submenu: viewSubmenu }, + { label: i18n.t('menu.window'), submenu: windowSubmenu }, + helpSubmenu + ] + : [ + { label: i18n.t('menu.file'), submenu: [settingsItem, { type: 'separator' }, { role: 'quit', label: i18n.t('menu.quit', { app: appName }) }] }, + { label: i18n.t('menu.edit'), submenu: editSubmenu }, + { label: i18n.t('menu.view'), submenu: viewSubmenu }, + { label: i18n.t('menu.window'), submenu: windowSubmenu }, + helpSubmenu + ] + ); + Menu.setApplicationMenu(appMenu); +} + function getTrayIconPath(recording) { const iconName = recording ? 'trayIconRecordingTemplate' : 'trayIconTemplate'; if (app.isPackaged) { @@ -1615,7 +1811,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 +1821,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 +1840,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 +1852,7 @@ function updateTrayMenu() { } }, { - label: 'Settings', + label: i18n.t('tray.settings'), click: () => { showAndFocusWindow(); if (mainWindow) { @@ -1665,25 +1861,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 +2087,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 +2661,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 +3877,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 +3999,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 +4054,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 +6122,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 +6789,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 +6802,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 +7958,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 +7985,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 +8016,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 +8060,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 +9087,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 +9099,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 +11159,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..5a146e35 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", @@ -161,6 +163,10 @@ { "from": "../bin/mic-monitor", "to": "mic-monitor" + }, + { + "from": "build/lproj/de.lproj/InfoPlist.strings", + "to": "de.lproj/InfoPlist.strings" } ], "extendInfo": { 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() {