From bdd9516a988e5726458b353cda23397b3be8e215 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 04:15:39 +0000 Subject: [PATCH] feat: live language switch for all UI, board transfer & per-tile colors - i18n: shared translator (src/i18n.py) + bus.languageChanged; dashboard table headers, filters, version sub-rows, board bar, tiles, context menus, dialogs, messages and shortcut table now switch live (DE/EN/ES/ ZH/JA/RU, 174 new keys, 100% parity) - fix: dashboard context menu dispatched exports by German menu text, which would have broken all exports after translation - translator: auto_register flag; the running app no longer writes empty keys into the bundled translations.json - regression fix: tile context menu "Move to board" / "Duplicate to board" (incl. "New board ..."), atomic Storage.transfer_item() - board rename button, "Pin to board" in prompt tree context menu - feature: per-tile color (BoardItem.color, validated #RRGGBB, presets + custom color), carried along on move/duplicate - board grid: column count follows dock width; stale tiles hidden on rebuild (no overlapping ghost tiles) - 32 new tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JjQnwo3ePCbGvz8VAZhnrD --- CHANGELOG.md | 19 + EXPORTFORMAT.md | 4 + locales/translations.json | 1392 +++++++++++++++++ src/appearance_dialog.py | 53 +- src/board_manager.py | 320 +++- src/clipboard_manager.py | 3 +- src/copy_settings_dialog.py | 33 +- src/dashboard.py | 225 +-- src/event_bus.py | 1 + src/i18n.py | 103 ++ src/models.py | 17 + src/pdf_exporter.py | 7 +- src/profiprompt.py | 123 +- src/prompt_dialog.py | 151 +- src/shortcuts_dialog.py | 11 +- src/storage.py | 89 +- src/storage_actions.py | 9 +- tests/conftest.py | 9 + ...ive_board_transfer_tile_colors_20261006.py | 396 +++++ translator.py | 9 +- 20 files changed, 2615 insertions(+), 359 deletions(-) create mode 100644 src/i18n.py create mode 100644 tests/test_i18n_live_board_transfer_tile_colors_20261006.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8372c87..e370b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Sprachwechsel für alle Bereiche, Kacheln auf andere Boards senden/duplizieren & Kachelfarben (2026-10-06) + +- **Sprachwechsel live für die gesamte Oberfläche (`src/i18n.py`, alle Widgets):** + - Bisher übersetzte ein Sprachwechsel nur die Menüleiste; Tabellenköpfe des Prompt-Baums (Titel/Zweck/Tags/Erstellt/Aktualisiert), Filterleiste, Versions-Unterzeilen (Kopier-Buttons), Board-Leiste, Kacheln, Kontextmenüs, Dialoge, Export-/Fehlermeldungen und die Tastenspalte der Shortcut-Tabelle blieben deutsch. + - Neues Modul `i18n.py` mit einem gemeinsamen Translator (`tr()`); `MainWindow.change_language()` verteilt `bus.languageChanged`, worauf Dashboard, Prompt-Baum und BoardManager per `retranslate_ui()` live neu beschriften – kein Neustart mehr nötig. Die modale Hinweisbox wurde durch eine Statusleisten-Meldung ersetzt. + - 174 neue Übersetzungs-Keys in allen 6 Sprachen (DE/EN/ES/ZH/JA/RU, 299 Keys, 100 % Parität). + - **Bugfix:** Das Dashboard-Kontextmenü verglich Export-Aktionen per deutschem Menütext (`chosen.text().startswith(...)`); nach Übersetzung wären alle Exporte wirkungslos gewesen. Jetzt Vergleich per Aktionsobjekt. + - `TranslationSystem(auto_register=...)`: Die laufende App schreibt fehlende Keys nicht mehr mit leeren Werten in die gebündelte `translations.json`. +- **Regression behoben – Kacheln auf andere Boards senden/duplizieren (`src/board_manager.py`, `src/storage.py`):** + - Kachel-Kontextmenü: „Auf Board verschieben ▸“ und „Auf Board duplizieren ▸“ mit allen anderen Boards sowie „Neues Board …“. + - `Storage.transfer_item()` / `move_item_to_board()` / `copy_item_to_board()` aktualisieren Quell- und Ziel-Board in einem atomaren Schreibvorgang (keine verlorenen/doppelten Kacheln); Duplikate auf dem Ziel-Board werden mit Hinweis abgewiesen. + - Neuer Button „Umbenennen“ in der Board-Leiste (`Storage.rename_board()`); Prompt-Baum-Kontextmenü „Auf Board heften ▸“ als tastaturbedienbare Alternative zu Drag & Drop. +- **Feature – individuelle Kachelfarbe (`src/models.py`, `src/board_manager.py`):** + - Kontextmenü „Kachelfarbe ▸“ mit Standardfarbe, 9 Vorgaben und „Eigene Farbe …“ (Farbdialog). Gespeichert pro Board-Kachel im neuen optionalen Feld `BoardItem.color` (`#RRGGBB`), wird beim Verschieben/Duplizieren mitgenommen. + - Strikte Validierung (`normalize_item_color`): ungültige Werte aus fremden/alten JSONs werden verworfen (kein QSS-Injection, kein Crash); Dateien ohne `color` laden unverändert. +- **Board-Raster (`src/board_manager.py`):** + - Spaltenzahl folgt jetzt der Dock-Breite (statt fest 3 Spalten mit horizontalem Scrollen); alte Kacheln werden beim Neuaufbau sofort ausgeblendet (behebt kurzzeitig überlappende Geister-Kacheln). +- **Tests (`tests/test_i18n_live_board_transfer_tile_colors_20261006.py`):** 32 neue Tests (Live-Sprachwechsel inkl. Tabellenköpfen, Key-Abdeckung aller `tr()`-Aufrufe mit Platzhalter-Parität, Transfer/Verschieben/Duplizieren, Kachelfarben, responsives Raster). Gesamtsuite: 325 passed, 3 skipped. + ### Barrierefreiheit: Kachel-Tastatursteuerung, Version-AccessibleNames & Schema-Drift-Härtung (2026-10-04) - **Kachel-Tastaturbedienung & Signale (`src/board_manager.py`):** diff --git a/EXPORTFORMAT.md b/EXPORTFORMAT.md index d8f2751..ed851c1 100644 --- a/EXPORTFORMAT.md +++ b/EXPORTFORMAT.md @@ -82,6 +82,10 @@ Pflichtfelder: - `version_id`: referenzierte Version-ID oder `null`, wenn das Item auf den Haupt-Prompt zeigt. - `created_at`: ISO-8601-Zeitstempel. +Optionale Felder (additiv, seit 2026-10-06): + +- `color`: individuelle Kachelfarbe als `#RRGGBB` oder `null` für die Standardfarbe. Importe ohne dieses Feld bleiben gültig; ungültige Werte werden beim Laden verworfen. + ## Beispiel ```json diff --git a/locales/translations.json b/locales/translations.json index d10a56a..8bd8268 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -998,5 +998,1397 @@ "zh": "将整个提示词库备份为 JSON 格式", "ja": "ライブラリ全体を JSON 形式で保存", "ru": "Сохранить всю библиотеку в формате JSON" + }, + "Theme-Auswahl": { + "de": "Theme-Auswahl", + "en": "Theme selection", + "es": "Selección de tema", + "zh": "主题选择", + "ja": "テーマの選択", + "ru": "Выбор темы" + }, + "Auswahl zwischen dunklem und hellem Farbschema": { + "de": "Auswahl zwischen dunklem und hellem Farbschema", + "en": "Choose between dark and light color scheme", + "es": "Elegir entre esquema de colores oscuro y claro", + "zh": "在深色和浅色配色方案之间选择", + "ja": "ダークとライトの配色を選択", + "ru": "Выбор между тёмной и светлой цветовой схемой" + }, + "Farbe Hauptprompt-Kacheln": { + "de": "Farbe Hauptprompt-Kacheln", + "en": "Main prompt tile color", + "es": "Color de mosaicos de prompt principal", + "zh": "主提示磁贴颜色", + "ja": "メインプロンプトタイルの色", + "ru": "Цвет плиток основных промптов" + }, + "Farbe Versionsprompt-Kacheln": { + "de": "Farbe Versionsprompt-Kacheln", + "en": "Version prompt tile color", + "es": "Color de mosaicos de versión", + "zh": "版本提示磁贴颜色", + "ja": "バージョンタイルの色", + "ru": "Цвет плиток версий" + }, + "Farbe Hauptprompt-Kacheln:": { + "de": "Farbe Hauptprompt-Kacheln:", + "en": "Main prompt tile color:", + "es": "Color de mosaicos de prompt principal:", + "zh": "主提示磁贴颜色:", + "ja": "メインプロンプトタイルの色:", + "ru": "Цвет плиток основных промптов:" + }, + "Farbe Versionsprompt-Kacheln:": { + "de": "Farbe Versionsprompt-Kacheln:", + "en": "Version prompt tile color:", + "es": "Color de mosaicos de versión:", + "zh": "版本提示磁贴颜色:", + "ja": "バージョンタイルの色:", + "ru": "Цвет плиток версий:" + }, + "Kachelfarben zurücksetzen": { + "de": "Kachelfarben zurücksetzen", + "en": "Reset tile colors", + "es": "Restablecer colores de mosaicos", + "zh": "重置磁贴颜色", + "ja": "タイルの色をリセット", + "ru": "Сбросить цвета плиток" + }, + "Setzt die Kachelfarben auf die Standardwerte zurück": { + "de": "Setzt die Kachelfarben auf die Standardwerte zurück", + "en": "Resets the tile colors to their defaults", + "es": "Restablece los colores de los mosaicos a los valores predeterminados", + "zh": "将磁贴颜色恢复为默认值", + "ja": "タイルの色を既定値に戻します", + "ru": "Сбрасывает цвета плиток на значения по умолчанию" + }, + "Standardfarben wiederherstellen": { + "de": "Standardfarben wiederherstellen", + "en": "Restore default colors", + "es": "Restaurar colores predeterminados", + "zh": "恢复默认颜色", + "ja": "既定の色に戻す", + "ru": "Восстановить цвета по умолчанию" + }, + "Darstellungs-Einstellungen speichern und anwenden": { + "de": "Darstellungs-Einstellungen speichern und anwenden", + "en": "Save and apply appearance settings", + "es": "Guardar y aplicar la configuración de apariencia", + "zh": "保存并应用外观设置", + "ja": "表示設定を保存して適用", + "ru": "Сохранить и применить настройки оформления" + }, + "Einstellungen speichern (Enter)": { + "de": "Einstellungen speichern (Enter)", + "en": "Save settings (Enter)", + "es": "Guardar configuración (Intro)", + "zh": "保存设置(Enter)", + "ja": "設定を保存 (Enter)", + "ru": "Сохранить настройки (Enter)" + }, + "Änderungen verwerfen und Dialog schließen": { + "de": "Änderungen verwerfen und Dialog schließen", + "en": "Discard changes and close dialog", + "es": "Descartar cambios y cerrar el diálogo", + "zh": "放弃更改并关闭对话框", + "ja": "変更を破棄してダイアログを閉じる", + "ru": "Отменить изменения и закрыть диалог" + }, + "Abbrechen (Esc)": { + "de": "Abbrechen (Esc)", + "en": "Cancel (Esc)", + "es": "Cancelar (Esc)", + "zh": "取消(Esc)", + "ja": "キャンセル (Esc)", + "ru": "Отмена (Esc)" + }, + "Hauptprompts": { + "de": "Hauptprompts", + "en": "main prompts", + "es": "prompts principales", + "zh": "主提示", + "ja": "メインプロンプト", + "ru": "основных промптов" + }, + "Versionsprompts": { + "de": "Versionsprompts", + "en": "version prompts", + "es": "versiones", + "zh": "版本提示", + "ja": "バージョン", + "ru": "версий" + }, + "Kachelfarbe für {kind} ändern (Aktuell: {color})": { + "de": "Kachelfarbe für {kind} ändern (Aktuell: {color})", + "en": "Change tile color for {kind} (current: {color})", + "es": "Cambiar color de mosaico para {kind} (actual: {color})", + "zh": "更改{kind}的磁贴颜色(当前:{color})", + "ja": "{kind}のタイル色を変更 (現在: {color})", + "ru": "Изменить цвет плиток для {kind} (текущий: {color})" + }, + "Aktuelle Hex-Farbe für {kind}: {color}": { + "de": "Aktuelle Hex-Farbe für {kind}: {color}", + "en": "Current hex color for {kind}: {color}", + "es": "Color hex actual para {kind}: {color}", + "zh": "{kind}的当前十六进制颜色:{color}", + "ja": "{kind}の現在の16進カラー: {color}", + "ru": "Текущий hex-цвет для {kind}: {color}" + }, + "Kachelfarbe wählen": { + "de": "Kachelfarbe wählen", + "en": "Choose tile color", + "es": "Elegir color de mosaico", + "zh": "选择磁贴颜色", + "ja": "タイルの色を選択", + "ru": "Выбрать цвет плитки" + }, + "Möchten Sie diese Kachel wirklich vom Board entfernen?": { + "de": "Möchten Sie diese Kachel wirklich vom Board entfernen?", + "en": "Do you really want to remove this tile from the board?", + "es": "¿Desea realmente quitar este mosaico del tablero?", + "zh": "确定要从看板中移除此磁贴吗?", + "ja": "このタイルをボードから削除しますか?", + "ru": "Действительно удалить эту плитку с доски?" + }, + "Kachel entfernen": { + "de": "Kachel entfernen", + "en": "Remove Tile", + "es": "Quitar mosaico", + "zh": "移除磁贴", + "ja": "タイルを削除", + "ru": "Удалить плитку" + }, + "Kachel-Typ: {badge}": { + "de": "Kachel-Typ: {badge}", + "en": "Tile type: {badge}", + "es": "Tipo de mosaico: {badge}", + "zh": "磁贴类型:{badge}", + "ja": "タイルの種類: {badge}", + "ru": "Тип плитки: {badge}" + }, + "Kachel-Untertitel: {text}": { + "de": "Kachel-Untertitel: {text}", + "en": "Tile subtitle: {text}", + "es": "Subtítulo del mosaico: {text}", + "zh": "磁贴副标题:{text}", + "ja": "タイルのサブタイトル: {text}", + "ru": "Подзаголовок плитки: {text}" + }, + "Kachel-Vorschau: {text}": { + "de": "Kachel-Vorschau: {text}", + "en": "Tile preview: {text}", + "es": "Vista previa del mosaico: {text}", + "zh": "磁贴预览:{text}", + "ja": "タイルのプレビュー: {text}", + "ru": "Предпросмотр плитки: {text}" + }, + "Prompt-Kachel: {title}": { + "de": "Prompt-Kachel: {title}", + "en": "Prompt tile: {title}", + "es": "Mosaico de prompt: {title}", + "zh": "提示磁贴:{title}", + "ja": "プロンプトタイル: {title}", + "ru": "Плитка промпта: {title}" + }, + "Umbenennen": { + "de": "Umbenennen", + "en": "Rename", + "es": "Renombrar", + "zh": "重命名", + "ja": "名前を変更", + "ru": "Переименовать" + }, + "Schriftart": { + "de": "Schriftart", + "en": "Font", + "es": "Fuente", + "zh": "字体", + "ja": "フォント", + "ru": "Шрифт" + }, + "Aktives Board": { + "de": "Aktives Board", + "en": "Active board", + "es": "Tablero activo", + "zh": "当前看板", + "ja": "アクティブなボード", + "ru": "Активная доска" + }, + "Wählt das aktive Prompt-Board aus": { + "de": "Wählt das aktive Prompt-Board aus", + "en": "Selects the active prompt board", + "es": "Selecciona el tablero de prompts activo", + "zh": "选择当前提示看板", + "ja": "アクティブなプロンプトボードを選択します", + "ru": "Выбирает активную доску промптов" + }, + "Erstellt ein neues leeres Prompt-Board": { + "de": "Erstellt ein neues leeres Prompt-Board", + "en": "Creates a new empty prompt board", + "es": "Crea un nuevo tablero de prompts vacío", + "zh": "创建一个新的空提示看板", + "ja": "新しい空のプロンプトボードを作成します", + "ru": "Создаёт новую пустую доску промптов" + }, + "Benennt das aktuell ausgewählte Prompt-Board um": { + "de": "Benennt das aktuell ausgewählte Prompt-Board um", + "en": "Renames the currently selected prompt board", + "es": "Renombra el tablero de prompts seleccionado", + "zh": "重命名当前选中的提示看板", + "ja": "選択中のプロンプトボードの名前を変更します", + "ru": "Переименовывает выбранную доску промптов" + }, + "Löscht das aktuell ausgewählte Prompt-Board": { + "de": "Löscht das aktuell ausgewählte Prompt-Board", + "en": "Deletes the currently selected prompt board", + "es": "Elimina el tablero de prompts seleccionado", + "zh": "删除当前选中的提示看板", + "ja": "選択中のプロンプトボードを削除します", + "ru": "Удаляет выбранную доску промптов" + }, + "Öffnet die Schriftartenauswahl für Board-Kacheln": { + "de": "Öffnet die Schriftartenauswahl für Board-Kacheln", + "en": "Opens the font selection for board tiles", + "es": "Abre la selección de fuente para los mosaicos", + "zh": "打开看板磁贴的字体选择", + "ja": "ボードタイルのフォント選択を開きます", + "ru": "Открывает выбор шрифта для плиток доски" + }, + "Board-Arbeitsfläche": { + "de": "Board-Arbeitsfläche", + "en": "Board workspace", + "es": "Área de trabajo del tablero", + "zh": "看板工作区", + "ja": "ボードのワークスペース", + "ru": "Рабочая область доски" + }, + "Bereich mit angehefteten Prompt-Kacheln": { + "de": "Bereich mit angehefteten Prompt-Kacheln", + "en": "Area with pinned prompt tiles", + "es": "Área con mosaicos de prompts fijados", + "zh": "包含已固定提示磁贴的区域", + "ja": "ピン留めされたプロンプトタイルの領域", + "ru": "Область с закреплёнными плитками промптов" + }, + "Kachel-Raster": { + "de": "Kachel-Raster", + "en": "Tile grid", + "es": "Cuadrícula de mosaicos", + "zh": "磁贴网格", + "ja": "タイルグリッド", + "ru": "Сетка плиток" + }, + "Schriftart wählen": { + "de": "Schriftart wählen", + "en": "Choose font", + "es": "Elegir fuente", + "zh": "选择字体", + "ja": "フォントを選択", + "ru": "Выбрать шрифт" + }, + "Name:": { + "de": "Name:", + "en": "Name:", + "es": "Nombre:", + "zh": "名称:", + "ja": "名前:", + "ru": "Название:" + }, + "Kopieren": { + "de": "Kopieren", + "en": "Copy", + "es": "Copiar", + "zh": "复制", + "ja": "コピー", + "ru": "Копировать" + }, + "Auf Board verschieben": { + "de": "Auf Board verschieben", + "en": "Move to board", + "es": "Mover al tablero", + "zh": "移动到看板", + "ja": "ボードへ移動", + "ru": "Переместить на доску" + }, + "Auf Board duplizieren": { + "de": "Auf Board duplizieren", + "en": "Duplicate to board", + "es": "Duplicar en tablero", + "zh": "复制到看板", + "ja": "ボードへ複製", + "ru": "Дублировать на доску" + }, + "Neues Board …": { + "de": "Neues Board …", + "en": "New board …", + "es": "Nuevo tablero …", + "zh": "新建看板 …", + "ja": "新しいボード …", + "ru": "Новая доска …" + }, + "(ohne Titel)": { + "de": "(ohne Titel)", + "en": "(untitled)", + "es": "(sin título)", + "zh": "(无标题)", + "ja": "(無題)", + "ru": "(без названия)" + }, + "Kachelfarbe": { + "de": "Kachelfarbe", + "en": "Tile color", + "es": "Color del mosaico", + "zh": "磁贴颜色", + "ja": "タイルの色", + "ru": "Цвет плитки" + }, + "Standardfarbe": { + "de": "Standardfarbe", + "en": "Default color", + "es": "Color predeterminado", + "zh": "默认颜色", + "ja": "既定の色", + "ru": "Цвет по умолчанию" + }, + "Eigene Farbe …": { + "de": "Eigene Farbe …", + "en": "Custom color …", + "es": "Color personalizado …", + "zh": "自定义颜色 …", + "ja": "カスタムカラー …", + "ru": "Свой цвет …" + }, + "Vom Board entfernen": { + "de": "Vom Board entfernen", + "en": "Remove from board", + "es": "Quitar del tablero", + "zh": "从看板移除", + "ja": "ボードから削除", + "ru": "Убрать с доски" + }, + "Board „{title}“ wirklich löschen?": { + "de": "Board „{title}“ wirklich löschen?", + "en": "Really delete board “{title}”?", + "es": "¿Eliminar realmente el tablero «{title}»?", + "zh": "确定删除看板“{title}”吗?", + "ja": "ボード「{title}」を削除しますか?", + "ru": "Действительно удалить доску «{title}»?" + }, + "Kachel auf Board „{title}“ verschoben.": { + "de": "Kachel auf Board „{title}“ verschoben.", + "en": "Tile moved to board “{title}”.", + "es": "Mosaico movido al tablero «{title}».", + "zh": "磁贴已移动到看板“{title}”。", + "ja": "タイルをボード「{title}」へ移動しました。", + "ru": "Плитка перемещена на доску «{title}»." + }, + "Kachel auf Board „{title}“ dupliziert.": { + "de": "Kachel auf Board „{title}“ dupliziert.", + "en": "Tile duplicated to board “{title}”.", + "es": "Mosaico duplicado en el tablero «{title}».", + "zh": "磁贴已复制到看板“{title}”。", + "ja": "タイルをボード「{title}」へ複製しました。", + "ru": "Плитка продублирована на доску «{title}»." + }, + "Diese Kachel ist auf Board „{title}“ bereits vorhanden.": { + "de": "Diese Kachel ist auf Board „{title}“ bereits vorhanden.", + "en": "This tile already exists on board “{title}”.", + "es": "Este mosaico ya existe en el tablero «{title}».", + "zh": "此磁贴已存在于看板“{title}”上。", + "ja": "このタイルはボード「{title}」に既にあります。", + "ru": "Эта плитка уже есть на доске «{title}»." + }, + "Rot": { + "de": "Rot", + "en": "Red", + "es": "Rojo", + "zh": "红色", + "ja": "赤", + "ru": "Красный" + }, + "Orange": { + "de": "Orange", + "en": "Orange", + "es": "Naranja", + "zh": "橙色", + "ja": "オレンジ", + "ru": "Оранжевый" + }, + "Gelb": { + "de": "Gelb", + "en": "Yellow", + "es": "Amarillo", + "zh": "黄色", + "ja": "黄", + "ru": "Жёлтый" + }, + "Grün": { + "de": "Grün", + "en": "Green", + "es": "Verde", + "zh": "绿色", + "ja": "緑", + "ru": "Зелёный" + }, + "Türkis": { + "de": "Türkis", + "en": "Teal", + "es": "Turquesa", + "zh": "青绿色", + "ja": "ターコイズ", + "ru": "Бирюзовый" + }, + "Blau": { + "de": "Blau", + "en": "Blue", + "es": "Azul", + "zh": "蓝色", + "ja": "青", + "ru": "Синий" + }, + "Violett": { + "de": "Violett", + "en": "Purple", + "es": "Violeta", + "zh": "紫色", + "ja": "紫", + "ru": "Фиолетовый" + }, + "Rosa": { + "de": "Rosa", + "en": "Pink", + "es": "Rosa", + "zh": "粉色", + "ja": "ピンク", + "ru": "Розовый" + }, + "Grau": { + "de": "Grau", + "en": "Gray", + "es": "Gris", + "zh": "灰色", + "ja": "グレー", + "ru": "Серый" + }, + "Kopiert 📋": { + "de": "Kopiert 📋", + "en": "Copied 📋", + "es": "Copiado 📋", + "zh": "已复制 📋", + "ja": "コピーしました 📋", + "ru": "Скопировано 📋" + }, + "Kopiermodus": { + "de": "Kopiermodus", + "en": "Copy mode", + "es": "Modo de copia", + "zh": "复制模式", + "ja": "コピーモード", + "ru": "Режим копирования" + }, + "Kopiermodus:": { + "de": "Kopiermodus:", + "en": "Copy mode:", + "es": "Modo de copia:", + "zh": "复制模式:", + "ja": "コピーモード:", + "ru": "Режим копирования:" + }, + "Auswahl, welche Inhalte beim Kopieren in die Zwischenablage übernommen werden": { + "de": "Auswahl, welche Inhalte beim Kopieren in die Zwischenablage übernommen werden", + "en": "Selects which content is copied to the clipboard", + "es": "Selecciona qué contenido se copia al portapapeles", + "zh": "选择复制到剪贴板的内容", + "ja": "クリップボードにコピーする内容を選択します", + "ru": "Выбор содержимого, копируемого в буфер обмена" + }, + "Metadaten (Tags) hinzufügen": { + "de": "Metadaten (Tags) hinzufügen", + "en": "Add metadata (tags)", + "es": "Añadir metadatos (etiquetas)", + "zh": "添加元数据(标签)", + "ja": "メタデータ(タグ)を追加", + "ru": "Добавить метаданные (теги)" + }, + "Metadaten hinzufügen": { + "de": "Metadaten hinzufügen", + "en": "Add metadata", + "es": "Añadir metadatos", + "zh": "添加元数据", + "ja": "メタデータを追加", + "ru": "Добавить метаданные" + }, + "Schlagwörter und Metadaten an den kopierten Text anfügen": { + "de": "Schlagwörter und Metadaten an den kopierten Text anfügen", + "en": "Append tags and metadata to the copied text", + "es": "Añadir etiquetas y metadatos al texto copiado", + "zh": "在复制的文本后附加标签和元数据", + "ja": "コピーしたテキストにタグとメタデータを付加", + "ru": "Добавлять теги и метаданные к скопированному тексту" + }, + "Kopier-Einstellungen speichern und Dialog schließen": { + "de": "Kopier-Einstellungen speichern und Dialog schließen", + "en": "Save copy settings and close dialog", + "es": "Guardar configuración de copia y cerrar el diálogo", + "zh": "保存复制设置并关闭对话框", + "ja": "コピー設定を保存してダイアログを閉じる", + "ru": "Сохранить настройки копирования и закрыть диалог" + }, + "Nur Titel": { + "de": "Nur Titel", + "en": "Title only", + "es": "Solo título", + "zh": "仅标题", + "ja": "タイトルのみ", + "ru": "Только заголовок" + }, + "Nur Ergebnis": { + "de": "Nur Ergebnis", + "en": "Result only", + "es": "Solo resultado", + "zh": "仅结果", + "ja": "結果のみ", + "ru": "Только результат" + }, + "Alles": { + "de": "Alles", + "en": "Everything", + "es": "Todo", + "zh": "全部", + "ja": "すべて", + "ru": "Всё" + }, + "Titel": { + "de": "Titel", + "en": "Title", + "es": "Título", + "zh": "标题", + "ja": "タイトル", + "ru": "Заголовок" + }, + "Zweck": { + "de": "Zweck", + "en": "Purpose", + "es": "Propósito", + "zh": "用途", + "ja": "目的", + "ru": "Назначение" + }, + "Tags": { + "de": "Tags", + "en": "Tags", + "es": "Etiquetas", + "zh": "标签", + "ja": "タグ", + "ru": "Теги" + }, + "Erstellt": { + "de": "Erstellt", + "en": "Created", + "es": "Creado", + "zh": "创建时间", + "ja": "作成日", + "ru": "Создано" + }, + "Aktualisiert": { + "de": "Aktualisiert", + "en": "Updated", + "es": "Actualizado", + "zh": "更新时间", + "ja": "更新日", + "ru": "Обновлено" + }, + "Hierarchische Übersicht aller Prompts und Versionen mit Tastaturbedienung": { + "de": "Hierarchische Übersicht aller Prompts und Versionen mit Tastaturbedienung", + "en": "Hierarchical overview of all prompts and versions with keyboard support", + "es": "Vista jerárquica de todos los prompts y versiones con soporte de teclado", + "zh": "所有提示和版本的层级概览,支持键盘操作", + "ja": "全プロンプトとバージョンの階層表示(キーボード操作対応)", + "ru": "Иерархический обзор всех промптов и версий с управлением с клавиатуры" + }, + "Alle Tags": { + "de": "Alle Tags", + "en": "All tags", + "es": "Todas las etiquetas", + "zh": "所有标签", + "ja": "すべてのタグ", + "ru": "Все теги" + }, + "Suchbegriff": { + "de": "Suchbegriff", + "en": "Search term", + "es": "Término de búsqueda", + "zh": "搜索词", + "ja": "検索語", + "ru": "Поисковый запрос" + }, + "Suchfeld für Volltextsuche in Titeln, Texten und Tags": { + "de": "Suchfeld für Volltextsuche in Titeln, Texten und Tags", + "en": "Full-text search in titles, texts and tags", + "es": "Búsqueda de texto completo en títulos, textos y etiquetas", + "zh": "在标题、文本和标签中进行全文搜索", + "ja": "タイトル・本文・タグの全文検索", + "ru": "Полнотекстовый поиск по заголовкам, текстам и тегам" + }, + "Tag-Filter": { + "de": "Tag-Filter", + "en": "Tag filter", + "es": "Filtro de etiquetas", + "zh": "标签筛选", + "ja": "タグフィルター", + "ru": "Фильтр по тегам" + }, + "Filtert Prompts nach ausgewählten Tags": { + "de": "Filtert Prompts nach ausgewählten Tags", + "en": "Filters prompts by the selected tag", + "es": "Filtra los prompts por la etiqueta seleccionada", + "zh": "按所选标签筛选提示", + "ja": "選択したタグでプロンプトを絞り込みます", + "ru": "Фильтрует промпты по выбранному тегу" + }, + "Startdatum": { + "de": "Startdatum", + "en": "Start date", + "es": "Fecha de inicio", + "zh": "开始日期", + "ja": "開始日", + "ru": "Дата начала" + }, + "Filtert Prompts ab dem angegebenen Erstellungsdatum": { + "de": "Filtert Prompts ab dem angegebenen Erstellungsdatum", + "en": "Filters prompts from the given date", + "es": "Filtra los prompts desde la fecha indicada", + "zh": "筛选指定日期之后的提示", + "ja": "指定日以降のプロンプトを絞り込みます", + "ru": "Фильтрует промпты начиная с указанной даты" + }, + "Enddatum": { + "de": "Enddatum", + "en": "End date", + "es": "Fecha de fin", + "zh": "结束日期", + "ja": "終了日", + "ru": "Дата окончания" + }, + "Filtert Prompts bis zum angegebenen Erstellungsdatum": { + "de": "Filtert Prompts bis zum angegebenen Erstellungsdatum", + "en": "Filters prompts up to the given date", + "es": "Filtra los prompts hasta la fecha indicada", + "zh": "筛选指定日期之前的提示", + "ja": "指定日までのプロンプトを絞り込みます", + "ru": "Фильтрует промпты до указанной даты" + }, + "Setzt alle Such-, Tag- und Datumsfilter zurück": { + "de": "Setzt alle Such-, Tag- und Datumsfilter zurück", + "en": "Resets all search, tag and date filters", + "es": "Restablece todos los filtros de búsqueda, etiqueta y fecha", + "zh": "重置所有搜索、标签和日期筛选", + "ja": "検索・タグ・日付のフィルターをすべてリセットします", + "ru": "Сбрасывает все фильтры поиска, тегов и дат" + }, + "Ohne Titel": { + "de": "Ohne Titel", + "en": "Untitled", + "es": "Sin título", + "zh": "无标题", + "ja": "無題", + "ru": "Без названия" + }, + "Prompt kopieren: {title}": { + "de": "Prompt kopieren: {title}", + "en": "Copy prompt: {title}", + "es": "Copiar prompt: {title}", + "zh": "复制提示:{title}", + "ja": "プロンプトをコピー: {title}", + "ru": "Копировать промпт: {title}" + }, + "Kopiert den Prompt-Text entsprechend den Kopiermodi in die Zwischenablage": { + "de": "Kopiert den Prompt-Text entsprechend den Kopiermodi in die Zwischenablage", + "en": "Copies the prompt text to the clipboard according to the copy mode", + "es": "Copia el texto del prompt al portapapeles según el modo de copia", + "zh": "按复制模式将提示文本复制到剪贴板", + "ja": "コピーモードに従ってプロンプトをクリップボードにコピーします", + "ru": "Копирует текст промпта в буфер обмена согласно режиму копирования" + }, + "Version kopieren: {title}": { + "de": "Version kopieren: {title}", + "en": "Copy version: {title}", + "es": "Copiar versión: {title}", + "zh": "复制版本:{title}", + "ja": "バージョンをコピー: {title}", + "ru": "Копировать версию: {title}" + }, + "Kopiert diese Version in die Zwischenablage": { + "de": "Kopiert diese Version in die Zwischenablage", + "en": "Copies this version to the clipboard", + "es": "Copia esta versión al portapapeles", + "zh": "将此版本复制到剪贴板", + "ja": "このバージョンをクリップボードにコピーします", + "ru": "Копирует эту версию в буфер обмена" + }, + "Neuen Prompt hinzufügen": { + "de": "Neuen Prompt hinzufügen", + "en": "Add new prompt", + "es": "Añadir nuevo prompt", + "zh": "添加新提示", + "ja": "新しいプロンプトを追加", + "ru": "Добавить новый промпт" + }, + "Prompt kopieren": { + "de": "Prompt kopieren", + "en": "Copy prompt", + "es": "Copiar prompt", + "zh": "复制提示", + "ja": "プロンプトをコピー", + "ru": "Копировать промпт" + }, + "Prompt inkl. Versionen kopieren": { + "de": "Prompt inkl. Versionen kopieren", + "en": "Copy prompt incl. versions", + "es": "Copiar prompt con versiones", + "zh": "复制提示(含版本)", + "ja": "プロンプトをバージョンごとコピー", + "ru": "Копировать промпт с версиями" + }, + "Version kopieren": { + "de": "Version kopieren", + "en": "Copy version", + "es": "Copiar versión", + "zh": "复制版本", + "ja": "バージョンをコピー", + "ru": "Копировать версию" + }, + "Neue Version anlegen": { + "de": "Neue Version anlegen", + "en": "Create new version", + "es": "Crear nueva versión", + "zh": "新建版本", + "ja": "新しいバージョンを作成", + "ru": "Создать новую версию" + }, + "Auf Board heften": { + "de": "Auf Board heften", + "en": "Pin to board", + "es": "Fijar en tablero", + "zh": "固定到看板", + "ja": "ボードにピン留め", + "ru": "Закрепить на доске" + }, + "Dieses Element ist auf dem Board bereits vorhanden.": { + "de": "Dieses Element ist auf dem Board bereits vorhanden.", + "en": "This item is already on the board.", + "es": "Este elemento ya está en el tablero.", + "zh": "此项目已在看板上。", + "ja": "この項目は既にボードにあります。", + "ru": "Этот элемент уже есть на доске." + }, + "Prompt exportieren (TXT)": { + "de": "Prompt exportieren (TXT)", + "en": "Export prompt (TXT)", + "es": "Exportar prompt (TXT)", + "zh": "导出提示(TXT)", + "ja": "プロンプトをエクスポート (TXT)", + "ru": "Экспорт промпта (TXT)" + }, + "Prompt exportieren (PDF)": { + "de": "Prompt exportieren (PDF)", + "en": "Export prompt (PDF)", + "es": "Exportar prompt (PDF)", + "zh": "导出提示(PDF)", + "ja": "プロンプトをエクスポート (PDF)", + "ru": "Экспорт промпта (PDF)" + }, + "Prompt+Versionen exportieren (TXT)": { + "de": "Prompt+Versionen exportieren (TXT)", + "en": "Export prompt + versions (TXT)", + "es": "Exportar prompt + versiones (TXT)", + "zh": "导出提示及版本(TXT)", + "ja": "プロンプト+バージョンをエクスポート (TXT)", + "ru": "Экспорт промпта с версиями (TXT)" + }, + "Prompt+Versionen exportieren (PDF)": { + "de": "Prompt+Versionen exportieren (PDF)", + "en": "Export prompt + versions (PDF)", + "es": "Exportar prompt + versiones (PDF)", + "zh": "导出提示及版本(PDF)", + "ja": "プロンプト+バージョンをエクスポート (PDF)", + "ru": "Экспорт промпта с версиями (PDF)" + }, + "Version exportieren (TXT)": { + "de": "Version exportieren (TXT)", + "en": "Export version (TXT)", + "es": "Exportar versión (TXT)", + "zh": "导出版本(TXT)", + "ja": "バージョンをエクスポート (TXT)", + "ru": "Экспорт версии (TXT)" + }, + "Version exportieren (PDF)": { + "de": "Version exportieren (PDF)", + "en": "Export version (PDF)", + "es": "Exportar versión (PDF)", + "zh": "导出版本(PDF)", + "ja": "バージョンをエクスポート (PDF)", + "ru": "Экспорт версии (PDF)" + }, + "Export": { + "de": "Export", + "en": "Export", + "es": "Exportar", + "zh": "导出", + "ja": "エクスポート", + "ru": "Экспорт" + }, + "TXT speichern": { + "de": "TXT speichern", + "en": "Save TXT", + "es": "Guardar TXT", + "zh": "保存 TXT", + "ja": "TXT を保存", + "ru": "Сохранить TXT" + }, + "Textdatei (*.txt)": { + "de": "Textdatei (*.txt)", + "en": "Text file (*.txt)", + "es": "Archivo de texto (*.txt)", + "zh": "文本文件 (*.txt)", + "ja": "テキストファイル (*.txt)", + "ru": "Текстовый файл (*.txt)" + }, + "PDF speichern": { + "de": "PDF speichern", + "en": "Save PDF", + "es": "Guardar PDF", + "zh": "保存 PDF", + "ja": "PDF を保存", + "ru": "Сохранить PDF" + }, + "PDF-Datei (*.pdf)": { + "de": "PDF-Datei (*.pdf)", + "en": "PDF file (*.pdf)", + "es": "Archivo PDF (*.pdf)", + "zh": "PDF 文件 (*.pdf)", + "ja": "PDF ファイル (*.pdf)", + "ru": "PDF-файл (*.pdf)" + }, + "Prompt „{title}“ wirklich löschen?": { + "de": "Prompt „{title}“ wirklich löschen?", + "en": "Really delete prompt “{title}”?", + "es": "¿Eliminar realmente el prompt «{title}»?", + "zh": "确定删除提示“{title}”吗?", + "ja": "プロンプト「{title}」を削除しますか?", + "ru": "Действительно удалить промпт «{title}»?" + }, + "Version „v{num} – {title}“ wirklich löschen?": { + "de": "Version „v{num} – {title}“ wirklich löschen?", + "en": "Really delete version “v{num} – {title}”?", + "es": "¿Eliminar realmente la versión «v{num} – {title}»?", + "zh": "确定删除版本“v{num} – {title}”吗?", + "ja": "バージョン「v{num} – {title}」を削除しますか?", + "ru": "Действительно удалить версию «v{num} – {title}»?" + }, + "TXT-Export fehlgeschlagen:\n{error}": { + "de": "TXT-Export fehlgeschlagen:\n{error}", + "en": "TXT export failed:\n{error}", + "es": "Error al exportar TXT:\n{error}", + "zh": "TXT 导出失败:\n{error}", + "ja": "TXT のエクスポートに失敗しました:\n{error}", + "ru": "Ошибка экспорта TXT:\n{error}" + }, + "Bundle-Export fehlgeschlagen:\n{error}": { + "de": "Bundle-Export fehlgeschlagen:\n{error}", + "en": "Bundle export failed:\n{error}", + "es": "Error al exportar el paquete:\n{error}", + "zh": "合集导出失败:\n{error}", + "ja": "バンドルのエクスポートに失敗しました:\n{error}", + "ru": "Ошибка экспорта пакета:\n{error}" + }, + "PDF-Export fehlgeschlagen:\n{error}": { + "de": "PDF-Export fehlgeschlagen:\n{error}", + "en": "PDF export failed:\n{error}", + "es": "Error al exportar PDF:\n{error}", + "zh": "PDF 导出失败:\n{error}", + "ja": "PDF のエクスポートに失敗しました:\n{error}", + "ru": "Ошибка экспорта PDF:\n{error}" + }, + "JSON-Export fehlgeschlagen:\n{error}": { + "de": "JSON-Export fehlgeschlagen:\n{error}", + "en": "JSON export failed:\n{error}", + "es": "Error al exportar JSON:\n{error}", + "zh": "JSON 导出失败:\n{error}", + "ja": "JSON のエクスポートに失敗しました:\n{error}", + "ru": "Ошибка экспорта JSON:\n{error}" + }, + "Boards-Bereich": { + "de": "Boards-Bereich", + "en": "Boards area", + "es": "Área de tableros", + "zh": "看板区域", + "ja": "ボード領域", + "ru": "Область досок" + }, + "Bereich für Kachel-Boards und Arbeitsflächen": { + "de": "Bereich für Kachel-Boards und Arbeitsflächen", + "en": "Area for tile boards and workspaces", + "es": "Área para tableros de mosaicos y espacios de trabajo", + "zh": "磁贴看板和工作区区域", + "ja": "タイルボードとワークスペースの領域", + "ru": "Область для досок с плитками и рабочих пространств" + }, + "Statusleiste": { + "de": "Statusleiste", + "en": "Status bar", + "es": "Barra de estado", + "zh": "状态栏", + "ja": "ステータスバー", + "ru": "Строка состояния" + }, + "Zeigt Statusmeldungen und Tastenkürzelhinweise an": { + "de": "Zeigt Statusmeldungen und Tastenkürzelhinweise an", + "en": "Shows status messages and shortcut hints", + "es": "Muestra mensajes de estado y atajos de teclado", + "zh": "显示状态消息和快捷键提示", + "ja": "ステータスメッセージとショートカットのヒントを表示します", + "ru": "Показывает сообщения о состоянии и подсказки по сочетаниям клавиш" + }, + "• Doppelklick auf Liste: Bearbeiten\n• Drag & Drop auf Board rechts: Prompt anheften\n• Rechtsklick: Kontextmenü für Export/Löschen\n• Rechtsklick auf Kachel: auf anderes Board verschieben/duplizieren, Kachelfarbe": { + "de": "• Doppelklick auf Liste: Bearbeiten\n• Drag & Drop auf Board rechts: Prompt anheften\n• Rechtsklick: Kontextmenü für Export/Löschen\n• Rechtsklick auf Kachel: auf anderes Board verschieben/duplizieren, Kachelfarbe", + "en": "• Double-click in list: edit\n• Drag & drop onto the board on the right: pin prompt\n• Right-click: context menu for export/delete\n• Right-click on tile: move/duplicate to another board, tile color", + "es": "• Doble clic en la lista: editar\n• Arrastrar y soltar en el tablero de la derecha: fijar prompt\n• Clic derecho: menú contextual para exportar/eliminar\n• Clic derecho en un mosaico: mover/duplicar a otro tablero, color del mosaico", + "zh": "• 在列表中双击:编辑\n• 拖放到右侧看板:固定提示\n• 右键:导出/删除的上下文菜单\n• 右键单击磁贴:移动/复制到其他看板、磁贴颜色", + "ja": "• リストをダブルクリック: 編集\n• 右側のボードへドラッグ&ドロップ: プロンプトをピン留め\n• 右クリック: エクスポート/削除のコンテキストメニュー\n• タイルを右クリック: 別のボードへ移動/複製、タイルの色", + "ru": "• Двойной щелчок в списке: редактировать\n• Перетащить на доску справа: закрепить промпт\n• Правый щелчок: контекстное меню экспорта/удаления\n• Правый щелчок по плитке: переместить/дублировать на другую доску, цвет плитки" + }, + "Bibliothek exportieren": { + "de": "Bibliothek exportieren", + "en": "Export library", + "es": "Exportar biblioteca", + "zh": "导出库", + "ja": "ライブラリをエクスポート", + "ru": "Экспорт библиотеки" + }, + "JSON-Bibliothek erfolgreich gespeichert ({count} Prompts).": { + "de": "JSON-Bibliothek erfolgreich gespeichert ({count} Prompts).", + "en": "JSON library saved successfully ({count} prompts).", + "es": "Biblioteca JSON guardada correctamente ({count} prompts).", + "zh": "JSON 库已成功保存({count} 个提示)。", + "ja": "JSON ライブラリを保存しました({count} 件のプロンプト)。", + "ru": "JSON-библиотека успешно сохранена ({count} промптов)." + }, + "Prompt erstellen": { + "de": "Prompt erstellen", + "en": "Create Prompt", + "es": "Crear prompt", + "zh": "创建提示", + "ja": "プロンプトを作成", + "ru": "Создать промпт" + }, + "Titel des Prompts (Pflichtfeld)": { + "de": "Titel des Prompts (Pflichtfeld)", + "en": "Prompt title (required)", + "es": "Título del prompt (obligatorio)", + "zh": "提示标题(必填)", + "ja": "プロンプトのタイトル(必須)", + "ru": "Заголовок промпта (обязательно)" + }, + "Titel des Prompts eingeben …": { + "de": "Titel des Prompts eingeben …", + "en": "Enter prompt title …", + "es": "Introduzca el título del prompt …", + "zh": "输入提示标题 …", + "ja": "プロンプトのタイトルを入力 …", + "ru": "Введите заголовок промпта …" + }, + "Zweck und Verwendungsziel des Prompts": { + "de": "Zweck und Verwendungsziel des Prompts", + "en": "Purpose and intended use of the prompt", + "es": "Propósito y uso previsto del prompt", + "zh": "提示的用途与目标", + "ja": "プロンプトの目的と用途", + "ru": "Назначение и цель использования промпта" + }, + "Verwendungszweck beschreiben …": { + "de": "Verwendungszweck beschreiben …", + "en": "Describe the purpose …", + "es": "Describa el propósito …", + "zh": "描述用途 …", + "ja": "目的を記述 …", + "ru": "Опишите назначение …" + }, + "Kommagetrennte Liste von Schlagwörtern": { + "de": "Kommagetrennte Liste von Schlagwörtern", + "en": "Comma-separated list of tags", + "es": "Lista de etiquetas separadas por comas", + "zh": "以逗号分隔的标签列表", + "ja": "カンマ区切りのタグ一覧", + "ru": "Список тегов через запятую" + }, + "z. B. Coding, Refactoring, Python": { + "de": "z. B. Coding, Refactoring, Python", + "en": "e.g. coding, refactoring, Python", + "es": "p. ej. programación, refactorización, Python", + "zh": "例如:编程, 重构, Python", + "ja": "例: コーディング, リファクタリング, Python", + "ru": "напр. код, рефакторинг, Python" + }, + "Prompt-Text": { + "de": "Prompt-Text", + "en": "Prompt text", + "es": "Texto del prompt", + "zh": "提示文本", + "ja": "プロンプト本文", + "ru": "Текст промпта" + }, + "Vollständiger Prompt-Text (Pflichtfeld)": { + "de": "Vollständiger Prompt-Text (Pflichtfeld)", + "en": "Full prompt text (required)", + "es": "Texto completo del prompt (obligatorio)", + "zh": "完整提示文本(必填)", + "ja": "プロンプト全文(必須)", + "ru": "Полный текст промпта (обязательно)" + }, + "Hier den Prompt-Text formulieren …": { + "de": "Hier den Prompt-Text formulieren …", + "en": "Write the prompt text here …", + "es": "Redacte aquí el texto del prompt …", + "zh": "在此编写提示文本 …", + "ja": "ここにプロンプト本文を入力 …", + "ru": "Сформулируйте текст промпта здесь …" + }, + "Ergebnis": { + "de": "Ergebnis", + "en": "Result", + "es": "Resultado", + "zh": "结果", + "ja": "結果", + "ru": "Результат" + }, + "Zuletzt erzieltes Testergebnis oder Modellausgabe": { + "de": "Zuletzt erzieltes Testergebnis oder Modellausgabe", + "en": "Most recent test result or model output", + "es": "Último resultado de prueba o salida del modelo", + "zh": "最近的测试结果或模型输出", + "ja": "最新のテスト結果またはモデル出力", + "ru": "Последний результат теста или ответ модели" + }, + "Optionale Modellausgabe oder Notizen …": { + "de": "Optionale Modellausgabe oder Notizen …", + "en": "Optional model output or notes …", + "es": "Salida del modelo o notas opcionales …", + "zh": "可选的模型输出或备注 …", + "ja": "モデル出力やメモ(任意) …", + "ru": "Необязательный ответ модели или заметки …" + }, + "Titel*": { + "de": "Titel*", + "en": "Title*", + "es": "Título*", + "zh": "标题*", + "ja": "タイトル*", + "ru": "Заголовок*" + }, + "Tags (Komma)": { + "de": "Tags (Komma)", + "en": "Tags (comma)", + "es": "Etiquetas (coma)", + "zh": "标签(逗号分隔)", + "ja": "タグ(カンマ区切り)", + "ru": "Теги (через запятую)" + }, + "Prompt-Text*": { + "de": "Prompt-Text*", + "en": "Prompt text*", + "es": "Texto del prompt*", + "zh": "提示文本*", + "ja": "プロンプト本文*", + "ru": "Текст промпта*" + }, + "Versionen-Liste": { + "de": "Versionen-Liste", + "en": "Version list", + "es": "Lista de versiones", + "zh": "版本列表", + "ja": "バージョン一覧", + "ru": "Список версий" + }, + "Übersicht aller Versionen dieses Prompts": { + "de": "Übersicht aller Versionen dieses Prompts", + "en": "Overview of all versions of this prompt", + "es": "Resumen de todas las versiones de este prompt", + "zh": "此提示所有版本的概览", + "ja": "このプロンプトの全バージョンの一覧", + "ru": "Обзор всех версий этого промпта" + }, + "Versionen": { + "de": "Versionen", + "en": "Versions", + "es": "Versiones", + "zh": "版本", + "ja": "バージョン", + "ru": "Версии" + }, + "Versionen-Gruppe": { + "de": "Versionen-Gruppe", + "en": "Versions group", + "es": "Grupo de versiones", + "zh": "版本分组", + "ja": "バージョングループ", + "ru": "Группа версий" + }, + "Speichert die Änderungen und schließt den Dialog": { + "de": "Speichert die Änderungen und schließt den Dialog", + "en": "Saves the changes and closes the dialog", + "es": "Guarda los cambios y cierra el diálogo", + "zh": "保存更改并关闭对话框", + "ja": "変更を保存してダイアログを閉じます", + "ru": "Сохраняет изменения и закрывает диалог" + }, + "Prompt speichern (Enter)": { + "de": "Prompt speichern (Enter)", + "en": "Save prompt (Enter)", + "es": "Guardar prompt (Intro)", + "zh": "保存提示(Enter)", + "ja": "プロンプトを保存 (Enter)", + "ru": "Сохранить промпт (Enter)" + }, + "Verwirft Änderungen und schließt den Dialog": { + "de": "Verwirft Änderungen und schließt den Dialog", + "en": "Discards changes and closes the dialog", + "es": "Descarta los cambios y cierra el diálogo", + "zh": "放弃更改并关闭对话框", + "ja": "変更を破棄してダイアログを閉じます", + "ru": "Отменяет изменения и закрывает диалог" + }, + "Versions-Kontext": { + "de": "Versions-Kontext", + "en": "Version context", + "es": "Contexto de la versión", + "zh": "版本上下文", + "ja": "バージョンの情報", + "ru": "Контекст версии" + }, + "Prompt: {title}": { + "de": "Prompt: {title}", + "en": "Prompt: {title}", + "es": "Prompt: {title}", + "zh": "提示:{title}", + "ja": "プロンプト: {title}", + "ru": "Промпт: {title}" + }, + "Bearbeite: v{num} — {title}": { + "de": "Bearbeite: v{num} — {title}", + "en": "Editing: v{num} — {title}", + "es": "Editando: v{num} — {title}", + "zh": "正在编辑:v{num} — {title}", + "ja": "編集中: v{num} — {title}", + "ru": "Редактирование: v{num} — {title}" + }, + "Zugeordneter Prompt: {title}": { + "de": "Zugeordneter Prompt: {title}", + "en": "Associated prompt: {title}", + "es": "Prompt asociado: {title}", + "zh": "所属提示:{title}", + "ja": "関連するプロンプト: {title}", + "ru": "Связанный промпт: {title}" + }, + "Versionstitel": { + "de": "Versionstitel", + "en": "Version title", + "es": "Título de la versión", + "zh": "版本标题", + "ja": "バージョンのタイトル", + "ru": "Заголовок версии" + }, + "Titel dieser Prompt-Version (Pflichtfeld)": { + "de": "Titel dieser Prompt-Version (Pflichtfeld)", + "en": "Title of this prompt version (required)", + "es": "Título de esta versión (obligatorio)", + "zh": "此提示版本的标题(必填)", + "ja": "このバージョンのタイトル(必須)", + "ru": "Заголовок этой версии (обязательно)" + }, + "Titel der Version …": { + "de": "Titel der Version …", + "en": "Version title …", + "es": "Título de la versión …", + "zh": "版本标题 …", + "ja": "バージョンのタイトル …", + "ru": "Заголовок версии …" + }, + "z. B. v2, überarbeitet, kurz": { + "de": "z. B. v2, überarbeitet, kurz", + "en": "e.g. v2, revised, short", + "es": "p. ej. v2, revisado, corto", + "zh": "例如:v2, 修订, 简短", + "ja": "例: v2, 改訂, 短縮", + "ru": "напр. v2, исправлено, кратко" + }, + "Vollständiger Prompt-Text dieser Version (Pflichtfeld)": { + "de": "Vollständiger Prompt-Text dieser Version (Pflichtfeld)", + "en": "Full prompt text of this version (required)", + "es": "Texto completo de esta versión (obligatorio)", + "zh": "此版本的完整提示文本(必填)", + "ja": "このバージョンのプロンプト全文(必須)", + "ru": "Полный текст этой версии (обязательно)" + }, + "Prompt-Text für diese Version formulieren …": { + "de": "Prompt-Text für diese Version formulieren …", + "en": "Write the prompt text for this version …", + "es": "Redacte el texto de esta versión …", + "zh": "编写此版本的提示文本 …", + "ja": "このバージョンの本文を入力 …", + "ru": "Сформулируйте текст для этой версии …" + }, + "Testergebnis oder Notizen dieser Version": { + "de": "Testergebnis oder Notizen dieser Version", + "en": "Test result or notes of this version", + "es": "Resultado de prueba o notas de esta versión", + "zh": "此版本的测试结果或备注", + "ja": "このバージョンのテスト結果またはメモ", + "ru": "Результат теста или заметки этой версии" + }, + "Optionale Modellausgabe für diese Version …": { + "de": "Optionale Modellausgabe für diese Version …", + "en": "Optional model output for this version …", + "es": "Salida del modelo opcional para esta versión …", + "zh": "此版本的可选模型输出 …", + "ja": "このバージョンのモデル出力(任意) …", + "ru": "Необязательный ответ модели для этой версии …" + }, + "Verwirft Eingaben und schließt den Dialog": { + "de": "Verwirft Eingaben und schließt den Dialog", + "en": "Discards input and closes the dialog", + "es": "Descarta la entrada y cierra el diálogo", + "zh": "放弃输入并关闭对话框", + "ja": "入力を破棄してダイアログを閉じます", + "ru": "Отменяет ввод и закрывает диалог" + }, + "Speichert die Änderungen an dieser Version": { + "de": "Speichert die Änderungen an dieser Version", + "en": "Saves the changes to this version", + "es": "Guarda los cambios de esta versión", + "zh": "保存对此版本的更改", + "ja": "このバージョンへの変更を保存します", + "ru": "Сохраняет изменения этой версии" + }, + "Version aktualisieren (Enter)": { + "de": "Version aktualisieren (Enter)", + "en": "Update version (Enter)", + "es": "Actualizar versión (Intro)", + "zh": "更新版本(Enter)", + "ja": "バージョンを更新 (Enter)", + "ru": "Обновить версию (Enter)" + }, + "Erstellt eine neue Version mit fortlaufender Nummer": { + "de": "Erstellt eine neue Version mit fortlaufender Nummer", + "en": "Creates a new version with the next number", + "es": "Crea una nueva versión con el siguiente número", + "zh": "以下一个编号创建新版本", + "ja": "連番で新しいバージョンを作成します", + "ru": "Создаёт новую версию со следующим номером" + }, + "Als neue Version anlegen": { + "de": "Als neue Version anlegen", + "en": "Create as new version", + "es": "Crear como nueva versión", + "zh": "另存为新版本", + "ja": "新しいバージョンとして作成", + "ru": "Создать как новую версию" + }, + "Erstellt eine neue Version dieses Prompts": { + "de": "Erstellt eine neue Version dieses Prompts", + "en": "Creates a new version of this prompt", + "es": "Crea una nueva versión de este prompt", + "zh": "创建此提示的新版本", + "ja": "このプロンプトの新しいバージョンを作成します", + "ru": "Создаёт новую версию этого промпта" + }, + "Version anlegen (Enter)": { + "de": "Version anlegen (Enter)", + "en": "Create version (Enter)", + "es": "Crear versión (Intro)", + "zh": "创建版本(Enter)", + "ja": "バージョンを作成 (Enter)", + "ru": "Создать версию (Enter)" + }, + "Speichern fehlgeschlagen": { + "de": "Speichern fehlgeschlagen", + "en": "Save failed", + "es": "Error al guardar", + "zh": "保存失败", + "ja": "保存に失敗しました", + "ru": "Ошибка сохранения" + }, + "Der zugehörige Prompt wurde nicht gefunden.": { + "de": "Der zugehörige Prompt wurde nicht gefunden.", + "en": "The associated prompt was not found.", + "es": "No se encontró el prompt asociado.", + "zh": "未找到对应的提示。", + "ja": "関連するプロンプトが見つかりません。", + "ru": "Связанный промпт не найден." + }, + "Bibliothek nicht verfügbar": { + "de": "Bibliothek nicht verfügbar", + "en": "Library unavailable", + "es": "Biblioteca no disponible", + "zh": "库不可用", + "ja": "ライブラリを利用できません", + "ru": "Библиотека недоступна" + }, + "Die Bibliothek konnte nicht gelesen oder gespeichert werden.\nPrüfen Sie die Datei und ihre Zugriffsrechte und versuchen Sie es erneut.": { + "de": "Die Bibliothek konnte nicht gelesen oder gespeichert werden.\nPrüfen Sie die Datei und ihre Zugriffsrechte und versuchen Sie es erneut.", + "en": "The library could not be read or saved.\nCheck the file and its access permissions and try again.", + "es": "No se pudo leer ni guardar la biblioteca.\nCompruebe el archivo y sus permisos de acceso e inténtelo de nuevo.", + "zh": "无法读取或保存库。\n请检查文件及其访问权限后重试。", + "ja": "ライブラリを読み込めないか保存できませんでした。\nファイルとアクセス権を確認して再試行してください。", + "ru": "Не удалось прочитать или сохранить библиотеку.\nПроверьте файл и права доступа и повторите попытку." + }, + "Eingabe / Return": { + "de": "Eingabe / Return", + "en": "Enter / Return", + "es": "Intro / Retorno", + "zh": "Enter / 回车", + "ja": "Enter / Return", + "ru": "Enter / Return" + }, + "Entf / Backspace": { + "de": "Entf / Backspace", + "en": "Del / Backspace", + "es": "Supr / Retroceso", + "zh": "Delete / 退格", + "ja": "Delete / Backspace", + "ru": "Delete / Backspace" + }, + "Leertaste": { + "de": "Leertaste", + "en": "Space", + "es": "Barra espaciadora", + "zh": "空格键", + "ja": "スペース", + "ru": "Пробел" + }, + "Kontextmenü / Shift+F10": { + "de": "Kontextmenü / Shift+F10", + "en": "Menu key / Shift+F10", + "es": "Tecla de menú / Mayús+F10", + "zh": "菜单键 / Shift+F10", + "ja": "メニューキー / Shift+F10", + "ru": "Клавиша меню / Shift+F10" + }, + "Kachel auf anderes Board verschieben / duplizieren": { + "de": "Kachel auf anderes Board verschieben / duplizieren", + "en": "Move / duplicate tile to another board", + "es": "Mover / duplicar mosaico a otro tablero", + "zh": "将磁贴移动/复制到其他看板", + "ja": "タイルを別のボードへ移動/複製", + "ru": "Переместить / дублировать плитку на другую доску" + }, + "Kachelfarbe ändern": { + "de": "Kachelfarbe ändern", + "en": "Change tile color", + "es": "Cambiar color del mosaico", + "zh": "更改磁贴颜色", + "ja": "タイルの色を変更", + "ru": "Изменить цвет плитки" } } diff --git a/src/appearance_dialog.py b/src/appearance_dialog.py index 5258edc..d3532da 100644 --- a/src/appearance_dialog.py +++ b/src/appearance_dialog.py @@ -15,22 +15,23 @@ from settings_manager import SettingsManager import theme as theme_mod +from i18n import tr class AppearanceDialog(QtWidgets.QDialog): def __init__(self, settings: SettingsManager, parent=None): super().__init__(parent) self.settings = settings - self.setWindowTitle("Darstellung") + self.setWindowTitle(tr("Darstellung")) self.setModal(True) self.setMinimumWidth(360) # --- Theme (U2) --- self.theme_combo = QtWidgets.QComboBox() - self.theme_combo.setAccessibleName("Theme-Auswahl") - self.theme_combo.setAccessibleDescription("Auswahl zwischen dunklem und hellem Farbschema") - self.theme_combo.addItem("Dunkel", "dark") - self.theme_combo.addItem("Hell", "light") + self.theme_combo.setAccessibleName(tr("Theme-Auswahl")) + self.theme_combo.setAccessibleDescription(tr("Auswahl zwischen dunklem und hellem Farbschema")) + self.theme_combo.addItem(tr("Dunkel"), "dark") + self.theme_combo.addItem(tr("Hell"), "light") cur_theme = self.settings.get_theme() idx = self.theme_combo.findData(cur_theme) self.theme_combo.setCurrentIndex(idx if idx >= 0 else 0) @@ -40,25 +41,25 @@ def __init__(self, settings: SettingsManager, parent=None): self._version_color = self.settings.get_tile_color("version") self.btn_main = QtWidgets.QPushButton() - self.btn_main.setAccessibleName("Farbe Hauptprompt-Kacheln") + self.btn_main.setAccessibleName(tr("Farbe Hauptprompt-Kacheln")) self.btn_main.clicked.connect(lambda: self._pick_color("main")) self.btn_version = QtWidgets.QPushButton() - self.btn_version.setAccessibleName("Farbe Versionsprompt-Kacheln") + self.btn_version.setAccessibleName(tr("Farbe Versionsprompt-Kacheln")) self.btn_version.clicked.connect(lambda: self._pick_color("version")) self._refresh_swatch("main") self._refresh_swatch("version") - self.btn_reset = QtWidgets.QPushButton("Zurücksetzen") - self.btn_reset.setAccessibleName("Kachelfarben zurücksetzen") - self.btn_reset.setAccessibleDescription("Setzt die Kachelfarben auf die Standardwerte zurück") - self.btn_reset.setToolTip("Standardfarben wiederherstellen") + self.btn_reset = QtWidgets.QPushButton(tr("Zurücksetzen")) + self.btn_reset.setAccessibleName(tr("Kachelfarben zurücksetzen")) + self.btn_reset.setAccessibleDescription(tr("Setzt die Kachelfarben auf die Standardwerte zurück")) + self.btn_reset.setToolTip(tr("Standardfarben wiederherstellen")) self.btn_reset.clicked.connect(self._reset_colors) - lbl_theme = QtWidgets.QLabel("Theme:") + lbl_theme = QtWidgets.QLabel(tr("Theme:")) lbl_theme.setBuddy(self.theme_combo) - lbl_main = QtWidgets.QLabel("Farbe Hauptprompt-Kacheln:") + lbl_main = QtWidgets.QLabel(tr("Farbe Hauptprompt-Kacheln:")) lbl_main.setBuddy(self.btn_main) - lbl_version = QtWidgets.QLabel("Farbe Versionsprompt-Kacheln:") + lbl_version = QtWidgets.QLabel(tr("Farbe Versionsprompt-Kacheln:")) lbl_version.setBuddy(self.btn_version) # --- Layout --- @@ -68,16 +69,16 @@ def __init__(self, settings: SettingsManager, parent=None): form.addRow(lbl_version, self.btn_version) form.addRow("", self.btn_reset) - btn_ok = QtWidgets.QPushButton("OK") + btn_ok = QtWidgets.QPushButton(tr("OK")) btn_ok.setDefault(True) - btn_ok.setAccessibleName("OK") - btn_ok.setAccessibleDescription("Darstellungs-Einstellungen speichern und anwenden") - btn_ok.setToolTip("Einstellungen speichern (Enter)") + btn_ok.setAccessibleName(tr("OK")) + btn_ok.setAccessibleDescription(tr("Darstellungs-Einstellungen speichern und anwenden")) + btn_ok.setToolTip(tr("Einstellungen speichern (Enter)")) - btn_cancel = QtWidgets.QPushButton("Abbrechen") - btn_cancel.setAccessibleName("Abbrechen") - btn_cancel.setAccessibleDescription("Änderungen verwerfen und Dialog schließen") - btn_cancel.setToolTip("Abbrechen (Esc)") + btn_cancel = QtWidgets.QPushButton(tr("Abbrechen")) + btn_cancel.setAccessibleName(tr("Abbrechen")) + btn_cancel.setAccessibleDescription(tr("Änderungen verwerfen und Dialog schließen")) + btn_cancel.setToolTip(tr("Abbrechen (Esc)")) btn_ok.clicked.connect(self.accept) btn_cancel.clicked.connect(self.reject) @@ -107,9 +108,9 @@ def _refresh_swatch(self, kind: str): hexcolor = self._current_color(kind) text_col = theme_mod.contrast_text(hexcolor) btn.setText(hexcolor) - label_kind = "Hauptprompts" if kind == "main" else "Versionsprompts" - btn.setToolTip(f"Kachelfarbe für {label_kind} ändern (Aktuell: {hexcolor})") - btn.setAccessibleDescription(f"Aktuelle Hex-Farbe für {label_kind}: {hexcolor}") + label_kind = tr("Hauptprompts") if kind == "main" else tr("Versionsprompts") + btn.setToolTip(tr("Kachelfarbe für {kind} ändern (Aktuell: {color})", kind=label_kind, color=hexcolor)) + btn.setAccessibleDescription(tr("Aktuelle Hex-Farbe für {kind}: {color}", kind=label_kind, color=hexcolor)) btn.setStyleSheet( f"background-color: {hexcolor}; color: {text_col}; " f"border: 1px solid #888; border-radius: 4px; padding: 6px 12px;" @@ -118,7 +119,7 @@ def _refresh_swatch(self, kind: str): def _pick_color(self, kind: str): initial = QtGui.QColor(self._current_color(kind)) chosen = QtWidgets.QColorDialog.getColor( - initial, self, "Kachelfarbe wählen" + initial, self, tr("Kachelfarbe wählen") ) if chosen.isValid(): self._set_current_color(kind, chosen.name().upper()) diff --git a/src/board_manager.py b/src/board_manager.py index e16bb9f..65ff1b1 100644 --- a/src/board_manager.py +++ b/src/board_manager.py @@ -15,6 +15,7 @@ from prompt_dialog import PromptDialog, VersionDialog from pdf_exporter import export_single_prompt, export_single_version import theme as theme_mod +from i18n import tr class PromptTile(QtWidgets.QFrame): clicked = QtCore.Signal(str, object) @@ -24,10 +25,11 @@ class PromptTile(QtWidgets.QFrame): removeRequested = QtCore.Signal(QtWidgets.QFrame) def __init__(self, prompt: Prompt, version: Optional[Version], font_family: Optional[str], - tile_palette: Optional[Dict] = None, parent=None): + tile_palette: Optional[Dict] = None, parent=None, color: Optional[str] = None): super().__init__(parent) self.prompt = prompt self.version = version + self.color = color # individuelle Kachelfarbe (None => Standardfarbe der Kachelart) self._drag_start_pos: Optional[QtCore.QPoint] = None self._suppress_click = False @@ -38,9 +40,9 @@ def __init__(self, prompt: Prompt, version: Optional[Version], font_family: Opti self.setStyleSheet(self._tile_styles(font_family, tile_palette)) if version: v_title = version.title or "" - self.setAccessibleName(f"Prompt-Kachel: {prompt.title} (v{version.version_number} — {v_title})".strip()) + self.setAccessibleName(tr("Prompt-Kachel: {title}", title=f"{prompt.title} (v{version.version_number} — {v_title})").strip()) else: - self.setAccessibleName(f"Prompt-Kachel: {prompt.title}") + self.setAccessibleName(tr("Prompt-Kachel: {title}", title=prompt.title)) # Etwas dezenterer Schatten für Dark Mode shadow = QtWidgets.QGraphicsDropShadowEffect(self) @@ -78,19 +80,19 @@ def __init__(self, prompt: Prompt, version: Optional[Version], font_family: Opti top_row = QtWidgets.QHBoxLayout() badge = QtWidgets.QLabel(badge_txt) badge.setObjectName("Badge") - badge.setAccessibleName(f"Kachel-Typ: {badge_txt}") + badge.setAccessibleName(tr("Kachel-Typ: {badge}", badge=badge_txt)) subtitle = QtWidgets.QLabel(sub_txt) subtitle.setObjectName("Subtitle") subtitle.setWordWrap(True) - subtitle.setAccessibleName(f"Kachel-Untertitel: {sub_txt}") + subtitle.setAccessibleName(tr("Kachel-Untertitel: {text}", text=sub_txt)) # Preview Text preview = QtWidgets.QLabel(prev_txt) preview.setObjectName("Preview") preview.setWordWrap(True) preview.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) - preview.setAccessibleName(f"Kachel-Vorschau: {prev_txt}") + preview.setAccessibleName(tr("Kachel-Vorschau: {text}", text=prev_txt)) vbox.addWidget(badge) vbox.addWidget(subtitle) @@ -191,6 +193,19 @@ def keyPressEvent(self, event: QtGui.QKeyEvent): class BoardManager(QtWidgets.QWidget): MIME = "application/x-prompt-item" + # Vordefinierte Kachelfarben fuer das Kontextmenue (Name = Uebersetzungs-Key). + TILE_COLOR_PRESETS = ( + ("Rot", "#B23A48"), + ("Orange", "#C8682C"), + ("Gelb", "#C9A227"), + ("Grün", "#3E7D4F"), + ("Türkis", "#1F7A7A"), + ("Blau", "#2F5D9E"), + ("Violett", "#6A4C93"), + ("Rosa", "#B0577E"), + ("Grau", "#5F6B73"), + ) + def __init__(self, storage: Storage, settings: SettingsManager, parent=None): super().__init__(parent) self.storage = storage @@ -199,32 +214,19 @@ def __init__(self, storage: Storage, settings: SettingsManager, parent=None): # Header self.board_combo = QtWidgets.QComboBox() - self.btn_new_board = QtWidgets.QPushButton("Neu") - self.btn_del_board = QtWidgets.QPushButton("Löschen") - self.btn_font = QtWidgets.QPushButton("Font") - - # Icons (optional, hier textbasiert um Ressource-Fehler zu vermeiden) - # self.btn_new_board.setIcon(...) - - lbl_board = QtWidgets.QLabel("Board:") - lbl_board.setBuddy(self.board_combo) - - self.board_combo.setAccessibleName("Aktives Board") - self.board_combo.setAccessibleDescription("Wählt das aktive Prompt-Board aus") + self.btn_new_board = QtWidgets.QPushButton() + self.btn_ren_board = QtWidgets.QPushButton() + self.btn_del_board = QtWidgets.QPushButton() + self.btn_font = QtWidgets.QPushButton() - self.btn_new_board.setAccessibleName("Neues Board") - self.btn_new_board.setAccessibleDescription("Erstellt ein neues leeres Prompt-Board") - - self.btn_del_board.setAccessibleName("Board löschen") - self.btn_del_board.setAccessibleDescription("Löscht das aktuell ausgewählte Prompt-Board") - - self.btn_font.setAccessibleName("Kachelschriftart wählen") - self.btn_font.setAccessibleDescription("Öffnet die Schriftartenauswahl für Board-Kacheln") + self.lbl_board = QtWidgets.QLabel() + self.lbl_board.setBuddy(self.board_combo) header = QtWidgets.QHBoxLayout() - header.addWidget(lbl_board) + header.addWidget(self.lbl_board) header.addWidget(self.board_combo, stretch=1) header.addWidget(self.btn_new_board) + header.addWidget(self.btn_ren_board) header.addWidget(self.btn_del_board) header.addWidget(self.btn_font) @@ -234,9 +236,6 @@ def __init__(self, storage: Storage, settings: SettingsManager, parent=None): self.container = QtWidgets.QWidget() self.container.setObjectName("BoardContainer") - self.scroll.setAccessibleName("Board-Arbeitsfläche") - self.scroll.setAccessibleDescription("Bereich mit angehefteten Prompt-Kacheln") - self.container.setAccessibleName("Kachel-Raster") # Board-Flaechen-Hintergrund folgt dem Theme (U2) self._apply_surface_styles() @@ -245,25 +244,59 @@ def __init__(self, storage: Storage, settings: SettingsManager, parent=None): self.grid.setContentsMargins(20, 20, 20, 20) self.grid.setHorizontalSpacing(20) self.grid.setVerticalSpacing(20) + self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) self.scroll.setWidget(self.container) + self.scroll.viewport().installEventFilter(self) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(header) layout.addWidget(self.scroll) + self.retranslate_ui() + # Connects self.board_combo.currentIndexChanged.connect(self.reload_items) self.btn_new_board.clicked.connect(self.create_board) + self.btn_ren_board.clicked.connect(self.rename_current_board) self.btn_del_board.clicked.connect(self.delete_current_board) self.btn_font.clicked.connect(self.choose_tile_font) - + bus.boardsChanged.connect(self.reload) bus.promptsChanged.connect(self.reload_items) + bus.languageChanged.connect(self._on_language_changed) self.setAcceptDrops(True) self.reload() + def retranslate_ui(self): + """Setzt alle statischen Texte der Board-Leiste in der aktiven Sprache.""" + self.lbl_board.setText(tr("Board:")) + self.btn_new_board.setText(tr("Neu")) + self.btn_ren_board.setText(tr("Umbenennen")) + self.btn_del_board.setText(tr("Löschen")) + self.btn_font.setText(tr("Schriftart")) + + self.board_combo.setAccessibleName(tr("Aktives Board")) + self.board_combo.setAccessibleDescription(tr("Wählt das aktive Prompt-Board aus")) + self.btn_new_board.setAccessibleName(tr("Neues Board")) + self.btn_new_board.setAccessibleDescription(tr("Erstellt ein neues leeres Prompt-Board")) + self.btn_ren_board.setAccessibleName(tr("Board umbenennen")) + self.btn_ren_board.setAccessibleDescription(tr("Benennt das aktuell ausgewählte Prompt-Board um")) + self.btn_del_board.setAccessibleName(tr("Board löschen")) + self.btn_del_board.setAccessibleDescription(tr("Löscht das aktuell ausgewählte Prompt-Board")) + self.btn_font.setAccessibleName(tr("Kachelschriftart wählen")) + self.btn_font.setAccessibleDescription(tr("Öffnet die Schriftartenauswahl für Board-Kacheln")) + + self.scroll.setAccessibleName(tr("Board-Arbeitsfläche")) + self.scroll.setAccessibleDescription(tr("Bereich mit angehefteten Prompt-Kacheln")) + self.container.setAccessibleName(tr("Kachel-Raster")) + + def _on_language_changed(self, _lang: str = ""): + self.retranslate_ui() + # Kacheln tragen uebersetzte Accessible-Names -> neu aufbauen + self.reload_items() + def _get_tile_font_family(self) -> Optional[str]: fam = self.settings.qs.value("tiles/font_family", "", type=str) return fam or None @@ -282,7 +315,7 @@ def apply_theme_and_reload(self): def choose_tile_font(self): cur_fam = self._get_tile_font_family() cur_font = QtGui.QFont(cur_fam) if cur_fam else QtGui.QFont() - ok, font = QtWidgets.QFontDialog.getFont(cur_font, self, "Schriftart wählen") + ok, font = QtWidgets.QFontDialog.getFont(cur_font, self, tr("Schriftart wählen")) if ok: self.settings.qs.setValue("tiles/font_family", font.family()) self.reload_items() @@ -305,6 +338,8 @@ def reload(self, select_board_id: Optional[str] = None): if hasattr(self, "btn_del_board"): self.btn_del_board.setEnabled(len(boards) > 0) + if hasattr(self, "btn_ren_board"): + self.btn_ren_board.setEnabled(len(boards) > 0) self.reload_items() @@ -314,9 +349,9 @@ def remove_tile_item(self, prompt_id: str, version_id: Optional[str] = None): board = self.current_board() if not board: return - msg = "Möchten Sie diese Kachel wirklich vom Board entfernen?" + msg = tr("Möchten Sie diese Kachel wirklich vom Board entfernen?") if QtWidgets.QMessageBox.question( - self, "Kachel entfernen", msg + self, tr("Kachel entfernen"), msg ) == QtWidgets.QMessageBox.StandardButton.Yes: success = self.storage.remove_item_from_board(board.id, prompt_id, version_id) if success: @@ -328,12 +363,50 @@ def current_board(self) -> Optional[Board]: if not bid: return None return next((b for b in self.storage.load_boards() if b.id == bid), None) - def reload_items(self): - # Clear Grid + TILE_WIDTH = 260 + + def _column_count(self) -> int: + """Spaltenzahl passend zur sichtbaren Breite (mind. 1, max. 6).""" + m = self.grid.contentsMargins() + avail = self.scroll.viewport().width() - m.left() - m.right() + step = self.TILE_WIDTH + self.grid.horizontalSpacing() + return max(1, min(6, (avail + self.grid.horizontalSpacing()) // step)) + + def _clear_grid(self): while self.grid.count(): item = self.grid.takeAt(0) w = item.widget() - if w: w.deleteLater() + if w: + # Sofort verstecken: deleteLater greift erst im Event-Loop, bis dahin + # blieben alte Kacheln sichtbar und ueberlappten die neuen. + w.hide() + w.deleteLater() + + def _layout_tiles(self, tiles): + """Ordnet Kacheln im Raster an; Spaltenzahl folgt der Dock-Breite.""" + cols = self._column_count() + self._layout_cols = cols + for i, tile in enumerate(tiles): + self.grid.addWidget(tile, i // cols, i % cols) + # Spacer damit alles oben links bleibt + rows = (len(tiles) + cols - 1) // cols + spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.grid.addItem(spacer, rows, 0) + + def eventFilter(self, obj, event): + # Viewport-Breite geaendert (Dock gezogen, Scrollbar ein/aus) -> Spalten anpassen + if obj is self.scroll.viewport() and event.type() == QtCore.QEvent.Type.Resize: + tiles = getattr(self, "_tiles", []) + if tiles and self._column_count() != getattr(self, "_layout_cols", None): + # Nur neu anordnen (kein Disk-Zugriff, Kacheln bleiben erhalten) + while self.grid.count(): + self.grid.takeAt(0) + self._layout_tiles(tiles) + return super().eventFilter(obj, event) + + def reload_items(self): + self._clear_grid() + self._tiles = [] board = self.current_board() if not board: return @@ -345,11 +418,10 @@ def reload_items(self): # Haupt-Prompt- und Versions-Kacheln, einmal pro Reload berechnet. main_pal = theme_mod.derive_tile_palette(self.settings.get_tile_color("main")) version_pal = theme_mod.derive_tile_palette(self.settings.get_tile_color("version")) + # Individuelle Kachelfarben: Palette je Farbe nur einmal ableiten + custom_pals: Dict[str, Dict] = {} - # Responsive Grid Logic (fixe Spaltenanzahl ist oft unflexibel, hier 3) - cols = 3 - row, col = 0, 0 - + tiles = [] for item in board.items: if not item: continue @@ -366,37 +438,59 @@ def reload_items(self): # Hauptprompt gerendert werden (fuehrt zu irrefuehrender UI & unloeschbaren Kacheln) continue - tile = PromptTile(p, v, font_family, version_pal if v else main_pal, self) + item_color = getattr(item, "color", None) + if item_color: + pal = custom_pals.get(item_color) + if pal is None: + pal = custom_pals[item_color] = theme_mod.derive_tile_palette(item_color) + else: + pal = version_pal if v else main_pal + + tile = PromptTile(p, v, font_family, pal, self, color=item_color) tile.clicked.connect(self._on_tile_clicked) tile.doubleClicked.connect(self._on_tile_double_clicked) tile.contextRequested.connect(self._on_tile_context_menu) tile.removeRequested.connect(self._remove_item_from_board) + tiles.append(tile) - self.grid.addWidget(tile, row, col) - col += 1 - if col >= cols: - col = 0 - row += 1 - - # Spacer damit alles oben links bleibt - spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) - self.grid.addItem(spacer, row + 1, 0) + self._tiles = tiles + self._layout_tiles(tiles) # --- Actions --- + def _ask_board_title(self, title: str, default: str = "") -> Optional[str]: + text, ok = QtWidgets.QInputDialog.getText(self, title, tr("Name:"), text=default) + if ok and text.strip(): + return text.strip() + return None + @report_storage_errors - def create_board(self): - title, ok = QtWidgets.QInputDialog.getText(self, "Neues Board", "Name:") - if ok and title.strip(): - b = Board(id=gen_id(), title=title.strip(), items=[]) + def create_board(self) -> Optional[Board]: + title = self._ask_board_title(tr("Neues Board")) + if title: + b = Board(id=gen_id(), title=title, items=[]) self.storage.upsert_board(b) self._pending_select_board_id = b.id bus.boardsChanged.emit() + return b + return None + + @report_storage_errors + def rename_current_board(self): + b = self.current_board() + if not b: + return + title = self._ask_board_title(tr("Board umbenennen"), b.title) + if title and title != b.title and self.storage.rename_board(b.id, title): + self._pending_select_board_id = b.id + bus.boardsChanged.emit() @report_storage_errors def delete_current_board(self): b = self.current_board() if not b: return - if QtWidgets.QMessageBox.question(self, "Löschen", f"Board '{b.title}' wirklich löschen?") == QtWidgets.QMessageBox.StandardButton.Yes: + if QtWidgets.QMessageBox.question( + self, tr("Löschen"), tr("Board „{title}“ wirklich löschen?", title=b.title) + ) == QtWidgets.QMessageBox.StandardButton.Yes: self.storage.delete_board(b.id) bus.boardsChanged.emit() @@ -419,13 +513,117 @@ def _on_tile_double_clicked(self, pid, vid): if PromptDialog(self.storage, p, self).exec(): bus.promptsChanged.emit() - def _on_tile_context_menu(self, tile, gpos): + @staticmethod + def _color_icon(hexcolor: str) -> QtGui.QIcon: + pm = QtGui.QPixmap(16, 16) + pm.fill(QtGui.QColor(hexcolor)) + return QtGui.QIcon(pm) + + def build_tile_context_menu(self, tile) -> QtWidgets.QMenu: + """Baut das Kachel-Kontextmenue (separat testbar, ohne exec()).""" + pid = tile.prompt.id + vid = tile.version.id if tile.version else None menu = QtWidgets.QMenu(self) - menu.addAction("Kopieren", lambda: self._on_tile_clicked(tile.prompt.id, tile.version.id if tile.version else None)) - menu.addAction("Bearbeiten", lambda: self._on_tile_double_clicked(tile.prompt.id, tile.version.id if tile.version else None)) + menu.addAction(tr("Kopieren"), lambda: self._on_tile_clicked(pid, vid)) + menu.addAction(tr("Bearbeiten"), lambda: self._on_tile_double_clicked(pid, vid)) menu.addSeparator() - menu.addAction("Vom Board entfernen", lambda: self._remove_item_from_board(tile)) - menu.exec(gpos) + + # Auf anderes Board senden (verschieben) / duplizieren (kopieren) + board = self.current_board() + others = [b for b in self.storage.load_boards() if board is None or b.id != board.id] + m_move = menu.addMenu(tr("Auf Board verschieben")) + m_copy = menu.addMenu(tr("Auf Board duplizieren")) + for sub, move in ((m_move, True), (m_copy, False)): + for b in others: + sub.addAction(b.title or tr("(ohne Titel)"), + lambda bid=b.id, mv=move: self.transfer_tile(tile, bid, move=mv)) + if others: + sub.addSeparator() + sub.addAction(tr("Neues Board …"), + lambda mv=move: self.transfer_tile_to_new_board(tile, move=mv)) + m_move.setEnabled(board is not None) + m_copy.setEnabled(board is not None) + + # Kachelfarbe + m_color = menu.addMenu(tr("Kachelfarbe")) + act_default = m_color.addAction(tr("Standardfarbe"), lambda: self.set_tile_color(tile, None)) + act_default.setCheckable(True) + act_default.setChecked(not tile.color) + m_color.addSeparator() + for name, hexcolor in self.TILE_COLOR_PRESETS: + act = m_color.addAction(self._color_icon(hexcolor), tr(name), + lambda c=hexcolor: self.set_tile_color(tile, c)) + act.setCheckable(True) + act.setChecked((tile.color or "").upper() == hexcolor) + m_color.addSeparator() + m_color.addAction(tr("Eigene Farbe …"), lambda: self.choose_custom_tile_color(tile)) + + menu.addSeparator() + menu.addAction(tr("Vom Board entfernen"), lambda: self._remove_item_from_board(tile)) + return menu + + def _on_tile_context_menu(self, tile, gpos): + self.build_tile_context_menu(tile).exec(gpos) + + @report_storage_errors + def transfer_tile(self, tile, target_board_id: str, move: bool = False) -> bool: + """Sendet (move=True) oder dupliziert (move=False) eine Kachel auf ein anderes Board.""" + board = self.current_board() + if not board or not target_board_id: + return False + pid = tile.prompt.id + vid = tile.version.id if tile.version else None + ok, reason = self.storage.transfer_item(board.id, target_board_id, pid, vid, move=move) + target = next((b for b in self.storage.load_boards() if b.id == target_board_id), None) + target_title = target.title if target else "" + if ok: + if move: + self.reload_items() + bus.boardsChanged.emit() + msg = (tr("Kachel auf Board „{title}“ verschoben.", title=target_title) if move + else tr("Kachel auf Board „{title}“ dupliziert.", title=target_title)) + self._show_status(msg) + return True + if reason == "duplicate": + QtWidgets.QMessageBox.information( + self, tr("Hinweis"), + tr("Diese Kachel ist auf Board „{title}“ bereits vorhanden.", title=target_title)) + return False + + @report_storage_errors + def transfer_tile_to_new_board(self, tile, move: bool = False) -> bool: + title = self._ask_board_title(tr("Neues Board")) + if not title: + return False + b = Board(id=gen_id(), title=title, items=[]) + self.storage.upsert_board(b) + return self.transfer_tile(tile, b.id, move=move) + + @report_storage_errors + def set_tile_color(self, tile, hexcolor: Optional[str]) -> bool: + board = self.current_board() + if not board: + return False + vid = tile.version.id if tile.version else None + if self.storage.set_item_color(board.id, tile.prompt.id, vid, hexcolor): + self.reload_items() + return True + return False + + def choose_custom_tile_color(self, tile): + if tile.color: + initial = QtGui.QColor(tile.color) + else: + initial = QtGui.QColor(self.settings.get_tile_color("version" if tile.version else "main")) + chosen = QtWidgets.QColorDialog.getColor(initial, self, tr("Kachelfarbe wählen")) + if chosen.isValid(): + self.set_tile_color(tile, chosen.name().upper()) + + def _show_status(self, message: str): + win = self.window() + status = win.statusBar() if isinstance(win, QtWidgets.QMainWindow) else None + if status is not None: + status.showMessage(message, 4000) @report_storage_errors def _remove_item_from_board(self, tile): diff --git a/src/clipboard_manager.py b/src/clipboard_manager.py index afd9d50..35c71ac 100644 --- a/src/clipboard_manager.py +++ b/src/clipboard_manager.py @@ -2,6 +2,7 @@ from PySide6 import QtWidgets from models import CopyMode, Prompt, Version from settings_manager import SettingsManager +from i18n import tr class ClipboardManager: def __init__(self, settings: SettingsManager): @@ -58,5 +59,5 @@ def copy_to_clipboard(self, widget, text: str): if widget is not None: QtWidgets.QToolTip.showText( widget.mapToGlobal(widget.rect().center()), - "Kopiert 📋" + tr("Kopiert 📋") ) diff --git a/src/copy_settings_dialog.py b/src/copy_settings_dialog.py index 164fc46..ad2c3e9 100644 --- a/src/copy_settings_dialog.py +++ b/src/copy_settings_dialog.py @@ -1,25 +1,26 @@ from PySide6 import QtWidgets from models import CopyMode from settings_manager import SettingsManager +from i18n import tr class CopySettingsDialog(QtWidgets.QDialog): def __init__(self, settings: SettingsManager, parent=None): super().__init__(parent) - self.setWindowTitle("Kopier-Einstellungen") + self.setWindowTitle(tr("Kopier-Einstellungen")) self.setModal(True) self.settings = settings # ComboBox mit Label und zugehörigem Enum-Wert self.mode_combo = QtWidgets.QComboBox() - self.mode_combo.setAccessibleName("Kopiermodus") - self.mode_combo.setAccessibleDescription("Auswahl, welche Inhalte beim Kopieren in die Zwischenablage übernommen werden") + self.mode_combo.setAccessibleName(tr("Kopiermodus")) + self.mode_combo.setAccessibleDescription(tr("Auswahl, welche Inhalte beim Kopieren in die Zwischenablage übernommen werden")) for label, mode in [ ("Nur Titel", CopyMode.TITLE.value), ("Nur Prompt-Text", CopyMode.TEXT.value), ("Nur Ergebnis", CopyMode.RESULT.value), ("Alles", CopyMode.ALL.value), ]: - self.mode_combo.addItem(label, mode) + self.mode_combo.addItem(tr(label), mode) # Aktuellen Modus vorwählen current = self.settings.get_copy_mode().value @@ -28,12 +29,12 @@ def __init__(self, settings: SettingsManager, parent=None): self.mode_combo.setCurrentIndex(idx) # Checkbox für Metadaten - self.chk_meta = QtWidgets.QCheckBox("Metadaten (Tags) hinzufügen") - self.chk_meta.setAccessibleName("Metadaten hinzufügen") - self.chk_meta.setAccessibleDescription("Schlagwörter und Metadaten an den kopierten Text anfügen") + self.chk_meta = QtWidgets.QCheckBox(tr("Metadaten (Tags) hinzufügen")) + self.chk_meta.setAccessibleName(tr("Metadaten hinzufügen")) + self.chk_meta.setAccessibleDescription(tr("Schlagwörter und Metadaten an den kopierten Text anfügen")) self.chk_meta.setChecked(self.settings.get_include_metadata()) - lbl_mode = QtWidgets.QLabel("Kopiermodus:") + lbl_mode = QtWidgets.QLabel(tr("Kopiermodus:")) lbl_mode.setBuddy(self.mode_combo) # Layout @@ -41,16 +42,16 @@ def __init__(self, settings: SettingsManager, parent=None): form.addRow(lbl_mode, self.mode_combo) form.addRow("", self.chk_meta) - btn_ok = QtWidgets.QPushButton("OK") + btn_ok = QtWidgets.QPushButton(tr("OK")) btn_ok.setDefault(True) - btn_ok.setAccessibleName("OK") - btn_ok.setAccessibleDescription("Kopier-Einstellungen speichern und Dialog schließen") - btn_ok.setToolTip("Einstellungen speichern (Enter)") + btn_ok.setAccessibleName(tr("OK")) + btn_ok.setAccessibleDescription(tr("Kopier-Einstellungen speichern und Dialog schließen")) + btn_ok.setToolTip(tr("Einstellungen speichern (Enter)")) - btn_cancel = QtWidgets.QPushButton("Abbrechen") - btn_cancel.setAccessibleName("Abbrechen") - btn_cancel.setAccessibleDescription("Änderungen verwerfen und Dialog schließen") - btn_cancel.setToolTip("Abbrechen (Esc)") + btn_cancel = QtWidgets.QPushButton(tr("Abbrechen")) + btn_cancel.setAccessibleName(tr("Abbrechen")) + btn_cancel.setAccessibleDescription(tr("Änderungen verwerfen und Dialog schließen")) + btn_cancel.setToolTip(tr("Abbrechen (Esc)")) btn_ok.clicked.connect(self.accept) btn_cancel.clicked.connect(self.reject) diff --git a/src/dashboard.py b/src/dashboard.py index 5388017..1a6b588 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -14,6 +14,7 @@ from prompt_dialog import PromptDialog, VersionDialog from clipboard_manager import ClipboardManager from atomic_io import atomic_write_text +from i18n import tr from pdf_exporter import ( export_single_prompt, export_single_version, @@ -37,11 +38,9 @@ class PromptTree(QtWidgets.QTreeWidget): def __init__(self, parent=None): super().__init__(parent) - self.setHeaderLabels( - ["Titel", "Zweck", "Tags", "Erstellt", "Aktualisiert", "", ""] - ) # Letzte beiden Spalten für Icons freihalten self.setColumnCount(7) + self.retranslate_ui() self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.setDragEnabled(True) self.setAcceptDrops(False) @@ -49,8 +48,14 @@ def __init__(self, parent=None): self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection) self.setAlternatingRowColors(True) self.setUniformRowHeights(True) - self.setAccessibleName("Prompt-Übersicht") - self.setAccessibleDescription("Hierarchische Übersicht aller Prompts und Versionen mit Tastaturbedienung") + + HEADER_KEYS = ("Titel", "Zweck", "Tags", "Erstellt", "Aktualisiert") + + def retranslate_ui(self): + """Spaltenkoepfe + Accessible-Names in der aktiven Sprache.""" + self.setHeaderLabels([tr(k) for k in self.HEADER_KEYS] + ["", ""]) + self.setAccessibleName(tr("Prompt-Übersicht")) + self.setAccessibleDescription(tr("Hierarchische Übersicht aller Prompts und Versionen mit Tastaturbedienung")) def mimeTypes(self) -> List[str]: @@ -128,10 +133,9 @@ def __init__( # --- Filter Controls --- self.search_edit = QtWidgets.QLineEdit() - self.search_edit.setPlaceholderText("Suche in Titel/Text/Tags …") self.tag_combo = QtWidgets.QComboBox() - self.tag_combo.addItem("Alle Tags", "") + self.tag_combo.addItem(tr("Alle Tags"), "") self.date_from = QtWidgets.QDateEdit(calendarPopup=True, displayFormat="yyyy-MM-dd") self.date_to = QtWidgets.QDateEdit(calendarPopup=True, displayFormat="yyyy-MM-dd") @@ -143,7 +147,7 @@ def __init__( d.setDate(sentinel) self._date_sentinel = sentinel - self.btn_clear = QtWidgets.QPushButton("Filter zurücksetzen") + self.btn_clear = QtWidgets.QPushButton() # Filter-Aenderungen: nur Cache neu filtern (kein Disk-Zugriff) self.search_edit.textChanged.connect(self._apply_filters) @@ -158,29 +162,15 @@ def __init__( self.tree.customContextMenuRequested.connect(self.open_context_menu) self.customContextMenuRequested.connect(self.open_context_menu) # --- Layout --- - self.search_edit.setAccessibleName("Suchbegriff") - self.search_edit.setAccessibleDescription("Suchfeld für Volltextsuche in Titeln, Texten und Tags") - - self.tag_combo.setAccessibleName("Tag-Filter") - self.tag_combo.setAccessibleDescription("Filtert Prompts nach ausgewählten Tags") - - self.date_from.setAccessibleName("Startdatum") - self.date_from.setAccessibleDescription("Filtert Prompts ab dem angegebenen Erstellungsdatum") - - self.date_to.setAccessibleName("Enddatum") - self.date_to.setAccessibleDescription("Filtert Prompts bis zum angegebenen Erstellungsdatum") - - self.btn_clear.setAccessibleName("Filter zurücksetzen") - self.btn_clear.setAccessibleDescription("Setzt alle Such-, Tag- und Datumsfilter zurück") - - lbl_search = QtWidgets.QLabel("Suche:") + self.lbl_search = lbl_search = QtWidgets.QLabel() lbl_search.setBuddy(self.search_edit) - lbl_tag = QtWidgets.QLabel("Tag:") + self.lbl_tag = lbl_tag = QtWidgets.QLabel() lbl_tag.setBuddy(self.tag_combo) - lbl_from = QtWidgets.QLabel("Von:") + self.lbl_from = lbl_from = QtWidgets.QLabel() lbl_from.setBuddy(self.date_from) - lbl_to = QtWidgets.QLabel("Bis:") + self.lbl_to = lbl_to = QtWidgets.QLabel() lbl_to.setBuddy(self.date_to) + self._retranslate_controls() filter_layout = QtWidgets.QGridLayout() filter_layout.addWidget(lbl_search, 0, 0) @@ -199,10 +189,37 @@ def __init__( # Externe Datenänderungen: vollen Reload (Disk + Filter) bus.promptsChanged.connect(self.reload) + bus.languageChanged.connect(self.retranslate_ui) # Initial load self.reload() + def _retranslate_controls(self): + self.search_edit.setPlaceholderText(tr("Suche in Titel/Text/Tags …")) + self.btn_clear.setText(tr("Filter zurücksetzen")) + self.lbl_search.setText(tr("Suche:")) + self.lbl_tag.setText(tr("Tag:")) + self.lbl_from.setText(tr("Von:")) + self.lbl_to.setText(tr("Bis:")) + + self.search_edit.setAccessibleName(tr("Suchbegriff")) + self.search_edit.setAccessibleDescription(tr("Suchfeld für Volltextsuche in Titeln, Texten und Tags")) + self.tag_combo.setAccessibleName(tr("Tag-Filter")) + self.tag_combo.setAccessibleDescription(tr("Filtert Prompts nach ausgewählten Tags")) + self.date_from.setAccessibleName(tr("Startdatum")) + self.date_from.setAccessibleDescription(tr("Filtert Prompts ab dem angegebenen Erstellungsdatum")) + self.date_to.setAccessibleName(tr("Enddatum")) + self.date_to.setAccessibleDescription(tr("Filtert Prompts bis zum angegebenen Erstellungsdatum")) + self.btn_clear.setAccessibleName(tr("Filter zurücksetzen")) + self.btn_clear.setAccessibleDescription(tr("Setzt alle Such-, Tag- und Datumsfilter zurück")) + + def retranslate_ui(self, _lang: str = ""): + """Live-Sprachwechsel: Filterleiste, Tabellenkoepfe und Zeilen neu beschriften.""" + self._retranslate_controls() + self.tree.retranslate_ui() + # Baum neu aufbauen (Tooltips/Accessible-Names der Kopier-Buttons, "Alle Tags") + self._apply_filters() + def _clear_filters(self): self.search_edit.clear() self.tag_combo.setCurrentIndex(0) @@ -225,7 +242,7 @@ def _apply_filters(self): current_tag = self.tag_combo.currentData() self.tag_combo.blockSignals(True) self.tag_combo.clear() - self.tag_combo.addItem("Alle Tags", "") + self.tag_combo.addItem(tr("Alle Tags"), "") for tag in self._collect_tags(prompts): self.tag_combo.addItem(tag, tag) idx = self.tag_combo.findData(current_tag) @@ -326,10 +343,10 @@ def safe_date(s: Optional[str]) -> str: btn_copy = QtWidgets.QToolButton() btn_copy.setIcon(QtGui.QIcon(CLIPBOARD_ICON)) btn_copy.setAutoRaise(True) - p_title_str = p.title or "Ohne Titel" - btn_copy.setToolTip(f"Prompt kopieren: {p_title_str}") - btn_copy.setAccessibleName(f"Prompt kopieren: {p_title_str}") - btn_copy.setAccessibleDescription("Kopiert den Prompt-Text entsprechend den Kopiermodi in die Zwischenablage") + p_title_str = p.title or tr("Ohne Titel") + btn_copy.setToolTip(tr("Prompt kopieren: {title}", title=p_title_str)) + btn_copy.setAccessibleName(tr("Prompt kopieren: {title}", title=p_title_str)) + btn_copy.setAccessibleDescription(tr("Kopiert den Prompt-Text entsprechend den Kopiermodi in die Zwischenablage")) btn_copy.clicked.connect(lambda _, pid=p.id: self._copy_prompt(pid)) self.tree.setItemWidget(parent, 6, btn_copy) @@ -353,9 +370,9 @@ def safe_date(s: Optional[str]) -> str: btn_ver_copy = QtWidgets.QToolButton() btn_ver_copy.setIcon(QtGui.QIcon(CLIPBOARD_ICON)) btn_ver_copy.setAutoRaise(True) - btn_ver_copy.setToolTip(f"Version kopieren: {v_title_str}") - btn_ver_copy.setAccessibleName(f"Version kopieren: {v_title_str}") - btn_ver_copy.setAccessibleDescription("Kopiert diese Version in die Zwischenablage") + btn_ver_copy.setToolTip(tr("Version kopieren: {title}", title=v_title_str)) + btn_ver_copy.setAccessibleName(tr("Version kopieren: {title}", title=v_title_str)) + btn_ver_copy.setAccessibleDescription(tr("Kopiert diese Version in die Zwischenablage")) btn_ver_copy.clicked.connect( lambda _, pid=p.id, vid=v.id: self._copy_version(pid, vid) ) @@ -378,7 +395,7 @@ def open_context_menu(self, pos: QtCore.QPoint): if not item: # Leerer Bereich - menu.addAction("Neuen Prompt hinzufügen", self.create_prompt) + menu.addAction(tr("Neuen Prompt hinzufügen"), self.create_prompt) menu.exec(self.tree.viewport().mapToGlobal(pos)) return @@ -398,36 +415,48 @@ def open_context_menu(self, pos: QtCore.QPoint): return # Copy Aktionen + act_copy_full = act_new_ver = None + act_exp_txt = act_exp_pdf = act_bundle_txt = act_bundle_pdf = None if kind == "prompt": - act_copy = menu.addAction("Prompt kopieren") - act_copy_full = menu.addAction("Prompt inkl. Versionen kopieren") + act_copy = menu.addAction(tr("Prompt kopieren")) + act_copy_full = menu.addAction(tr("Prompt inkl. Versionen kopieren")) else: - act_copy = menu.addAction("Version kopieren") + act_copy = menu.addAction(tr("Version kopieren")) menu.addSeparator() # Neue Version / Prompt if kind == "prompt": - act_new_ver = menu.addAction("Neue Version anlegen") - act_new_p = menu.addAction("Neuer Prompt") + act_new_ver = menu.addAction(tr("Neue Version anlegen")) + act_new_p = menu.addAction(tr("Neuer Prompt")) menu.addSeparator() # Editieren - act_edit = menu.addAction("Bearbeiten") + act_edit = menu.addAction(tr("Bearbeiten")) menu.addSeparator() - # Export + # Board: Prompt/Version direkt auf ein Board heften + m_board = menu.addMenu(tr("Auf Board heften")) + boards = self.storage.load_boards() + board_actions = {} + for b in boards: + board_actions[m_board.addAction(b.title or tr("(ohne Titel)"))] = b.id + m_board.setEnabled(bool(boards)) + menu.addSeparator() + + # Export (Aktionen werden per Objekt verglichen, NICHT per Text: + # Textvergleiche brachen nach einem Sprachwechsel alle Exporte) if kind == "prompt": - menu.addAction("Prompt exportieren (TXT)") - menu.addAction("Prompt exportieren (PDF)") - menu.addAction("Prompt+Versionen exportieren (TXT)") - menu.addAction("Prompt+Versionen exportieren (PDF)") + act_exp_txt = menu.addAction(tr("Prompt exportieren (TXT)")) + act_exp_pdf = menu.addAction(tr("Prompt exportieren (PDF)")) + act_bundle_txt = menu.addAction(tr("Prompt+Versionen exportieren (TXT)")) + act_bundle_pdf = menu.addAction(tr("Prompt+Versionen exportieren (PDF)")) else: - menu.addAction("Version exportieren (TXT)") - menu.addAction("Version exportieren (PDF)") + act_exp_txt = menu.addAction(tr("Version exportieren (TXT)")) + act_exp_pdf = menu.addAction(tr("Version exportieren (PDF)")) menu.addSeparator() # Löschen - act_delete = menu.addAction("Löschen") + act_delete = menu.addAction(tr("Löschen")) chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) if not chosen: @@ -437,13 +466,13 @@ def open_context_menu(self, pos: QtCore.QPoint): if chosen == act_copy: txt = self.clip.build_copy_text(p, v) self.clip.copy_to_clipboard(self.tree, txt) - elif kind == "prompt" and chosen == act_copy_full: + elif act_copy_full is not None and chosen == act_copy_full: parts = [self.clip.build_copy_text(p)] versions = [vv for vv in (p.versions or []) if vv is not None] for vv in sorted(versions, key=lambda x: getattr(x, "version_number", 0) or 0): parts.append(self.clip.build_copy_text(p, vv)) self.clip.copy_to_clipboard(self.tree, "\n\n".join(parts)) - elif kind == "prompt" and chosen == act_new_ver: + elif act_new_ver is not None and chosen == act_new_ver: dlg = VersionDialog(self.storage, p, None, self) if dlg.exec() == QtWidgets.QDialog.Accepted: bus.promptsChanged.emit() @@ -456,46 +485,56 @@ def open_context_menu(self, pos: QtCore.QPoint): dlg = VersionDialog(self.storage, p, v, self) if dlg.exec() == QtWidgets.QDialog.Accepted: bus.promptsChanged.emit() - elif chosen.text().startswith("Prompt exportieren") and "TXT" in chosen.text(): + elif chosen in board_actions: + ok, _ = self.storage.add_item_to_board(board_actions[chosen], p.id, v.id if v else None) + if ok: + bus.boardsChanged.emit() + else: + QtWidgets.QMessageBox.information( + self, tr("Hinweis"), tr("Dieses Element ist auf dem Board bereits vorhanden.")) + elif kind == "prompt" and chosen == act_exp_txt: self._export_prompt_txt(p) - elif chosen.text().startswith("Prompt exportieren") and "PDF" in chosen.text(): + elif kind == "prompt" and chosen == act_exp_pdf: default_pdf = f"{sanitize_export_filename(p.title)}.pdf" path, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "PDF speichern", default_pdf, "PDF-Datei (*.pdf)") + self, tr("PDF speichern"), default_pdf, tr("PDF-Datei (*.pdf)")) if path: export_single_prompt(p, self.settings, path, self, protected_paths=self._protected_paths()) - elif chosen.text().startswith("Prompt+Versionen exportieren") and "TXT" in chosen.text(): + elif kind == "prompt" and chosen == act_bundle_txt: self._export_bundle_txt(p) - elif chosen.text().startswith("Prompt+Versionen exportieren") and "PDF" in chosen.text(): + elif kind == "prompt" and chosen == act_bundle_pdf: default_pdf = f"{sanitize_export_filename(p.title)}_all.pdf" path, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "PDF speichern", default_pdf, "PDF-Datei (*.pdf)") + self, tr("PDF speichern"), default_pdf, tr("PDF-Datei (*.pdf)")) if path: export_single_prompt_with_versions(p, self.settings, path, self, protected_paths=self._protected_paths()) - elif kind == "version" and chosen.text().startswith("Version exportieren") and "TXT" in chosen.text(): + elif kind == "version" and chosen == act_exp_txt: self._export_version_txt(v) - elif kind == "version" and chosen.text().startswith("Version exportieren") and "PDF" in chosen.text(): + elif kind == "version" and chosen == act_exp_pdf: default_pdf = f"{sanitize_export_filename(v.title, 'version')}.pdf" path, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "PDF speichern", default_pdf, "PDF-Datei (*.pdf)") + self, tr("PDF speichern"), default_pdf, tr("PDF-Datei (*.pdf)")) if path: export_single_version(v, path, parent=self, settings=self.settings, protected_paths=self._protected_paths()) elif chosen == act_delete: - if kind == "prompt": - if QtWidgets.QMessageBox.question( - self, "Löschen", f"Prompt „{p.title}“ wirklich löschen?" - ) == QtWidgets.QMessageBox.StandardButton.Yes: - self.storage.delete_prompt(p.id) - bus.promptsChanged.emit() - else: - if QtWidgets.QMessageBox.question( - self, "Löschen", - f"Version „v{v.version_number} – {v.title}“ wirklich löschen?" - ) == QtWidgets.QMessageBox.StandardButton.Yes: - self.storage.delete_version(p.id, v.id) - p.versions = [x for x in (p.versions or []) if x is not None and getattr(x, "id", None) != v.id] - p.updated_at = now_iso() - bus.promptsChanged.emit() + self._confirm_and_delete(p, v if kind == "version" else None) + + def _confirm_and_delete(self, p: Prompt, v: Optional[Version] = None): + if v is None: + if QtWidgets.QMessageBox.question( + self, tr("Löschen"), tr("Prompt „{title}“ wirklich löschen?", title=p.title) + ) == QtWidgets.QMessageBox.StandardButton.Yes: + self.storage.delete_prompt(p.id) + bus.promptsChanged.emit() + else: + if QtWidgets.QMessageBox.question( + self, tr("Löschen"), + tr("Version „v{num} – {title}“ wirklich löschen?", num=v.version_number, title=v.title) + ) == QtWidgets.QMessageBox.StandardButton.Yes: + self.storage.delete_version(p.id, v.id) + p.versions = [x for x in (p.versions or []) if x is not None and getattr(x, "id", None) != v.id] + p.updated_at = now_iso() + bus.promptsChanged.emit() # -- Copy‐Shortcuts für Tree‐Icons ------------------------------- @@ -540,23 +579,13 @@ def delete_current_item(self, item: Optional[QtWidgets.QTreeWidgetItem] = None): if not p: return if kind == "prompt": - if QtWidgets.QMessageBox.question( - self, "Löschen", f"Prompt „{p.title}“ wirklich löschen?" - ) == QtWidgets.QMessageBox.StandardButton.Yes: - self.storage.delete_prompt(p.id) - bus.promptsChanged.emit() + self._confirm_and_delete(p) else: vid = rest[0] if rest else None v = self.storage.get_version(pid, vid) if vid else None if not v: return - if QtWidgets.QMessageBox.question( - self, "Löschen", f"Version „v{v.version_number} – {v.title}“ wirklich löschen?" - ) == QtWidgets.QMessageBox.StandardButton.Yes: - self.storage.delete_version(p.id, v.id) - p.versions = [x for x in (p.versions or []) if x is not None and getattr(x, "id", None) != v.id] - p.updated_at = now_iso() - bus.promptsChanged.emit() + self._confirm_and_delete(p, v) def copy_current_item_to_clipboard(self, item: Optional[QtWidgets.QTreeWidgetItem] = None): target = item or self.tree.currentItem() @@ -657,39 +686,39 @@ def _protected_paths(self) -> List[Path]: def _export_prompt_txt(self, p: Prompt): default_name = f"{sanitize_export_filename(p.title)}.txt" path, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "TXT speichern", default_name, "Textdatei (*.txt)" + self, tr("TXT speichern"), default_name, tr("Textdatei (*.txt)") ) if not path: return try: text = self.clip.build_copy_text(p) atomic_write_text(path, text, protected_paths=self._protected_paths()) - QtWidgets.QMessageBox.information(self, "Export", "Prompt erfolgreich exportiert.") + QtWidgets.QMessageBox.information(self, tr("Export"), tr("Prompt erfolgreich exportiert.")) except Exception as e: - QtWidgets.QMessageBox.critical(self, "Fehler", f"TXT-Export fehlgeschlagen:\n{e}") + QtWidgets.QMessageBox.critical(self, tr("Fehler"), tr("TXT-Export fehlgeschlagen:\n{error}", error=e)) def _export_version_txt(self, v): default_name = f"{sanitize_export_filename(v.title, 'version')}.txt" path, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "TXT speichern", default_name, "Textdatei (*.txt)" + self, tr("TXT speichern"), default_name, tr("Textdatei (*.txt)") ) if not path: return p = self.storage.get_prompt(v.prompt_id) if hasattr(v, "prompt_id") else None if not p: - QtWidgets.QMessageBox.critical(self, "Fehler", "Zugehöriger Prompt nicht gefunden.") + QtWidgets.QMessageBox.critical(self, tr("Fehler"), tr("Zugehöriger Prompt nicht gefunden.")) return try: text = self.clip.build_copy_text(p, v) atomic_write_text(path, text, protected_paths=self._protected_paths()) - QtWidgets.QMessageBox.information(self, "Export", "Version erfolgreich exportiert.") + QtWidgets.QMessageBox.information(self, tr("Export"), tr("Version erfolgreich exportiert.")) except Exception as e: - QtWidgets.QMessageBox.critical(self, "Fehler", f"TXT-Export fehlgeschlagen:\n{e}") + QtWidgets.QMessageBox.critical(self, tr("Fehler"), tr("TXT-Export fehlgeschlagen:\n{error}", error=e)) def _export_bundle_txt(self, p: Prompt): default_name = f"{sanitize_export_filename(p.title)}_all.txt" path, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "TXT speichern", default_name, "Textdatei (*.txt)" + self, tr("TXT speichern"), default_name, tr("Textdatei (*.txt)") ) if not path: return @@ -700,9 +729,9 @@ def _export_bundle_txt(self, p: Prompt): parts.append(self.clip.build_copy_text(p, ver)) bundle_content = "\n\n".join(parts) atomic_write_text(path, bundle_content, protected_paths=self._protected_paths()) - QtWidgets.QMessageBox.information(self, "Export", "Bundle erfolgreich exportiert.") + QtWidgets.QMessageBox.information(self, tr("Export"), tr("Bundle erfolgreich exportiert.")) except Exception as e: - QtWidgets.QMessageBox.critical(self, "Fehler", f"Bundle-Export fehlgeschlagen:\n{e}") + QtWidgets.QMessageBox.critical(self, tr("Fehler"), tr("Bundle-Export fehlgeschlagen:\n{error}", error=e)) # --- Create Prompt helper --- diff --git a/src/event_bus.py b/src/event_bus.py index 9e01708..bf54c62 100644 --- a/src/event_bus.py +++ b/src/event_bus.py @@ -6,5 +6,6 @@ class EventBus(QObject): copyRequested = Signal(str, str, object) # kind, id, parent dragRequested = Signal(str, object) # kind, (prompt_id, version_id) dragItem = Signal(object) + languageChanged = Signal(str) # neuer Sprachcode (live-Umschaltung) bus = EventBus() diff --git a/src/i18n.py b/src/i18n.py new file mode 100644 index 0000000..1037f69 --- /dev/null +++ b/src/i18n.py @@ -0,0 +1,103 @@ +"""Zentrale Laufzeit-Uebersetzung fuer alle ProfiPrompt-Widgets. + +Bisher nutzte nur die Menueleiste den Translator; Tabellenkoepfe, Board-Leiste, +Kontextmenues, Dialoge und Meldungen blieben nach einem Sprachwechsel deutsch. +Dieses Modul stellt EINEN gemeinsamen Translator bereit: + + * ``tr(key, **kwargs)`` -- uebersetzt einen (deutschen) Quell-Key. + * ``set_language(lang)`` -- schaltet die Sprache um und meldet das ueber + ``bus.languageChanged``; Widgets bauen ihre Texte + dann in ``retranslate_ui()`` live neu auf. + +Laufzeit-Lookups registrieren KEINE fehlenden Keys in locales/translations.json +(``auto_register=False``), damit die gebuendelte Datei nie mit leeren Eintraegen +verunreinigt wird. +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Optional + + +def app_base_dir() -> Path: + """Basisverzeichnis fuer gebuendelte Daten (locales/translator). + + Frozen (PyInstaller): sys._MEIPASS; sonst der Repo-Root (Parent von src/). + """ + if getattr(sys, "frozen", False): + return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) + return Path(__file__).resolve().parent.parent + + +_BASE_DIR = app_base_dir() +if str(_BASE_DIR) not in sys.path: + sys.path.insert(0, str(_BASE_DIR)) + +try: + from translator import TranslationSystem +except Exception: # pragma: no cover - Uebersetzung ist optional + TranslationSystem = None + +_translator = None + + +def make_translator(lang: str, *, auto_register: bool = False): + """Erzeugt ein TranslationSystem mit robuster locales-Aufloesung (oder None).""" + if TranslationSystem is None: + return None + try: + return TranslationSystem(lang, app_dir=_BASE_DIR, auto_register=auto_register) + except Exception: + return None + + +def get_translator(): + """Gemeinsamer Translator (lazy, Default-Sprache 'de').""" + global _translator + if _translator is None: + _translator = make_translator("de") + return _translator + + +def init(lang: str): + """Setzt die Startsprache ohne Signal (vor dem Aufbau der Widgets).""" + tr_obj = get_translator() + if tr_obj is not None: + tr_obj.set_language(lang) + return tr_obj + + +def get_language() -> str: + tr_obj = get_translator() + return tr_obj.get_language() if tr_obj is not None else "de" + + +def set_language(lang: str) -> bool: + """Schaltet die Sprache um und benachrichtigt alle Widgets (live).""" + tr_obj = get_translator() + if tr_obj is None or not tr_obj.set_language(lang): + return False + from event_bus import bus + bus.languageChanged.emit(lang) + return True + + +def tr(key: str, **kwargs) -> str: + """Uebersetzt key in die aktive Sprache (Fallback: key, ggf. formatiert).""" + tr_obj = get_translator() + if tr_obj is not None: + return tr_obj.t(key, **kwargs) + if kwargs: + try: + return key.format(**kwargs) + except (KeyError, IndexError, ValueError): + return key + return key + + +def translate_with(translator: Optional[object], key: str, **kwargs) -> str: + """Hilfsfunktion fuer Komponenten mit optional injiziertem Translator.""" + if translator is not None: + return translator.t(key, **kwargs) + return tr(key, **kwargs) diff --git a/src/models.py b/src/models.py index f36c134..99c042b 100644 --- a/src/models.py +++ b/src/models.py @@ -6,6 +6,7 @@ from datetime import datetime import uuid from enum import Enum +import re class CopyMode(str, Enum): @@ -58,6 +59,7 @@ class BoardItem: prompt_id: str version_id: Optional[str] = None # None => Haupt-Prompt created_at: str = field(default_factory=now_iso) + color: Optional[str] = None # individuelle Kachelfarbe '#RRGGBB'; None => Standardfarbe @dataclass @@ -134,6 +136,20 @@ def prompt_from_dict(d: Dict[str, Any]) -> Prompt: ) +_HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") + + +def normalize_item_color(value: Any) -> Optional[str]: + """Gibt eine gueltige '#RRGGBB'-Farbe (Grossbuchstaben) oder None zurueck. + + Board-JSONs koennen von Hand oder fremden Tools stammen; nur strikt valide + Hex-Farben gelangen ins Kachel-Stylesheet (keine QSS-Injektion, kein Crash). + """ + if isinstance(value, str) and _HEX_COLOR_RE.match(value.strip()): + return value.strip().upper() + return None + + def boarditem_from_dict(d: Dict[str, Any]) -> BoardItem: """Reconstruct a BoardItem from a dict (feldweise, schema-drift-robust).""" # Bugsweep 19/28 BUG-BM01: .get()-Defaults für id, board_id, prompt_id verhindern KeyError bei schema-drift @@ -143,6 +159,7 @@ def boarditem_from_dict(d: Dict[str, Any]) -> BoardItem: prompt_id=d.get("prompt_id") or "", version_id=d.get("version_id"), created_at=d.get("created_at") or now_iso(), + color=normalize_item_color(d.get("color")), ) diff --git a/src/pdf_exporter.py b/src/pdf_exporter.py index 772a7db..2a65ac8 100644 --- a/src/pdf_exporter.py +++ b/src/pdf_exporter.py @@ -9,6 +9,7 @@ from PySide6.QtWidgets import QMessageBox from atomic_io import atomic_publish_file, is_protected_path +from i18n import tr def _format_tags(tags) -> str: """Formatiert Tags robust als kommagetrennte Liste (filtert None/Leereintraege).""" @@ -118,7 +119,7 @@ def _export_html_to_pdf(html: str, path: str, parent=None, protected_paths: Opti if is_protected_path(target_path, protected_paths): msg = f"Zielpfad '{target_path}' darf keine geschützte Bibliotheksdatei überschreiben." if parent: - QMessageBox.critical(parent, "Fehler", f"PDF-Export fehlgeschlagen:\n{msg}") + QMessageBox.critical(parent, tr("Fehler"), tr("PDF-Export fehlgeschlagen:\n{error}", error=msg)) return False target_path.parent.mkdir(parents=True, exist_ok=True) @@ -144,11 +145,11 @@ def _export_html_to_pdf(html: str, path: str, parent=None, protected_paths: Opti atomic_publish_file(tmp_path, target_path, protected_paths=protected_paths) if parent: - QMessageBox.information(parent, "Export", "PDF erfolgreich gespeichert.") + QMessageBox.information(parent, tr("Export"), tr("PDF erfolgreich gespeichert.")) return True except Exception as e: if parent: - QMessageBox.critical(parent, "Fehler", f"PDF-Export fehlgeschlagen:\n{e}") + QMessageBox.critical(parent, tr("Fehler"), tr("PDF-Export fehlgeschlagen:\n{error}", error=e)) return False finally: if tmp_path.exists(): diff --git a/src/profiprompt.py b/src/profiprompt.py index 07bcdea..b4178b8 100644 --- a/src/profiprompt.py +++ b/src/profiprompt.py @@ -26,39 +26,18 @@ export_single_version, ) -# --- Uebersetzung / i18n (Welle-1 U1: sichtbarer DE/EN-Sprachschalter) ------- -import os -from pathlib import Path - - -def _app_base_dir() -> Path: - """Basisverzeichnis fuer gebuendelte Daten (locales/translator). - - Frozen (PyInstaller): sys._MEIPASS; sonst der Repo-Root (Parent von src/). - """ - if getattr(sys, "frozen", False): - return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) - return Path(__file__).resolve().parent.parent - +# --- Uebersetzung / i18n ------------------------------------------------------ +# Ein gemeinsamer Translator (i18n.py) fuer ALLE Widgets; ein Sprachwechsel wird +# per bus.languageChanged live an Dashboard, Boards und Dialoge verteilt. +import i18n +from i18n import tr, app_base_dir as _app_base_dir, TranslationSystem # noqa: F401 _BASE_DIR = _app_base_dir() -if str(_BASE_DIR) not in sys.path: - sys.path.insert(0, str(_BASE_DIR)) - -try: - from translator import TranslationSystem -except Exception: # pragma: no cover - Uebersetzung ist optional - TranslationSystem = None def make_translator(lang: str): - """Erzeugt ein TranslationSystem mit robuster locales-Aufloesung (oder None).""" - if TranslationSystem is None: - return None - try: - return TranslationSystem(lang, app_dir=_BASE_DIR) - except Exception: - return None + """Erzeugt ein eigenstaendiges TranslationSystem (Kompat-API, oder None).""" + return i18n.make_translator(lang) from theme import apply_theme @@ -86,7 +65,8 @@ def __init__(self, storage: Storage, settings: SettingsManager): self.storage = storage self.settings = settings self.app = QApplication.instance() - self.translator = make_translator(self.settings.get_language()) + # Gemeinsamer App-Translator: Startsprache setzen, BEVOR Widgets entstehen + self.translator = i18n.init(self.settings.get_language()) self.setWindowTitle("Prompt Manager") self.setWindowIcon(load_app_icon()) @@ -97,10 +77,8 @@ def __init__(self, storage: Storage, settings: SettingsManager): self.setCentralWidget(self.dashboard) # Dock: Boards - self.boardDock = QDockWidget("Boards", self) + self.boardDock = QDockWidget(tr("Boards"), self) self.boardDock.setObjectName("BoardsDock") - self.boardDock.setAccessibleName("Boards-Bereich") - self.boardDock.setAccessibleDescription("Bereich für Kachel-Boards und Arbeitsflächen") self.boardDock.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea) self.boardManager = BoardManager(self.storage, self.settings) @@ -110,8 +88,7 @@ def __init__(self, storage: Storage, settings: SettingsManager): # Statusleiste (WCAG 2.1 AA / Screenreader-Unterstützung für Statusmeldungen & Action-Tips) status_bar = self.statusBar() status_bar.setObjectName("MainWindowStatusBar") - status_bar.setAccessibleName("Statusleiste") - status_bar.setAccessibleDescription("Zeigt Statusmeldungen und Tastenkürzelhinweise an") + self._retranslate_chrome() # Menü & Aktionen self._build_menu() @@ -124,9 +101,18 @@ def __init__(self, storage: Storage, settings: SettingsManager): bus.copyRequested.connect(self.handle_copy_request) bus.dragRequested.connect(self.handle_drag_request) - def _t(self, key: str) -> str: + def _t(self, key: str, **kwargs) -> str: """Uebersetzt key in die aktuelle Sprache (Fallback: key selbst).""" - return self.translator.t(key) if self.translator is not None else key + return tr(key, **kwargs) + + def _retranslate_chrome(self): + """Dock-Titel, Statusleiste & Accessible-Names des Hauptfensters.""" + self.boardDock.setWindowTitle(tr("Boards")) + self.boardDock.setAccessibleName(tr("Boards-Bereich")) + self.boardDock.setAccessibleDescription(tr("Bereich für Kachel-Boards und Arbeitsflächen")) + status_bar = self.statusBar() + status_bar.setAccessibleName(tr("Statusleiste")) + status_bar.setAccessibleDescription(tr("Zeigt Statusmeldungen und Tastenkürzelhinweise an")) def _build_menu(self): menubar = self.menuBar() @@ -174,7 +160,7 @@ def _build_menu(self): # Sprache / Language (Alt+S) m_lang = menubar.addMenu(_t("&Sprache / Language")) - cur = self.translator.get_language() if self.translator is not None else self.settings.get_language() + cur = i18n.get_language() if self.translator is not None else self.settings.get_language() lang_group = QActionGroup(self) lang_group.setExclusive(True) lang_names = { @@ -193,26 +179,30 @@ def _build_menu(self): m_lang.addAction(act) def change_language(self, lang: str): - """Setzt die Sprache, persistiert sie und stellt die Menueleiste live um.""" + """Setzt die Sprache, persistiert sie und uebersetzt ALLE Bereiche live. + + Frueher wurde nur die Menueleiste neu aufgebaut; Tabellenkoepfe, Filter, + Board-Leiste und Kacheln blieben bis zum Neustart deutsch. Jetzt verteilt + i18n.set_language() das Signal bus.languageChanged an alle Widgets. + """ self.settings.set_language(lang) - if self.translator is not None: - self.translator.set_language(lang) + i18n.set_language(lang) self.retranslate() messages = { - "de": "Sprache auf Deutsch umgestellt. Einige Texte werden erst nach einem Neustart übersetzt.", - "en": "Language switched to English. Some texts update after a restart.", - "es": "Idioma cambiado a español. Algunos textos se actualizarán tras reiniciar.", - "zh": "语言已切换为中文。部分文本将在重启后生效。", - "ja": "言語を日本語に切り替えました。一部のテキストは再起動後に反映されます。", - "ru": "Язык переключен на русский. Некоторые тексты обновятся после перезапуска.", + "de": "Sprache auf Deutsch umgestellt.", + "en": "Language switched to English.", + "es": "Idioma cambiado a español.", + "zh": "语言已切换为中文。", + "ja": "言語を日本語に切り替えました。", + "ru": "Язык переключен на русский.", } - msg = messages.get(lang, messages["en"]) - QMessageBox.information(self, "Sprache / Language", msg) + self.statusBar().showMessage(messages.get(lang, messages["en"]), 5000) def retranslate(self): - """Baut die Menueleiste in der aktuellen Sprache neu auf.""" + """Baut Menueleiste und Fenster-Chrome in der aktuellen Sprache neu auf.""" self.menuBar().clear() self._build_menu() + self._retranslate_chrome() def _action(self, text: str, slot, shortcut: Optional[str] = None, status_tip: Optional[str] = None): act = QAction(text, self) @@ -247,14 +237,15 @@ def open_shortcuts_dialog(self) -> ShortcutsDialog: def _show_help(self): QMessageBox.information( - self, "Anleitung", - "• Doppelklick auf Liste: Bearbeiten\n" - "• Drag & Drop auf Board rechts: Prompt anheften\n" - "• Rechtsklick: Kontextmenü für Export/Löschen" + self, tr("Anleitung"), + tr("• Doppelklick auf Liste: Bearbeiten\n" + "• Drag & Drop auf Board rechts: Prompt anheften\n" + "• Rechtsklick: Kontextmenü für Export/Löschen\n" + "• Rechtsklick auf Kachel: auf anderes Board verschieben/duplizieren, Kachelfarbe") ) def _show_about(self): - QMessageBox.information(self, "Über", "Prompt Manager v1.0.1\nModern Dark Edition") + QMessageBox.information(self, tr("Über"), "Prompt Manager v1.0.1\nModern Dark Edition") # --- Exports --- @report_storage_errors @@ -288,10 +279,10 @@ def export_all_txt(self): try: atomic_write_text(path, "\n\n".join(parts), protected_paths=self._protected_paths()) from PySide6.QtWidgets import QMessageBox - QMessageBox.information(self, "Export", "TXT erfolgreich gespeichert.") + QMessageBox.information(self, tr("Export"), tr("TXT erfolgreich gespeichert.")) except Exception as e: from PySide6.QtWidgets import QMessageBox - QMessageBox.critical(self, "Fehler", f"TXT-Export fehlgeschlagen:\n{e}") + QMessageBox.critical(self, tr("Fehler"), tr("TXT-Export fehlgeschlagen:\n{error}", error=e)) def _protected_paths(self): protected = [] @@ -315,7 +306,7 @@ def export_all_pdf(self): def export_library_json(self): path, _ = QFileDialog.getSaveFileName( self, - "Bibliothek exportieren", + tr("Bibliothek exportieren"), "profiprompt-library-v1.json", "JSON (*.json)", ) @@ -326,11 +317,11 @@ def export_library_json(self): count = payload["stats"]["prompt_count"] QMessageBox.information( self, - "Export", - f"JSON-Bibliothek erfolgreich gespeichert ({count} Prompts).", + tr("Export"), + tr("JSON-Bibliothek erfolgreich gespeichert ({count} Prompts).", count=count), ) except Exception as e: - QMessageBox.critical(self, "Fehler", f"JSON-Export fehlgeschlagen:\n{e}") + QMessageBox.critical(self, tr("Fehler"), tr("JSON-Export fehlgeschlagen:\n{error}", error=e)) def export_current_prompt_txt(self): p = self.dashboard.get_current_prompt() @@ -340,7 +331,7 @@ def export_current_prompt_txt(self): if not path: return text = ClipboardManager(self.settings).build_copy_text(p) - self._write_txt_export(path, text, "TXT erfolgreich gespeichert.") + self._write_txt_export(path, text, tr("TXT erfolgreich gespeichert.")) def export_current_prompt_pdf(self): p = self.dashboard.get_current_prompt() @@ -362,10 +353,10 @@ def export_current_version_txt(self): return p = self.storage.get_prompt(v.prompt_id) if not p: - QMessageBox.critical(self, "Fehler", "Zugehöriger Prompt nicht gefunden.") + QMessageBox.critical(self, tr("Fehler"), tr("Zugehöriger Prompt nicht gefunden.")) return text = ClipboardManager(self.settings).build_copy_text(p, v) - self._write_txt_export(path, text, "TXT erfolgreich gespeichert.") + self._write_txt_export(path, text, tr("TXT erfolgreich gespeichert.")) def export_current_version_pdf(self): v = self.dashboard.get_current_version() @@ -381,9 +372,9 @@ def export_current_version_pdf(self): def _write_txt_export(self, path: str, text: str, success_message: str): try: atomic_write_text(path, text, protected_paths=self._protected_paths()) - QMessageBox.information(self, "Export", success_message) + QMessageBox.information(self, tr("Export"), success_message) except Exception as e: - QMessageBox.critical(self, "Fehler", f"TXT-Export fehlgeschlagen:\n{e}") + QMessageBox.critical(self, tr("Fehler"), tr("TXT-Export fehlgeschlagen:\n{error}", error=e)) # --- Handlers --- def open_copy_settings(self): @@ -424,7 +415,7 @@ def handle_copy_request(self, kind, item_id, parent): text = clip_mgr.build_copy_text(target_p, target_v) clipboard.setText(text) if parent: - parent.setToolTip("Kopiert! 📋") + parent.setToolTip(tr("Kopiert! 📋")) from PySide6.QtCore import QTimer QTimer.singleShot(1500, lambda: parent.setToolTip("")) diff --git a/src/prompt_dialog.py b/src/prompt_dialog.py index f8ce300..e692f53 100644 --- a/src/prompt_dialog.py +++ b/src/prompt_dialog.py @@ -5,49 +5,50 @@ from storage import Storage from storage_actions import report_storage_errors from dataclasses import replace +from i18n import tr class PromptDialog(QtWidgets.QDialog): def __init__(self, storage: Storage, prompt: Optional[Prompt] = None, parent=None): super().__init__(parent) - self.setWindowTitle("Prompt bearbeiten" if prompt else "Prompt erstellen") + self.setWindowTitle(tr("Prompt bearbeiten") if prompt else tr("Prompt erstellen")) self.setModal(True) self.storage = storage self.prompt = prompt self.title_edit = QtWidgets.QLineEdit() - self.title_edit.setAccessibleName("Titel") - self.title_edit.setAccessibleDescription("Titel des Prompts (Pflichtfeld)") - self.title_edit.setPlaceholderText("Titel des Prompts eingeben …") + self.title_edit.setAccessibleName(tr("Titel")) + self.title_edit.setAccessibleDescription(tr("Titel des Prompts (Pflichtfeld)")) + self.title_edit.setPlaceholderText(tr("Titel des Prompts eingeben …")) self.purpose_edit = QtWidgets.QLineEdit() - self.purpose_edit.setAccessibleName("Zweck") - self.purpose_edit.setAccessibleDescription("Zweck und Verwendungsziel des Prompts") - self.purpose_edit.setPlaceholderText("Verwendungszweck beschreiben …") + self.purpose_edit.setAccessibleName(tr("Zweck")) + self.purpose_edit.setAccessibleDescription(tr("Zweck und Verwendungsziel des Prompts")) + self.purpose_edit.setPlaceholderText(tr("Verwendungszweck beschreiben …")) self.tags_edit = QtWidgets.QLineEdit() - self.tags_edit.setAccessibleName("Tags") - self.tags_edit.setAccessibleDescription("Kommagetrennte Liste von Schlagwörtern") - self.tags_edit.setPlaceholderText("z. B. Coding, Refactoring, Python") + self.tags_edit.setAccessibleName(tr("Tags")) + self.tags_edit.setAccessibleDescription(tr("Kommagetrennte Liste von Schlagwörtern")) + self.tags_edit.setPlaceholderText(tr("z. B. Coding, Refactoring, Python")) self.text_edit = QtWidgets.QPlainTextEdit() - self.text_edit.setAccessibleName("Prompt-Text") - self.text_edit.setAccessibleDescription("Vollständiger Prompt-Text (Pflichtfeld)") - self.text_edit.setPlaceholderText("Hier den Prompt-Text formulieren …") + self.text_edit.setAccessibleName(tr("Prompt-Text")) + self.text_edit.setAccessibleDescription(tr("Vollständiger Prompt-Text (Pflichtfeld)")) + self.text_edit.setPlaceholderText(tr("Hier den Prompt-Text formulieren …")) self.result_edit = QtWidgets.QPlainTextEdit() - self.result_edit.setAccessibleName("Ergebnis") - self.result_edit.setAccessibleDescription("Zuletzt erzieltes Testergebnis oder Modellausgabe") - self.result_edit.setPlaceholderText("Optionale Modellausgabe oder Notizen …") + self.result_edit.setAccessibleName(tr("Ergebnis")) + self.result_edit.setAccessibleDescription(tr("Zuletzt erzieltes Testergebnis oder Modellausgabe")) + self.result_edit.setPlaceholderText(tr("Optionale Modellausgabe oder Notizen …")) - lbl_title = QtWidgets.QLabel("Titel*") + lbl_title = QtWidgets.QLabel(tr("Titel*")) lbl_title.setBuddy(self.title_edit) - lbl_purpose = QtWidgets.QLabel("Zweck") + lbl_purpose = QtWidgets.QLabel(tr("Zweck")) lbl_purpose.setBuddy(self.purpose_edit) - lbl_tags = QtWidgets.QLabel("Tags (Komma)") + lbl_tags = QtWidgets.QLabel(tr("Tags (Komma)")) lbl_tags.setBuddy(self.tags_edit) - lbl_text = QtWidgets.QLabel("Prompt-Text*") + lbl_text = QtWidgets.QLabel(tr("Prompt-Text*")) lbl_text.setBuddy(self.text_edit) - lbl_result = QtWidgets.QLabel("Ergebnis") + lbl_result = QtWidgets.QLabel(tr("Ergebnis")) lbl_result.setBuddy(self.result_edit) form = QtWidgets.QFormLayout() @@ -60,24 +61,24 @@ def __init__(self, storage: Storage, prompt: Optional[Prompt] = None, parent=Non # Versionenliste (readonly) self.versions_list = QtWidgets.QListWidget() self.versions_list.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) - self.versions_list.setAccessibleName("Versionen-Liste") - self.versions_list.setAccessibleDescription("Übersicht aller Versionen dieses Prompts") - group_versions = QtWidgets.QGroupBox("Versionen") - group_versions.setAccessibleName("Versionen-Gruppe") + self.versions_list.setAccessibleName(tr("Versionen-Liste")) + self.versions_list.setAccessibleDescription(tr("Übersicht aller Versionen dieses Prompts")) + group_versions = QtWidgets.QGroupBox(tr("Versionen")) + group_versions.setAccessibleName(tr("Versionen-Gruppe")) vlay = QtWidgets.QVBoxLayout(group_versions) vlay.addWidget(self.versions_list) # Buttons - btn_save = QtWidgets.QPushButton("Speichern") + btn_save = QtWidgets.QPushButton(tr("Speichern")) btn_save.setDefault(True) - btn_save.setAccessibleName("Speichern") - btn_save.setAccessibleDescription("Speichert die Änderungen und schließt den Dialog") - btn_save.setToolTip("Prompt speichern (Enter)") + btn_save.setAccessibleName(tr("Speichern")) + btn_save.setAccessibleDescription(tr("Speichert die Änderungen und schließt den Dialog")) + btn_save.setToolTip(tr("Prompt speichern (Enter)")) - btn_cancel = QtWidgets.QPushButton("Abbrechen") - btn_cancel.setAccessibleName("Abbrechen") - btn_cancel.setAccessibleDescription("Verwirft Änderungen und schließt den Dialog") - btn_cancel.setToolTip("Abbrechen (Esc)") + btn_cancel = QtWidgets.QPushButton(tr("Abbrechen")) + btn_cancel.setAccessibleName(tr("Abbrechen")) + btn_cancel.setAccessibleDescription(tr("Verwirft Änderungen und schließt den Dialog")) + btn_cancel.setToolTip(tr("Abbrechen (Esc)")) btns = QtWidgets.QHBoxLayout() btns.addStretch(1) @@ -118,7 +119,7 @@ def on_save(self): title = self.title_edit.text().strip() text = self.text_edit.toPlainText().strip() if not title or not text: - QtWidgets.QMessageBox.warning(self, "Fehler", "Titel und Prompt-Text sind Pflichtfelder.") + QtWidgets.QMessageBox.warning(self, tr("Fehler"), tr("Titel und Prompt-Text sind Pflichtfelder.")) return tags = [t.strip() for t in self.tags_edit.text().split(",") if t.strip()] purpose = self.purpose_edit.text().strip() @@ -151,7 +152,7 @@ def __init__(self, storage: Storage, prompt: Prompt, version: Optional[Version] self.version = version # None => Neuerstellung, sonst Bearbeitung is_edit = self.version is not None - self.setWindowTitle("Version bearbeiten" if is_edit else "Neue Version anlegen") + self.setWindowTitle(tr("Version bearbeiten") if is_edit else tr("Neue Version anlegen")) self.setModal(True) # Kontext/Status @@ -159,12 +160,12 @@ def __init__(self, storage: Storage, prompt: Prompt, version: Optional[Version] v_title = (self.version.title or "") if is_edit and self.version else "" v_num = getattr(self.version, "version_number", None) or "?" if is_edit and self.version else "" context_lbl = QtWidgets.QLabel( - f"Prompt: {p_title}" - + (f"\nBearbeite: v{v_num} — {v_title}" if is_edit else "") + tr("Prompt: {title}", title=p_title) + + ("\n" + tr("Bearbeite: v{num} — {title}", num=v_num, title=v_title) if is_edit else "") ) context_lbl.setStyleSheet("color:#666;") - context_lbl.setAccessibleName("Versions-Kontext") - context_lbl.setAccessibleDescription(f"Zugeordneter Prompt: {p_title}") + context_lbl.setAccessibleName(tr("Versions-Kontext")) + context_lbl.setAccessibleDescription(tr("Zugeordneter Prompt: {title}", title=p_title)) # Felder raw_tags = (self.version.tags if is_edit and self.version else (getattr(self.prompt, "tags", []) or [])) @@ -172,36 +173,36 @@ def __init__(self, storage: Storage, prompt: Prompt, version: Optional[Version] str(t).strip() for t in (raw_tags or []) if t is not None and str(t).strip() ] self.title_edit = QtWidgets.QLineEdit((self.version.title or "") if is_edit and self.version else "") - self.title_edit.setAccessibleName("Versionstitel") - self.title_edit.setAccessibleDescription("Titel dieser Prompt-Version (Pflichtfeld)") - self.title_edit.setPlaceholderText("Titel der Version …") + self.title_edit.setAccessibleName(tr("Versionstitel")) + self.title_edit.setAccessibleDescription(tr("Titel dieser Prompt-Version (Pflichtfeld)")) + self.title_edit.setPlaceholderText(tr("Titel der Version …")) self.tags_edit = QtWidgets.QLineEdit(", ".join(tag_items)) - self.tags_edit.setAccessibleName("Tags") - self.tags_edit.setAccessibleDescription("Kommagetrennte Liste von Schlagwörtern") - self.tags_edit.setPlaceholderText("z. B. v2, überarbeitet, kurz") + self.tags_edit.setAccessibleName(tr("Tags")) + self.tags_edit.setAccessibleDescription(tr("Kommagetrennte Liste von Schlagwörtern")) + self.tags_edit.setPlaceholderText(tr("z. B. v2, überarbeitet, kurz")) self.text_edit = QtWidgets.QPlainTextEdit( (self.version.text or "") if is_edit and self.version else (getattr(self.prompt, "text", "") or "") ) - self.text_edit.setAccessibleName("Prompt-Text") - self.text_edit.setAccessibleDescription("Vollständiger Prompt-Text dieser Version (Pflichtfeld)") - self.text_edit.setPlaceholderText("Prompt-Text für diese Version formulieren …") + self.text_edit.setAccessibleName(tr("Prompt-Text")) + self.text_edit.setAccessibleDescription(tr("Vollständiger Prompt-Text dieser Version (Pflichtfeld)")) + self.text_edit.setPlaceholderText(tr("Prompt-Text für diese Version formulieren …")) self.result_edit = QtWidgets.QPlainTextEdit( (getattr(self.version, "result", "") or "") if is_edit and self.version else "" ) - self.result_edit.setAccessibleName("Ergebnis") - self.result_edit.setAccessibleDescription("Testergebnis oder Notizen dieser Version") - self.result_edit.setPlaceholderText("Optionale Modellausgabe für diese Version …") + self.result_edit.setAccessibleName(tr("Ergebnis")) + self.result_edit.setAccessibleDescription(tr("Testergebnis oder Notizen dieser Version")) + self.result_edit.setPlaceholderText(tr("Optionale Modellausgabe für diese Version …")) - lbl_title = QtWidgets.QLabel("Titel*") + lbl_title = QtWidgets.QLabel(tr("Titel*")) lbl_title.setBuddy(self.title_edit) - lbl_tags = QtWidgets.QLabel("Tags (Komma)") + lbl_tags = QtWidgets.QLabel(tr("Tags (Komma)")) lbl_tags.setBuddy(self.tags_edit) - lbl_text = QtWidgets.QLabel("Prompt-Text*") + lbl_text = QtWidgets.QLabel(tr("Prompt-Text*")) lbl_text.setBuddy(self.text_edit) - lbl_result = QtWidgets.QLabel("Ergebnis") + lbl_result = QtWidgets.QLabel(tr("Ergebnis")) lbl_result.setBuddy(self.result_edit) form = QtWidgets.QFormLayout() @@ -211,28 +212,28 @@ def __init__(self, storage: Storage, prompt: Prompt, version: Optional[Version] form.addRow(lbl_result, self.result_edit) # Buttons - btn_cancel = QtWidgets.QPushButton("Abbrechen") - btn_cancel.setAccessibleName("Abbrechen") - btn_cancel.setAccessibleDescription("Verwirft Eingaben und schließt den Dialog") - btn_cancel.setToolTip("Abbrechen (Esc)") + btn_cancel = QtWidgets.QPushButton(tr("Abbrechen")) + btn_cancel.setAccessibleName(tr("Abbrechen")) + btn_cancel.setAccessibleDescription(tr("Verwirft Eingaben und schließt den Dialog")) + btn_cancel.setToolTip(tr("Abbrechen (Esc)")) if is_edit: - btn_save_update = QtWidgets.QPushButton("Speichern") + btn_save_update = QtWidgets.QPushButton(tr("Speichern")) btn_save_update.setDefault(True) - btn_save_update.setAccessibleName("Speichern") - btn_save_update.setAccessibleDescription("Speichert die Änderungen an dieser Version") - btn_save_update.setToolTip("Version aktualisieren (Enter)") - - btn_save_new = QtWidgets.QPushButton("Als neue Version speichern") - btn_save_new.setAccessibleName("Als neue Version speichern") - btn_save_new.setAccessibleDescription("Erstellt eine neue Version mit fortlaufender Nummer") - btn_save_new.setToolTip("Als neue Version anlegen") + btn_save_update.setAccessibleName(tr("Speichern")) + btn_save_update.setAccessibleDescription(tr("Speichert die Änderungen an dieser Version")) + btn_save_update.setToolTip(tr("Version aktualisieren (Enter)")) + + btn_save_new = QtWidgets.QPushButton(tr("Als neue Version speichern")) + btn_save_new.setAccessibleName(tr("Als neue Version speichern")) + btn_save_new.setAccessibleDescription(tr("Erstellt eine neue Version mit fortlaufender Nummer")) + btn_save_new.setToolTip(tr("Als neue Version anlegen")) else: - btn_save_create = QtWidgets.QPushButton("Version erstellen") + btn_save_create = QtWidgets.QPushButton(tr("Version erstellen")) btn_save_create.setDefault(True) - btn_save_create.setAccessibleName("Version erstellen") - btn_save_create.setAccessibleDescription("Erstellt eine neue Version dieses Prompts") - btn_save_create.setToolTip("Version anlegen (Enter)") + btn_save_create.setAccessibleName(tr("Version erstellen")) + btn_save_create.setAccessibleDescription(tr("Erstellt eine neue Version dieses Prompts")) + btn_save_create.setToolTip(tr("Version anlegen (Enter)")) btns = QtWidgets.QHBoxLayout() btns.addStretch(1) @@ -259,7 +260,7 @@ def _validate(self) -> Optional[tuple[str, list[str], str, str]]: title = self.title_edit.text().strip() text = self.text_edit.toPlainText().strip() if not title or not text: - QtWidgets.QMessageBox.warning(self, "Fehler", "Titel und Prompt-Text sind Pflichtfelder.") + QtWidgets.QMessageBox.warning(self, tr("Fehler"), tr("Titel und Prompt-Text sind Pflichtfelder.")) return None tags = [t.strip() for t in self.tags_edit.text().split(",") if t.strip()] result = self.result_edit.toPlainText().strip() @@ -277,7 +278,7 @@ def _on_save_update(self): pid = self.prompt.id if self.prompt else v.prompt_id if not self.storage.upsert_version(pid, v): - QtWidgets.QMessageBox.warning(self, "Speichern fehlgeschlagen", "Der zugehörige Prompt wurde nicht gefunden.") + QtWidgets.QMessageBox.warning(self, tr("Speichern fehlgeschlagen"), tr("Der zugehörige Prompt wurde nicht gefunden.")) return self.version = v @@ -313,7 +314,7 @@ def _on_save_create(self): updated_at=now_iso(), ) if not self.storage.add_version(self.prompt.id, new_v): - QtWidgets.QMessageBox.warning(self, "Speichern fehlgeschlagen", "Der zugehörige Prompt wurde nicht gefunden.") + QtWidgets.QMessageBox.warning(self, tr("Speichern fehlgeschlagen"), tr("Der zugehörige Prompt wurde nicht gefunden.")) return if self.prompt: self.prompt.versions.append(new_v) diff --git a/src/shortcuts_dialog.py b/src/shortcuts_dialog.py index fecda51..8447fa1 100644 --- a/src/shortcuts_dialog.py +++ b/src/shortcuts_dialog.py @@ -8,6 +8,7 @@ from typing import List, Tuple, Optional from PySide6.QtCore import Qt +from i18n import translate_with from PySide6.QtWidgets import ( QDialog, QHBoxLayout, @@ -49,6 +50,8 @@ class ShortcutsDialog(QDialog): ("Leertaste", "Kachel aktivieren / auswählen", "Board"), ("Ctrl+C", "Kachel-Text in Zwischenablage kopieren", "Board"), ("Entf / Backspace", "Kachel vom Board entfernen", "Board"), + ("Kontextmenü / Shift+F10", "Kachel auf anderes Board verschieben / duplizieren", "Board"), + ("Kontextmenü / Shift+F10", "Kachelfarbe ändern", "Board"), ("Kontextmenü / Shift+F10", "Kontextmenü öffnen", "Global"), ] @@ -64,9 +67,8 @@ def __init__(self, parent: Optional[QWidget] = None, translator=None) -> None: self.retranslate_ui() def _t(self, key: str) -> str: - if self.translator is not None: - return self.translator.t(key) - return key + # Ohne injizierten Translator: gemeinsamer App-Translator (aktive Sprache) + return translate_with(self.translator, key) def _setup_ui(self) -> None: layout = QVBoxLayout(self) @@ -137,7 +139,8 @@ def retranslate_ui(self) -> None: self._table.setRowCount(len(self.SHORTCUTS)) for row_idx, (keys, action, scope) in enumerate(self.SHORTCUTS): - item_keys = QTableWidgetItem(keys) + # Tastennamen wie "Entf", "Leertaste" sind sprachabhaengig + item_keys = QTableWidgetItem(self._t(keys)) item_keys.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) font = item_keys.font() font.setBold(True) diff --git a/src/storage.py b/src/storage.py index e43c352..c51ac14 100644 --- a/src/storage.py +++ b/src/storage.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import List, Optional, Tuple from atomic_io import atomic_write_json -from models import Prompt, Version, Board, BoardItem, prompt_from_dict, prompt_to_dict, board_from_dict, board_to_dict, gen_id, now_iso +from models import Prompt, Version, Board, BoardItem, prompt_from_dict, prompt_to_dict, board_from_dict, board_to_dict, gen_id, now_iso, normalize_item_color class StorageReadError(OSError): """A library could not be read completely; a mutation must not replace it.""" @@ -229,7 +229,8 @@ def delete_board(self, board_id: str): self._write_boards(boards) @_locked - def add_item_to_board(self, board_id: str, prompt_id: str, version_id: Optional[str] = None, validate_prompt: bool = False) -> Tuple[bool, Optional[str]]: + def add_item_to_board(self, board_id: str, prompt_id: str, version_id: Optional[str] = None, validate_prompt: bool = False, + color: Optional[str] = None) -> Tuple[bool, Optional[str]]: if not prompt_id or not str(prompt_id).strip(): return False, None if validate_prompt: @@ -248,7 +249,8 @@ def add_item_to_board(self, board_id: str, prompt_id: str, version_id: Optional[ for it in b.items: if it and getattr(it, "prompt_id", None) == prompt_id and getattr(it, "version_id", None) == version_id: return False, None - item = BoardItem(id=gen_id(), board_id=board_id, prompt_id=prompt_id, version_id=version_id) + item = BoardItem(id=gen_id(), board_id=board_id, prompt_id=prompt_id, version_id=version_id, + color=normalize_item_color(color)) b.items.append(item) self._write_boards(boards) return True, item.id @@ -265,3 +267,84 @@ def remove_item_from_board(self, board_id: str, prompt_id: str, version_id: Opti self._write_boards(boards) return True return False + + # --- Kachel zwischen Boards senden / duplizieren, Kachelfarbe --- + @staticmethod + def _find_item(board: Board, prompt_id: str, version_id: Optional[str]) -> int: + return next((i for i, it in enumerate(getattr(board, "items", None) or []) + if it and it.prompt_id == prompt_id and it.version_id == version_id), -1) + + @_locked + def transfer_item(self, source_board_id: str, target_board_id: str, prompt_id: str, + version_id: Optional[str] = None, *, move: bool = False) -> Tuple[bool, str]: + """Kopiert (move=False) oder verschiebt (move=True) eine Kachel auf ein anderes Board. + + Die individuelle Kachelfarbe wird mitgenommen. Beide Boards werden in EINEM + atomaren Schreibvorgang aktualisiert, damit ein Verschieben nie zu einer + verlorenen oder doppelten Kachel fuehrt. + + Rueckgabe: (Erfolg, Grund) mit Grund in + 'ok' | 'same_board' | 'source_missing' | 'target_missing' | 'duplicate'. + """ + if source_board_id == target_board_id: + return False, "same_board" + boards = self.load_boards(strict=True) + src = next((b for b in boards if b and b.id == source_board_id), None) + dst = next((b for b in boards if b and b.id == target_board_id), None) + if src is None: + return False, "source_missing" + if dst is None: + return False, "target_missing" + idx = self._find_item(src, prompt_id, version_id) + if idx < 0: + return False, "source_missing" + if dst.items is None: + dst.items = [] + if self._find_item(dst, prompt_id, version_id) >= 0: + return False, "duplicate" + original = src.items[idx] + dst.items.append(BoardItem(id=gen_id(), board_id=dst.id, prompt_id=prompt_id, + version_id=version_id, color=original.color)) + if move: + src.items.pop(idx) + self._write_boards(boards) + return True, "ok" + + def copy_item_to_board(self, source_board_id: str, target_board_id: str, prompt_id: str, + version_id: Optional[str] = None) -> Tuple[bool, str]: + return self.transfer_item(source_board_id, target_board_id, prompt_id, version_id, move=False) + + def move_item_to_board(self, source_board_id: str, target_board_id: str, prompt_id: str, + version_id: Optional[str] = None) -> Tuple[bool, str]: + return self.transfer_item(source_board_id, target_board_id, prompt_id, version_id, move=True) + + @_locked + def set_item_color(self, board_id: str, prompt_id: str, version_id: Optional[str] = None, + color: Optional[str] = None) -> bool: + """Setzt (oder entfernt mit color=None) die individuelle Farbe einer Kachel.""" + new_color = normalize_item_color(color) + if color is not None and new_color is None: + return False + boards = self.load_boards(strict=True) + for board in boards: + if board and board.id == board_id: + idx = self._find_item(board, prompt_id, version_id) + if idx < 0: + return False + board.items[idx].color = new_color + self._write_boards(boards) + return True + return False + + @_locked + def rename_board(self, board_id: str, title: str) -> bool: + title = (title or "").strip() + if not title: + return False + boards = self.load_boards(strict=True) + for board in boards: + if board and board.id == board_id: + board.title = title + self._write_boards(boards) + return True + return False diff --git a/src/storage_actions.py b/src/storage_actions.py index 53c450c..724c0d9 100644 --- a/src/storage_actions.py +++ b/src/storage_actions.py @@ -1,6 +1,7 @@ """Keep Qt actions open and report unsuccessful library operations.""" from functools import wraps from PySide6 import QtWidgets +from i18n import tr def report_storage_errors(action): @@ -10,10 +11,10 @@ def run(self, *args, **kwargs): return action(self, *args, **kwargs) except OSError as error: QtWidgets.QMessageBox.critical( - self, "Bibliothek nicht verfügbar", - "Die Bibliothek konnte nicht gelesen oder gespeichert werden.\n" - "Prüfen Sie die Datei und ihre Zugriffsrechte und versuchen Sie es erneut.\n\n" - f"{error}", + self, tr("Bibliothek nicht verfügbar"), + tr("Die Bibliothek konnte nicht gelesen oder gespeichert werden.\n" + "Prüfen Sie die Datei und ihre Zugriffsrechte und versuchen Sie es erneut.") + + f"\n\n{error}", ) return None return run diff --git a/tests/conftest.py b/tests/conftest.py index e4ba83f..b091f8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,3 +32,12 @@ def qapp(): """Eine einzige QApplication-Instanz fuer alle GUI-nahen Tests.""" app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) return app + + +@pytest.fixture(autouse=True) +def _reset_ui_language(): + """Der App-Translator (i18n) ist global: nach jedem Test auf 'de' zuruecksetzen, + damit ein Sprachwechsel-Test nachfolgende Tests (deutsche Labels) nicht beeinflusst.""" + yield + import i18n + i18n.init("de") diff --git a/tests/test_i18n_live_board_transfer_tile_colors_20261006.py b/tests/test_i18n_live_board_transfer_tile_colors_20261006.py new file mode 100644 index 0000000..8bb2be8 --- /dev/null +++ b/tests/test_i18n_live_board_transfer_tile_colors_20261006.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +"""Regressionstests 2026-10-06: Sprachwechsel live, Kachel-Transfer zwischen Boards, Kachelfarben. + +Gepruefte Punkte: + I18N-01 (HOCH): Sprachwechsel auf Englisch wurde nur fuer die Menueleiste uebernommen. + Tabellenkoepfe (Prompt-Baum, Shortcut-Tabelle), Filterleiste, Board-Leiste, + Kacheln, Kontextmenues und Dialoge blieben deutsch. + I18N-02 (HOCH): Dashboard-Kontextmenue verglich Aktionen per deutschem Text + (chosen.text().startswith("Prompt exportieren")) -> nach Uebersetzung + waeren alle Exporte stumm wirkungslos gewesen. + I18N-03 (MITTEL): Laufzeit-Lookups durften fehlende Keys mit leeren Uebersetzungen in die + gebuendelte translations.json schreiben (Paritaets-Bruch). + I18N-04: Jeder im Code verwendete tr()-Key existiert in allen 6 Sprachen mit gleichen + Platzhaltern. + BOARD-01 (Regression): Kacheln liessen sich nicht mehr auf ein anderes Board senden + (verschieben) oder dorthin duplizieren. + TILE-01 (Feature): individuelle Kachelfarbe pro Board-Kachel (persistiert, validiert). +""" +from __future__ import annotations + +import ast +import json +import re +from pathlib import Path + +import pytest +from PySide6 import QtCore, QtWidgets + +import i18n +from models import Board, BoardItem, Prompt, Version, boarditem_from_dict, board_to_dict +from storage import Storage +from settings_manager import SettingsManager +from board_manager import BoardManager +from dashboard import DashboardWidget +from shortcuts_dialog import ShortcutsDialog +from profiprompt import MainWindow +from translator import TranslationSystem + +ROOT = Path(__file__).resolve().parents[1] +LANGS = ("de", "en", "es", "zh", "ja", "ru") + + +# --------------------------------------------------------------------------- fixtures +@pytest.fixture +def settings(tmp_path): + sm = SettingsManager() + sm.qs = QtCore.QSettings(str(tmp_path / "settings.ini"), QtCore.QSettings.Format.IniFormat) + return sm + + +@pytest.fixture +def storage(tmp_path): + st = Storage(tmp_path / "data") + p1 = Prompt(id="p1", title="Alpha", purpose="Zweck A", text="Text A", tags=["x"], + versions=[Version(id="v1", prompt_id="p1", version_number=1, title="Kurz", text="VT")]) + p2 = Prompt(id="p2", title="Beta", purpose="Zweck B", text="Text B") + st.save_prompts([p1, p2]) + st.save_boards([ + Board(id="b1", title="Eins", items=[ + BoardItem(id="i1", board_id="b1", prompt_id="p1"), + BoardItem(id="i2", board_id="b1", prompt_id="p1", version_id="v1", color="#2F5D9E"), + ]), + Board(id="b2", title="Zwei", items=[]), + ]) + return st + + +def _tiles(bm: BoardManager): + return [bm.grid.itemAt(i).widget() for i in range(bm.grid.count()) if bm.grid.itemAt(i).widget()] + + +def _board(st: Storage, bid: str) -> Board: + return next(b for b in st.load_boards() if b.id == bid) + + +# --------------------------------------------------------------------------- I18N +def test_i18n_01_language_switch_translates_tables_boards_and_filters(qapp, storage, settings): + win = MainWindow(storage, settings) + try: + tree = win.dashboard.tree + de_headers = [tree.headerItem().text(c) for c in range(5)] + assert de_headers == ["Titel", "Zweck", "Tags", "Erstellt", "Aktualisiert"] + + win.change_language("en") + + en_headers = [tree.headerItem().text(c) for c in range(5)] + assert en_headers == ["Title", "Purpose", "Tags", "Created", "Updated"] + assert win.dashboard.lbl_search.text() == "Search:" + assert win.dashboard.btn_clear.text() == "Reset Filter" + assert win.dashboard.tag_combo.itemText(0) == "All tags" + assert tree.accessibleName() == "Prompt Overview" + + bm = win.boardManager + assert bm.lbl_board.text() == "Board:" + assert bm.btn_new_board.text() == "New" + assert bm.btn_del_board.text() == "Delete" + assert bm.btn_ren_board.text() == "Rename" + assert bm.btn_font.text() == "Font" + assert win.boardDock.windowTitle() == "Boards" + assert win.statusBar().accessibleName() == "Status bar" + + # Kacheln wurden mit englischen Accessible-Names neu aufgebaut + for tile in _tiles(bm): + assert tile.accessibleName().startswith("Prompt tile:") + + # Kopier-Buttons in den Baumzeilen (Unterzeilen = Versionen) + top = tree.topLevelItem(0) + btn = tree.itemWidget(top, 6) + assert btn.toolTip().startswith("Copy prompt:") + + # Menueleiste ebenfalls englisch + titles = [a.text() for a in win.menuBar().actions()] + assert "&File" in titles and "&Edit" in titles + + # Zurueck auf Deutsch + win.change_language("de") + assert [tree.headerItem().text(c) for c in range(5)] == de_headers + assert bm.btn_new_board.text() == "Neu" + finally: + win.close() + + +@pytest.mark.parametrize("lang", LANGS) +def test_i18n_01_dashboard_headers_in_every_language(qapp, storage, settings, lang): + i18n.init(lang) + dash = DashboardWidget(storage, settings) + try: + headers = [dash.tree.headerItem().text(c) for c in range(5)] + assert all(headers) + if lang != "de": + assert headers[0] != "Titel" + finally: + dash.close() + + +def test_i18n_01_shortcut_table_key_names_translated(qapp): + i18n.init("en") + dlg = ShortcutsDialog() + try: + keys = [dlg._table.item(r, 0).text() for r in range(dlg._table.rowCount())] + assert "Enter / Return" in keys + assert "Space" in keys + assert not any(k in keys for k in ("Leertaste", "Entf / Backspace")) + scopes = {dlg._table.item(r, 2).text() for r in range(dlg._table.rowCount())} + assert "Prompt List" in scopes + finally: + dlg.close() + + +def test_i18n_02_dashboard_context_menu_not_dispatched_by_text(): + src = (ROOT / "src" / "dashboard.py").read_text(encoding="utf-8") + assert "chosen.text()" not in src, "Kontextmenue-Aktionen duerfen nicht per (uebersetztem) Text verglichen werden" + + +def test_i18n_03_runtime_lookup_never_writes_translation_file(tmp_path): + (tmp_path / "locales").mkdir() + f = tmp_path / "locales" / "translations.json" + f.write_text("{}", encoding="utf-8") + tr_obj = TranslationSystem("en", app_dir=tmp_path, auto_register=False) + assert tr_obj.t("Völlig unbekannter Schlüssel") == "Völlig unbekannter Schlüssel" + assert f.read_text(encoding="utf-8") == "{}" + # Shared App-Translator ist ebenfalls ohne Auto-Registrierung + assert i18n.get_translator().auto_register is False + + +def _collect_tr_keys(): + keys = {} + for f in sorted((ROOT / "src").glob("*.py")): + tree = ast.parse(f.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fn = node.func + name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", None) + if name in ("tr", "_t") and node.args and isinstance(node.args[0], ast.Constant) \ + and isinstance(node.args[0].value, str): + keys.setdefault(node.args[0].value, f.name) + # nicht-literale Keys (Tabellen/Presets/Modi) + from dashboard import PromptTree + for k in PromptTree.HEADER_KEYS: + keys.setdefault(k, "dashboard.py") + for name, _ in BoardManager.TILE_COLOR_PRESETS: + keys.setdefault(name, "board_manager.py") + for k in ("Nur Titel", "Nur Prompt-Text", "Nur Ergebnis", "Alles"): + keys.setdefault(k, "copy_settings_dialog.py") + for combo, action, scope in ShortcutsDialog.SHORTCUTS: + keys.setdefault(action, "shortcuts_dialog.py") + keys.setdefault(scope, "shortcuts_dialog.py") + return keys + + +def test_i18n_04_every_ui_key_translated_in_all_languages(): + data = json.loads((ROOT / "locales" / "translations.json").read_text(encoding="utf-8")) + keys = _collect_tr_keys() + assert len(keys) > 150 + missing = sorted(k for k in keys if k not in data) + assert not missing, f"Fehlende Uebersetzungs-Keys: {missing}" + for key in keys: + placeholders = set(re.findall(r"\{(\w+)\}", key)) + for lang in LANGS: + value = data[key][lang] + assert value.strip(), f"{key!r} leer in {lang}" + assert set(re.findall(r"\{(\w+)\}", value)) == placeholders, (key, lang, value) + + +# --------------------------------------------------------------------------- Storage transfer +def test_board_01_copy_item_keeps_source_and_color(storage): + ok, reason = storage.copy_item_to_board("b1", "b2", "p1", "v1") + assert (ok, reason) == (True, "ok") + assert len(_board(storage, "b1").items) == 2 + dst = _board(storage, "b2").items + assert len(dst) == 1 and dst[0].version_id == "v1" and dst[0].board_id == "b2" + assert dst[0].color == "#2F5D9E" + # zweites Duplizieren wird abgewiesen + assert storage.copy_item_to_board("b1", "b2", "p1", "v1") == (False, "duplicate") + + +def test_board_01_move_item_removes_from_source(storage): + ok, reason = storage.move_item_to_board("b1", "b2", "p1", None) + assert ok and reason == "ok" + assert [(i.prompt_id, i.version_id) for i in _board(storage, "b1").items] == [("p1", "v1")] + assert [(i.prompt_id, i.version_id) for i in _board(storage, "b2").items] == [("p1", None)] + + +def test_board_01_transfer_rejects_invalid_targets(storage): + assert storage.move_item_to_board("b1", "b1", "p1") == (False, "same_board") + assert storage.move_item_to_board("b1", "nope", "p1") == (False, "target_missing") + assert storage.move_item_to_board("nope", "b2", "p1") == (False, "source_missing") + assert storage.move_item_to_board("b1", "b2", "p2") == (False, "source_missing") + # Nichts wurde veraendert + assert len(_board(storage, "b1").items) == 2 and not _board(storage, "b2").items + + +def test_board_01_move_duplicate_keeps_source(storage): + storage.add_item_to_board("b2", "p1", None) + assert storage.move_item_to_board("b1", "b2", "p1", None) == (False, "duplicate") + assert len(_board(storage, "b1").items) == 2 + + +def test_board_rename(storage): + assert storage.rename_board("b2", " Neu ") is True + assert _board(storage, "b2").title == "Neu" + assert storage.rename_board("b2", " ") is False + assert storage.rename_board("missing", "X") is False + + +# --------------------------------------------------------------------------- Tile colors +def test_tile_01_set_and_reset_item_color(storage): + assert storage.set_item_color("b1", "p1", None, "#b23a48") is True + item = next(i for i in _board(storage, "b1").items if i.version_id is None) + assert item.color == "#B23A48" + assert storage.set_item_color("b1", "p1", None, None) is True + item = next(i for i in _board(storage, "b1").items if i.version_id is None) + assert item.color is None + # Ungueltige Farbe wird abgewiesen und nichts geschrieben + assert storage.set_item_color("b1", "p1", None, "red; background: url(x)") is False + assert storage.set_item_color("b1", "p2", None, "#000000") is False + + +@pytest.mark.parametrize("raw,expected", [ + ("#abcdef", "#ABCDEF"), ("#ABCDEF", "#ABCDEF"), ("abcdef", None), ("#abc", None), + ("#12345G", None), (123, None), (None, None), ("#000000; color:red", None), +]) +def test_tile_01_color_schema_validation(raw, expected): + item = boarditem_from_dict({"id": "i", "board_id": "b", "prompt_id": "p", "color": raw}) + assert item.color == expected + + +def test_tile_01_color_roundtrips_and_old_json_without_color(storage): + d = board_to_dict(_board(storage, "b1")) + assert any(i.get("color") == "#2F5D9E" for i in d["items"]) + old = boarditem_from_dict({"id": "i", "board_id": "b", "prompt_id": "p"}) + assert old.color is None + + +# --------------------------------------------------------------------------- BoardManager UI +def _menu_titles(menu: QtWidgets.QMenu): + return [a.text() for a in menu.actions() if not a.isSeparator()] + + +def test_board_01_tile_context_menu_offers_move_and_duplicate(qapp, storage, settings): + bm = BoardManager(storage, settings) + try: + bm.board_combo.setCurrentIndex(bm.board_combo.findData("b1")) + tile = _tiles(bm)[0] + menu = bm.build_tile_context_menu(tile) + titles = _menu_titles(menu) + assert "Auf Board verschieben" in titles + assert "Auf Board duplizieren" in titles + assert "Kachelfarbe" in titles + sub = next(a.menu() for a in menu.actions() if a.text() == "Auf Board verschieben") + sub_titles = _menu_titles(sub) + assert "Zwei" in sub_titles and "Eins" not in sub_titles + assert "Neues Board …" in sub_titles + finally: + bm.close() + + +def test_board_01_transfer_tile_move_and_copy(qapp, storage, settings): + bm = BoardManager(storage, settings) + try: + bm.board_combo.setCurrentIndex(bm.board_combo.findData("b1")) + version_tile = next(t for t in _tiles(bm) if t.version is not None) + assert bm.transfer_tile(version_tile, "b2", move=False) is True + qapp.processEvents() + assert len(_board(storage, "b1").items) == 2 + assert len(_board(storage, "b2").items) == 1 + + main_tile = next(t for t in _tiles(bm) if t.version is None) + assert bm.transfer_tile(main_tile, "b2", move=True) is True + assert [(i.prompt_id, i.version_id) for i in _board(storage, "b1").items] == [("p1", "v1")] + assert len(_board(storage, "b2").items) == 2 + finally: + bm.close() + + +def test_board_01_transfer_tile_to_new_board(qapp, storage, settings, monkeypatch): + bm = BoardManager(storage, settings) + try: + bm.board_combo.setCurrentIndex(bm.board_combo.findData("b1")) + monkeypatch.setattr(QtWidgets.QInputDialog, "getText", + staticmethod(lambda *a, **k: ("Drei", True))) + tile = next(t for t in _tiles(bm) if t.version is None) + assert bm.transfer_tile_to_new_board(tile, move=False) is True + new_board = next(b for b in storage.load_boards() if b.title == "Drei") + assert [(i.prompt_id, i.version_id) for i in new_board.items] == [("p1", None)] + finally: + bm.close() + + +def test_tile_01_set_tile_color_rerenders_with_color(qapp, storage, settings): + bm = BoardManager(storage, settings) + try: + bm.board_combo.setCurrentIndex(bm.board_combo.findData("b1")) + tile = next(t for t in _tiles(bm) if t.version is None) + assert tile.color is None + assert bm.set_tile_color(tile, "#3E7D4F") is True + qapp.processEvents() + tile = next(t for t in _tiles(bm) if t.version is None) + assert tile.color == "#3E7D4F" + assert "#3E7D4F" in tile.styleSheet() + # Version-Kachel mit gespeicherter Farbe + vtile = next(t for t in _tiles(bm) if t.version is not None) + assert vtile.color == "#2F5D9E" and "#2F5D9E" in vtile.styleSheet() + # Zuruecksetzen auf Standardfarbe + assert bm.set_tile_color(tile, None) is True + tile = next(t for t in _tiles(bm) if t.version is None) + assert tile.color is None + finally: + bm.close() + + +def test_board_rename_via_ui(qapp, storage, settings, monkeypatch): + bm = BoardManager(storage, settings) + try: + bm.board_combo.setCurrentIndex(bm.board_combo.findData("b2")) + monkeypatch.setattr(QtWidgets.QInputDialog, "getText", + staticmethod(lambda *a, **k: ("Zwei neu", True))) + bm.rename_current_board() + qapp.processEvents() + assert _board(storage, "b2").title == "Zwei neu" + assert bm.board_combo.currentData() == "b2" + assert bm.board_combo.currentText() == "Zwei neu" + finally: + bm.close() + + +# --------------------------------------------------------------------------- Responsive grid +def test_grid_columns_follow_viewport_width_without_overlap(qapp, tmp_path, settings): + st = Storage(tmp_path / "grid") + st.save_prompts([Prompt(id=f"p{i}", title=f"T{i}", purpose="", text="x") for i in range(5)]) + st.save_boards([Board(id="b", title="B", items=[ + BoardItem(id=f"i{i}", board_id="b", prompt_id=f"p{i}") for i in range(5)])]) + bm = BoardManager(st, settings) + try: + bm.resize(340, 600) + bm.show() + qapp.processEvents() + bm.reload_items() + qapp.processEvents() + assert bm._layout_cols == 1 + bm.resize(1000, 600) + qapp.processEvents() + assert bm._layout_cols == 3 + visible = [t for t in _tiles(bm) if t.isVisible()] + assert len(visible) == 5 + rects = [t.geometry() for t in visible] + for i, a in enumerate(rects): + for b in rects[i + 1:]: + assert not a.intersects(b), "Kacheln duerfen sich nicht ueberlappen" + # Nach Reload sind alte Kacheln sofort unsichtbar (kein Geister-Overlap) + old = _tiles(bm) + bm.reload_items() + assert all(not t.isVisible() for t in old) + finally: + bm.close() diff --git a/translator.py b/translator.py index ac0d35f..113d649 100644 --- a/translator.py +++ b/translator.py @@ -54,14 +54,19 @@ class TranslationSystem: "ru": "Русский (ru)", } - def __init__(self, default_lang: str = "de", app_dir: Optional[Path] = None): + def __init__(self, default_lang: str = "de", app_dir: Optional[Path] = None, + auto_register: bool = True): """ Initialisiert Translation-System. Args: default_lang: Standard-Sprache ('de', 'en', 'es', 'zh', 'ja', 'ru') app_dir: Verzeichnis der Anwendung (default: Verzeichnis von translator.py) + auto_register: Fehlende deutsche Keys bei t() in translations.json + anlegen (Entwickler-Werkzeug). Die laufende App schaltet das ab, + damit die gebuendelte Datei nie leere Eintraege erhaelt. """ + self.auto_register = auto_register self.current_lang = default_lang if default_lang in self.SUPPORTED_LANGUAGES else "de" if app_dir is None: @@ -150,7 +155,7 @@ def t(self, key: str, **kwargs) -> str: return value return value - if self._is_german(key) and key not in self.translations: + if self.auto_register and self._is_german(key) and key not in self.translations: self.translations[key] = self._new_translation_entry(key, "") self._save_translations()