diff --git a/README.de.md b/README.de.md
new file mode 100644
index 0000000..3848b2a
--- /dev/null
+++ b/README.de.md
@@ -0,0 +1,375 @@
+
+

+
+
Blitztext Linux
+
Dein lokaler KI-Sprachassistent für KDE Plasma & Wayland
+
+
+
+
+
+
+
🇬🇧 English | 🇩🇪 Deutsch
+
Sprache per Hotkey aufnehmen, lokal oder online transkribieren, optional per LLM umschreiben und direkt in die aktive Anwendung einfügen.
+
+
+> [!IMPORTANT]
+> **Eigenständiger Linux-Port:** Dieses Repository enthält ausschließlich den Linux-Port von Blitztext – eine eigenständige Python 3/PyQt6-Implementierung optimiert für **Kubuntu/Ubuntu unter KDE Plasma mit Wayland**. Für die originale macOS-Version besuche bitte das [offizielle Haupt-Repository](https://github.com/cmagnussen/blitztext-app).
+
+---
+
+## Features
+
+- **NEU: Mehrsprachige Oberfläche (EN/DE):** Schalte die App-Oberfläche zwischen Deutsch und Englisch um – unter **Einstellungen → Allgemein → „Sprache"** (die Änderung greift nach einem Neustart der App).
+- **Eigennamen / Begriffe:** Erweitere das Vokabular der KI um eigene Begriffe, Namen oder Fachwörter für perfekte Transkriptionen.
+
+- **Globale Hotkeys:** Jederzeit von überall im System aufnehmen.
+- **Auto-Paste:** Erkennt Sprache und fügt sie direkt dort ein, wo der Cursor ist.
+- **LLM-gestützte Workflows:** Lass die KI deine Sätze professionell umformulieren, emotional filtern oder mit passenden Emojis anreichern.
+- **Lokale Verarbeitung:** Optional 100% offline für volle Privatsphäre.
+
+---
+
+## Installation
+
+### Schnellinstallation (empfohlen)
+
+Der einfachste Weg, um Blitztext auf deinem System bereitzustellen:
+
+```bash
+git clone https://github.com/TimInTech/blitztext-linux.git
+cd blitztext-linux
+bash scripts/install.sh
+```
+
+**Was macht das Skript?**
+Es ist idempotent (mehrfach ausführbar) und erledigt alles vollautomatisch:
+1. Prüft dein System (Ubuntu/Debian) & Python-Version.
+2. Installiert fehlende Systempakete (inkl. `pipx`).
+3. Richtet eine `.venv` Umgebung ein und installiert `openai-whisper`/`faster-whisper`.
+4. Bereitet `ydotool.service` und den systemd-User-Service vor.
+
+### Nach der Installation
+
+1. **Neustart erforderlich** (oder ab-/anmelden), damit die Gruppe `input` aktiv wird. Danach checken:
+ ```bash
+ bash scripts/verify.sh
+ ```
+2. **Manuell testen:**
+ ```bash
+ ./run.sh
+ ```
+ *(Erscheint das Tray-Symbol und reagieren die Hotkeys? Dann lief alles glatt!)*
+3. **Autostart aktivieren:**
+ ```bash
+ systemctl --user start blitztext-linux
+ ```
+
+
+Autostart wieder deaktivieren
+
+```bash
+systemctl --user stop blitztext-linux
+systemctl --user disable blitztext-linux
+```
+
+
+
+Manuelle Installation (Diagnose / Experten)
+
+Falls du gezielt debuggen möchtest, anstatt `scripts/install.sh` zu nutzen:
+
+**1. Systempakete (apt)**
+```bash
+sudo apt install pulseaudio-utils wl-clipboard xclip ydotool ffmpeg python3-venv python3-evdev build-essential python3-dev socat pipx
+```
+
+| Paket | Zweck |
+| :--- | :--- |
+| `pulseaudio-utils` | `parec` für die Audioaufnahme via PulseAudio/PipeWire |
+| `wl-clipboard` / `xclip` | Zwischenablage unter Wayland (`wl-copy`) bzw. X11-Fallback |
+| `ydotool` (≥ 1.0) | Simuliert `Ctrl+V` für automatisches Einfügen (Auto-Paste). Ab Version 1.0 werden rohe Keycodes verwendet. **Ubuntu 25.10/26.04** liefern ydotool ≥ 1.0 (1.0.4) direkt via `apt`. **Ubuntu 24.04 und 22.04** liefern per `apt` nur 0.1.x (z. B. 0.1.8), das keine Keycodes unterstützt und damit kein Auto-Paste – dort ydotool ≥ 1.0 aus dem Quellcode bauen (siehe unten). Auto-Paste auf 24.04, 25.10 und 26.04 verifiziert. |
+| `ffmpeg` | Audio-Konvertierungen |
+| `python3-evdev` | Eingabegeräte-Zugriff für den systemweiten Hotkey-Daemon |
+| `socat` | Optionale Socket-Kommunikation |
+| `pipx` | Isolierte Installation von Whisper-Engines |
+
+**2. evdev-Rechte vergeben**
+```bash
+sudo usermod -aG input $USER
+```
+
+**3. Virtuelle Umgebung & Python-Pakete**
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+pip install PyQt6 evdev openai pytest openai-whisper faster-whisper
+```
+
+**4. Whisper-Engine als Alternative via pipx**
+Falls Sie `openai-whisper` losgelöst von der venv installieren möchten (umgeht Versionskonflikte auf neueren Ubuntu-Setups durch Python 3.11):
+```bash
+pipx install --python "$(command -v python3.11)" openai-whisper
+pipx inject openai-whisper faster-whisper # optional, für beschleunigte Ausführung
+```
+
+**5. ydotool prüfen**
+```bash
+systemctl --user start ydotool.service
+```
+Liefert `apt` nur ydotool 0.1.x (Ubuntu 24.04/22.04), ydotool ≥ 1.0 aus dem Quellcode bauen:
+```bash
+sudo apt install cmake build-essential scdoc git
+git clone --depth 1 --branch v1.0.4 https://github.com/ReimuNotMoe/ydotool.git
+cd ydotool && cmake -B build -DCMAKE_BUILD_TYPE=Release && make -C build && sudo make -C build install
+systemctl --user enable --now ydotool.service # nutzt /usr/local/bin/ydotoold
+```
+
+**6. Anwendung starten**
+```bash
+./run.sh
+```
+
+
+---
+
+## Die 5 Workflows und Hotkeys
+
+Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast du die volle Kontrolle:
+
+| Workflow | Hotkey | LLM? | Beschreibung |
+| :--- | :--- | :---: | :--- |
+| **Blitztext** | Meta + H | ❌ | Standard: Nimmt auf, transkribiert und fügt den Text ein. |
+| **Blitztext Lokal** | Meta + Shift + H | ❌ | Erzwingt eine reine **Offline-Transkription**. |
+| **Blitztext+** | Meta + Shift + T | ✅ | Formuliert deine Aufnahme professionell via LLM um. |
+| **Blitztext $%&!** | Meta + Shift + D | ✅ | Emotionale Entladung: Wandelt Frust in eine sachliche Nachricht um. |
+| **Blitztext :)** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. |
+
+> [!NOTE]
+> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **OpenAI API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable `OPENAI_API_KEY` mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung.
+
+## KI-Workflows
+
+Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstellungen findest du direkt in der App:
+
+
+

+
+
+
+### Schreibstil-Vorlagen
+
+Für den Workflow **Blitztext+** (Text-Verbesserer) gibt es vorgefertigte Schreibstil-Vorlagen, die du unter **Einstellungen → KI-Workflows → „Schreibstil-Vorlage"** auswählst:
+
+| Vorlage | Wirkung |
+| --- | --- |
+| **Standard (Text verbessern)** | Bisheriges Verhalten – sauber formatierter Text, der gewählte **Tonfall** greift. |
+| **E-Mail – formell** | Höfliche E-Mail in der Sie-Form mit klarer Struktur. |
+| **E-Mail – locker** | Freundliche E-Mail in der Du-Form. |
+| **Stichpunkte** | Gliedert den Inhalt in prägnante Stichpunkte. |
+| **Zusammenfassung** | Knappe, sachliche Zusammenfassung der Kernaussagen. |
+| **Persönlich (Du-Form)** | Klarer Text in der persönlichen Du-Form. |
+| **Höflich (Sie-Form)** | Klarer Text in der höflichen Sie-Form. |
+| **Kurz & präzise** | Maximal knapp, ohne Füllwörter und Wiederholungen. |
+
+> Bei **Standard** wird zusätzlich der eingestellte **Tonfall** angewendet. Jede andere Vorlage bringt ihren eigenen Schreibstil mit und ersetzt den Tonfall. Eigennamen/Begriffe bleiben in allen Vorlagen erhalten.
+
+---
+
+## Tray-Symbol: Statusfarben
+
+Das Mikrofon im System-Tray ist dein Indikator für den aktuellen Zustand:
+
+
+
+> [!NOTE]
+> Steht im Desktop-Environment kein Tray-Bereich zur Verfügung, fällt das Icon auf das System-Theme `audio-input-microphone` zurück; die Farbkodierung greift dann ggf. nicht.
+
+---
+
+## Hauptfenster (grafischer Fallback)
+
+Falls du keine Tastatur parat hast oder Hotkeys blockiert sind:
+
+
+
+

+
+
+
+- **Maus-Steuerung:** Start/Stopp-Button für die Aufnahme.
+- **Workflow-Menü:** Dropdown für alle 5 Modi.
+- **Abbruch:** Verwirft eine Aufnahme sofort ohne Transkription.
+- **Schnellzugriffe:** Diktat, Verlauf, Vorlesen und Einstellungen.
+
+*Das Fenster öffnet sich beim Start sowie über den Tray-Eintrag **Fenster anzeigen** oder einen Klick auf das Tray-Icon. Schließen versteckt das Fenster nur — die App läuft im Tray weiter.*
+
+---
+
+## Diktat, Verlauf und Vorlesen
+
+Zusätzlich zu den Workflows bietet das Tool drei Komfort-Funktionen:
+
+
+
+

+

+
+
+
+| Menüpunkt | Beschreibung |
+| :--- | :--- |
+| **Diktat-Modus** | Umschalter. Ist er aktiv, werden alle Transkripte als Diktat-Einträge gesammelt und einzeln als Markdown-Datei gespeichert. Im Verlauf erscheint dann eine Schaltfläche **Zusammenführen**, die alle Einträge kombiniert und in die Zwischenablage kopiert. |
+| **Verlauf…** | Öffnet ein Fenster mit den letzten Transkripten. Pro Eintrag: In Zwischenablage kopieren oder löschen. |
+| **Vorlesen…** | Lässt dir beliebigen Text vorlesen — lokal per **Piper TTS** (Standard) oder optional über **OpenAI Cloud-TTS** (inklusive Anbieter-, Stimmen- und Modellauswahl)! |
+
+> [!NOTE]
+> **Diktat-Notizen** werden ausschließlich in einen Ordner **innerhalb des Home-Verzeichnisses** geschrieben (Schutz gegen Pfad-Ausbruch), mit Berechtigungen `0o600`.
+
+> [!IMPORTANT]
+> **Piper TTS** muss für die Vorlesefunktion (sowie Stimmen) installiert sein:
+> ```bash
+> .venv/bin/pip install piper-tts
+> # Stimmen (.onnx + .onnx.json) nach ~/.local/share/piper-voices/ legen
+> ```
+> Fehlt Piper oder eine Stimme, zeigt das Vorlese-Fenster einen Installationshinweis; alle übrigen Funktionen bleiben nutzbar. Optionale Desktop-Benachrichtigungen nutzen `notify-send` (Paket `libnotify-bin`).
+
+> [!NOTE]
+> **OpenAI Cloud-TTS** ist eine optionale Alternative zu Piper. Voraussetzung: das `openai`-Paket (`.venv/bin/pip install openai`) und ein gültiger Key in der Umgebungsvariable `OPENAI_API_KEY` (siehe `secrets.env` unten). Beim ersten Umschalten auf den Anbieter „OpenAI Cloud" fragt das Vorlese-Fenster einmalig nach Bestätigung, da der eingegebene Text zur Synthese an die OpenAI-Server gesendet wird. Piper bleibt Standard und arbeitet vollständig lokal.
+
+---
+
+## Konfiguration
+
+Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespeichert. Der OpenAI-Schlüssel wird nicht mehr in dieser Datei abgelegt, sondern aus einer Umgebungsvariable gelesen. Die Konfigurationsdatei lässt sich für erweiterte Prompt- und Workflow-Anpassungen direkt aus den Einstellungen öffnen: **Einstellungen → Allgemein → „Konfigurationsdatei öffnen"**.
+
+
+

+
+
+
+> [!IMPORTANT]
+> Die Konfigurationsdatei wird automatisch mit restriktiven Dateiberechtigungen (**`0o600` / `chmod 600`**) gespeichert. Der echte OpenAI-Key liegt stattdessen in `~/.config/blitztext-linux/secrets.env` oder wird als Umgebungsvariable bereitgestellt.
+
+
+Beispiel-Konfiguration & Felderklärung
+
+```json
+{
+ "model": "base",
+ "language": "de",
+ "ui_language": "de",
+ "backend": "openai-whisper",
+ "hotkey_mode": "toggle",
+ "openai_api_key_env": "OPENAI_API_KEY",
+ "autopaste": true,
+ "audio_device": "@DEFAULT_SOURCE@",
+ "workflows": {
+ "text_improver_tone": "neutral",
+ "emoji_density": "mittel",
+ "dampf_system_prompt": ""
+ }
+}
+```
+
+- **model**: Whisper-Modellgröße (`tiny`, `base`, `small`, `medium`, `large`, `large-v2`, `large-v3`, `large-v3-turbo`). Standard: `base`.
+- **language**: Transkriptions-Sprache (`de`, `en`) oder `auto`.
+- **ui_language**: Sprache der App-Oberfläche (`de` oder `en`). Standard: `de`. Änderungen greifen nach einem Neustart.
+- **backend**: `openai-whisper` oder `faster-whisper`.
+- **hotkey_mode**:
+ - `toggle`: Einmal drücken startet, erneutes Drücken beendet.
+ - `hold`: Aufnahme läuft solange der Hotkey gedrückt wird.
+- **openai_api_key_env**: Name der Umgebungsvariable für den OpenAI API-Key. Standard: `OPENAI_API_KEY`.
+- Der eigentliche Key liegt nicht in `config.json`, sondern in `~/.config/blitztext-linux/secrets.env` oder einer bereits gesetzten Umgebungsvariable.
+- **autopaste**: Fügt per `ydotool` ein.
+- **audio_device**: Name der Audioquelle.
+- **tts_provider**: TTS-Anbieter für „Vorlesen" — `piper` (lokal, Standard) oder `openai` (Cloud).
+- **tts_openai_model** / **tts_openai_voice**: Modell und Stimme für OpenAI Cloud-TTS (Standard: `gpt-4o-mini-tts`, `marin`).
+- **tts_openai_consent**: `true`, sobald die einmalige Datenschutz-Bestätigung für Cloud-TTS erteilt wurde. Standard: `false`.
+- **workflows**: Feintuning von Tonalität (`text_improver_tone`), Schreibstil-Vorlage (`writing_preset`), Emojis (`emoji_density`) und dem Dampf-Prompt (`dampf_system_prompt`).
+
+
+---
+
+## Entwicklung und Tests
+
+Wir lieben Stabilität! Führe die Tests lokal aus:
+
+```bash
+pytest
+```
+
+Mit `WHISPER_GUI_TESTS=1 QT_QPA_PLATFORM=offscreen pytest` laufen zusätzlich die GUI-Tests des Hauptfensters.
+
+
+Verzeichnisüberblick
+
+```text
+.
+├── app/
+│ ├── __init__.py
+│ ├── audio_recorder.py # PulseAudio/PipeWire-Aufnahme via parec
+│ ├── blitztext_linux.py # PyQt6-Hauptanwendung (System-Tray)
+│ ├── config.py # Konfigurations-Manager
+│ ├── hotkey_service.py # evdev-basierter Hotkey-Daemon
+│ ├── i18n.py # Übersetzungen (DE/EN) für die Oberfläche
+│ ├── llm_service.py # OpenAI API Schnittstelle
+│ ├── paste_service.py # Wayland-Clipboard-Integration
+│ ├── transcribe.py # Whisper-Transkription
+│ └── workflows.py # Workflows Definition
+├── tests/ # Test-Suite
+└── README.md # Dieses Dokument (englische Fassung)
+```
+
+
+---
+
+## Wichtige Hinweise
+
+- **Linux Exclusive:** Nur für Linux-Systeme.
+- **Wayland Fokus:** Entwickelt für Wayland (`wl-clipboard`, `ydotool`).
+- **Datenschutz:** Lokale Workflows bleiben zu 100% auf deinem Rechner. OpenAI wird nur bei Bedarf für LLM-Aufgaben kontaktiert.
+- **Sicherheit (`evdev` & `input` Gruppe):** Das Tool liest Input global über `/dev/input/event*`. Auf System-Ebene bedeutet dies, dass alle Prozesse des Benutzers Eingaben mitlesen könnten (Trade-off unter Wayland ohne XDG GlobalShortcuts). Nutzen Sie Blitztext nur in Umgebungen, denen Sie vertrauen!
+- **Entwickler-Hinweis:** Dieses Projekt wurde mit Unterstützung künstlicher Intelligenz (AI-assisted) entworfen. Architektur, Code und Tests wurden manuell gesichtet und auf Funktion/Sicherheit lokal verifiziert.
+
+---
+
+## Legal / Impressum & Datenschutz (Original-Projekt)
+
+Dieses Projekt ist ein Linux-Port der macOS-Anwendung "Blitztext". Der Fairness halber und zur korrekten Attribution verweisen wir auf die rechtlichen Angaben des Original-Projekts:
+
+Das Original-Projekt ist ein experimentelles, nicht-kommerzielles Open-Source-Projekt unter der MIT-Lizenz. Die zugehörige Website ([blitztext.de](https://blitztext.de/)) wird betrieben von der Blackboat Internet GmbH:
+
+- Impressum: https://www.blackboat.com/impressum
+- Datenschutz / Privacy: https://www.blackboat.com/datenschutz
+
+---
+
+
+ Erstellt mit ❤️ (und ein bisschen KI-Hilfe).
+
diff --git a/README.md b/README.md
index b67de95..10c1246 100644
--- a/README.md
+++ b/README.md
@@ -2,37 +2,39 @@
Blitztext Linux
- Dein lokaler KI-Sprachassistent für KDE Plasma & Wayland
+ Your local AI voice assistant for KDE Plasma & Wayland
-
-
+
+
- Sprache per Hotkey aufnehmen, lokal oder online transkribieren, optional per LLM umschreiben und direkt in die aktive Anwendung einfügen.
+ 🇬🇧 English | 🇩🇪 Deutsch
+ Record speech via hotkey, transcribe locally or online, optionally rewrite it with an LLM, and paste it directly into the active application.
> [!IMPORTANT]
-> **Eigenständiger Linux-Port:** Dieses Repository enthält ausschließlich den Linux-Port von Blitztext – eine eigenständige Python 3/PyQt6-Implementierung optimiert für **Kubuntu/Ubuntu unter KDE Plasma mit Wayland**. Für die originale macOS-Version besuche bitte das [offizielle Haupt-Repository](https://github.com/cmagnussen/blitztext-app).
+> **Standalone Linux port:** This repository contains exclusively the Linux port of Blitztext – a standalone Python 3/PyQt6 implementation optimized for **Kubuntu/Ubuntu running KDE Plasma with Wayland**. For the original macOS version, please visit the [official main repository](https://github.com/cmagnussen/blitztext-app).
---
## Features
-- **NEU: Eigennamen / Begriffe:** Erweitere das Vokabular der KI um eigene Begriffe, Namen oder Fachwörter für perfekte Transkriptionen.
+- **NEW: Multilingual interface (EN/DE):** Switch the app interface between German and English under **Settings → General → "Language"** (the change takes effect after restarting the app).
+- **Custom names / terms:** Extend the AI's vocabulary with your own terms, names, or technical words for perfect transcriptions.
-- **Globale Hotkeys:** Jederzeit von überall im System aufnehmen.
-- **Auto-Paste:** Erkennt Sprache und fügt sie direkt dort ein, wo der Cursor ist.
-- **LLM-gestützte Workflows:** Lass die KI deine Sätze professionell umformulieren, emotional filtern oder mit passenden Emojis anreichern.
-- **Lokale Verarbeitung:** Optional 100% offline für volle Privatsphäre.
+- **Global hotkeys:** Record from anywhere in the system at any time.
+- **Auto-paste:** Detects speech and pastes it right where your cursor is.
+- **LLM-powered workflows:** Let the AI rephrase your sentences professionally, filter them emotionally, or enrich them with fitting emojis.
+- **Local processing:** Optionally 100% offline for full privacy.
---
## Installation
-### Schnellinstallation (empfohlen)
+### Quick install (recommended)
-Der einfachste Weg, um Blitztext auf deinem System bereitzustellen:
+The easiest way to set up Blitztext on your system:
```bash
git clone https://github.com/TimInTech/blitztext-linux.git
@@ -40,31 +42,31 @@ cd blitztext-linux
bash scripts/install.sh
```
-**Was macht das Skript?**
-Es ist idempotent (mehrfach ausführbar) und erledigt alles vollautomatisch:
-1. Prüft dein System (Ubuntu/Debian) & Python-Version.
-2. Installiert fehlende Systempakete (inkl. `pipx`).
-3. Richtet eine `.venv` Umgebung ein und installiert `openai-whisper`/`faster-whisper`.
-4. Bereitet `ydotool.service` und den systemd-User-Service vor.
+**What does the script do?**
+It is idempotent (safe to run repeatedly) and handles everything fully automatically:
+1. Checks your system (Ubuntu/Debian) & Python version.
+2. Installs missing system packages (incl. `pipx`).
+3. Sets up a `.venv` environment and installs `openai-whisper`/`faster-whisper`.
+4. Prepares `ydotool.service` and the systemd user service.
-### Nach der Installation
+### After installation
-1. **Neustart erforderlich** (oder ab-/anmelden), damit die Gruppe `input` aktiv wird. Danach checken:
+1. **Restart required** (or log out/in) so the `input` group becomes active. Then verify:
```bash
bash scripts/verify.sh
```
-2. **Manuell testen:**
+2. **Test manually:**
```bash
./run.sh
```
- *(Erscheint das Tray-Symbol und reagieren die Hotkeys? Dann lief alles glatt!)*
-3. **Autostart aktivieren:**
+ *(Does the tray icon appear and do the hotkeys respond? Then everything went smoothly!)*
+3. **Enable autostart:**
```bash
systemctl --user start blitztext-linux
```
-Autostart wieder deaktivieren
+Disable autostart again
```bash
systemctl --user stop blitztext-linux
@@ -73,57 +75,57 @@ systemctl --user disable blitztext-linux
-Manuelle Installation (Diagnose / Experten)
+Manual installation (diagnostics / experts)
-Falls du gezielt debuggen möchtest, anstatt `scripts/install.sh` zu nutzen:
+In case you want to debug specifically instead of using `scripts/install.sh`:
-**1. Systempakete (apt)**
+**1. System packages (apt)**
```bash
sudo apt install pulseaudio-utils wl-clipboard xclip ydotool ffmpeg python3-venv python3-evdev build-essential python3-dev socat pipx
```
-| Paket | Zweck |
+| Package | Purpose |
| :--- | :--- |
-| `pulseaudio-utils` | `parec` für die Audioaufnahme via PulseAudio/PipeWire |
-| `wl-clipboard` / `xclip` | Zwischenablage unter Wayland (`wl-copy`) bzw. X11-Fallback |
-| `ydotool` (≥ 1.0) | Simuliert `Ctrl+V` für automatisches Einfügen (Auto-Paste). Ab Version 1.0 werden rohe Keycodes verwendet. **Ubuntu 25.10/26.04** liefern ydotool ≥ 1.0 (1.0.4) direkt via `apt`. **Ubuntu 24.04 und 22.04** liefern per `apt` nur 0.1.x (z. B. 0.1.8), das keine Keycodes unterstützt und damit kein Auto-Paste – dort ydotool ≥ 1.0 aus dem Quellcode bauen (siehe unten). Auto-Paste auf 24.04, 25.10 und 26.04 verifiziert. |
-| `ffmpeg` | Audio-Konvertierungen |
-| `python3-evdev` | Eingabegeräte-Zugriff für den systemweiten Hotkey-Daemon |
-| `socat` | Optionale Socket-Kommunikation |
-| `pipx` | Isolierte Installation von Whisper-Engines |
-
-**2. evdev-Rechte vergeben**
+| `pulseaudio-utils` | `parec` for audio recording via PulseAudio/PipeWire |
+| `wl-clipboard` / `xclip` | Clipboard under Wayland (`wl-copy`) or X11 fallback |
+| `ydotool` (≥ 1.0) | Simulates `Ctrl+V` for automatic pasting (auto-paste). From version 1.0 onward, raw keycodes are used. **Ubuntu 25.10/26.04** ship ydotool ≥ 1.0 (1.0.4) directly via `apt`. **Ubuntu 24.04 and 22.04** only ship 0.1.x via `apt` (e.g. 0.1.8), which does not support keycodes and therefore has no auto-paste – build ydotool ≥ 1.0 from source there (see below). Auto-paste verified on 24.04, 25.10, and 26.04. |
+| `ffmpeg` | Audio conversions |
+| `python3-evdev` | Input device access for the system-wide hotkey daemon |
+| `socat` | Optional socket communication |
+| `pipx` | Isolated installation of Whisper engines |
+
+**2. Grant evdev permissions**
```bash
sudo usermod -aG input $USER
```
-**3. Virtuelle Umgebung & Python-Pakete**
+**3. Virtual environment & Python packages**
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install PyQt6 evdev openai pytest openai-whisper faster-whisper
```
-**4. Whisper-Engine als Alternative via pipx**
-Falls Sie `openai-whisper` losgelöst von der venv installieren möchten (umgeht Versionskonflikte auf neueren Ubuntu-Setups durch Python 3.11):
+**4. Whisper engine as an alternative via pipx**
+If you want to install `openai-whisper` decoupled from the venv (avoids version conflicts on newer Ubuntu setups due to Python 3.11):
```bash
pipx install --python "$(command -v python3.11)" openai-whisper
-pipx inject openai-whisper faster-whisper # optional, für beschleunigte Ausführung
+pipx inject openai-whisper faster-whisper # optional, for accelerated execution
```
-**5. ydotool prüfen**
+**5. Check ydotool**
```bash
systemctl --user start ydotool.service
```
-Liefert `apt` nur ydotool 0.1.x (Ubuntu 24.04/22.04), ydotool ≥ 1.0 aus dem Quellcode bauen:
+If `apt` only provides ydotool 0.1.x (Ubuntu 24.04/22.04), build ydotool ≥ 1.0 from source:
```bash
sudo apt install cmake build-essential scdoc git
git clone --depth 1 --branch v1.0.4 https://github.com/ReimuNotMoe/ydotool.git
cd ydotool && cmake -B build -DCMAKE_BUILD_TYPE=Release && make -C build && sudo make -C build install
-systemctl --user enable --now ydotool.service # nutzt /usr/local/bin/ydotoold
+systemctl --user enable --now ydotool.service # uses /usr/local/bin/ydotoold
```
-**6. Anwendung starten**
+**6. Start the application**
```bash
./run.sh
```
@@ -131,156 +133,157 @@ systemctl --user enable --now ydotool.service # nutzt /usr/local/bin/ydotoold
---
-## Die 5 Workflows und Hotkeys
+## The 5 workflows and hotkeys
-Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast du die volle Kontrolle:
+Blitztext registers global hotkeys via `evdev`. With these combinations you have full control:
-| Workflow | Hotkey | LLM? | Beschreibung |
+| Workflow | Hotkey | LLM? | Description |
| :--- | :--- | :---: | :--- |
-| **Blitztext** | Meta + H | ❌ | Standard: Nimmt auf, transkribiert und fügt den Text ein. |
-| **Blitztext Lokal** | Meta + Shift + H | ❌ | Erzwingt eine reine **Offline-Transkription**. |
-| **Blitztext+** | Meta + Shift + T | ✅ | Formuliert deine Aufnahme professionell via LLM um. |
-| **Blitztext $%&!** | Meta + Shift + D | ✅ | Emotionale Entladung: Wandelt Frust in eine sachliche Nachricht um. |
-| **Blitztext :)** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. |
+| **Blitztext** | Meta + H | ❌ | Default: records, transcribes, and pastes the text. |
+| **Blitztext Local** | Meta + Shift + H | ❌ | Forces a pure **offline transcription**. |
+| **Blitztext+** | Meta + Shift + T | ✅ | Rephrases your recording professionally via LLM. |
+| **Blitztext $%&!** | Meta + Shift + D | ✅ | Emotional release: turns frustration into a matter-of-fact message. |
+| **Blitztext :)** | Meta + Shift + E | ✅ | Enriches your message with fitting emojis. |
> [!NOTE]
-> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **OpenAI API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable `OPENAI_API_KEY` mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung.
+> **LLM workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) require a valid **OpenAI API key**. The easiest way is to place it in `~/.config/blitztext-linux/secrets.env` by setting the variable `OPENAI_API_KEY` there with your key as the value (line format `NAME=VALUE`). `./run.sh` and the systemd service load this file automatically. Without this key, these functions are disabled in the menu and via the hotkeys, or result in an error message.
-## KI-Workflows
+## AI workflows
-Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstellungen findest du direkt in der App:
+The AI workflows help with phrasing, tone, and emojis. You'll find the relevant settings directly in the app:
-

+
-### Schreibstil-Vorlagen
+### Writing-style presets
-Für den Workflow **Blitztext+** (Text-Verbesserer) gibt es vorgefertigte Schreibstil-Vorlagen, die du unter **Einstellungen → KI-Workflows → „Schreibstil-Vorlage"** auswählst:
+For the **Blitztext+** workflow (text improver) there are ready-made writing-style presets that you select under **Settings → AI Workflows → "Writing-style preset"**:
-| Vorlage | Wirkung |
+| Preset | Effect |
| --- | --- |
-| **Standard (Text verbessern)** | Bisheriges Verhalten – sauber formatierter Text, der gewählte **Tonfall** greift. |
-| **E-Mail – formell** | Höfliche E-Mail in der Sie-Form mit klarer Struktur. |
-| **E-Mail – locker** | Freundliche E-Mail in der Du-Form. |
-| **Stichpunkte** | Gliedert den Inhalt in prägnante Stichpunkte. |
-| **Zusammenfassung** | Knappe, sachliche Zusammenfassung der Kernaussagen. |
-| **Persönlich (Du-Form)** | Klarer Text in der persönlichen Du-Form. |
-| **Höflich (Sie-Form)** | Klarer Text in der höflichen Sie-Form. |
-| **Kurz & präzise** | Maximal knapp, ohne Füllwörter und Wiederholungen. |
+| **Standard (improve text)** | Previous behavior – cleanly formatted text, the selected **tone** applies. |
+| **Email – formal** | Polite email in the formal form with a clear structure. |
+| **Email – casual** | Friendly email in the informal form. |
+| **Bullet points** | Structures the content into concise bullet points. |
+| **Summary** | Concise, factual summary of the key statements. |
+| **Personal (informal)** | Clear text in a personal, informal tone. |
+| **Polite (formal)** | Clear text in a polite, formal tone. |
+| **Short & precise** | As concise as possible, without filler words and repetitions. |
-> Bei **Standard** wird zusätzlich der eingestellte **Tonfall** angewendet. Jede andere Vorlage bringt ihren eigenen Schreibstil mit und ersetzt den Tonfall. Eigennamen/Begriffe bleiben in allen Vorlagen erhalten.
+> With **Standard**, the configured **tone** is additionally applied. Every other preset brings its own writing style and replaces the tone. Custom names/terms are preserved in all presets.
---
-## Tray-Symbol: Statusfarben
+## Tray icon: status colors
-Das Mikrofon im System-Tray ist dein Indikator für den aktuellen Zustand:
+The microphone in the system tray is your indicator of the current state:

- Grün (IDLE)
- Bereit — wartet auf deinen Einsatz.
+ Green (IDLE)
+ Ready — waiting for your action.
|

- Rot (RECORDING)
- Aufnahme läuft aktiv.
+ Red (RECORDING)
+ Recording is actively running.
|

Orange (TRANSCRIBING)
- Magie läuft (Transkription / KI-Umformulierung).
+ Magic in progress (transcription / AI rephrasing).
|

- Grau (ERROR)
- Ups, etwas ist schiefgelaufen.
+ Gray (ERROR)
+ Oops, something went wrong.
|
> [!NOTE]
-> Steht im Desktop-Environment kein Tray-Bereich zur Verfügung, fällt das Icon auf das System-Theme `audio-input-microphone` zurück; die Farbkodierung greift dann ggf. nicht.
+> If no tray area is available in the desktop environment, the icon falls back to the system theme `audio-input-microphone`; the color coding may then not apply.
---
-## Hauptfenster (grafischer Fallback)
+## Main window (graphical fallback)
-Falls du keine Tastatur parat hast oder Hotkeys blockiert sind:
+In case you don't have a keyboard handy or hotkeys are blocked:
-

+
-- **Maus-Steuerung:** Start/Stopp-Button für die Aufnahme.
-- **Workflow-Menü:** Dropdown für alle 5 Modi.
-- **Abbruch:** Verwirft eine Aufnahme sofort ohne Transkription.
-- **Schnellzugriffe:** Diktat, Verlauf, Vorlesen und Einstellungen.
+- **Mouse control:** Start/stop button for recording.
+- **Workflow menu:** Dropdown for all 5 modes.
+- **Cancel:** Discards a recording immediately without transcription.
+- **Quick access:** Dictation, history, read-aloud, and settings.
-*Das Fenster öffnet sich beim Start sowie über den Tray-Eintrag **Fenster anzeigen** oder einen Klick auf das Tray-Icon. Schließen versteckt das Fenster nur — die App läuft im Tray weiter.*
+*The window opens at startup as well as via the tray entry **Show window** or a click on the tray icon. Closing only hides the window — the app keeps running in the tray.*
---
-## Diktat, Verlauf und Vorlesen
+## Dictation, history, and read-aloud
-Zusätzlich zu den Workflows bietet das Tool drei Komfort-Funktionen:
+In addition to the workflows, the tool offers three convenience functions:
-| Menüpunkt | Beschreibung |
+| Menu item | Description |
| :--- | :--- |
-| **Diktat-Modus** | Umschalter. Ist er aktiv, werden alle Transkripte als Diktat-Einträge gesammelt und einzeln als Markdown-Datei gespeichert. Im Verlauf erscheint dann eine Schaltfläche **Zusammenführen**, die alle Einträge kombiniert und in die Zwischenablage kopiert. |
-| **Verlauf…** | Öffnet ein Fenster mit den letzten Transkripten. Pro Eintrag: In Zwischenablage kopieren oder löschen. |
-| **Vorlesen…** | Lässt dir beliebigen Text vorlesen — lokal per **Piper TTS** (Standard) oder optional über **OpenAI Cloud-TTS** (inklusive Anbieter-, Stimmen- und Modellauswahl)! |
+| **Dictation mode** | Toggle. When active, all transcripts are collected as dictation entries and each saved as a Markdown file. The history then shows a **Merge** button that combines all entries and copies them to the clipboard. |
+| **History…** | Opens a window with the most recent transcripts. Per entry: copy to clipboard or delete. |
+| **Read aloud…** | Reads any text aloud to you — locally via **Piper TTS** (default) or optionally via **OpenAI Cloud TTS** (including provider, voice, and model selection)! |
> [!NOTE]
-> **Diktat-Notizen** werden ausschließlich in einen Ordner **innerhalb des Home-Verzeichnisses** geschrieben (Schutz gegen Pfad-Ausbruch), mit Berechtigungen `0o600`.
+> **Dictation notes** are written exclusively into a folder **inside the home directory** (protection against path traversal), with permissions `0o600`.
> [!IMPORTANT]
-> **Piper TTS** muss für die Vorlesefunktion (sowie Stimmen) installiert sein:
+> **Piper TTS** must be installed for the read-aloud function (as well as voices):
> ```bash
> .venv/bin/pip install piper-tts
-> # Stimmen (.onnx + .onnx.json) nach ~/.local/share/piper-voices/ legen
+> # Place voices (.onnx + .onnx.json) into ~/.local/share/piper-voices/
> ```
-> Fehlt Piper oder eine Stimme, zeigt das Vorlese-Fenster einen Installationshinweis; alle übrigen Funktionen bleiben nutzbar. Optionale Desktop-Benachrichtigungen nutzen `notify-send` (Paket `libnotify-bin`).
+> If Piper or a voice is missing, the read-aloud window shows an installation hint; all other functions remain usable. Optional desktop notifications use `notify-send` (package `libnotify-bin`).
> [!NOTE]
-> **OpenAI Cloud-TTS** ist eine optionale Alternative zu Piper. Voraussetzung: das `openai`-Paket (`.venv/bin/pip install openai`) und ein gültiger Key in der Umgebungsvariable `OPENAI_API_KEY` (siehe `secrets.env` unten). Beim ersten Umschalten auf den Anbieter „OpenAI Cloud" fragt das Vorlese-Fenster einmalig nach Bestätigung, da der eingegebene Text zur Synthese an die OpenAI-Server gesendet wird. Piper bleibt Standard und arbeitet vollständig lokal.
+> **OpenAI Cloud TTS** is an optional alternative to Piper. Requirements: the `openai` package (`.venv/bin/pip install openai`) and a valid key in the environment variable `OPENAI_API_KEY` (see `secrets.env` below). When first switching to the "OpenAI Cloud" provider, the read-aloud window asks for confirmation once, because the entered text is sent to OpenAI's servers for synthesis. Piper remains the default and works entirely locally.
---
-## Konfiguration
+## Configuration
-Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespeichert. Der OpenAI-Schlüssel wird nicht mehr in dieser Datei abgelegt, sondern aus einer Umgebungsvariable gelesen. Die Konfigurationsdatei lässt sich für erweiterte Prompt- und Workflow-Anpassungen direkt aus den Einstellungen öffnen: **Einstellungen → Allgemein → „Konfigurationsdatei öffnen"**.
+Everything is stored locally and securely under `~/.config/blitztext-linux/config.json`. The OpenAI key is no longer stored in this file but read from an environment variable. The configuration file can be opened directly from the settings for advanced prompt and workflow adjustments: **Settings → General → "Open configuration file"**.
-

+
> [!IMPORTANT]
-> Die Konfigurationsdatei wird automatisch mit restriktiven Dateiberechtigungen (**`0o600` / `chmod 600`**) gespeichert. Der echte OpenAI-Key liegt stattdessen in `~/.config/blitztext-linux/secrets.env` oder wird als Umgebungsvariable bereitgestellt.
+> The configuration file is automatically saved with restrictive file permissions (**`0o600` / `chmod 600`**). The real OpenAI key instead lives in `~/.config/blitztext-linux/secrets.env` or is provided as an environment variable.
-Beispiel-Konfiguration & Felderklärung
+Example configuration & field explanation
```json
{
"model": "base",
"language": "de",
+ "ui_language": "de",
"backend": "openai-whisper",
"hotkey_mode": "toggle",
"openai_api_key_env": "OPENAI_API_KEY",
@@ -294,78 +297,79 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei
}
```
-- **model**: Whisper-Modellgröße (`tiny`, `base`, `small`, `medium`, `large`, `large-v2`, `large-v3`, `large-v3-turbo`). Standard: `base`.
-- **language**: Sprache (`de`, `en`) oder `auto`.
-- **backend**: `openai-whisper` oder `faster-whisper`.
+- **model**: Whisper model size (`tiny`, `base`, `small`, `medium`, `large`, `large-v2`, `large-v3`, `large-v3-turbo`). Default: `base`.
+- **language**: Transcription language (`de`, `en`) or `auto`.
+- **ui_language**: Language of the app interface (`de` or `en`). Default: `de`. Changes take effect after a restart.
+- **backend**: `openai-whisper` or `faster-whisper`.
- **hotkey_mode**:
- - `toggle`: Einmal drücken startet, erneutes Drücken beendet.
- - `hold`: Aufnahme läuft solange der Hotkey gedrückt wird.
-- **openai_api_key_env**: Name der Umgebungsvariable für den OpenAI API-Key. Standard: `OPENAI_API_KEY`.
-- Der eigentliche Key liegt nicht in `config.json`, sondern in `~/.config/blitztext-linux/secrets.env` oder einer bereits gesetzten Umgebungsvariable.
-- **autopaste**: Fügt per `ydotool` ein.
-- **audio_device**: Name der Audioquelle.
-- **tts_provider**: TTS-Anbieter für „Vorlesen" — `piper` (lokal, Standard) oder `openai` (Cloud).
-- **tts_openai_model** / **tts_openai_voice**: Modell und Stimme für OpenAI Cloud-TTS (Standard: `gpt-4o-mini-tts`, `marin`).
-- **tts_openai_consent**: `true`, sobald die einmalige Datenschutz-Bestätigung für Cloud-TTS erteilt wurde. Standard: `false`.
-- **workflows**: Feintuning von Tonalität (`text_improver_tone`), Schreibstil-Vorlage (`writing_preset`), Emojis (`emoji_density`) und dem Dampf-Prompt (`dampf_system_prompt`).
+ - `toggle`: press once to start, press again to stop.
+ - `hold`: recording runs as long as the hotkey is held.
+- **openai_api_key_env**: Name of the environment variable for the OpenAI API key. Default: `OPENAI_API_KEY`.
+- The actual key does not live in `config.json` but in `~/.config/blitztext-linux/secrets.env` or an already-set environment variable.
+- **autopaste**: Pastes via `ydotool`.
+- **audio_device**: Name of the audio source.
+- **tts_provider**: TTS provider for "Read aloud" — `piper` (local, default) or `openai` (cloud).
+- **tts_openai_model** / **tts_openai_voice**: Model and voice for OpenAI Cloud TTS (default: `gpt-4o-mini-tts`, `marin`).
+- **tts_openai_consent**: `true` once the one-time privacy confirmation for Cloud TTS has been granted. Default: `false`.
+- **workflows**: Fine-tuning of tonality (`text_improver_tone`), writing-style preset (`writing_preset`), emojis (`emoji_density`), and the steam-release prompt (`dampf_system_prompt`).
---
-## Entwicklung und Tests
+## Development and tests
-Wir lieben Stabilität! Führe die Tests lokal aus:
+We love stability! Run the tests locally:
```bash
pytest
```
-Die Suite umfasst aktuell **130 Tests** (9 werden ohne optionale Abhängigkeiten wie Piper TTS übersprungen).
-Mit `WHISPER_GUI_TESTS=1 QT_QPA_PLATFORM=offscreen pytest` laufen zusätzlich die GUI-Tests des Hauptfensters.
+With `WHISPER_GUI_TESTS=1 QT_QPA_PLATFORM=offscreen pytest`, the GUI tests of the main window run additionally.
-Verzeichnisüberblick
+Directory overview
```text
.
├── app/
│ ├── __init__.py
-│ ├── audio_recorder.py # PulseAudio/PipeWire-Aufnahme via parec
-│ ├── blitztext_linux.py # PyQt6-Hauptanwendung (System-Tray)
-│ ├── config.py # Konfigurations-Manager
-│ ├── hotkey_service.py # evdev-basierter Hotkey-Daemon
-│ ├── llm_service.py # OpenAI API Schnittstelle
-│ ├── paste_service.py # Wayland-Clipboard-Integration
-│ ├── transcribe.py # Whisper-Transkription
-│ └── workflows.py # Workflows Definition
-├── tests/ # Test-Suite
-└── README.md # Dieses Dokument
+│ ├── audio_recorder.py # PulseAudio/PipeWire recording via parec
+│ ├── blitztext_linux.py # PyQt6 main application (system tray)
+│ ├── config.py # Configuration manager
+│ ├── hotkey_service.py # evdev-based hotkey daemon
+│ ├── i18n.py # Interface translations (DE/EN)
+│ ├── llm_service.py # OpenAI API interface
+│ ├── paste_service.py # Wayland clipboard integration
+│ ├── transcribe.py # Whisper transcription
+│ └── workflows.py # Workflow definitions
+├── tests/ # Test suite
+└── README.md # This document (German version: README.de.md)
```
---
-## Wichtige Hinweise
+## Important notes
-- **Linux Exclusive:** Nur für Linux-Systeme.
-- **Wayland Fokus:** Entwickelt für Wayland (`wl-clipboard`, `ydotool`).
-- **Datenschutz:** Lokale Workflows bleiben zu 100% auf deinem Rechner. OpenAI wird nur bei Bedarf für LLM-Aufgaben kontaktiert.
-- **Sicherheit (`evdev` & `input` Gruppe):** Das Tool liest Input global über `/dev/input/event*`. Auf System-Ebene bedeutet dies, dass alle Prozesse des Benutzers Eingaben mitlesen könnten (Trade-off unter Wayland ohne XDG GlobalShortcuts). Nutzen Sie Blitztext nur in Umgebungen, denen Sie vertrauen!
-- **Entwickler-Hinweis:** Dieses Projekt wurde mit Unterstützung künstlicher Intelligenz (AI-assisted) entworfen. Architektur, Code und Tests wurden manuell gesichtet und auf Funktion/Sicherheit lokal verifiziert.
+- **Linux exclusive:** For Linux systems only.
+- **Wayland focus:** Developed for Wayland (`wl-clipboard`, `ydotool`).
+- **Privacy:** Local workflows stay 100% on your machine. OpenAI is only contacted when needed for LLM tasks.
+- **Security (`evdev` & `input` group):** The tool reads input globally via `/dev/input/event*`. At the system level, this means all of the user's processes could read along with input (a trade-off under Wayland without XDG GlobalShortcuts). Only use Blitztext in environments you trust!
+- **Developer note:** This project was designed with the support of artificial intelligence (AI-assisted). Architecture, code, and tests were reviewed manually and verified locally for function/security.
---
-## Legal / Impressum & Datenschutz (Original-Projekt)
+## Legal / Imprint & privacy (original project)
-Dieses Projekt ist ein Linux-Port der macOS-Anwendung "Blitztext". Der Fairness halber und zur korrekten Attribution verweisen wir auf die rechtlichen Angaben des Original-Projekts:
+This project is a Linux port of the macOS application "Blitztext". For fairness and correct attribution, we refer to the legal information of the original project:
-Das Original-Projekt ist ein experimentelles, nicht-kommerzielles Open-Source-Projekt unter der MIT-Lizenz. Die zugehörige Website ([blitztext.de](https://blitztext.de/)) wird betrieben von der Blackboat Internet GmbH:
+The original project is an experimental, non-commercial open-source project under the MIT license. The associated website ([blitztext.de](https://blitztext.de/)) is operated by Blackboat Internet GmbH:
-- Impressum: https://www.blackboat.com/impressum
-- Datenschutz / Privacy: https://www.blackboat.com/datenschutz
+- Imprint: https://www.blackboat.com/impressum
+- Privacy: https://www.blackboat.com/datenschutz
---
- Erstellt mit ❤️ (und ein bisschen KI-Hilfe).
+ Made with ❤️ (and a little AI help).
diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py
index 0e5e8f8..fa32bc1 100644
--- a/app/blitztext_linux.py
+++ b/app/blitztext_linux.py
@@ -32,7 +32,7 @@
from app.config import Config, VALID_HOTKEY_KEYS
from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, LLMServiceError
-from app.writing_presets import WRITING_PRESETS, WRITING_PRESET_KEYS, get_preset, preset_index
+from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index
from app.hotkey_service import HotkeyWorker
from app.audio_recorder import AudioRecorder, AudioRecorderError
from app.transcribe import transcribe, TranscribeError
@@ -40,6 +40,7 @@
from app.history_panel import HistoryPanel
from app.tts_window import TtsWindow
from app.main_window import MainWindow
+from app.i18n import LANGUAGES, LANGUAGE_DISPLAY_NAMES, set_language, t
from app import notify as notify_service
from app import __version__ as APP_VERSION
@@ -156,7 +157,7 @@ class SettingsDialog(QDialog):
def __init__(self, config: Config, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.config = config
- self.setWindowTitle("Blitztext Einstellungen")
+ self.setWindowTitle(t("settings.window_title"))
self.resize(580, 560)
self.init_ui()
@@ -208,25 +209,25 @@ def init_ui(self) -> None:
self.combo_transcription_key.addItems(sorted(VALID_HOTKEY_KEYS))
self.combo_transcription_key.setCurrentText(self.config.transcription_hotkey)
- form_whisper.addRow("Whisper-Modell:", self.combo_model)
- form_whisper.addRow(create_help_label("Wählen Sie die Modellgröße. Größere Modelle sind genauer, benötigen aber mehr Ressourcen."))
+ form_whisper.addRow(t("settings.whisper_model.label"), self.combo_model)
+ form_whisper.addRow(create_help_label(t("settings.whisper_model.help")))
- form_whisper.addRow("Transkription-Backend:", self.combo_backend)
- form_whisper.addRow(create_help_label("faster-whisper ist deutlich schneller und ressourcenschonender."))
+ form_whisper.addRow(t("settings.transcription_backend.label"), self.combo_backend)
+ form_whisper.addRow(create_help_label(t("settings.transcription_backend.help")))
- form_whisper.addRow("Sprache:", self.edit_language)
- form_whisper.addRow(create_help_label("Zweistelliger Ländercode (z. B. 'de', 'en') oder 'auto' für automatische Erkennung."))
+ form_whisper.addRow(t("settings.language.label"), self.edit_language)
+ form_whisper.addRow(create_help_label(t("settings.language.help")))
- form_whisper.addRow("Audio-Eingabegerät:", self.edit_audio_device)
- form_whisper.addRow(create_help_label("'@DEFAULT_SOURCE@' nutzt das Standardmikrofon von PulseAudio/PipeWire."))
+ form_whisper.addRow(t("settings.audio_device.label"), self.edit_audio_device)
+ form_whisper.addRow(create_help_label(t("settings.audio_device.help")))
- form_whisper.addRow("Hotkey-Modus:", self.combo_hotkey_mode)
- form_whisper.addRow(create_help_label("toggle: Einmal drücken zum Starten, erneut drücken zum Stoppen.\nhold: Gedrückt halten zum Aufnehmen, Loslassen zum Stoppen."))
+ form_whisper.addRow(t("settings.hotkey_mode.label"), self.combo_hotkey_mode)
+ form_whisper.addRow(create_help_label(t("settings.hotkey_mode.help")))
- form_whisper.addRow("Aufnahme-Taste:", self.combo_transcription_key)
- form_whisper.addRow(create_help_label("Einzelne Taste ohne Modifier (z. B. KEY_LEFTALT). Änderung wird sofort übernommen."))
+ form_whisper.addRow(t("settings.record_key.label"), self.combo_transcription_key)
+ form_whisper.addRow(create_help_label(t("settings.record_key.help")))
- self.tabs.addTab(self._scrollable(tab_whisper), "Spracherkennung")
+ self.tabs.addTab(self._scrollable(tab_whisper), t("settings.tab.speech"))
# Tab 2: LLM (KI)
tab_llm = QWidget()
@@ -247,7 +248,7 @@ def init_ui(self) -> None:
api_key_layout.addWidget(self.lbl_api_key_status)
if self.config.has_legacy_openai_api_key:
self.lbl_legacy_api_key_notice = QLabel(
- "Legacy openai_api_key gefunden. Er wird beim nächsten Speichern entfernt."
+ t("settings.api_key.legacy_notice")
)
self.lbl_legacy_api_key_notice.setWordWrap(True)
self.lbl_legacy_api_key_notice.setStyleSheet("color: #b26a00; font-size: 10px;")
@@ -260,7 +261,7 @@ def init_ui(self) -> None:
self.combo_llm_provider = QComboBox()
self.combo_llm_provider.addItem("OpenAI", "openai")
self.combo_llm_provider.addItem("OpenRouter", "openrouter")
- self.combo_llm_provider.addItem("Eigener Endpunkt", "custom")
+ self.combo_llm_provider.addItem(t("settings.llm_provider.custom_endpoint"), "custom")
provider_index = self.combo_llm_provider.findData(self.config.llm_provider)
self.combo_llm_provider.setCurrentIndex(provider_index if provider_index >= 0 else 0)
self.combo_llm_provider.currentIndexChanged.connect(lambda *_: self._on_llm_provider_changed())
@@ -280,7 +281,7 @@ def init_ui(self) -> None:
self.combo_writing_preset = QComboBox()
for key in WRITING_PRESET_KEYS:
- self.combo_writing_preset.addItem(WRITING_PRESETS[key].display_name, key)
+ self.combo_writing_preset.addItem(t(f"preset.{key}.name"), key)
self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset))
self.combo_emoji = QComboBox()
@@ -289,20 +290,20 @@ def init_ui(self) -> None:
self.edit_dampf_prompt = QPlainTextEdit()
self.edit_dampf_prompt.setPlainText(self.config.dampf_system_prompt)
- self.edit_dampf_prompt.setPlaceholderText("Standard-Systemprompt verwenden...")
+ self.edit_dampf_prompt.setPlaceholderText(t("settings.dampf_prompt.placeholder"))
self.edit_dampf_prompt.setMinimumHeight(72)
self.edit_dampf_prompt.setMaximumHeight(120)
self.edit_custom_term = QLineEdit()
- self.edit_custom_term.setPlaceholderText("z. B. Blitztext")
+ self.edit_custom_term.setPlaceholderText(t("settings.custom_terms.placeholder"))
self.edit_custom_term.returnPressed.connect(self._add_custom_term)
self.list_custom_terms = QListWidget()
self.list_custom_terms.addItems(self.config.custom_terms)
self.list_custom_terms.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.list_custom_terms.setMaximumHeight(120)
- self.btn_add_custom_term = QPushButton("Hinzufügen")
+ self.btn_add_custom_term = QPushButton(t("settings.custom_terms.add"))
self.btn_add_custom_term.clicked.connect(self._add_custom_term)
- self.btn_remove_custom_term = QPushButton("Ausgewählte entfernen")
+ self.btn_remove_custom_term = QPushButton(t("settings.custom_terms.remove_selected"))
self.btn_remove_custom_term.clicked.connect(self._remove_selected_custom_term)
custom_terms_input_layout = QHBoxLayout()
@@ -317,27 +318,27 @@ def init_ui(self) -> None:
custom_terms_widget = QWidget()
custom_terms_widget.setLayout(custom_terms_layout)
- form_llm.addRow("API-Key-Umgebung:", api_key_layout)
- form_llm.addRow(create_help_label("Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen (secrets.env). Für OpenRouter z. B. OPENROUTER_API_KEY."))
+ form_llm.addRow(t("settings.api_key_env.label"), api_key_layout)
+ form_llm.addRow(create_help_label(t("settings.api_key_env.help")))
- form_llm.addRow("LLM-Anbieter:", self.combo_llm_provider)
- form_llm.addRow(create_help_label("OpenAI = Standard. OpenRouter und 'Eigener Endpunkt' nutzen das OpenAI-kompatible API über eine eigene Basis-URL und ein eigenes Modell."))
- form_llm.addRow("Basis-URL (base_url):", self.edit_base_url)
- form_llm.addRow(create_help_label("Leer = OpenAI-Standard. Für OpenRouter: https://openrouter.ai/api/v1. Muss mit http:// oder https:// beginnen."))
- form_llm.addRow("LLM-Modell:", self.edit_llm_model)
- form_llm.addRow(create_help_label("Modellname beim Anbieter, z. B. 'gpt-4o-mini' (OpenAI) oder 'openai/gpt-4o' (OpenRouter)."))
+ form_llm.addRow(t("settings.llm_provider.label"), self.combo_llm_provider)
+ form_llm.addRow(create_help_label(t("settings.llm_provider.help")))
+ form_llm.addRow(t("settings.base_url.label"), self.edit_base_url)
+ form_llm.addRow(create_help_label(t("settings.base_url.help")))
+ form_llm.addRow(t("settings.llm_model.label"), self.edit_llm_model)
+ form_llm.addRow(create_help_label(t("settings.llm_model.help")))
- form_llm.addRow("Text-Verbesserer Tonfall:", self.combo_tone)
- form_llm.addRow("Schreibstil-Vorlage:", self.combo_writing_preset)
- form_llm.addRow(create_help_label("Vorlage für den Text-Verbesserer (z. B. E-Mail formell, Stichpunkte). Bei 'Standard' greift der Tonfall oben; jede andere Vorlage bestimmt den Schreibstil selbst und ersetzt den Tonfall."))
- form_llm.addRow("Emoji-Dichte:", self.combo_emoji)
+ form_llm.addRow(t("settings.tone.label"), self.combo_tone)
+ form_llm.addRow(t("settings.writing_preset.label"), self.combo_writing_preset)
+ form_llm.addRow(create_help_label(t("settings.writing_preset.help")))
+ form_llm.addRow(t("settings.emoji_density.label"), self.combo_emoji)
- form_llm.addRow("Dampf-Umschreiber Prompt:", self.edit_dampf_prompt)
- form_llm.addRow(create_help_label("Eigener System-Prompt, um wütende Aussagen in eine professionelle Form umzuschreiben."))
- form_llm.addRow("Eigennamen / Begriffe:", custom_terms_widget)
- form_llm.addRow(create_help_label("Wörter, Namen und Fachbegriffe, die bei Transkription und KI-Umschreibung exakt beibehalten werden sollen."))
+ form_llm.addRow(t("settings.dampf_prompt.label"), self.edit_dampf_prompt)
+ form_llm.addRow(create_help_label(t("settings.dampf_prompt.help")))
+ form_llm.addRow(t("settings.custom_terms.label"), custom_terms_widget)
+ form_llm.addRow(create_help_label(t("settings.custom_terms.help")))
- self.tabs.addTab(self._scrollable(tab_llm), "KI-Workflows")
+ self.tabs.addTab(self._scrollable(tab_llm), t("settings.tab.workflows"))
# Tab 3: Allgemein
tab_general = QWidget()
@@ -345,42 +346,54 @@ def init_ui(self) -> None:
form_general.setSpacing(10)
form_general.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
- self.check_autopaste = QCheckBox("Text automatisch einfügen (Auto-Paste)")
+ self.check_autopaste = QCheckBox(t("settings.autopaste.label"))
self.check_autopaste.setChecked(self.config.autopaste)
form_general.addRow(self.check_autopaste)
- form_general.addRow(create_help_label("Simuliert Strg+V nach Abschluss der Aufnahme. Benötigt das Tool 'ydotool'."))
+ form_general.addRow(create_help_label(t("settings.autopaste.help")))
self.edit_notes_folder = QLineEdit()
self.edit_notes_folder.setText(self.config.notes_folder)
self.edit_notes_folder.setPlaceholderText(str(Path.home() / "Blitztext-Notizen"))
- form_general.addRow("Diktat-Notizordner:", self.edit_notes_folder)
- form_general.addRow(create_help_label("Ordner für Diktat-Notizen (muss innerhalb von ~ liegen). Leer = Speichern deaktiviert."))
+ form_general.addRow(t("settings.notes_folder.label"), self.edit_notes_folder)
+ form_general.addRow(create_help_label(t("settings.notes_folder.help")))
self.spin_history_size = QComboBox()
self.spin_history_size.addItems(["10", "25", "50", "75", "100"])
self.spin_history_size.setCurrentText(str(self.config.history_size))
- form_general.addRow("Verlauf-Größe:", self.spin_history_size)
- form_general.addRow(create_help_label("Maximale Anzahl der im Verlauf gespeicherten Einträge."))
-
- self.btn_open_config = QPushButton("📄 Konfigurationsdatei öffnen")
+ form_general.addRow(t("settings.history_size.label"), self.spin_history_size)
+ form_general.addRow(create_help_label(t("settings.history_size.help")))
+
+ self.combo_ui_language = QComboBox()
+ for lang in LANGUAGES:
+ self.combo_ui_language.addItem(LANGUAGE_DISPLAY_NAMES[lang], lang)
+ ui_language_index = self.combo_ui_language.findData(self.config.ui_language)
+ self.combo_ui_language.setCurrentIndex(ui_language_index if ui_language_index >= 0 else 0)
+ form_general.addRow(t("settings.ui_language.label"), self.combo_ui_language)
+ form_general.addRow(create_help_label(t("settings.ui_language.help")))
+
+ self.btn_open_config = QPushButton(t("settings.open_config.button"))
self.btn_open_config.clicked.connect(self._open_config_file)
form_general.addRow(self.btn_open_config)
- form_general.addRow(create_help_label(
- "Öffnet config.json im Standard-Editor – für erweiterte Prompt- und "
- "Workflow-Anpassungen, die über die Felder oben hinausgehen."))
+ form_general.addRow(create_help_label(t("settings.open_config.help")))
# Dezente Versionsanzeige ganz unten auf der letzten Einstellungsseite
- version_label = QLabel(f"Version {APP_VERSION}")
+ version_label = QLabel(t("settings.version").format(version=APP_VERSION))
version_label.setStyleSheet("color: gray; font-size: 9px;")
version_label.setAlignment(Qt.AlignmentFlag.AlignRight)
form_general.addRow(version_label)
- self.tabs.addTab(self._scrollable(tab_general), "Allgemein")
+ self.tabs.addTab(self._scrollable(tab_general), t("settings.tab.general"))
layout.addWidget(self.tabs)
# Dialog Button Box
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
+ button_save = button_box.button(QDialogButtonBox.StandardButton.Save)
+ if button_save is not None:
+ button_save.setText(t("button.save"))
+ button_cancel = button_box.button(QDialogButtonBox.StandardButton.Cancel)
+ if button_cancel is not None:
+ button_cancel.setText(t("button.cancel"))
button_box.accepted.connect(self.save_settings)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
@@ -389,7 +402,9 @@ def _refresh_api_key_status(self) -> None:
env_name = self.edit_api_key_env.text().strip() or self.config.openai_api_key_env
env_value = os.environ.get(env_name, "").strip()
status = "gesetzt" if env_value else "nicht gesetzt"
- self.lbl_api_key_status.setText(f"Status: {status} ({env_name})")
+ self.lbl_api_key_status.setText(
+ t("settings.api_key.status").format(status=status, env_name=env_name)
+ )
def _on_llm_provider_changed(self) -> None:
provider = self.combo_llm_provider.currentData()
@@ -418,14 +433,14 @@ def _open_config_file(self) -> None:
if not opened:
QMessageBox.warning(
self,
- "Öffnen fehlgeschlagen",
- f"Konfigurationsdatei konnte nicht geöffnet werden:\n{self.config.config_file}",
+ t("settings.open_config.warning_title"),
+ t("settings.open_config.warning_message").format(path=self.config.config_file),
)
except Exception as e:
QMessageBox.critical(
self,
- "Fehler",
- f"Konfigurationsdatei konnte nicht geöffnet werden: {e}",
+ t("settings.open_config.error_title"),
+ t("settings.open_config.error_message").format(error=e),
)
def _collect_custom_terms(self) -> list[str]:
@@ -479,11 +494,17 @@ def save_settings(self) -> None:
self.config.autopaste = self.check_autopaste.isChecked()
self.config.notes_folder = self.edit_notes_folder.text().strip()
self.config.history_size = int(self.spin_history_size.currentText())
+ self.config.ui_language = self.combo_ui_language.currentData()
self.config.save()
+ set_language(self.config.ui_language)
self.accept()
except Exception as e:
- QMessageBox.critical(self, "Fehler beim Speichern", f"Konfiguration konnte nicht gespeichert werden: {e}")
+ QMessageBox.critical(
+ self,
+ t("settings.save_error.title"),
+ t("settings.save_error.message").format(error=e),
+ )
class _WorkerSignals(QObject):
@@ -578,6 +599,8 @@ def __init__(self, app: QApplication) -> None:
super().__init__()
self.app = app
self.config = Config.load()
+ set_language(self.config.ui_language)
+ self.app.setApplicationName(t("app.name"))
self.llm_service = self._build_llm_service()
self.audio_recorder = AudioRecorder()
@@ -641,7 +664,7 @@ def setup_tray(self) -> None:
if icon.isNull():
icon = self.app.style().standardIcon(QStyle.StandardPixmap.SP_MediaVolume)
self.tray_icon.setIcon(icon)
- self.tray_icon.setToolTip("Blitztext")
+ self.tray_icon.setToolTip(t("app.name"))
# Show window via single/double click on the tray icon
self.tray_icon.activated.connect(self._on_tray_activated)
@@ -650,41 +673,41 @@ def setup_tray(self) -> None:
self.menu = QMenu()
# Fenster anzeigen (grafischer Fallback)
- self.action_show_window = QAction("🪟 Fenster anzeigen", self)
+ self.action_show_window = QAction(f"🪟 {t('tray.show_window')}", self)
self.action_show_window.triggered.connect(self.show_main_window)
self.menu.addAction(self.action_show_window)
self.menu.addSeparator()
# Actions für die fünf Workflows
- self.action_transcription = QAction("🎙 Blitztext\tMeta+H", self)
+ self.action_transcription = QAction(f"{t('workflow.transcription.name')}\tMeta+H", self)
self.action_transcription.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.TRANSCRIPTION))
self.menu.addAction(self.action_transcription)
- self.action_local = QAction("🔒 Blitztext Lokal\tMeta+Shift+H", self)
+ self.action_local = QAction(f"{t('workflow.local.name')}\tMeta+Shift+H", self)
self.action_local.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.LOCAL))
self.menu.addAction(self.action_local)
- self.action_improver = QAction("✨ Blitztext+\tMeta+Shift+T", self)
+ self.action_improver = QAction(f"{t('workflow.text_improver.name')}\tMeta+Shift+T", self)
self.action_improver.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.TEXT_IMPROVER))
self.menu.addAction(self.action_improver)
- self.action_dampf = QAction("🔥 Blitztext $%&!\tMeta+Shift+D", self)
+ self.action_dampf = QAction(f"{t('workflow.dampf_ablassen.name')}\tMeta+Shift+D", self)
self.action_dampf.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.DAMPF_ABLASSEN))
self.menu.addAction(self.action_dampf)
- self.action_emoji = QAction("😊 Blitztext :)\tMeta+Shift+E", self)
+ self.action_emoji = QAction(f"{t('workflow.emoji_text.name')}\tMeta+Shift+E", self)
self.action_emoji.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.EMOJI_TEXT))
self.menu.addAction(self.action_emoji)
# Submenu: Schreibstil-Vorlage für Blitztext+ (Text-Verbesserer).
# Exklusive, abhakbare Auswahl gespeist aus dem Preset-Katalog; die
# Vorauswahl spiegelt die persistierte config.writing_preset wider.
- self.menu_preset = self.menu.addMenu("✨ Schreibstil-Vorlage")
+ self.menu_preset = self.menu.addMenu(f"✨ {t('tray.writing_preset')}")
self.preset_action_group = QActionGroup(self)
self.preset_action_group.setExclusive(True)
self.preset_actions: dict[str, QAction] = {}
for key in WRITING_PRESET_KEYS:
- preset_action = QAction(WRITING_PRESETS[key].display_name, self)
+ preset_action = QAction(t(f"preset.{key}.name"), self)
preset_action.setCheckable(True)
preset_action.triggered.connect(
lambda _checked=False, preset_key=key: self._on_writing_preset_selected(preset_key)
@@ -715,12 +738,12 @@ def setup_tray(self) -> None:
self.menu.addSeparator()
# Settings action
- self.action_settings = QAction("⚙ Einstellungen...", self)
+ self.action_settings = QAction(f"⚙ {t('tray.settings')}...", self)
self.action_settings.triggered.connect(self.show_settings_dialog)
self.menu.addAction(self.action_settings)
# Quit action
- self.action_quit = QAction("✕ Beenden", self)
+ self.action_quit = QAction(f"✕ {t('tray.quit')}", self)
self.action_quit.triggered.connect(self.quit_app)
self.menu.addAction(self.action_quit)
@@ -759,6 +782,31 @@ def update_menu_availability(self) -> None:
# nutzbaren LLM-Dienst wird das Submenu mitdeaktiviert.
self.menu_preset.setEnabled(available)
+ def _refresh_i18n_texts(self) -> None:
+ """Aktualisiert Texte, die nach Settings-Save bereits existieren."""
+ self.app.setApplicationName(t("app.name"))
+ if hasattr(self, "action_show_window"):
+ self.action_show_window.setText(f"🪟 {t('tray.show_window')}")
+ if hasattr(self, "action_transcription"):
+ self.action_transcription.setText(f"{t('workflow.transcription.name')}\tMeta+H")
+ if hasattr(self, "action_local"):
+ self.action_local.setText(f"{t('workflow.local.name')}\tMeta+Shift+H")
+ if hasattr(self, "action_improver"):
+ self.action_improver.setText(f"{t('workflow.text_improver.name')}\tMeta+Shift+T")
+ if hasattr(self, "action_dampf"):
+ self.action_dampf.setText(f"{t('workflow.dampf_ablassen.name')}\tMeta+Shift+D")
+ if hasattr(self, "action_emoji"):
+ self.action_emoji.setText(f"{t('workflow.emoji_text.name')}\tMeta+Shift+E")
+ if hasattr(self, "menu_preset"):
+ self.menu_preset.setTitle(f"✨ {t('tray.writing_preset')}")
+ self.action_settings.setText(f"⚙ {t('tray.settings')}...")
+ self.action_quit.setText(f"✕ {t('tray.quit')}")
+ if hasattr(self, "preset_actions"):
+ self._refresh_preset_menu()
+ if self._main_window is not None:
+ self._main_window.setWindowTitle(t("app.name"))
+ self.update_tray_state()
+
def _refresh_preset_menu(self) -> None:
"""Spiegelt die aktuelle ``config.writing_preset`` im Preset-Submenu.
@@ -768,6 +816,8 @@ def _refresh_preset_menu(self) -> None:
gewordener Config-Wert eine konsistente Auswahl ergibt.
"""
current_key = get_preset(self.config.writing_preset).key
+ for key, preset_action in self.preset_actions.items():
+ preset_action.setText(t(f"preset.{key}.name"))
action = self.preset_actions.get(current_key)
if action is not None:
action.setChecked(True)
@@ -818,6 +868,7 @@ def show_settings_dialog(self) -> None:
if dialog.exec() == QDialog.DialogCode.Accepted:
# Update LLM Service parameters from saved configuration
self.llm_service = self._build_llm_service()
+ self._refresh_i18n_texts()
self.update_menu_availability()
# Preset kann im Dialog geändert worden sein -> Häkchen angleichen.
self._refresh_preset_menu()
@@ -1108,7 +1159,7 @@ def update_tray_state(self) -> None:
self.tray_icon.setToolTip(f"Blitztext Fehler: {self._tray_error_message}")
elif self.state == "IDLE":
self.tray_icon.setIcon(self._tray_icons["IDLE"])
- self.tray_icon.setToolTip("Blitztext")
+ self.tray_icon.setToolTip(t("app.name"))
elif self.state == "RECORDING":
self.tray_icon.setIcon(self._tray_icons["RECORDING"])
wf_name = self.current_workflow.value if self.current_workflow else ""
@@ -1180,7 +1231,7 @@ def main() -> int:
)
return 1
- app.setApplicationName("Blitztext")
+ app.setApplicationName(t("app.name"))
app.setQuitOnLastWindowClosed(False)
# Design-System: Glass-Theme + Marken-App-Icon (Mikrofon + Blitz)
diff --git a/app/config.py b/app/config.py
index 6610513..33ca3c3 100644
--- a/app/config.py
+++ b/app/config.py
@@ -16,6 +16,8 @@
from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS
+from app.i18n import LANGUAGES as I18N_LANGUAGES, DEFAULT_LANGUAGE as I18N_DEFAULT_LANGUAGE
+
logger = logging.getLogger("blitztext.config")
DEFAULTS: dict[str, Any] = {
@@ -45,6 +47,7 @@
"custom_terms": [],
"writing_preset": DEFAULT_PRESET_KEY,
},
+ "ui_language": I18N_DEFAULT_LANGUAGE,
}
VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"}
@@ -56,6 +59,7 @@
VALID_LLM_PROVIDERS = {"openai", "openrouter", "custom"}
VALID_TTS_PROVIDERS = {"piper", "openai"}
VALID_OPENAI_TTS_VOICES = {"alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse", "marin", "cedar"}
+VALID_UI_LANGUAGES = set(I18N_LANGUAGES)
BASE_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
VALID_HOTKEY_KEYS = {
"KEY_LEFTALT", "KEY_RIGHTALT", "KEY_RIGHTCTRL", "KEY_LEFTCTRL",
@@ -199,6 +203,18 @@ def language(self) -> str:
def language(self, value: str) -> None:
self._data["language"] = value
+ @property
+ def ui_language(self) -> str:
+ """Gebe die UI-Sprache zurück, validiert."""
+ value = self._data.get("ui_language", DEFAULTS["ui_language"])
+ return value if value in VALID_UI_LANGUAGES else DEFAULTS["ui_language"]
+
+ @ui_language.setter
+ def ui_language(self, value: str) -> None:
+ if value not in VALID_UI_LANGUAGES:
+ raise ValueError(f"Ungueltige UI-Sprache: {value!r}. Gueltig: {sorted(VALID_UI_LANGUAGES)}")
+ self._data["ui_language"] = value
+
@property
def backend(self) -> str:
return self._data["backend"]
@@ -375,6 +391,8 @@ def _validate_and_sanitize(self) -> None:
self._data["model"] = "base"
if self._data.get("backend") not in VALID_BACKENDS:
self._data["backend"] = "openai-whisper"
+ if self._data.get("ui_language") not in VALID_UI_LANGUAGES:
+ self._data["ui_language"] = DEFAULTS["ui_language"]
if self._data.get("hotkey_mode") not in VALID_HOTKEY_MODES:
self._data["hotkey_mode"] = "toggle"
if self._data.get("transcription_hotkey") not in VALID_HOTKEY_KEYS:
diff --git a/app/history_panel.py b/app/history_panel.py
index efb602b..dbc25de 100644
--- a/app/history_panel.py
+++ b/app/history_panel.py
@@ -26,6 +26,8 @@
QWidget,
)
+from app.i18n import t
+
logger = logging.getLogger("blitztext.history")
@@ -58,7 +60,7 @@ def save_dictation_note(folder: str, text: str) -> Optional[str]:
filename = now.strftime("%Y-%m-%d_%H-%M-%S") + ".md"
heading = now.strftime("%Y-%m-%d %H:%M:%S")
path = os.path.join(resolved, filename)
- content = f"# Diktat {heading}\n\n{text}\n"
+ content = f"# {t('history.note.heading').format(heading=heading)}\n\n{text}\n"
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
@@ -92,10 +94,10 @@ def save_merged_dictation(folder: str, combined: str) -> Optional[str]:
try:
os.makedirs(resolved, exist_ok=True)
now = datetime.now()
- filename = "Diktat-zusammengefuehrt_" + now.strftime("%Y-%m-%d_%H-%M-%S") + ".md"
+ filename = t("history.note.merged_filename_prefix") + now.strftime("%Y-%m-%d_%H-%M-%S") + ".md"
heading = now.strftime("%Y-%m-%d %H:%M:%S")
path = os.path.join(resolved, filename)
- content = f"# Diktat (zusammengefuehrt) {heading}\n\n{combined}\n"
+ content = f"# {t('history.note.merged_heading').format(heading=heading)}\n\n{combined}\n"
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
@@ -171,7 +173,7 @@ def __init__(self, entry: HistoryEntry, parent: Optional[QWidget] = None) -> Non
layout.setSpacing(3)
top_row = QHBoxLayout()
- meta_text = f"{entry.timestamp} · {entry.word_count} Wörter"
+ meta_text = t("history.entry.meta").format(timestamp=entry.timestamp, count=entry.word_count)
if entry.is_dictation:
meta_text = f"\U0001f3a4 {meta_text}"
meta_label = QLabel(meta_text)
@@ -179,13 +181,13 @@ def __init__(self, entry: HistoryEntry, parent: Optional[QWidget] = None) -> Non
top_row.addWidget(meta_label, 1)
self._btn_copy = QPushButton("\U0001f4cb")
- self._btn_copy.setToolTip("In Zwischenablage kopieren")
+ self._btn_copy.setToolTip(t("history.tooltip.copy"))
self._btn_copy.setFixedSize(28, 24)
self._btn_copy.clicked.connect(self._copy_to_clipboard)
top_row.addWidget(self._btn_copy)
btn_delete = QPushButton("✕")
- btn_delete.setToolTip("Eintrag löschen")
+ btn_delete.setToolTip(t("history.tooltip.delete"))
btn_delete.setFixedSize(28, 24)
btn_delete.clicked.connect(lambda: self.deleted.emit(self.entry))
top_row.addWidget(btn_delete)
@@ -244,17 +246,17 @@ def _setup_ui(self) -> None:
layout.setSpacing(4)
header_row = QHBoxLayout()
- self._header_label = QLabel("Verlauf (0)")
+ self._header_label = QLabel(t("history.header").format(count=0))
self._header_label.setStyleSheet("font-size: 13px; font-weight: bold;")
header_row.addWidget(self._header_label, 1)
- self._btn_merge = QPushButton("Zusammenführen")
- self._btn_merge.setToolTip("Alle Diktat-Einträge kombinieren, als Datei speichern und kopieren")
+ self._btn_merge = QPushButton(t("history.button.merge"))
+ self._btn_merge.setToolTip(t("history.tooltip.merge"))
self._btn_merge.clicked.connect(self._merge_dictation)
self._btn_merge.hide()
header_row.addWidget(self._btn_merge)
- self._btn_clear = QPushButton("Alles löschen")
+ self._btn_clear = QPushButton(t("history.button.clear_all"))
self._btn_clear.clicked.connect(self._on_clear_clicked)
header_row.addWidget(self._btn_clear)
@@ -319,7 +321,7 @@ def _remove_widget_for_entry(self, entry: HistoryEntry) -> None:
def _on_clear_clicked(self) -> None:
if not self._clear_armed:
self._clear_armed = True
- self._btn_clear.setText("Sicher?")
+ self._btn_clear.setText(t("history.button.confirm_clear"))
self._btn_clear.setStyleSheet("color: #f44336; font-weight: bold;")
QTimer.singleShot(3000, self._disarm_clear)
else:
@@ -327,7 +329,7 @@ def _on_clear_clicked(self) -> None:
def _disarm_clear(self) -> None:
self._clear_armed = False
- self._btn_clear.setText("Alles löschen")
+ self._btn_clear.setText(t("history.button.clear_all"))
self._btn_clear.setStyleSheet("")
def clear_all(self) -> None:
@@ -337,7 +339,7 @@ def clear_all(self) -> None:
widget.deleteLater()
self._entry_widgets.clear()
self._clear_armed = False
- self._btn_clear.setText("Alles löschen")
+ self._btn_clear.setText(t("history.button.clear_all"))
self._btn_clear.setStyleSheet("")
self._update_header()
self._update_merge_button()
@@ -352,17 +354,17 @@ def _merge_dictation(self) -> None:
if path:
self.merged.emit(path)
- self._btn_merge.setText("✓ Gespeichert" if path else "✓ Kopiert")
+ self._btn_merge.setText(t("history.status.saved") if path else t("history.status.copied"))
self._btn_merge.setStyleSheet("color: #4caf50; font-weight: bold;")
QTimer.singleShot(2500, self._reset_merge_button)
def _reset_merge_button(self) -> None:
- self._btn_merge.setText("Zusammenführen")
+ self._btn_merge.setText(t("history.button.merge"))
self._btn_merge.setStyleSheet("")
def _update_header(self) -> None:
count = len(self._entries)
- self._header_label.setText(f"Verlauf ({count})")
+ self._header_label.setText(t("history.header").format(count=count))
self.count_changed.emit(count)
def _update_merge_button(self) -> None:
diff --git a/app/i18n.py b/app/i18n.py
new file mode 100644
index 0000000..e454b89
--- /dev/null
+++ b/app/i18n.py
@@ -0,0 +1,347 @@
+"""i18n-Modul für BlitztextLinux — Sprachen, Übersetzungen, Fallback.
+
+Reine Domänendaten + minimale Funktionen ohne Qt-Dependencies.
+"""
+from __future__ import annotations
+
+LANGUAGES: tuple[str, ...] = ("de", "en")
+DEFAULT_LANGUAGE: str = "de"
+
+LANGUAGE_DISPLAY_NAMES: dict[str, str] = {
+ "de": "Deutsch",
+ "en": "English",
+}
+
+TRANSLATIONS: dict[str, dict[str, str]] = {
+ "de": {
+ "app.name": "Blitztext",
+ "tray.settings": "Einstellungen",
+ "tray.quit": "Beenden",
+ "button.save": "Speichern",
+ "button.cancel": "Abbrechen",
+ "settings.window_title": "Blitztext Einstellungen",
+ "settings.tab.general": "Allgemein",
+ "settings.tab.speech": "Spracherkennung",
+ "settings.tab.workflows": "KI-Workflows",
+ "settings.whisper_model.label": "Whisper-Modell:",
+ "settings.whisper_model.help": "Wählen Sie die Modellgröße. Größere Modelle sind genauer, benötigen aber mehr Ressourcen.",
+ "settings.transcription_backend.label": "Transkription-Backend:",
+ "settings.transcription_backend.help": "faster-whisper ist deutlich schneller und ressourcenschonender.",
+ "settings.language.label": "Sprache:",
+ "settings.language.help": "Zweistelliger Ländercode (z. B. 'de', 'en') oder 'auto' für automatische Erkennung.",
+ "settings.audio_device.label": "Audio-Eingabegerät:",
+ "settings.audio_device.help": "'@DEFAULT_SOURCE@' nutzt das Standardmikrofon von PulseAudio/PipeWire.",
+ "settings.hotkey_mode.label": "Hotkey-Modus:",
+ "settings.hotkey_mode.help": "toggle: Einmal drücken zum Starten, erneut drücken zum Stoppen.\nhold: Gedrückt halten zum Aufnehmen, Loslassen zum Stoppen.",
+ "settings.record_key.label": "Aufnahme-Taste:",
+ "settings.record_key.help": "Einzelne Taste ohne Modifier (z. B. KEY_LEFTALT). Änderung wird sofort übernommen.",
+ "settings.api_key_env.label": "API-Key-Umgebung:",
+ "settings.api_key_env.help": "Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen (secrets.env). Für OpenRouter z. B. OPENROUTER_API_KEY.",
+ "settings.api_key.status": "Status: {status} ({env_name})",
+ "settings.api_key.legacy_notice": "Legacy openai_api_key gefunden. Er wird beim nächsten Speichern entfernt.",
+ "settings.llm_provider.label": "LLM-Anbieter:",
+ "settings.llm_provider.custom_endpoint": "Eigener Endpunkt",
+ "settings.llm_provider.help": "OpenAI = Standard. OpenRouter und 'Eigener Endpunkt' nutzen das OpenAI-kompatible API über eine eigene Basis-URL und ein eigenes Modell.",
+ "settings.base_url.label": "Basis-URL (base_url):",
+ "settings.base_url.help": "Leer = OpenAI-Standard. Für OpenRouter: https://openrouter.ai/api/v1. Muss mit http:// oder https:// beginnen.",
+ "settings.llm_model.label": "LLM-Modell:",
+ "settings.llm_model.help": "Modellname beim Anbieter, z. B. 'gpt-4o-mini' (OpenAI) oder 'openai/gpt-4o' (OpenRouter).",
+ "settings.tone.label": "Text-Verbesserer Tonfall:",
+ "settings.writing_preset.label": "Schreibstil-Vorlage:",
+ "settings.writing_preset.help": "Vorlage für den Text-Verbesserer (z. B. E-Mail formell, Stichpunkte). Bei 'Standard' greift der Tonfall oben; jede andere Vorlage bestimmt den Schreibstil selbst und ersetzt den Tonfall.",
+ "settings.emoji_density.label": "Emoji-Dichte:",
+ "settings.dampf_prompt.label": "Dampf-Umschreiber Prompt:",
+ "settings.dampf_prompt.placeholder": "Standard-Systemprompt verwenden...",
+ "settings.dampf_prompt.help": "Eigener System-Prompt, um wütende Aussagen in eine professionelle Form umzuschreiben.",
+ "settings.custom_terms.label": "Eigennamen / Begriffe:",
+ "settings.custom_terms.placeholder": "z. B. Blitztext",
+ "settings.custom_terms.add": "Hinzufügen",
+ "settings.custom_terms.remove_selected": "Ausgewählte entfernen",
+ "settings.custom_terms.help": "Wörter, Namen und Fachbegriffe, die bei Transkription und KI-Umschreibung exakt beibehalten werden sollen.",
+ "settings.autopaste.label": "Text automatisch einfügen (Auto-Paste)",
+ "settings.autopaste.help": "Simuliert Strg+V nach Abschluss der Aufnahme. Benötigt das Tool 'ydotool'.",
+ "settings.notes_folder.label": "Diktat-Notizordner:",
+ "settings.notes_folder.help": "Ordner für Diktat-Notizen (muss innerhalb von ~ liegen). Leer = Speichern deaktiviert.",
+ "settings.history_size.label": "Verlauf-Größe:",
+ "settings.history_size.help": "Maximale Anzahl der im Verlauf gespeicherten Einträge.",
+ "settings.ui_language.label": "Oberflächen-Sprache:",
+ "settings.ui_language.help": "Sprache für Fenster, Tray-Menü und Dialoge. Änderung wird nach dem Speichern übernommen.",
+ "settings.open_config.button": "📄 Konfigurationsdatei öffnen",
+ "settings.open_config.help": "Öffnet config.json im Standard-Editor – für erweiterte Prompt- und Workflow-Anpassungen, die über die Felder oben hinausgehen.",
+ "settings.open_config.warning_title": "Öffnen fehlgeschlagen",
+ "settings.open_config.warning_message": "Konfigurationsdatei konnte nicht geöffnet werden:\n{path}",
+ "settings.open_config.error_title": "Fehler",
+ "settings.open_config.error_message": "Konfigurationsdatei konnte nicht geöffnet werden: {error}",
+ "settings.version": "Version {version}",
+ "settings.save_error.title": "Fehler beim Speichern",
+ "settings.save_error.message": "Konfiguration konnte nicht gespeichert werden: {error}",
+ "tts.window_title": "Vorlesen",
+ "tts.text.placeholder": "Text zum Vorlesen eingeben…",
+ "tts.provider.label": "Anbieter:",
+ "tts.provider.piper_local": "Piper lokal",
+ "tts.provider.openai_cloud": "OpenAI Cloud",
+ "tts.voice.label": "Stimme:",
+ "tts.voice.none_found": "(keine Stimmen gefunden)",
+ "tts.model.label": "Modell:",
+ "tts.speed.label": "Tempo:",
+ "tts.speed.very_fast": "Sehr Schnell",
+ "tts.speed.fast": "Schnell",
+ "tts.speed.normal": "Normal",
+ "tts.speed.slow": "Langsam",
+ "tts.speed.very_slow": "Sehr Langsam",
+ "tts.button.close": "Schließen",
+ "tts.button.replay": "🔁 Nochmal",
+ "tts.button.pause": "Pause",
+ "tts.button.resume": "Fortsetzen",
+ "tts.button.speak": "Vorlesen",
+ "tts.button.stop": "Stopp",
+ "tts.error.piper_not_found": "Piper nicht gefunden. Installieren: pip install piper-tts und Stimmen nach ~/.local/share/piper-voices legen.",
+ "tts.error.openai_not_available": "OpenAI Cloud-TTS ist nicht verfuegbar. Bitte OPENAI_API_KEY in ~/.config/blitztext-linux/secrets.env setzen.",
+ "tts.consent.title": "OpenAI Cloud-TTS aktivieren?",
+ "tts.consent.message": "OpenAI Cloud-TTS sendet den eingegebenen Text zur Sprachsynthese an die OpenAI-Server. Lokale Texte verlassen damit deinen Rechner.\n\nMoechtest du Cloud-TTS aktivieren?",
+ "tts.status.openai_ready": "OpenAI Cloud-TTS bereit.",
+ "tts.status.piper_ready": "Piper bereit.",
+ "tts.status.playback": "Wiedergabe…",
+ "tts.status.paused": "Pausiert",
+ "tts.status.no_text": "Kein Text.",
+ "tts.status.no_voice_path": "Keine Stimme in ~/.local/share/piper-voices gefunden.",
+ "tts.status.synthesis": "Synthese…",
+ "tts.status.openai_not_confirmed": "OpenAI Cloud-TTS wurde nicht bestaetigt.",
+ "tts.status.cloud_synthesis": "Cloud-Synthese…",
+ "tts.status.cancelled": "Abgebrochen.",
+ "tts.status.error": "Fehler: {message}",
+ "tts.status.done": "Fertig.",
+ "history.tooltip.copy": "In Zwischenablage kopieren",
+ "history.tooltip.delete": "Eintrag löschen",
+ "history.tooltip.merge": "Alle Diktat-Einträge kombinieren, als Datei speichern und kopieren",
+ "history.header": "Verlauf ({count})",
+ "history.button.merge": "Zusammenführen",
+ "history.button.clear_all": "Alles löschen",
+ "history.button.confirm_clear": "Sicher?",
+ "history.status.saved": "✓ Gespeichert",
+ "history.status.copied": "✓ Kopiert",
+ "history.entry.meta": "{timestamp} · {count} Wörter",
+ "history.note.heading": "Diktat {heading}",
+ "history.note.merged_heading": "Diktat (zusammengefuehrt) {heading}",
+ "history.note.merged_filename_prefix": "Diktat-zusammengefuehrt_",
+ "tray.show_window": "Fenster anzeigen",
+ "tray.writing_preset": "Schreibstil-Vorlage",
+ "workflow.transcription.name": "🎙 Blitztext",
+ "workflow.local.name": "🔒 Blitztext Lokal",
+ "workflow.text_improver.name": "✨ Blitztext+",
+ "workflow.dampf_ablassen.name": "🔥 Blitztext $%&!",
+ "workflow.emoji_text.name": "😊 Blitztext :)",
+ "preset.standard.name": "Standard (Text verbessern)",
+ "preset.email_formal.name": "E-Mail – formell",
+ "preset.email_locker.name": "E-Mail – locker",
+ "preset.stichpunkte.name": "Stichpunkte",
+ "preset.zusammenfassung.name": "Zusammenfassung",
+ "preset.du_form.name": "Persönlich (Du-Form)",
+ "preset.sie_form.name": "Höflich (Sie-Form)",
+ "preset.kurz_praezise.name": "Kurz & präzise",
+ },
+ "en": {
+ "app.name": "Blitztext",
+ "tray.settings": "Settings",
+ "tray.quit": "Quit",
+ "button.save": "Save",
+ "button.cancel": "Cancel",
+ "settings.window_title": "Blitztext Settings",
+ "settings.tab.general": "General",
+ "settings.tab.speech": "Speech Recognition",
+ "settings.tab.workflows": "AI Workflows",
+ "settings.whisper_model.label": "Whisper model:",
+ "settings.whisper_model.help": "Choose the model size. Larger models are more accurate but require more resources.",
+ "settings.transcription_backend.label": "Transcription backend:",
+ "settings.transcription_backend.help": "faster-whisper is significantly faster and more resource-efficient.",
+ "settings.language.label": "Language:",
+ "settings.language.help": "Two-letter country code (e.g. 'de', 'en') or 'auto' for automatic detection.",
+ "settings.audio_device.label": "Audio input device:",
+ "settings.audio_device.help": "'@DEFAULT_SOURCE@' uses the default PulseAudio/PipeWire microphone.",
+ "settings.hotkey_mode.label": "Hotkey mode:",
+ "settings.hotkey_mode.help": "toggle: Press once to start, press again to stop.\nhold: Hold to record, release to stop.",
+ "settings.record_key.label": "Recording key:",
+ "settings.record_key.help": "Single key without modifiers (e.g. KEY_LEFTALT). Changes apply immediately.",
+ "settings.api_key_env.label": "API key environment:",
+ "settings.api_key_env.help": "Only the environment variable name is stored. The key itself is read from os.environ (secrets.env). For OpenRouter, for example OPENROUTER_API_KEY.",
+ "settings.api_key.status": "Status: {status} ({env_name})",
+ "settings.api_key.legacy_notice": "Legacy openai_api_key found. It will be removed the next time settings are saved.",
+ "settings.llm_provider.label": "LLM provider:",
+ "settings.llm_provider.custom_endpoint": "Custom endpoint",
+ "settings.llm_provider.help": "OpenAI = default. OpenRouter and 'Custom endpoint' use the OpenAI-compatible API with a custom base URL and custom model.",
+ "settings.base_url.label": "Base URL (base_url):",
+ "settings.base_url.help": "Empty = OpenAI default. For OpenRouter: https://openrouter.ai/api/v1. Must start with http:// or https://.",
+ "settings.llm_model.label": "LLM model:",
+ "settings.llm_model.help": "Model name at the provider, e.g. 'gpt-4o-mini' (OpenAI) or 'openai/gpt-4o' (OpenRouter).",
+ "settings.tone.label": "Text improver tone:",
+ "settings.writing_preset.label": "Writing style preset:",
+ "settings.writing_preset.help": "Preset for the text improver (e.g. formal email, bullet points). With 'Standard', the tone above is used; every other preset determines the writing style itself and replaces the tone.",
+ "settings.emoji_density.label": "Emoji density:",
+ "settings.dampf_prompt.label": "Dampf rewrite prompt:",
+ "settings.dampf_prompt.placeholder": "Use default system prompt...",
+ "settings.dampf_prompt.help": "Custom system prompt for rewriting angry statements into a professional form.",
+ "settings.custom_terms.label": "Proper names / terms:",
+ "settings.custom_terms.placeholder": "e.g. Blitztext",
+ "settings.custom_terms.add": "Add",
+ "settings.custom_terms.remove_selected": "Remove selected",
+ "settings.custom_terms.help": "Words, names, and technical terms that should be preserved exactly during transcription and AI rewriting.",
+ "settings.autopaste.label": "Insert text automatically (Auto-Paste)",
+ "settings.autopaste.help": "Simulates Ctrl+V after recording finishes. Requires the 'ydotool' tool.",
+ "settings.notes_folder.label": "Dictation notes folder:",
+ "settings.notes_folder.help": "Folder for dictation notes (must be inside ~). Empty = saving disabled.",
+ "settings.history_size.label": "History size:",
+ "settings.history_size.help": "Maximum number of entries stored in history.",
+ "settings.ui_language.label": "Interface language:",
+ "settings.ui_language.help": "Language for windows, tray menu, and dialogs. Changes apply after saving.",
+ "settings.open_config.button": "📄 Open configuration file",
+ "settings.open_config.help": "Opens config.json in the default editor for advanced prompt and workflow adjustments beyond the fields above.",
+ "settings.open_config.warning_title": "Open failed",
+ "settings.open_config.warning_message": "Configuration file could not be opened:\n{path}",
+ "settings.open_config.error_title": "Error",
+ "settings.open_config.error_message": "Configuration file could not be opened: {error}",
+ "settings.version": "Version {version}",
+ "settings.save_error.title": "Error while saving",
+ "settings.save_error.message": "Configuration could not be saved: {error}",
+ "tts.window_title": "Read Aloud",
+ "tts.text.placeholder": "Enter text to read aloud…",
+ "tts.provider.label": "Provider:",
+ "tts.provider.piper_local": "Piper local",
+ "tts.provider.openai_cloud": "OpenAI Cloud",
+ "tts.voice.label": "Voice:",
+ "tts.voice.none_found": "(no voices found)",
+ "tts.model.label": "Model:",
+ "tts.speed.label": "Speed:",
+ "tts.speed.very_fast": "Very Fast",
+ "tts.speed.fast": "Fast",
+ "tts.speed.normal": "Normal",
+ "tts.speed.slow": "Slow",
+ "tts.speed.very_slow": "Very Slow",
+ "tts.button.close": "Close",
+ "tts.button.replay": "🔁 Again",
+ "tts.button.pause": "Pause",
+ "tts.button.resume": "Resume",
+ "tts.button.speak": "Read Aloud",
+ "tts.button.stop": "Stop",
+ "tts.error.piper_not_found": "Piper not found. Install with: pip install piper-tts and place voices in ~/.local/share/piper-voices.",
+ "tts.error.openai_not_available": "OpenAI Cloud-TTS is not available. Please set OPENAI_API_KEY in ~/.config/blitztext-linux/secrets.env.",
+ "tts.consent.title": "Enable OpenAI Cloud-TTS?",
+ "tts.consent.message": "OpenAI Cloud-TTS sends the entered text to OpenAI servers for speech synthesis. Local texts will leave your computer.\n\nDo you want to enable Cloud-TTS?",
+ "tts.status.openai_ready": "OpenAI Cloud-TTS ready.",
+ "tts.status.piper_ready": "Piper ready.",
+ "tts.status.playback": "Playback…",
+ "tts.status.paused": "Paused",
+ "tts.status.no_text": "No text.",
+ "tts.status.no_voice_path": "No voice found in ~/.local/share/piper-voices.",
+ "tts.status.synthesis": "Synthesizing…",
+ "tts.status.openai_not_confirmed": "OpenAI Cloud-TTS was not confirmed.",
+ "tts.status.cloud_synthesis": "Cloud synthesis…",
+ "tts.status.cancelled": "Cancelled.",
+ "tts.status.error": "Error: {message}",
+ "tts.status.done": "Done.",
+ "history.tooltip.copy": "Copy to clipboard",
+ "history.tooltip.delete": "Delete entry",
+ "history.tooltip.merge": "Combine all dictation entries, save as a file, and copy",
+ "history.header": "History ({count})",
+ "history.button.merge": "Merge",
+ "history.button.clear_all": "Delete all",
+ "history.button.confirm_clear": "Sure?",
+ "history.status.saved": "✓ Saved",
+ "history.status.copied": "✓ Copied",
+ "history.entry.meta": "{timestamp} · {count} words",
+ "history.note.heading": "Dictation {heading}",
+ "history.note.merged_heading": "Dictation (merged) {heading}",
+ "history.note.merged_filename_prefix": "Dictation-merged_",
+ "tray.show_window": "Show window",
+ "tray.writing_preset": "Writing style preset",
+ "workflow.transcription.name": "🎙 Blitztext",
+ "workflow.local.name": "🔒 Blitztext Local",
+ "workflow.text_improver.name": "✨ Blitztext+",
+ "workflow.dampf_ablassen.name": "🔥 Blitztext $%&!",
+ "workflow.emoji_text.name": "😊 Blitztext :)",
+ "preset.standard.name": "Standard (improve text)",
+ "preset.email_formal.name": "Email - formal",
+ "preset.email_locker.name": "Email - casual",
+ "preset.stichpunkte.name": "Bullet points",
+ "preset.zusammenfassung.name": "Summary",
+ "preset.du_form.name": "Personal (Du form)",
+ "preset.sie_form.name": "Polite (Sie form)",
+ "preset.kurz_praezise.name": "Short & precise",
+ },
+}
+
+_active_language: str = DEFAULT_LANGUAGE
+
+
+def get_language() -> str:
+ """Gebe die aktive UI-Sprache zurück.
+
+ Returns:
+ "de" oder "en" (oder eine andere registrierte Sprache).
+ """
+ return _active_language
+
+
+def set_language(lang: str) -> None:
+ """Setze die aktive UI-Sprache.
+
+ Args:
+ lang: Sprach-Code, muss in LANGUAGES sein.
+
+ Raises:
+ ValueError: Wenn die Sprache nicht registriert ist.
+ """
+ global _active_language
+ if lang not in LANGUAGES:
+ raise ValueError(f"Ungültige Sprache: {lang!r}. Gültig: {LANGUAGES}")
+ _active_language = lang
+
+
+def t(key: str) -> str:
+ """Hole Übersetzung für Schlüssel in aktiver Sprache.
+
+ Fallback:
+ 1. Wert in aktiver Sprache
+ 2. Wert in DEFAULT_LANGUAGE ("de")
+ 3. Der Schlüssel selbst
+
+ Args:
+ key: Übersetzungs-Schlüssel (z.B. "app.name", "button.save").
+
+ Returns:
+ Übersetzter String oder Fallback.
+ """
+ active = get_language()
+
+ # Versuch in aktiver Sprache
+ if active in TRANSLATIONS and key in TRANSLATIONS[active]:
+ return TRANSLATIONS[active][key]
+
+ # Fallback auf Default-Sprache
+ if DEFAULT_LANGUAGE in TRANSLATIONS and key in TRANSLATIONS[DEFAULT_LANGUAGE]:
+ return TRANSLATIONS[DEFAULT_LANGUAGE][key]
+
+ # Fallback auf Key selbst
+ return key
+
+
+def missing_keys() -> set[str]:
+ """Prüfe Vollständigkeits-Invariante.
+
+ Returns:
+ Menge von Keys, die in mindestens einer Sprache fehlen
+ (sollte leer sein).
+ """
+ if not TRANSLATIONS:
+ return set()
+
+ all_keys = set()
+ for lang_dict in TRANSLATIONS.values():
+ all_keys.update(lang_dict.keys())
+
+ missing = set()
+ for lang, lang_dict in TRANSLATIONS.items():
+ for key in all_keys:
+ if key not in lang_dict:
+ missing.add(f"{lang}: {key}")
+
+ return missing
diff --git a/app/main_window.py b/app/main_window.py
index 5454c20..ddffae3 100644
--- a/app/main_window.py
+++ b/app/main_window.py
@@ -28,7 +28,7 @@
)
from app.llm_service import WorkflowType, LLM_WORKFLOWS
-from app.workflows import WORKFLOW_META
+from app.i18n import t
from app import theme
# Reihenfolge der Workflows in der Auswahl
@@ -159,7 +159,7 @@ def __init__(self, controller, parent: Optional[QWidget] = None) -> None:
self._state = "IDLE"
self._rec_start: Optional[float] = None
- self.setWindowTitle("Blitztext")
+ self.setWindowTitle(t("app.name"))
self.setFixedWidth(256)
self.resize(256, 232)
@@ -179,9 +179,7 @@ def _setup_ui(self) -> None:
self._workflow_combo = QComboBox()
self._workflow_combo.setMinimumHeight(28)
for wf in _WORKFLOW_ORDER:
- meta = WORKFLOW_META.get(wf, {})
- label = str(meta.get("display_name", wf.value)).strip()
- self._workflow_combo.addItem(label, userData=wf)
+ self._workflow_combo.addItem(t(f"workflow.{wf.value}.name"), userData=wf)
layout.addWidget(self._workflow_combo)
# Hero: runder Record-Shutter
diff --git a/app/tts_window.py b/app/tts_window.py
index 67c3d29..0ed80cf 100644
--- a/app/tts_window.py
+++ b/app/tts_window.py
@@ -23,17 +23,11 @@
QWidget,
)
+from app.i18n import t
+
PIPER_VENV_PATH = str(Path(__file__).resolve().parents[1] / ".venv" / "bin" / "piper")
VOICES_DIR = Path.home() / ".local" / "share" / "piper-voices"
TTS_WAV = os.path.join(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()), "blitztext-tts.wav")
-PIPER_INSTALL_HINT = (
- "Piper nicht gefunden. Installieren: pip install piper-tts und Stimmen nach "
- "~/.local/share/piper-voices legen."
-)
-OPENAI_TTS_INSTALL_HINT = (
- "OpenAI Cloud-TTS ist nicht verfuegbar. Bitte OPENAI_API_KEY in "
- "~/.config/blitztext-linux/secrets.env setzen."
-)
OPENAI_TTS_MODEL_DEFAULT = "gpt-4o-mini-tts"
OPENAI_TTS_VOICE_DEFAULT = "marin"
OPENAI_TTS_VOICES = [
@@ -53,11 +47,18 @@
]
OPENAI_TTS_MODEL_OPTIONS = [OPENAI_TTS_MODEL_DEFAULT, "tts-1", "tts-1-hd"]
OPENAI_TTS_TIMEOUT = 30.0
-OPENAI_TTS_CONSENT_TEXT = (
- "OpenAI Cloud-TTS sendet den eingegebenen Text zur Sprachsynthese an die "
- "OpenAI-Server. Lokale Texte verlassen damit deinen Rechner.\n\n"
- "Moechtest du Cloud-TTS aktivieren?"
-)
+
+
+def _piper_install_hint() -> str:
+ return t("tts.error.piper_not_found")
+
+
+def _openai_tts_install_hint() -> str:
+ return t("tts.error.openai_not_available")
+
+
+def _openai_tts_consent_text() -> str:
+ return t("tts.consent.message")
def _scrub_secret(text: str, secret: str) -> str:
@@ -219,7 +220,7 @@ class TtsWindow(QDialog):
def __init__(self, config, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._config = config
- self.setWindowTitle("Vorlesen")
+ self.setWindowTitle(t("tts.window_title"))
self.resize(430, 340)
self._piper_proc: Optional[QProcess] = None
self._aplay_proc: Optional[QProcess] = None
@@ -241,22 +242,22 @@ def _setup_ui(self) -> None:
layout.setSpacing(6)
self._text_edit = QTextEdit()
- self._text_edit.setPlaceholderText("Text zum Vorlesen eingeben…")
+ self._text_edit.setPlaceholderText(t("tts.text.placeholder"))
layout.addWidget(self._text_edit, 1)
provider_row = QHBoxLayout()
provider_row.setSpacing(6)
- provider_row.addWidget(QLabel("Anbieter:"))
+ provider_row.addWidget(QLabel(t("tts.provider.label")))
self._provider_combo = QComboBox()
- self._provider_combo.addItem("Piper lokal", "piper")
- self._provider_combo.addItem("OpenAI Cloud", "openai")
+ self._provider_combo.addItem(t("tts.provider.piper_local"), "piper")
+ self._provider_combo.addItem(t("tts.provider.openai_cloud"), "openai")
self._provider_combo.currentIndexChanged.connect(self._on_provider_changed)
provider_row.addWidget(self._provider_combo, 1)
layout.addLayout(provider_row)
voice_row = QHBoxLayout()
voice_row.setSpacing(6)
- voice_row.addWidget(QLabel("Stimme:"))
+ voice_row.addWidget(QLabel(t("tts.voice.label")))
self._voice_combo = QComboBox()
self._voice_combo.currentIndexChanged.connect(self._on_voice_changed)
voice_row.addWidget(self._voice_combo, 1)
@@ -264,7 +265,7 @@ def _setup_ui(self) -> None:
model_row = QHBoxLayout()
model_row.setSpacing(6)
- model_row.addWidget(QLabel("Modell:"))
+ model_row.addWidget(QLabel(t("tts.model.label")))
self._model_edit = QLineEdit()
self._model_edit.setPlaceholderText(OPENAI_TTS_MODEL_DEFAULT)
self._model_edit.editingFinished.connect(self._on_model_changed)
@@ -273,13 +274,13 @@ def _setup_ui(self) -> None:
speed_row = QHBoxLayout()
speed_row.setSpacing(6)
- speed_row.addWidget(QLabel("Tempo:"))
+ speed_row.addWidget(QLabel(t("tts.speed.label")))
self._speed_combo = QComboBox()
- self._speed_combo.addItem("Sehr Schnell", 0.6)
- self._speed_combo.addItem("Schnell", 0.8)
- self._speed_combo.addItem("Normal", 1.0)
- self._speed_combo.addItem("Langsam", 1.25)
- self._speed_combo.addItem("Sehr Langsam", 1.5)
+ self._speed_combo.addItem(t("tts.speed.very_fast"), 0.6)
+ self._speed_combo.addItem(t("tts.speed.fast"), 0.8)
+ self._speed_combo.addItem(t("tts.speed.normal"), 1.0)
+ self._speed_combo.addItem(t("tts.speed.slow"), 1.25)
+ self._speed_combo.addItem(t("tts.speed.very_slow"), 1.5)
saved_speed = float(self._config.tts_speed)
idx = self._speed_combo.findData(saved_speed)
self._speed_combo.setCurrentIndex(idx if idx >= 0 else 2)
@@ -294,21 +295,21 @@ def _setup_ui(self) -> None:
btn_row = QHBoxLayout()
btn_row.addStretch()
- self._btn_close = QPushButton("Schließen")
+ self._btn_close = QPushButton(t("tts.button.close"))
self._btn_close.clicked.connect(self.accept)
btn_row.addWidget(self._btn_close)
- self._btn_replay = QPushButton("🔁 Nochmal")
+ self._btn_replay = QPushButton(t("tts.button.replay"))
self._btn_replay.clicked.connect(self._on_replay_clicked)
self._btn_replay.setEnabled(False)
btn_row.addWidget(self._btn_replay)
- self._btn_pause = QPushButton("Pause")
+ self._btn_pause = QPushButton(t("tts.button.pause"))
self._btn_pause.clicked.connect(self._on_pause_clicked)
self._btn_pause.setEnabled(False)
btn_row.addWidget(self._btn_pause)
- self._btn_speak = QPushButton("Vorlesen")
+ self._btn_speak = QPushButton(t("tts.button.speak"))
self._btn_speak.clicked.connect(self._on_speak_clicked)
btn_row.addWidget(self._btn_speak)
@@ -334,7 +335,7 @@ def _populate_voices(self) -> None:
else:
voices = list_voices()
if not voices:
- self._voice_combo.addItem("(keine Stimmen gefunden)")
+ self._voice_combo.addItem(t("tts.voice.none_found"))
self._voice_combo.setEnabled(False)
else:
for label, path in voices:
@@ -353,17 +354,17 @@ def _refresh_status_hint(self) -> None:
if provider == "openai":
service = CloudTtsService(self._config)
if service.is_available():
- self._status_label.setText("OpenAI Cloud-TTS bereit.")
+ self._status_label.setText(t("tts.status.openai_ready"))
self._status_label.setStyleSheet("color: #4caf50;")
else:
- self._status_label.setText(OPENAI_TTS_INSTALL_HINT)
+ self._status_label.setText(_openai_tts_install_hint())
self._status_label.setStyleSheet("color: #f44336;")
else:
if self._piper_path:
- self._status_label.setText("Piper bereit.")
+ self._status_label.setText(t("tts.status.piper_ready"))
self._status_label.setStyleSheet("color: #4caf50;")
else:
- self._status_label.setText(PIPER_INSTALL_HINT)
+ self._status_label.setText(_piper_install_hint())
self._status_label.setStyleSheet("color: #f44336;")
self._update_speak_button_state()
@@ -399,8 +400,8 @@ def _ensure_openai_consent(self) -> bool:
return True
answer = QMessageBox.question(
self,
- "OpenAI Cloud-TTS aktivieren?",
- OPENAI_TTS_CONSENT_TEXT,
+ t("tts.consent.title"),
+ _openai_tts_consent_text(),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
@@ -516,13 +517,13 @@ def _on_pause_clicked(self) -> None:
if self._is_paused:
os.kill(pid, signal.SIGCONT)
self._is_paused = False
- self._btn_pause.setText("Pause")
- self._status_label.setText("Wiedergabe…")
+ self._btn_pause.setText(t("tts.button.pause"))
+ self._status_label.setText(t("tts.status.playback"))
else:
os.kill(pid, signal.SIGSTOP)
self._is_paused = True
- self._btn_pause.setText("Fortsetzen")
- self._status_label.setText("Pausiert")
+ self._btn_pause.setText(t("tts.button.resume"))
+ self._status_label.setText(t("tts.status.paused"))
except OSError:
pass
@@ -530,7 +531,7 @@ def _start_tts(self, text: Optional[str] = None) -> None:
if text is None:
text = self._current_tts_text()
if not text:
- self._status_label.setText("Kein Text.")
+ self._status_label.setText(t("tts.status.no_text"))
self._status_label.setStyleSheet("color: #f44336;")
return
self._last_tts_text = text
@@ -544,13 +545,13 @@ def _start_tts(self, text: Optional[str] = None) -> None:
def _start_piper_tts(self, text: str) -> None:
if not self._piper_path:
- self._status_label.setText(PIPER_INSTALL_HINT)
+ self._status_label.setText(_piper_install_hint())
self._status_label.setStyleSheet("color: #f44336;")
return
model_path = self._current_voice()
if not model_path:
- self._status_label.setText("Keine Stimme in ~/.local/share/piper-voices gefunden.")
+ self._status_label.setText(t("tts.status.no_voice_path"))
self._status_label.setStyleSheet("color: #f44336;")
return
@@ -565,9 +566,9 @@ def _start_piper_tts(self, text: str) -> None:
proc.errorOccurred.connect(self._on_tts_error)
self._piper_proc = proc
- self._status_label.setText("Synthese…")
+ self._status_label.setText(t("tts.status.synthesis"))
self._status_label.setStyleSheet("")
- self._btn_speak.setText("Stopp")
+ self._btn_speak.setText(t("tts.button.stop"))
self._update_speak_button_state()
proc.start()
@@ -576,13 +577,13 @@ def _start_piper_tts(self, text: str) -> None:
def _start_cloud_tts(self, text: str) -> None:
if not self._config.tts_openai_consent:
- self._status_label.setText("OpenAI Cloud-TTS wurde nicht bestaetigt.")
+ self._status_label.setText(t("tts.status.openai_not_confirmed"))
self._status_label.setStyleSheet("color: #f44336;")
self._update_speak_button_state()
return
service = CloudTtsService(self._config)
if not service.is_available():
- self._status_label.setText(OPENAI_TTS_INSTALL_HINT)
+ self._status_label.setText(_openai_tts_install_hint())
self._status_label.setStyleSheet("color: #f44336;")
self._update_speak_button_state()
return
@@ -601,9 +602,9 @@ def _start_cloud_tts(self, text: str) -> None:
self._cloud_thread = thread
self._cloud_worker = worker
- self._status_label.setText("Cloud-Synthese…")
+ self._status_label.setText(t("tts.status.cloud_synthesis"))
self._status_label.setStyleSheet("")
- self._btn_speak.setText("Stopp")
+ self._btn_speak.setText(t("tts.button.stop"))
self._btn_pause.setEnabled(False)
thread.start()
self._update_speak_button_state()
@@ -637,11 +638,11 @@ def _stop_tts(self) -> None:
self._detach_cloud_thread()
- self._btn_speak.setText("Vorlesen")
+ self._btn_speak.setText(t("tts.button.speak"))
self._btn_pause.setEnabled(False)
self._is_paused = False
- self._btn_pause.setText("Pause")
- self._status_label.setText("Abgebrochen.")
+ self._btn_pause.setText(t("tts.button.pause"))
+ self._status_label.setText(t("tts.status.cancelled"))
self._status_label.setStyleSheet("color: #ff9800;")
self._update_speak_button_state()
QTimer.singleShot(2000, self._clear_status)
@@ -688,7 +689,7 @@ def _on_detached_thread_finished(self, thread: QThread) -> None:
@pyqtSlot(str)
def _on_cloud_finished(self, wav_path: str) -> None:
self._cleanup_cloud_state()
- self._status_label.setText("Wiedergabe…")
+ self._status_label.setText(t("tts.status.playback"))
program, args = _playback_command(wav_path)
aplay = QProcess(self)
aplay.setProgram(program)
@@ -697,20 +698,20 @@ def _on_cloud_finished(self, wav_path: str) -> None:
aplay.errorOccurred.connect(self._on_tts_error)
self._aplay_proc = aplay
self._is_paused = False
- self._btn_pause.setText("Pause")
+ self._btn_pause.setText(t("tts.button.pause"))
self._btn_pause.setEnabled(True)
- self._btn_speak.setText("Stopp")
+ self._btn_speak.setText(t("tts.button.stop"))
aplay.start()
@pyqtSlot(str)
def _on_cloud_error(self, message: str) -> None:
self._cleanup_cloud_state()
- self._status_label.setText(f"Fehler: {message}")
+ self._status_label.setText(t("tts.status.error").format(message=message))
self._status_label.setStyleSheet("color: #f44336;")
- self._btn_speak.setText("Vorlesen")
+ self._btn_speak.setText(t("tts.button.speak"))
self._btn_pause.setEnabled(False)
self._is_paused = False
- self._btn_pause.setText("Pause")
+ self._btn_pause.setText(t("tts.button.pause"))
self._update_speak_button_state()
QTimer.singleShot(2500, self._clear_status)
@@ -729,16 +730,16 @@ def _on_piper_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -
stderr = bytes(proc.readAllStandardError()).decode("utf-8", "replace").strip()
proc.deleteLater()
msg = stderr or f"Exit {exit_code}"
- self._status_label.setText(f"Fehler: {msg}")
+ self._status_label.setText(t("tts.status.error").format(message=msg))
self._status_label.setStyleSheet("color: #f44336;")
- self._btn_speak.setText("Vorlesen")
+ self._btn_speak.setText(t("tts.button.speak"))
self._update_speak_button_state()
QTimer.singleShot(2500, self._clear_status)
return
if proc is not None:
proc.deleteLater()
- self._status_label.setText("Wiedergabe…")
+ self._status_label.setText(t("tts.status.playback"))
program, args = _playback_command(TTS_WAV)
aplay = QProcess(self)
aplay.setProgram(program)
@@ -747,7 +748,7 @@ def _on_piper_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -
aplay.errorOccurred.connect(self._on_tts_error)
self._aplay_proc = aplay
self._is_paused = False
- self._btn_pause.setText("Pause")
+ self._btn_pause.setText(t("tts.button.pause"))
self._btn_pause.setEnabled(True)
aplay.start()
@@ -755,19 +756,19 @@ def _on_piper_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -
def _on_aplay_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None:
proc = self._aplay_proc
self._aplay_proc = None
- self._btn_speak.setText("Vorlesen")
+ self._btn_speak.setText(t("tts.button.speak"))
self._btn_pause.setEnabled(False)
self._is_paused = False
- self._btn_pause.setText("Pause")
+ self._btn_pause.setText(t("tts.button.pause"))
if exit_status == QProcess.ExitStatus.NormalExit and exit_code == 0:
- self._status_label.setText("Fertig.")
+ self._status_label.setText(t("tts.status.done"))
self._status_label.setStyleSheet("color: #4caf50;")
else:
stderr = ""
if proc is not None:
stderr = bytes(proc.readAllStandardError()).decode("utf-8", "replace").strip()
msg = stderr or f"Exit {exit_code}"
- self._status_label.setText(f"Fehler: {msg}")
+ self._status_label.setText(t("tts.status.error").format(message=msg))
self._status_label.setStyleSheet("color: #f44336;")
if proc is not None:
proc.deleteLater()
@@ -777,14 +778,14 @@ def _on_aplay_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -
@pyqtSlot(QProcess.ProcessError)
def _on_tts_error(self, error: QProcess.ProcessError) -> None:
if error == QProcess.ProcessError.FailedToStart:
- self._status_label.setText(PIPER_INSTALL_HINT if self._current_provider() == "piper" else OPENAI_TTS_INSTALL_HINT)
+ self._status_label.setText(_piper_install_hint() if self._current_provider() == "piper" else _openai_tts_install_hint())
else:
- self._status_label.setText(f"Fehler: {error.name}")
+ self._status_label.setText(t("tts.status.error").format(message=error.name))
self._status_label.setStyleSheet("color: #f44336;")
- self._btn_speak.setText("Vorlesen")
+ self._btn_speak.setText(t("tts.button.speak"))
self._btn_pause.setEnabled(False)
self._is_paused = False
- self._btn_pause.setText("Pause")
+ self._btn_pause.setText(t("tts.button.pause"))
for attr in ("_piper_proc", "_aplay_proc"):
proc = getattr(self, attr, None)
if proc is not None:
diff --git a/tests/test_config.py b/tests/test_config.py
index 303f9d3..19157df 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -260,3 +260,64 @@ def test_env_value_wins_over_legacy_fallback(self, config_dir, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "env-placeholder")
loaded = BlitztextConfig(config_dir=config_dir)
assert loaded.resolve_openai_api_key() == "env-placeholder"
+
+
+class TestUILanguage:
+ """Tests für ui_language Config-Property."""
+
+ def test_ui_language_default_is_de(self, config):
+ """Default ui_language ist 'de'."""
+ assert config.ui_language == "de"
+
+ def test_ui_language_setter_accepts_en(self, config):
+ """Setter akzeptiert 'en'."""
+ config.ui_language = "en"
+ assert config.ui_language == "en"
+
+ def test_ui_language_setter_accepts_de(self, config):
+ """Setter akzeptiert 'de'."""
+ config.ui_language = "de"
+ assert config.ui_language == "de"
+
+ def test_ui_language_setter_rejects_invalid(self, config):
+ """Setter wirft ValueError für ungültige Sprachen."""
+ with pytest.raises(ValueError) as exc_info:
+ config.ui_language = "fr"
+ assert "fr" in str(exc_info.value).lower() or "language" in str(exc_info.value).lower()
+
+ def test_ui_language_in_as_dict(self, config):
+ """as_dict() enthält ui_language."""
+ config.ui_language = "en"
+ data = config.as_dict()
+ assert "ui_language" in data
+ assert data["ui_language"] == "en"
+
+ def test_ui_language_persists_on_save_load(self, config, config_dir):
+ """ui_language wird in JSON gespeichert und geladen."""
+ config.ui_language = "en"
+ config.save()
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.ui_language == "en"
+
+ def test_ui_language_corrupted_sanitized_to_de(self, config_dir):
+ """Ungültiges ui_language wird auf 'de' saniert."""
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config.json").write_text(
+ json.dumps({"model": "base", "ui_language": "fr"}),
+ encoding="utf-8"
+ )
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.ui_language == "de"
+
+ def test_ui_language_missing_defaults_to_de(self, config_dir):
+ """Fehlender ui_language wird auf 'de' gesetzt."""
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config.json").write_text(
+ json.dumps({"model": "base"}),
+ encoding="utf-8"
+ )
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.ui_language == "de"
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 0000000..fa9be46
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,189 @@
+"""Tests für BlitztextLinux i18n-Modul — Sprachen, Übersetzungen, Fallback."""
+from __future__ import annotations
+
+import re
+
+import pytest
+
+from app.i18n import LANGUAGES, DEFAULT_LANGUAGE, TRANSLATIONS, t, get_language, set_language, missing_keys
+
+
+PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
+
+
+@pytest.fixture(autouse=True)
+def reset_language():
+ """Reset auf DEFAULT_LANGUAGE nach jedem Test."""
+ set_language(DEFAULT_LANGUAGE)
+ yield
+ set_language(DEFAULT_LANGUAGE)
+
+
+class TestLanguages:
+ """Test Sprachen-Konstanten."""
+
+ def test_languages_tuple(self):
+ """LANGUAGES ist ein Tuple mit de und en."""
+ assert LANGUAGES == ("de", "en")
+ assert isinstance(LANGUAGES, tuple)
+
+ def test_default_language(self):
+ """DEFAULT_LANGUAGE ist 'de'."""
+ assert DEFAULT_LANGUAGE == "de"
+ assert DEFAULT_LANGUAGE in LANGUAGES
+
+
+class TestTranslations:
+ """Test Übersetzungs-Dict."""
+
+ def test_translations_structure(self):
+ """TRANSLATIONS ist ein Dict mit de und en Keys."""
+ assert isinstance(TRANSLATIONS, dict)
+ assert set(TRANSLATIONS.keys()) == {"de", "en"}
+
+ def test_translations_completeness(self):
+ """Beide Sprachen haben identische Key-Mengen."""
+ de_keys = set(TRANSLATIONS["de"].keys())
+ en_keys = set(TRANSLATIONS["en"].keys())
+ assert de_keys == en_keys, f"Key-Mismatch: de-only={de_keys - en_keys}, en-only={en_keys - de_keys}"
+
+ def test_missing_keys_empty(self):
+ """missing_keys() meldet keine fehlenden Übersetzungen."""
+ assert missing_keys() == set()
+
+ def test_translations_no_empty_values(self):
+ """Keine leeren Strings in Übersetzungen."""
+ for lang, strings in TRANSLATIONS.items():
+ for key, value in strings.items():
+ assert value.strip(), f"{lang}[{key!r}] ist leer"
+
+ def test_translation_placeholders_match_between_languages(self):
+ """Format-Placeholders sind in de/en identisch."""
+ for key in TRANSLATIONS["de"]:
+ de_placeholders = set(PLACEHOLDER_RE.findall(TRANSLATIONS["de"][key]))
+ en_placeholders = set(PLACEHOLDER_RE.findall(TRANSLATIONS["en"][key]))
+ assert de_placeholders == en_placeholders, (
+ f"Placeholder-Mismatch fuer {key}: de={de_placeholders}, en={en_placeholders}"
+ )
+
+ def test_base_keys_seeded(self):
+ """Grundstock an Keys ist vorhanden."""
+ expected_keys = {"app.name", "tray.settings", "tray.quit", "button.save", "button.cancel"}
+ actual_keys = set(TRANSLATIONS["de"].keys())
+ assert expected_keys.issubset(actual_keys), f"Fehlende Base-Keys: {expected_keys - actual_keys}"
+
+ def test_tts_and_history_namespaces_seeded(self):
+ """Wave-B3 Namespaces enthalten die erwarteten UI-Schlüssel."""
+ expected_keys = {
+ "tts.window_title",
+ "tts.button.speak",
+ "tts.error.piper_not_found",
+ "tts.error.openai_not_available",
+ "tts.consent.message",
+ "history.header",
+ "history.tooltip.copy",
+ "history.button.merge",
+ "history.status.saved",
+ "history.status.copied",
+ }
+ actual_keys = set(TRANSLATIONS["de"].keys())
+ assert expected_keys.issubset(actual_keys), f"Fehlende Wave-B3-Keys: {expected_keys - actual_keys}"
+
+
+class TestTranslationFunction:
+ """Test t() Funktion."""
+
+ def test_t_returns_current_language_value(self):
+ """t(key) gibt Wert der aktiven Sprache."""
+ set_language("de")
+ de_value = t("app.name")
+ assert isinstance(de_value, str)
+ assert de_value.strip()
+
+ set_language("en")
+ en_value = t("app.name")
+ assert isinstance(en_value, str)
+ assert en_value.strip()
+ # Die Werte können unterschiedlich sein
+ # (wir prüfen nur, dass sie jeweils non-empty sind)
+
+ def test_t_switches_with_set_language(self):
+ """Nach set_language() liefert t() Wert der neuen Sprache."""
+ assert get_language() == "de"
+ de_name = t("app.name")
+
+ set_language("en")
+ en_name = t("app.name")
+
+ # Wir prüfen, dass beide unterschiedliche Werte haben können
+ # (oder gleich, aber beide gültig)
+ assert de_name.strip()
+ assert en_name.strip()
+
+ def test_t_unknown_key_returns_key_itself(self):
+ """t(unknown_key) gibt den Key selbst zurück (Fallback)."""
+ unknown = "unknown.key.that.does.not.exist"
+ result = t(unknown)
+ assert result == unknown
+
+ def test_t_missing_in_active_falls_back_to_de(self):
+ """Fehlt ein Key in aktiver Sprache, Fallback auf 'de'."""
+ # Diesen Test können wir nur mit künstlich manipulierten TRANSLATIONS testen
+ # oder wir vertrauen auf die Vollständigkeits-Invariante
+ # -> Hier nur dokumentarisch: wenn es passiert, fällt zurück auf 'de'
+ # Wir prüfen, dass die Invariante erzwingt, dass es nicht passiert
+ set_language("en")
+ for key in TRANSLATIONS["en"].keys():
+ result = t(key)
+ assert result == TRANSLATIONS["en"][key]
+
+
+class TestGetLanguage:
+ """Test get_language()."""
+
+ def test_get_language_default(self):
+ """get_language() gibt DEFAULT_LANGUAGE zurück."""
+ assert get_language() == "de"
+
+ def test_get_language_after_set(self):
+ """get_language() gibt aktive Sprache nach set_language()."""
+ set_language("en")
+ assert get_language() == "en"
+
+ set_language("de")
+ assert get_language() == "de"
+
+
+class TestSetLanguage:
+ """Test set_language()."""
+
+ def test_set_language_valid_de(self):
+ """set_language('de') funktioniert."""
+ set_language("de")
+ assert get_language() == "de"
+
+ def test_set_language_valid_en(self):
+ """set_language('en') funktioniert."""
+ set_language("en")
+ assert get_language() == "en"
+
+ def test_set_language_invalid_raises_valueerror(self):
+ """set_language('fr') wirft ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ set_language("fr")
+ assert "fr" in str(exc_info.value).lower() or "language" in str(exc_info.value).lower()
+
+ def test_set_language_empty_string_raises_valueerror(self):
+ """set_language('') wirft ValueError."""
+ with pytest.raises(ValueError):
+ set_language("")
+
+ def test_set_language_none_raises_error(self):
+ """set_language(None) wirft Fehler."""
+ with pytest.raises((ValueError, TypeError)):
+ set_language(None) # type: ignore
+
+ def test_set_language_case_sensitive(self):
+ """set_language('EN') wirft ValueError (case-sensitive)."""
+ with pytest.raises(ValueError):
+ set_language("EN")
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index 00ead8e..b4a06e1 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -3,10 +3,11 @@
import json
from types import SimpleNamespace
-from unittest.mock import patch
+from unittest.mock import Mock, patch
from app.blitztext_linux import SettingsDialog
from app.config import BlitztextConfig
+from app.i18n import DEFAULT_LANGUAGE, get_language, set_language
def _fake_self(config_dir):
@@ -130,7 +131,7 @@ def isChecked(self):
return self._checked
-def _fake_save_self(config_dir, preset_key):
+def _fake_save_self(config_dir, preset_key, ui_language="de"):
config = BlitztextConfig(config_dir=config_dir)
return SimpleNamespace(
config=config,
@@ -152,6 +153,10 @@ def _fake_save_self(config_dir, preset_key):
check_autopaste=_Check(True),
edit_notes_folder=_Edit(""),
spin_history_size=_Combo("50"),
+ combo_ui_language=_Combo(
+ text="English" if ui_language == "en" else "Deutsch",
+ data=ui_language,
+ ),
accept=lambda: None,
)
@@ -179,6 +184,65 @@ def test_save_settings_persists_llm_provider_fields(tmp_path):
assert reloaded.llm_model == "openai/gpt-4o"
+def test_save_settings_persists_and_applies_ui_language(tmp_path):
+ config_dir = tmp_path / ".config" / "blitztext-linux"
+ fake = _fake_save_self(config_dir, "standard", ui_language="en")
+
+ try:
+ set_language("de")
+ SettingsDialog.save_settings(fake)
+
+ reloaded = BlitztextConfig(config_dir=config_dir)
+ assert reloaded.ui_language == "en"
+ assert get_language() == "en"
+ finally:
+ set_language(DEFAULT_LANGUAGE)
+
+
+def test_app_init_applies_configured_ui_language(tmp_path):
+ from app.blitztext_linux import BlitztextApp
+
+ config = BlitztextConfig(config_dir=tmp_path / ".config" / "blitztext-linux")
+ config.ui_language = "en"
+ fake_qapp = Mock()
+
+ try:
+ set_language("de")
+ with patch("app.blitztext_linux.Config.load", return_value=config), \
+ patch.object(BlitztextApp, "setup_tray"), \
+ patch.object(BlitztextApp, "start_hotkey_worker"), \
+ patch("app.blitztext_linux.AudioRecorder"), \
+ patch("app.blitztext_linux.PasteService"):
+ BlitztextApp(fake_qapp)
+
+ assert get_language() == "en"
+ finally:
+ set_language(DEFAULT_LANGUAGE)
+
+
+def test_refresh_i18n_texts_updates_existing_shell():
+ from app.blitztext_linux import BlitztextApp
+
+ fake = SimpleNamespace(
+ app=Mock(),
+ action_settings=Mock(),
+ action_quit=Mock(),
+ _main_window=Mock(),
+ update_tray_state=Mock(),
+ )
+
+ try:
+ set_language("en")
+ BlitztextApp._refresh_i18n_texts(fake)
+
+ fake.action_settings.setText.assert_called_once_with("⚙ Settings...")
+ fake.action_quit.setText.assert_called_once_with("✕ Quit")
+ fake._main_window.setWindowTitle.assert_called_once_with("Blitztext")
+ fake.update_tray_state.assert_called_once()
+ finally:
+ set_language(DEFAULT_LANGUAGE)
+
+
def test_build_llm_service_includes_base_url_and_model(tmp_path):
# Regression: beide Konstruktionsorte (Init + Settings-Save) gehen ueber
# _build_llm_service, damit base_url/model nicht an einer Stelle fehlen.
diff --git a/tests/test_smoke_launch.py b/tests/test_smoke_launch.py
index 571f5fc..cd45bb6 100644
--- a/tests/test_smoke_launch.py
+++ b/tests/test_smoke_launch.py
@@ -17,33 +17,47 @@
``WHISPER_GUI_TESTS=1`` (Display/Offscreen noetig).
"""
import os
+from unittest.mock import patch
import pytest
+from app.i18n import DEFAULT_LANGUAGE, get_language, set_language
+
_GUI = os.environ.get("WHISPER_GUI_TESTS") == "1"
gui_only = pytest.mark.skipif(not _GUI, reason="benötigt WHISPER_GUI_TESTS=1 (Display)")
@gui_only
-def test_app_boots_idles_and_exits_clean():
+@pytest.mark.parametrize("ui_language", ["de", "en"])
+def test_app_boots_idles_and_exits_clean(ui_language, tmp_path):
"""Echte App bootet offscreen, idlet kurz und beendet mit Exit 0."""
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication
from app.blitztext_linux import BlitztextApp
+ from app.config import Config
- qapp = QApplication.instance() or QApplication([])
- app = BlitztextApp(qapp)
- # Kein echter evdev-Thread waehrend des Idle-Loops (Pattern aus gui_app).
- app.stop_hotkey_worker()
- # Hauptfenster offscreen hochfahren -- exakt wie main().
- app.show_main_window()
-
- # Kurz idlen, dann deterministisch sauber beenden.
- QTimer.singleShot(50, qapp.quit)
- exit_code = qapp.exec()
+ config = Config.load(tmp_path / "config.json")
+ config.ui_language = ui_language
- assert exit_code == 0
-
- # Idempotentes Cleanup, damit kein Thread in Folgetests nachhaengt.
- app.stop_hotkey_worker()
+ qapp = QApplication.instance() or QApplication([])
+ app = None
+ try:
+ with patch("app.blitztext_linux.Config.load", return_value=config):
+ app = BlitztextApp(qapp)
+ # Kein echter evdev-Thread waehrend des Idle-Loops (Pattern aus gui_app).
+ app.stop_hotkey_worker()
+ # Hauptfenster offscreen hochfahren -- exakt wie main().
+ app.show_main_window()
+
+ # Kurz idlen, dann deterministisch sauber beenden.
+ QTimer.singleShot(50, qapp.quit)
+ exit_code = qapp.exec()
+
+ assert exit_code == 0
+ assert get_language() == ui_language
+ finally:
+ # Idempotentes Cleanup, damit kein Thread in Folgetests nachhaengt.
+ if app is not None:
+ app.stop_hotkey_worker()
+ set_language(DEFAULT_LANGUAGE)