diff --git a/CHANGELOG.md b/CHANGELOG.md index cf10ee6..261cf8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Review & Fehlerbehebung: Cloud-Ordner, Vorschau, Datenschutz, Sprache (2026-10-06) +- **Cloud-Ordner sichtbar:** Unter Windows blendete die Dateiliste OneDrive-/Cloud-Platzhalter (Reparse-Points) und Verknüpfungen (`*.lnk`) aus; der Filter enthält jetzt `QDir.System`. Neue Seitenleisten-Gruppe **Cloud-Speicher** (OneDrive privat/geschäftlich, Dropbox, Google Drive, iCloud, Nextcloud u. a.; Windows-SyncRootManager nur lesend). Schnellzugriff-Einträge sind aufklappbar. +- **Ordnernamen statt „Ordner“:** Typ- und Datumsspalte nutzten `ResizeToContents` und konnten die Namensspalte auf wenige Pixel zusammendrücken. Feste, verstellbare Breiten; die Namensspalte behält mindestens 160 px, lange Namen werden mittig gekürzt. +- **Absturz/Hänger bei Klick auf Datei:** Vorschau fängt jeden Fehler ab, lädt keine Cloud-Platzhalter („nur online“) mehr herunter, beachtet die maximale Vorschaugröße und die Vorschau-Schalter, dekodiert Bilder verkleinert (inkl. EXIF-Drehung), rendert PDFs speicherbegrenzt und kopiert den Pixelpuffer. Windows-Verknüpfungen werden mit einem nativen Parser statt per PowerShell-Prozess aufgelöst. Dateizählung blockiert den GUI-Thread nicht mehr. Native Abstürze und unbehandelte Ausnahmen werden lokal in `~/.explorerpro/logs/crash.log` protokolliert. +- **Datenschutz-Ampel bedienbar:** Klick öffnet die Datenschutz-Einstellungen, Rechtsklick ein Schnellmenü (Überwachung ein/aus, Ampel zurücksetzen). Neuer Dialog mit editierbarer **Blacklist** und **Whitelist** (Hinzufügen, Bearbeiten, Filtern, Import/Export TXT/CSV/XLSX) und Testbereich. Die Überwachung lässt sich wieder einschalten, nachdem sie gestoppt wurde. +- **Spracheinstellung wirkt:** Ein Laufzeit-Übersetzer übersetzt alle Fenster, Menüs, Tooltips und Barrierefreiheitstexte und schaltet beim Speichern der Einstellungen live um. Dynamische Meldungen nutzen `t()`-Vorlagen; der Katalog wurde um die fehlenden Texte in allen sechs Sprachen ergänzt. Zur Laufzeit wird `translations.json` nicht mehr beschrieben (im Store-Paket schreibgeschützt). +- **Einstellungen werden angewendet:** Startordner, versteckte Dateien, Farbschema, Schriftgröße (0 = Systemstandard), Symbolgröße, Vorschau-Optionen, Überwachung und Hinweisdialoge sowie „Fenstergröße merken“. Indizierung beachtet maximale Dateigröße und Ausschlussmuster und liest keine Cloud-Platzhalter. + ### Übersetzte Hilfetexte und gespeicherte Sprache (2026-10-01) - 86 bislang fehlende Hilfetexte, Beschriftungen und Fehlermeldungen im Katalog ergänzt; Hilfetexte über die Übersetzungsfunktion angebunden (DE, EN, ES, ZH, JA, RU). - Die Sprachauswahl erklärt in allen sechs Sprachen, dass Sprachänderungen beim nächsten Start übernommen werden. diff --git a/locales/translations.json b/locales/translations.json index 27a5abe..f347423 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -2998,5 +2998,5405 @@ "zh": "复制差异", "ja": "差分をコピー", "ru": "Копировать различия" + }, + "'{name}' entfernen?": { + "de": "'{name}' entfernen?", + "en": "Remove '{name}'?", + "es": "¿Quitar '{name}'?", + "zh": "移除 '{name}'?", + "ja": "'{name}' を削除しますか?", + "ru": "Удалить '{name}'?" + }, + "'{name}' konnte nicht hinzugefügt werden: Datenschutz-Monitor nicht initialisiert.": { + "de": "'{name}' konnte nicht hinzugefügt werden: Datenschutz-Monitor nicht initialisiert.", + "en": "'{name}' could not be added: Privacy monitor not initialized.", + "es": "No se pudo agregar '{name}': el monitor de privacidad no está inicializado.", + "zh": "无法添加 '{name}':隐私监视器未初始化。", + "ja": "'{name}' を追加できませんでした: プライバシーモニターが初期化されていません。", + "ru": "Не удалось добавить '{name}': монитор конфиденциальности не инициализирован." + }, + "'{name}' synchronisieren?\n\nQuelle: {source}\nZiel: {target}": { + "de": "'{name}' synchronisieren?\n\nQuelle: {source}\nZiel: {target}", + "en": "Synchronize '{name}'?\n\nSource: {source}\nTarget: {target}", + "es": "¿Sincronizar '{name}'?\n\nOrigen: {source}\nDestino: {target}", + "zh": "同步 '{name}'?\n\n源:{source}\n目标:{target}", + "ja": "'{name}' を同期しますか?\n\n同期元: {source}\n同期先: {target}", + "ru": "Синхронизировать '{name}'?\n\nИсточник: {source}\nЦель: {target}" + }, + "'{name}' wirklich löschen?": { + "de": "'{name}' wirklich löschen?", + "en": "Really delete '{name}'?", + "es": "¿Seguro que desea eliminar '{name}'?", + "zh": "确定要删除 '{name}' 吗?", + "ja": "'{name}' を本当に削除しますか?", + "ru": "Действительно удалить '{name}'?" + }, + "'{name}' wurde zur Datenschutz-Blacklist hinzugefügt.": { + "de": "'{name}' wurde zur Datenschutz-Blacklist hinzugefügt.", + "en": "'{name}' was added to the privacy blacklist.", + "es": "'{name}' se agregó a la lista negra de privacidad.", + "zh": "'{name}' 已添加到隐私黑名单。", + "ja": "'{name}' をプライバシーブラックリストに追加しました。", + "ru": "'{name}' добавлен в чёрный список конфиденциальности." + }, + "(leer)": { + "de": "(leer)", + "en": "(empty)", + "es": "(vacío)", + "zh": "(空)", + "ja": "(空)", + "ru": "(пусто)" + }, + "+{added} hinzugefügt | -{deleted} entfernt | {identical} identisch": { + "de": "+{added} hinzugefügt | -{deleted} entfernt | {identical} identisch", + "en": "+{added} added | -{deleted} removed | {identical} identical", + "es": "+{added} agregados | -{deleted} eliminados | {identical} idénticos", + "zh": "+{added} 已添加 | -{deleted} 已移除 | {identical} 相同", + "ja": "+{added} 追加 | -{deleted} 削除 | {identical} 同一", + "ru": "+{added} добавлено | -{deleted} удалено | {identical} идентично" + }, + "... und {count} weitere": { + "de": "... und {count} weitere", + "en": "... and {count} more", + "es": "... y {count} más", + "zh": "... 以及另外 {count} 项", + "ja": "... 他 {count} 件", + "ru": "... и ещё {count}" + }, + "... {count} weitere Einträge": { + "de": "... {count} weitere Einträge", + "en": "... {count} more entries", + "es": "... {count} entradas más", + "zh": "... 另外 {count} 个条目", + "ja": "... 他 {count} 件のエントリ", + "ru": "... ещё записей: {count}" + }, + "Aktion": { + "de": "Aktion", + "en": "Action", + "es": "Acción", + "zh": "操作", + "ja": "操作", + "ru": "Действие" + }, + "Aktive Patterns": { + "de": "Aktive Patterns", + "en": "Active Patterns", + "es": "Patrones activos", + "zh": "活动模式", + "ja": "アクティブなパターン", + "ru": "Активные шаблоны" + }, + "Aktiviert die Auswertung regulärer Ausdrücke im Suchbegriff.": { + "de": "Aktiviert die Auswertung regulärer Ausdrücke im Suchbegriff.", + "en": "Enables evaluation of regular expressions in the search term.", + "es": "Activa la evaluación de expresiones regulares en el término de búsqueda.", + "zh": "启用对搜索词中正则表达式的解析。", + "ja": "検索語での正規表現の評価を有効にします。", + "ru": "Включает обработку регулярных выражений в поисковом запросе." + }, + "Aktueller Ampelstatus": { + "de": "Aktueller Ampelstatus", + "en": "Current traffic light status", + "es": "Estado actual del semáforo", + "zh": "当前信号灯状态", + "ja": "現在の信号の状態", + "ru": "Текущее состояние светофора" + }, + "Aktueller Ordnerpfad": { + "de": "Aktueller Ordnerpfad", + "en": "Current folder path", + "es": "Ruta de carpeta actual", + "zh": "当前文件夹路径", + "ja": "現在のフォルダーのパス", + "ru": "Текущий путь к папке" + }, + "Aktueller Ordnerpfad: {path}": { + "de": "Aktueller Ordnerpfad: {path}", + "en": "Current folder path: {path}", + "es": "Ruta de carpeta actual: {path}", + "zh": "当前文件夹路径:{path}", + "ja": "現在のフォルダーのパス: {path}", + "ru": "Текущий путь к папке: {path}" + }, + "Aktueller Pfad / Status": { + "de": "Aktueller Pfad / Status", + "en": "Current path / status", + "es": "Ruta actual / estado", + "zh": "当前路径 / 状态", + "ja": "現在のパス / 状態", + "ru": "Текущий путь / состояние" + }, + "Aktueller Pfad: {path}": { + "de": "Aktueller Pfad: {path}", + "en": "Current path: {path}", + "es": "Ruta actual: {path}", + "zh": "当前路径:{path}", + "ja": "現在のパス: {path}", + "ru": "Текущий путь: {path}" + }, + "Alle": { + "de": "Alle", + "en": "All", + "es": "Todos", + "zh": "全部", + "ja": "すべて", + "ru": "Все" + }, + "Alle Dateien": { + "de": "Alle Dateien", + "en": "All Files", + "es": "Todos los archivos", + "zh": "所有文件", + "ja": "すべてのファイル", + "ru": "Все файлы" + }, + "Alle Duplikate auswählen": { + "de": "Alle Duplikate auswählen", + "en": "Select All Duplicates", + "es": "Seleccionar todos los duplicados", + "zh": "选择所有重复项", + "ja": "すべての重複を選択", + "ru": "Выбрать все дубликаты" + }, + "Alle Prüfsummen kopieren": { + "de": "Alle Prüfsummen kopieren", + "en": "Copy All Checksums", + "es": "Copiar todas las sumas de comprobación", + "zh": "复制所有校验和", + "ja": "すべてのチェックサムをコピー", + "ru": "Скопировать все контрольные суммы" + }, + "Alle Prüfsummen wurden formatiert in die Zwischenablage kopiert.": { + "de": "Alle Prüfsummen wurden formatiert in die Zwischenablage kopiert.", + "en": "All checksums were copied to the clipboard in formatted form.", + "es": "Todas las sumas de comprobación se copiaron con formato al portapapeles.", + "zh": "所有校验和已格式化并复制到剪贴板。", + "ja": "すべてのチェックサムを書式付きでクリップボードにコピーしました。", + "ru": "Все контрольные суммы скопированы в буфер обмена в отформатированном виде." + }, + "Alle löschen": { + "de": "Alle löschen", + "en": "Delete All", + "es": "Eliminar todo", + "zh": "全部删除", + "ja": "すべて削除", + "ru": "Удалить все" + }, + "Ampel zurücksetzen": { + "de": "Ampel zurücksetzen", + "en": "Reset Traffic Light", + "es": "Restablecer semáforo", + "zh": "重置信号灯", + "ja": "信号をリセット", + "ru": "Сбросить светофор" + }, + "Analysiere bitte folgende Datei:": { + "de": "Analysiere bitte folgende Datei:", + "en": "Please analyze the following file:", + "es": "Por favor, analiza el siguiente archivo:", + "zh": "请分析以下文件:", + "ja": "次のファイルを分析してください:", + "ru": "Пожалуйста, проанализируйте следующий файл:" + }, + "Analysiere bitte folgenden Ordner:": { + "de": "Analysiere bitte folgenden Ordner:", + "en": "Please analyze the following folder:", + "es": "Por favor, analiza la siguiente carpeta:", + "zh": "请分析以下文件夹:", + "ja": "次のフォルダーを分析してください:", + "ru": "Пожалуйста, проанализируйте следующую папку:" + }, + "Analysiert die Änderungen des ausgewählten Paars ohne Dateien zu übertragen": { + "de": "Analysiert die Änderungen des ausgewählten Paars ohne Dateien zu übertragen", + "en": "Analyzes the changes of the selected pair without transferring any files", + "es": "Analiza los cambios del par seleccionado sin transferir archivos", + "zh": "分析所选同步对的更改,但不传输文件", + "ja": "ファイルを転送せずに、選択したペアの変更を分析します", + "ru": "Анализирует изменения выбранной пары без передачи файлов" + }, + "Anonymisiert:": { + "de": "Anonymisiert:", + "en": "Anonymized:", + "es": "Anonimizado:", + "zh": "已匿名化:", + "ja": "匿名化済み:", + "ru": "Анонимизировано:" + }, + "Ansicht umschalten": { + "de": "Ansicht umschalten", + "en": "Toggle View", + "es": "Cambiar vista", + "zh": "切换视图", + "ja": "表示を切り替え", + "ru": "Переключить вид" + }, + "Anwendungsname": { + "de": "Anwendungsname", + "en": "Application name", + "es": "Nombre de la aplicación", + "zh": "应用名称", + "ja": "アプリケーション名", + "ru": "Название приложения" + }, + "Anzahl der Dateien und Ordner sowie aktuelle Elementauswahl.": { + "de": "Anzahl der Dateien und Ordner sowie aktuelle Elementauswahl.", + "en": "Number of files and folders and the current item selection.", + "es": "Número de archivos y carpetas, y selección actual de elementos.", + "zh": "文件和文件夹的数量以及当前选中的项目。", + "ja": "ファイルとフォルダーの数、および現在の選択項目です。", + "ru": "Количество файлов и папок, а также текущий выбор элементов." + }, + "Anzahl geplanter Synchronisationsänderungen": { + "de": "Anzahl geplanter Synchronisationsänderungen", + "en": "Number of planned synchronization changes", + "es": "Número de cambios de sincronización planificados", + "zh": "计划的同步更改数量", + "ja": "予定されている同期変更の数", + "ru": "Количество запланированных изменений синхронизации" + }, + "Anzeigename der Anwendung im Schnellstarter.": { + "de": "Anzeigename der Anwendung im Schnellstarter.", + "en": "Display name of the application in the quick launcher.", + "es": "Nombre visible de la aplicación en el lanzador rápido.", + "zh": "应用在快速启动器中显示的名称。", + "ja": "クイックランチャーに表示されるアプリケーション名です。", + "ru": "Отображаемое имя приложения в быстром запуске." + }, + "App bearbeiten": { + "de": "App bearbeiten", + "en": "Edit App", + "es": "Editar aplicación", + "zh": "编辑应用", + "ja": "アプリを編集", + "ru": "Изменить приложение" + }, + "App gestartet: {name}": { + "de": "App gestartet: {name}", + "en": "App started: {name}", + "es": "Aplicación iniciada: {name}", + "zh": "应用已启动:{name}", + "ja": "アプリを起動しました: {name}", + "ru": "Приложение запущено: {name}" + }, + "App speichern": { + "de": "App speichern", + "en": "Save App", + "es": "Guardar aplicación", + "zh": "保存应用", + "ja": "アプリを保存", + "ru": "Сохранить приложение" + }, + "App {name}": { + "de": "App {name}", + "en": "App {name}", + "es": "Aplicación {name}", + "zh": "应用 {name}", + "ja": "アプリ {name}", + "ru": "Приложение {name}" + }, + "App-Kategorien": { + "de": "App-Kategorien", + "en": "App Categories", + "es": "Categorías de aplicaciones", + "zh": "应用类别", + "ja": "アプリのカテゴリ", + "ru": "Категории приложений" + }, + "App-Launcher": { + "de": "App-Launcher", + "en": "App Launcher", + "es": "Lanzador de aplicaciones", + "zh": "应用启动器", + "ja": "アプリランチャー", + "ru": "Запуск приложений" + }, + "Apps öffnen": { + "de": "Apps öffnen", + "en": "Open Apps", + "es": "Abrir aplicaciones", + "zh": "打开应用", + "ja": "アプリを開く", + "ru": "Открыть приложения" + }, + "Arbeitsbereich exportieren": { + "de": "Arbeitsbereich exportieren", + "en": "Export Workspace", + "es": "Exportar espacio de trabajo", + "zh": "导出工作区", + "ja": "ワークスペースをエクスポート", + "ru": "Экспортировать рабочую область" + }, + "Aus Datei importieren: {list}": { + "de": "Aus Datei importieren: {list}", + "en": "Import from file: {list}", + "es": "Importar desde archivo: {list}", + "zh": "从文件导入:{list}", + "ja": "ファイルからインポート: {list}", + "ru": "Импортировать из файла: {list}" + }, + "Ausführbare Dateien (*.exe *.bat *.cmd *.py);;Alle": { + "de": "Ausführbare Dateien (*.exe *.bat *.cmd *.py);;Alle", + "en": "Executable Files (*.exe *.bat *.cmd *.py);;All", + "es": "Archivos ejecutables (*.exe *.bat *.cmd *.py);;Todos", + "zh": "可执行文件 (*.exe *.bat *.cmd *.py);;全部", + "ja": "実行可能ファイル (*.exe *.bat *.cmd *.py);;すべて", + "ru": "Исполняемые файлы (*.exe *.bat *.cmd *.py);;Все" + }, + "Ausgabe des ausgeführten Skripts (Standardausgabe und Fehlermeldungen).": { + "de": "Ausgabe des ausgeführten Skripts (Standardausgabe und Fehlermeldungen).", + "en": "Output of the executed script (standard output and error messages).", + "es": "Salida del script ejecutado (salida estándar y mensajes de error).", + "zh": "所执行脚本的输出(标准输出和错误信息)。", + "ja": "実行したスクリプトの出力(標準出力とエラーメッセージ)。", + "ru": "Вывод выполненного скрипта (стандартный вывод и сообщения об ошибках)." + }, + "Ausgeschlossene Dateimuster": { + "de": "Ausgeschlossene Dateimuster", + "en": "Excluded File Patterns", + "es": "Patrones de archivo excluidos", + "zh": "排除的文件模式", + "ja": "除外するファイルパターン", + "ru": "Исключённые шаблоны файлов" + }, + "Ausgewählte Datei öffnen": { + "de": "Ausgewählte Datei öffnen", + "en": "Open Selected File", + "es": "Abrir archivo seleccionado", + "zh": "打开所选文件", + "ja": "選択したファイルを開く", + "ru": "Открыть выбранный файл" + }, + "Ausgewählte Duplikate löschen": { + "de": "Ausgewählte Duplikate löschen", + "en": "Delete Selected Duplicates", + "es": "Eliminar duplicados seleccionados", + "zh": "删除所选重复项", + "ja": "選択した重複を削除", + "ru": "Удалить выбранные дубликаты" + }, + "Ausgewählte Einträge entfernen: {list}": { + "de": "Ausgewählte Einträge entfernen: {list}", + "en": "Remove selected entries: {list}", + "es": "Quitar las entradas seleccionadas: {list}", + "zh": "移除所选条目:{list}", + "ja": "選択したエントリを削除: {list}", + "ru": "Удалить выбранные записи: {list}" + }, + "Ausgewählten Prompt bearbeiten": { + "de": "Ausgewählten Prompt bearbeiten", + "en": "Edit Selected Prompt", + "es": "Editar prompt seleccionado", + "zh": "编辑所选提示词", + "ja": "選択したプロンプトを編集", + "ru": "Изменить выбранный промпт" + }, + "Ausgewählten Prompt kopieren": { + "de": "Ausgewählten Prompt kopieren", + "en": "Copy Selected Prompt", + "es": "Copiar prompt seleccionado", + "zh": "复制所选提示词", + "ja": "選択したプロンプトをコピー", + "ru": "Копировать выбранный промпт" + }, + "Aussagekräftiger Name der Prompt-Vorlage.": { + "de": "Aussagekräftiger Name der Prompt-Vorlage.", + "en": "Descriptive name of the prompt template.", + "es": "Nombre descriptivo de la plantilla de prompt.", + "zh": "提示词模板的描述性名称。", + "ja": "プロンプトテンプレートのわかりやすい名前です。", + "ru": "Понятное название шаблона промпта." + }, + "Ausschließen": { + "de": "Ausschließen", + "en": "Exclude", + "es": "Excluir", + "zh": "排除", + "ja": "除外", + "ru": "Исключить" + }, + "Auswahl der Benutzeroberflächensprache.": { + "de": "Auswahl der Benutzeroberflächensprache.", + "en": "Selection of the user interface language.", + "es": "Selección del idioma de la interfaz de usuario.", + "zh": "选择用户界面语言。", + "ja": "ユーザーインターフェイスの言語を選択します。", + "ru": "Выбор языка интерфейса." + }, + "Auswahl zwischen Index-basierter Duplikatsuche und Verzeichnis-Scan.": { + "de": "Auswahl zwischen Index-basierter Duplikatsuche und Verzeichnis-Scan.", + "en": "Choose between index-based duplicate search and directory scan.", + "es": "Elige entre la búsqueda de duplicados basada en el índice y el análisis de directorios.", + "zh": "在基于索引的重复项搜索和目录扫描之间选择。", + "ja": "インデックスベースの重複検索とディレクトリスキャンのどちらかを選択します。", + "ru": "Выбор между поиском дубликатов по индексу и сканированием каталога." + }, + "Auswahlzähler": { + "de": "Auswahlzähler", + "en": "Selection counter", + "es": "Contador de selección", + "zh": "选择计数器", + "ja": "選択数カウンター", + "ru": "Счётчик выбора" + }, + "Baumansicht der gefundenen Duplikat-Gruppen mit Dateigröße und Speicherpfad.": { + "de": "Baumansicht der gefundenen Duplikat-Gruppen mit Dateigröße und Speicherpfad.", + "en": "Tree view of the duplicate groups found, with file size and storage path.", + "es": "Vista de árbol de los grupos de duplicados encontrados, con tamaño de archivo y ruta de almacenamiento.", + "zh": "显示所找到重复项分组的树状视图,包含文件大小和存储路径。", + "ja": "見つかった重複グループのツリー表示です。ファイルサイズと保存パスを表示します。", + "ru": "Древовидное представление найденных групп дубликатов с размером файла и путём хранения." + }, + "Bearbeitet Allgemein-, Index-, Vorschau-, Datenschutz- und Darstellungseinstellungen von ExplorerPro.": { + "de": "Bearbeitet Allgemein-, Index-, Vorschau-, Datenschutz- und Darstellungseinstellungen von ExplorerPro.", + "en": "Edits the general, index, preview, privacy and appearance settings of ExplorerPro.", + "es": "Edita la configuración general, de índice, de vista previa, de privacidad y de apariencia de ExplorerPro.", + "zh": "编辑 ExplorerPro 的常规、索引、预览、隐私和外观设置。", + "ja": "ExplorerPro の一般、インデックス、プレビュー、プライバシー、表示の設定を編集します。", + "ru": "Изменяет общие настройки ExplorerPro, а также настройки индекса, предпросмотра, конфиденциальности и внешнего вида." + }, + "Beenden": { + "de": "Beenden", + "en": "Exit", + "es": "Salir", + "zh": "退出", + "ja": "終了", + "ru": "Выход" + }, + "Begriff hinzufügen zu: {list}": { + "de": "Begriff hinzufügen zu: {list}", + "en": "Add term to: {list}", + "es": "Agregar término a: {list}", + "zh": "添加词条到:{list}", + "ja": "用語を追加: {list}", + "ru": "Добавить термин в: {list}" + }, + "Begriffe aus TXT (eine Zeile pro Begriff), CSV oder Excel übernehmen": { + "de": "Begriffe aus TXT (eine Zeile pro Begriff), CSV oder Excel übernehmen", + "en": "Import terms from TXT (one line per term), CSV or Excel", + "es": "Importar términos desde TXT (una línea por término), CSV o Excel", + "zh": "从 TXT(每行一个词条)、CSV 或 Excel 导入词条", + "ja": "TXT(1行に1語)、CSV、Excel から用語を取り込む", + "ru": "Импортировать термины из TXT (один термин на строку), CSV или Excel" + }, + "Begriffe exportieren": { + "de": "Begriffe exportieren", + "en": "Export Terms", + "es": "Exportar términos", + "zh": "导出词条", + "ja": "用語をエクスポート", + "ru": "Экспортировать термины" + }, + "Begriffe importieren": { + "de": "Begriffe importieren", + "en": "Import Terms", + "es": "Importar términos", + "zh": "导入词条", + "ja": "用語をインポート", + "ru": "Импортировать термины" + }, + "Bei Konflikten": { + "de": "Bei Konflikten", + "en": "On conflicts", + "es": "En caso de conflicto", + "zh": "发生冲突时", + "ja": "競合時", + "ru": "При конфликтах" + }, + "Beispieltext zum Prüfen": { + "de": "Beispieltext zum Prüfen", + "en": "Sample text to check", + "es": "Texto de ejemplo para comprobar", + "zh": "用于检查的示例文本", + "ja": "確認用のサンプルテキスト", + "ru": "Образец текста для проверки" + }, + "Belegter Speicher: {size}": { + "de": "Belegter Speicher: {size}", + "en": "Used storage: {size}", + "es": "Almacenamiento usado: {size}", + "zh": "已用存储空间:{size}", + "ja": "使用中のストレージ: {size}", + "ru": "Занятое место: {size}" + }, + "Belegter oder berechneter Speicherplatz der Dateien.": { + "de": "Belegter oder berechneter Speicherplatz der Dateien.", + "en": "Used or calculated storage space of the files.", + "es": "Espacio de almacenamiento usado o calculado de los archivos.", + "zh": "文件占用或计算得出的存储空间。", + "ja": "ファイルが使用している、または計算されたストレージ容量です。", + "ru": "Занятое или рассчитанное место на диске, занимаемое файлами." + }, + "Benachrichtigung bei erkannten Datenschutz-Mustern einblenden": { + "de": "Benachrichtigung bei erkannten Datenschutz-Mustern einblenden", + "en": "Show notification when privacy patterns are detected", + "es": "Mostrar notificación cuando se detecten patrones de privacidad", + "zh": "检测到隐私模式时显示通知", + "ja": "プライバシーパターンを検出したときに通知を表示", + "ru": "Показывать уведомление при обнаружении шаблонов конфиденциальных данных" + }, + "Benutzerdefinierte Dateiendungen": { + "de": "Benutzerdefinierte Dateiendungen", + "en": "Custom File Extensions", + "es": "Extensiones de archivo personalizadas", + "zh": "自定义文件扩展名", + "ja": "カスタムファイル拡張子", + "ru": "Пользовательские расширения файлов" + }, + "Berechnen": { + "de": "Berechnen", + "en": "Calculate", + "es": "Calcular", + "zh": "计算", + "ja": "計算", + "ru": "Вычислить" + }, + "Berechnet MD5, SHA-1, SHA-256 und SHA-512 Prüfsummen und ermöglicht den Vergleich.": { + "de": "Berechnet MD5, SHA-1, SHA-256 und SHA-512 Prüfsummen und ermöglicht den Vergleich.", + "en": "Calculates MD5, SHA-1, SHA-256 and SHA-512 checksums and allows comparing them.", + "es": "Calcula sumas de comprobación MD5, SHA-1, SHA-256 y SHA-512 y permite compararlas.", + "zh": "计算 MD5、SHA-1、SHA-256 和 SHA-512 校验和并支持比较。", + "ja": "MD5、SHA-1、SHA-256、SHA-512 のチェックサムを計算し、比較できます。", + "ru": "Вычисляет контрольные суммы MD5, SHA-1, SHA-256 и SHA-512 и позволяет их сравнивать." + }, + "Berechnete Prüfsummen": { + "de": "Berechnete Prüfsummen", + "en": "Calculated Checksums", + "es": "Sumas de comprobación calculadas", + "zh": "计算出的校验和", + "ja": "計算されたチェックサム", + "ru": "Вычисленные контрольные суммы" + }, + "Berechnete {algorithm}-Prüfsumme für die ausgewählte Datei.": { + "de": "Berechnete {algorithm}-Prüfsumme für die ausgewählte Datei.", + "en": "Calculated {algorithm} checksum for the selected file.", + "es": "Suma de comprobación {algorithm} calculada para el archivo seleccionado.", + "zh": "所选文件的 {algorithm} 校验和计算结果。", + "ja": "選択したファイルの {algorithm} チェックサムの計算結果です。", + "ru": "Вычисленная контрольная сумма {algorithm} для выбранного файла." + }, + "Berechnungsfortschritt": { + "de": "Berechnungsfortschritt", + "en": "Calculation progress", + "es": "Progreso del cálculo", + "zh": "计算进度", + "ja": "計算の進行状況", + "ru": "Ход вычисления" + }, + "Beschreibung": { + "de": "Beschreibung", + "en": "Description", + "es": "Descripción", + "zh": "描述", + "ja": "説明", + "ru": "Описание" + }, + "Bezieht versteckte Dateien und Ordner in die Synchronisation ein": { + "de": "Bezieht versteckte Dateien und Ordner in die Synchronisation ein", + "en": "Includes hidden files and folders in the synchronization", + "es": "Incluye los archivos y carpetas ocultos en la sincronización", + "zh": "将隐藏的文件和文件夹包含在同步中", + "ja": "隠しファイルとフォルダーを同期に含めます", + "ru": "Включает скрытые файлы и папки в синхронизацию" + }, + "Bidirektional ↔": { + "de": "Bidirektional ↔", + "en": "Bidirectional ↔", + "es": "Bidireccional ↔", + "zh": "双向 ↔", + "ja": "双方向 ↔", + "ru": "Двусторонняя ↔" + }, + "Bilder": { + "de": "Bilder", + "en": "Images", + "es": "Imágenes", + "zh": "图片", + "ja": "画像", + "ru": "Изображения" + }, + "Bildvorschau ist in den Einstellungen deaktiviert.": { + "de": "Bildvorschau ist in den Einstellungen deaktiviert.", + "en": "Image preview is disabled in the settings.", + "es": "La vista previa de imágenes está desactivada en la configuración.", + "zh": "图片预览已在设置中禁用。", + "ja": "画像のプレビューは設定で無効になっています。", + "ru": "Предпросмотр изображений отключён в настройках." + }, + "Bitte Namen eingeben!": { + "de": "Bitte Namen eingeben!", + "en": "Please enter a name!", + "es": "¡Ingrese un nombre!", + "zh": "请输入名称!", + "ja": "名前を入力してください!", + "ru": "Введите название!" + }, + "Bitte Pfad angeben!": { + "de": "Bitte Pfad angeben!", + "en": "Please specify a path!", + "es": "¡Especifique una ruta!", + "zh": "请指定路径!", + "ja": "パスを指定してください!", + "ru": "Укажите путь!" + }, + "Bitte Prompt-Inhalt eingeben!": { + "de": "Bitte Prompt-Inhalt eingeben!", + "en": "Please enter the prompt content!", + "es": "¡Ingrese el contenido del prompt!", + "zh": "请输入提示词内容!", + "ja": "プロンプトの内容を入力してください!", + "ru": "Введите содержимое промпта!" + }, + "Bitte Quellordner angeben!": { + "de": "Bitte Quellordner angeben!", + "en": "Please specify a source folder!", + "es": "¡Especifique una carpeta de origen!", + "zh": "请指定源文件夹!", + "ja": "同期元フォルダーを指定してください!", + "ru": "Укажите исходную папку!" + }, + "Bitte Zielordner angeben!": { + "de": "Bitte Zielordner angeben!", + "en": "Please specify a target folder!", + "es": "¡Especifique una carpeta de destino!", + "zh": "请指定目标文件夹!", + "ja": "同期先フォルダーを指定してください!", + "ru": "Укажите целевую папку!" + }, + "Bitte einen Titel eingeben!": { + "de": "Bitte einen Titel eingeben!", + "en": "Please enter a title!", + "es": "¡Ingrese un título!", + "zh": "请输入标题!", + "ja": "タイトルを入力してください!", + "ru": "Введите заголовок!" + }, + "Bitte geben Sie mindestens einen Suchbegriff, Dateityp oder Tag an.": { + "de": "Bitte geben Sie mindestens einen Suchbegriff, Dateityp oder Tag an.", + "en": "Please specify at least one search term, file type or tag.", + "es": "Especifique al menos un término de búsqueda, tipo de archivo o etiqueta.", + "zh": "请至少指定一个搜索词、文件类型或标签。", + "ja": "検索語、ファイルの種類、タグのうち少なくとも1つを指定してください。", + "ru": "Укажите хотя бы один поисковый запрос, тип файла или тег." + }, + "Bitte wählen Sie einen Ordner!": { + "de": "Bitte wählen Sie einen Ordner!", + "en": "Please select a folder!", + "es": "¡Seleccione una carpeta!", + "zh": "请选择一个文件夹!", + "ja": "フォルダーを選択してください!", + "ru": "Выберите папку!" + }, + "Bitte zuerst einen Text eingeben.": { + "de": "Bitte zuerst einen Text eingeben.", + "en": "Please enter some text first.", + "es": "Primero ingrese un texto.", + "zh": "请先输入文本。", + "ja": "先にテキストを入力してください。", + "ru": "Сначала введите текст." + }, + "Bitte zuerst speichern!": { + "de": "Bitte zuerst speichern!", + "en": "Please save first!", + "es": "¡Guarde primero!", + "zh": "请先保存!", + "ja": "先に保存してください!", + "ru": "Сначала сохраните!" + }, + "Blacklist": { + "de": "Blacklist", + "en": "Blacklist", + "es": "Lista negra", + "zh": "黑名单", + "ja": "ブラックリスト", + "ru": "Чёрный список" + }, + "Blacklist und Whitelist bearbeiten": { + "de": "Blacklist und Whitelist bearbeiten", + "en": "Edit Blacklist and Whitelist", + "es": "Editar lista negra y lista blanca", + "zh": "编辑黑名单和白名单", + "ja": "ブラックリストとホワイトリストを編集", + "ru": "Изменить чёрный и белый списки" + }, + "Blacklist-Einträge": { + "de": "Blacklist-Einträge", + "en": "Blacklist Entries", + "es": "Entradas de la lista negra", + "zh": "黑名单条目", + "ja": "ブラックリストのエントリ", + "ru": "Записи чёрного списка" + }, + "Blacklist/Whitelist bearbeiten": { + "de": "Blacklist/Whitelist bearbeiten", + "en": "Edit Blacklist/Whitelist", + "es": "Editar lista negra/blanca", + "zh": "编辑黑名单/白名单", + "ja": "ブラックリスト/ホワイトリストを編集", + "ru": "Изменить чёрный/белый список" + }, + "Bricht den laufenden Hintergrund-Scan sofort ab.": { + "de": "Bricht den laufenden Hintergrund-Scan sofort ab.", + "en": "Cancels the running background scan immediately.", + "es": "Cancela de inmediato el análisis en segundo plano en curso.", + "zh": "立即取消正在运行的后台扫描。", + "ja": "実行中のバックグラウンドスキャンをすぐに中止します。", + "ru": "Немедленно отменяет выполняющееся фоновое сканирование." + }, + "Bricht den laufenden Unterprozess ab.": { + "de": "Bricht den laufenden Unterprozess ab.", + "en": "Cancels the running subprocess.", + "es": "Cancela el subproceso en ejecución.", + "zh": "取消正在运行的子进程。", + "ja": "実行中のサブプロセスを中止します。", + "ru": "Отменяет выполняющийся подпроцесс." + }, + "Bricht die Bearbeitung ab": { + "de": "Bricht die Bearbeitung ab", + "en": "Cancels editing", + "es": "Cancela la edición", + "zh": "取消编辑", + "ja": "編集を中止します", + "ru": "Отменяет редактирование" + }, + "Clipboard bei Alarm automatisch leeren": { + "de": "Clipboard bei Alarm automatisch leeren", + "en": "Automatically clear clipboard on alarm", + "es": "Vaciar automáticamente el portapapeles en caso de alarma", + "zh": "触发警报时自动清空剪贴板", + "ja": "アラート時にクリップボードを自動的に空にする", + "ru": "Автоматически очищать буфер обмена при тревоге" + }, + "Clipboard bei ROT automatisch leeren": { + "de": "Clipboard bei ROT automatisch leeren", + "en": "Automatically clear clipboard on RED", + "es": "Vaciar automáticamente el portapapeles en ROJO", + "zh": "状态为红色时自动清空剪贴板", + "ja": "赤になったらクリップボードを自動的に空にする", + "ru": "Автоматически очищать буфер обмена при КРАСНОМ" + }, + "Clipboard ist sicher": { + "de": "Clipboard ist sicher", + "en": "Clipboard is safe", + "es": "El portapapeles es seguro", + "zh": "剪贴板安全", + "ja": "クリップボードは安全です", + "ru": "Буфер обмена безопасен" + }, + "Cloud-Speicher": { + "de": "Cloud-Speicher", + "en": "Cloud Storage", + "es": "Almacenamiento en la nube", + "zh": "云存储", + "ja": "クラウドストレージ", + "ru": "Облачное хранилище" + }, + "Code ausführen": { + "de": "Code ausführen", + "en": "Run Code", + "es": "Ejecutar código", + "zh": "运行代码", + "ja": "コードを実行", + "ru": "Выполнить код" + }, + "Code-Editor": { + "de": "Code-Editor", + "en": "Code Editor", + "es": "Editor de código", + "zh": "代码编辑器", + "ja": "コードエディター", + "ru": "Редактор кода" + }, + "Cursor-Position": { + "de": "Cursor-Position", + "en": "Cursor position", + "es": "Posición del cursor", + "zh": "光标位置", + "ja": "カーソル位置", + "ru": "Позиция курсора" + }, + "Datei 1 durchsuchen": { + "de": "Datei 1 durchsuchen", + "en": "Browse for file 1", + "es": "Examinar archivo 1", + "zh": "浏览文件 1", + "ja": "ファイル 1 を参照", + "ru": "Выбрать файл 1" + }, + "Datei 2 durchsuchen": { + "de": "Datei 2 durchsuchen", + "en": "Browse for file 2", + "es": "Examinar archivo 2", + "zh": "浏览文件 2", + "ja": "ファイル 2 を参照", + "ru": "Выбрать файл 2" + }, + "Datei extern öffnen": { + "de": "Datei extern öffnen", + "en": "Open File Externally", + "es": "Abrir archivo externamente", + "zh": "在外部打开文件", + "ja": "ファイルを外部で開く", + "ru": "Открыть файл во внешней программе" + }, + "Datei für Prüfsummenberechnung auswählen": { + "de": "Datei für Prüfsummenberechnung auswählen", + "en": "Select file for checksum calculation", + "es": "Seleccionar archivo para calcular la suma de comprobación", + "zh": "选择用于校验和计算的文件", + "ja": "チェックサム計算用のファイルを選択", + "ru": "Выбрать файл для вычисления контрольной суммы" + }, + "Datei nicht gefunden:\n{path}": { + "de": "Datei nicht gefunden:\n{path}", + "en": "File not found:\n{path}", + "es": "Archivo no encontrado:\n{path}", + "zh": "未找到文件:\n{path}", + "ja": "ファイルが見つかりません:\n{path}", + "ru": "Файл не найден:\n{path}" + }, + "Datei speichern": { + "de": "Datei speichern", + "en": "Save File", + "es": "Guardar archivo", + "zh": "保存文件", + "ja": "ファイルを保存", + "ru": "Сохранить файл" + }, + "Datei zu groß für die Vorschau ({size} MB, Grenze {limit} MB).": { + "de": "Datei zu groß für die Vorschau ({size} MB, Grenze {limit} MB).", + "en": "File too large for preview ({size} MB, limit {limit} MB).", + "es": "Archivo demasiado grande para la vista previa ({size} MB, límite {limit} MB).", + "zh": "文件过大,无法预览({size} MB,上限 {limit} MB)。", + "ja": "ファイルが大きすぎてプレビューできません({size} MB、上限 {limit} MB)。", + "ru": "Файл слишком большой для предпросмотра ({size} МБ, лимит {limit} МБ)." + }, + "Datei öffnen": { + "de": "Datei öffnen", + "en": "Open File", + "es": "Abrir archivo", + "zh": "打开文件", + "ja": "ファイルを開く", + "ru": "Открыть файл" + }, + "Datei-Information": { + "de": "Datei-Information", + "en": "File Information", + "es": "Información del archivo", + "zh": "文件信息", + "ja": "ファイル情報", + "ru": "Информация о файле" + }, + "Datei-Notizen": { + "de": "Datei-Notizen", + "en": "File Notes", + "es": "Notas del archivo", + "zh": "文件备注", + "ja": "ファイルメモ", + "ru": "Заметки к файлу" + }, + "Datei-Tags filtern": { + "de": "Datei-Tags filtern", + "en": "Filter File Tags", + "es": "Filtrar etiquetas de archivo", + "zh": "筛选文件标签", + "ja": "ファイルタグをフィルター", + "ru": "Фильтр тегов файлов" + }, + "Datei-Vergleichs-Dialog": { + "de": "Datei-Vergleichs-Dialog", + "en": "File Comparison Dialog", + "es": "Cuadro de diálogo de comparación de archivos", + "zh": "文件比较对话框", + "ja": "ファイル比較ダイアログ", + "ru": "Диалог сравнения файлов" + }, + "Datei: {name}": { + "de": "Datei: {name}", + "en": "File: {name}", + "es": "Archivo: {name}", + "zh": "文件:{name}", + "ja": "ファイル: {name}", + "ru": "Файл: {name}" + }, + "Dateien jetzt umbenennen": { + "de": "Dateien jetzt umbenennen", + "en": "Rename Files Now", + "es": "Cambiar nombre de archivos ahora", + "zh": "立即重命名文件", + "ja": "今すぐファイル名を変更", + "ru": "Переименовать файлы сейчас" + }, + "Dateien unter dieser Mindestgröße werden beim Scan ignoriert.": { + "de": "Dateien unter dieser Mindestgröße werden beim Scan ignoriert.", + "en": "Files below this minimum size are ignored during the scan.", + "es": "Los archivos por debajo de este tamaño mínimo se ignoran durante el análisis.", + "zh": "扫描时将忽略小于此最小大小的文件。", + "ja": "この最小サイズ未満のファイルはスキャン時に無視されます。", + "ru": "Файлы меньше этого минимального размера пропускаются при сканировании." + }, + "Dateien vergleichen": { + "de": "Dateien vergleichen", + "en": "Compare Files", + "es": "Comparar archivos", + "zh": "比较文件", + "ja": "ファイルを比較", + "ru": "Сравнить файлы" + }, + "Dateiliste": { + "de": "Dateiliste", + "en": "File list", + "es": "Lista de archivos", + "zh": "文件列表", + "ja": "ファイル一覧", + "ru": "Список файлов" + }, + "Dateinamen-Präfix": { + "de": "Dateinamen-Präfix", + "en": "Filename prefix", + "es": "Prefijo del nombre de archivo", + "zh": "文件名前缀", + "ja": "ファイル名の接頭辞", + "ru": "Префикс имени файла" + }, + "Dateinamen-Suffix": { + "de": "Dateinamen-Suffix", + "en": "Filename suffix", + "es": "Sufijo del nombre de archivo", + "zh": "文件名后缀", + "ja": "ファイル名の接尾辞", + "ru": "Суффикс имени файла" + }, + "Dateipfad der ersten Datei (Basis) für den Vergleich.": { + "de": "Dateipfad der ersten Datei (Basis) für den Vergleich.", + "en": "File path of the first file (base) for the comparison.", + "es": "Ruta del primer archivo (base) para la comparación.", + "zh": "用于比较的第一个文件(基准)的路径。", + "ja": "比較に使用する最初のファイル(基準)のパスです。", + "ru": "Путь к первому файлу (базовому) для сравнения." + }, + "Dateipfad der zweiten Datei für den Vergleich.": { + "de": "Dateipfad der zweiten Datei für den Vergleich.", + "en": "File path of the second file for the comparison.", + "es": "Ruta del segundo archivo para la comparación.", + "zh": "用于比较的第二个文件的路径。", + "ja": "比較に使用する2番目のファイルのパスです。", + "ru": "Путь ко второму файлу для сравнения." + }, + "Dateipfad zur ausführbaren Datei oder zum Skript.": { + "de": "Dateipfad zur ausführbaren Datei oder zum Skript.", + "en": "File path to the executable or script.", + "es": "Ruta del archivo ejecutable o del script.", + "zh": "可执行文件或脚本的路径。", + "ja": "実行可能ファイルまたはスクリプトのパス。", + "ru": "Путь к исполняемому файлу или скрипту." + }, + "Dateisuche": { + "de": "Dateisuche", + "en": "File Search", + "es": "Búsqueda de archivos", + "zh": "文件搜索", + "ja": "ファイル検索", + "ru": "Поиск файлов" + }, + "Dateityp-Filter": { + "de": "Dateityp-Filter", + "en": "File type filter", + "es": "Filtro de tipo de archivo", + "zh": "文件类型筛选", + "ja": "ファイルの種類フィルター", + "ru": "Фильтр по типу файла" + }, + "Datenbank-gestützter Volltextsuche": { + "de": "Datenbank-gestützter Volltextsuche", + "en": "Database-backed full-text search", + "es": "Búsqueda de texto completo respaldada por base de datos", + "zh": "基于数据库的全文搜索", + "ja": "データベースベースの全文検索", + "ru": "Полнотекстовый поиск на основе базы данных" + }, + "Datenschutz prüfen": { + "de": "Datenschutz prüfen", + "en": "Check Privacy", + "es": "Comprobar privacidad", + "zh": "检查隐私", + "ja": "プライバシーを確認", + "ru": "Проверить конфиденциальность" + }, + "Datenschutz-Ampel": { + "de": "Datenschutz-Ampel", + "en": "Privacy Indicator", + "es": "Indicador de privacidad", + "zh": "隐私指示灯", + "ja": "プライバシーインジケーター", + "ru": "Индикатор конфиденциальности" + }, + "Datenschutz-Bereiche": { + "de": "Datenschutz-Bereiche", + "en": "Privacy Areas", + "es": "Áreas de privacidad", + "zh": "隐私区域", + "ja": "プライバシー領域", + "ru": "Области конфиденциальности" + }, + "Datenschutz-Monitor inaktiv": { + "de": "Datenschutz-Monitor inaktiv", + "en": "Privacy monitor inactive", + "es": "Monitor de privacidad inactivo", + "zh": "隐私监控未启用", + "ja": "プライバシーモニターは無効です", + "ru": "Монитор конфиденциальности неактивен" + }, + "Datenschutz-Monitor nicht initialisiert.": { + "de": "Datenschutz-Monitor nicht initialisiert.", + "en": "Privacy monitor not initialized.", + "es": "El monitor de privacidad no está inicializado.", + "zh": "隐私监控尚未初始化。", + "ja": "プライバシーモニターが初期化されていません。", + "ru": "Монитор конфиденциальности не инициализирован." + }, + "Datenschutz-Prüfung": { + "de": "Datenschutz-Prüfung", + "en": "Privacy Check", + "es": "Comprobación de privacidad", + "zh": "隐私检查", + "ja": "プライバシーチェック", + "ru": "Проверка конфиденциальности" + }, + "Datenschutz-Prüfung nur für Text-Dateien verfügbar.": { + "de": "Datenschutz-Prüfung nur für Text-Dateien verfügbar.", + "en": "Privacy check is only available for text files.", + "es": "La comprobación de privacidad solo está disponible para archivos de texto.", + "zh": "隐私检查仅适用于文本文件。", + "ja": "プライバシーチェックはテキストファイルでのみ利用できます。", + "ru": "Проверка конфиденциальности доступна только для текстовых файлов." + }, + "Datenschutz-Status": { + "de": "Datenschutz-Status", + "en": "Privacy Status", + "es": "Estado de privacidad", + "zh": "隐私状态", + "ja": "プライバシーの状態", + "ru": "Состояние конфиденциальности" + }, + "Datenschutz-Status: Gelb. Warnung vor potenziell sensiblen Daten in der Zwischenablage.": { + "de": "Datenschutz-Status: Gelb. Warnung vor potenziell sensiblen Daten in der Zwischenablage.", + "en": "Privacy status: Yellow. Warning about potentially sensitive data in the clipboard.", + "es": "Estado de privacidad: amarillo. Advertencia sobre datos potencialmente confidenciales en el portapapeles.", + "zh": "隐私状态:黄色。剪贴板中可能包含敏感数据,请注意。", + "ja": "プライバシーの状態:黄。クリップボードに機密の可能性があるデータが含まれています。", + "ru": "Состояние конфиденциальности: жёлтое. Предупреждение о потенциально конфиденциальных данных в буфере обмена." + }, + "Datenschutz-Status: Grau. Datenschutz-Monitor ist inaktiv.": { + "de": "Datenschutz-Status: Grau. Datenschutz-Monitor ist inaktiv.", + "en": "Privacy status: Gray. Privacy monitor is inactive.", + "es": "Estado de privacidad: gris. El monitor de privacidad está inactivo.", + "zh": "隐私状态:灰色。隐私监控未启用。", + "ja": "プライバシーの状態:グレー。プライバシーモニターは無効です。", + "ru": "Состояние конфиденциальности: серое. Монитор конфиденциальности неактивен." + }, + "Datenschutz-Status: Grün. Zwischenablage ist sicher und unbedenklich.": { + "de": "Datenschutz-Status: Grün. Zwischenablage ist sicher und unbedenklich.", + "en": "Privacy status: Green. The clipboard is safe and unproblematic.", + "es": "Estado de privacidad: verde. El portapapeles es seguro y no presenta problemas.", + "zh": "隐私状态:绿色。剪贴板安全,没有问题。", + "ja": "プライバシーの状態:緑。クリップボードは安全で、問題ありません。", + "ru": "Состояние конфиденциальности: зелёное. Буфер обмена безопасен, проблем нет." + }, + "Datenschutz-Status: Rot. Sensible Daten wurden im Zwischenspeicher blockiert.": { + "de": "Datenschutz-Status: Rot. Sensible Daten wurden im Zwischenspeicher blockiert.", + "en": "Privacy status: Red. Sensitive data was blocked in the clipboard.", + "es": "Estado de privacidad: rojo. Se bloquearon datos confidenciales en el portapapeles.", + "zh": "隐私状态:红色。剪贴板中的敏感数据已被拦截。", + "ja": "プライバシーの状態:赤。クリップボード内の機密データがブロックされました。", + "ru": "Состояние конфиденциальности: красное. Конфиденциальные данные в буфере обмена заблокированы." + }, + "Datenschutz-Status: {status}": { + "de": "Datenschutz-Status: {status}", + "en": "Privacy status: {status}", + "es": "Estado de privacidad: {status}", + "zh": "隐私状态:{status}", + "ja": "プライバシーの状態:{status}", + "ru": "Состояние конфиденциальности: {status}" + }, + "Datenschutz-Warnung": { + "de": "Datenschutz-Warnung", + "en": "Privacy Warning", + "es": "Advertencia de privacidad", + "zh": "隐私警告", + "ja": "プライバシー警告", + "ru": "Предупреждение о конфиденциальности" + }, + "Datenschutz-Überwachung aktiviert": { + "de": "Datenschutz-Überwachung aktiviert", + "en": "Privacy monitoring enabled", + "es": "Supervisión de privacidad activada", + "zh": "隐私监控已启用", + "ja": "プライバシー監視を有効にしました", + "ru": "Мониторинг конфиденциальности включён" + }, + "Datenschutz-Überwachung deaktiviert": { + "de": "Datenschutz-Überwachung deaktiviert", + "en": "Privacy monitoring disabled", + "es": "Supervisión de privacidad desactivada", + "zh": "隐私监控已停用", + "ja": "プライバシー監視を無効にしました", + "ru": "Мониторинг конфиденциальности отключён" + }, + "Datenschutz: Alles OK": { + "de": "Datenschutz: Alles OK", + "en": "Privacy: All OK", + "es": "Privacidad: todo correcto", + "zh": "隐私:一切正常", + "ja": "プライバシー:問題なし", + "ru": "Конфиденциальность: всё в порядке" + }, + "Datenschutz: Blockiert!": { + "de": "Datenschutz: Blockiert!", + "en": "Privacy: Blocked!", + "es": "Privacidad: ¡bloqueado!", + "zh": "隐私:已拦截!", + "ja": "プライバシー:ブロックしました!", + "ru": "Конфиденциальность: заблокировано!" + }, + "Datenschutz: Warnung": { + "de": "Datenschutz: Warnung", + "en": "Privacy: Warning", + "es": "Privacidad: advertencia", + "zh": "隐私:警告", + "ja": "プライバシー:警告", + "ru": "Конфиденциальность: предупреждение" + }, + "Der Ordner konnte nicht geöffnet werden:\n{path}\n\n{error}": { + "de": "Der Ordner konnte nicht geöffnet werden:\n{path}\n\n{error}", + "en": "The folder could not be opened:\n{path}\n\n{error}", + "es": "No se pudo abrir la carpeta:\n{path}\n\n{error}", + "zh": "无法打开文件夹:\n{path}\n\n{error}", + "ja": "フォルダーを開けませんでした:\n{path}\n\n{error}", + "ru": "Не удалось открыть папку:\n{path}\n\n{error}" + }, + "Der Pfad konnte nicht geöffnet werden:\n{path}\n\n{error}": { + "de": "Der Pfad konnte nicht geöffnet werden:\n{path}\n\n{error}", + "en": "The path could not be opened:\n{path}\n\n{error}", + "es": "No se pudo abrir la ruta:\n{path}\n\n{error}", + "zh": "无法打开路径:\n{path}\n\n{error}", + "ja": "パスを開けませんでした:\n{path}\n\n{error}", + "ru": "Не удалось открыть путь:\n{path}\n\n{error}" + }, + "Desktop": { + "de": "Desktop", + "en": "Desktop", + "es": "Escritorio", + "zh": "桌面", + "ja": "デスクトップ", + "ru": "Рабочий стол" + }, + "Detaillierte Dateisuche mit Filtern für Suchbereich, Dateityp, Änderungsdatum, Dateigröße und Tags.": { + "de": "Detaillierte Dateisuche mit Filtern für Suchbereich, Dateityp, Änderungsdatum, Dateigröße und Tags.", + "en": "Detailed file search with filters for search scope, file type, modification date, file size and tags.", + "es": "Búsqueda detallada de archivos con filtros de ámbito de búsqueda, tipo de archivo, fecha de modificación, tamaño de archivo y etiquetas.", + "zh": "详细的文件搜索,可按搜索范围、文件类型、修改日期、文件大小和标签筛选。", + "ja": "検索範囲、ファイルの種類、更新日、ファイルサイズ、タグで絞り込める詳細なファイル検索です。", + "ru": "Подробный поиск файлов с фильтрами по области поиска, типу файла, дате изменения, размеру файла и тегам." + }, + "Deutsche SVN": { + "de": "Deutsche SVN", + "en": "German SVN (social insurance number)", + "es": "SVN alemán (número de la seguridad social)", + "zh": "德国社会保险号 (SVN)", + "ja": "ドイツ社会保険番号 (SVN)", + "ru": "Немецкий SVN (номер социального страхования)" + }, + "Deutsche/EU Kontonummern": { + "de": "Deutsche/EU Kontonummern", + "en": "German/EU account numbers", + "es": "Números de cuenta alemanes/de la UE", + "zh": "德国/欧盟账号", + "ja": "ドイツ/EUの口座番号", + "ru": "Немецкие/европейские номера счетов" + }, + "Dialog schließen": { + "de": "Dialog schließen", + "en": "Close Dialog", + "es": "Cerrar cuadro de diálogo", + "zh": "关闭对话框", + "ja": "ダイアログを閉じる", + "ru": "Закрыть диалог" + }, + "Dialog zur Konfiguration von Prompt-Titel, Kategorie, Tags und Vorlagentext mit Variablen.": { + "de": "Dialog zur Konfiguration von Prompt-Titel, Kategorie, Tags und Vorlagentext mit Variablen.", + "en": "Dialog for configuring the prompt title, category, tags and template text with variables.", + "es": "Cuadro de diálogo para configurar el título, la categoría, las etiquetas y el texto de plantilla con variables del prompt.", + "zh": "用于配置提示词标题、分类、标签以及带变量的模板文本的对话框。", + "ja": "プロンプトのタイトル、カテゴリ、タグ、変数を含むテンプレートテキストを設定するダイアログです。", + "ru": "Диалог для настройки названия, категории, тегов и текста шаблона с переменными для промпта." + }, + "Die Datei konnte nicht gelesen werden:": { + "de": "Die Datei konnte nicht gelesen werden:", + "en": "The file could not be read:", + "es": "No se pudo leer el archivo:", + "zh": "无法读取文件:", + "ja": "ファイルを読み取れませんでした:", + "ru": "Не удалось прочитать файл:" + }, + "Die Datei konnte nicht geschrieben werden:": { + "de": "Die Datei konnte nicht geschrieben werden:", + "en": "The file could not be written:", + "es": "No se pudo escribir el archivo:", + "zh": "无法写入文件:", + "ja": "ファイルを書き込めませんでした:", + "ru": "Не удалось записать файл:" + }, + "Die Datei konnte nicht geöffnet werden:": { + "de": "Die Datei konnte nicht geöffnet werden:", + "en": "The file could not be opened:", + "es": "No se pudo abrir el archivo:", + "zh": "无法打开文件:", + "ja": "ファイルを開けませんでした:", + "ru": "Не удалось открыть файл:" + }, + "Die Einstellungen werden in der Konfigurationsdatei des Benutzerprofils gespeichert.": { + "de": "Die Einstellungen werden in der Konfigurationsdatei des Benutzerprofils gespeichert.", + "en": "Settings are saved in the configuration file of the user profile.", + "es": "La configuración se guarda en el archivo de configuración del perfil de usuario.", + "zh": "设置将保存在用户配置文件的配置文件中。", + "ja": "設定はユーザープロファイルの設定ファイルに保存されます。", + "ru": "Настройки сохраняются в файле конфигурации профиля пользователя." + }, + "Die Liste kann über einen Klick auf die Datenschutz-Ampel bearbeitet werden.": { + "de": "Die Liste kann über einen Klick auf die Datenschutz-Ampel bearbeitet werden.", + "en": "The list can be edited by clicking the privacy indicator.", + "es": "La lista se puede editar haciendo clic en el indicador de privacidad.", + "zh": "单击隐私指示灯即可编辑此列表。", + "ja": "プライバシーインジケーターをクリックすると、この一覧を編集できます。", + "ru": "Список можно изменить, щёлкнув индикатор конфиденциальности." + }, + "Diese Datei ist nur online verfügbar.": { + "de": "Diese Datei ist nur online verfügbar.", + "en": "This file is only available online.", + "es": "Este archivo solo está disponible en línea.", + "zh": "此文件仅在线可用。", + "ja": "このファイルはオンラインでのみ利用できます。", + "ru": "Этот файл доступен только онлайн." + }, + "Diff in Zwischenablage kopieren": { + "de": "Diff in Zwischenablage kopieren", + "en": "Copy Diff to Clipboard", + "es": "Copiar diff al portapapeles", + "zh": "将差异复制到剪贴板", + "ja": "差分をクリップボードにコピー", + "ru": "Скопировать diff в буфер обмена" + }, + "Diff-Ergebnistabelle": { + "de": "Diff-Ergebnistabelle", + "en": "Diff results table", + "es": "Tabla de resultados del diff", + "zh": "差异结果表", + "ja": "差分結果の表", + "ru": "Таблица результатов сравнения" + }, + "Dokumente": { + "de": "Dokumente", + "en": "Documents", + "es": "Documentos", + "zh": "文档", + "ja": "ドキュメント", + "ru": "Документы" + }, + "Doppelklick bearbeitet einen Eintrag, Entf entfernt die Auswahl.": { + "de": "Doppelklick bearbeitet einen Eintrag, Entf entfernt die Auswahl.", + "en": "Double-click edits an entry, Delete removes the selection.", + "es": "Haga doble clic para editar una entrada; Supr elimina la selección.", + "zh": "双击可编辑条目,按 Delete 键可删除所选项。", + "ja": "ダブルクリックでエントリを編集し、Delete キーで選択項目を削除します。", + "ru": "Двойной щелчок изменяет запись, клавиша Delete удаляет выделенное." + }, + "Downloads": { + "de": "Downloads", + "en": "Downloads", + "es": "Descargas", + "zh": "下载", + "ja": "ダウンロード", + "ru": "Загрузки" + }, + "Drag & Drop": { + "de": "Drag & Drop", + "en": "Drag & Drop", + "es": "Arrastrar y soltar", + "zh": "拖放", + "ja": "ドラッグ&ドロップ", + "ru": "Перетаскивание" + }, + "Duplikate löschen": { + "de": "Duplikate löschen", + "en": "Delete Duplicates", + "es": "Eliminar duplicados", + "zh": "删除重复项", + "ja": "重複を削除", + "ru": "Удалить дубликаты" + }, + "Duplikate-Ergebnisliste": { + "de": "Duplikate-Ergebnisliste", + "en": "Duplicates results list", + "es": "Lista de resultados de duplicados", + "zh": "重复项结果列表", + "ja": "重複の検索結果一覧", + "ru": "Список результатов поиска дубликатов" + }, + "Duplikatsuche ist nicht verfügbar.": { + "de": "Duplikatsuche ist nicht verfügbar.", + "en": "Duplicate search is not available.", + "es": "La búsqueda de duplicados no está disponible.", + "zh": "重复项搜索不可用。", + "ja": "重複の検索は利用できません。", + "ru": "Поиск дубликатов недоступен." + }, + "E-Mail Adressen": { + "de": "E-Mail Adressen", + "en": "Email addresses", + "es": "Direcciones de correo electrónico", + "zh": "电子邮件地址", + "ja": "メールアドレス", + "ru": "Адреса электронной почты" + }, + "Editor öffnen": { + "de": "Editor öffnen", + "en": "Open Editor", + "es": "Abrir editor", + "zh": "打开编辑器", + "ja": "エディターを開く", + "ru": "Открыть редактор" + }, + "Ein Element namens '{name}' existiert bereits in diesem Verzeichnis.": { + "de": "Ein Element namens '{name}' existiert bereits in diesem Verzeichnis.", + "en": "An item named '{name}' already exists in this directory.", + "es": "Ya existe un elemento llamado '{name}' en este directorio.", + "zh": "此目录中已存在名为“{name}”的项目。", + "ja": "このディレクトリには '{name}' という名前の項目が既に存在します。", + "ru": "В этом каталоге уже существует элемент с именем '{name}'." + }, + "Ein Ordner oder eine Datei mit dem Namen '{name}' existiert bereits.": { + "de": "Ein Ordner oder eine Datei mit dem Namen '{name}' existiert bereits.", + "en": "A folder or file named '{name}' already exists.", + "es": "Ya existe una carpeta o un archivo llamado '{name}'.", + "zh": "已存在名为“{name}”的文件夹或文件。", + "ja": "'{name}' という名前のフォルダーまたはファイルが既に存在します。", + "ru": "Папка или файл с именем '{name}' уже существует." + }, + "Ein intelligenter Datei-Explorer mit:": { + "de": "Ein intelligenter Datei-Explorer mit:", + "en": "A smart file explorer with:", + "es": "Un explorador de archivos inteligente con:", + "zh": "智能文件资源管理器,具有:", + "ja": "次の機能を備えたスマートなファイルエクスプローラー:", + "ru": "Интеллектуальный проводник с функциями:" + }, + "Eindeutiger Name für dieses Synchronisationspaar": { + "de": "Eindeutiger Name für dieses Synchronisationspaar", + "en": "Unique name for this sync pair", + "es": "Nombre único para este par de sincronización", + "zh": "此同步对的唯一名称", + "ja": "この同期ペアの一意の名前", + "ru": "Уникальное имя для этой пары синхронизации" + }, + "Einfügen": { + "de": "Einfügen", + "en": "Paste", + "es": "Pegar", + "zh": "粘贴", + "ja": "貼り付け", + "ru": "Вставить" + }, + "Eingabefeld für den Vergleichs-Hash. Erkennt automatisch den passenden Prüfsummen-Algorithmus.": { + "de": "Eingabefeld für den Vergleichs-Hash. Erkennt automatisch den passenden Prüfsummen-Algorithmus.", + "en": "Input field for the comparison hash. Automatically detects the matching checksum algorithm.", + "es": "Campo de entrada para el hash de comparación. Detecta automáticamente el algoritmo de suma de comprobación adecuado.", + "zh": "用于输入比较哈希值的输入框。会自动识别匹配的校验和算法。", + "ja": "比較用ハッシュの入力欄です。適合するチェックサムアルゴリズムを自動的に判別します。", + "ru": "Поле ввода для хеша сравнения. Автоматически определяет подходящий алгоритм контрольной суммы." + }, + "Eingefügt": { + "de": "Eingefügt", + "en": "Pasted", + "es": "Pegado", + "zh": "已粘贴", + "ja": "貼り付け済み", + "ru": "Вставлено" + }, + "Einige Dateien konnten nicht übertragen werden:": { + "de": "Einige Dateien konnten nicht übertragen werden:", + "en": "Some files could not be transferred:", + "es": "No se pudieron transferir algunos archivos:", + "zh": "部分文件无法传输:", + "ja": "一部のファイルを転送できませんでした:", + "ru": "Не удалось передать некоторые файлы:" + }, + "Einstellungen gespeichert": { + "de": "Einstellungen gespeichert", + "en": "Settings saved", + "es": "Configuración guardada", + "zh": "设置已保存", + "ja": "設定を保存しました", + "ru": "Настройки сохранены" + }, + "Einstellungen speichern": { + "de": "Einstellungen speichern", + "en": "Save Settings", + "es": "Guardar configuración", + "zh": "保存设置", + "ja": "設定を保存", + "ru": "Сохранить настройки" + }, + "Einstellungen übernehmen": { + "de": "Einstellungen übernehmen", + "en": "Apply Settings", + "es": "Aplicar configuración", + "zh": "应用设置", + "ja": "設定を適用", + "ru": "Применить настройки" + }, + "Einstellungen...": { + "de": "Einstellungen...", + "en": "Settings...", + "es": "Configuración...", + "zh": "设置...", + "ja": "設定...", + "ru": "Настройки..." + }, + "Einstellungsbereiche": { + "de": "Einstellungsbereiche", + "en": "Settings sections", + "es": "Secciones de configuración", + "zh": "设置分区", + "ja": "設定のセクション", + "ru": "Разделы настроек" + }, + "Einträge: {list}": { + "de": "Einträge: {list}", + "en": "Entries: {list}", + "es": "Entradas: {list}", + "zh": "条目:{list}", + "ja": "エントリ:{list}", + "ru": "Записи: {list}" + }, + "Elementanzahl": { + "de": "Elementanzahl", + "en": "Item count", + "es": "Número de elementos", + "zh": "项目数", + "ja": "項目数", + "ru": "Количество элементов" + }, + "Entfernen": { + "de": "Entfernen", + "en": "Remove", + "es": "Quitar", + "zh": "移除", + "ja": "削除", + "ru": "Удалить" + }, + "Enthaltenden Ordner öffnen": { + "de": "Enthaltenden Ordner öffnen", + "en": "Open Containing Folder", + "es": "Abrir carpeta contenedora", + "zh": "打开所在文件夹", + "ja": "保存先のフォルダーを開く", + "ru": "Открыть содержащую папку" + }, + "Entwicklung": { + "de": "Entwicklung", + "en": "Development", + "es": "Desarrollo", + "zh": "开发", + "ja": "開発", + "ru": "Разработка" + }, + "Erfolgreich": { + "de": "Erfolgreich", + "en": "Successful", + "es": "Correcto", + "zh": "成功", + "ja": "成功", + "ru": "Успешно" + }, + "Ergebnis: {status}": { + "de": "Ergebnis: {status}", + "en": "Result: {status}", + "es": "Resultado: {status}", + "zh": "结果:{status}", + "ja": "結果:{status}", + "ru": "Результат: {status}" + }, + "Ergebnisse": { + "de": "Ergebnisse", + "en": "Results", + "es": "Resultados", + "zh": "结果", + "ja": "結果", + "ru": "Результаты" + }, + "Erkannt:": { + "de": "Erkannt:", + "en": "Detected:", + "es": "Detectado:", + "zh": "检测到:", + "ja": "検出:", + "ru": "Обнаружено:" + }, + "Erkannt: {items}": { + "de": "Erkannt: {items}", + "en": "Detected: {items}", + "es": "Detectado: {items}", + "zh": "检测到:{items}", + "ja": "検出:{items}", + "ru": "Обнаружено: {items}" + }, + "Erkannte Muster:": { + "de": "Erkannte Muster:", + "en": "Detected patterns:", + "es": "Patrones detectados:", + "zh": "检测到的模式:", + "ja": "検出されたパターン:", + "ru": "Обнаруженные шаблоны:" + }, + "Erkennung": { + "de": "Erkennung", + "en": "Detection", + "es": "Detección", + "zh": "检测", + "ja": "検出", + "ru": "Обнаружение" + }, + "Erkennungsmuster": { + "de": "Erkennungsmuster", + "en": "Detection patterns", + "es": "Patrones de detección", + "zh": "检测模式", + "ja": "検出パターン", + "ru": "Шаблоны обнаружения" + }, + "Erkennungsmuster, Blacklist und Whitelist werden im Dialog Datenschutz-Einstellungen gepflegt (auch per Klick auf die Ampel).": { + "de": "Erkennungsmuster, Blacklist und Whitelist werden im Dialog Datenschutz-Einstellungen gepflegt (auch per Klick auf die Ampel).", + "en": "Detection patterns, blacklist and whitelist are managed in the Privacy Settings dialog (also by clicking the indicator).", + "es": "Los patrones de detección, la lista negra y la lista blanca se gestionan en el cuadro de diálogo Configuración de privacidad (también haciendo clic en el indicador).", + "zh": "检测模式、黑名单和白名单在“隐私设置”对话框中管理(也可单击指示灯打开)。", + "ja": "検出パターン、ブラックリスト、ホワイトリストは「プライバシー設定」ダイアログで管理します(インジケーターのクリックでも開けます)。", + "ru": "Шаблоны обнаружения, чёрный и белый списки настраиваются в диалоге «Настройки конфиденциальности» (его также можно открыть щелчком по индикатору)." + }, + "Ersetzungstext": { + "de": "Ersetzungstext", + "en": "Replacement text", + "es": "Texto de reemplazo", + "zh": "替换文本", + "ja": "置換テキスト", + "ru": "Текст замены" + }, + "Erwartete Prüfsumme zur Verifikation": { + "de": "Erwartete Prüfsumme zur Verifikation", + "en": "Expected checksum for verification", + "es": "Suma de comprobación esperada para la verificación", + "zh": "用于验证的预期校验和", + "ja": "検証用の期待されるチェックサム", + "ru": "Ожидаемая контрольная сумма для проверки" + }, + "Erweiterte Dateisuche": { + "de": "Erweiterte Dateisuche", + "en": "Advanced File Search", + "es": "Búsqueda avanzada de archivos", + "zh": "高级文件搜索", + "ja": "詳細ファイル検索", + "ru": "Расширенный поиск файлов" + }, + "Erweiterte Suche öffnen": { + "de": "Erweiterte Suche öffnen", + "en": "Open Advanced Search", + "es": "Abrir búsqueda avanzada", + "zh": "打开高级搜索", + "ja": "詳細検索を開く", + "ru": "Открыть расширенный поиск" + }, + "Es gibt ungespeicherte Änderungen.": { + "de": "Es gibt ungespeicherte Änderungen.", + "en": "There are unsaved changes.", + "es": "Hay cambios sin guardar.", + "zh": "有未保存的更改。", + "ja": "保存されていない変更があります。", + "ru": "Есть несохранённые изменения." + }, + "Excel-Arbeitsblatt": { + "de": "Excel-Arbeitsblatt", + "en": "Excel worksheet", + "es": "Hoja de cálculo de Excel", + "zh": "Excel 工作表", + "ja": "Excel ワークシート", + "ru": "Лист Excel" + }, + "ExplorerPro": { + "de": "ExplorerPro", + "en": "ExplorerPro", + "es": "ExplorerPro", + "zh": "ExplorerPro", + "ja": "ExplorerPro", + "ru": "ExplorerPro" + }, + "Export fehlgeschlagen": { + "de": "Export fehlgeschlagen", + "en": "Export failed", + "es": "Error al exportar", + "zh": "导出失败", + "ja": "エクスポートに失敗しました", + "ru": "Не удалось экспортировать" + }, + "Exportieren": { + "de": "Exportieren", + "en": "Export", + "es": "Exportar", + "zh": "导出", + "ja": "エクスポート", + "ru": "Экспортировать" + }, + "Exportiert: {path}": { + "de": "Exportiert: {path}", + "en": "Exported: {path}", + "es": "Exportado: {path}", + "zh": "已导出:{path}", + "ja": "エクスポートしました:{path}", + "ru": "Экспортировано: {path}" + }, + "Externes Öffnen fehlgeschlagen: {error}": { + "de": "Externes Öffnen fehlgeschlagen: {error}", + "en": "External open failed: {error}", + "es": "Error al abrir externamente: {error}", + "zh": "外部打开失败:{error}", + "ja": "外部で開けませんでした:{error}", + "ru": "Не удалось открыть во внешней программе: {error}" + }, + "Farbschema": { + "de": "Farbschema", + "en": "Color scheme", + "es": "Combinación de colores", + "zh": "配色方案", + "ja": "配色", + "ru": "Цветовая схема" + }, + "Fehler beim Dateivergleich:\n{error}": { + "de": "Fehler beim Dateivergleich:\n{error}", + "en": "Error comparing files:\n{error}", + "es": "Error al comparar archivos:\n{error}", + "zh": "比较文件时出错:\n{error}", + "ja": "ファイルの比較中にエラーが発生しました:\n{error}", + "ru": "Ошибка при сравнении файлов:\n{error}" + }, + "Fehler beim Laden:\n{error}": { + "de": "Fehler beim Laden:\n{error}", + "en": "Error loading:\n{error}", + "es": "Error al cargar:\n{error}", + "zh": "加载时出错:\n{error}", + "ja": "読み込み中にエラーが発生しました:\n{error}", + "ru": "Ошибка при загрузке:\n{error}" + }, + "Fehler beim Laden: {error}": { + "de": "Fehler beim Laden: {error}", + "en": "Error loading: {error}", + "es": "Error al cargar: {error}", + "zh": "加载时出错:{error}", + "ja": "読み込み中にエラーが発生しました:{error}", + "ru": "Ошибка при загрузке: {error}" + }, + "Fehler beim Lesen: {error}": { + "de": "Fehler beim Lesen: {error}", + "en": "Error reading: {error}", + "es": "Error al leer: {error}", + "zh": "读取时出错:{error}", + "ja": "読み取り中にエラーが発生しました:{error}", + "ru": "Ошибка при чтении: {error}" + }, + "Fehler beim Löschen": { + "de": "Fehler beim Löschen", + "en": "Error deleting", + "es": "Error al eliminar", + "zh": "删除时出错", + "ja": "削除中にエラーが発生しました", + "ru": "Ошибка при удалении" + }, + "Fehler beim Speichern:\n{error}": { + "de": "Fehler beim Speichern:\n{error}", + "en": "Error saving:\n{error}", + "es": "Error al guardar:\n{error}", + "zh": "保存时出错:\n{error}", + "ja": "保存中にエラーが発生しました:\n{error}", + "ru": "Ошибка при сохранении:\n{error}" + }, + "Fehler: {error}": { + "de": "Fehler: {error}", + "en": "Error: {error}", + "es": "Error: {error}", + "zh": "错误:{error}", + "ja": "エラー:{error}", + "ru": "Ошибка: {error}" + }, + "Fehlgeschlagen": { + "de": "Fehlgeschlagen", + "en": "Failed", + "es": "Error", + "zh": "失败", + "ja": "失敗", + "ru": "Не удалось" + }, + "Fertig": { + "de": "Fertig", + "en": "Done", + "es": "Listo", + "zh": "完成", + "ja": "完了", + "ru": "Готово" + }, + "Filtert die Anwendungsliste nach Name.": { + "de": "Filtert die Anwendungsliste nach Name.", + "en": "Filters the application list by name.", + "es": "Filtra la lista de aplicaciones por nombre.", + "zh": "按名称筛选应用程序列表。", + "ja": "アプリケーション一覧を名前で絞り込みます。", + "ru": "Фильтрует список приложений по имени." + }, + "Filtert gespeicherte Prompts nach Titel, Kategorie oder Tags.": { + "de": "Filtert gespeicherte Prompts nach Titel, Kategorie oder Tags.", + "en": "Filters saved prompts by title, category or tags.", + "es": "Filtra los prompts guardados por título, categoría o etiquetas.", + "zh": "按标题、分类或标签筛选已保存的提示词。", + "ja": "保存済みのプロンプトをタイトル、カテゴリ、タグで絞り込みます。", + "ru": "Фильтрует сохранённые промпты по названию, категории или тегам." + }, + "Folgende Elemente konnten nicht gelöscht werden:": { + "de": "Folgende Elemente konnten nicht gelöscht werden:", + "en": "The following items could not be deleted:", + "es": "No se pudieron eliminar los siguientes elementos:", + "zh": "无法删除以下项目:", + "ja": "次の項目を削除できませんでした:", + "ru": "Не удалось удалить следующие элементы:" + }, + "Fortschritt der Prüfsummenberechnung in Prozent.": { + "de": "Fortschritt der Prüfsummenberechnung in Prozent.", + "en": "Progress of the checksum calculation in percent.", + "es": "Progreso del cálculo de la suma de comprobación en porcentaje.", + "zh": "校验和计算进度(百分比)。", + "ja": "チェックサム計算の進行状況(パーセント)です。", + "ru": "Ход вычисления контрольной суммы в процентах." + }, + "Fortschritt der Synchronisationsanalyse": { + "de": "Fortschritt der Synchronisationsanalyse", + "en": "Progress of the sync analysis", + "es": "Progreso del análisis de sincronización", + "zh": "同步分析进度", + "ja": "同期分析の進行状況", + "ru": "Ход анализа синхронизации" + }, + "Fusion aus: {tools}": { + "de": "Fusion aus: {tools}", + "en": "Merged from: {tools}", + "es": "Fusión de: {tools}", + "zh": "合并自:{tools}", + "ja": "統合元:{tools}", + "ru": "Объединено из: {tools}" + }, + "Fügt den aktuellen Ordnerpfad zur Favoritenliste hinzu.": { + "de": "Fügt den aktuellen Ordnerpfad zur Favoritenliste hinzu.", + "en": "Adds the current folder path to the favorites list.", + "es": "Agrega la ruta de la carpeta actual a la lista de favoritos.", + "zh": "将当前文件夹路径添加到收藏列表。", + "ja": "現在のフォルダーのパスをお気に入りの一覧に追加します。", + "ru": "Добавляет путь к текущей папке в список избранного." + }, + "Führt das aktuelle Skript im Unterprozess aus.": { + "de": "Führt das aktuelle Skript im Unterprozess aus.", + "en": "Runs the current script in a subprocess.", + "es": "Ejecuta el script actual en un subproceso.", + "zh": "在子进程中运行当前脚本。", + "ja": "現在のスクリプトをサブプロセスで実行します。", + "ru": "Запускает текущий скрипт в подпроцессе." + }, + "Gefundene Duplikate": { + "de": "Gefundene Duplikate", + "en": "Duplicates found", + "es": "Duplicados encontrados", + "zh": "找到的重复项", + "ja": "見つかった重複", + "ru": "Найденные дубликаты" + }, + "Gehe zu Zeile": { + "de": "Gehe zu Zeile", + "en": "Go to Line", + "es": "Ir a la línea", + "zh": "转到行", + "ja": "行へ移動", + "ru": "Перейти к строке" + }, + "Gelb – potenziell sensible Daten erkannt": { + "de": "Gelb – potenziell sensible Daten erkannt", + "en": "Yellow – potentially sensitive data detected", + "es": "Amarillo: se detectaron datos potencialmente confidenciales", + "zh": "黄色 – 检测到可能的敏感数据", + "ja": "黄 – 機密の可能性があるデータを検出", + "ru": "Жёлтый – обнаружены потенциально конфиденциальные данные" + }, + "Geplante Synchronisationsänderungen": { + "de": "Geplante Synchronisationsänderungen", + "en": "Scheduled sync changes", + "es": "Cambios de sincronización programados", + "zh": "计划的同步更改", + "ja": "予定されている同期の変更", + "ru": "Запланированные изменения синхронизации" + }, + "Geändert": { + "de": "Geändert", + "en": "Modified", + "es": "Modificado", + "zh": "已修改", + "ja": "更新日時", + "ru": "Изменено" + }, + "Geändert bis Datum": { + "de": "Geändert bis Datum", + "en": "Modified until date", + "es": "Modificado hasta la fecha", + "zh": "修改日期截止", + "ja": "更新日(終了)", + "ru": "Изменено до даты" + }, + "Geändert von Datum": { + "de": "Geändert von Datum", + "en": "Modified from date", + "es": "Modificado desde la fecha", + "zh": "修改日期起始", + "ja": "更新日(開始)", + "ru": "Изменено с даты" + }, + "Grafik": { + "de": "Grafik", + "en": "Image", + "es": "Imagen", + "zh": "图片", + "ja": "画像", + "ru": "Изображение" + }, + "Grau – Überwachung inaktiv": { + "de": "Grau – Überwachung inaktiv", + "en": "Gray – monitoring inactive", + "es": "Gris: supervisión inactiva", + "zh": "灰色 – 监控未启用", + "ja": "グレー – 監視は無効", + "ru": "Серый – мониторинг неактивен" + }, + "Groß- und Kleinschreibung beachten": { + "de": "Groß- und Kleinschreibung beachten", + "en": "Case sensitive", + "es": "Distinguir mayúsculas y minúsculas", + "zh": "区分大小写", + "ja": "大文字と小文字を区別", + "ru": "Учитывать регистр" + }, + "Grund": { + "de": "Grund", + "en": "Reason", + "es": "Motivo", + "zh": "原因", + "ja": "理由", + "ru": "Причина" + }, + "Größe": { + "de": "Größe", + "en": "Size", + "es": "Tamaño", + "zh": "大小", + "ja": "サイズ", + "ru": "Размер" + }, + "Größe: {size}": { + "de": "Größe: {size}", + "en": "Size: {size}", + "es": "Tamaño: {size}", + "zh": "大小:{size}", + "ja": "サイズ:{size}", + "ru": "Размер: {size}" + }, + "Größere Datei gewinnt": { + "de": "Größere Datei gewinnt", + "en": "Larger file wins", + "es": "Gana el archivo más grande", + "zh": "保留较大的文件", + "ja": "サイズの大きいファイルを優先", + "ru": "Побеждает файл большего размера" + }, + "Grün": { + "de": "Grün", + "en": "Green", + "es": "Verde", + "zh": "绿色", + "ja": "緑", + "ru": "Зелёный" + }, + "Grün – keine sensiblen Daten erkannt": { + "de": "Grün – keine sensiblen Daten erkannt", + "en": "Green – no sensitive data detected", + "es": "Verde: no se detectaron datos confidenciales", + "zh": "绿色 – 未检测到敏感数据", + "ja": "緑 – 機密データは検出されませんでした", + "ru": "Зелёный – конфиденциальные данные не обнаружены" + }, + "Hierarchische Struktur aller lokalen Laufwerke und Schnellzugriff-Verzeichnisse.": { + "de": "Hierarchische Struktur aller lokalen Laufwerke und Schnellzugriff-Verzeichnisse.", + "en": "Hierarchical structure of all local drives and quick access directories.", + "es": "Estructura jerárquica de todas las unidades locales y los directorios de acceso rápido.", + "zh": "所有本地驱动器和快速访问目录的层级结构。", + "ja": "すべてのローカルドライブとクイックアクセスのディレクトリの階層構造です。", + "ru": "Иерархическая структура всех локальных дисков и каталогов быстрого доступа." + }, + "Hinweis": { + "de": "Hinweis", + "en": "Note", + "es": "Nota", + "zh": "提示", + "ja": "メモ", + "ru": "Примечание" + }, + "Hinweis: {count} Muster erkannt": { + "de": "Hinweis: {count} Muster erkannt", + "en": "Note: {count} patterns detected", + "es": "Nota: {count} patrones detectados", + "zh": "提示:检测到 {count} 个模式", + "ja": "メモ:{count} 件のパターンを検出", + "ru": "Примечание: обнаружено шаблонов: {count}" + }, + "Hinzufügen": { + "de": "Hinzufügen", + "en": "Add", + "es": "Agregar", + "zh": "添加", + "ja": "追加", + "ru": "Добавить" + }, + "Home": { + "de": "Home", + "en": "Home", + "es": "Inicio", + "zh": "主页", + "ja": "ホーム", + "ru": "Домашняя папка" + }, + "Im Dateiinhalt suchen": { + "de": "Im Dateiinhalt suchen", + "en": "Search in file content", + "es": "Buscar en el contenido del archivo", + "zh": "在文件内容中搜索", + "ja": "ファイルの内容を検索", + "ru": "Искать в содержимом файла" + }, + "Im Dateinamen suchen": { + "de": "Im Dateinamen suchen", + "en": "Search in file name", + "es": "Buscar en el nombre del archivo", + "zh": "在文件名中搜索", + "ja": "ファイル名を検索", + "ru": "Искать в имени файла" + }, + "Im Pfad suchen": { + "de": "Im Pfad suchen", + "en": "Search in path", + "es": "Buscar en la ruta", + "zh": "在路径中搜索", + "ja": "パスを検索", + "ru": "Искать в пути" + }, + "Import abgeschlossen": { + "de": "Import abgeschlossen", + "en": "Import completed", + "es": "Importación completada", + "zh": "导入完成", + "ja": "インポートが完了しました", + "ru": "Импорт завершён" + }, + "Import fehlgeschlagen": { + "de": "Import fehlgeschlagen", + "en": "Import failed", + "es": "Error al importar", + "zh": "导入失败", + "ja": "インポートに失敗しました", + "ru": "Не удалось импортировать" + }, + "Importieren": { + "de": "Importieren", + "en": "Import", + "es": "Importar", + "zh": "导入", + "ja": "インポート", + "ru": "Импортировать" + }, + "In Datei exportieren: {list}": { + "de": "In Datei exportieren: {list}", + "en": "Export to file: {list}", + "es": "Exportar a archivo: {list}", + "zh": "导出到文件:{list}", + "ja": "ファイルにエクスポート:{list}", + "ru": "Экспортировать в файл: {list}" + }, + "In Editor öffnen": { + "de": "In Editor öffnen", + "en": "Open in Editor", + "es": "Abrir en el editor", + "zh": "在编辑器中打开", + "ja": "エディターで開く", + "ru": "Открыть в редакторе" + }, + "In Index suchen": { + "de": "In Index suchen", + "en": "Search in index", + "es": "Buscar en el índice", + "zh": "在索引中搜索", + "ja": "インデックス内を検索", + "ru": "Искать в индексе" + }, + "In die Zwischenablage kopiert": { + "de": "In die Zwischenablage kopiert", + "en": "Copied to clipboard", + "es": "Copiado al portapapeles", + "zh": "已复制到剪贴板", + "ja": "クリップボードにコピーしました", + "ru": "Скопировано в буфер обмена" + }, + "In externer Anwendung öffnen": { + "de": "In externer Anwendung öffnen", + "en": "Open in external application", + "es": "Abrir en una aplicación externa", + "zh": "在外部应用程序中打开", + "ja": "外部アプリケーションで開く", + "ru": "Открыть во внешнем приложении" + }, + "Indiziere: {current}/{total}": { + "de": "Indiziere: {current}/{total}", + "en": "Indexing: {current}/{total}", + "es": "Indexando: {current}/{total}", + "zh": "正在索引:{current}/{total}", + "ja": "インデックス作成中: {current}/{total}", + "ru": "Индексирование: {current}/{total}" + }, + "Indizierung ist nicht verfügbar.": { + "de": "Indizierung ist nicht verfügbar.", + "en": "Indexing is not available.", + "es": "La indexación no está disponible.", + "zh": "索引功能不可用。", + "ja": "インデックス作成は利用できません。", + "ru": "Индексирование недоступно." + }, + "Integrierter Code-Bearbeitung": { + "de": "Integrierter Code-Bearbeitung", + "en": "Integrated code editing", + "es": "Edición de código integrada", + "zh": "集成代码编辑", + "ja": "統合コード編集", + "ru": "Встроенное редактирование кода" + }, + "Internet": { + "de": "Internet", + "en": "Internet", + "es": "Internet", + "zh": "互联网", + "ja": "インターネット", + "ru": "Интернет" + }, + "JSON-Dateien (*.json)": { + "de": "JSON-Dateien (*.json)", + "en": "JSON files (*.json)", + "es": "Archivos JSON (*.json)", + "zh": "JSON 文件 (*.json)", + "ja": "JSON ファイル (*.json)", + "ru": "Файлы JSON (*.json)" + }, + "Kategorie": { + "de": "Kategorie", + "en": "Category", + "es": "Categoría", + "zh": "类别", + "ja": "カテゴリ", + "ru": "Категория" + }, + "Kategorie zur Gruppierung im Schnellstarter.": { + "de": "Kategorie zur Gruppierung im Schnellstarter.", + "en": "Category used for grouping in the quick launcher.", + "es": "Categoría para agrupar en el inicio rápido.", + "zh": "用于在快速启动器中分组的类别。", + "ja": "クイックランチャーでグループ化するためのカテゴリです。", + "ru": "Категория для группировки в быстром запуске." + }, + "Kein Index verfügbar!": { + "de": "Kein Index verfügbar!", + "en": "No index available!", + "es": "¡No hay índice disponible!", + "zh": "无可用索引!", + "ja": "インデックスが利用できません!", + "ru": "Индекс недоступен!" + }, + "Keine Ausführung für {ext} unterstützt": { + "de": "Keine Ausführung für {ext} unterstützt", + "en": "Running {ext} files is not supported", + "es": "No se admite la ejecución de {ext}", + "zh": "不支持运行 {ext}", + "ja": "{ext} の実行はサポートされていません", + "ru": "Запуск файлов {ext} не поддерживается" + }, + "Keine Dateien in der Zwischenablage": { + "de": "Keine Dateien in der Zwischenablage", + "en": "No files in clipboard", + "es": "No hay archivos en el portapapeles", + "zh": "剪贴板中没有文件", + "ja": "クリップボードにファイルがありません", + "ru": "В буфере обмена нет файлов" + }, + "Keine Grenze": { + "de": "Keine Grenze", + "en": "No limit", + "es": "Sin límite", + "zh": "无限制", + "ja": "制限なし", + "ru": "Без ограничений" + }, + "Keine Treffer für: {query}": { + "de": "Keine Treffer für: {query}", + "en": "No results for: {query}", + "es": "Sin resultados para: {query}", + "zh": "没有找到:{query}", + "ja": "「{query}」に一致する結果はありません", + "ru": "Нет результатов для: {query}" + }, + "Keine sensiblen Daten erkannt": { + "de": "Keine sensiblen Daten erkannt", + "en": "No sensitive data detected", + "es": "No se detectaron datos confidenciales", + "zh": "未检测到敏感数据", + "ja": "機密データは検出されませんでした", + "ru": "Конфиденциальные данные не обнаружены" + }, + "Keine sensiblen Daten erkannt.": { + "de": "Keine sensiblen Daten erkannt.", + "en": "No sensitive data detected.", + "es": "No se detectaron datos confidenciales.", + "zh": "未检测到敏感数据。", + "ja": "機密データは検出されませんでした。", + "ru": "Конфиденциальные данные не обнаружены." + }, + "Klicken: Datenschutz-Einstellungen · Rechtsklick: Schnellmenü": { + "de": "Klicken: Datenschutz-Einstellungen · Rechtsklick: Schnellmenü", + "en": "Click: Privacy Settings · Right-click: Quick menu", + "es": "Clic: configuración de privacidad · Clic derecho: menú rápido", + "zh": "单击:隐私设置 · 右键单击:快捷菜单", + "ja": "クリック: プライバシー設定 · 右クリック: クイックメニュー", + "ru": "Щелчок: настройки конфиденциальности · Правый щелчок: быстрое меню" + }, + "Kommagetrennte Muster für Dateien und Ordner, die nicht synchronisiert werden": { + "de": "Kommagetrennte Muster für Dateien und Ordner, die nicht synchronisiert werden", + "en": "Comma-separated patterns for files and folders that are not synchronized", + "es": "Patrones separados por comas para archivos y carpetas que no se sincronizarán", + "zh": "以逗号分隔的不同步文件和文件夹模式", + "ja": "同期しないファイルとフォルダーのパターン(カンマ区切り)", + "ru": "Шаблоны файлов и папок через запятую, которые не синхронизируются" + }, + "Kommandozeilenparameter": { + "de": "Kommandozeilenparameter", + "en": "Command-line parameters", + "es": "Parámetros de línea de comandos", + "zh": "命令行参数", + "ja": "コマンドラインパラメーター", + "ru": "Параметры командной строки" + }, + "Konfiguration von Erkennungsmustern, Blacklist, Whitelist und automatischer Bereinigung.": { + "de": "Konfiguration von Erkennungsmustern, Blacklist, Whitelist und automatischer Bereinigung.", + "en": "Configuration of detection patterns, blacklist, whitelist and automatic cleanup.", + "es": "Configuración de patrones de detección, lista negra (Blacklist), lista blanca (Whitelist) y limpieza automática.", + "zh": "配置检测模式、Blacklist、Whitelist 和自动清理。", + "ja": "検出パターン、Blacklist、Whitelist、自動クリーンアップの設定です。", + "ru": "Настройка шаблонов обнаружения, Blacklist, Whitelist и автоматической очистки." + }, + "Konfigurationsdialog für Name, Programmpfad, Kategorie, Beschreibung und Startargumente.": { + "de": "Konfigurationsdialog für Name, Programmpfad, Kategorie, Beschreibung und Startargumente.", + "en": "Configuration dialog for name, program path, category, description and launch arguments.", + "es": "Cuadro de diálogo de configuración para el nombre, la ruta del programa, la categoría, la descripción y los argumentos de inicio.", + "zh": "用于设置名称、程序路径、类别、描述和启动参数的配置对话框。", + "ja": "名前、プログラムのパス、カテゴリ、説明、起動引数を設定するダイアログです。", + "ru": "Диалог настройки имени, пути к программе, категории, описания и аргументов запуска." + }, + "Konfliktlösung": { + "de": "Konfliktlösung", + "en": "Conflict resolution", + "es": "Resolución de conflictos", + "zh": "冲突解决", + "ja": "競合の解決", + "ru": "Разрешение конфликтов" + }, + "Konnte Datei nicht erstellen:": { + "de": "Konnte Datei nicht erstellen:", + "en": "Could not create file:", + "es": "No se pudo crear el archivo:", + "zh": "无法创建文件:", + "ja": "ファイルを作成できませんでした:", + "ru": "Не удалось создать файл:" + }, + "Konnte Datei nicht prüfen: {error}": { + "de": "Konnte Datei nicht prüfen: {error}", + "en": "Could not check file: {error}", + "es": "No se pudo comprobar el archivo: {error}", + "zh": "无法检查文件:{error}", + "ja": "ファイルを確認できませんでした: {error}", + "ru": "Не удалось проверить файл: {error}" + }, + "Konnte Element nicht umbenennen:": { + "de": "Konnte Element nicht umbenennen:", + "en": "Could not rename item:", + "es": "No se pudo cambiar el nombre del elemento:", + "zh": "无法重命名项目:", + "ja": "項目の名前を変更できませんでした:", + "ru": "Не удалось переименовать элемент:" + }, + "Konnte Ordner nicht erstellen:": { + "de": "Konnte Ordner nicht erstellen:", + "en": "Could not create folder:", + "es": "No se pudo crear la carpeta:", + "zh": "无法创建文件夹:", + "ja": "フォルダーを作成できませんでした:", + "ru": "Не удалось создать папку:" + }, + "Konnte Ordner nicht öffnen:\n{error}": { + "de": "Konnte Ordner nicht öffnen:\n{error}", + "en": "Could not open folder:\n{error}", + "es": "No se pudo abrir la carpeta:\n{error}", + "zh": "无法打开文件夹:\n{error}", + "ja": "フォルダーを開けませんでした:\n{error}", + "ru": "Не удалось открыть папку:\n{error}" + }, + "Konnte Terminal nicht öffnen:": { + "de": "Konnte Terminal nicht öffnen:", + "en": "Could not open terminal:", + "es": "No se pudo abrir el terminal:", + "zh": "无法打开终端:", + "ja": "ターミナルを開けませんでした:", + "ru": "Не удалось открыть терминал:" + }, + "Konnte nicht starten:\n{error}": { + "de": "Konnte nicht starten:\n{error}", + "en": "Could not start:\n{error}", + "es": "No se pudo iniciar:\n{error}", + "zh": "无法启动:\n{error}", + "ja": "起動できませんでした:\n{error}", + "ru": "Не удалось запустить:\n{error}" + }, + "Kopiert alle berechneten Hash-Werte strukturiert in die Zwischenablage.": { + "de": "Kopiert alle berechneten Hash-Werte strukturiert in die Zwischenablage.", + "en": "Copies all calculated hash values to the clipboard in a structured format.", + "es": "Copia todos los valores hash calculados al portapapeles de forma estructurada.", + "zh": "将所有已计算的哈希值以结构化格式复制到剪贴板。", + "ja": "計算したすべてのハッシュ値を構造化してクリップボードにコピーします。", + "ru": "Копирует все вычисленные хеш-значения в буфер обмена в структурированном виде." + }, + "Kopiert den Prompt in die Zwischenablage": { + "de": "Kopiert den Prompt in die Zwischenablage", + "en": "Copies the prompt to the clipboard", + "es": "Copia el prompt al portapapeles", + "zh": "将 Prompt 复制到剪贴板", + "ja": "プロンプトをクリップボードにコピーします", + "ru": "Копирует промпт в буфер обмена" + }, + "Kopiert den Unified Diff in die Zwischenablage": { + "de": "Kopiert den Unified Diff in die Zwischenablage", + "en": "Copies the unified diff to the clipboard", + "es": "Copia el unified diff al portapapeles", + "zh": "将统一 Diff 复制到剪贴板", + "ja": "Unified Diff をクリップボードにコピーします", + "ru": "Копирует unified diff в буфер обмена" + }, + "Kopiert die berechnete {algorithm}-Prüfsumme in die Zwischenablage.": { + "de": "Kopiert die berechnete {algorithm}-Prüfsumme in die Zwischenablage.", + "en": "Copies the calculated {algorithm} checksum to the clipboard.", + "es": "Copia la suma de comprobación {algorithm} calculada al portapapeles.", + "zh": "将已计算的 {algorithm} 校验和复制到剪贴板。", + "ja": "計算した {algorithm} チェックサムをクリップボードにコピーします。", + "ru": "Копирует вычисленную контрольную сумму {algorithm} в буфер обмена." + }, + "Laufwerke": { + "de": "Laufwerke", + "en": "Drives", + "es": "Unidades", + "zh": "驱动器", + "ja": "ドライブ", + "ru": "Диски" + }, + "Leer = Benutzerordner": { + "de": "Leer = Benutzerordner", + "en": "Empty = user folder", + "es": "Vacío = carpeta de usuario", + "zh": "留空 = 用户文件夹", + "ja": "空欄 = ユーザーフォルダー", + "ru": "Пусто = папка пользователя" + }, + "Leert das Suchfeld und entfernt die aktuellen Suchergebnisse.": { + "de": "Leert das Suchfeld und entfernt die aktuellen Suchergebnisse.", + "en": "Clears the search field and removes the current search results.", + "es": "Borra el campo de búsqueda y elimina los resultados de búsqueda actuales.", + "zh": "清空搜索框并移除当前搜索结果。", + "ja": "検索ボックスを空にし、現在の検索結果を消去します。", + "ru": "Очищает поле поиска и удаляет текущие результаты поиска." + }, + "Leert die Zwischenablage, sobald die Ampel auf Rot springt.": { + "de": "Leert die Zwischenablage, sobald die Ampel auf Rot springt.", + "en": "Clears the clipboard as soon as the traffic light turns red.", + "es": "Vacía el portapapeles en cuanto el semáforo se pone en rojo.", + "zh": "一旦指示灯变为红色,就清空剪贴板。", + "ja": "信号が赤に変わると同時にクリップボードを空にします。", + "ru": "Очищает буфер обмена, как только светофор переключается на красный." + }, + "Legt fest, ob Dateien von Quelle zu Ziel, von Ziel zu Quelle oder in beide Richtungen übertragen werden": { + "de": "Legt fest, ob Dateien von Quelle zu Ziel, von Ziel zu Quelle oder in beide Richtungen übertragen werden", + "en": "Determines whether files are transferred from source to target, from target to source, or in both directions", + "es": "Define si los archivos se transfieren del origen al destino, del destino al origen o en ambas direcciones", + "zh": "决定文件是从源传输到目标、从目标传输到源,还是双向传输", + "ja": "ファイルを、ソースからターゲット、ターゲットからソース、または双方向のどれで転送するかを指定します", + "ru": "Определяет, передаются ли файлы из источника в цель, из цели в источник или в обоих направлениях" + }, + "Legt fest, ob alle Dateien oder nur ausgewählte Kategorien in den Suchergebnissen erscheinen.": { + "de": "Legt fest, ob alle Dateien oder nur ausgewählte Kategorien in den Suchergebnissen erscheinen.", + "en": "Determines whether all files or only selected categories appear in the search results.", + "es": "Define si en los resultados de búsqueda aparecen todos los archivos o solo las categorías seleccionadas.", + "zh": "决定搜索结果中显示所有文件还是仅显示所选类别。", + "ja": "検索結果にすべてのファイルを表示するか、選択したカテゴリのみを表示するかを指定します。", + "ru": "Определяет, будут ли в результатах поиска отображаться все файлы или только выбранные категории." + }, + "Legt fest, welche Datei bei unterschiedlichen Versionen behalten wird": { + "de": "Legt fest, welche Datei bei unterschiedlichen Versionen behalten wird", + "en": "Determines which file is kept when versions differ", + "es": "Define qué archivo se conserva cuando las versiones son distintas", + "zh": "决定版本不同时保留哪个文件", + "ja": "バージョンが異なる場合にどちらのファイルを残すかを指定します", + "ru": "Определяет, какой файл сохраняется при различии версий" + }, + "Letzte 30 Tage": { + "de": "Letzte 30 Tage", + "en": "Last 30 days", + "es": "Últimos 30 días", + "zh": "最近 30 天", + "ja": "過去 30 日間", + "ru": "Последние 30 дней" + }, + "Letzte 7 Tage": { + "de": "Letzte 7 Tage", + "en": "Last 7 days", + "es": "Últimos 7 días", + "zh": "最近 7 天", + "ja": "過去 7 日間", + "ru": "Последние 7 дней" + }, + "Letzte Umbenennung rückgängig machen": { + "de": "Letzte Umbenennung rückgängig machen", + "en": "Undo last rename", + "es": "Deshacer el último cambio de nombre", + "zh": "撤销上次重命名", + "ja": "最後の名前変更を元に戻す", + "ru": "Отменить последнее переименование" + }, + "Letztes Jahr": { + "de": "Letztes Jahr", + "en": "Last year", + "es": "Último año", + "zh": "最近一年", + "ja": "過去 1 年間", + "ru": "Последний год" + }, + "Liste aller gespeicherten Prompts der ausgewählten Kategorie.": { + "de": "Liste aller gespeicherten Prompts der ausgewählten Kategorie.", + "en": "List of all saved prompts in the selected category.", + "es": "Lista de todos los prompts guardados de la categoría seleccionada.", + "zh": "所选类别中所有已保存 Prompt 的列表。", + "ja": "選択したカテゴリに保存されているすべてのプロンプトの一覧です。", + "ru": "Список всех сохранённых промптов выбранной категории." + }, + "Liste der gefundenen Dateien. Mit den Pfeiltasten auswählen und mit Enter oder Doppelklick öffnen.": { + "de": "Liste der gefundenen Dateien. Mit den Pfeiltasten auswählen und mit Enter oder Doppelklick öffnen.", + "en": "List of found files. Select with the arrow keys and open with Enter or double-click.", + "es": "Lista de archivos encontrados. Selecciónelos con las teclas de flecha y ábralos con Intro o doble clic.", + "zh": "找到的文件列表。使用方向键选择,按 Enter 键或双击打开。", + "ja": "見つかったファイルの一覧です。矢印キーで選択し、Enter キーまたはダブルクリックで開きます。", + "ru": "Список найденных файлов. Выбирайте клавишами со стрелками и открывайте клавишей Enter или двойным щелчком." + }, + "Liste der gespeicherten Schnellzugriff-Favoriten.": { + "de": "Liste der gespeicherten Schnellzugriff-Favoriten.", + "en": "List of saved quick-access favorites.", + "es": "Lista de los favoritos de acceso rápido guardados.", + "zh": "已保存的快速访问收藏夹列表。", + "ja": "保存されているクイックアクセスのお気に入りの一覧です。", + "ru": "Список сохранённых избранных элементов быстрого доступа." + }, + "Liste der gespeicherten Synchronisationspaare; mit Doppelklick wird eine Vorschau geöffnet": { + "de": "Liste der gespeicherten Synchronisationspaare; mit Doppelklick wird eine Vorschau geöffnet", + "en": "List of saved sync pairs; double-click to open a preview", + "es": "Lista de pares de sincronización guardados; haga doble clic para abrir una vista previa", + "zh": "已保存的同步对列表;双击可打开预览", + "ja": "保存されている同期ペアの一覧です。ダブルクリックでプレビューを開きます", + "ru": "Список сохранённых пар синхронизации; двойной щелчок открывает предпросмотр" + }, + "Liste filtern": { + "de": "Liste filtern", + "en": "Filter list", + "es": "Filtrar lista", + "zh": "筛选列表", + "ja": "リストをフィルター", + "ru": "Фильтровать список" + }, + "Liste filtern: {list}": { + "de": "Liste filtern: {list}", + "en": "Filter list: {list}", + "es": "Filtrar lista: {list}", + "zh": "筛选列表:{list}", + "ja": "リストをフィルター: {list}", + "ru": "Фильтровать список: {list}" + }, + "Liste gefundener Dateien mit Pfad, Typ, Größe und Datum.": { + "de": "Liste gefundener Dateien mit Pfad, Typ, Größe und Datum.", + "en": "List of found files with path, type, size and date.", + "es": "Lista de archivos encontrados con ruta, tipo, tamaño y fecha.", + "zh": "找到的文件列表,包含路径、类型、大小和日期。", + "ja": "見つかったファイルのパス、種類、サイズ、日付を表示する一覧です。", + "ru": "Список найденных файлов с путём, типом, размером и датой." + }, + "Liste leeren": { + "de": "Liste leeren", + "en": "Clear list", + "es": "Vaciar lista", + "zh": "清空列表", + "ja": "リストを空にする", + "ru": "Очистить список" + }, + "Liste vollständig leeren: {list}": { + "de": "Liste vollständig leeren: {list}", + "en": "Clear entire list: {list}", + "es": "Vaciar la lista por completo: {list}", + "zh": "完全清空列表:{list}", + "ja": "リストをすべて空にする: {list}", + "ru": "Полностью очистить список: {list}" + }, + "Löschen bestätigen": { + "de": "Löschen bestätigen", + "en": "Confirm deletion", + "es": "Confirmar eliminación", + "zh": "确认删除", + "ja": "削除の確認", + "ru": "Подтверждение удаления" + }, + "Löscht alle markierten Duplikat-Dateien unwiderruflich von der Festplatte.": { + "de": "Löscht alle markierten Duplikat-Dateien unwiderruflich von der Festplatte.", + "en": "Permanently deletes all marked duplicate files from the disk.", + "es": "Elimina de forma irreversible del disco todos los archivos duplicados marcados.", + "zh": "从磁盘上永久删除所有已标记的重复文件。", + "ja": "マークされたすべての重複ファイルをディスクから完全に削除します。", + "ru": "Безвозвратно удаляет с диска все отмеченные файлы-дубликаты." + }, + "Maximale Dateigröße für den Index": { + "de": "Maximale Dateigröße für den Index", + "en": "Maximum file size for the index", + "es": "Tamaño máximo de archivo para el índice", + "zh": "索引的最大文件大小", + "ja": "インデックス対象の最大ファイルサイズ", + "ru": "Максимальный размер файла для индекса" + }, + "Maximale Dateigröße für die Vorschau": { + "de": "Maximale Dateigröße für die Vorschau", + "en": "Maximum file size for preview", + "es": "Tamaño máximo de archivo para la vista previa", + "zh": "预览的最大文件大小", + "ja": "プレビュー対象の最大ファイルサイズ", + "ru": "Максимальный размер файла для предпросмотра" + }, + "Maximale Dateigröße in Kilobyte": { + "de": "Maximale Dateigröße in Kilobyte", + "en": "Maximum file size in kilobytes", + "es": "Tamaño máximo de archivo en kilobytes", + "zh": "最大文件大小(KB)", + "ja": "最大ファイルサイズ(キロバイト)", + "ru": "Максимальный размер файла в килобайтах" + }, + "Mehrere Begriffe können durch Zeilenumbruch oder Semikolon getrennt werden.": { + "de": "Mehrere Begriffe können durch Zeilenumbruch oder Semikolon getrennt werden.", + "en": "Multiple terms can be separated by a line break or semicolon.", + "es": "Se pueden separar varios términos con un salto de línea o un punto y coma.", + "zh": "多个词条可以用换行或分号分隔。", + "ja": "複数の語句は、改行またはセミコロンで区切ることができます。", + "ru": "Несколько терминов можно разделять переводом строки или точкой с запятой." + }, + "Mehrfachumbenennung": { + "de": "Mehrfachumbenennung", + "en": "Batch rename", + "es": "Cambio de nombre múltiple", + "zh": "批量重命名", + "ja": "一括名前変更", + "ru": "Пакетное переименование" + }, + "Mehrzeiliger Quelltext-Editor mit Zeilennummern und Syntax-Hervorhebung.": { + "de": "Mehrzeiliger Quelltext-Editor mit Zeilennummern und Syntax-Hervorhebung.", + "en": "Multi-line source code editor with line numbers and syntax highlighting.", + "es": "Editor de código fuente de varias líneas con números de línea y resaltado de sintaxis.", + "zh": "带行号和语法高亮的多行源代码编辑器。", + "ja": "行番号とシンタックスハイライト付きの複数行ソースコードエディターです。", + "ru": "Многострочный редактор исходного кода с номерами строк и подсветкой синтаксиса." + }, + "Metadaten anzeigen": { + "de": "Metadaten anzeigen", + "en": "Show metadata", + "es": "Mostrar metadatos", + "zh": "显示元数据", + "ja": "メタデータを表示", + "ru": "Показать метаданные" + }, + "Metadaten-Tags": { + "de": "Metadaten-Tags", + "en": "Metadata tags", + "es": "Etiquetas de metadatos", + "zh": "元数据标签", + "ja": "メタデータタグ", + "ru": "Теги метаданных" + }, + "Mindeststellenanzahl": { + "de": "Mindeststellenanzahl", + "en": "Minimum number of digits", + "es": "Número mínimo de dígitos", + "zh": "最少位数", + "ja": "最小桁数", + "ru": "Минимальное количество разрядов" + }, + "Minimale Dateigröße": { + "de": "Minimale Dateigröße", + "en": "Minimum file size", + "es": "Tamaño mínimo de archivo", + "zh": "最小文件大小", + "ja": "最小ファイルサイズ", + "ru": "Минимальный размер файла" + }, + "Multimedia": { + "de": "Multimedia", + "en": "Multimedia", + "es": "Multimedia", + "zh": "多媒体", + "ja": "マルチメディア", + "ru": "Мультимедиа" + }, + "Musik": { + "de": "Musik", + "en": "Music", + "es": "Música", + "zh": "音乐", + "ja": "ミュージック", + "ru": "Музыка" + }, + "Möchten Sie '{name}' wirklich unwiderruflich löschen?": { + "de": "Möchten Sie '{name}' wirklich unwiderruflich löschen?", + "en": "Do you really want to permanently delete '{name}'?", + "es": "¿Seguro que desea eliminar '{name}' de forma permanente?", + "zh": "确定要永久删除'{name}'吗?", + "ja": "'{name}' を完全に削除しますか?", + "ru": "Вы действительно хотите безвозвратно удалить '{name}'?" + }, + "Möchten Sie die Änderungen speichern?": { + "de": "Möchten Sie die Änderungen speichern?", + "en": "Do you want to save the changes?", + "es": "¿Desea guardar los cambios?", + "zh": "要保存更改吗?", + "ja": "変更を保存しますか?", + "ru": "Сохранить изменения?" + }, + "Möchten Sie diese {count} Elemente wirklich unwiderruflich löschen?": { + "de": "Möchten Sie diese {count} Elemente wirklich unwiderruflich löschen?", + "en": "Do you really want to permanently delete these {count} items?", + "es": "¿Seguro que desea eliminar de forma permanente estos {count} elementos?", + "zh": "确定要永久删除这 {count} 个项目吗?", + "ja": "これらの {count} 個の項目を完全に削除しますか?", + "ru": "Вы действительно хотите безвозвратно удалить эти элементы ({count})?" + }, + "Möchten Sie {count} Dateien löschen?\n\nGesamtgröße: {size}\n\nDiese Aktion kann nicht rückgängig gemacht werden!": { + "de": "Möchten Sie {count} Dateien löschen?\n\nGesamtgröße: {size}\n\nDiese Aktion kann nicht rückgängig gemacht werden!", + "en": "Do you want to delete {count} files?\n\nTotal size: {size}\n\nThis action cannot be undone!", + "es": "¿Desea eliminar {count} archivos?\n\nTamaño total: {size}\n\n¡Esta acción no se puede deshacer!", + "zh": "要删除 {count} 个文件吗?\n\n总大小:{size}\n\n此操作无法撤销!", + "ja": "{count} 個のファイルを削除しますか?\n\n合計サイズ: {size}\n\nこの操作は元に戻せません!", + "ru": "Удалить файлы ({count})?\n\nОбщий размер: {size}\n\nЭто действие нельзя отменить!" + }, + "Name": { + "de": "Name", + "en": "Name", + "es": "Nombre", + "zh": "名称", + "ja": "名前", + "ru": "Имя" + }, + "Name der Anwendung eingeben": { + "de": "Name der Anwendung eingeben", + "en": "Enter application name", + "es": "Introduzca el nombre de la aplicación", + "zh": "输入应用程序名称", + "ja": "アプリケーション名を入力", + "ru": "Введите имя приложения" + }, + "Name des Synchronisationspaars": { + "de": "Name des Synchronisationspaars", + "en": "Name of the sync pair", + "es": "Nombre del par de sincronización", + "zh": "同步对名称", + "ja": "同期ペアの名前", + "ru": "Имя пары синхронизации" + }, + "Neue App": { + "de": "Neue App", + "en": "New app", + "es": "Nueva aplicación", + "zh": "新建应用", + "ja": "新しいアプリ", + "ru": "Новое приложение" + }, + "Neue App hinzufügen": { + "de": "Neue App hinzufügen", + "en": "Add new app", + "es": "Agregar aplicación nueva", + "zh": "添加新应用", + "ja": "新しいアプリを追加", + "ru": "Добавить новое приложение" + }, + "Neue Dateiendung": { + "de": "Neue Dateiendung", + "en": "New file extension", + "es": "Nueva extensión de archivo", + "zh": "新文件扩展名", + "ja": "新しい拡張子", + "ru": "Новое расширение файла" + }, + "Neue Dateiendung mit Punkt, oder leer lassen um bestehende Endung zu behalten.": { + "de": "Neue Dateiendung mit Punkt, oder leer lassen um bestehende Endung zu behalten.", + "en": "New file extension with a dot, or leave empty to keep the existing extension.", + "es": "Nueva extensión de archivo con punto, o déjelo vacío para conservar la extensión actual.", + "zh": "带点的新文件扩展名;留空则保留现有扩展名。", + "ja": "ドット付きの新しい拡張子を入力します。空欄の場合は現在の拡張子を維持します。", + "ru": "Новое расширение файла с точкой; оставьте пустым, чтобы сохранить текущее расширение." + }, + "Neue Endung eingeben oder leer lassen": { + "de": "Neue Endung eingeben oder leer lassen", + "en": "Enter new extension or leave empty", + "es": "Introduzca la nueva extensión o déjelo vacío", + "zh": "输入新扩展名或留空", + "ja": "新しい拡張子を入力、または空欄のまま", + "ru": "Введите новое расширение или оставьте пустым" + }, + "Neuen Begriff eingeben und Enter drücken": { + "de": "Neuen Begriff eingeben und Enter drücken", + "en": "Enter a new term and press Enter", + "es": "Introduzca un término nuevo y pulse Intro", + "zh": "输入新词条并按 Enter 键", + "ja": "新しい語句を入力して Enter キーを押してください", + "ru": "Введите новый термин и нажмите Enter" + }, + "Neuer Begriff für: {list}": { + "de": "Neuer Begriff für: {list}", + "en": "New term for: {list}", + "es": "Término nuevo para: {list}", + "zh": "{list} 的新词条", + "ja": "新しい語句: {list}", + "ru": "Новый термин для: {list}" + }, + "Neuer Prompt": { + "de": "Neuer Prompt", + "en": "New prompt", + "es": "Nuevo prompt", + "zh": "新建 Prompt", + "ja": "新しいプロンプト", + "ru": "Новый промпт" + }, + "Neuer Text, der anstelle des Suchbegriffs eingesetzt wird.": { + "de": "Neuer Text, der anstelle des Suchbegriffs eingesetzt wird.", + "en": "New text that is inserted in place of the search term.", + "es": "Texto nuevo que se inserta en lugar del término de búsqueda.", + "zh": "用于替换搜索词的新文本。", + "ja": "検索語句の代わりに挿入される新しいテキストです。", + "ru": "Новый текст, который подставляется вместо поискового запроса." + }, + "Neuere Version gewinnt": { + "de": "Neuere Version gewinnt", + "en": "Newer version wins", + "es": "Prevalece la versión más reciente", + "zh": "较新的版本优先", + "ja": "新しいバージョンを優先", + "ru": "Побеждает более новая версия" + }, + "Neues Fenster": { + "de": "Neues Fenster", + "en": "New window", + "es": "Nueva ventana", + "zh": "新建窗口", + "ja": "新しいウィンドウ", + "ru": "Новое окно" + }, + "Neues Synchronisationspaar erstellen": { + "de": "Neues Synchronisationspaar erstellen", + "en": "Create new sync pair", + "es": "Crear un par de sincronización nuevo", + "zh": "创建新的同步对", + "ja": "新しい同期ペアを作成", + "ru": "Создать новую пару синхронизации" + }, + "Nicht lesbar": { + "de": "Nicht lesbar", + "en": "Not readable", + "es": "No se puede leer", + "zh": "无法读取", + "ja": "読み取れません", + "ru": "Не читается" + }, + "Notizen": { + "de": "Notizen", + "en": "Notes", + "es": "Notas", + "zh": "备注", + "ja": "メモ", + "ru": "Заметки" + }, + "Nur im Inhalt suchen": { + "de": "Nur im Inhalt suchen", + "en": "Search content only", + "es": "Buscar solo en el contenido", + "zh": "仅在内容中搜索", + "ja": "内容のみを検索", + "ru": "Искать только в содержимом" + }, + "Office": { + "de": "Office", + "en": "Office", + "es": "Office", + "zh": "办公", + "ja": "Office", + "ru": "Офис" + }, + "Optionale Beschreibung": { + "de": "Optionale Beschreibung", + "en": "Optional description", + "es": "Descripción opcional", + "zh": "可选描述", + "ja": "説明(省略可)", + "ru": "Описание (необязательно)" + }, + "Optionale Beschreibung der Anwendung.": { + "de": "Optionale Beschreibung der Anwendung.", + "en": "Optional description of the application.", + "es": "Descripción opcional de la aplicación.", + "zh": "应用程序的可选描述。", + "ja": "アプリケーションの説明です(省略可)。", + "ru": "Необязательное описание приложения." + }, + "Optionen": { + "de": "Optionen", + "en": "Options", + "es": "Opciones", + "zh": "选项", + "ja": "オプション", + "ru": "Параметры" + }, + "Ordner auswählen": { + "de": "Ordner auswählen", + "en": "Select folder", + "es": "Seleccionar carpeta", + "zh": "选择文件夹", + "ja": "フォルダーを選択", + "ru": "Выбрать папку" + }, + "Ordner indizieren": { + "de": "Ordner indizieren", + "en": "Index folder", + "es": "Indexar carpeta", + "zh": "索引文件夹", + "ja": "フォルダーをインデックス化", + "ru": "Индексировать папку" + }, + "Ordner kann nicht in seinen eigenen Unterordner kopiert oder verschoben werden.": { + "de": "Ordner kann nicht in seinen eigenen Unterordner kopiert oder verschoben werden.", + "en": "A folder cannot be copied or moved into its own subfolder.", + "es": "Una carpeta no se puede copiar ni mover a su propia subcarpeta.", + "zh": "文件夹不能复制或移动到其自身的子文件夹中。", + "ja": "フォルダーを自身のサブフォルダーにコピーまたは移動することはできません。", + "ru": "Папку нельзя копировать или перемещать в её собственную вложенную папку." + }, + "Ordner konnte nicht gelesen werden:": { + "de": "Ordner konnte nicht gelesen werden:", + "en": "Folder could not be read:", + "es": "No se pudo leer la carpeta:", + "zh": "无法读取文件夹:", + "ja": "フォルダーを読み取れませんでした:", + "ru": "Не удалось прочитать папку:" + }, + "Ordner scannen": { + "de": "Ordner scannen", + "en": "Scan folder", + "es": "Escanear carpeta", + "zh": "扫描文件夹", + "ja": "フォルダーをスキャン", + "ru": "Сканировать папку" + }, + "Ordner wählen": { + "de": "Ordner wählen", + "en": "Select folder", + "es": "Seleccionar carpeta", + "zh": "选择文件夹", + "ja": "フォルダーを選択", + "ru": "Выбрать папку" + }, + "Ordner, aus dem Dateien abhängig von der gewählten Richtung übertragen werden": { + "de": "Ordner, aus dem Dateien abhängig von der gewählten Richtung übertragen werden", + "en": "Folder from which files are transferred, depending on the selected direction", + "es": "Carpeta desde la que se transfieren los archivos, según la dirección seleccionada", + "zh": "根据所选方向传出文件的文件夹", + "ja": "選択した方向に応じて、ファイルの転送元となるフォルダー", + "ru": "Папка, из которой передаются файлы, в зависимости от выбранного направления" + }, + "Ordner, in den Dateien abhängig von der gewählten Richtung übertragen werden": { + "de": "Ordner, in den Dateien abhängig von der gewählten Richtung übertragen werden", + "en": "Folder to which files are transferred, depending on the selected direction", + "es": "Carpeta a la que se transfieren los archivos, según la dirección seleccionada", + "zh": "根据所选方向接收文件的文件夹", + "ja": "選択した方向に応じて、ファイルの転送先となるフォルダー", + "ru": "Папка, в которую передаются файлы, в зависимости от выбранного направления" + }, + "Ordner: {path}": { + "de": "Ordner: {path}", + "en": "Folder: {path}", + "es": "Carpeta: {path}", + "zh": "文件夹:{path}", + "ja": "フォルダー: {path}", + "ru": "Папка: {path}" + }, + "Ordnerbaum": { + "de": "Ordnerbaum", + "en": "Folder tree", + "es": "Árbol de carpetas", + "zh": "文件夹树", + "ja": "フォルダーツリー", + "ru": "Дерево папок" + }, + "Ordnername": { + "de": "Ordnername", + "en": "Folder name", + "es": "Nombre de la carpeta", + "zh": "文件夹名称", + "ja": "フォルダー名", + "ru": "Имя папки" + }, + "Ordnerpfad": { + "de": "Ordnerpfad", + "en": "Folder path", + "es": "Ruta de la carpeta", + "zh": "文件夹路径", + "ja": "フォルダーのパス", + "ru": "Путь к папке" + }, + "PDF-Vorschau ist in den Einstellungen deaktiviert.": { + "de": "PDF-Vorschau ist in den Einstellungen deaktiviert.", + "en": "PDF preview is disabled in the settings.", + "es": "La vista previa de PDF está desactivada en la configuración.", + "zh": "PDF 预览已在设置中禁用。", + "ja": "PDF のプレビューは設定で無効になっています。", + "ru": "Предпросмотр PDF отключён в настройках." + }, + "Passwort: xyz": { + "de": "Passwort: xyz", + "en": "Password: xyz", + "es": "Contraseña: xyz", + "zh": "密码:xyz", + "ja": "パスワード: xyz", + "ru": "Пароль: xyz" + }, + "Persönlicher Ordner": { + "de": "Persönlicher Ordner", + "en": "Personal folder", + "es": "Carpeta personal", + "zh": "个人文件夹", + "ja": "個人用フォルダー", + "ru": "Личная папка" + }, + "Pfad": { + "de": "Pfad", + "en": "Path", + "es": "Ruta", + "zh": "路径", + "ja": "パス", + "ru": "Путь" + }, + "Pfad als Prompt speichern": { + "de": "Pfad als Prompt speichern", + "en": "Save path as prompt", + "es": "Guardar la ruta como prompt", + "zh": "将路径保存为 Prompt", + "ja": "パスをプロンプトとして保存", + "ru": "Сохранить путь как промпт" + }, + "Pfad eingeben": { + "de": "Pfad eingeben", + "en": "Enter path", + "es": "Introduzca la ruta", + "zh": "输入路径", + "ja": "パスを入力", + "ru": "Введите путь" + }, + "Pfad ist keine Datei:\n{path}": { + "de": "Pfad ist keine Datei:\n{path}", + "en": "Path is not a file:\n{path}", + "es": "La ruta no es un archivo:\n{path}", + "zh": "路径不是文件:\n{path}", + "ja": "パスはファイルではありません:\n{path}", + "ru": "Путь не является файлом:\n{path}" + }, + "Pfad zu Datei 1": { + "de": "Pfad zu Datei 1", + "en": "Path to file 1", + "es": "Ruta al archivo 1", + "zh": "文件 1 的路径", + "ja": "ファイル 1 のパス", + "ru": "Путь к файлу 1" + }, + "Pfad zu Datei 2": { + "de": "Pfad zu Datei 2", + "en": "Path to file 2", + "es": "Ruta al archivo 2", + "zh": "文件 2 的路径", + "ja": "ファイル 2 のパス", + "ru": "Путь к файлу 2" + }, + "Pfad: {path}": { + "de": "Pfad: {path}", + "en": "Path: {path}", + "es": "Ruta: {path}", + "zh": "路径: {path}", + "ja": "パス: {path}", + "ru": "Путь: {path}" + }, + "Pfad: {name}": { + "de": "Pfad: {name}", + "en": "Path: {name}", + "es": "Ruta: {name}", + "zh": "路径:{name}", + "ja": "パス: {name}", + "ru": "Путь: {name}" + }, + "Position der Nummerierung": { + "de": "Position der Nummerierung", + "en": "Numbering position", + "es": "Posición de la numeración", + "zh": "编号位置", + "ja": "番号の位置", + "ru": "Позиция нумерации" + }, + "Potenziell sensible Daten erkannt": { + "de": "Potenziell sensible Daten erkannt", + "en": "Potentially sensitive data detected", + "es": "Se detectaron datos potencialmente confidenciales", + "zh": "检测到可能的敏感数据", + "ja": "機密の可能性があるデータが検出されました", + "ru": "Обнаружены потенциально конфиденциальные данные" + }, + "Programm wählen": { + "de": "Programm wählen", + "en": "Choose program", + "es": "Elegir programa", + "zh": "选择程序", + "ja": "プログラムを選択", + "ru": "Выбрать программу" + }, + "Programmausgabe": { + "de": "Programmausgabe", + "en": "Program output", + "es": "Salida del programa", + "zh": "程序输出", + "ja": "プログラムの出力", + "ru": "Вывод программы" + }, + "Programmdatei durchsuchen": { + "de": "Programmdatei durchsuchen", + "en": "Browse for program file", + "es": "Buscar archivo de programa", + "zh": "浏览程序文件", + "ja": "プログラムファイルを参照", + "ru": "Выбрать файл программы" + }, + "Programmpfad": { + "de": "Programmpfad", + "en": "Program path", + "es": "Ruta del programa", + "zh": "程序路径", + "ja": "プログラムのパス", + "ru": "Путь к программе" + }, + "Prompt": { + "de": "Prompt", + "en": "Prompt", + "es": "Prompt", + "zh": "Prompt", + "ja": "プロンプト", + "ru": "Промпт" + }, + "Prompt bearbeiten": { + "de": "Prompt bearbeiten", + "en": "Edit prompt", + "es": "Editar prompt", + "zh": "编辑 Prompt", + "ja": "プロンプトを編集", + "ru": "Редактировать промпт" + }, + "Prompt für '{name}' wurde in der Bibliothek gespeichert.": { + "de": "Prompt für '{name}' wurde in der Bibliothek gespeichert.", + "en": "Prompt for '{name}' was saved to the library.", + "es": "El prompt de '{name}' se guardó en la biblioteca.", + "zh": "'{name}' 的 Prompt 已保存到库中。", + "ja": "'{name}' のプロンプトをライブラリに保存しました。", + "ru": "Промпт для '{name}' сохранён в библиотеке." + }, + "Prompt gespeichert": { + "de": "Prompt gespeichert", + "en": "Prompt saved", + "es": "Prompt guardado", + "zh": "Prompt 已保存", + "ja": "プロンプトを保存しました", + "ru": "Промпт сохранён" + }, + "Prompt in Zwischenablage": { + "de": "Prompt in Zwischenablage", + "en": "Prompt to clipboard", + "es": "Prompt al portapapeles", + "zh": "Prompt 到剪贴板", + "ja": "プロンプトをクリップボードへ", + "ru": "Промпт в буфер обмена" + }, + "Prompt kopiert: {text}": { + "de": "Prompt kopiert: {text}", + "en": "Prompt copied: {text}", + "es": "Prompt copiado: {text}", + "zh": "已复制提示词:{text}", + "ja": "プロンプトをコピーしました: {text}", + "ru": "Промпт скопирован: {text}" + }, + "Prompt speichern": { + "de": "Prompt speichern", + "en": "Save prompt", + "es": "Guardar prompt", + "zh": "保存提示词", + "ja": "プロンプトを保存", + "ru": "Сохранить промпт" + }, + "Prompt-Bibliothek": { + "de": "Prompt-Bibliothek", + "en": "Prompt library", + "es": "Biblioteca de prompts", + "zh": "提示词库", + "ja": "プロンプトライブラリ", + "ru": "Библиотека промптов" + }, + "Prompt-Inhaltstext": { + "de": "Prompt-Inhaltstext", + "en": "Prompt content text", + "es": "Texto del contenido del prompt", + "zh": "提示词内容文本", + "ja": "プロンプトの内容テキスト", + "ru": "Текст содержимого промпта" + }, + "Prompt-Kategorie": { + "de": "Prompt-Kategorie", + "en": "Prompt category", + "es": "Categoría del prompt", + "zh": "提示词分类", + "ja": "プロンプトのカテゴリ", + "ru": "Категория промпта" + }, + "Prompt-Kategorien": { + "de": "Prompt-Kategorien", + "en": "Prompt categories", + "es": "Categorías de prompts", + "zh": "提示词分类列表", + "ja": "プロンプトのカテゴリ一覧", + "ru": "Категории промптов" + }, + "Prompt-Liste": { + "de": "Prompt-Liste", + "en": "Prompt list", + "es": "Lista de prompts", + "zh": "提示词列表", + "ja": "プロンプト一覧", + "ru": "Список промптов" + }, + "Prompt-Tags": { + "de": "Prompt-Tags", + "en": "Prompt tags", + "es": "Etiquetas del prompt", + "zh": "提示词标签", + "ja": "プロンプトのタグ", + "ru": "Теги промпта" + }, + "Prompt-Text hier eingeben": { + "de": "Prompt-Text hier eingeben", + "en": "Enter prompt text here", + "es": "Introduzca aquí el texto del prompt", + "zh": "在此输入提示词文本", + "ja": "ここにプロンプトのテキストを入力", + "ru": "Введите текст промпта здесь" + }, + "Prompt-Titel": { + "de": "Prompt-Titel", + "en": "Prompt title", + "es": "Título del prompt", + "zh": "提示词标题", + "ja": "プロンプトのタイトル", + "ru": "Название промпта" + }, + "Prompt-Vorlage für '{name}' in die Zwischenablage kopiert.": { + "de": "Prompt-Vorlage für '{name}' in die Zwischenablage kopiert.", + "en": "Prompt template for '{name}' copied to clipboard.", + "es": "Plantilla de prompt de '{name}' copiada al portapapeles.", + "zh": "已将“{name}”的提示词模板复制到剪贴板。", + "ja": "'{name}' のプロンプトテンプレートをクリップボードにコピーしました。", + "ru": "Шаблон промпта '{name}' скопирован в буфер обмена." + }, + "Prompt-Vorschautext": { + "de": "Prompt-Vorschautext", + "en": "Prompt preview text", + "es": "Texto de vista previa del prompt", + "zh": "提示词预览文本", + "ja": "プロンプトのプレビューテキスト", + "ru": "Текст предпросмотра промпта" + }, + "Prompts": { + "de": "Prompts", + "en": "Prompts", + "es": "Prompts", + "zh": "提示词", + "ja": "プロンプト", + "ru": "Промпты" + }, + "Prompts durchsuchen": { + "de": "Prompts durchsuchen", + "en": "Search prompts", + "es": "Buscar prompts", + "zh": "搜索提示词", + "ja": "プロンプトを検索", + "ru": "Поиск промптов" + }, + "Prompts öffnen": { + "de": "Prompts öffnen", + "en": "Open prompts", + "es": "Abrir prompts", + "zh": "打开提示词", + "ja": "プロンプトを開く", + "ru": "Открыть промпты" + }, + "Prüfe: {name}": { + "de": "Prüfe: {name}", + "en": "Checking: {name}", + "es": "Comprobando: {name}", + "zh": "正在检查:{name}", + "ja": "確認中: {name}", + "ru": "Проверка: {name}" + }, + "Prüfe: {name}...": { + "de": "Prüfe: {name}...", + "en": "Checking: {name}...", + "es": "Comprobando: {name}...", + "zh": "正在检查:{name}...", + "ja": "確認中: {name}...", + "ru": "Проверка: {name}..." + }, + "Prüfen": { + "de": "Prüfen", + "en": "Check", + "es": "Comprobar", + "zh": "检查", + "ja": "確認", + "ru": "Проверить" + }, + "Prüfergebnis": { + "de": "Prüfergebnis", + "en": "Check result", + "es": "Resultado de la comprobación", + "zh": "检查结果", + "ja": "確認結果", + "ru": "Результат проверки" + }, + "Prüfsumme vergleichen": { + "de": "Prüfsumme vergleichen", + "en": "Compare checksum", + "es": "Comparar suma de comprobación", + "zh": "比较校验和", + "ja": "チェックサムを比較", + "ru": "Сравнить контрольную сумму" + }, + "Prüfsummen": { + "de": "Prüfsummen", + "en": "Checksums", + "es": "Sumas de comprobación", + "zh": "校验和", + "ja": "チェックサム", + "ru": "Контрольные суммы" + }, + "Prüfsummen berechnen": { + "de": "Prüfsummen berechnen", + "en": "Calculate checksums", + "es": "Calcular sumas de comprobación", + "zh": "计算校验和", + "ja": "チェックサムを計算", + "ru": "Вычислить контрольные суммы" + }, + "Prüfsummen — {name}": { + "de": "Prüfsummen — {name}", + "en": "Checksums — {name}", + "es": "Sumas de comprobación — {name}", + "zh": "校验和 — {name}", + "ja": "チェックサム — {name}", + "ru": "Контрольные суммы — {name}" + }, + "Prüfsummen-Dialog": { + "de": "Prüfsummen-Dialog", + "en": "Checksums dialog", + "es": "Cuadro de diálogo de sumas de comprobación", + "zh": "校验和对话框", + "ja": "チェックサムのダイアログ", + "ru": "Диалог контрольных сумм" + }, + "PyMuPDF nicht installiert.\nPDF-Vorschau nicht verfügbar.": { + "de": "PyMuPDF nicht installiert.\nPDF-Vorschau nicht verfügbar.", + "en": "PyMuPDF not installed.\nPDF preview not available.", + "es": "PyMuPDF no está instalado.\nVista previa de PDF no disponible.", + "zh": "未安装 PyMuPDF。\nPDF 预览不可用。", + "ja": "PyMuPDF がインストールされていません。\nPDF プレビューは利用できません。", + "ru": "PyMuPDF не установлен.\nПредварительный просмотр PDF недоступен." + }, + "Python (*.py);;Alle": { + "de": "Python (*.py);;Alle", + "en": "Python (*.py);;All", + "es": "Python (*.py);;Todos", + "zh": "Python (*.py);;全部", + "ja": "Python (*.py);;すべて", + "ru": "Python (*.py);;Все" + }, + "Python-Skript ausführen": { + "de": "Python-Skript ausführen", + "en": "Run Python script", + "es": "Ejecutar script de Python", + "zh": "运行 Python 脚本", + "ja": "Python スクリプトを実行", + "ru": "Запустить скрипт Python" + }, + "Quelle": { + "de": "Quelle", + "en": "Source", + "es": "Origen", + "zh": "源", + "ja": "ソース", + "ru": "Источник" + }, + "Quelle gewinnt immer": { + "de": "Quelle gewinnt immer", + "en": "Source always wins", + "es": "El origen siempre prevalece", + "zh": "始终以源为准", + "ja": "常にソースを優先", + "ru": "Источник всегда приоритетен" + }, + "Quelle nicht gefunden: {path}": { + "de": "Quelle nicht gefunden: {path}", + "en": "Source not found: {path}", + "es": "Origen no encontrado: {path}", + "zh": "未找到源:{path}", + "ja": "ソースが見つかりません: {path}", + "ru": "Источник не найден: {path}" + }, + "Quelle → Ziel": { + "de": "Quelle → Ziel", + "en": "Source → Target", + "es": "Origen → Destino", + "zh": "源 → 目标", + "ja": "ソース → ターゲット", + "ru": "Источник → Назначение" + }, + "Quellordner": { + "de": "Quellordner", + "en": "Source folder", + "es": "Carpeta de origen", + "zh": "源文件夹", + "ja": "ソースフォルダー", + "ru": "Исходная папка" + }, + "Quellordner existiert nicht: {path}": { + "de": "Quellordner existiert nicht: {path}", + "en": "Source folder does not exist: {path}", + "es": "La carpeta de origen no existe: {path}", + "zh": "源文件夹不存在:{path}", + "ja": "ソースフォルダーが存在しません: {path}", + "ru": "Исходная папка не существует: {path}" + }, + "Quick Editor - {name}": { + "de": "Quick Editor - {name}", + "en": "Quick Editor - {name}", + "es": "Quick Editor - {name}", + "zh": "Quick Editor - {name}", + "ja": "Quick Editor - {name}", + "ru": "Quick Editor - {name}" + }, + "Regelbasierte Umbenennung mehrerer Dateien mit konfigurierbaren Ersetzungen, Präfixen, Suffixen und Nummerierung.": { + "de": "Regelbasierte Umbenennung mehrerer Dateien mit konfigurierbaren Ersetzungen, Präfixen, Suffixen und Nummerierung.", + "en": "Rule-based renaming of multiple files with configurable replacements, prefixes, suffixes and numbering.", + "es": "Cambio de nombre basado en reglas de varios archivos con reemplazos, prefijos, sufijos y numeración configurables.", + "zh": "基于规则批量重命名多个文件,支持可配置的替换、前缀、后缀和编号。", + "ja": "設定可能な置換、プレフィックス、サフィックス、連番を使って、複数のファイル名をルールに基づいて一括変更します。", + "ru": "Пакетное переименование нескольких файлов по правилам с настраиваемыми заменами, префиксами, суффиксами и нумерацией." + }, + "Reguläre Ausdrücke": { + "de": "Reguläre Ausdrücke", + "en": "Regular expressions", + "es": "Expresiones regulares", + "zh": "正则表达式", + "ja": "正規表現", + "ru": "Регулярные выражения" + }, + "Richtung": { + "de": "Richtung", + "en": "Direction", + "es": "Dirección", + "zh": "方向", + "ja": "方向", + "ru": "Направление" + }, + "Rot – sensible Daten erkannt": { + "de": "Rot – sensible Daten erkannt", + "en": "Red – sensitive data detected", + "es": "Rojo: datos sensibles detectados", + "zh": "红色 – 检测到敏感数据", + "ja": "赤 – 機密データを検出", + "ru": "Красный – обнаружены конфиденциальные данные" + }, + "Scan abbrechen": { + "de": "Scan abbrechen", + "en": "Cancel scan", + "es": "Cancelar análisis", + "zh": "取消扫描", + "ja": "スキャンをキャンセル", + "ru": "Отменить сканирование" + }, + "Scan-Fortschritt": { + "de": "Scan-Fortschritt", + "en": "Scan progress", + "es": "Progreso del análisis", + "zh": "扫描进度", + "ja": "スキャンの進行状況", + "ru": "Ход сканирования" + }, + "Scan-Optionen": { + "de": "Scan-Optionen", + "en": "Scan options", + "es": "Opciones de análisis", + "zh": "扫描选项", + "ja": "スキャンのオプション", + "ru": "Параметры сканирования" + }, + "Scan-Quelle": { + "de": "Scan-Quelle", + "en": "Scan source", + "es": "Origen del análisis", + "zh": "扫描源", + "ja": "スキャン対象", + "ru": "Источник сканирования" + }, + "Schaltet die sequentielle Dateinummerierung ein.": { + "de": "Schaltet die sequentielle Dateinummerierung ein.", + "en": "Enables sequential file numbering.", + "es": "Activa la numeración secuencial de archivos.", + "zh": "启用文件顺序编号。", + "ja": "ファイルの連番付けを有効にします。", + "ru": "Включает последовательную нумерацию файлов." + }, + "Schließt die Vorschau ohne Dateien zu übertragen": { + "de": "Schließt die Vorschau ohne Dateien zu übertragen", + "en": "Closes the preview without transferring any files", + "es": "Cierra la vista previa sin transferir archivos", + "zh": "关闭预览,不传输任何文件", + "ja": "ファイルを転送せずにプレビューを閉じます", + "ru": "Закрывает предпросмотр без передачи файлов" + }, + "Schnellzugriff": { + "de": "Schnellzugriff", + "en": "Quick access", + "es": "Acceso rápido", + "zh": "快速访问", + "ja": "クイックアクセス", + "ru": "Быстрый доступ" + }, + "Schreibweisenumwandlung": { + "de": "Schreibweisenumwandlung", + "en": "Case conversion", + "es": "Conversión de mayúsculas y minúsculas", + "zh": "大小写转换", + "ja": "大文字・小文字の変換", + "ru": "Преобразование регистра" + }, + "Schriftgröße": { + "de": "Schriftgröße", + "en": "Font size", + "es": "Tamaño de fuente", + "zh": "字体大小", + "ja": "フォントサイズ", + "ru": "Размер шрифта" + }, + "Schrittweite der Nummerierung": { + "de": "Schrittweite der Nummerierung", + "en": "Numbering step size", + "es": "Incremento de la numeración", + "zh": "编号步长", + "ja": "連番の増分", + "ru": "Шаг нумерации" + }, + "Schweregrad: {severity}": { + "de": "Schweregrad: {severity}", + "en": "Severity: {severity}", + "es": "Gravedad: {severity}", + "zh": "严重程度:{severity}", + "ja": "重大度: {severity}", + "ru": "Серьезность: {severity}" + }, + "Sensible Daten wurden blockiert": { + "de": "Sensible Daten wurden blockiert", + "en": "Sensitive data was blocked", + "es": "Se bloquearon datos sensibles", + "zh": "敏感数据已被拦截", + "ja": "機密データをブロックしました", + "ru": "Конфиденциальные данные заблокированы" + }, + "Setzt eine gelbe oder rote Ampel wieder auf Grün": { + "de": "Setzt eine gelbe oder rote Ampel wieder auf Grün", + "en": "Resets a yellow or red traffic light back to green", + "es": "Restablece un semáforo amarillo o rojo a verde", + "zh": "将黄色或红色指示灯重置为绿色", + "ja": "黄色または赤のインジケーターを緑に戻します", + "ru": "Сбрасывает желтый или красный индикатор обратно на зеленый" + }, + "Sidebar anzeigen": { + "de": "Sidebar anzeigen", + "en": "Show sidebar", + "es": "Mostrar barra lateral", + "zh": "显示侧边栏", + "ja": "サイドバーを表示", + "ru": "Показать боковую панель" + }, + "Sollen wirklich alle {count} Einträge entfernt werden?": { + "de": "Sollen wirklich alle {count} Einträge entfernt werden?", + "en": "Do you really want to remove all {count} entries?", + "es": "¿Realmente desea eliminar las {count} entradas?", + "zh": "确定要删除全部 {count} 个条目吗?", + "ja": "本当に {count} 件のエントリをすべて削除しますか?", + "ru": "Действительно удалить все записи ({count})?" + }, + "Speichern unter": { + "de": "Speichern unter", + "en": "Save as", + "es": "Guardar como", + "zh": "另存为", + "ja": "名前を付けて保存", + "ru": "Сохранить как" + }, + "Speicherplatzanzeige": { + "de": "Speicherplatzanzeige", + "en": "Storage space indicator", + "es": "Indicador de espacio de almacenamiento", + "zh": "存储空间指示器", + "ja": "ストレージ容量の表示", + "ru": "Индикатор свободного места" + }, + "Speicherplatzanzeige: {size}": { + "de": "Speicherplatzanzeige: {size}", + "en": "Storage space indicator: {size}", + "es": "Indicador de espacio de almacenamiento: {size}", + "zh": "存储空间指示器:{size}", + "ja": "ストレージ容量の表示: {size}", + "ru": "Индикатор свободного места: {size}" + }, + "Speichert den Inhalt des Editors in die aktuelle Datei.": { + "de": "Speichert den Inhalt des Editors in die aktuelle Datei.", + "en": "Saves the editor content to the current file.", + "es": "Guarda el contenido del editor en el archivo actual.", + "zh": "将编辑器内容保存到当前文件。", + "ja": "エディターの内容を現在のファイルに保存します。", + "ru": "Сохраняет содержимое редактора в текущий файл." + }, + "Speichert den Prompt": { + "de": "Speichert den Prompt", + "en": "Saves the prompt", + "es": "Guarda el prompt", + "zh": "保存提示词", + "ja": "プロンプトを保存します", + "ru": "Сохраняет промпт" + }, + "Spiele": { + "de": "Spiele", + "en": "Games", + "es": "Juegos", + "zh": "游戏", + "ja": "ゲーム", + "ru": "Игры" + }, + "Sprachauswahl": { + "de": "Sprachauswahl", + "en": "Language selection", + "es": "Selección de idioma", + "zh": "语言选择", + "ja": "言語の選択", + "ru": "Выбор языка" + }, + "Sprachänderungen werden sofort übernommen; einzelne Texte erst nach einem Neustart.": { + "de": "Sprachänderungen werden sofort übernommen; einzelne Texte erst nach einem Neustart.", + "en": "Language changes apply immediately; some texts only after a restart.", + "es": "Los cambios de idioma se aplican de inmediato; algunos textos solo después de reiniciar.", + "zh": "语言更改立即生效;部分文本需重启后才会更新。", + "ja": "言語の変更はすぐに反映されます。一部のテキストは再起動後に反映されます。", + "ru": "Изменения языка применяются сразу; некоторые тексты — только после перезапуска." + }, + "Standardordner, der beim Start der Anwendung geöffnet wird.": { + "de": "Standardordner, der beim Start der Anwendung geöffnet wird.", + "en": "Default folder that is opened when the application starts.", + "es": "Carpeta predeterminada que se abre al iniciar la aplicación.", + "zh": "应用程序启动时打开的默认文件夹。", + "ja": "アプリケーションの起動時に開くデフォルトのフォルダーです。", + "ru": "Папка по умолчанию, которая открывается при запуске приложения." + }, + "Startargumente": { + "de": "Startargumente", + "en": "Startup arguments", + "es": "Argumentos de inicio", + "zh": "启动参数", + "ja": "起動引数", + "ru": "Аргументы запуска" + }, + "Starten": { + "de": "Starten", + "en": "Launch", + "es": "Iniciar", + "zh": "启动", + "ja": "起動", + "ru": "Запустить" + }, + "Startet die Anwendung {name}. Dateipfad: {path}": { + "de": "Startet die Anwendung {name}. Dateipfad: {path}", + "en": "Launches the application {name}. File path: {path}", + "es": "Inicia la aplicación {name}. Ruta del archivo: {path}", + "zh": "启动应用程序 {name}。文件路径:{path}", + "ja": "アプリケーション {name} を起動します。ファイルパス: {path}", + "ru": "Запускает приложение {name}. Путь к файлу: {path}" + }, + "Startet die Dateisuche mit dem aktuellen Suchbegriff.": { + "de": "Startet die Dateisuche mit dem aktuellen Suchbegriff.", + "en": "Starts the file search with the current search term.", + "es": "Inicia la búsqueda de archivos con el término de búsqueda actual.", + "zh": "使用当前搜索词开始文件搜索。", + "ja": "現在の検索語でファイル検索を開始します。", + "ru": "Запускает поиск файлов по текущему запросу." + }, + "Startet die Differenzanalyse zwischen Datei 1 und Datei 2.": { + "de": "Startet die Differenzanalyse zwischen Datei 1 und Datei 2.", + "en": "Starts the difference analysis between file 1 and file 2.", + "es": "Inicia el análisis de diferencias entre el archivo 1 y el archivo 2.", + "zh": "开始分析文件 1 与文件 2 之间的差异。", + "ja": "ファイル 1 とファイル 2 の差分の分析を開始します。", + "ru": "Запускает анализ различий между файлом 1 и файлом 2." + }, + "Startet die Suche nach Dateiduplikaten anhand von SHA-256 Hashes.": { + "de": "Startet die Suche nach Dateiduplikaten anhand von SHA-256 Hashes.", + "en": "Starts the search for duplicate files using SHA-256 hashes.", + "es": "Inicia la búsqueda de archivos duplicados mediante hashes SHA-256.", + "zh": "使用 SHA-256 哈希开始搜索重复文件。", + "ja": "SHA-256 ハッシュを使って重複ファイルの検索を開始します。", + "ru": "Запускает поиск дубликатов файлов по хешам SHA-256." + }, + "Startet die Synchronisation des ausgewählten Paars nach einer Bestätigung": { + "de": "Startet die Synchronisation des ausgewählten Paars nach einer Bestätigung", + "en": "Starts the synchronization of the selected pair after confirmation", + "es": "Inicia la sincronización del par seleccionado tras una confirmación", + "zh": "确认后开始同步所选的同步对", + "ja": "確認後、選択したペアの同期を開始します", + "ru": "Запускает синхронизацию выбранной пары после подтверждения" + }, + "Startordner": { + "de": "Startordner", + "en": "Start folder", + "es": "Carpeta de inicio", + "zh": "启动文件夹", + "ja": "開始フォルダー", + "ru": "Начальная папка" + }, + "Startordner auswählen": { + "de": "Startordner auswählen", + "en": "Select start folder", + "es": "Seleccionar carpeta de inicio", + "zh": "选择启动文件夹", + "ja": "開始フォルダーを選択", + "ru": "Выбрать начальную папку" + }, + "Startwert der Nummerierung": { + "de": "Startwert der Nummerierung", + "en": "Starting number for the numbering", + "es": "Valor inicial de la numeración", + "zh": "编号起始值", + "ja": "連番の開始番号", + "ru": "Начальное значение нумерации" + }, + "Statistik": { + "de": "Statistik", + "en": "Statistics", + "es": "Estadísticas", + "zh": "统计", + "ja": "統計", + "ru": "Статистика" + }, + "Status der Dateisynchronisierung (aktuell oder aktiv).": { + "de": "Status der Dateisynchronisierung (aktuell oder aktiv).", + "en": "Status of file synchronization (up to date or active).", + "es": "Estado de la sincronización de archivos (actualizada o activa).", + "zh": "文件同步状态(已是最新或正在进行)。", + "ja": "ファイル同期の状態 (最新または実行中)。", + "ru": "Состояние синхронизации файлов (актуально или выполняется)." + }, + "Status: {status}": { + "de": "Status: {status}", + "en": "Status: {status}", + "es": "Estado: {status}", + "zh": "状态:{status}", + "ja": "状態: {status}", + "ru": "Статус: {status}" + }, + "Stellenanzahl mit führenden Nullen": { + "de": "Stellenanzahl mit führenden Nullen", + "en": "Number of digits with leading zeros", + "es": "Cantidad de dígitos con ceros iniciales", + "zh": "带前导零的位数", + "ja": "先頭にゼロを付ける桁数", + "ru": "Количество разрядов с ведущими нулями" + }, + "Suchbegriff": { + "de": "Suchbegriff", + "en": "Search term", + "es": "Término de búsqueda", + "zh": "搜索词", + "ja": "検索語", + "ru": "Поисковый запрос" + }, + "Suchbegriff eingeben": { + "de": "Suchbegriff eingeben", + "en": "Enter search term", + "es": "Introduzca un término de búsqueda", + "zh": "输入搜索词", + "ja": "検索語を入力", + "ru": "Введите поисковый запрос" + }, + "Suchbegriff eingeben und mit Enter oder der Schaltfläche Suchen starten.": { + "de": "Suchbegriff eingeben und mit Enter oder der Schaltfläche Suchen starten.", + "en": "Enter a search term and start the search with Enter or the Search button.", + "es": "Introduzca un término de búsqueda e inicie la búsqueda con Enter o con el botón Buscar.", + "zh": "输入搜索词,然后按 Enter 键或点击“搜索”按钮开始搜索。", + "ja": "検索語を入力し、Enter キーまたは [検索] ボタンで検索を開始します。", + "ru": "Введите поисковый запрос и начните поиск клавишей Enter или кнопкой «Поиск»." + }, + "Suchbegriff für Dateiname, Inhalt oder Pfad.": { + "de": "Suchbegriff für Dateiname, Inhalt oder Pfad.", + "en": "Search term for file name, content or path.", + "es": "Término de búsqueda para el nombre, el contenido o la ruta del archivo.", + "zh": "用于文件名、内容或路径的搜索词。", + "ja": "ファイル名、内容、またはパスの検索語です。", + "ru": "Поисковый запрос для имени файла, содержимого или пути." + }, + "Suchbegriff für Umbenennung": { + "de": "Suchbegriff für Umbenennung", + "en": "Search term for renaming", + "es": "Término de búsqueda para el cambio de nombre", + "zh": "用于重命名的搜索词", + "ja": "名前変更用の検索語", + "ru": "Поисковый запрос для переименования" + }, + "Suchbegriff für die Volltextsuche im Dateiindex eingeben. Mindestens zwei Zeichen starten die Suche.": { + "de": "Suchbegriff für die Volltextsuche im Dateiindex eingeben. Mindestens zwei Zeichen starten die Suche.", + "en": "Enter a search term for the full-text search in the file index. At least two characters start the search.", + "es": "Introduzca un término de búsqueda para la búsqueda de texto completo en el índice de archivos. La búsqueda se inicia con al menos dos caracteres.", + "zh": "输入用于在文件索引中进行全文搜索的搜索词。至少输入两个字符才会开始搜索。", + "ja": "ファイルインデックスの全文検索に使う検索語を入力します。2 文字以上で検索が開始されます。", + "ru": "Введите поисковый запрос для полнотекстового поиска по индексу файлов. Поиск начинается при вводе не менее двух символов." + }, + "Suche ausführen": { + "de": "Suche ausführen", + "en": "Run search", + "es": "Ejecutar búsqueda", + "zh": "执行搜索", + "ja": "検索を実行", + "ru": "Выполнить поиск" + }, + "Suche in": { + "de": "Suche in", + "en": "Search in", + "es": "Buscar en", + "zh": "搜索范围", + "ja": "検索場所", + "ru": "Где искать" + }, + "Suche löschen": { + "de": "Suche löschen", + "en": "Clear search", + "es": "Borrar búsqueda", + "zh": "清除搜索", + "ja": "検索をクリア", + "ru": "Очистить поиск" + }, + "Suche nach '{query}'...": { + "de": "Suche nach '{query}'...", + "en": "Searching for '{query}'...", + "es": "Buscando '{query}'...", + "zh": "正在搜索“{query}”...", + "ja": "'{query}' を検索中...", + "ru": "Поиск '{query}'..." + }, + "Suche starten": { + "de": "Suche starten", + "en": "Start search", + "es": "Iniciar búsqueda", + "zh": "开始搜索", + "ja": "検索を開始", + "ru": "Начать поиск" + }, + "Suchergebnisse": { + "de": "Suchergebnisse", + "en": "Search results", + "es": "Resultados de la búsqueda", + "zh": "搜索结果", + "ja": "検索結果", + "ru": "Результаты поиска" + }, + "Suchfilter zurücksetzen": { + "de": "Suchfilter zurücksetzen", + "en": "Reset search filters", + "es": "Restablecer filtros de búsqueda", + "zh": "重置搜索筛选器", + "ja": "検索フィルターをリセット", + "ru": "Сбросить фильтры поиска" + }, + "Suchfortschritt": { + "de": "Suchfortschritt", + "en": "Search progress", + "es": "Progreso de la búsqueda", + "zh": "搜索进度", + "ja": "検索の進行状況", + "ru": "Ход поиска" + }, + "Suchkriterien": { + "de": "Suchkriterien", + "en": "Search criteria", + "es": "Criterios de búsqueda", + "zh": "搜索条件", + "ja": "検索条件", + "ru": "Критерии поиска" + }, + "Suchstatus": { + "de": "Suchstatus", + "en": "Search status", + "es": "Estado de la búsqueda", + "zh": "搜索状态", + "ja": "検索の状態", + "ru": "Состояние поиска" + }, + "Symbolgröße": { + "de": "Symbolgröße", + "en": "Icon size", + "es": "Tamaño de iconos", + "zh": "图标大小", + "ja": "アイコンサイズ", + "ru": "Размер значков" + }, + "Sync": { + "de": "Sync", + "en": "Sync", + "es": "Sincronizar", + "zh": "同步", + "ja": "同期", + "ru": "Синхронизация" + }, + "Sync öffnen": { + "de": "Sync öffnen", + "en": "Open Sync", + "es": "Abrir sincronización", + "zh": "打开同步", + "ja": "同期を開く", + "ru": "Открыть синхронизацию" + }, + "Sync-Paar bearbeiten": { + "de": "Sync-Paar bearbeiten", + "en": "Edit sync pair", + "es": "Editar par de sincronización", + "zh": "编辑同步对", + "ja": "同期ペアを編集", + "ru": "Изменить пару синхронизации" + }, + "Sync-Vorschau: {name}": { + "de": "Sync-Vorschau: {name}", + "en": "Sync preview: {name}", + "es": "Vista previa de sincronización: {name}", + "zh": "同步预览:{name}", + "ja": "同期プレビュー: {name}", + "ru": "Предпросмотр синхронизации: {name}" + }, + "Synchronisation": { + "de": "Synchronisation", + "en": "Synchronization", + "es": "Sincronización", + "zh": "同步", + "ja": "同期", + "ru": "Синхронизация" + }, + "Synchronisation abgeschlossen: {count} Dateien": { + "de": "Synchronisation abgeschlossen: {count} Dateien", + "en": "Synchronization completed: {count} files", + "es": "Sincronización completada: {count} archivos", + "zh": "同步完成:{count} 个文件", + "ja": "同期が完了しました: {count} 個のファイル", + "ru": "Синхронизация завершена: файлов — {count}" + }, + "Synchronisation ist aktuell und auf dem neuesten Stand.": { + "de": "Synchronisation ist aktuell und auf dem neuesten Stand.", + "en": "Synchronization is up to date.", + "es": "La sincronización está al día.", + "zh": "同步已是最新状态。", + "ja": "同期は最新の状態です。", + "ru": "Синхронизация актуальна." + }, + "Synchronisation starten": { + "de": "Synchronisation starten", + "en": "Start synchronization", + "es": "Iniciar sincronización", + "zh": "开始同步", + "ja": "同期を開始", + "ru": "Начать синхронизацию" + }, + "Synchronisation wird derzeit im Hintergrund ausgeführt.": { + "de": "Synchronisation wird derzeit im Hintergrund ausgeführt.", + "en": "Synchronization is currently running in the background.", + "es": "La sincronización se está ejecutando actualmente en segundo plano.", + "zh": "同步当前正在后台运行。", + "ja": "同期は現在バックグラウンドで実行中です。", + "ru": "Синхронизация сейчас выполняется в фоновом режиме." + }, + "Synchronisationspaare": { + "de": "Synchronisationspaare", + "en": "Sync pairs", + "es": "Pares de sincronización", + "zh": "同步对", + "ja": "同期ペア", + "ru": "Пары синхронизации" + }, + "Synchronisationsrichtung": { + "de": "Synchronisationsrichtung", + "en": "Sync direction", + "es": "Dirección de sincronización", + "zh": "同步方向", + "ja": "同期の方向", + "ru": "Направление синхронизации" + }, + "Synchronisationsstatus": { + "de": "Synchronisationsstatus", + "en": "Sync status", + "es": "Estado de sincronización", + "zh": "同步状态", + "ja": "同期の状態", + "ru": "Состояние синхронизации" + }, + "Synchronisationsvorschau öffnen": { + "de": "Synchronisationsvorschau öffnen", + "en": "Open sync preview", + "es": "Abrir vista previa de sincronización", + "zh": "打开同步预览", + "ja": "同期プレビューを開く", + "ru": "Открыть предпросмотр синхронизации" + }, + "Synchronisieren": { + "de": "Synchronisieren", + "en": "Synchronize", + "es": "Sincronizar", + "zh": "同步", + "ja": "同期する", + "ru": "Синхронизировать" + }, + "Synchronisierung: Aktuell": { + "de": "Synchronisierung: Aktuell", + "en": "Synchronization: Up to date", + "es": "Sincronización: al día", + "zh": "同步:已是最新", + "ja": "同期: 最新", + "ru": "Синхронизация: актуально" + }, + "Syntax validieren": { + "de": "Syntax validieren", + "en": "Validate syntax", + "es": "Validar sintaxis", + "zh": "验证语法", + "ja": "構文を検証", + "ru": "Проверить синтаксис" + }, + "System": { + "de": "System", + "en": "System", + "es": "Sistema", + "zh": "系统", + "ja": "システム", + "ru": "Система" + }, + "Systemstandard": { + "de": "Systemstandard", + "en": "System default", + "es": "Predeterminado del sistema", + "zh": "系统默认", + "ja": "システムの既定", + "ru": "Системная по умолчанию" + }, + "Tabelle der Dateien und Ordner im aktuellen Verzeichnis. Navigieren mit Pfeiltasten, Öffnen mit Enter oder Doppelklick.": { + "de": "Tabelle der Dateien und Ordner im aktuellen Verzeichnis. Navigieren mit Pfeiltasten, Öffnen mit Enter oder Doppelklick.", + "en": "Table of the files and folders in the current directory. Navigate with the arrow keys, open with Enter or double-click.", + "es": "Tabla de los archivos y carpetas del directorio actual. Navegue con las teclas de flecha y abra con Enter o doble clic.", + "zh": "当前目录中的文件和文件夹表格。使用方向键导航,按 Enter 键或双击打开。", + "ja": "現在のディレクトリ内のファイルとフォルダーの表です。方向キーで移動し、Enter キーまたはダブルクリックで開きます。", + "ru": "Таблица файлов и папок в текущем каталоге. Перемещайтесь стрелками, открывайте клавишей Enter или двойным щелчком." + }, + "Tabelle mit Aktion, Datei, Richtung und Grund jeder geplanten Synchronisationsänderung": { + "de": "Tabelle mit Aktion, Datei, Richtung und Grund jeder geplanten Synchronisationsänderung", + "en": "Table with the action, file, direction and reason of each planned synchronization change", + "es": "Tabla con la acción, el archivo, la dirección y el motivo de cada cambio de sincronización planificado", + "zh": "列出每项计划同步更改的操作、文件、方向和原因的表格", + "ja": "計画されている各同期変更の操作、ファイル、方向、理由を示す表", + "ru": "Таблица с действием, файлом, направлением и причиной каждого запланированного изменения синхронизации" + }, + "Tabellenvorschau": { + "de": "Tabellenvorschau", + "en": "Table preview", + "es": "Vista previa de tabla", + "zh": "表格预览", + "ja": "表のプレビュー", + "ru": "Предпросмотр таблицы" + }, + "Tags": { + "de": "Tags", + "en": "Tags", + "es": "Etiquetas", + "zh": "标签", + "ja": "タグ", + "ru": "Теги" + }, + "Tags bearbeiten": { + "de": "Tags bearbeiten", + "en": "Edit tags", + "es": "Editar etiquetas", + "zh": "编辑标签", + "ja": "タグを編集", + "ru": "Изменить теги" + }, + "Tags/Notizen konnten nicht gelesen werden: %s": { + "de": "Tags/Notizen konnten nicht gelesen werden: %s", + "en": "Tags/notes could not be read: %s", + "es": "No se pudieron leer las etiquetas/notas: %s", + "zh": "无法读取标签/备注:%s", + "ja": "タグ/メモを読み込めませんでした: %s", + "ru": "Не удалось прочитать теги/заметки: %s" + }, + "Tags/Notizen konnten nicht gespeichert werden: %s": { + "de": "Tags/Notizen konnten nicht gespeichert werden: %s", + "en": "Tags/notes could not be saved: %s", + "es": "No se pudieron guardar las etiquetas/notas: %s", + "zh": "无法保存标签/备注:%s", + "ja": "タグ/メモを保存できませんでした: %s", + "ru": "Не удалось сохранить теги/заметки: %s" + }, + "Teilweise gelöscht": { + "de": "Teilweise gelöscht", + "en": "Partially deleted", + "es": "Eliminado parcialmente", + "zh": "部分已删除", + "ja": "一部削除済み", + "ru": "Частично удалено" + }, + "Telefonnummern": { + "de": "Telefonnummern", + "en": "Phone numbers", + "es": "Números de teléfono", + "zh": "电话号码", + "ja": "電話番号", + "ru": "Номера телефонов" + }, + "Testen": { + "de": "Testen", + "en": "Test", + "es": "Probar", + "zh": "测试", + "ja": "テスト", + "ru": "Проверить" + }, + "Testtext für die Datenschutz-Prüfung": { + "de": "Testtext für die Datenschutz-Prüfung", + "en": "Test text for the privacy check", + "es": "Texto de prueba para la comprobación de privacidad", + "zh": "用于隐私检查的测试文本", + "ja": "プライバシーチェック用のテストテキスト", + "ru": "Тестовый текст для проверки конфиденциальности" + }, + "Testtext prüfen": { + "de": "Testtext prüfen", + "en": "Check test text", + "es": "Comprobar texto de prueba", + "zh": "检查测试文本", + "ja": "テストテキストを確認", + "ru": "Проверить тестовый текст" + }, + "Text der Prompt-Vorlage. Variablen in doppelten geschweiften Klammern werden beim Verwenden abgefragt.": { + "de": "Text der Prompt-Vorlage. Variablen in doppelten geschweiften Klammern werden beim Verwenden abgefragt.", + "en": "Text of the prompt template. Variables in double curly braces are requested when the prompt is used.", + "es": "Texto de la plantilla de prompt. Las variables entre llaves dobles se solicitan al utilizar el prompt.", + "zh": "提示词模板的文本。使用时会询问双花括号中的变量。", + "ja": "プロンプトテンプレートのテキストです。二重波括弧で囲んだ変数は、使用時に入力を求められます。", + "ru": "Текст шаблона промпта. Переменные в двойных фигурных скобках запрашиваются при использовании промпта." + }, + "Text einfügen und prüfen, wie die Ampel mit den aktuellen (auch noch nicht gespeicherten) Einstellungen reagieren würde.": { + "de": "Text einfügen und prüfen, wie die Ampel mit den aktuellen (auch noch nicht gespeicherten) Einstellungen reagieren würde.", + "en": "Paste text and check how the traffic light would react with the current settings (including those not yet saved).", + "es": "Pegue un texto y compruebe cómo reaccionaría el semáforo con la configuración actual (incluso la que aún no se ha guardado).", + "zh": "粘贴文本,查看指示灯在当前设置(包括尚未保存的设置)下会如何反应。", + "ja": "テキストを貼り付けて、現在の設定 (未保存の設定を含む) でインジケーターがどう反応するかを確認します。", + "ru": "Вставьте текст и проверьте, как отреагирует индикатор при текущих настройках (в том числе еще не сохраненных)." + }, + "Text, der an das Ende des Dateinamens vor die Dateiendung gehängt wird.": { + "de": "Text, der an das Ende des Dateinamens vor die Dateiendung gehängt wird.", + "en": "Text that is appended to the end of the file name, before the file extension.", + "es": "Texto que se añade al final del nombre de archivo, antes de la extensión.", + "zh": "附加在文件名末尾、扩展名之前的文本。", + "ja": "ファイル名の末尾、拡張子の前に追加されるテキストです。", + "ru": "Текст, добавляемый в конец имени файла перед расширением." + }, + "Text, der vor jeden Dateinamen eingefügt wird.": { + "de": "Text, der vor jeden Dateinamen eingefügt wird.", + "en": "Text that is inserted before each file name.", + "es": "Texto que se inserta antes de cada nombre de archivo.", + "zh": "插入到每个文件名之前的文本。", + "ja": "各ファイル名の先頭に挿入されるテキストです。", + "ru": "Текст, добавляемый в начало каждого имени файла." + }, + "Textvorschau ist in den Einstellungen deaktiviert.": { + "de": "Textvorschau ist in den Einstellungen deaktiviert.", + "en": "Text preview is disabled in the settings.", + "es": "La vista previa de texto está desactivada en la configuración.", + "zh": "文本预览已在设置中禁用。", + "ja": "テキストのプレビューは設定で無効になっています。", + "ru": "Предпросмотр текста отключен в настройках." + }, + "Titel der Vorlage eingeben": { + "de": "Titel der Vorlage eingeben", + "en": "Enter template title", + "es": "Introducir el título de la plantilla", + "zh": "输入模板标题", + "ja": "テンプレートのタイトルを入力", + "ru": "Введите название шаблона" + }, + "Titel des ausgewählten Prompts": { + "de": "Titel des ausgewählten Prompts", + "en": "Title of the selected prompt", + "es": "Título del prompt seleccionado", + "zh": "所选提示词的标题", + "ja": "選択したプロンプトのタイトル", + "ru": "Название выбранного промпта" + }, + "Umbenennungs-Vorschautabelle": { + "de": "Umbenennungs-Vorschautabelle", + "en": "Rename preview table", + "es": "Tabla de vista previa del cambio de nombre", + "zh": "重命名预览表", + "ja": "名前変更プレビュー表", + "ru": "Таблица предпросмотра переименования" + }, + "Ungespeicherte Änderungen": { + "de": "Ungespeicherte Änderungen", + "en": "Unsaved changes", + "es": "Cambios sin guardar", + "zh": "未保存的更改", + "ja": "未保存の変更", + "ru": "Несохранённые изменения" + }, + "Unterscheidet zwischen Groß- und Kleinbuchstaben beim Suchen.": { + "de": "Unterscheidet zwischen Groß- und Kleinbuchstaben beim Suchen.", + "en": "Distinguishes between uppercase and lowercase letters when searching.", + "es": "Distingue entre mayúsculas y minúsculas al buscar.", + "zh": "搜索时区分大小写。", + "ja": "検索時に大文字と小文字を区別します。", + "ru": "Учитывает регистр букв при поиске." + }, + "Validieren": { + "de": "Validieren", + "en": "Validate", + "es": "Validar", + "zh": "验证", + "ja": "検証", + "ru": "Проверить" + }, + "Validiert die Syntax der aktuellen Datei (z. B. JSON, TOML).": { + "de": "Validiert die Syntax der aktuellen Datei (z. B. JSON, TOML).", + "en": "Validates the syntax of the current file (e.g. JSON, TOML).", + "es": "Valida la sintaxis del archivo actual (p. ej., JSON, TOML).", + "zh": "验证当前文件的语法(例如 JSON、TOML)。", + "ja": "現在のファイルの構文を検証します(例:JSON、TOML)。", + "ru": "Проверяет синтаксис текущего файла (например, JSON, TOML)." + }, + "Variable: {name}": { + "de": "Variable: {name}", + "en": "Variable: {name}", + "es": "Variable: {name}", + "zh": "变量:{name}", + "ja": "変数:{name}", + "ru": "Переменная: {name}" + }, + "Variablen: {{variable}} werden beim Kopieren abgefragt": { + "de": "Variablen: {{variable}} werden beim Kopieren abgefragt", + "en": "Variables: {{variable}} will be requested when copying", + "es": "Variables: se solicitará {{variable}} al copiar", + "zh": "变量:复制时将询问 {{variable}}", + "ja": "変数:コピー時に {{variable}} の入力を求められます", + "ru": "Переменные: значения {{variable}} будут запрошены при копировании" + }, + "Vergleichsstatistik": { + "de": "Vergleichsstatistik", + "en": "Comparison statistics", + "es": "Estadísticas de comparación", + "zh": "比较统计", + "ja": "比較統計", + "ru": "Статистика сравнения" + }, + "Vergleichsstatus": { + "de": "Vergleichsstatus", + "en": "Comparison status", + "es": "Estado de la comparación", + "zh": "比较状态", + "ja": "比較ステータス", + "ru": "Состояние сравнения" + }, + "Vergleicht zwei Dateien zeilenweise mit visueller Gegenüberstellung und Syntax-Hervorhebung.": { + "de": "Vergleicht zwei Dateien zeilenweise mit visueller Gegenüberstellung und Syntax-Hervorhebung.", + "en": "Compares two files line by line with a visual side-by-side view and syntax highlighting.", + "es": "Compara dos archivos línea por línea con una vista comparativa visual y resaltado de sintaxis.", + "zh": "逐行比较两个文件,提供可视化对照和语法高亮。", + "ja": "2つのファイルを行ごとに比較し、並べて表示して構文を強調表示します。", + "ru": "Построчно сравнивает два файла с наглядным сопоставлением и подсветкой синтаксиса." + }, + "Verifikationsergebnis": { + "de": "Verifikationsergebnis", + "en": "Verification result", + "es": "Resultado de la verificación", + "zh": "验证结果", + "ja": "検証結果", + "ru": "Результат проверки" + }, + "Verknüpfung konnte nicht aufgelöst werden\noder das Ziel existiert nicht.": { + "de": "Verknüpfung konnte nicht aufgelöst werden\noder das Ziel existiert nicht.", + "en": "Shortcut could not be resolved\nor the target does not exist.", + "es": "No se pudo resolver el acceso directo\no el destino no existe.", + "zh": "快捷方式无法解析\n或目标不存在。", + "ja": "ショートカットを解決できませんでした\nまたはターゲットが存在しません。", + "ru": "Не удалось разрешить ярлык\nили целевой объект не существует." + }, + "Verknüpfung: {name}": { + "de": "Verknüpfung: {name}", + "en": "Shortcut: {name}", + "es": "Acceso directo: {name}", + "zh": "快捷方式:{name}", + "ja": "ショートカット:{name}", + "ru": "Ярлык: {name}" + }, + "Version {version}": { + "de": "Version {version}", + "en": "Version {version}", + "es": "Versión {version}", + "zh": "版本 {version}", + "ja": "バージョン {version}", + "ru": "Версия {version}" + }, + "Videos": { + "de": "Videos", + "en": "Videos", + "es": "Vídeos", + "zh": "视频", + "ja": "ビデオ", + "ru": "Видео" + }, + "Vollständiger Pfad in die Zwischenablage kopiert:\n\n{path}": { + "de": "Vollständiger Pfad in die Zwischenablage kopiert:\n\n{path}", + "en": "Full path copied to clipboard:\n\n{path}", + "es": "Ruta completa copiada al portapapeles:\n\n{path}", + "zh": "完整路径已复制到剪贴板:\n\n{path}", + "ja": "完全なパスをクリップボードにコピーしました:\n\n{path}", + "ru": "Полный путь скопирован в буфер обмена:\n\n{path}" + }, + "Volltextsuche": { + "de": "Volltextsuche", + "en": "Full-text search", + "es": "Búsqueda de texto completo", + "zh": "全文搜索", + "ja": "全文検索", + "ru": "Полнотекстовый поиск" + }, + "Vorschau anzeigen": { + "de": "Vorschau anzeigen", + "en": "Show preview", + "es": "Mostrar vista previa", + "zh": "显示预览", + "ja": "プレビューを表示", + "ru": "Показать предпросмотр" + }, + "Vorschau nicht verfügbar\nfür diesen Dateityp": { + "de": "Vorschau nicht verfügbar\nfür diesen Dateityp", + "en": "Preview not available\nfor this file type", + "es": "Vista previa no disponible\npara este tipo de archivo", + "zh": "此文件类型\n不支持预览", + "ja": "このファイル形式のプレビューは\n利用できません", + "ru": "Предварительный просмотр недоступен\nдля этого типа файла" + }, + "Vorschau nicht verfügbar: {reason}": { + "de": "Vorschau nicht verfügbar: {reason}", + "en": "Preview not available: {reason}", + "es": "Vista previa no disponible: {reason}", + "zh": "预览不可用:{reason}", + "ja": "プレビューは利用できません:{reason}", + "ru": "Предпросмотр недоступен: {reason}" + }, + "Vorschau-Hinweis": { + "de": "Vorschau-Hinweis", + "en": "Preview notice", + "es": "Aviso de vista previa", + "zh": "预览提示", + "ja": "プレビューの注意", + "ru": "Примечание к предпросмотру" + }, + "Vorschau: {path}": { + "de": "Vorschau: {path}", + "en": "Preview: {path}", + "es": "Vista previa: {path}", + "zh": "预览:{path}", + "ja": "プレビュー:{path}", + "ru": "Предпросмотр: {path}" + }, + "Vorschautabelle der Excel-Zellendaten.": { + "de": "Vorschautabelle der Excel-Zellendaten.", + "en": "Preview table of the Excel cell data.", + "es": "Tabla de vista previa de los datos de celdas de Excel.", + "zh": "Excel 单元格数据的预览表。", + "ja": "Excel セルデータのプレビュー表です。", + "ru": "Таблица предпросмотра данных ячеек Excel." + }, + "Vorschauänderungen synchronisieren": { + "de": "Vorschauänderungen synchronisieren", + "en": "Sync previewed changes", + "es": "Sincronizar los cambios de la vista previa", + "zh": "同步预览的更改", + "ja": "プレビューの変更を同期", + "ru": "Синхронизировать изменения из предпросмотра" + }, + "WARNUNG: {count} sensible Muster erkannt!": { + "de": "WARNUNG: {count} sensible Muster erkannt!", + "en": "WARNING: {count} sensitive patterns detected!", + "es": "ADVERTENCIA: ¡Se detectaron {count} patrones sensibles!", + "zh": "警告:检测到 {count} 个敏感模式!", + "ja": "警告:{count} 件の機密パターンを検出しました!", + "ru": "ПРЕДУПРЕЖДЕНИЕ: обнаружено конфиденциальных шаблонов: {count}!" + }, + "Wandelt die Schreibweise aller Dateinamen einheitlich um.": { + "de": "Wandelt die Schreibweise aller Dateinamen einheitlich um.", + "en": "Converts the capitalization of all file names uniformly.", + "es": "Convierte de forma uniforme el uso de mayúsculas y minúsculas de todos los nombres de archivo.", + "zh": "统一转换所有文件名的大小写格式。", + "ja": "すべてのファイル名の表記(大文字・小文字)を統一して変換します。", + "ru": "Единообразно преобразует написание (регистр) всех имён файлов." + }, + "Warnhinweis bei roter Ampel als Dialog anzeigen": { + "de": "Warnhinweis bei roter Ampel als Dialog anzeigen", + "en": "Show warning as a dialog when the traffic light is red", + "es": "Mostrar la advertencia como diálogo cuando el semáforo esté en rojo", + "zh": "红灯时以对话框形式显示警告", + "ja": "赤信号のときに警告をダイアログで表示", + "ru": "Показывать предупреждение в диалоге при красном светофоре" + }, + "Warnhinweis bei roter Ampel anzeigen": { + "de": "Warnhinweis bei roter Ampel anzeigen", + "en": "Show warning when the traffic light is red", + "es": "Mostrar advertencia cuando el semáforo esté en rojo", + "zh": "红灯时显示警告", + "ja": "赤信号のときに警告を表示", + "ru": "Показывать предупреждение при красном светофоре" + }, + "Warnung": { + "de": "Warnung", + "en": "Warning", + "es": "Advertencia", + "zh": "警告", + "ja": "警告", + "ru": "Предупреждение" + }, + "Warte auf Abschluss der Berechnung": { + "de": "Warte auf Abschluss der Berechnung", + "en": "Waiting for the calculation to finish", + "es": "Esperando a que finalice el cálculo", + "zh": "正在等待计算完成", + "ja": "計算の完了を待機中", + "ru": "Ожидание завершения вычисления" + }, + "Wechselt zur Seitenleiste für den Ordnerabgleich.": { + "de": "Wechselt zur Seitenleiste für den Ordnerabgleich.", + "en": "Switches to the sidebar for folder synchronization.", + "es": "Cambia a la barra lateral de sincronización de carpetas.", + "zh": "切换到用于文件夹同步的侧边栏。", + "ja": "フォルダー同期用のサイドバーに切り替えます。", + "ru": "Переключает на боковую панель синхронизации папок." + }, + "Wechselt zur Seitenleiste für die Volltext- und Dateisuche.": { + "de": "Wechselt zur Seitenleiste für die Volltext- und Dateisuche.", + "en": "Switches to the sidebar for full-text and file search.", + "es": "Cambia a la barra lateral de búsqueda de texto completo y de archivos.", + "zh": "切换到用于全文和文件搜索的侧边栏。", + "ja": "全文検索とファイル検索用のサイドバーに切り替えます。", + "ru": "Переключает на боковую панель полнотекстового поиска и поиска файлов." + }, + "Wechselt zur Seitenleiste mit Laufwerken und Schnellzugriff.": { + "de": "Wechselt zur Seitenleiste mit Laufwerken und Schnellzugriff.", + "en": "Switches to the sidebar with drives and quick access.", + "es": "Cambia a la barra lateral con unidades y acceso rápido.", + "zh": "切换到包含驱动器和快速访问的侧边栏。", + "ja": "ドライブとクイックアクセスのサイドバーに切り替えます。", + "ru": "Переключает на боковую панель с дисками и быстрым доступом." + }, + "Wechselt zur Seitenleiste mit gespeicherten Favoritenordnern.": { + "de": "Wechselt zur Seitenleiste mit gespeicherten Favoritenordnern.", + "en": "Switches to the sidebar with saved favorite folders.", + "es": "Cambia a la barra lateral con las carpetas favoritas guardadas.", + "zh": "切换到包含已保存收藏文件夹的侧边栏。", + "ja": "保存したお気に入りフォルダーのサイドバーに切り替えます。", + "ru": "Переключает на боковую панель с сохранёнными избранными папками." + }, + "Wechselt zur Seitenleiste mit häufig genutzten Programmen.": { + "de": "Wechselt zur Seitenleiste mit häufig genutzten Programmen.", + "en": "Switches to the sidebar with frequently used programs.", + "es": "Cambia a la barra lateral con los programas de uso frecuente.", + "zh": "切换到包含常用程序的侧边栏。", + "ja": "よく使うプログラムのサイドバーに切り替えます。", + "ru": "Переключает на боковую панель с часто используемыми программами." + }, + "Wechselt zur Seitenleiste mit lokalen Prompt-Vorlagen.": { + "de": "Wechselt zur Seitenleiste mit lokalen Prompt-Vorlagen.", + "en": "Switches to the sidebar with local prompt templates.", + "es": "Cambia a la barra lateral con las plantillas de prompts locales.", + "zh": "切换到包含本地提示词模板的侧边栏。", + "ja": "ローカルのプロンプトテンプレートのサイドバーに切り替えます。", + "ru": "Переключает на боковую панель с локальными шаблонами промптов." + }, + "Wenn aktiviert, sucht ExplorerPro nur im Dateiinhalt und nicht im Dateinamen.": { + "de": "Wenn aktiviert, sucht ExplorerPro nur im Dateiinhalt und nicht im Dateinamen.", + "en": "When enabled, ExplorerPro searches only in the file content and not in the file name.", + "es": "Si está activado, ExplorerPro busca solo en el contenido del archivo y no en el nombre.", + "zh": "启用后,ExplorerPro 仅在文件内容中搜索,而不在文件名中搜索。", + "ja": "有効にすると、ExplorerPro はファイル名ではなくファイルの内容のみを検索します。", + "ru": "Если включено, ExplorerPro ищет только в содержимом файлов, а не в их именах." + }, + "Wert für '{name}' eingeben:": { + "de": "Wert für '{name}' eingeben:", + "en": "Enter a value for '{name}':", + "es": "Introduzca un valor para '{name}':", + "zh": "输入“{name}”的值:", + "ja": "'{name}' の値を入力:", + "ru": "Введите значение для '{name}':" + }, + "Whitelist": { + "de": "Whitelist", + "en": "Whitelist", + "es": "Lista blanca", + "zh": "白名单", + "ja": "ホワイトリスト", + "ru": "Белый список" + }, + "Whitelist-Einträge": { + "de": "Whitelist-Einträge", + "en": "Whitelist entries", + "es": "Entradas de la lista blanca", + "zh": "白名单条目", + "ja": "ホワイトリストのエントリ", + "ru": "Записи белого списка" + }, + "Wird berechnet": { + "de": "Wird berechnet", + "en": "Calculating", + "es": "Calculando", + "zh": "正在计算", + "ja": "計算中", + "ru": "Вычисляется" + }, + "Wählt alle Duplikate außer dem jeweils ersten Eintrag jeder Gruppe zum Löschen aus.": { + "de": "Wählt alle Duplikate außer dem jeweils ersten Eintrag jeder Gruppe zum Löschen aus.", + "en": "Selects all duplicates for deletion except the first entry of each group.", + "es": "Selecciona para eliminar todos los duplicados excepto la primera entrada de cada grupo.", + "zh": "选择每组中除第一项以外的所有重复项进行删除。", + "ja": "各グループの最初のエントリを除くすべての重複を削除対象として選択します。", + "ru": "Выбирает для удаления все дубликаты, кроме первой записи в каждой группе." + }, + "Wählt neuere Duplikate aus und behält die älteste Datei.": { + "de": "Wählt neuere Duplikate aus und behält die älteste Datei.", + "en": "Selects newer duplicates and keeps the oldest file.", + "es": "Selecciona los duplicados más recientes y conserva el archivo más antiguo.", + "zh": "选择较新的重复项并保留最旧的文件。", + "ja": "新しい重複を選択し、最も古いファイルを残します。", + "ru": "Выбирает более новые дубликаты и сохраняет самый старый файл." + }, + "Wählt ältere Duplikate aus und behält die neueste Datei.": { + "de": "Wählt ältere Duplikate aus und behält die neueste Datei.", + "en": "Selects older duplicates and keeps the newest file.", + "es": "Selecciona los duplicados más antiguos y conserva el archivo más reciente.", + "zh": "选择较旧的重复项并保留最新的文件。", + "ja": "古い重複を選択し、最も新しいファイルを残します。", + "ru": "Выбирает более старые дубликаты и сохраняет самый новый файл." + }, + "Zeichenkodierung": { + "de": "Zeichenkodierung", + "en": "Character encoding", + "es": "Codificación de caracteres", + "zh": "字符编码", + "ja": "文字エンコーディング", + "ru": "Кодировка символов" + }, + "Zeigt Originalnamen, vorgeschlagene neue Namen und eventuelle Konflikte.": { + "de": "Zeigt Originalnamen, vorgeschlagene neue Namen und eventuelle Konflikte.", + "en": "Shows original names, proposed new names and any conflicts.", + "es": "Muestra los nombres originales, los nuevos nombres propuestos y los posibles conflictos.", + "zh": "显示原始名称、建议的新名称以及可能的冲突。", + "ja": "元の名前、提案された新しい名前、および競合がある場合はその内容を表示します。", + "ru": "Показывает исходные имена, предлагаемые новые имена и возможные конфликты." + }, + "Zeigt das aktuelle Arbeitsverzeichnis oder den Systemstatus an.": { + "de": "Zeigt das aktuelle Arbeitsverzeichnis oder den Systemstatus an.", + "en": "Shows the current working directory or the system status.", + "es": "Muestra el directorio de trabajo actual o el estado del sistema.", + "zh": "显示当前工作目录或系统状态。", + "ja": "現在の作業ディレクトリまたはシステムの状態を表示します。", + "ru": "Показывает текущий рабочий каталог или состояние системы." + }, + "Zeigt den aktuellen Ordnerpfad und erlaubt direkte Navigation per Enter.": { + "de": "Zeigt den aktuellen Ordnerpfad und erlaubt direkte Navigation per Enter.", + "en": "Shows the current folder path and allows direct navigation by pressing Enter.", + "es": "Muestra la ruta de la carpeta actual y permite navegar directamente pulsando Enter.", + "zh": "显示当前文件夹路径,并允许按 Enter 键直接导航。", + "ja": "現在のフォルダーパスを表示し、Enter キーで直接移動できます。", + "ru": "Показывает путь к текущей папке и позволяет перейти по нему нажатием Enter." + }, + "Zeile: 1, Spalte: 1": { + "de": "Zeile: 1, Spalte: 1", + "en": "Line: 1, Column: 1", + "es": "Línea: 1, Columna: 1", + "zh": "行:1,列:1", + "ja": "行:1、列:1", + "ru": "Строка: 1, столбец: 1" + }, + "Zeile: {line}, Spalte: {column}": { + "de": "Zeile: {line}, Spalte: {column}", + "en": "Line: {line}, Column: {column}", + "es": "Línea: {line}, Columna: {column}", + "zh": "行:{line},列:{column}", + "ja": "行:{line}、列:{column}", + "ru": "Строка: {line}, столбец: {column}" + }, + "Zeilennummer": { + "de": "Zeilennummer", + "en": "Line number", + "es": "Número de línea", + "zh": "行号", + "ja": "行番号", + "ru": "Номер строки" + }, + "Zeilenweiser Vergleich beider Dateien: Grün = hinzugefügt, Rot = entfernt.": { + "de": "Zeilenweiser Vergleich beider Dateien: Grün = hinzugefügt, Rot = entfernt.", + "en": "Line-by-line comparison of both files: green = added, red = removed.", + "es": "Comparación línea por línea de ambos archivos: verde = añadido, rojo = eliminado.", + "zh": "两个文件的逐行比较:绿色 = 已添加,红色 = 已删除。", + "ja": "両方のファイルの行単位の比較:緑 = 追加、赤 = 削除。", + "ru": "Построчное сравнение обоих файлов: зелёный = добавлено, красный = удалено." + }, + "Ziel gewinnt immer": { + "de": "Ziel gewinnt immer", + "en": "Target always wins", + "es": "El destino siempre prevalece", + "zh": "始终以目标为准", + "ja": "常にターゲットを優先", + "ru": "Целевая папка всегда приоритетна" + }, + "Ziel → Quelle": { + "de": "Ziel → Quelle", + "en": "Target → Source", + "es": "Destino → Origen", + "zh": "目标 → 源", + "ja": "ターゲット → ソース", + "ru": "Цель → Источник" + }, + "Ziel: {path}": { + "de": "Ziel: {path}", + "en": "Target: {path}", + "es": "Destino: {path}", + "zh": "目标:{path}", + "ja": "ターゲット:{path}", + "ru": "Цель: {path}" + }, + "Zielordner": { + "de": "Zielordner", + "en": "Target folder", + "es": "Carpeta de destino", + "zh": "目标文件夹", + "ja": "ターゲットフォルダー", + "ru": "Целевая папка" + }, + "Zielordner existiert nicht: {path}": { + "de": "Zielordner existiert nicht: {path}", + "en": "Target folder does not exist: {path}", + "es": "La carpeta de destino no existe: {path}", + "zh": "目标文件夹不存在:{path}", + "ja": "ターゲットフォルダーが存在しません:{path}", + "ru": "Целевая папка не существует: {path}" + }, + "Zu Favoriten hinzufügen": { + "de": "Zu Favoriten hinzufügen", + "en": "Add to favorites", + "es": "Agregar a favoritos", + "zh": "添加到收藏夹", + "ja": "お気に入りに追加", + "ru": "Добавить в избранное" + }, + "Zu suchender Text im ursprünglichen Dateinamen.": { + "de": "Zu suchender Text im ursprünglichen Dateinamen.", + "en": "Text to search for in the original file name.", + "es": "Texto que se buscará en el nombre de archivo original.", + "zh": "要在原始文件名中搜索的文本。", + "ja": "元のファイル名内で検索するテキストです。", + "ru": "Текст для поиска в исходном имени файла." + }, + "Zum Öffnen doppelklicken – die Datei wird dabei heruntergeladen.": { + "de": "Zum Öffnen doppelklicken – die Datei wird dabei heruntergeladen.", + "en": "Double-click to open – the file will be downloaded.", + "es": "Haga doble clic para abrir: el archivo se descargará.", + "zh": "双击打开 – 文件将被下载。", + "ja": "ダブルクリックで開きます – ファイルがダウンロードされます。", + "ru": "Дважды щёлкните, чтобы открыть – файл будет загружен." + }, + "Zur Blacklist hinzufügen": { + "de": "Zur Blacklist hinzufügen", + "en": "Add to Blacklist", + "es": "Agregar a la lista negra", + "zh": "添加到黑名单", + "ja": "ブラックリストに追加", + "ru": "Добавить в чёрный список" + }, + "Zusammenfassung der Umbenennung": { + "de": "Zusammenfassung der Umbenennung", + "en": "Rename summary", + "es": "Resumen del cambio de nombre", + "zh": "重命名摘要", + "ja": "名前変更の概要", + "ru": "Сводка переименования" + }, + "Zusätzliche Befehlszeilenargumente für den Programmaufruf.": { + "de": "Zusätzliche Befehlszeilenargumente für den Programmaufruf.", + "en": "Additional command-line arguments for launching the program.", + "es": "Argumentos de línea de comandos adicionales para iniciar el programa.", + "zh": "启动程序时使用的附加命令行参数。", + "ja": "プログラム起動時に渡す追加のコマンドライン引数です。", + "ru": "Дополнительные аргументы командной строки для запуска программы." + }, + "Zwischenablage automatisch leeren, wenn sensible Daten erkannt werden": { + "de": "Zwischenablage automatisch leeren, wenn sensible Daten erkannt werden", + "en": "Automatically clear clipboard when sensitive data is detected", + "es": "Vaciar automáticamente el portapapeles cuando se detecten datos sensibles", + "zh": "检测到敏感数据时自动清空剪贴板", + "ja": "機密データを検出したらクリップボードを自動的にクリア", + "ru": "Автоматически очищать буфер обмена при обнаружении конфиденциальных данных" + }, + "tag1, tag2,": { + "de": "tag1, tag2,", + "en": "tag1, tag2,", + "es": "tag1, tag2,", + "zh": "tag1, tag2,", + "ja": "tag1, tag2,", + "ru": "tag1, tag2," + }, + "tag1, tag2, tag3": { + "de": "tag1, tag2, tag3", + "en": "tag1, tag2, tag3", + "es": "tag1, tag2, tag3", + "zh": "tag1, tag2, tag3", + "ja": "tag1, tag2, tag3", + "ru": "tag1, tag2, tag3" + }, + "z.B. --verbose --config=config.ini": { + "de": "z.B. --verbose --config=config.ini", + "en": "e.g. --verbose --config=config.ini", + "es": "p. ej. --verbose --config=config.ini", + "zh": "例如 --verbose --config=config.ini", + "ja": "例:--verbose --config=config.ini", + "ru": "напр. --verbose --config=config.ini" + }, + "{added} neue Begriffe übernommen ({total} in der Datei).": { + "de": "{added} neue Begriffe übernommen ({total} in der Datei).", + "en": "{added} new terms added ({total} in the file).", + "es": "Se añadieron {added} términos nuevos ({total} en el archivo).", + "zh": "已添加 {added} 个新词条(文件中共 {total} 个)。", + "ja": "{added} 件の新しい用語を追加しました(ファイル内は合計 {total} 件)。", + "ru": "Добавлено новых терминов: {added} (всего в файле: {total})." + }, + "{algorithm} Prüfsumme": { + "de": "{algorithm} Prüfsumme", + "en": "{algorithm} checksum", + "es": "Suma de comprobación {algorithm}", + "zh": "{algorithm} 校验和", + "ja": "{algorithm} チェックサム", + "ru": "Контрольная сумма {algorithm}" + }, + "{algorithm} Prüfsumme kopieren": { + "de": "{algorithm} Prüfsumme kopieren", + "en": "Copy {algorithm} checksum", + "es": "Copiar suma de comprobación {algorithm}", + "zh": "复制 {algorithm} 校验和", + "ja": "{algorithm} チェックサムをコピー", + "ru": "Копировать контрольную сумму {algorithm}" + }, + "{algorithm}-Prüfsumme in die Zwischenablage kopieren": { + "de": "{algorithm}-Prüfsumme in die Zwischenablage kopieren", + "en": "Copy {algorithm} checksum to clipboard", + "es": "Copiar la suma de comprobación {algorithm} al portapapeles", + "zh": "将 {algorithm} 校验和复制到剪贴板", + "ja": "{algorithm} チェックサムをクリップボードにコピー", + "ru": "Скопировать контрольную сумму {algorithm} в буфер обмена" + }, + "{count} Dateien ausgewählt": { + "de": "{count} Dateien ausgewählt", + "en": "{count} files selected", + "es": "{count} archivos seleccionados", + "zh": "已选择 {count} 个文件", + "ja": "{count} 個のファイルを選択", + "ru": "Выбрано файлов: {count}" + }, + "{count} Dateien erfolgreich umbenannt.": { + "de": "{count} Dateien erfolgreich umbenannt.", + "en": "{count} files successfully renamed.", + "es": "{count} archivos renombrados correctamente.", + "zh": "已成功重命名 {count} 个文件。", + "ja": "{count} 個のファイルの名前を変更しました。", + "ru": "Успешно переименовано файлов: {count}." + }, + "{count} Dateien indiziert": { + "de": "{count} Dateien indiziert", + "en": "{count} files indexed", + "es": "{count} archivos indexados", + "zh": "已索引 {count} 个文件", + "ja": "{count} 個のファイルをインデックス済み", + "ru": "Проиндексировано файлов: {count}" + }, + "{count} Dateien umbenannt, aber Fehler aufgetreten:\n\n{errors}": { + "de": "{count} Dateien umbenannt, aber Fehler aufgetreten:\n\n{errors}", + "en": "{count} files renamed, but errors occurred:\n\n{errors}", + "es": "{count} archivos renombrados, pero se produjeron errores:\n\n{errors}", + "zh": "已重命名 {count} 个文件,但发生错误:\n\n{errors}", + "ja": "{count} 個のファイルの名前を変更しましたが、エラーが発生しました:\n\n{errors}", + "ru": "Переименовано файлов: {count}, но возникли ошибки:\n\n{errors}" + }, + "{count} Dateien werden umbenannt.": { + "de": "{count} Dateien werden umbenannt.", + "en": "{count} files will be renamed.", + "es": "Se renombrarán {count} archivos.", + "zh": "将重命名 {count} 个文件。", + "ja": "{count} 個のファイルの名前が変更されます。", + "ru": "Будет переименовано файлов: {count}." + }, + "{count} Dateien wurden auf die ursprünglichen Namen zurückgesetzt.": { + "de": "{count} Dateien wurden auf die ursprünglichen Namen zurückgesetzt.", + "en": "{count} files were restored to their original names.", + "es": "{count} archivos se restauraron a sus nombres originales.", + "zh": "已将 {count} 个文件恢复为原始名称。", + "ja": "{count} 個のファイルを元の名前に戻しました。", + "ru": "Файлов, возвращённых к исходным именам: {count}." + }, + "{count} Dateien wurden gelöscht.\nFreigegeben: {size}": { + "de": "{count} Dateien wurden gelöscht.\nFreigegeben: {size}", + "en": "{count} files were deleted.\nFreed: {size}", + "es": "Se eliminaron {count} archivos.\nEspacio liberado: {size}", + "zh": "已删除 {count} 个文件。\n已释放:{size}", + "ja": "{count} 個のファイルを削除しました。\n解放された容量:{size}", + "ru": "Удалено файлов: {count}.\nОсвобождено: {size}" + }, + "{count} Dateien zurückgesetzt, Fehler:\n{errors}": { + "de": "{count} Dateien zurückgesetzt, Fehler:\n{errors}", + "en": "{count} files restored, errors:\n{errors}", + "es": "{count} archivos restaurados, errores:\n{errors}", + "zh": "已恢复 {count} 个文件,错误:\n{errors}", + "ja": "{count} 個のファイルを元に戻しました。エラー:\n{errors}", + "ru": "Файлов восстановлено: {count}, ошибки:\n{errors}" + }, + "{count} Einträge": { + "de": "{count} Einträge", + "en": "{count} entries", + "es": "{count} entradas", + "zh": "{count} 个条目", + "ja": "{count} 件のエントリ", + "ru": "Записей: {count}" + }, + "{count} Elemente": { + "de": "{count} Elemente", + "en": "{count} items", + "es": "{count} elementos", + "zh": "{count} 个项目", + "ja": "{count} 個のアイテム", + "ru": "Элементов: {count}" + }, + "{count} Ergebnis": { + "de": "{count} Ergebnis", + "en": "{count} result", + "es": "{count} resultado", + "zh": "{count} 个结果", + "ja": "{count} 件の結果", + "ru": "Результатов: {count}" + }, + "{count} Ergebnis gefunden": { + "de": "{count} Ergebnis gefunden", + "en": "{count} result found", + "es": "{count} resultado encontrado", + "zh": "找到 {count} 个结果", + "ja": "{count} 件の結果が見つかりました", + "ru": "Найдено результатов: {count}" + }, + "{count} Ergebnisse": { + "de": "{count} Ergebnisse", + "en": "{count} results", + "es": "{count} resultados", + "zh": "{count} 个结果", + "ja": "{count} 件の結果", + "ru": "Результатов: {count}" + }, + "{count} Ergebnisse gefunden": { + "de": "{count} Ergebnisse gefunden", + "en": "{count} results found", + "es": "{count} resultados encontrados", + "zh": "找到 {count} 个结果", + "ja": "{count} 件の結果が見つかりました", + "ru": "Найдено результатов: {count}" + }, + "{count} Treffer für: {query}": { + "de": "{count} Treffer für: {query}", + "en": "{count} matches for: {query}", + "es": "{count} coincidencias para: {query}", + "zh": "“{query}”的 {count} 个匹配项:", + "ja": "{query} の一致:{count} 件", + "ru": "Совпадений для «{query}»: {count}" + }, + "{count} zu ändern, Konflikte oder ungültige Namen gefunden!": { + "de": "{count} zu ändern, Konflikte oder ungültige Namen gefunden!", + "en": "{count} to change, conflicts or invalid names found!", + "es": "{count} por cambiar, ¡se encontraron conflictos o nombres no válidos!", + "zh": "{count} 个待更改,发现冲突或无效名称!", + "ja": "変更対象 {count} 件、競合または無効な名前が見つかりました!", + "ru": "К изменению: {count}, обнаружены конфликты или недопустимые имена!" + }, + "{deleted} von {total} Dateien gelöscht.\n\nFehler:\n{errors}": { + "de": "{deleted} von {total} Dateien gelöscht.\n\nFehler:\n{errors}", + "en": "{deleted} of {total} files deleted.\n\nErrors:\n{errors}", + "es": "Se eliminaron {deleted} de {total} archivos.\n\nErrores:\n{errors}", + "zh": "已删除 {total} 个文件中的 {deleted} 个。\n\n错误:\n{errors}", + "ja": "{total} 個中 {deleted} 個のファイルを削除しました。\n\nエラー:\n{errors}", + "ru": "Удалено файлов: {deleted} из {total}.\n\nОшибки:\n{errors}" + }, + "{files} Dateien, {folders} Ordner": { + "de": "{files} Dateien, {folders} Ordner", + "en": "{files} files, {folders} folders", + "es": "{files} archivos, {folders} carpetas", + "zh": "{files} 个文件,{folders} 个文件夹", + "ja": "{files} 個のファイル、{folders} 個のフォルダー", + "ru": "Файлов: {files}, папок: {folders}" + }, + "{mode}: Analysiere...": { + "de": "{mode}: Analysiere...", + "en": "{mode}: Analyzing...", + "es": "{mode}: Analizando...", + "zh": "{mode}:正在分析...", + "ja": "{mode}:分析中...", + "ru": "{mode}: Анализ..." + }, + "{name1} ({size1} Bytes) ↔ {name2} ({size2} Bytes)": { + "de": "{name1} ({size1} Bytes) ↔ {name2} ({size2} Bytes)", + "en": "{name1} ({size1} bytes) ↔ {name2} ({size2} bytes)", + "es": "{name1} ({size1} bytes) ↔ {name2} ({size2} bytes)", + "zh": "{name1}({size1} 字节)↔ {name2}({size2} 字节)", + "ja": "{name1}({size1} バイト)↔ {name2}({size2} バイト)", + "ru": "{name1} ({size1} байт) ↔ {name2} ({size2} байт)" + }, + "{selected} von {count} ausgewählt": { + "de": "{selected} von {count} ausgewählt", + "en": "{selected} of {count} selected", + "es": "{selected} de {count} seleccionados", + "zh": "已选择 {selected}/{count}", + "ja": "{count} 個中 {selected} 個を選択", + "ru": "Выбрано {selected} из {count}" + }, + "{shown} von {total} Prompts": { + "de": "{shown} von {total} Prompts", + "en": "{shown} of {total} prompts", + "es": "{shown} de {total} prompts", + "zh": "{total} 个提示词中的 {shown} 个", + "ja": "{total} 件中 {shown} 件のプロンプト", + "ru": "{shown} из {total} промптов" + }, + "{type}-Datei ({extension})": { + "de": "{type}-Datei ({extension})", + "en": "{type} file ({extension})", + "es": "Archivo {type} ({extension})", + "zh": "{type} 文件 ({extension})", + "ja": "{type} ファイル ({extension})", + "ru": "Файл {type} ({extension})" + }, + "Änderungen anwenden, ohne den Dialog zu schließen": { + "de": "Änderungen anwenden, ohne den Dialog zu schließen", + "en": "Apply changes without closing the dialog", + "es": "Aplicar los cambios sin cerrar el cuadro de diálogo", + "zh": "应用更改而不关闭对话框", + "ja": "ダイアログを閉じずに変更を適用", + "ru": "Применить изменения, не закрывая диалог" + }, + "Änderungsstatus": { + "de": "Änderungsstatus", + "en": "Change status", + "es": "Estado de los cambios", + "zh": "更改状态", + "ja": "変更ステータス", + "ru": "Состояние изменений" + }, + "Änderungszeitraum": { + "de": "Änderungszeitraum", + "en": "Modification period", + "es": "Período de modificación", + "zh": "修改时间范围", + "ja": "更新期間", + "ru": "Период изменения" + }, + "Öffnet das Menü für Ansichtsoptionen sowie App-, Prompt- und Sync-Bereiche.": { + "de": "Öffnet das Menü für Ansichtsoptionen sowie App-, Prompt- und Sync-Bereiche.", + "en": "Opens the menu for view options as well as the app, prompt and sync areas.", + "es": "Abre el menú con las opciones de vista y las áreas de la aplicación, de prompts y de sincronización.", + "zh": "打开包含视图选项以及应用、提示词和同步区域的菜单。", + "ja": "表示オプションと、アプリ・プロンプト・同期の各領域のメニューを開きます。", + "ru": "Открывает меню с параметрами вида, а также разделами приложения, промптов и синхронизации." + }, + "Öffnet den Dialog mit erweiterten Suchfiltern wie Größe, Datum und regulären Ausdrücken.": { + "de": "Öffnet den Dialog mit erweiterten Suchfiltern wie Größe, Datum und regulären Ausdrücken.", + "en": "Opens the dialog with advanced search filters such as size, date and regular expressions.", + "es": "Abre el cuadro de diálogo con filtros de búsqueda avanzados, como tamaño, fecha y expresiones regulares.", + "zh": "打开包含大小、日期和正则表达式等高级搜索筛选条件的对话框。", + "ja": "サイズ、日付、正規表現などの詳細な検索フィルターのダイアログを開きます。", + "ru": "Открывает диалог с расширенными фильтрами поиска, такими как размер, дата и регулярные выражения." + }, + "Öffnet den Dialog zum Anlegen eines neuen Synchronisationspaars": { + "de": "Öffnet den Dialog zum Anlegen eines neuen Synchronisationspaars", + "en": "Opens the dialog for creating a new sync pair", + "es": "Abre el cuadro de diálogo para crear un nuevo par de sincronización", + "zh": "打开用于创建新同步对的对话框", + "ja": "新しい同期ペアを作成するダイアログを開きます", + "ru": "Открывает диалог создания новой пары синхронизации" + }, + "Öffnet den Dialog zum Erstellen einer neuen Prompt-Vorlage.": { + "de": "Öffnet den Dialog zum Erstellen einer neuen Prompt-Vorlage.", + "en": "Opens the dialog for creating a new prompt template.", + "es": "Abre el cuadro de diálogo para crear una nueva plantilla de prompt.", + "zh": "打开用于创建新提示词模板的对话框。", + "ja": "新しいプロンプトテンプレートを作成するダイアログを開きます。", + "ru": "Открывает диалог создания нового шаблона промпта." + }, + "Öffnet den Dialog zum Hinzufügen einer Anwendung zum Starter.": { + "de": "Öffnet den Dialog zum Hinzufügen einer Anwendung zum Starter.", + "en": "Opens the dialog for adding an application to the launcher.", + "es": "Abre el cuadro de diálogo para añadir una aplicación al lanzador.", + "zh": "打开用于向启动器添加应用程序的对话框。", + "ja": "ランチャーにアプリケーションを追加するダイアログを開きます。", + "ru": "Открывает диалог добавления приложения в панель запуска." + }, + "Öffnet den Dialog zur Berechnung und Verifizierung von Hash-Prüfsummen.": { + "de": "Öffnet den Dialog zur Berechnung und Verifizierung von Hash-Prüfsummen.", + "en": "Opens the dialog for calculating and verifying hash checksums.", + "es": "Abre el cuadro de diálogo para calcular y verificar sumas de comprobación hash.", + "zh": "打开用于计算和验证哈希校验和的对话框。", + "ja": "ハッシュチェックサムの計算と検証を行うダイアログを開きます。", + "ru": "Открывает диалог вычисления и проверки хеш-сумм." + }, + "Öffnet die Datenschutz-Einstellungen mit Black- und Whitelist": { + "de": "Öffnet die Datenschutz-Einstellungen mit Black- und Whitelist", + "en": "Opens the privacy settings with Blacklist and Whitelist", + "es": "Abre la configuración de privacidad con la lista negra y la lista blanca", + "zh": "打开包含黑名单和白名单的隐私设置", + "ja": "ブラックリストとホワイトリストを含むプライバシー設定を開きます", + "ru": "Открывает настройки конфиденциальности с чёрным и белым списками" + }, + "Öffnet die Ordnerauswahl für den Quellordner": { + "de": "Öffnet die Ordnerauswahl für den Quellordner", + "en": "Opens the folder picker for the source folder", + "es": "Abre el selector de carpetas para la carpeta de origen", + "zh": "打开源文件夹的文件夹选择器", + "ja": "ソースフォルダーのフォルダー選択を開きます", + "ru": "Открывает выбор папки для исходной папки" + }, + "Öffnet die Ordnerauswahl für den Zielordner": { + "de": "Öffnet die Ordnerauswahl für den Zielordner", + "en": "Opens the folder picker for the target folder", + "es": "Abre el selector de carpetas para la carpeta de destino", + "zh": "打开目标文件夹的文件夹选择器", + "ja": "ターゲットフォルダーのフォルダー選択を開きます", + "ru": "Открывает выбор папки для целевой папки" + }, + "Öffnet einen Dialog zur Auswahl des zu scannenden Verzeichnisses.": { + "de": "Öffnet einen Dialog zur Auswahl des zu scannenden Verzeichnisses.", + "en": "Opens a dialog for selecting the directory to scan.", + "es": "Abre un cuadro de diálogo para seleccionar el directorio que se va a escanear.", + "zh": "打开用于选择要扫描的目录的对话框。", + "ja": "スキャンするディレクトリを選択するダイアログを開きます。", + "ru": "Открывает диалог выбора каталога для сканирования." + }, + "Über ExplorerPro": { + "de": "Über ExplorerPro", + "en": "About ExplorerPro", + "es": "Acerca de ExplorerPro", + "zh": "关于 ExplorerPro", + "ja": "ExplorerPro について", + "ru": "О программе ExplorerPro" + }, + "Übereinstimmung gefunden! ({algorithm})": { + "de": "Übereinstimmung gefunden! ({algorithm})", + "en": "Match found! ({algorithm})", + "es": "¡Coincidencia encontrada! ({algorithm})", + "zh": "找到匹配项!({algorithm})", + "ja": "一致しました!({algorithm})", + "ru": "Совпадение найдено! ({algorithm})" + }, + "Überträgt alle in der Vorschau gezeigten Änderungen": { + "de": "Überträgt alle in der Vorschau gezeigten Änderungen", + "en": "Applies all changes shown in the preview", + "es": "Aplica todos los cambios mostrados en la vista previa", + "zh": "应用预览中显示的所有更改", + "ja": "プレビューに表示されたすべての変更を適用します", + "ru": "Применяет все изменения, показанные в предпросмотре" + }, + "▶ Starte: {path}": { + "de": "▶ Starte: {path}", + "en": "▶ Starting: {path}", + "es": "▶ Iniciando: {path}", + "zh": "▶ 正在启动:{path}", + "ja": "▶ 起動中:{path}", + "ru": "▶ Запуск: {path}" + }, + "⚠️ {synced} synchronisiert, {errors} Fehler": { + "de": "⚠️ {synced} synchronisiert, {errors} Fehler", + "en": "⚠️ {synced} synchronized, {errors} errors", + "es": "⚠️ {synced} sincronizados, {errors} errores", + "zh": "⚠️ 已同步 {synced} 个,{errors} 个错误", + "ja": "⚠️ {synced} 件を同期、エラー {errors} 件", + "ru": "⚠️ Синхронизировано: {synced}, ошибок: {errors}" + }, + "✅ '{title}' kopiert!": { + "de": "✅ '{title}' kopiert!", + "en": "✅ '{title}' copied!", + "es": "✅ ¡'{title}' copiado!", + "zh": "✅ 已复制“{title}”!", + "ja": "✅ '{title}' をコピーしました!", + "ru": "✅ '{title}' скопировано!" + }, + "✅ Keine Duplikate gefunden ({count} Dateien geprüft)": { + "de": "✅ Keine Duplikate gefunden ({count} Dateien geprüft)", + "en": "✅ No duplicates found ({count} files checked)", + "es": "✅ No se encontraron duplicados ({count} archivos comprobados)", + "zh": "✅ 未发现重复项(已检查 {count} 个文件)", + "ja": "✅ 重複は見つかりませんでした({count} 個のファイルを確認)", + "ru": "✅ Дубликаты не найдены (проверено файлов: {count})" + }, + "✅ {count} Dateien synchronisiert": { + "de": "✅ {count} Dateien synchronisiert", + "en": "✅ {count} files synchronized", + "es": "✅ {count} archivos sincronizados", + "zh": "✅ 已同步 {count} 个文件", + "ja": "✅ {count} 個のファイルを同期しました", + "ru": "✅ Синхронизировано файлов: {count}" + }, + "✅ {groups} Duplikat-Gruppen gefunden ({count} doppelte Dateien)": { + "de": "✅ {groups} Duplikat-Gruppen gefunden ({count} doppelte Dateien)", + "en": "✅ {groups} duplicate groups found ({count} duplicate files)", + "es": "✅ Se encontraron {groups} grupos de duplicados ({count} archivos duplicados)", + "zh": "✅ 发现 {groups} 个重复组({count} 个重复文件)", + "ja": "✅ 重複グループが {groups} 件見つかりました(重複ファイル {count} 個)", + "ru": "✅ Найдено групп дубликатов: {groups} (дублирующихся файлов: {count})" + }, + "✓ Beendet (Exit-Code: {code})": { + "de": "✓ Beendet (Exit-Code: {code})", + "en": "✓ Finished (exit code: {code})", + "es": "✓ Finalizado (código de salida: {code})", + "zh": "✓ 已结束(退出代码:{code})", + "ja": "✓ 終了しました(終了コード:{code})", + "ru": "✓ Завершено (код выхода: {code})" + }, + "✓ Gespeichert: {path}": { + "de": "✓ Gespeichert: {path}", + "en": "✓ Saved: {path}", + "es": "✓ Guardado: {path}", + "zh": "✓ 已保存:{path}", + "ja": "✓ 保存しました:{path}", + "ru": "✓ Сохранено: {path}" + }, + "✗ Beendet mit Fehler (Exit-Code: {code})": { + "de": "✗ Beendet mit Fehler (Exit-Code: {code})", + "en": "✗ Finished with error (exit code: {code})", + "es": "✗ Finalizado con error (código de salida: {code})", + "zh": "✗ 因错误而结束(退出代码:{code})", + "ja": "✗ エラーで終了しました(終了コード:{code})", + "ru": "✗ Завершено с ошибкой (код выхода: {code})" + }, + "✗ Ungültiges JSON: {error}": { + "de": "✗ Ungültiges JSON: {error}", + "en": "✗ Invalid JSON: {error}", + "es": "✗ JSON no válido: {error}", + "zh": "✗ 无效的 JSON:{error}", + "ja": "✗ 無効な JSON:{error}", + "ru": "✗ Недопустимый JSON: {error}" + }, + "✗ Ungültiges TOML: {error}": { + "de": "✗ Ungültiges TOML: {error}", + "en": "✗ Invalid TOML: {error}", + "es": "✗ TOML no válido: {error}", + "zh": "✗ 无效的 TOML:{error}", + "ja": "✗ 無効な TOML:{error}", + "ru": "✗ Недопустимый TOML: {error}" + }, + "❌ Fehler: {error}": { + "de": "❌ Fehler: {error}", + "en": "❌ Error: {error}", + "es": "❌ Error: {error}", + "zh": "❌ 错误:{error}", + "ja": "❌ エラー:{error}", + "ru": "❌ Ошибка: {error}" + }, + "💾 Verschwendeter Speicher: {size}": { + "de": "💾 Verschwendeter Speicher: {size}", + "en": "💾 Wasted space: {size}", + "es": "💾 Espacio desperdiciado: {size}", + "zh": "💾 浪费的空间:{size}", + "ja": "💾 無駄な容量:{size}", + "ru": "💾 Впустую занято: {size}" + }, + "📁 {count} Dateien": { + "de": "📁 {count} Dateien", + "en": "📁 {count} files", + "es": "📁 {count} archivos", + "zh": "📁 {count} 个文件", + "ja": "📁 {count} 個のファイル", + "ru": "📁 Файлов: {count}" + }, + "📊 {count} Änderungen gefunden:": { + "de": "📊 {count} Änderungen gefunden:", + "en": "📊 {count} changes found:", + "es": "📊 Se encontraron {count} cambios:", + "zh": "📊 发现 {count} 处更改:", + "ja": "📊 {count} 件の変更が見つかりました:", + "ru": "📊 Найдено изменений ({count}):" + }, + "📊 {count}x verwendet": { + "de": "📊 {count}x verwendet", + "en": "📊 Used {count}x", + "es": "📊 Usado {count} veces", + "zh": "📊 已使用 {count} 次", + "ja": "📊 {count} 回使用", + "ru": "📊 Использовано раз: {count}" + }, + "Bis": { + "de": "Bis", + "en": "To", + "es": "Hasta", + "zh": "至", + "ja": "まで", + "ru": "До" + }, + "Von": { + "de": "Von", + "en": "From", + "es": "Desde", + "zh": "从", + "ja": "から", + "ru": "От" + }, + "Typ": { + "de": "Typ", + "en": "Type", + "es": "Tipo", + "zh": "类型", + "ja": "種類", + "ru": "Тип" + }, + "Max": { + "de": "Max", + "en": "Max", + "es": "Máx.", + "zh": "最大", + "ja": "最大", + "ru": "Макс." + }, + "Min": { + "de": "Min", + "en": "Min", + "es": "Mín.", + "zh": "最小", + "ja": "最小", + "ru": "Мин." + }, + "nur online": { + "de": "nur online", + "en": "online only", + "es": "solo en línea", + "zh": "仅在线", + "ja": "オンラインのみ", + "ru": "только онлайн" + }, + "Begriffe der Blacklist gelten immer als sensibel (z. B. Namen, Projekt- oder Kundennummern). Sie färben die Ampel, sobald sie in der Zwischenablage auftauchen.": { + "de": "Begriffe der Blacklist gelten immer als sensibel (z. B. Namen, Projekt- oder Kundennummern). Sie färben die Ampel, sobald sie in der Zwischenablage auftauchen.", + "en": "Blacklist terms are always treated as sensitive (e.g. names, project or customer numbers). They change the traffic light as soon as they appear in the clipboard.", + "es": "Los términos de la lista negra siempre se consideran sensibles (p. ej., nombres, números de proyecto o de cliente). Cambian el semáforo en cuanto aparecen en el portapapeles.", + "zh": "黑名单中的词语始终被视为敏感信息(例如姓名、项目编号或客户编号)。一旦出现在剪贴板中,信号灯就会变色。", + "ja": "ブラックリストの語句は常に機密情報として扱われます(例: 氏名、プロジェクト番号、顧客番号)。クリップボードに現れるとすぐに信号の色が変わります。", + "ru": "Термины из черного списка всегда считаются конфиденциальными (например, имена, номера проектов или клиентов). Как только они появляются в буфере обмена, светофор меняет цвет." + }, + "Begriffe der Whitelist werden nie als sensibel gemeldet – auch wenn ein Muster passt (z. B. die eigene Firmen-E-Mail-Adresse).": { + "de": "Begriffe der Whitelist werden nie als sensibel gemeldet – auch wenn ein Muster passt (z. B. die eigene Firmen-E-Mail-Adresse).", + "en": "Whitelist terms are never reported as sensitive – even if a pattern matches (e.g. your own company e-mail address).", + "es": "Los términos de la lista blanca nunca se notifican como sensibles, aunque coincida un patrón (p. ej., la dirección de correo de su empresa).", + "zh": "白名单中的词语永远不会被报告为敏感信息——即使匹配了某个模式(例如您自己的公司电子邮件地址)。", + "ja": "ホワイトリストの語句は、パターンに一致しても機密情報として報告されません(例: 自社のメールアドレス)。", + "ru": "Термины из белого списка никогда не считаются конфиденциальными — даже если совпадает шаблон (например, собственный корпоративный адрес эл. почты)." + }, + "Beispieltext zum Prüfen...": { + "de": "Beispieltext zum Prüfen...", + "en": "Sample text to check...", + "es": "Texto de ejemplo para comprobar...", + "zh": "用于检查的示例文本...", + "ja": "確認用のサンプルテキスト...", + "ru": "Образец текста для проверки..." + }, + "Blacklist/Whitelist bearbeiten...": { + "de": "Blacklist/Whitelist bearbeiten...", + "en": "Edit Blacklist/Whitelist...", + "es": "Editar lista negra/blanca...", + "zh": "编辑黑名单/白名单...", + "ja": "ブラックリスト/ホワイトリストを編集...", + "ru": "Изменить чёрный/белый список..." + }, + "Exportieren...": { + "de": "Exportieren...", + "en": "Export...", + "es": "Exportar...", + "zh": "导出...", + "ja": "エクスポート...", + "ru": "Экспортировать..." + }, + "Importieren...": { + "de": "Importieren...", + "en": "Import...", + "es": "Importar...", + "zh": "导入...", + "ja": "インポート...", + "ru": "Импортировать..." + }, + "Liste filtern...": { + "de": "Liste filtern...", + "en": "Filter list...", + "es": "Filtrar lista...", + "zh": "筛选列表...", + "ja": "リストをフィルター...", + "ru": "Фильтровать список..." + }, + "Zurück": { + "de": "Zurück", + "en": "Back", + "es": "Atrás", + "zh": "后退", + "ja": "戻る", + "ru": "Назад" + }, + "Vorwärts": { + "de": "Vorwärts", + "en": "Forward", + "es": "Adelante", + "zh": "前进", + "ja": "進む", + "ru": "Вперёд" + }, + "Übergeordneter Ordner": { + "de": "Übergeordneter Ordner", + "en": "Parent folder", + "es": "Carpeta superior", + "zh": "上级文件夹", + "ja": "親フォルダー", + "ru": "Родительская папка" + }, + "Zum vorherigen Ordner wechseln.": { + "de": "Zum vorherigen Ordner wechseln.", + "en": "Go to the previous folder.", + "es": "Ir a la carpeta anterior.", + "zh": "转到上一个文件夹。", + "ja": "前のフォルダーに移動します。", + "ru": "Перейти к предыдущей папке." + }, + "Zum nächsten Ordner in der Navigation wechseln.": { + "de": "Zum nächsten Ordner in der Navigation wechseln.", + "en": "Go to the next folder in the navigation history.", + "es": "Ir a la siguiente carpeta del historial de navegación.", + "zh": "转到导航历史中的下一个文件夹。", + "ja": "ナビゲーション履歴の次のフォルダーに移動します。", + "ru": "Перейти к следующей папке в истории навигации." + }, + "Eine Ebene nach oben im aktuellen Ordnerpfad wechseln.": { + "de": "Eine Ebene nach oben im aktuellen Ordnerpfad wechseln.", + "en": "Go up one level in the current folder path.", + "es": "Subir un nivel en la ruta de carpeta actual.", + "zh": "在当前文件夹路径中向上一级。", + "ja": "現在のフォルダーパスで 1 つ上の階層に移動します。", + "ru": "Перейти на уровень выше в текущем пути." + }, + "Suche": { + "de": "Suche", + "en": "Search", + "es": "Búsqueda", + "zh": "搜索", + "ja": "検索", + "ru": "Поиск" + }, + "Apps": { + "de": "Apps", + "en": "Apps", + "es": "Aplicaciones", + "zh": "应用", + "ja": "アプリ", + "ru": "Приложения" + }, + "Gelb": { + "de": "Gelb", + "en": "Yellow", + "es": "Amarillo", + "zh": "黄色", + "ja": "黄", + "ru": "Жёлтый" + }, + "Rot": { + "de": "Rot", + "en": "Red", + "es": "Rojo", + "zh": "红色", + "ja": "赤", + "ru": "Красный" + }, + "Inaktiv": { + "de": "Inaktiv", + "en": "Inactive", + "es": "Inactivo", + "zh": "未启用", + "ja": "無効", + "ru": "Неактивно" + }, + "hoch": { + "de": "hoch", + "en": "high", + "es": "alta", + "zh": "高", + "ja": "高", + "ru": "высокая" + }, + "mittel": { + "de": "mittel", + "en": "medium", + "es": "media", + "zh": "中", + "ja": "中", + "ru": "средняя" + }, + "niedrig": { + "de": "niedrig", + "en": "low", + "es": "baja", + "zh": "低", + "ja": "低", + "ru": "низкая" + }, + "Systemvorgabe": { + "de": "Systemvorgabe", + "en": "System default", + "es": "Predeterminado del sistema", + "zh": "系统默认", + "ja": "システムの既定", + "ru": "Системная настройка" + }, + "Hell": { + "de": "Hell", + "en": "Light", + "es": "Claro", + "zh": "浅色", + "ja": "ライト", + "ru": "Светлая" + }, + "Dunkel": { + "de": "Dunkel", + "en": "Dark", + "es": "Oscuro", + "zh": "深色", + "ja": "ダーク", + "ru": "Тёмная" + }, + "IBAN (Kontonummer)": { + "de": "IBAN (Kontonummer)", + "en": "IBAN (account number)", + "es": "IBAN (número de cuenta)", + "zh": "IBAN(账号)", + "ja": "IBAN(口座番号)", + "ru": "IBAN (номер счёта)" + }, + "Telefonnummern (DE)": { + "de": "Telefonnummern (DE)", + "en": "Phone numbers (DE)", + "es": "Números de teléfono (DE)", + "zh": "电话号码(德国)", + "ja": "電話番号(DE)", + "ru": "Номера телефонов (DE)" + }, + "Kreditkarten": { + "de": "Kreditkarten", + "en": "Credit cards", + "es": "Tarjetas de crédito", + "zh": "信用卡", + "ja": "クレジットカード", + "ru": "Кредитные карты" + }, + "Sozialversicherungsnr.": { + "de": "Sozialversicherungsnr.", + "en": "Social security no.", + "es": "N.º de seguridad social", + "zh": "社会保险号", + "ja": "社会保険番号", + "ru": "Номер соц. страхования" + }, + "Passwort-Hinweise": { + "de": "Passwort-Hinweise", + "en": "Password hints", + "es": "Indicios de contraseña", + "zh": "密码提示", + "ja": "パスワードの手がかり", + "ru": "Признаки паролей" } } diff --git a/src/app.py b/src/app.py index 4aca4d2..115c2a1 100644 --- a/src/app.py +++ b/src/app.py @@ -14,6 +14,8 @@ from gui.main_window import MainWindow from modules.privacy.privacy_monitor import PrivacyMonitor from core.file_index import FileIndex +from core.settings_manager import SettingsManager +from translator import t logging.basicConfig(level=logging.INFO) @@ -42,6 +44,7 @@ def __init__(self): # Einstellungen & Verbindungen self._load_settings() self._setup_connections() + self._apply_settings(startup=True) logging.info("ExplorerPro gestartet") @@ -57,8 +60,11 @@ def _init_privacy_monitor(self): self.privacy_monitor.warning.connect(self._on_privacy_warning) self.privacy_monitor.alert.connect(self._on_privacy_alert) - # Monitor starten - self.privacy_monitor.start() + # Monitor starten (sofern in den Einstellungen nicht deaktiviert) + if SettingsManager.instance().get("privacy", "enable_clipboard_monitor", True): + self.privacy_monitor.start() + else: + self.privacy_monitor.stop() logging.info("PrivacyMonitor initialisiert") def _init_file_index(self): @@ -79,7 +85,8 @@ def _load_settings(self): settings = QSettings() # Fenstergeometrie - geometry = settings.value("window/geometry") + remember = SettingsManager.instance().get("general", "remember_window_size", True) + geometry = settings.value("window/geometry") if remember else None if geometry: self.restoreGeometry(geometry) else: @@ -118,6 +125,7 @@ def _setup_connections(self): # Browser -> Preview self.file_browser.file_selected.connect(self.preview_panel.show_preview) + self.file_browser.path_changed.connect(lambda _path: self.preview_panel.clear_preview()) # Browser -> StatusBar self.file_browser.path_changed.connect(self.status_widget.update_path) @@ -149,14 +157,14 @@ def _on_search(self, query: str): if results: self.statusBar().showMessage( - f"{len(results)} Treffer für: {query}", 5000 + t("{count} Treffer für: {query}").format(count=len(results), query=query), 5000 ) # Ergebnisse im SearchPanel anzeigen self.sidebar.search_panel.show_results(results) # Zum Such-Tab wechseln self.sidebar.switch_to_search() else: - self.statusBar().showMessage(f"Keine Treffer für: {query}", 3000) + self.statusBar().showMessage(t("Keine Treffer für: {query}").format(query=query), 3000) def _on_privacy_warning(self, message: str): """Handler für Datenschutz-Warnungen""" @@ -164,25 +172,37 @@ def _on_privacy_warning(self, message: str): def _on_privacy_alert(self, alert): """Handler für Datenschutz-Alerts""" - if alert.status.value == 'red': - QMessageBox.warning( - self, - "Datenschutz-Warnung", - f"{alert.message}\n\nErkannt: {', '.join(alert.detected_patterns)}" - ) + if alert.status.value != 'red': + return + if not SettingsManager.instance().get("privacy", "show_notifications", True): + return + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle(t("Datenschutz-Warnung")) + box.setText(alert.message) + box.setInformativeText( + t("Erkannt: {items}").format(items=", ".join(alert.detected_patterns)) + ) + box.setStandardButtons(QMessageBox.StandardButton.Ok) + settings_btn = box.addButton(t("Einstellungen..."), QMessageBox.ButtonRole.ActionRole) + box.exec() + if box.clickedButton() is settings_btn: + self._show_privacy_settings() def _on_app_launched(self, path: str): """Handler für gestartete Apps""" - self.statusBar().showMessage(f"🚀 App gestartet: {Path(path).name}", 3000) + self.statusBar().showMessage("🚀 " + t("App gestartet: {name}").format(name=Path(path).name), 3000) def _on_prompt_copied(self, content: str): """Handler für kopierte Prompts""" preview = content[:50] + "..." if len(content) > 50 else content - self.statusBar().showMessage(f"📋 Prompt kopiert: {preview}", 3000) + self.statusBar().showMessage("📋 " + t("Prompt kopiert: {text}").format(text=preview), 3000) def _on_sync_finished(self, count: int): """Handler für abgeschlossene Synchronisation""" - self.statusBar().showMessage(f"🔄 Synchronisation abgeschlossen: {count} Dateien", 5000) + self.statusBar().showMessage( + "🔄 " + t("Synchronisation abgeschlossen: {count} Dateien").format(count=count), 5000 + ) def index_current_folder(self): """Indiziert den aktuellen Ordner""" @@ -195,10 +215,14 @@ def index_current_folder(self): # Worker starten self.index_worker = IndexWorker(self.file_index, current_path) self.index_worker.progress.connect( - lambda c, t: self.statusBar().showMessage(f"Indiziere: {c}/{t}") + lambda current, total: self.statusBar().showMessage( + t("Indiziere: {current}/{total}").format(current=current, total=total) + ) ) self.index_worker.finished_indexing.connect( - lambda n: self.statusBar().showMessage(f"✅ {n} Dateien indiziert", 5000) + lambda n: self.statusBar().showMessage( + "✅ " + t("{count} Dateien indiziert").format(count=n), 5000 + ) ) self.index_worker.start() @@ -251,7 +275,8 @@ def closeEvent(self, event): # Einstellungen speichern settings = QSettings() - settings.setValue("window/geometry", self.saveGeometry()) + if SettingsManager.instance().get("general", "remember_window_size", True): + settings.setValue("window/geometry", self.saveGeometry()) for key, splitter in ( ("splitter/main", self.main_splitter), ("splitter/right", self.right_splitter), diff --git a/src/core/appearance.py b/src/core/appearance.py new file mode 100644 index 0000000..d117d59 --- /dev/null +++ b/src/core/appearance.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +appearance — wendet Farbschema und Schriftgröße auf die QApplication an. + +* ``system``: folgt dem Hell/Dunkel-Modus des Betriebssystems +* ``light`` / ``dark``: erzwingt das jeweilige Schema +* Schriftgröße ``0`` bedeutet Systemstandard +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QApplication + +THEMES = ("system", "light", "dark") + + +def _dark_palette() -> QPalette: + palette = QPalette() + window = QColor(45, 45, 48) + base = QColor(30, 30, 30) + text = QColor(230, 230, 230) + disabled = QColor(130, 130, 130) + highlight = QColor(0, 120, 215) + palette.setColor(QPalette.ColorRole.Window, window) + palette.setColor(QPalette.ColorRole.WindowText, text) + palette.setColor(QPalette.ColorRole.Base, base) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(40, 40, 42)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(60, 60, 64)) + palette.setColor(QPalette.ColorRole.ToolTipText, text) + palette.setColor(QPalette.ColorRole.Text, text) + palette.setColor(QPalette.ColorRole.Button, window) + palette.setColor(QPalette.ColorRole.ButtonText, text) + palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 80, 80)) + palette.setColor(QPalette.ColorRole.Link, QColor(80, 160, 255)) + palette.setColor(QPalette.ColorRole.Highlight, highlight) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.PlaceholderText, disabled) + for role in (QPalette.ColorRole.WindowText, QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText): + palette.setColor(QPalette.ColorGroup.Disabled, role, disabled) + return palette + + +def apply_theme(app: QApplication, theme: str) -> None: + """Setzt das Farbschema; unbekannte Werte gelten als ``system``.""" + theme = theme if theme in THEMES else "system" + hints = app.styleHints() + if hasattr(hints, "setColorScheme"): # Qt >= 6.8 + scheme = { + "system": Qt.ColorScheme.Unknown, + "light": Qt.ColorScheme.Light, + "dark": Qt.ColorScheme.Dark, + }[theme] + hints.setColorScheme(scheme) + app.setPalette(app.style().standardPalette()) + return + if theme == "dark": + app.setPalette(_dark_palette()) + else: + app.setPalette(app.style().standardPalette()) + + +def apply_font_size(app: QApplication, point_size: int) -> None: + """Setzt die Basisschriftgröße; 0 stellt die ursprüngliche Größe wieder her.""" + original = getattr(app, "_ep_original_point_size", None) + font = app.font() + if original is None: + original = font.pointSizeF() + app._ep_original_point_size = original + try: + size = float(point_size) + except (TypeError, ValueError): + size = 0 + target = size if 6 <= size <= 32 else original + if target > 0 and abs(font.pointSizeF() - target) > 0.01: + font.setPointSizeF(target) + app.setFont(font) diff --git a/src/core/cloud_locations.py b/src/core/cloud_locations.py new file mode 100644 index 0000000..fddb989 --- /dev/null +++ b/src/core/cloud_locations.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +cloud_locations — findet lokale Synchronisationsordner von Cloud-Diensten. + +Es werden ausschließlich lokale Quellen gelesen (Umgebungsvariablen, +Benutzerverzeichnis, unter Windows die Registry nur lesend); es findet +kein Netzwerkzugriff statt. + +Quellen: +* Windows: ``%OneDrive%``, ``%OneDriveConsumer%``, ``%OneDriveCommercial%`` + sowie die von Windows verwalteten Sync-Roots + (``HKLM\\...\\Explorer\\SyncRootManager``), über die sich OneDrive, + Dropbox, iCloud, Nextcloud, Box usw. beim Explorer anmelden. +* macOS: ``~/Library/CloudStorage/*`` und iCloud Drive. +* Alle Plattformen: bekannte Ordnernamen direkt im Benutzerverzeichnis. +""" +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class CloudLocation: + label: str + path: str + + +# Präfixe bekannter Sync-Ordner im Benutzerverzeichnis (Vergleich ohne +# Groß-/Kleinschreibung). "OneDrive - Firma" und "Dropbox (Team)" werden +# über den Präfix erkannt. +_HOME_PREFIXES = ( + "onedrive", + "dropbox", + "google drive", + "googledrive", + "my drive", + "icloud drive", + "iclouddrive", + "nextcloud", + "owncloud", + "pcloud drive", + "pclouddrive", + "mega", + "megasync", + "box", + "box sync", + "tresorit", + "proton drive", + "seafile", + "yandex.disk", + "synologydrive", + "magentacloud", + "ionos hidrive", + "hidrive", +) + + +def _matches_known_name(name: str) -> bool: + lower = name.lower() + for prefix in _HOME_PREFIXES: + if lower == prefix: + return True + if lower.startswith(prefix) and len(lower) > len(prefix) and lower[len(prefix)] in " -_(": + return True + return False + + +def _label_for(path: str) -> str: + name = os.path.basename(os.path.normpath(path)) or path + lower = name.lower() + if lower.startswith("onedrive-"): + # macOS: ~/Library/CloudStorage/OneDrive-Persönlich + return "OneDrive - " + name.split("-", 1)[1] + if lower.startswith("googledrive-"): + return "Google Drive (" + name.split("-", 1)[1] + ")" + if lower == "com~apple~clouddocs": + return "iCloud Drive" + return name + + +def _windows_sync_roots() -> list[str]: + """Liest die beim Explorer registrierten Cloud-Sync-Roots (nur lesend).""" + try: + import winreg # type: ignore[import-not-found] + except ImportError: + return [] + + roots: list[str] = [] + base = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager" + try: + manager = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, base, 0, winreg.KEY_READ) + except OSError: + return [] + try: + index = 0 + while True: + try: + provider = winreg.EnumKey(manager, index) + except OSError: + break + index += 1 + try: + user_roots = winreg.OpenKey(manager, provider + r"\UserSyncRoots", 0, winreg.KEY_READ) + except OSError: + continue + try: + value_index = 0 + while True: + try: + _, data, _ = winreg.EnumValue(user_roots, value_index) + except OSError: + break + value_index += 1 + if isinstance(data, str) and data.strip(): + roots.append(data.strip()) + finally: + winreg.CloseKey(user_roots) + finally: + winreg.CloseKey(manager) + return roots + + +def find_cloud_locations(home: str | None = None, environ: dict | None = None, + platform: str | None = None) -> list[CloudLocation]: + """Liefert vorhandene Cloud-Sync-Ordner ohne Duplikate, sortiert nach Name.""" + home = home or str(Path.home()) + environ = os.environ if environ is None else environ + platform = platform or sys.platform + + candidates: list[str] = [] + + for var in ("OneDriveConsumer", "OneDriveCommercial", "OneDrive"): + value = environ.get(var) + if value: + candidates.append(value) + + if platform.startswith("win"): + candidates.extend(_windows_sync_roots()) + + if platform == "darwin": + cloud_storage = os.path.join(home, "Library", "CloudStorage") + try: + for entry in sorted(os.listdir(cloud_storage)): + candidates.append(os.path.join(cloud_storage, entry)) + except OSError: + pass + candidates.append(os.path.join(home, "Library", "Mobile Documents", "com~apple~CloudDocs")) + + try: + for entry in sorted(os.listdir(home)): + if _matches_known_name(entry): + candidates.append(os.path.join(home, entry)) + except OSError: + pass + + seen: set[str] = set() + result: list[CloudLocation] = [] + for candidate in candidates: + try: + if not os.path.isdir(candidate): + continue + key = os.path.normcase(os.path.realpath(candidate)) + except (OSError, ValueError): + continue + if key in seen: + continue + seen.add(key) + result.append(CloudLocation(label=_label_for(candidate), path=candidate)) + + result.sort(key=lambda loc: loc.label.lower()) + return result diff --git a/src/core/crash_log.py b/src/core/crash_log.py new file mode 100644 index 0000000..0540103 --- /dev/null +++ b/src/core/crash_log.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +crash_log — lokale Fehlerprotokollierung ohne Netzwerkzugriff. + +* ``faulthandler`` schreibt bei nativen Abstürzen (Access Violation, + Segfault) die Python-Stacks aller Threads in ``crash.log``. +* Unbehandelte Python-Ausnahmen (auch aus Qt-Slots, die PySide über + ``sys.excepthook`` meldet) und aus Threads werden mit Zeitstempel + protokolliert, statt in der fensterlosen EXE spurlos zu verschwinden. + +Die Datei bleibt ausschließlich lokal im Benutzerprofil. +""" +from __future__ import annotations + +import datetime as _dt +import faulthandler +import sys +import threading +import traceback +from pathlib import Path + +MAX_LOG_BYTES = 1024 * 1024 + +_log_file = None + + +def default_log_dir() -> Path: + return Path.home() / ".explorerpro" / "logs" + + +def _write(text: str) -> None: + if _log_file is None: + return + try: + _log_file.write(text) + _log_file.flush() + except (OSError, ValueError): + pass + + +def _format_exception(exc_type, exc, tb, origin: str) -> str: + stamp = _dt.datetime.now().isoformat(timespec="seconds") + body = "".join(traceback.format_exception(exc_type, exc, tb)) + return f"\n=== {stamp} | {origin} ===\n{body}" + + +def install_crash_logging(log_dir: Path | None = None) -> Path | None: + """Aktiviert faulthandler und Exception-Hooks; liefert den Logpfad.""" + global _log_file + if _log_file is not None: + return Path(_log_file.name) + + log_dir = log_dir or default_log_dir() + try: + log_dir.mkdir(parents=True, exist_ok=True) + path = log_dir / "crash.log" + if path.exists() and path.stat().st_size > MAX_LOG_BYTES: + path.replace(log_dir / "crash.old.log") + _log_file = open(path, "a", encoding="utf-8") + except OSError: + return None + + try: + faulthandler.enable(file=_log_file, all_threads=True) + except (OSError, ValueError, RuntimeError): + pass + + previous_hook = sys.excepthook + + def excepthook(exc_type, exc, tb): + _write(_format_exception(exc_type, exc, tb, "Unbehandelte Ausnahme")) + if previous_hook is not None and sys.stderr is not None: + try: + previous_hook(exc_type, exc, tb) + except Exception: + pass + + sys.excepthook = excepthook + + previous_thread_hook = threading.excepthook + + def thread_hook(args): + name = getattr(args.thread, "name", "?") + _write(_format_exception(args.exc_type, args.exc_value, args.exc_traceback, f"Thread {name}")) + if sys.stderr is not None: + try: + previous_thread_hook(args) + except Exception: + pass + + threading.excepthook = thread_hook + return path + + +def uninstall_crash_logging() -> None: + """Schließt das Log (für Tests und sauberes Beenden).""" + global _log_file + if _log_file is None: + return + try: + faulthandler.disable() + except Exception: + pass + sys.excepthook = sys.__excepthook__ + threading.excepthook = threading.__excepthook__ + try: + _log_file.close() + except OSError: + pass + _log_file = None diff --git a/src/core/file_attributes.py b/src/core/file_attributes.py new file mode 100644 index 0000000..263736c --- /dev/null +++ b/src/core/file_attributes.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +file_attributes — erkennt Cloud-Platzhalter ohne sie herunterzuladen. + +OneDrive, Dropbox, iCloud & Co. legen bei "Dateien bei Bedarf" lokale +Platzhalter an. Das Lesen des Inhalts löst einen Download aus, der bei +langsamer oder fehlender Verbindung die Oberfläche blockiert. Die Prüfung +nutzt ausschließlich Metadaten (``lstat``) und greift nie auf Inhalte zu. +""" +from __future__ import annotations + +import os +import stat +import sys + +# Windows-Dateiattribute (winnt.h) +FILE_ATTRIBUTE_OFFLINE = 0x00001000 +FILE_ATTRIBUTE_RECALL_ON_OPEN = 0x00040000 +FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 0x00400000 +_WINDOWS_CLOUD_ATTRIBUTES = ( + FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_OPEN | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS +) + +# macOS: SF_DATALESS (sys/stat.h) markiert iCloud-/File-Provider-Platzhalter. +SF_DATALESS = 0x40000000 + + +def is_cloud_placeholder(path: str, stat_result=None, platform: str | None = None) -> bool: + """True, wenn *path* ein nicht lokal vorhandener Cloud-Platzhalter ist.""" + platform = platform or sys.platform + try: + st = stat_result if stat_result is not None else os.lstat(path) + except (OSError, ValueError): + return False + + if platform.startswith("win"): + attributes = getattr(st, "st_file_attributes", 0) or 0 + return bool(attributes & _WINDOWS_CLOUD_ATTRIBUTES) + + if platform == "darwin": + flags = getattr(st, "st_flags", 0) or 0 + return bool(flags & SF_DATALESS) and stat.S_ISREG(st.st_mode) + + return False diff --git a/src/core/file_index.py b/src/core/file_index.py index 818bac0..2bdbf59 100644 --- a/src/core/file_index.py +++ b/src/core/file_index.py @@ -5,6 +5,7 @@ Basiert auf ProFiler V14 """ +import fnmatch import os import re import sqlite3 @@ -247,21 +248,34 @@ def extract_text(self, filepath: str) -> Optional[str]: return None - def index_file(self, filepath: str, calculate_hash: bool = True) -> bool: - """Indiziert eine einzelne Datei""" + def index_file(self, filepath: str, calculate_hash: bool = True, + max_content_bytes: int = 100 * 1024 * 1024) -> bool: + """Indiziert eine einzelne Datei. + + Inhalte (Hash, Volltext) werden nur für lokal vorhandene Dateien bis + *max_content_bytes* gelesen. Cloud-Platzhalter ("Nur online") werden + nur mit Metadaten aufgenommen, damit die Indizierung keine Downloads + auslöst. + """ if not os.path.exists(filepath): return False try: + from core.file_attributes import is_cloud_placeholder + stat = os.stat(filepath) filename = os.path.basename(filepath) ext = os.path.splitext(filename)[1].lower() + read_content = ( + stat.st_size <= max_content_bytes and not is_cloud_placeholder(filepath) + ) + file_hash = None - if calculate_hash and stat.st_size < 100 * 1024 * 1024: # Max 100MB + if calculate_hash and read_content: file_hash = self.calculate_hash(filepath) - text_content = self.extract_text(filepath) + text_content = self.extract_text(filepath) if read_content else None category = self.get_category(filename) conn = sqlite3.connect(self.db_path) @@ -725,12 +739,36 @@ class IndexWorker(QThread): finished_indexing = Signal(int) # total indexed error = Signal(str) - def __init__(self, index: FileIndex, folder: str, recursive: bool = True): + def __init__(self, index: FileIndex, folder: str, recursive: bool = True, + max_file_size_mb: Optional[int] = None, excluded_patterns: Optional[List[str]] = None): super().__init__() self.index = index self.folder = folder self.recursive = recursive self._cancelled = False + # Einstellungen im GUI-Thread lesen; der Worker arbeitet nur mit Kopien. + if max_file_size_mb is None or excluded_patterns is None: + try: + from core.settings_manager import SettingsManager + + settings = SettingsManager.instance() + if max_file_size_mb is None: + max_file_size_mb = settings.get("index", "max_file_size_mb", 100) + if excluded_patterns is None: + excluded_patterns = settings.get("index", "excluded_patterns", []) + except Exception: + pass + try: + self.max_content_bytes = max(1, int(max_file_size_mb or 100)) * 1024 * 1024 + except (TypeError, ValueError): + self.max_content_bytes = 100 * 1024 * 1024 + self.excluded_patterns = [ + str(p).lower() for p in (excluded_patterns or []) if isinstance(p, str) and p.strip() + ] + + def _is_excluded(self, name: str) -> bool: + lower = name.lower() + return any(fnmatch.fnmatch(lower, pattern) for pattern in self.excluded_patterns) def cancel(self): self._cancelled = True @@ -742,14 +780,18 @@ def run(self): try: files = [] if self.recursive: - for root, _, filenames in os.walk(self.folder): + for root, dirs, filenames in os.walk(self.folder): + if self._cancelled: + break + dirs[:] = [d for d in dirs if not self._is_excluded(d)] for filename in filenames: - files.append(os.path.join(root, filename)) + if not self._is_excluded(filename): + files.append(os.path.join(root, filename)) else: files = [ os.path.join(self.folder, f) for f in os.listdir(self.folder) - if os.path.isfile(os.path.join(self.folder, f)) + if os.path.isfile(os.path.join(self.folder, f)) and not self._is_excluded(f) ] total = len(files) @@ -758,7 +800,7 @@ def run(self): if self._cancelled: break - if self.index.index_file(filepath): + if self.index.index_file(filepath, max_content_bytes=self.max_content_bytes): indexed_count += 1 self.file_indexed.emit(filepath) diff --git a/src/core/platform_utils.py b/src/core/platform_utils.py index cfb32e4..547bb9f 100644 --- a/src/core/platform_utils.py +++ b/src/core/platform_utils.py @@ -60,3 +60,28 @@ def open_terminal_in_directory(path: str) -> subprocess.Popen: cmd = get_terminal_command(os.path.abspath(target_dir)) flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) if sys.platform.startswith("win") else 0 return subprocess.Popen(cmd, cwd=target_dir, creationflags=flags) + + +def normalize_user_path(path: str) -> str: + """Bereinigt einen vom Nutzer eingegebenen oder eingefügten Pfad. + + Entfernt umschließende Anführungszeichen (Windows "Als Pfad kopieren"), + wandelt ``file://``-URLs um und expandiert ``~`` sowie Umgebungsvariablen + wie ``%OneDrive%`` oder ``$HOME``. + """ + if not path: + return path + text = str(path).strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'": + text = text[1:-1].strip() + if text.lower().startswith("file:"): + from urllib.parse import unquote, urlparse + + parsed = urlparse(text) + local = unquote(parsed.path) + if sys.platform.startswith("win") and len(local) > 2 and local[0] == "/" and local[2] == ":": + local = local[1:] + if parsed.netloc and parsed.netloc.lower() != "localhost": + local = f"//{parsed.netloc}{local}" + text = os.path.normpath(local) + return os.path.expandvars(os.path.expanduser(text)) diff --git a/src/core/settings_manager.py b/src/core/settings_manager.py index d6b639c..6dc0055 100644 --- a/src/core/settings_manager.py +++ b/src/core/settings_manager.py @@ -40,18 +40,18 @@ class SettingsManager: "preview_images": True, "preview_pdfs": True, "preview_code": True, - "max_preview_size_mb": 10, + "max_preview_size_mb": 50, }, "privacy": { "enable_clipboard_monitor": True, - "auto_block_sensitive": True, + "auto_block_sensitive": False, "blacklist_patterns": [], "show_notifications": True, }, "appearance": { "theme": "system", # system, light, dark - "font_size": 10, - "icon_size": 24, + "font_size": 0, # 0 = Systemstandard + "icon_size": 16, }, } diff --git a/src/core/shortcut_utils.py b/src/core/shortcut_utils.py index f51a34f..14afdcd 100644 --- a/src/core/shortcut_utils.py +++ b/src/core/shortcut_utils.py @@ -2,6 +2,7 @@ import os import ntpath +import struct import subprocess import sys from dataclasses import dataclass @@ -25,11 +26,118 @@ def resolve_windows_shortcut_target(path: str) -> str | None: return None target = _read_shortcut_target_with_win32com(path) + if target: + return target + # Reiner Python-Parser: kein Kindprozess, kein Konsolenfenster, keine + # Wartezeit im GUI-Thread. PowerShell bleibt nur letzter Ausweg. + target = read_shortcut_target_native(path) if target: return target return _read_shortcut_target_with_powershell(path) +# MS-SHLLINK Konstanten +_LNK_HEADER_SIZE = 0x4C +_LNK_CLSID = bytes.fromhex("0114020000000000c000000000000046") +_HAS_LINK_TARGET_ID_LIST = 0x01 +_HAS_LINK_INFO = 0x02 +_HAS_NAME = 0x04 +_HAS_RELATIVE_PATH = 0x08 +_HAS_WORKING_DIR = 0x10 +_HAS_ARGUMENTS = 0x20 +_HAS_ICON_LOCATION = 0x40 +_IS_UNICODE = 0x80 +_HAS_EXP_STRING = 0x200 +_ENVIRONMENT_BLOCK_SIGNATURE = 0xA0000001 +_MAX_LNK_BYTES = 1024 * 1024 + + +def _c_string(data: bytes, offset: int, unicode: bool) -> str: + if offset <= 0 or offset >= len(data): + return "" + if unicode: + end = offset + while end + 1 < len(data) and data[end:end + 2] != b"\x00\x00": + end += 2 + return data[offset:end].decode("utf-16-le", errors="replace") + end = data.find(b"\x00", offset) + if end < 0: + end = len(data) + raw = data[offset:end] + try: + return raw.decode("mbcs", errors="replace") # ANSI-Codepage (nur Windows) + except LookupError: + return raw.decode("cp1252", errors="replace") + + +def parse_shortcut_target(data: bytes) -> str | None: + """Liest das Ziel einer Windows-Verknüpfung (MS-SHLLINK) aus Rohdaten.""" + if len(data) < _LNK_HEADER_SIZE or struct.unpack_from(" len(data): + return None + offset += 2 + struct.unpack_from("= 0x24 and len(info) >= 0x24: + local_base_unicode, suffix_unicode = struct.unpack_from("<2I", info, 0x1C) + if info_flags & 0x01: # VolumeIDAndLocalBasePath + base = (_c_string(info, local_base_unicode, True) if local_base_unicode + else _c_string(info, local_base, False)) + tail = (_c_string(info, suffix_unicode, True) if suffix_unicode + else _c_string(info, suffix, False)) + target = base + tail # MS-SHLLINK: einfache Verkettung + offset += info_size + + # Optionale StringData-Blöcke überspringen, um ExtraData zu erreichen. + unicode = bool(flags & _IS_UNICODE) + for flag in (_HAS_NAME, _HAS_RELATIVE_PATH, _HAS_WORKING_DIR, _HAS_ARGUMENTS, _HAS_ICON_LOCATION): + if not flags & flag: + continue + if offset + 2 > len(data): + break + count = struct.unpack_from("= 0x314: + block = data[offset:offset + size] + target = _c_string(block, 0x10C, True) or _c_string(block, 8, False) + break + offset += size + + return target.strip() or None + + +def read_shortcut_target_native(path: str) -> str | None: + try: + with open(path, "rb") as handle: + data = handle.read(_MAX_LNK_BYTES) + except OSError: + return None + try: + return parse_shortcut_target(data) + except (struct.error, ValueError, LookupError): + return None + + def build_shortcut_preview_target(path: str) -> ShortcutPreviewTarget | None: target = resolve_windows_shortcut_target(path) if not target: @@ -97,6 +205,7 @@ def _read_shortcut_target_with_powershell(path: str) -> str | None: errors="replace", text=True, timeout=5, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): return None diff --git a/src/core/ui_translator.py b/src/core/ui_translator.py new file mode 100644 index 0000000..30e3ba1 --- /dev/null +++ b/src/core/ui_translator.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ui_translator — übersetzt sichtbare Qt-Texte zentral zur Laufzeit. + +Viele GUI-Texte sind als deutsche Literale im Code hinterlegt. Statt jede +Stelle einzeln in ``t()`` zu kapseln, übersetzt ein anwendungsweiter +Event-Filter beim Anzeigen eines Widgets dessen statische Texte über den +Katalog ``locales/translations.json``: + +* Fenstertitel, Tooltips, Status-/WhatsThis-Texte, Barrierefreiheitsnamen +* Beschriftungen von Labels, Buttons, Gruppen, Tabs und Toolboxen +* Platzhaltertexte von Eingabefeldern +* Spaltenköpfe von QTreeWidget/QTableWidget +* Einträge von Comboboxen (nur exakte Katalogschlüssel) +* Menüs, Menüleisten und Toolbars samt ihrer QActions + +Bei einem Sprachwechsel werden alle bereits übersetzten Texte live neu +übersetzt, sofern der Code sie zwischenzeitlich nicht geändert hat. +Nutzerdaten (Dateinamen, Pfade, Editorinhalte) werden nie angefasst; +bei Widgets mit Nutzerdaten im Text (Labels, Listen, Comboboxen) schließt +``widget.setProperty(NO_TRANSLATE, True)`` den angezeigten Inhalt aus – +Tooltips und Barrierefreiheitstexte werden weiterhin übersetzt. +""" +from __future__ import annotations + +from typing import Callable, Iterable + +from PySide6.QtCore import QEvent, QObject +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QAbstractButton, QApplication, QComboBox, QGroupBox, QLabel, QLineEdit, + QMenu, QMenuBar, QPlainTextEdit, QTabWidget, QTableWidget, QTextEdit, + QToolBar, QToolBox, QTreeWidget, QWidget, +) + +from translator import get_translator + +NO_TRANSLATE = "ep_no_translate" +_RECORD = "_ep_i18n" + +# Attribut -> (Getter, Setter) für alle QWidgets. +_WIDGET_ATTRS = ( + ("windowTitle", QWidget.windowTitle, QWidget.setWindowTitle), + ("toolTip", QWidget.toolTip, QWidget.setToolTip), + ("statusTip", QWidget.statusTip, QWidget.setStatusTip), + ("whatsThis", QWidget.whatsThis, QWidget.setWhatsThis), + ("accessibleName", QWidget.accessibleName, QWidget.setAccessibleName), + ("accessibleDescription", QWidget.accessibleDescription, QWidget.setAccessibleDescription), +) + +_ACTION_ATTRS = ( + ("text", QAction.text, QAction.setText), + ("statusTip", QAction.statusTip, QAction.setStatusTip), + ("whatsThis", QAction.whatsThis, QAction.setWhatsThis), +) + + +def _stripped_action_text(text: str) -> str: + """Nachbildung von Qts Tooltip-Ableitung aus dem Aktionstext.""" + text = text.replace("&&", "\0").replace("&", "").replace("\0", "&") + if text.endswith("..."): + text = text[:-3] + elif text.endswith("…"): + text = text[:-1] + return text + + +class UiTranslator(QObject): + """Anwendungsweiter Event-Filter für die Laufzeitübersetzung.""" + + def __init__(self, app: QApplication): + super().__init__(app) + self._app = app + self._translator = get_translator() + self._last_language = self._translator.get_language() + self._translator.add_language_listener(self._on_language_changed) + app.installEventFilter(self) + + def shutdown(self) -> None: + self._app.removeEventFilter(self) + self._translator.remove_language_listener(self._on_language_changed) + + # ------------------------------------------------------------------ # + # Event-Filter # + # ------------------------------------------------------------------ # + + def eventFilter(self, watched, event): # noqa: N802 (Qt-API) + try: + if event.type() == QEvent.Type.Show and isinstance(watched, QWidget): + self.translate_widget(watched) + except Exception: + # Übersetzung ist Komfort; sie darf niemals ein Fenster blockieren. + pass + return False + + # ------------------------------------------------------------------ # + # Übersetzung einzelner Objekte # + # ------------------------------------------------------------------ # + + def translate_widget(self, widget: QWidget) -> None: + """Übersetzt die statischen Texte eines Widgets (nicht rekursiv).""" + record = self._record(widget) + changed = False + + for name, getter, setter in _WIDGET_ATTRS: + changed |= self._apply(widget, record, name, getter, setter) + + if widget.property(NO_TRANSLATE): + # Inhalt sind Nutzerdaten: nur Hilfetexte (oben) übersetzen. + pass + elif isinstance(widget, QAbstractButton): + changed |= self._apply(widget, record, "text", QAbstractButton.text, QAbstractButton.setText) + elif isinstance(widget, QLabel): + changed |= self._apply(widget, record, "text", QLabel.text, QLabel.setText) + elif isinstance(widget, QGroupBox): + changed |= self._apply(widget, record, "title", QGroupBox.title, QGroupBox.setTitle) + elif isinstance(widget, QLineEdit): + changed |= self._apply( + widget, record, "placeholderText", QLineEdit.placeholderText, QLineEdit.setPlaceholderText + ) + elif isinstance(widget, QPlainTextEdit): + changed |= self._apply( + widget, record, "placeholderText", + QPlainTextEdit.placeholderText, QPlainTextEdit.setPlaceholderText, + ) + elif isinstance(widget, QTextEdit): + changed |= self._apply( + widget, record, "placeholderText", QTextEdit.placeholderText, QTextEdit.setPlaceholderText + ) + elif isinstance(widget, QMenu): + changed |= self._apply(widget, record, "title", QMenu.title, QMenu.setTitle) + elif isinstance(widget, QTabWidget): + for i in range(widget.count()): + changed |= self._apply_indexed(widget, record, "tab", i, widget.tabText, widget.setTabText) + changed |= self._apply_indexed( + widget, record, "tabtip", i, widget.tabToolTip, widget.setTabToolTip + ) + elif isinstance(widget, QToolBox): + for i in range(widget.count()): + changed |= self._apply_indexed(widget, record, "item", i, widget.itemText, widget.setItemText) + elif isinstance(widget, QComboBox): + changed |= self._translate_combo(widget, record) + elif isinstance(widget, QTreeWidget): + header = widget.headerItem() + if header is not None: + for i in range(header.columnCount()): + changed |= self._apply_indexed( + widget, record, "header", i, + lambda col, h=header: h.text(col), + lambda col, value, h=header: h.setText(col, value), + ) + elif isinstance(widget, QTableWidget): + for i in range(widget.columnCount()): + item = widget.horizontalHeaderItem(i) + if item is not None: + changed |= self._apply_indexed( + widget, record, "hheader", i, + lambda col, it=item: it.text(), + lambda col, value, it=item: it.setText(value), + ) + + if changed: + widget.setProperty(_RECORD, record) + + if isinstance(widget, (QMenu, QMenuBar, QToolBar)) and not widget.property(NO_TRANSLATE): + for action in widget.actions(): + self.translate_action(action) + + def translate_action(self, action: QAction) -> None: + if action.isSeparator() or action.property(NO_TRANSLATE): + return + record = self._record(action) + original_text = action.text() + changed = False + for name, getter, setter in _ACTION_ATTRS: + changed |= self._apply(action, record, name, getter, setter) + # Nur explizit gesetzte Tooltips übersetzen; abgeleitete folgen dem Text. + tooltip = action.toolTip() + if "toolTip" in record or tooltip != _stripped_action_text(original_text): + changed |= self._apply(action, record, "toolTip", QAction.toolTip, QAction.setToolTip) + if changed: + action.setProperty(_RECORD, record) + + def _translate_combo(self, combo: QComboBox, record: dict) -> bool: + changed = False + blocked = combo.blockSignals(True) + try: + for i in range(combo.count()): + key = f"combo:{i}" + current = combo.itemText(i) + if key not in record and not self._translator.has_key(current.strip()): + # Comboboxen enthalten oft Nutzerdaten (Blattnamen, Pfade): + # nur exakte Katalogschlüssel werden übersetzt. + continue + changed |= self._apply_indexed(combo, record, "combo", i, combo.itemText, combo.setItemText) + finally: + combo.blockSignals(blocked) + return changed + + # ------------------------------------------------------------------ # + # Kernlogik # + # ------------------------------------------------------------------ # + + @staticmethod + def _record(obj: QObject) -> dict: + value = obj.property(_RECORD) + return dict(value) if isinstance(value, dict) else {} + + def _apply(self, obj, record: dict, name: str, getter: Callable, setter: Callable) -> bool: + return self._apply_value( + record, name, lambda: getter(obj), lambda value: setter(obj, value) + ) + + def _apply_indexed(self, obj, record: dict, prefix: str, index: int, + getter: Callable, setter: Callable) -> bool: + return self._apply_value( + record, f"{prefix}:{index}", lambda: getter(index), lambda value: setter(index, value) + ) + + def _apply_value(self, record: dict, name: str, get: Callable, set_: Callable) -> bool: + current = get() + if not current: + return False + language = self._translator.get_language() + entry = record.get(name) + if entry is not None and list(entry)[2] == current and list(entry)[1] == language: + return False # bereits in der aktiven Sprache übersetzt + translated = self._translator.translate_text(current) + if translated == current and self._translator.source_text(current.strip()) is None: + if entry is not None: + record.pop(name, None) + return True + return False + if translated != current: + set_(translated) + record[name] = [current, language, translated] + return True + + def _retranslate_value(self, record: dict, name: str, get: Callable, set_: Callable, + old_language: str) -> bool: + entry = record.get(name) + if entry is None: + return False + raw, raw_language, last = list(entry) + current = get() + if current != last: + # Der Code hat den Text inzwischen selbst gesetzt (typisch per t() + # in der bisherigen Sprache); als neue Quelle übernehmen. + raw, raw_language = current, old_language + translated = self._translator.translate_text_from(raw, raw_language) + if translated != current: + set_(translated) + record[name] = [raw, raw_language, translated] + return True + + # ------------------------------------------------------------------ # + # Live-Sprachwechsel # + # ------------------------------------------------------------------ # + + def _on_language_changed(self, language: str) -> None: + old_language, self._last_language = self._last_language, language + try: + self.retranslate_all(old_language) + except Exception: + pass + + def retranslate_all(self, old_language: str) -> None: + seen_actions: set[int] = set() + for widget in QApplication.allWidgets(): + self._retranslate_object(widget, old_language) + for action in widget.actions(): + if id(action) in seen_actions: + continue + seen_actions.add(id(action)) + self._retranslate_object(action, old_language) + + def _retranslate_object(self, obj: QObject, old_language: str) -> None: + record = self._record(obj) + if not record: + return + changed = False + for name in list(record): + getter_setter = self._accessor(obj, name) + if getter_setter is None: + record.pop(name, None) + changed = True + continue + changed |= self._retranslate_value(record, name, *getter_setter, old_language) + if changed: + obj.setProperty(_RECORD, record) + + @staticmethod + def _accessor(obj: QObject, name: str): + """Liefert (get, set) für einen aufgezeichneten Attributnamen.""" + prefix, _, index_text = name.partition(":") + if index_text: + index = int(index_text) + if prefix == "tab" and isinstance(obj, QTabWidget) and index < obj.count(): + return (lambda: obj.tabText(index), lambda v: obj.setTabText(index, v)) + if prefix == "tabtip" and isinstance(obj, QTabWidget) and index < obj.count(): + return (lambda: obj.tabToolTip(index), lambda v: obj.setTabToolTip(index, v)) + if prefix == "item" and isinstance(obj, QToolBox) and index < obj.count(): + return (lambda: obj.itemText(index), lambda v: obj.setItemText(index, v)) + if prefix == "combo" and isinstance(obj, QComboBox) and index < obj.count(): + def set_combo(value): + blocked = obj.blockSignals(True) + try: + obj.setItemText(index, value) + finally: + obj.blockSignals(blocked) + return (lambda: obj.itemText(index), set_combo) + if prefix == "header" and isinstance(obj, QTreeWidget): + header = obj.headerItem() + if header is not None and index < header.columnCount(): + return (lambda: header.text(index), lambda v: header.setText(index, v)) + if prefix == "hheader" and isinstance(obj, QTableWidget): + item = obj.horizontalHeaderItem(index) if index < obj.columnCount() else None + if item is not None: + return (item.text, item.setText) + return None + + candidates: Iterable = () + if isinstance(obj, QAction): + candidates = _ACTION_ATTRS + (("toolTip", QAction.toolTip, QAction.setToolTip),) + else: + extra = [] + if isinstance(obj, QAbstractButton): + extra.append(("text", QAbstractButton.text, QAbstractButton.setText)) + elif isinstance(obj, QLabel): + extra.append(("text", QLabel.text, QLabel.setText)) + elif isinstance(obj, QGroupBox): + extra.append(("title", QGroupBox.title, QGroupBox.setTitle)) + elif isinstance(obj, QMenu): + extra.append(("title", QMenu.title, QMenu.setTitle)) + elif isinstance(obj, QLineEdit): + extra.append(("placeholderText", QLineEdit.placeholderText, QLineEdit.setPlaceholderText)) + elif isinstance(obj, QPlainTextEdit): + extra.append(("placeholderText", QPlainTextEdit.placeholderText, + QPlainTextEdit.setPlaceholderText)) + elif isinstance(obj, QTextEdit): + extra.append(("placeholderText", QTextEdit.placeholderText, QTextEdit.setPlaceholderText)) + candidates = tuple(_WIDGET_ATTRS) + tuple(extra) + for attr, getter, setter in candidates: + if attr == name: + return (lambda: getter(obj), lambda v: setter(obj, v)) + return None + + +def uninstall_ui_translator(app: QApplication) -> None: + """Entfernt Filter und Sprach-Listener vor dem Abbau der QApplication. + + Ein Python-Eventfilter, der während der Zerstörung der Anwendung noch + Ereignisse erhält, kann beim Beenden zu nativen Abstürzen führen. + """ + existing = getattr(app, "_ep_ui_translator", None) + if existing is None: + return + app._ep_ui_translator = None + existing.shutdown() + + +def install_ui_translator(app: QApplication) -> UiTranslator: + """Installiert den Übersetzer einmalig am QApplication-Objekt.""" + existing = getattr(app, "_ep_ui_translator", None) + if existing is None: + existing = UiTranslator(app) + app._ep_ui_translator = existing + return existing diff --git a/src/gui/batch_rename_dialog.py b/src/gui/batch_rename_dialog.py index 6f6553d..b996598 100644 --- a/src/gui/batch_rename_dialog.py +++ b/src/gui/batch_rename_dialog.py @@ -67,7 +67,7 @@ def _setup_ui(self): main_layout.setSpacing(10) # Info-Kopfzeile - count_lbl = QLabel(f"{len(self.file_paths)} " + t("Dateien ausgewählt")) + count_lbl = QLabel(t("{count} Dateien ausgewählt").format(count=len(self.file_paths))) font = count_lbl.font() font.setBold(True) count_lbl.setFont(font) @@ -347,7 +347,7 @@ def _update_preview(self): # Zusammenfassung if has_conflicts: self.summary_label.setText( - f"⚠️ {actionable_count} " + t("zu ändern, Konflikte oder ungültige Namen gefunden!") + "⚠️ " + t("{count} zu ändern, Konflikte oder ungültige Namen gefunden!").format(count=actionable_count) ) self.summary_label.setStyleSheet("color: #c62828; font-weight: bold;") self.rename_btn.setEnabled(False) @@ -357,7 +357,7 @@ def _update_preview(self): self.rename_btn.setEnabled(False) else: self.summary_label.setText( - f"✓ {actionable_count} " + t("Dateien werden umbenannt.") + "✓ " + t("{count} Dateien werden umbenannt.").format(count=actionable_count) ) self.summary_label.setStyleSheet("color: #2e7d32; font-weight: bold;") self.rename_btn.setEnabled(True) @@ -369,16 +369,18 @@ def _do_rename(self): if errors: err_text = "\n".join(errors[:5]) if len(errors) > 5: - err_text += f"\n... und {len(errors) - 5} weitere" + err_text += "\n" + t("... und {count} weitere").format(count=len(errors) - 5) QMessageBox.warning( self, t("Fehler beim Umbenennen"), - f"{success_count} " + t("Dateien umbenannt, aber Fehler aufgetreten:\n\n") + err_text + t("{count} Dateien umbenannt, aber Fehler aufgetreten:\n\n{errors}").format( + count=success_count, errors=err_text + ) ) else: self._has_renamed = True QMessageBox.information( self, t("Erfolg"), - f"{success_count} " + t("Dateien erfolgreich umbenannt.") + t("{count} Dateien erfolgreich umbenannt.").format(count=success_count) ) if self.history: @@ -414,12 +416,14 @@ def _do_rollback(self): if errors: QMessageBox.warning( self, t("Rollback unvollständig"), - f"{restored} " + t("Dateien zurückgesetzt, Fehler:\n") + "\n".join(errors) + t("{count} Dateien zurückgesetzt, Fehler:\n{errors}").format( + count=restored, errors="\n".join(errors) + ) ) else: QMessageBox.information( self, t("Rollback erfolgreich"), - f"{restored} " + t("Dateien wurden auf die ursprünglichen Namen zurückgesetzt.") + t("{count} Dateien wurden auf die ursprünglichen Namen zurückgesetzt.").format(count=restored) ) self._update_preview() diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index 0bafe73..a943a65 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -17,9 +17,11 @@ import os import subprocess import shutil +import sys from pathlib import Path -from core.platform_utils import open_path_with_system +from core.platform_utils import open_path_with_system, normalize_user_path +from translator import t # Editor-Extensions EDITOR_EXTENSIONS = { @@ -33,6 +35,19 @@ } +def base_entry_filters() -> QDir.Filter: + """Filter für die Dateiliste. + + Unter Windows blendet Qt ohne ``QDir.System`` Einträge aus, die weder als + Datei noch als Ordner erkannt werden. Das betrifft u. a. OneDrive-/Cloud- + Platzhalter (Reparse-Points mit Cloud-Tag) und Verknüpfungen (*.lnk). + """ + filters = QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot + if sys.platform.startswith("win"): + filters |= QDir.Filter.System + return filters + + class _DnDTableView(QTableView): """QTableView-Unterklasse: delegiert DnD-Events und Tastatur-Shortcuts an den FileBrowser. @@ -65,6 +80,10 @@ def dropEvent(self, event): def startDrag(self, supported_actions): self._fb._start_drag_files(supported_actions) + def resizeEvent(self, event): + super().resizeEvent(event) + self._fb._ensure_name_column_visible() + def keyPressEvent(self, event): if event.key() == Qt.Key.Key_Delete: self._fb.delete_selection() @@ -95,6 +114,10 @@ class FileBrowser(QWidget): Integriert QuickEditor für Code-Dateien """ + # Standardbreiten für Größe, Typ, Änderungsdatum (Pixel) + COLUMN_WIDTHS = {1: 90, 2: 150, 3: 135} + MIN_NAME_WIDTH = 160 + # Signale file_selected = Signal(str) folder_changed = Signal(str) @@ -111,11 +134,26 @@ def __init__(self, parent=None): self._file_count = 0 self._setup_ui() - # Startverzeichnis + # Startverzeichnis (Einstellung "Startordner", sonst Benutzerordner) + self.navigate_to(self.default_start_path()) + + @staticmethod + def default_start_path() -> str: + """Startordner aus den Einstellungen oder das Benutzerverzeichnis.""" home = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.HomeLocation ) - self.navigate_to(home) + try: + from core.settings_manager import SettingsManager + + configured = SettingsManager.instance().get("general", "start_folder", "") or "" + except Exception: + configured = "" + if isinstance(configured, str) and configured.strip(): + candidate = normalize_user_path(configured) + if os.path.isdir(candidate): + return candidate + return home def _setup_ui(self): layout = QVBoxLayout(self) @@ -123,10 +161,7 @@ def _setup_ui(self): # Datei-System-Model self.model = QFileSystemModel(self) - self.model.setFilter( - QDir.Filter.AllEntries | - QDir.Filter.NoDotAndDotDot - ) + self.model.setFilter(base_entry_filters()) self.model.directoryLoaded.connect(self._on_directory_loaded) # Sortier-Proxy @@ -152,13 +187,22 @@ def _setup_ui(self): # System-Icons: explizite Größe damit die Icons in Spalte 0 sichtbar # dargestellt werden (QFileSystemModel liefert sie über QFileIconProvider). self.table.setIconSize(QSize(16, 16)) - - # Header + # Lange Namen in der Mitte kürzen, damit Anfang und Endung lesbar bleiben. + self.table.setTextElideMode(Qt.TextElideMode.ElideMiddle) + self.table.setWordWrap(False) + + # Header: Die Namensspalte erhält den Restplatz. Die übrigen Spalten + # haben feste, vom Nutzer veränderbare Breiten. Mit ResizeToContents + # konnten lange Typbezeichnungen ("Komprimierter (gezippter) Ordner") + # die Namensspalte auf wenige Pixel zusammendrücken – sichtbar blieb + # dann nur noch der Typ "Ordner" statt des Ordnernamens. header = self.table.horizontalHeader() + header.setMinimumSectionSize(48) + header.setStretchLastSection(False) header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + for column, width in self.COLUMN_WIDTHS.items(): + header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive) + header.resizeSection(column, width) # Signale self.table.clicked.connect(self._on_item_clicked) @@ -186,19 +230,45 @@ def _setup_ui(self): layout.addWidget(self.table) + def _ensure_name_column_visible(self): + """Verkleinert Zusatzspalten, wenn sonst kein Platz für Namen bleibt.""" + header = self.table.horizontalHeader() + available = self.table.viewport().width() + if available <= 0: + return + others = [c for c in self.COLUMN_WIDTHS if not self.table.isColumnHidden(c)] + used = sum(header.sectionSize(c) for c in others) + overflow = used + self.MIN_NAME_WIDTH - available + if overflow <= 0: + return + # Zuerst den Typ, dann das Datum, zuletzt die Größe verkleinern. + for column in (2, 3, 1): + if overflow <= 0 or column not in others: + continue + size = header.sectionSize(column) + shrink = min(overflow, max(0, size - header.minimumSectionSize())) + if shrink: + header.resizeSection(column, size - shrink) + overflow -= shrink + def _on_directory_loaded(self, path: str): """Handler wenn Verzeichnis geladen wurde""" - if path == self._current_path: + if os.path.normcase(os.path.normpath(path)) == os.path.normcase(os.path.normpath(self._current_path or "")): self._update_file_count() + self.selection_changed.emit(len(self.get_selected_files())) def _update_file_count(self): - """Aktualisiert die Datei-Anzahl""" - if self._current_path: - try: - entries = list(Path(self._current_path).iterdir()) - self._file_count = len(entries) - except (OSError, PermissionError): - self._file_count = 0 + """Aktualisiert die Anzahl sichtbarer Einträge. + + Zählt die Zeilen des (asynchron geladenen) Modells statt das + Verzeichnis erneut synchron im GUI-Thread aufzulisten – bei Netzwerk- + und Cloud-Ordnern blockierte das zuvor die Oberfläche. + """ + if not self._current_path: + self._file_count = 0 + return + root = self.model.index(self._current_path) + self._file_count = self.model.rowCount(root) if root.isValid() else 0 def _on_selection_changed(self): """Handler für Auswahl-Änderungen""" @@ -207,7 +277,18 @@ def _on_selection_changed(self): def navigate_to(self, path: str): """Navigiert zu einem Pfad""" - if not os.path.exists(path): + if not path: + return + path = normalize_user_path(path) + if os.path.isfile(path): + # Datei-Pfad (z. B. aus Suche oder Adresszeile): Ordner öffnen + # und die Datei anschließend markieren. + folder = os.path.dirname(path) + if folder and os.path.isdir(folder): + self.navigate_to(folder) + self.file_selected.emit(path) + return + if not os.path.isdir(path): return # History aktualisieren @@ -274,9 +355,17 @@ def refresh(self): self.table.setRootIndex(self.proxy.mapFromSource(source_index)) self._update_file_count() + def set_icon_size(self, size: int): + """Setzt die Größe der Dateisymbole (Einstellung "Symbolgröße").""" + size = max(12, min(64, int(size))) + self.table.setIconSize(QSize(size, size)) + self.table.verticalHeader().setDefaultSectionSize( + max(size + 6, self.table.fontMetrics().height() + 6) + ) + def set_show_hidden_files(self, show: bool): """Schaltet die Anzeige versteckter Dateien um.""" - filters = QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot + filters = base_entry_filters() if show: filters |= QDir.Filter.Hidden self.model.setFilter(filters) @@ -476,8 +565,8 @@ def _open_file(self, path: str): except (OSError, subprocess.CalledProcessError) as exc: QMessageBox.warning( self, - "Datei öffnen", - f"Die Datei konnte nicht geöffnet werden:\n{path}\n\n{exc}" + t("Datei öffnen"), + t("Die Datei konnte nicht geöffnet werden:") + f"\n{path}\n\n{exc}" ) def _edit_file(self, path: str): @@ -529,10 +618,10 @@ def _check_privacy(self, path: str): if alert.detected_patterns: QMessageBox.warning( - self, "Datenschutz-Prüfung", - f"Status: {alert.status.value.upper()}\n\n" - f"Erkannte Muster:\n• " + - "\n• ".join(alert.detected_patterns) + self, t("Datenschutz-Prüfung"), + t("Status: {status}").format(status=alert.status.value.upper()) + "\n\n" + + t("Erkannte Muster:") + "\n• " + + "\n• ".join(alert.detected_patterns) ) else: QMessageBox.information( @@ -541,8 +630,8 @@ def _check_privacy(self, path: str): ) except Exception as e: QMessageBox.warning( - self, "Fehler", - f"Konnte Datei nicht prüfen: {e}" + self, t("Fehler"), + t("Konnte Datei nicht prüfen: {error}").format(error=e) ) @property @@ -626,8 +715,8 @@ def open_terminal(self, target_path: str = None) -> bool: except Exception as exc: QMessageBox.warning( self, - "Terminal öffnen", - f"Konnte Terminal nicht öffnen:\n{exc}" + t("Terminal öffnen"), + t("Konnte Terminal nicht öffnen:") + f"\n{exc}" ) return False @@ -666,14 +755,14 @@ def create_new_folder(self) -> bool: return True except FileExistsError: QMessageBox.warning( - self, "Neuer Ordner", - f"Ein Ordner oder eine Datei mit dem Namen '{name}' existiert bereits." + self, t("Neuer Ordner"), + t("Ein Ordner oder eine Datei mit dem Namen '{name}' existiert bereits.").format(name=name) ) return False except OSError as exc: QMessageBox.warning( - self, "Neuer Ordner", - f"Konnte Ordner nicht erstellen:\n{exc}" + self, t("Neuer Ordner"), + t("Konnte Ordner nicht erstellen:") + f"\n{exc}" ) return False @@ -698,14 +787,14 @@ def create_new_file(self) -> bool: return True except FileExistsError: QMessageBox.warning( - self, "Neue Datei", - f"Ein Element namens '{name}' existiert bereits in diesem Verzeichnis." + self, t("Neue Datei"), + t("Ein Element namens '{name}' existiert bereits in diesem Verzeichnis.").format(name=name) ) return False except OSError as exc: QMessageBox.warning( - self, "Neue Datei", - f"Konnte Datei nicht erstellen:\n{exc}" + self, t("Neue Datei"), + t("Konnte Datei nicht erstellen:") + f"\n{exc}" ) return False @@ -736,8 +825,8 @@ def rename_selection(self, target_path: str = None) -> bool: new_path = os.path.join(parent_dir, new_name) if os.path.exists(new_path): QMessageBox.warning( - self, "Umbenennen", - f"Ein Element namens '{new_name}' existiert bereits in diesem Verzeichnis." + self, t("Umbenennen"), + t("Ein Element namens '{name}' existiert bereits in diesem Verzeichnis.").format(name=new_name) ) return False @@ -747,8 +836,8 @@ def rename_selection(self, target_path: str = None) -> bool: return True except OSError as exc: QMessageBox.warning( - self, "Umbenennen", - f"Konnte Element nicht umbenennen:\n{exc}" + self, t("Umbenennen"), + t("Konnte Element nicht umbenennen:") + f"\n{exc}" ) return False @@ -786,18 +875,22 @@ def delete_selection(self, target_paths: list = None) -> bool: count = len(target_paths) if count == 1: - msg = f"Möchten Sie '{os.path.basename(target_paths[0])}' wirklich unwiderruflich löschen?" + msg = t("Möchten Sie '{name}' wirklich unwiderruflich löschen?").format( + name=os.path.basename(target_paths[0]) + ) else: preview = "\n".join(f"• {os.path.basename(p)}" for p in target_paths[:5]) if count > 5: - preview += f"\n... und {count - 5} weitere" - msg = f"Möchten Sie diese {count} Elemente wirklich unwiderruflich löschen?\n\n{preview}" + preview += "\n" + t("... und {count} weitere").format(count=count - 5) + msg = t("Möchten Sie diese {count} Elemente wirklich unwiderruflich löschen?").format( + count=count + ) + f"\n\n{preview}" confirm_delete = SettingsManager.instance().get("general", "confirm_delete", True) is not False if confirm_delete: reply = QMessageBox.question( self, - "Löschen bestätigen", + t("Löschen bestätigen"), msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No @@ -818,8 +911,8 @@ def delete_selection(self, target_paths: list = None) -> bool: self.refresh() if errors: QMessageBox.warning( - self, "Fehler beim Löschen", - "Folgende Elemente konnten nicht gelöscht werden:\n\n" + "\n".join(errors) + self, t("Fehler beim Löschen"), + t("Folgende Elemente konnten nicht gelöscht werden:") + "\n\n" + "\n".join(errors) ) return False return True @@ -840,8 +933,10 @@ def _save_path_as_prompt(self, path: str): main_win = self.window() filename = os.path.basename(path) is_file = os.path.isfile(path) - content = f"Analysiere bitte folgende Datei:\n{path}" if is_file else f"Analysiere bitte folgenden Ordner:\n{path}" - title = f"Pfad: {filename}" + content = ( + t("Analysiere bitte folgende Datei:") if is_file else t("Analysiere bitte folgenden Ordner:") + ) + f"\n{path}" + title = t("Pfad: {name}").format(name=filename) if hasattr(main_win, 'sidebar') and hasattr(main_win.sidebar, 'prompts_panel'): prompts_panel = main_win.sidebar.prompts_panel @@ -854,14 +949,14 @@ def _save_path_as_prompt(self, path: str): if hasattr(main_win, 'show_prompts_panel'): main_win.show_prompts_panel() QMessageBox.information( - self, "Prompt gespeichert", - f"Prompt für '{filename}' wurde in der Bibliothek gespeichert." + self, t("Prompt gespeichert"), + t("Prompt für '{name}' wurde in der Bibliothek gespeichert.").format(name=filename) ) else: QApplication.clipboard().setText(content) QMessageBox.information( - self, "Prompt in Zwischenablage", - f"Prompt-Vorlage für '{filename}' in die Zwischenablage kopiert." + self, t("Prompt in Zwischenablage"), + t("Prompt-Vorlage für '{name}' in die Zwischenablage kopiert.").format(name=filename) ) def _add_to_blacklist(self, path: str): @@ -871,13 +966,16 @@ def _add_to_blacklist(self, path: str): if hasattr(main_win, 'privacy_monitor') and main_win.privacy_monitor: main_win.privacy_monitor.add_to_blacklist(filename) QMessageBox.information( - self, "Datenschutz", - f"'{filename}' wurde zur Datenschutz-Blacklist hinzugefügt." + self, t("Datenschutz"), + t("'{name}' wurde zur Datenschutz-Blacklist hinzugefügt.").format(name=filename) + + "\n\n" + t("Die Liste kann über einen Klick auf die Datenschutz-Ampel bearbeitet werden.") ) else: QMessageBox.information( - self, "Datenschutz", - f"'{filename}' konnte nicht hinzugefügt werden: Datenschutz-Monitor nicht initialisiert." + self, t("Datenschutz"), + t("'{name}' konnte nicht hinzugefügt werden: Datenschutz-Monitor nicht initialisiert.").format( + name=filename + ) ) def _sync_path(self, path: str): @@ -960,7 +1058,7 @@ def _do_file_drop(self, src_paths: list, target_dir: str, move: bool = False): for src in src_paths: src = os.path.normpath(src) if not os.path.exists(src): - errors.append(f"Quelle nicht gefunden: {src}") + errors.append(t("Quelle nicht gefunden: {path}").format(path=src)) continue src_real = os.path.normcase(os.path.realpath(src)) @@ -973,8 +1071,8 @@ def _do_file_drop(self, src_paths: list, target_dir: str, move: bool = False): target_is_within_source = False if target_is_within_source: errors.append( - f"{os.path.basename(src)}: Ordner kann nicht in seinen " - "eigenen Unterordner kopiert oder verschoben werden." + f"{os.path.basename(src)}: " + + t("Ordner kann nicht in seinen eigenen Unterordner kopiert oder verschoben werden.") ) continue elif os.path.normcase(os.path.realpath(os.path.dirname(src))) == target_real: @@ -1004,7 +1102,7 @@ def _do_file_drop(self, src_paths: list, target_dir: str, move: bool = False): if errors: QMessageBox.warning( self, - "Drag & Drop", - "Einige Dateien konnten nicht übertragen werden:\n\n" + t("Drag & Drop"), + t("Einige Dateien konnten nicht übertragen werden:") + "\n\n" + "\n".join(errors), ) diff --git a/src/gui/checksum_dialog.py b/src/gui/checksum_dialog.py index 94c1254..9e6cb7a 100644 --- a/src/gui/checksum_dialog.py +++ b/src/gui/checksum_dialog.py @@ -31,7 +31,7 @@ def __init__(self, filepath: str, parent=None): self._close_timer.timeout.connect(self._finish_pending_close) filename = os.path.basename(filepath) - self.setWindowTitle(f"Prüfsummen — {filename}") + self.setWindowTitle(t("Prüfsummen — {name}").format(name=filename)) self.setMinimumWidth(580) self.setAccessibleName("Prüfsummen-Dialog") self.setAccessibleDescription( @@ -90,15 +90,19 @@ def _setup_ui(self): edit.setReadOnly(True) edit.setFont(mono_font) edit.setPlaceholderText("Wird berechnet...") - edit.setAccessibleName(f"{algo.upper()} Prüfsumme") - edit.setAccessibleDescription(f"Berechnete {algo.upper()}-Prüfsumme für die ausgewählte Datei.") + edit.setAccessibleName(t("{algorithm} Prüfsumme").format(algorithm=algo.upper())) + edit.setAccessibleDescription( + t("Berechnete {algorithm}-Prüfsumme für die ausgewählte Datei.").format(algorithm=algo.upper()) + ) self.hash_edits[algo] = edit row_layout.addWidget(edit, 1) btn = QPushButton("Kopieren") - btn.setAccessibleName(f"{algo.upper()} Prüfsumme kopieren") - btn.setAccessibleDescription(f"Kopiert die berechnete {algo.upper()}-Prüfsumme in die Zwischenablage.") - btn.setToolTip(f"{algo.upper()}-Prüfsumme in die Zwischenablage kopieren") + btn.setAccessibleName(t("{algorithm} Prüfsumme kopieren").format(algorithm=algo.upper())) + btn.setAccessibleDescription( + t("Kopiert die berechnete {algorithm}-Prüfsumme in die Zwischenablage.").format(algorithm=algo.upper()) + ) + btn.setToolTip(t("{algorithm}-Prüfsumme in die Zwischenablage kopieren").format(algorithm=algo.upper())) btn.clicked.connect(lambda checked=False, a=algo: self._copy_hash(a)) row_layout.addWidget(btn) @@ -194,7 +198,7 @@ def _on_finished(self, results: dict): @Slot(str) def _on_error(self, error_msg: str): self.progress_bar.setFormat("Fehlgeschlagen") - self.verify_result_label.setText(f"Fehler: {error_msg}") + self.verify_result_label.setText(t("Fehler: {error}").format(error=error_msg)) self.verify_result_label.setStyleSheet("color: red; font-weight: bold;") def _copy_hash(self, algo: str): @@ -208,9 +212,9 @@ def _copy_all_hashes(self): filename = os.path.basename(self.filepath) size_str = self.size_label.text() if hasattr(self, "size_label") else "" lines = [ - f"Datei: {filename}", - f"Pfad: {self.filepath}", - f"Größe: {size_str}", + t("Datei: {name}").format(name=filename), + t("Pfad: {path}").format(path=self.filepath), + t("Größe: {size}").format(size=size_str), "-" * 40, ] for algo in ("md5", "sha1", "sha256", "sha512"): @@ -241,7 +245,9 @@ def _on_verify_text_changed(self, text: str): match = verify_hash(self._calculated_hashes, text) if match: algo, _ = match - self.verify_result_label.setText(f"✓ Übereinstimmung gefunden! ({algo.upper()})") + self.verify_result_label.setText( + "✓ " + t("Übereinstimmung gefunden! ({algorithm})").format(algorithm=algo.upper()) + ) self.verify_result_label.setStyleSheet("color: #0d8050; font-weight: bold;") else: self.verify_result_label.setText("✗ Keine Übereinstimmung mit berechneten Prüfsummen.") diff --git a/src/gui/diff_dialog.py b/src/gui/diff_dialog.py index f45e283..920107d 100644 --- a/src/gui/diff_dialog.py +++ b/src/gui/diff_dialog.py @@ -171,23 +171,23 @@ def _do_compare(self): return if not os.path.exists(f1): - QMessageBox.warning(self, t("Datei nicht gefunden"), f"{t('Datei nicht gefunden')}:\n{f1}") + QMessageBox.warning(self, t("Datei nicht gefunden"), t("Datei nicht gefunden:\n{path}").format(path=f1)) return if not os.path.exists(f2): - QMessageBox.warning(self, t("Datei nicht gefunden"), f"{t('Datei nicht gefunden')}:\n{f2}") + QMessageBox.warning(self, t("Datei nicht gefunden"), t("Datei nicht gefunden:\n{path}").format(path=f2)) return if not os.path.isfile(f1): - QMessageBox.warning(self, t("Ungültiger Pfad"), f"{t('Pfad ist keine Datei')}:\n{f1}") + QMessageBox.warning(self, t("Ungültiger Pfad"), t("Pfad ist keine Datei:\n{path}").format(path=f1)) return if not os.path.isfile(f2): - QMessageBox.warning(self, t("Ungültiger Pfad"), f"{t('Pfad ist keine Datei')}:\n{f2}") + QMessageBox.warning(self, t("Ungültiger Pfad"), t("Pfad ist keine Datei:\n{path}").format(path=f2)) return try: self.current_diff = compare_files(f1, f2) except Exception as exc: - QMessageBox.critical(self, t("Fehler"), f"{t('Fehler beim Dateivergleich')}:\n{exc}") + QMessageBox.critical(self, t("Fehler"), t("Fehler beim Dateivergleich:\n{error}").format(error=exc)) return self._render_diff() @@ -199,22 +199,27 @@ def _render_diff(self): # Status & Header if diff.is_identical: - self.status_label.setText(f"✓ {t('Dateien sind absolut identisch')}") + self.status_label.setText("✓ " + t("Dateien sind absolut identisch")) self.status_label.setStyleSheet("color: #2e7d32; font-weight: bold; font-size: 13px;") else: - self.status_label.setText(f"≠ {t('Dateien weisen Unterschiede auf')}") + self.status_label.setText("≠ " + t("Dateien weisen Unterschiede auf")) self.status_label.setStyleSheet("color: #c62828; font-weight: bold; font-size: 13px;") # Details - size_txt = f"{diff.file1_name} ({diff.file1_size:,} Bytes) ↔ {diff.file2_name} ({diff.file2_size:,} Bytes)" + size_txt = t("{name1} ({size1} Bytes) ↔ {name2} ({size2} Bytes)").format( + name1=diff.file1_name, + size1=f"{diff.file1_size:,}", + name2=diff.file2_name, + size2=f"{diff.file2_size:,}", + ) hash_txt = f"SHA256 1: {diff.file1_hash[:16]}... | SHA256 2: {diff.file2_hash[:16]}..." if diff.is_binary: stat_txt = t("Binärvergleich") else: - stat_txt = ( - f"+{diff.stats['added']} " + t("hinzugefügt") + " | " + - f"-{diff.stats['deleted']} " + t("entfernt") + " | " + - f"{diff.stats['identical']} " + t("identisch") + stat_txt = t("+{added} hinzugefügt | -{deleted} entfernt | {identical} identisch").format( + added=diff.stats['added'], + deleted=diff.stats['deleted'], + identical=diff.stats['identical'], ) self.meta_label.setText(f"{size_txt}\n{hash_txt}\n{stat_txt}") diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 789286c..fd51477 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -7,9 +7,8 @@ from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QSplitter, - QLabel, QToolBar, QLineEdit, QPushButton, QMessageBox, - QToolButton, QDialog, QFormLayout, QCheckBox, QGroupBox, - QVBoxLayout as QVBox, QDialogButtonBox, QFileDialog, + QToolBar, QLineEdit, QPushButton, QMessageBox, + QToolButton, QDialog, QFileDialog, QMenu ) from PySide6.QtCore import Qt, QSize, Signal, QStandardPaths @@ -21,6 +20,7 @@ from .browser.file_browser import FileBrowser from .preview.preview_panel import PreviewPanel from .status_bar import StatusBarWidget +from .privacy_dialog import PrivacySettingsDialog # noqa: F401 (öffentliche API) from version import __version__ from translator import t @@ -139,114 +139,6 @@ def set_path(self, path: str): self.path_edit.setText(path) -class PrivacySettingsDialog(QDialog): - """Dialog für Datenschutz-Einstellungen""" - - def __init__(self, privacy_monitor, parent=None): - super().__init__(parent) - self.privacy_monitor = privacy_monitor - self.setWindowTitle("Datenschutz-Einstellungen") - self.setMinimumWidth(450) - self.setAccessibleName("Datenschutz-Einstellungen") - self.setAccessibleDescription( - "Konfiguration von Erkennungsmustern, automatischer Bereinigung und Datenschutzkriterien." - ) - self._setup_ui() - self._load_settings() - - def _setup_ui(self): - layout = QVBox(self) - - # Pattern-Gruppe - pattern_group = QGroupBox("Erkennungsmuster") - pattern_layout = QVBox(pattern_group) - - self.pattern_checks = {} - from modules.privacy.privacy_monitor import BUILTIN_PATTERNS - - for key, info in BUILTIN_PATTERNS.items(): - cb = QCheckBox(f"{info['name']} - {info['description']}") - cb.setAccessibleName(f"{info['name']} Erkennungsmuster") - cb.setAccessibleDescription(info['description']) - cb.setToolTip(f"Schweregrad: {info['severity']}") - self.pattern_checks[key] = cb - pattern_layout.addWidget(cb) - - layout.addWidget(pattern_group) - - # Optionen - options_group = QGroupBox("Optionen") - options_layout = QVBox(options_group) - - self.case_sensitive_cb = QCheckBox("Groß-/Kleinschreibung beachten") - self.case_sensitive_cb.setAccessibleName("Groß- und Kleinschreibung beachten") - self.case_sensitive_cb.setToolTip(t("Groß-/Kleinschreibung bei der Mustersuche berücksichtigen")) - options_layout.addWidget(self.case_sensitive_cb) - - self.whole_words_cb = QCheckBox("Nur ganze Wörter") - self.whole_words_cb.setAccessibleName("Nur ganze Wörter") - self.whole_words_cb.setToolTip(t("Nur eigenständige Wörter als Treffer werten")) - options_layout.addWidget(self.whole_words_cb) - - self.auto_clear_cb = QCheckBox("Clipboard bei ROT automatisch leeren") - self.auto_clear_cb.setAccessibleName("Clipboard bei Alarm automatisch leeren") - self.auto_clear_cb.setToolTip("Zwischenablage automatisch leeren, wenn sensible Daten erkannt werden") - options_layout.addWidget(self.auto_clear_cb) - - layout.addWidget(options_group) - - # Statistik - stats_group = QGroupBox("Statistik") - stats_layout = QFormLayout(stats_group) - - stats = self.privacy_monitor.get_stats() - stats_layout.addRow("Blacklist-Einträge:", QLabel(str(stats['blacklist_count']))) - stats_layout.addRow("Whitelist-Einträge:", QLabel(str(stats['whitelist_count']))) - stats_layout.addRow("Aktive Patterns:", QLabel(str(stats['active_patterns']))) - - layout.addWidget(stats_group) - - # Buttons - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | - QDialogButtonBox.StandardButton.Cancel - ) - ok_btn = buttons.button(QDialogButtonBox.StandardButton.Ok) - if ok_btn: - ok_btn.setAccessibleName("Einstellungen speichern") - ok_btn.setToolTip(t("Speichert die Datenschutz-Einstellungen (Enter)")) - cancel_btn = buttons.button(QDialogButtonBox.StandardButton.Cancel) - if cancel_btn: - cancel_btn.setAccessibleName("Abbrechen") - cancel_btn.setToolTip(t("Verwirft Änderungen (Esc)")) - buttons.accepted.connect(self._save_and_close) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - def _load_settings(self): - """Lädt aktuelle Einstellungen""" - for key, cb in self.pattern_checks.items(): - cb.setChecked(self.privacy_monitor.pattern_enabled.get(key, False)) - - self.case_sensitive_cb.setChecked(self.privacy_monitor.case_sensitive) - self.whole_words_cb.setChecked(self.privacy_monitor.whole_words) - self.auto_clear_cb.setChecked(self.privacy_monitor._auto_clear) - - def _save_and_close(self): - """Speichert und schließt""" - for key, cb in self.pattern_checks.items(): - self.privacy_monitor.pattern_enabled[key] = cb.isChecked() - - self.privacy_monitor.case_sensitive = self.case_sensitive_cb.isChecked() - self.privacy_monitor.whole_words = self.whole_words_cb.isChecked() - self.privacy_monitor._auto_clear = self.auto_clear_cb.isChecked() - - self.privacy_monitor._compile_patterns() - self.privacy_monitor.save_config() - - self.accept() - - class MainWindow(QMainWindow): """ Hauptfenster mit 3-Panel Layout: @@ -532,6 +424,9 @@ def _setup_statusbar(self): """Erstellt die Statusleiste mit Ampel""" self.status_widget = StatusBarWidget() self.setStatusBar(self.status_widget) + self.status_widget.privacy_settings_requested.connect(self._show_privacy_settings) + self.status_widget.privacy_reset_requested.connect(self._reset_privacy_status) + self.status_widget.privacy_monitoring_toggled.connect(self._set_privacy_monitoring) # ===== Menü-Aktionen ===== @@ -539,9 +434,9 @@ def _export_workspace(self): """Exportiert den Arbeitsbereich als explorerpro-workspace-v1.json.""" output_path, _ = QFileDialog.getSaveFileName( self, - "Arbeitsbereich exportieren", + t("Arbeitsbereich exportieren"), str(Path.home() / "explorerpro-workspace.json"), - "JSON-Dateien (*.json)", + t("JSON-Dateien (*.json)"), ) if not output_path: return @@ -551,9 +446,9 @@ def _export_workspace(self): settings = SettingsManager.instance()._settings exporter = WorkspaceExporter(settings=settings) exporter.save_export(Path(output_path)) - self.statusBar().showMessage(f"✅ Exportiert: {output_path}", 5000) + self.statusBar().showMessage("✅ " + t("Exportiert: {path}").format(path=output_path), 5000) except Exception as e: - QMessageBox.warning(self, "Export fehlgeschlagen", str(e)) + QMessageBox.warning(self, t("Export fehlgeschlagen"), str(e)) def _show_properties(self): """Öffnet den Eigenschaften-Dialog für ausgewählte Datei oder Ordner.""" @@ -563,7 +458,7 @@ def _copy_path(self): """Kopiert den Pfad der Auswahl in die Zwischenablage.""" copied = self.file_browser.copy_path_to_clipboard() if copied: - self.statusBar().showMessage("📋 Pfad kopiert", 3000) + self.statusBar().showMessage("📋 " + t("Pfad kopiert"), 3000) def _open_terminal(self): """Öffnet ein Terminal im aktuellen Verzeichnis.""" @@ -573,7 +468,7 @@ def _open_folder(self): """Öffnet einen Ordner-Dialog""" folder = QFileDialog.getExistingDirectory( - self, "Ordner öffnen", + self, t("Ordner öffnen"), self.file_browser.current_path ) if folder: @@ -660,14 +555,18 @@ def _index_folder(self): if current_path: self.index_worker = IndexWorker(self.file_index, current_path) self.index_worker.progress.connect( - lambda c, t: self.statusBar().showMessage(f"Indiziere: {c}/{t}") + lambda current, total: self.statusBar().showMessage( + t("Indiziere: {current}/{total}").format(current=current, total=total) + ) ) self.index_worker.finished_indexing.connect( - lambda n: self.statusBar().showMessage(f"✅ {n} Dateien indiziert", 5000) + lambda n: self.statusBar().showMessage( + "✅ " + t("{count} Dateien indiziert").format(count=n), 5000 + ) ) self.index_worker.start() else: - self.statusBar().showMessage("Indizierung: Funktion in app.py", 3000) + self.statusBar().showMessage(t("Indizierung ist nicht verfügbar."), 3000) def _find_duplicates(self): """Duplikate finden - öffnet DuplicateFinderDialog""" @@ -678,7 +577,7 @@ def _find_duplicates(self): elif hasattr(self, 'show_duplicate_finder'): self.show_duplicate_finder() else: - self.statusBar().showMessage("Duplikate-Finder: In app.py verfügbar", 3000) + self.statusBar().showMessage(t("Duplikatsuche ist nicht verfügbar."), 3000) def _calculate_checksums(self): """Öffnet den Prüfsummen-Dialog für die ausgewählte Datei oder fordert zur Dateiauswahl auf.""" @@ -688,7 +587,7 @@ def _calculate_checksums(self): target_path = selected[0] else: file_path, _ = QFileDialog.getOpenFileName( - self, "Datei für Prüfsummenberechnung auswählen" + self, t("Datei für Prüfsummenberechnung auswählen") ) if file_path and os.path.isfile(file_path): target_path = file_path @@ -707,7 +606,7 @@ def _open_editor(self): self.file_browser._edit_file(path) break else: - self.statusBar().showMessage("Keine Datei ausgewählt", 3000) + self.statusBar().showMessage(t("Keine Datei ausgewählt"), 3000) def show_apps_panel(self): self.sidebar.switch_to_apps() @@ -718,17 +617,6 @@ def show_prompts_panel(self): def show_sync_panel(self): self.sidebar.switch_to_sync() - def _show_privacy_settings(self): - """Zeigt Datenschutz-Einstellungen""" - if hasattr(self, 'privacy_monitor'): - dialog = PrivacySettingsDialog(self.privacy_monitor, self) - dialog.exec() - else: - QMessageBox.information( - self, "Hinweis", - "Datenschutz-Monitor nicht initialisiert." - ) - def _open_new_window(self): """Öffnet ein weiteres ExplorerPro-Fenster im aktuellen Ordner.""" # Referenzen sammeln, sonst raeumt der Garbage Collector die neuen @@ -749,17 +637,17 @@ def _open_new_window(self): def _copy_selection(self): """Kopiert die Auswahl des Dateibrowsers in die Zwischenablage.""" if self.file_browser.copy_selection(): - self.statusBar().showMessage("In die Zwischenablage kopiert", 3000) + self.statusBar().showMessage(t("In die Zwischenablage kopiert"), 3000) else: - self.statusBar().showMessage("Keine Datei ausgewählt", 3000) + self.statusBar().showMessage(t("Keine Datei ausgewählt"), 3000) def _paste_clipboard(self): """Fügt Dateien aus der Zwischenablage in den aktuellen Ordner ein.""" if self.file_browser.paste_from_clipboard(): - self.statusBar().showMessage("Eingefügt", 3000) + self.statusBar().showMessage(t("Eingefügt"), 3000) else: self.statusBar().showMessage( - "Keine Dateien in der Zwischenablage", 3000 + t("Keine Dateien in der Zwischenablage"), 3000 ) def _create_new_folder(self): @@ -798,10 +686,13 @@ def _show_settings(self): # Referenz am Fenster halten, damit der Dialog nicht sofort wieder # eingesammelt wird. - self.settings_dialog = SettingsDialog(self) + self.settings_dialog = SettingsDialog( + self, privacy_monitor=getattr(self, "privacy_monitor", None) + ) + self.settings_dialog.privacy_lists_requested.connect(self._show_privacy_settings) if self.settings_dialog.exec() == QDialog.DialogCode.Accepted: self._apply_settings() - self.statusBar().showMessage("Einstellungen gespeichert", 3000) + self.statusBar().showMessage(t("Einstellungen gespeichert"), 3000) def _sync_delete_confirmation(self): from core.settings_manager import SettingsManager @@ -817,36 +708,106 @@ def _set_delete_confirmation(self, checked): settings.set("general", "confirm_delete", checked) settings.save() - def _apply_settings(self): - """Wendet die gespeicherten Einstellungen auf das laufende Fenster an.""" + def _apply_settings(self, startup: bool = False): + """Wendet die gespeicherten Einstellungen auf das laufende Fenster an. + + Wird beim Start und nach dem Einstellungsdialog aufgerufen, damit + jede Option im Dialog auch tatsächlich wirkt. + """ from core.settings_manager import SettingsManager + from core.appearance import apply_font_size, apply_theme + from PySide6.QtWidgets import QApplication + from translator import SUPPORTED_LANGUAGES, get_translator settings = SettingsManager.instance() self._sync_delete_confirmation() + # Sprache (live; Qt-Standarddialoge folgen über den Sprach-Listener) + language = settings.get("general", "language", "de") + if language in SUPPORTED_LANGUAGES: + get_translator().set_language(language) + show_hidden = bool(settings.get("general", "show_hidden_files", False)) if hasattr(self.file_browser, "set_show_hidden_files"): self.file_browser.set_show_hidden_files(show_hidden) show_preview = bool(settings.get("preview", "show_preview", True)) - self.preview_panel.setVisible(show_preview) self.toggle_preview.setChecked(show_preview) + self._toggle_preview() + + # Darstellung + app = QApplication.instance() + if app is not None: + apply_theme(app, str(settings.get("appearance", "theme", "system"))) + apply_font_size(app, settings.get("appearance", "font_size", 0)) + try: + icon_size = int(settings.get("appearance", "icon_size", 16)) + except (TypeError, ValueError): + icon_size = 16 + if hasattr(self.file_browser, "set_icon_size"): + self.file_browser.set_icon_size(icon_size) + + # Datenschutz-Ampel + monitor = getattr(self, "privacy_monitor", None) + if monitor is not None: + enabled = bool(settings.get("privacy", "enable_clipboard_monitor", True)) + if enabled != monitor.enabled: + monitor.enabled = enabled + if not startup: + # Beim Start bleibt privacy_config.json maßgeblich, damit eine + # alte Standardvorgabe nicht unbemerkt das Leeren aktiviert. + monitor.auto_clear = bool(settings.get("privacy", "auto_block_sensitive", False)) + monitor.save_config() + + def _show_privacy_settings(self): + """Zeigt Datenschutz-Einstellungen""" + if hasattr(self, 'privacy_monitor'): + dialog = PrivacySettingsDialog(self.privacy_monitor, self) + dialog.exec() + else: + QMessageBox.information( + self, t("Hinweis"), + t("Datenschutz-Monitor nicht initialisiert.") + ) + + def _set_privacy_monitoring(self, enabled: bool): + """Schaltet die Ampel aus dem Schnellmenü ein oder aus und speichert das.""" + from core.settings_manager import SettingsManager + + monitor = getattr(self, "privacy_monitor", None) + if monitor is None: + return + monitor.enabled = bool(enabled) + settings = SettingsManager.instance() + settings.set("privacy", "enable_clipboard_monitor", bool(enabled)) + settings.save() + self.statusBar().showMessage( + t("Datenschutz-Überwachung aktiviert") if enabled else t("Datenschutz-Überwachung deaktiviert"), + 3000, + ) + + def _reset_privacy_status(self): + monitor = getattr(self, "privacy_monitor", None) + if monitor is not None: + monitor.reset_status() def _show_about(self): """Zeigt den Über-Dialog""" + features = [ + t("Datenbank-gestützter Volltextsuche"), + t("Integrierter Code-Bearbeitung"), + t("Datenschutz-Ampel"), + t("App-Launcher"), + t("Prompt-Bibliothek"), + ] + items = "".join(f"
  • {item}
  • " for item in features) QMessageBox.about( self, - "Über ExplorerPro", + t("Über ExplorerPro"), f"""

    ExplorerPro

    -

    Version {__version__}

    -

    Ein intelligenter Datei-Explorer mit:

    -
      -
    • Datenbank-gestützter Volltextsuche
    • -
    • Integrierter Code-Bearbeitung
    • -
    • Datenschutz-Ampel
    • -
    • App-Launcher
    • -
    • Prompt-Bibliothek
    • -
    -

    Fusion aus: ProFiler, PythonBox, ProSync, AmpelTool, SoftwareCenter, ProfiPrompt

    +

    {t("Version {version}").format(version=__version__)}

    +

    {t("Ein intelligenter Datei-Explorer mit:")}

    +
      {items}
    +

    {t("Fusion aus: {tools}").format(tools="ProFiler, PythonBox, ProSync, AmpelTool, SoftwareCenter, ProfiPrompt")}

    """ ) diff --git a/src/gui/preview/preview_panel.py b/src/gui/preview/preview_panel.py index afcb030..8477fd1 100644 --- a/src/gui/preview/preview_panel.py +++ b/src/gui/preview/preview_panel.py @@ -10,15 +10,64 @@ QPlainTextEdit, QFrame, QComboBox, QPushButton, QTableWidget, QTableWidgetItem, ) -from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap, QImage, QFont, QSyntaxHighlighter, QTextCharFormat, QColor +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import ( + QPixmap, QImage, QImageReader, QFont, QSyntaxHighlighter, QTextCharFormat, QColor, +) +import logging import os from datetime import datetime from pathlib import Path +from core.file_attributes import is_cloud_placeholder from core.shortcut_utils import build_shortcut_preview_target, is_windows_shortcut +from core.ui_translator import NO_TRANSLATE from translator import t +IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tif', '.tiff'} +TEXT_EXTENSIONS = { + '.txt', '.md', '.py', '.pyw', '.js', '.html', '.htm', '.css', '.json', + '.xml', '.sql', '.c', '.cpp', '.h', '.hpp', '.java', '.ini', '.cfg', '.conf', + '.log', '.yml', '.yaml', '.toml', '.sh', '.bat', '.cmd', '.ps1', + '.csv', '.tsv', '.env', '.gitignore', '.editorconfig', + '.rs', '.go', '.ts', '.tsx', '.jsx', '.vue', '.svelte', '.kt', '.cs', '.swift', + '.r', '.rb', '.php', '.lua', '.tex', '.bib', '.cmake', '.rst', '.properties', +} +# Bilder werden höchstens in dieser Kantenlänge dekodiert (Speicher/Tempo). +MAX_IMAGE_EDGE = 2048 +# PDF-Seiten werden höchstens in dieser Breite gerendert. +MAX_PDF_WIDTH = 1600 + + +def _preview_setting(key: str, default): + try: + from core.settings_manager import SettingsManager + + return SettingsManager.instance().get("preview", key, default) + except Exception: + return default + + +def _looks_like_text(path: str) -> bool: + """Erkennt Textdateien ohne bekannte Endung (README, Makefile, *.conf ...).""" + try: + with open(path, 'rb') as f: + sample = f.read(4096) + except OSError: + return False + if not sample: + return True + if sample.startswith((b'\xff\xfe', b'\xfe\xff', b'\xef\xbb\xbf')): + return True + if b'\x00' in sample: + return False + try: + sample.decode('utf-8') + return True + except UnicodeDecodeError as exc: + # Abgeschnittenes Mehrbyte-Zeichen am Ende des Ausschnitts ist ok. + return exc.start >= len(sample) - 3 + # Optionale Imports try: import fitz # PyMuPDF @@ -90,20 +139,28 @@ def __init__(self, parent=None): self._original_pixmap = None def load_image(self, path: str): - """Lädt und zeigt ein Bild""" + """Lädt und zeigt ein Bild (verkleinert dekodiert, EXIF-Drehung beachtet)""" self._original_pixmap = None self.clear() try: - pixmap = QPixmap(path) + reader = QImageReader(path) + reader.setAutoTransform(True) + size = reader.size() + if size.isValid() and max(size.width(), size.height()) > MAX_IMAGE_EDGE: + reader.setScaledSize(size.scaled( + QSize(MAX_IMAGE_EDGE, MAX_IMAGE_EDGE), Qt.AspectRatioMode.KeepAspectRatio + )) + image = reader.read() + pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap(path) if pixmap.isNull(): - self.setText("Bild konnte nicht geladen werden") + self.setText(t("Bild konnte nicht geladen werden")) return self._original_pixmap = pixmap self._scale_to_fit() except Exception as e: self._original_pixmap = None - self.setText(f"Fehler: {e}") + self.setText(t("Fehler: {error}").format(error=e)) def _scale_to_fit(self): if self._original_pixmap and not self._original_pixmap.isNull(): @@ -192,7 +249,7 @@ def load_file(self, path: str): if self._highlighter is not None: self._highlighter.setDocument(None) self._highlighter = None - self.setPlainText(f"Fehler beim Laden: {e}") + self.setPlainText(t("Fehler beim Laden: {error}").format(error=e)) class DirectoryPreview(QPlainTextEdit): @@ -206,7 +263,7 @@ def __init__(self, parent=None): def load_directory(self, path: str, heading: str | None = None): folder = Path(path) - lines = [heading or f"Ordner: {folder}", ""] + lines = [heading or t("Ordner: {path}").format(path=folder), ""] try: entries = sorted( @@ -214,17 +271,19 @@ def load_directory(self, path: str, heading: str | None = None): key=lambda item: (not item.is_dir(), item.name.lower()), ) except OSError as exc: - self.setPlainText(f"Ordner konnte nicht gelesen werden:\n{folder}\n\n{exc}") + self.setPlainText( + t("Ordner konnte nicht gelesen werden:") + f"\n{folder}\n\n{exc}" + ) return if not entries: - lines.append("(leer)") + lines.append(t("(leer)")) else: for entry in entries[:200]: marker = "[DIR]" if entry.is_dir() else " " lines.append(f"{marker} {entry.name}") if len(entries) > 200: - lines.append(f"... {len(entries) - 200} weitere Einträge") + lines.append(t("... {count} weitere Einträge").format(count=len(entries) - 200)) self.setPlainText("\n".join(lines)) @@ -242,8 +301,9 @@ def __init__(self, parent=None): def load_pdf(self, path: str): """Lädt ein PDF und zeigt die erste Seite""" + self.content.clear() if not HAS_FITZ: - self.content.setText("PyMuPDF nicht installiert.\nPDF-Vorschau nicht verfügbar.") + self.content.setText(t("PyMuPDF nicht installiert.\nPDF-Vorschau nicht verfügbar.")) return try: @@ -251,25 +311,35 @@ def load_pdf(self, path: str): try: if len(doc) > 0: page = doc[0] - mat = fitz.Matrix(1.5, 1.5) # Zoom - pix = page.get_pixmap(matrix=mat) - + zoom = 1.5 + try: + width = float(page.rect.width) + if width > 0: + zoom = max(0.2, min(1.5, MAX_PDF_WIDTH / width)) + except (AttributeError, TypeError, ValueError): + pass + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat, alpha=False) + + # Puffer explizit halten und das Bild kopieren: QImage + # referenziert sonst Speicher, den PyMuPDF wieder freigibt. + samples = pix.samples img = QImage( - pix.samples, + samples, pix.width, pix.height, pix.stride, QImage.Format.Format_RGB888 - ) + ).copy() pixmap = QPixmap.fromImage(img) self.content.setPixmap(pixmap) else: - self.content.setText("Leeres PDF") + self.content.setText(t("Leeres PDF")) finally: doc.close() except Exception as e: - self.content.setText(f"Fehler beim Laden: {e}") + self.content.setText(t("Fehler beim Laden: {error}").format(error=e)) class MetadataPanel(QWidget): @@ -296,15 +366,22 @@ def save_user_data(self): tags, notes = self._user_data() if (tags, notes) == self._loaded_user_data: return - self._file_index.set_tags(self._current_path, tags.split(",")) - self._file_index.set_note(self._current_path, notes) + try: + self._file_index.set_tags(self._current_path, tags.split(",")) + self._file_index.set_note(self._current_path, notes) + except Exception as exc: # z. B. "database is locked" während einer Indizierung + logging.warning("Tags/Notizen konnten nicht gespeichert werden: %s", exc) + return self._loaded_user_data = (tags, notes) def _load_user_data(self, path: str): tags, notes = "", "" if self._file_index: - tags = ", ".join(self._file_index.get_tags(path)) - notes = self._file_index.get_note(path) + try: + tags = ", ".join(self._file_index.get_tags(path)) + notes = self._file_index.get_note(path) + except Exception as exc: + logging.warning("Tags/Notizen konnten nicht gelesen werden: %s", exc) self.tags_edit.setText(tags) self.notes_edit.setPlainText(notes) self._loaded_user_data = self._user_data() @@ -318,6 +395,8 @@ def _setup_ui(self): info_layout = QFormLayout(info_group) self.name_label = QLabel("-") + self.name_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.name_label.setWordWrap(True) info_layout.addRow("Name:", self.name_label) self.type_label = QLabel("-") @@ -332,6 +411,11 @@ def _setup_ui(self): self.created_label = QLabel("-") info_layout.addRow("Erstellt:", self.created_label) + # Werte sind Nutzerdaten (Dateinamen); nie automatisch übersetzen. + for label in (self.name_label, self.type_label, self.size_label, + self.modified_label, self.created_label): + label.setProperty(NO_TRANSLATE, True) + self.checksum_btn = QPushButton("🔑 Berechnen...") self.checksum_btn.setAccessibleName("Prüfsummen berechnen") self.checksum_btn.setAccessibleDescription("Öffnet den Dialog zur Berechnung und Verifizierung von Hash-Prüfsummen.") @@ -395,14 +479,20 @@ def show_metadata(self, path: str): except OSError: self.clear_metadata() self.name_label.setText(os.path.basename(path)) - self.type_label.setText("Nicht lesbar") + self.type_label.setText(t("Nicht lesbar")) return name = os.path.basename(path) ext = os.path.splitext(name)[1].lower() self.name_label.setText(name) - self.type_label.setText("Ordner" if os.path.isdir(path) else (ext or "Unbekannt")) + if os.path.isdir(path): + type_text = t("Ordner") + else: + type_text = ext or t("Unbekannt") + if is_cloud_placeholder(path): + type_text += " · ☁️ " + t("nur online") + self.type_label.setText(type_text) # Größe formatieren size = stat.st_size @@ -471,6 +561,7 @@ def _setup_ui(self): header.addWidget(QLabel("Arbeitsblatt:")) self.sheet_combo = QComboBox() + self.sheet_combo.setProperty(NO_TRANSLATE, True) # Blattnamen sind Nutzerdaten self.sheet_combo.setMinimumWidth(120) self.sheet_combo.setAccessibleName("Excel-Arbeitsblatt") self.sheet_combo.setToolTip(t("Arbeitsblatt der Excel-Arbeitsmappe auswählen")) @@ -564,7 +655,8 @@ def _show_fallback(self, reason: str): self.table.setRowCount(0) self.table.setColumnCount(0) self.status_label.setText( - f"Vorschau nicht verfügbar: {reason}\n→ Datei extern öffnen" + t("Vorschau nicht verfügbar: {reason}").format(reason=reason) + + "\n→ " + t("Datei extern öffnen") ) self.status_label.setVisible(True) self.open_extern_btn.setVisible(True) @@ -584,7 +676,7 @@ def _open_extern(self): else: subprocess.Popen(["xdg-open", self._path]) except Exception as exc: - self.status_label.setText(f"Externes Öffnen fehlgeschlagen: {exc}") + self.status_label.setText(t("Externes Öffnen fehlgeschlagen: {error}").format(error=exc)) self.status_label.setVisible(True) @@ -611,6 +703,7 @@ def _setup_ui(self): # Platzhalter placeholder = QLabel("Keine Datei ausgewählt") + placeholder.setWordWrap(True) placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_stack.addWidget(placeholder) @@ -631,8 +724,9 @@ def _setup_ui(self): self.preview_stack.addWidget(self.directory_preview) # Nicht unterstützt - self.unsupported_label = QLabel("Vorschau nicht verfügbar\nfür diesen Dateityp") + self.unsupported_label = QLabel(t("Vorschau nicht verfügbar\nfür diesen Dateityp")) self.unsupported_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.unsupported_label.setWordWrap(True) self.preview_stack.addWidget(self.unsupported_label) # Excel-Vorschau (.xlsx / .xls) — Index 6 @@ -658,7 +752,26 @@ def clear_preview(self): self.metadata_panel.clear_metadata() def show_preview(self, path: str): - """Zeigt Vorschau für eine Datei""" + """Zeigt Vorschau für eine Datei. + + Fehler beim Erzeugen der Vorschau dürfen niemals die Anwendung + beenden; sie werden protokolliert und als Hinweis angezeigt. + """ + try: + self._show_preview(path) + except Exception as exc: + logging.exception("Vorschau fehlgeschlagen für %s", path) + self._show_message(t("Vorschau nicht verfügbar: {reason}").format(reason=exc)) + try: + self.metadata_panel.show_metadata(path) + except Exception: + pass + + def _show_message(self, text: str): + self.unsupported_label.setText(text) + self.preview_stack.setCurrentWidget(self.unsupported_label) + + def _show_preview(self, path: str): if not path or not os.path.exists(path): self.clear_preview() return @@ -670,18 +783,17 @@ def show_preview(self, path: str): if is_windows_shortcut(path): shortcut_target = build_shortcut_preview_target(path) if shortcut_target is None: - self.unsupported_label.setText( - "Verknüpfung konnte nicht aufgelöst werden\noder das Ziel existiert nicht." + self._show_message( + t("Verknüpfung konnte nicht aufgelöst werden\noder das Ziel existiert nicht.") ) - self.preview_stack.setCurrentIndex(5) self.metadata_panel.show_metadata(path) return preview_path = shortcut_target.preview_path heading = ( - f"Verknüpfung: {os.path.basename(path)}\n" - f"Ziel: {shortcut_target.target_path}\n" - f"Vorschau: {shortcut_target.preview_path}" + t("Verknüpfung: {name}").format(name=os.path.basename(path)) + "\n" + + t("Ziel: {path}").format(path=shortcut_target.target_path) + "\n" + + t("Vorschau: {path}").format(path=shortcut_target.preview_path) ) self._show_preview_for_path(preview_path, heading) @@ -689,37 +801,79 @@ def show_preview(self, path: str): def _show_preview_for_path(self, path: str, heading: str | None = None): ext = os.path.splitext(path)[1].lower() - self.unsupported_label.setText("Vorschau nicht verfügbar\nfür diesen Dateityp") + self.unsupported_label.setText(t("Vorschau nicht verfügbar\nfür diesen Dateityp")) if os.path.isdir(path): self.directory_preview.load_directory(path, heading) self.preview_stack.setCurrentIndex(4) return + # Cloud-Platzhalter ("Nur online verfügbar") nicht öffnen: Das Lesen + # würde einen Download auslösen und die Oberfläche blockieren. + if is_cloud_placeholder(path): + self._show_message( + "☁️ " + t("Diese Datei ist nur online verfügbar.") + "\n" + + t("Zum Öffnen doppelklicken – die Datei wird dabei heruntergeladen.") + ) + return + + try: + size = os.path.getsize(path) + except OSError: + size = 0 + try: + limit_mb = float(_preview_setting("max_preview_size_mb", 50)) + except (TypeError, ValueError): + limit_mb = 50.0 + too_large = limit_mb > 0 and size > limit_mb * 1024 * 1024 + + def show_too_large(): + self._show_message( + t("Datei zu groß für die Vorschau ({size} MB, Grenze {limit} MB).").format( + size=f"{size / (1024 * 1024):.1f}", limit=f"{limit_mb:g}" + ) + ) + # Bild-Vorschau - if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']: - self.image_preview.load_image(path) - self.preview_stack.setCurrentIndex(1) - - # Text/Code-Vorschau - elif ext in ['.txt', '.md', '.py', '.js', '.html', '.css', '.json', - '.xml', '.sql', '.c', '.cpp', '.h', '.java', '.ini', '.cfg', - '.log', '.yml', '.yaml', '.toml', '.sh', '.bat', '.ps1', - '.csv', '.tsv', '.env', '.gitignore', '.editorconfig', - '.rs', '.go', '.ts', '.tsx', '.jsx', '.vue', '.svelte', - '.r', '.rb', '.php', '.lua', '.tex', '.bib', '.cmake']: - self.text_preview.load_file(path) - self.preview_stack.setCurrentIndex(2) + if ext in IMAGE_EXTENSIONS: + if not _preview_setting("preview_images", True): + self._show_message(t("Bildvorschau ist in den Einstellungen deaktiviert.")) + elif too_large: + show_too_large() + else: + self.image_preview.load_image(path) + self.preview_stack.setCurrentIndex(1) + + # Text/Code-Vorschau (liest höchstens 100 KB) + elif ext in TEXT_EXTENSIONS: + if not _preview_setting("preview_code", True): + self._show_message(t("Textvorschau ist in den Einstellungen deaktiviert.")) + else: + self.text_preview.load_file(path) + self.preview_stack.setCurrentIndex(2) # PDF-Vorschau elif ext == '.pdf': - self.pdf_preview.load_pdf(path) - self.preview_stack.setCurrentIndex(3) + if not _preview_setting("preview_pdfs", True): + self._show_message(t("PDF-Vorschau ist in den Einstellungen deaktiviert.")) + elif too_large: + show_too_large() + else: + self.pdf_preview.load_pdf(path) + self.preview_stack.setCurrentIndex(3) # Excel-Vorschau elif ext in ['.xlsx', '.xls']: - self.excel_preview.load_file(path) - self.preview_stack.setCurrentIndex(6) + if too_large: + show_too_large() + else: + self.excel_preview.load_file(path) + self.preview_stack.setCurrentIndex(6) + + # Unbekannte Endung, aber offensichtlich Text (README, Makefile, ...) + elif _preview_setting("preview_code", True) and _looks_like_text(path): + self.text_preview.load_file(path) + self.preview_stack.setCurrentIndex(2) # Nicht unterstützt else: diff --git a/src/gui/privacy_dialog.py b/src/gui/privacy_dialog.py new file mode 100644 index 0000000..1e10238 --- /dev/null +++ b/src/gui/privacy_dialog.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PrivacySettingsDialog - Einstellungen der Datenschutz-Ampel + +Bündelt alles, was die Ampel beeinflusst: +* Überwachung ein/aus, Hinweisdialoge, Ampel zurücksetzen +* eingebaute Erkennungsmuster und Suchoptionen +* frei editierbare Blacklist und Whitelist (inkl. Import/Export) +* einen Testbereich, der Eingaben mit der *noch nicht gespeicherten* + Konfiguration prüft +""" + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QAbstractItemView, QCheckBox, QDialog, QDialogButtonBox, QFileDialog, + QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, + QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, + QTabWidget, QVBoxLayout, QWidget, +) + +from core.ui_translator import NO_TRANSLATE +from modules.privacy.privacy_monitor import BUILTIN_PATTERNS +from modules.privacy.term_io import read_terms, write_terms +from translator import t + +TERM_FILE_FILTER = "Textdateien (*.txt);;CSV-Dateien (*.csv);;Excel-Dateien (*.xlsx);;Alle Dateien (*)" + +SEVERITY_LABELS = {"high": "hoch", "medium": "mittel", "low": "niedrig"} + +STATUS_LABELS = { + "green": "🟢 Grün – keine sensiblen Daten erkannt", + "yellow": "🟡 Gelb – potenziell sensible Daten erkannt", + "red": "🔴 Rot – sensible Daten erkannt", + "gray": "⚪ Grau – Überwachung inaktiv", +} + + +class TermListEditor(QWidget): + """Bearbeitbare Begriffsliste mit Filter, Import und Export.""" + + changed = Signal() + + def __init__(self, list_name: str, hint: str, parent=None): + super().__init__(parent) + self._list_name = list_name + layout = QVBoxLayout(self) + + hint_label = QLabel(hint) + hint_label.setWordWrap(True) + layout.addWidget(hint_label) + + add_row = QHBoxLayout() + self.term_edit = QLineEdit() + self.term_edit.setPlaceholderText("Neuen Begriff eingeben und Enter drücken") + self.term_edit.setAccessibleName(t("Neuer Begriff für: {list}").format(list=list_name)) + self.term_edit.setToolTip("Mehrere Begriffe können durch Zeilenumbruch oder Semikolon getrennt werden.") + self.term_edit.returnPressed.connect(self.add_from_input) + add_row.addWidget(self.term_edit, 1) + self.add_btn = QPushButton("Hinzufügen") + self.add_btn.setAccessibleName(t("Begriff hinzufügen zu: {list}").format(list=list_name)) + self.add_btn.clicked.connect(self.add_from_input) + add_row.addWidget(self.add_btn) + layout.addLayout(add_row) + + self.filter_edit = QLineEdit() + self.filter_edit.setPlaceholderText("Liste filtern...") + self.filter_edit.setClearButtonEnabled(True) + self.filter_edit.setAccessibleName(t("Liste filtern: {list}").format(list=list_name)) + self.filter_edit.textChanged.connect(self._apply_filter) + layout.addWidget(self.filter_edit) + + self.list_widget = QListWidget() + self.list_widget.setProperty(NO_TRANSLATE, True) # Einträge sind Nutzerdaten + self.list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.list_widget.setSortingEnabled(True) + self.list_widget.setAccessibleName(t("Einträge: {list}").format(list=list_name)) + self.list_widget.setToolTip("Doppelklick bearbeitet einen Eintrag, Entf entfernt die Auswahl.") + self.list_widget.itemChanged.connect(self._on_item_changed) + self.list_widget.itemSelectionChanged.connect(self._update_buttons) + layout.addWidget(self.list_widget, 1) + QShortcut(QKeySequence.StandardKey.Delete, self.list_widget, activated=self.remove_selected) + + buttons = QHBoxLayout() + self.remove_btn = QPushButton("Entfernen") + self.remove_btn.setAccessibleName(t("Ausgewählte Einträge entfernen: {list}").format(list=list_name)) + self.remove_btn.clicked.connect(self.remove_selected) + buttons.addWidget(self.remove_btn) + + self.import_btn = QPushButton("Importieren...") + self.import_btn.setAccessibleName(t("Aus Datei importieren: {list}").format(list=list_name)) + self.import_btn.setToolTip("Begriffe aus TXT (eine Zeile pro Begriff), CSV oder Excel übernehmen") + self.import_btn.clicked.connect(self.import_terms) + buttons.addWidget(self.import_btn) + + self.export_btn = QPushButton("Exportieren...") + self.export_btn.setAccessibleName(t("In Datei exportieren: {list}").format(list=list_name)) + self.export_btn.clicked.connect(self.export_terms) + buttons.addWidget(self.export_btn) + + buttons.addStretch() + + self.clear_btn = QPushButton("Alle löschen") + self.clear_btn.setAccessibleName(t("Liste vollständig leeren: {list}").format(list=list_name)) + self.clear_btn.clicked.connect(self.clear_terms) + buttons.addWidget(self.clear_btn) + layout.addLayout(buttons) + + self.count_label = QLabel() + layout.addWidget(self.count_label) + self._update_buttons() + + # ----- Daten ----- + + def terms(self) -> list: + return [self.list_widget.item(i).text().strip() for i in range(self.list_widget.count()) + if self.list_widget.item(i).text().strip()] + + def set_terms(self, terms) -> None: + self.list_widget.blockSignals(True) + try: + self.list_widget.clear() + for term in sorted({str(x).strip() for x in terms if str(x).strip()}, key=str.lower): + self._append_item(term) + finally: + self.list_widget.blockSignals(False) + self._update_buttons() + + def add_terms(self, terms) -> int: + existing = {term.lower() for term in self.terms()} + added = 0 + self.list_widget.blockSignals(True) + try: + for term in terms: + term = str(term).strip() + if term and term.lower() not in existing: + self._append_item(term) + existing.add(term.lower()) + added += 1 + finally: + self.list_widget.blockSignals(False) + if added: + self._apply_filter(self.filter_edit.text()) + self.changed.emit() + self._update_buttons() + return added + + def _append_item(self, term: str) -> None: + item = QListWidgetItem(term) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + self.list_widget.addItem(item) + + # ----- Aktionen ----- + + def add_from_input(self) -> None: + raw = self.term_edit.text() + parts = [part.strip() for chunk in raw.splitlines() for part in chunk.split(";")] + if self.add_terms(part for part in parts if part): + self.term_edit.clear() + self.term_edit.setFocus() + + def remove_selected(self) -> None: + rows = sorted({self.list_widget.row(item) for item in self.list_widget.selectedItems()}, reverse=True) + if not rows: + return + for row in rows: + self.list_widget.takeItem(row) + self.changed.emit() + self._update_buttons() + + def clear_terms(self) -> None: + if self.list_widget.count() == 0: + return + reply = QMessageBox.question( + self, + t("Liste leeren"), + t("Sollen wirklich alle {count} Einträge entfernt werden?").format( + count=self.list_widget.count() + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self.list_widget.clear() + self.changed.emit() + self._update_buttons() + + def import_terms(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, t("Begriffe importieren"), "", t(TERM_FILE_FILTER)) + if not path: + return + try: + terms = read_terms(path) + except Exception as exc: + QMessageBox.warning( + self, t("Import fehlgeschlagen"), + t("Die Datei konnte nicht gelesen werden:") + f"\n{path}\n\n{exc}", + ) + return + added = self.add_terms(terms) + QMessageBox.information( + self, t("Import abgeschlossen"), + t("{added} neue Begriffe übernommen ({total} in der Datei).").format( + added=added, total=len(terms) + ), + ) + + def export_terms(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, t("Begriffe exportieren"), f"{self._list_name.lower()}.txt", t(TERM_FILE_FILTER) + ) + if not path: + return + try: + write_terms(path, self.terms()) + except Exception as exc: + QMessageBox.warning( + self, t("Export fehlgeschlagen"), + t("Die Datei konnte nicht geschrieben werden:") + f"\n{path}\n\n{exc}", + ) + + # ----- Anzeige ----- + + def _on_item_changed(self, item: QListWidgetItem) -> None: + if not item.text().strip(): + self.list_widget.takeItem(self.list_widget.row(item)) + self.changed.emit() + self._update_buttons() + + def _apply_filter(self, text: str) -> None: + needle = text.strip().lower() + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + item.setHidden(bool(needle) and needle not in item.text().lower()) + + def _update_buttons(self) -> None: + count = self.list_widget.count() + self.remove_btn.setEnabled(bool(self.list_widget.selectedItems())) + self.clear_btn.setEnabled(count > 0) + self.export_btn.setEnabled(count > 0) + self.count_label.setText(t("{count} Einträge").format(count=count)) + + +class PrivacySettingsDialog(QDialog): + """Dialog für Datenschutz-Einstellungen inkl. Black- und Whitelist.""" + + def __init__(self, privacy_monitor, parent=None, settings=None): + super().__init__(parent) + self.privacy_monitor = privacy_monitor + if settings is None: + from core.settings_manager import SettingsManager + + settings = SettingsManager.instance() + self.settings = settings + self.setWindowTitle("Datenschutz-Einstellungen") + self.setMinimumSize(560, 560) + self.setAccessibleName("Datenschutz-Einstellungen") + self.setAccessibleDescription( + "Konfiguration von Erkennungsmustern, Blacklist, Whitelist und automatischer Bereinigung." + ) + self._setup_ui() + self._load_settings() + + # ===== Aufbau ===== + + def _setup_ui(self): + layout = QVBoxLayout(self) + + self.tabs = QTabWidget() + self.tabs.setAccessibleName("Datenschutz-Bereiche") + self.tabs.addTab(self._build_detection_tab(), "Erkennung") + + self.blacklist_editor = TermListEditor( + "Blacklist", + "Begriffe der Blacklist gelten immer als sensibel (z. B. Namen, Projekt- oder " + "Kundennummern). Sie färben die Ampel, sobald sie in der Zwischenablage auftauchen.", + ) + self.blacklist_editor.changed.connect(self._update_stats) + self.tabs.addTab(self.blacklist_editor, "Blacklist") + + self.whitelist_editor = TermListEditor( + "Whitelist", + "Begriffe der Whitelist werden nie als sensibel gemeldet – auch wenn ein Muster " + "passt (z. B. die eigene Firmen-E-Mail-Adresse).", + ) + self.whitelist_editor.changed.connect(self._update_stats) + self.tabs.addTab(self.whitelist_editor, "Whitelist") + + self.tabs.addTab(self._build_test_tab(), "Testen") + layout.addWidget(self.tabs, 1) + + # Statistik + stats_group = QGroupBox("Statistik") + stats_layout = QFormLayout(stats_group) + self.blacklist_count_label = QLabel("0") + self.whitelist_count_label = QLabel("0") + self.active_patterns_label = QLabel("0") + stats_layout.addRow("Blacklist-Einträge:", self.blacklist_count_label) + stats_layout.addRow("Whitelist-Einträge:", self.whitelist_count_label) + stats_layout.addRow("Aktive Patterns:", self.active_patterns_label) + layout.addWidget(stats_group) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Apply | + QDialogButtonBox.StandardButton.Cancel + ) + ok_btn = buttons.button(QDialogButtonBox.StandardButton.Ok) + ok_btn.setText("Speichern") + ok_btn.setAccessibleName("Einstellungen speichern") + ok_btn.setToolTip(t("Speichert die Datenschutz-Einstellungen (Enter)")) + apply_btn = buttons.button(QDialogButtonBox.StandardButton.Apply) + apply_btn.setText("Übernehmen") + apply_btn.setAccessibleName("Einstellungen übernehmen") + apply_btn.setToolTip("Änderungen anwenden, ohne den Dialog zu schließen") + apply_btn.clicked.connect(self.apply_settings) + cancel_btn = buttons.button(QDialogButtonBox.StandardButton.Cancel) + cancel_btn.setText("Abbrechen") + cancel_btn.setAccessibleName("Abbrechen") + cancel_btn.setToolTip(t("Verwirft Änderungen (Esc)")) + buttons.accepted.connect(self._save_and_close) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _build_detection_tab(self) -> QWidget: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + page = QWidget() + layout = QVBoxLayout(page) + + # Überwachung + monitor_group = QGroupBox("Datenschutz-Ampel") + monitor_layout = QVBoxLayout(monitor_group) + self.monitor_enabled_cb = QCheckBox("Zwischenablage überwachen") + self.monitor_enabled_cb.setAccessibleName("Zwischenablage überwachen") + self.monitor_enabled_cb.setToolTip(t("Zwischenablage kontinuierlich auf schutzwürdige Muster überwachen")) + monitor_layout.addWidget(self.monitor_enabled_cb) + + self.notifications_cb = QCheckBox("Warnhinweis bei roter Ampel als Dialog anzeigen") + self.notifications_cb.setAccessibleName("Warnhinweis bei roter Ampel anzeigen") + self.notifications_cb.setToolTip("Benachrichtigung bei erkannten Datenschutz-Mustern einblenden") + monitor_layout.addWidget(self.notifications_cb) + + status_row = QHBoxLayout() + self.status_label = QLabel() + self.status_label.setAccessibleName("Aktueller Ampelstatus") + status_row.addWidget(self.status_label, 1) + self.reset_btn = QPushButton("Ampel zurücksetzen") + self.reset_btn.setAccessibleName("Ampel zurücksetzen") + self.reset_btn.setToolTip("Setzt eine gelbe oder rote Ampel wieder auf Grün") + self.reset_btn.clicked.connect(self._reset_status) + status_row.addWidget(self.reset_btn) + monitor_layout.addLayout(status_row) + layout.addWidget(monitor_group) + + # Pattern-Gruppe + pattern_group = QGroupBox("Erkennungsmuster") + pattern_layout = QVBoxLayout(pattern_group) + + self.pattern_checks = {} + for key, info in BUILTIN_PATTERNS.items(): + name = t(info['name']) + description = t(info['description']) + cb = QCheckBox(f"{name} – {description}") + cb.setAccessibleName(f"{name} " + t("Erkennungsmuster")) + cb.setAccessibleDescription(description) + cb.setToolTip(t("Schweregrad: {severity}").format( + severity=t(SEVERITY_LABELS.get(info['severity'], info['severity'])) + )) + cb.toggled.connect(self._update_stats) + self.pattern_checks[key] = cb + pattern_layout.addWidget(cb) + + layout.addWidget(pattern_group) + + # Optionen + options_group = QGroupBox("Optionen") + options_layout = QVBoxLayout(options_group) + + self.case_sensitive_cb = QCheckBox("Groß-/Kleinschreibung beachten") + self.case_sensitive_cb.setAccessibleName("Groß- und Kleinschreibung beachten") + self.case_sensitive_cb.setToolTip(t("Groß-/Kleinschreibung bei der Mustersuche berücksichtigen")) + options_layout.addWidget(self.case_sensitive_cb) + + self.whole_words_cb = QCheckBox("Nur ganze Wörter") + self.whole_words_cb.setAccessibleName("Nur ganze Wörter") + self.whole_words_cb.setToolTip(t("Nur eigenständige Wörter als Treffer werten")) + options_layout.addWidget(self.whole_words_cb) + + self.auto_clear_cb = QCheckBox("Clipboard bei ROT automatisch leeren") + self.auto_clear_cb.setAccessibleName("Clipboard bei Alarm automatisch leeren") + self.auto_clear_cb.setToolTip("Zwischenablage automatisch leeren, wenn sensible Daten erkannt werden") + options_layout.addWidget(self.auto_clear_cb) + + layout.addWidget(options_group) + layout.addStretch() + scroll.setWidget(page) + return scroll + + def _build_test_tab(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + hint = QLabel( + "Text einfügen und prüfen, wie die Ampel mit den aktuellen (auch noch nicht " + "gespeicherten) Einstellungen reagieren würde." + ) + hint.setWordWrap(True) + layout.addWidget(hint) + self.test_input = QPlainTextEdit() + self.test_input.setPlaceholderText("Beispieltext zum Prüfen...") + self.test_input.setAccessibleName("Testtext für die Datenschutz-Prüfung") + layout.addWidget(self.test_input, 1) + self.test_btn = QPushButton("Prüfen") + self.test_btn.setAccessibleName("Testtext prüfen") + self.test_btn.clicked.connect(self.run_test) + layout.addWidget(self.test_btn) + self.test_result = QPlainTextEdit() + self.test_result.setReadOnly(True) + self.test_result.setAccessibleName("Prüfergebnis") + self.test_result.setMaximumHeight(140) + layout.addWidget(self.test_result) + return page + + # ===== Laden/Speichern ===== + + def _load_settings(self): + """Lädt aktuelle Einstellungen""" + monitor = self.privacy_monitor + for key, cb in self.pattern_checks.items(): + cb.setChecked(bool(monitor.pattern_enabled.get(key, False))) + + self.case_sensitive_cb.setChecked(bool(monitor.case_sensitive)) + self.whole_words_cb.setChecked(bool(monitor.whole_words)) + self.auto_clear_cb.setChecked(bool(monitor._auto_clear)) + self.monitor_enabled_cb.setChecked(bool(monitor.enabled)) + self.notifications_cb.setChecked( + bool(self.settings.get("privacy", "show_notifications", True)) + ) + self.blacklist_editor.set_terms(monitor.blacklist) + self.whitelist_editor.set_terms(monitor.whitelist) + self._update_status_label() + self._update_stats() + + def _patterns(self) -> dict: + return {key: cb.isChecked() for key, cb in self.pattern_checks.items()} + + def apply_settings(self): + """Überträgt alle Werte auf den Monitor und speichert sie dauerhaft.""" + monitor = self.privacy_monitor + for key, enabled in self._patterns().items(): + monitor.pattern_enabled[key] = enabled + monitor.case_sensitive = self.case_sensitive_cb.isChecked() + monitor.whole_words = self.whole_words_cb.isChecked() + monitor._auto_clear = self.auto_clear_cb.isChecked() + monitor.set_lists(self.blacklist_editor.terms(), self.whitelist_editor.terms()) + + enabled = self.monitor_enabled_cb.isChecked() + if enabled != monitor.enabled: + monitor.enabled = enabled + else: + monitor.recheck_clipboard() + + self.settings.set("privacy", "enable_clipboard_monitor", enabled) + self.settings.set("privacy", "show_notifications", self.notifications_cb.isChecked()) + self.settings.set("privacy", "auto_block_sensitive", self.auto_clear_cb.isChecked()) + self.settings.save() + self._update_status_label() + + def _save_and_close(self): + """Speichert und schließt""" + self.apply_settings() + self.accept() + + # ===== Hilfen ===== + + def _reset_status(self): + self.privacy_monitor.reset_status() + self._update_status_label() + + def _update_status_label(self): + status = self.privacy_monitor.status + self.status_label.setText(t("Status: {status}").format(status=t(STATUS_LABELS.get(status, status)))) + + def _update_stats(self, *_): + self.blacklist_count_label.setText(str(len(self.blacklist_editor.terms()))) + self.whitelist_count_label.setText(str(len(self.whitelist_editor.terms()))) + self.active_patterns_label.setText(str(sum(1 for v in self._patterns().values() if v))) + + def run_test(self): + text = self.test_input.toPlainText() + if not text.strip(): + self.test_result.setPlainText(t("Bitte zuerst einen Text eingeben.")) + return + alert = self.privacy_monitor.preview_check( + text, + blacklist=self.blacklist_editor.terms(), + whitelist=self.whitelist_editor.terms(), + patterns=self._patterns(), + case_sensitive=self.case_sensitive_cb.isChecked(), + whole_words=self.whole_words_cb.isChecked(), + ) + lines = [t("Ergebnis: {status}").format(status=t(STATUS_LABELS.get(alert.status.value, alert.status.value)))] + if alert.detected_patterns: + lines.append(t("Erkannt:")) + lines.extend(f" • {item}" for item in alert.detected_patterns) + lines.append("") + lines.append(t("Anonymisiert:")) + lines.append(alert.anonymized_text) + self.test_result.setPlainText("\n".join(lines)) diff --git a/src/gui/properties_dialog.py b/src/gui/properties_dialog.py index 547e35b..3eaf986 100644 --- a/src/gui/properties_dialog.py +++ b/src/gui/properties_dialog.py @@ -186,7 +186,7 @@ def _load_properties(self) -> None: try: st = os.stat(self.target_path) except OSError as exc: - self.type_subtitle.setText(f"{t('Fehler beim Lesen')}: {exc}") + self.type_subtitle.setText(t("Fehler beim Lesen: {error}").format(error=exc)) return # Dateityp ermitteln @@ -194,7 +194,7 @@ def _load_properties(self) -> None: if self.is_dir: type_str = t("Dateiordner") elif ext: - type_str = f"{ext[1:].upper()}-{t('Datei')} ({ext})" + type_str = t("{type}-Datei ({extension})").format(type=ext[1:].upper(), extension=ext) else: type_str = t("Datei") self.type_subtitle.setText(type_str) @@ -209,7 +209,9 @@ def _load_properties(self) -> None: if self.is_dir: file_count, dir_count, total_bytes = calculate_folder_stats(self.target_path) size_str = format_size(total_bytes) - contains_str = f"{file_count:,} {t('Dateien')}, {dir_count:,} {t('Ordner')}".replace(",", ".") + contains_str = t("{files} Dateien, {folders} Ordner").format( + files=f"{file_count:,}", folders=f"{dir_count:,}" + ).replace(",", ".") self._add_form_row(t("Größe:"), size_str) self._add_form_row(t("Inhalt:"), contains_str) else: @@ -304,8 +306,8 @@ def _setup_file_checksums(self, file_size: int) -> None: self.sha256_edit.setText(hashes.get("sha256", "")) self.md5_edit.setText(hashes.get("md5", "")) except Exception as exc: - self.sha256_edit.setText(f"{t('Fehler')}: {exc}") - self.md5_edit.setText(f"{t('Fehler')}: {exc}") + self.sha256_edit.setText(t("Fehler: {error}").format(error=exc)) + self.md5_edit.setText(t("Fehler: {error}").format(error=exc)) else: self.sha256_edit.setPlaceholderText(t("Datei > 50 MB: Klick zum Berechnen")) self.md5_edit.setPlaceholderText(t("Datei > 50 MB: Klick zum Berechnen")) @@ -359,7 +361,7 @@ def _copy_full_path(self) -> None: QMessageBox.information( self, t("Pfad kopiert"), - f"{t('Vollständiger Pfad in die Zwischenablage kopiert:')}\n\n{self.target_path}" + t("Vollständiger Pfad in die Zwischenablage kopiert:\n\n{path}").format(path=self.target_path) ) def _copy_to_clip(self, text: str) -> None: @@ -371,4 +373,4 @@ def _open_location(self) -> None: try: open_path_with_system(target) except Exception as exc: - QMessageBox.warning(self, t("Dateimanager öffnen"), f"{t('Konnte Ordner nicht öffnen')}:\n{exc}") + QMessageBox.warning(self, t("Dateimanager öffnen"), t("Konnte Ordner nicht öffnen:\n{error}").format(error=exc)) diff --git a/src/gui/settings_dialog.py b/src/gui/settings_dialog.py index 65988fb..8298ff5 100644 --- a/src/gui/settings_dialog.py +++ b/src/gui/settings_dialog.py @@ -8,6 +8,7 @@ settings.json des Benutzerprofils. """ +from PySide6.QtCore import Signal from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QTabWidget, QWidget, QCheckBox, QComboBox, QSpinBox, QLineEdit, QPushButton, QLabel, @@ -21,15 +22,19 @@ class SettingsDialog(QDialog): """Dialog für die Anwendungseinstellungen.""" + # Bittet das Hauptfenster, den Black-/Whitelist-Editor zu öffnen. + privacy_lists_requested = Signal() + THEMES = [ ("system", "Systemvorgabe"), ("light", "Hell"), ("dark", "Dunkel"), ] - def __init__(self, parent=None, settings: SettingsManager = None): + def __init__(self, parent=None, settings: SettingsManager = None, privacy_monitor=None): super().__init__(parent) self.settings = settings or SettingsManager.instance() + self.privacy_monitor = privacy_monitor self.setWindowTitle("Einstellungen") self.setMinimumWidth(520) self.setAccessibleName("Einstellungen") @@ -94,7 +99,7 @@ def _build_general_tab(self) -> QWidget: self.language_cb.setAccessibleDescription("Auswahl der Benutzeroberflächensprache.") self.language_cb.setToolTip( t("Programmsprache für Menüs und Dialoge") + "\n" - + t("Sprachänderungen werden beim nächsten Start übernommen.") + + t("Sprachänderungen werden sofort übernommen; einzelne Texte erst nach einem Neustart.") ) for code, display in TranslationSystem.get_language_display_names().items(): self.language_cb.addItem(display, code) @@ -186,21 +191,30 @@ def _build_privacy_tab(self) -> QWidget: self.auto_block_cb = QCheckBox("Sensible Inhalte automatisch blockieren") self.auto_block_cb.setAccessibleName("Sensible Inhalte automatisch blockieren") - self.auto_block_cb.setToolTip(t("Kopieren hochsensibler Daten wie Passwörter und API-Keys blockieren")) + self.auto_block_cb.setToolTip( + t("Kopieren hochsensibler Daten wie Passwörter und API-Keys blockieren") + "\n" + + t("Leert die Zwischenablage, sobald die Ampel auf Rot springt.") + ) form.addRow(self.auto_block_cb) - self.notifications_cb = QCheckBox("Hinweise anzeigen") + self.notifications_cb = QCheckBox("Warnhinweis bei roter Ampel als Dialog anzeigen") self.notifications_cb.setAccessibleName("Hinweise anzeigen") self.notifications_cb.setToolTip("Benachrichtigung bei erkannten Datenschutz-Mustern einblenden") form.addRow(self.notifications_cb) note = QLabel( - "Erkennungsmuster werden unter Tools, Datenschutz-Einstellungen " - "gepflegt." + "Erkennungsmuster, Blacklist und Whitelist werden im Dialog " + "Datenschutz-Einstellungen gepflegt (auch per Klick auf die Ampel)." ) note.setWordWrap(True) form.addRow(note) + self.privacy_lists_btn = QPushButton("Blacklist/Whitelist bearbeiten...") + self.privacy_lists_btn.setAccessibleName("Blacklist und Whitelist bearbeiten") + self.privacy_lists_btn.setToolTip("Öffnet die Datenschutz-Einstellungen mit Black- und Whitelist") + self.privacy_lists_btn.clicked.connect(self.privacy_lists_requested.emit) + form.addRow(self.privacy_lists_btn) + return page def _build_appearance_tab(self) -> QWidget: @@ -215,7 +229,10 @@ def _build_appearance_tab(self) -> QWidget: form.addRow("Farbschema:", self.theme_combo) self.font_size_spin = QSpinBox() - self.font_size_spin.setRange(6, 32) + self.font_size_spin.setRange(5, 32) + # Der Minimalwert steht für "Systemstandard" und wird als 0 gespeichert. + self.font_size_spin.setSpecialValueText(t("Systemstandard")) + self.font_size_spin.setSuffix(" pt") self.font_size_spin.setAccessibleName("Schriftgröße") self.font_size_spin.setToolTip(t("Basisschriftgröße in Punkten")) form.addRow("Schriftgröße:", self.font_size_spin) @@ -252,25 +269,38 @@ def _load_settings(self): self.auto_index_cb.setChecked(bool(get("index", "auto_index", True))) self.index_startup_cb.setChecked(bool(get("index", "index_on_startup", False))) - self.max_file_size_spin.setValue(int(get("index", "max_file_size_mb", 100))) + self.max_file_size_spin.setValue(self._int(get("index", "max_file_size_mb", 100), 100)) self.show_preview_cb.setChecked(bool(get("preview", "show_preview", True))) self.preview_images_cb.setChecked(bool(get("preview", "preview_images", True))) self.preview_pdfs_cb.setChecked(bool(get("preview", "preview_pdfs", True))) self.preview_code_cb.setChecked(bool(get("preview", "preview_code", True))) - self.max_preview_spin.setValue(int(get("preview", "max_preview_size_mb", 10))) + self.max_preview_spin.setValue(self._int(get("preview", "max_preview_size_mb", 50), 50)) self.clipboard_monitor_cb.setChecked( bool(get("privacy", "enable_clipboard_monitor", True)) ) - self.auto_block_cb.setChecked(bool(get("privacy", "auto_block_sensitive", True))) + if self.privacy_monitor is not None: + # privacy_config.json ist maßgeblich für das automatische Leeren. + self.auto_block_cb.setChecked(bool(self.privacy_monitor.auto_clear)) + self.clipboard_monitor_cb.setChecked(bool(self.privacy_monitor.enabled)) + else: + self.auto_block_cb.setChecked(bool(get("privacy", "auto_block_sensitive", False))) self.notifications_cb.setChecked(bool(get("privacy", "show_notifications", True))) theme = get("appearance", "theme", "system") index = self.theme_combo.findData(theme) self.theme_combo.setCurrentIndex(index if index >= 0 else 0) - self.font_size_spin.setValue(int(get("appearance", "font_size", 10))) - self.icon_size_spin.setValue(int(get("appearance", "icon_size", 24))) + font_size = self._int(get("appearance", "font_size", 0), 0) + self.font_size_spin.setValue(font_size if 6 <= font_size <= 32 else self.font_size_spin.minimum()) + self.icon_size_spin.setValue(self._int(get("appearance", "icon_size", 16), 16)) + + @staticmethod + def _int(value, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default def collect_settings(self) -> dict: """Liest die Bedienelemente aus und gibt die Werte als Dict zurück.""" @@ -301,7 +331,10 @@ def collect_settings(self) -> dict: }, "appearance": { "theme": self.theme_combo.currentData(), - "font_size": self.font_size_spin.value(), + "font_size": ( + 0 if self.font_size_spin.value() == self.font_size_spin.minimum() + else self.font_size_spin.value() + ), "icon_size": self.icon_size_spin.value(), }, } diff --git a/src/gui/sidebar/advanced_search_dialog.py b/src/gui/sidebar/advanced_search_dialog.py index 3eb20b9..654d5d2 100644 --- a/src/gui/sidebar/advanced_search_dialog.py +++ b/src/gui/sidebar/advanced_search_dialog.py @@ -488,14 +488,16 @@ def _on_results_ready(self, results: list): self.results_table.setItem(row, 4, QTableWidgetItem(date_str)) count = len(results) - self.status_label.setText(f"✅ {count} Ergebnis{'se' if count != 1 else ''} gefunden") + self.status_label.setText( + "✅ " + (t("{count} Ergebnis gefunden") if count == 1 else t("{count} Ergebnisse gefunden")).format(count=count) + ) @Slot(str) def _on_search_error(self, error: str): """Fehler bei Suche""" self.search_btn.setEnabled(True) self.progress_bar.hide() - self.status_label.setText(f"❌ Fehler: {error}") + self.status_label.setText("❌ " + t("Fehler: {error}").format(error=error)) def _format_size(self, size: int) -> str: """Formatiert Dateigröße""" diff --git a/src/gui/sidebar/search_panel.py b/src/gui/sidebar/search_panel.py index 81dd633..bce4a95 100644 --- a/src/gui/sidebar/search_panel.py +++ b/src/gui/sidebar/search_panel.py @@ -15,6 +15,7 @@ from pathlib import Path from dataclasses import dataclass from datetime import datetime +from translator import t @dataclass @@ -53,10 +54,11 @@ def __init__(self, result: SearchResult): self.setText(f"{icon} {result.name}") # Tooltip mit Details + modified_txt = result.modified.strftime('%d.%m.%Y %H:%M') if result.modified else t('Unbekannt') tooltip = f"""{result.name}
    📁 {result.path}
    📏 {self._format_size(result.size)}
    - 📅 {result.modified.strftime('%d.%m.%Y %H:%M') if result.modified else 'Unbekannt'}""" + 📅 {modified_txt}""" if result.snippet: tooltip += f"

    ...{result.snippet[:200]}..." @@ -298,7 +300,7 @@ def _execute_search(self): self.search_started.emit() self.progress_bar.setRange(0, 0) # Indeterminate self.progress_bar.show() - self.status_label.setText(f"Suche nach '{query}'...") + self.status_label.setText(t("Suche nach '{query}'...").format(query=query)) self.results_list.clear() # Suche starten @@ -330,7 +332,9 @@ def _on_results_ready(self, results: list): self.results_list.addItem(item) count = len(results) - self.status_label.setText(f"✅ {count} Ergebnis{'se' if count != 1 else ''}") + self.status_label.setText( + "✅ " + (t("{count} Ergebnis") if count == 1 else t("{count} Ergebnisse")).format(count=count) + ) self.clear_btn.setVisible(count > 0) self.search_finished.emit(count) @@ -338,7 +342,7 @@ def _on_results_ready(self, results: list): def _on_search_error(self, error: str): """Fehler bei Suche""" self.progress_bar.hide() - self.status_label.setText(f"❌ Fehler: {error}") + self.status_label.setText("❌ " + t("Fehler: {error}").format(error=error)) def _on_item_clicked(self, item: QListWidgetItem): """Item angeklickt""" diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py index a914721..658c068 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -16,6 +16,7 @@ import os # Module importieren - absolute Imports +from core.cloud_locations import find_cloud_locations from core.file_icon_helper import get_file_icon from gui.sidebar.search_panel import SearchPanel as AdvancedSearchPanel from modules.launcher import AppsPanel @@ -67,32 +68,53 @@ def _setup_ui(self): layout.addWidget(self.refresh_drives_button) def _populate(self): - """Füllt den Baum mit Laufwerken und Schnellzugriff""" + """Füllt den Baum mit Schnellzugriff, Cloud-Speichern und Laufwerken""" # Schnellzugriff - quick_access = QTreeWidgetItem(["⭐ Schnellzugriff"]) + quick_access = QTreeWidgetItem(["⭐ " + t("Schnellzugriff")]) quick_access.setFlags(quick_access.flags() & ~Qt.ItemFlag.ItemIsSelectable) locations = [ - ("Desktop", QStandardPaths.StandardLocation.DesktopLocation), - ("Dokumente", QStandardPaths.StandardLocation.DocumentsLocation), - ("Downloads", QStandardPaths.StandardLocation.DownloadLocation), - ("Bilder", QStandardPaths.StandardLocation.PicturesLocation), - ("Musik", QStandardPaths.StandardLocation.MusicLocation), + (t("Persönlicher Ordner"), QStandardPaths.StandardLocation.HomeLocation), + (t("Desktop"), QStandardPaths.StandardLocation.DesktopLocation), + (t("Dokumente"), QStandardPaths.StandardLocation.DocumentsLocation), + (t("Downloads"), QStandardPaths.StandardLocation.DownloadLocation), + (t("Bilder"), QStandardPaths.StandardLocation.PicturesLocation), + (t("Musik"), QStandardPaths.StandardLocation.MusicLocation), + (t("Videos"), QStandardPaths.StandardLocation.MoviesLocation), ] + seen = set() for name, location in locations: path = QStandardPaths.writableLocation(location) - if path and os.path.exists(path): - child = QTreeWidgetItem([name]) - child.setData(0, Qt.ItemDataRole.UserRole, path) - child.setIcon(0, get_file_icon(path)) - quick_access.addChild(child) + if not path or not os.path.isdir(path): + continue + key = os.path.normcase(os.path.normpath(path)) + if key in seen: + continue + seen.add(key) + quick_access.addChild(self._folder_item(name, path)) self.tree.addTopLevelItem(quick_access) quick_access.setExpanded(True) + # Cloud-Speicher (OneDrive, Dropbox, Google Drive, iCloud, ...) + self.cloud_item = None + try: + cloud_locations = find_cloud_locations() + except Exception: + cloud_locations = [] + if cloud_locations: + self.cloud_item = QTreeWidgetItem(["☁️ " + t("Cloud-Speicher")]) + self.cloud_item.setFlags(self.cloud_item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + for location in cloud_locations: + child = self._folder_item(location.label, location.path) + child.setToolTip(0, location.path) + self.cloud_item.addChild(child) + self.tree.addTopLevelItem(self.cloud_item) + self.cloud_item.setExpanded(True) + # Laufwerke - drives_item = QTreeWidgetItem(["💾 Laufwerke"]) + drives_item = QTreeWidgetItem(["💾 " + t("Laufwerke")]) drives_item.setFlags(drives_item.flags() & ~Qt.ItemFlag.ItemIsSelectable) self.tree.addTopLevelItem(drives_item) @@ -170,6 +192,16 @@ def _on_item_clicked(self, item: QTreeWidgetItem, column: int): if path: self.folder_selected.emit(path) + @staticmethod + def _folder_item(name: str, path: str) -> QTreeWidgetItem: + """Erzeugt einen aufklappbaren Ordner-Eintrag (Unterordner werden lazy geladen).""" + item = QTreeWidgetItem([name]) + item.setData(0, Qt.ItemDataRole.UserRole, path) + item.setIcon(0, get_file_icon(path)) + item.setToolTip(0, path) + item.setChildIndicatorPolicy(QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator) + return item + def _on_item_expanded(self, item: QTreeWidgetItem): """Lazy Loading für Unterordner""" path = item.data(0, Qt.ItemDataRole.UserRole) @@ -182,16 +214,27 @@ def _on_item_expanded(self, item: QTreeWidgetItem): item.takeChildren() try: - for name in os.listdir(path): - full_path = os.path.join(path, name) - if os.path.isdir(full_path) and not name.startswith('.'): - child = QTreeWidgetItem([name]) - child.setData(0, Qt.ItemDataRole.UserRole, full_path) - child.setIcon(0, get_file_icon(full_path)) - child.setChildIndicatorPolicy(QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator) - item.addChild(child) + names = sorted(os.listdir(path), key=str.lower) except OSError: - pass + names = [] + + for name in names: + if name.startswith('.'): + continue + full_path = os.path.join(path, name) + try: + # Cloud-Platzhalter (OneDrive "Nur online") sind Reparse-Points; + # isdir() liest nur Metadaten und lädt nichts herunter. + if not os.path.isdir(full_path): + continue + except OSError: + continue + item.addChild(self._folder_item(name, full_path)) + + if item.childCount() == 0: + item.setChildIndicatorPolicy( + QTreeWidgetItem.ChildIndicatorPolicy.DontShowIndicatorWhenChildless + ) class FavoritesPanel(QWidget): diff --git a/src/gui/status_bar.py b/src/gui/status_bar.py index 78e9737..22f1562 100644 --- a/src/gui/status_bar.py +++ b/src/gui/status_bar.py @@ -5,10 +5,13 @@ """ from PySide6.QtWidgets import ( - QStatusBar, QLabel, QFrame + QStatusBar, QLabel, QFrame, QMenu ) from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QColor +from PySide6.QtGui import QColor, QCursor + +from core.ui_translator import NO_TRANSLATE +from translator import t class PrivacyIndicator(QLabel): @@ -18,6 +21,7 @@ class PrivacyIndicator(QLabel): """ clicked = Signal() + context_menu_requested = Signal() # Status-Farben COLORS = { @@ -69,9 +73,10 @@ def set_status(self, status: str): }} """) - self.setToolTip(self.TOOLTIPS[status]) - self.setAccessibleName("Datenschutz-Status") - self.setAccessibleDescription(self.DESCRIPTIONS.get(status, self.TOOLTIPS[status])) + hint = t("Klicken: Datenschutz-Einstellungen · Rechtsklick: Schnellmenü") + self.setToolTip(t(self.TOOLTIPS[status]) + "\n\n" + hint) + self.setAccessibleName(t("Datenschutz-Status")) + self.setAccessibleDescription(t(self.DESCRIPTIONS.get(status, self.TOOLTIPS[status]))) def _darken(self, hex_color: str) -> str: """Dunkelt eine Farbe ab""" @@ -80,13 +85,23 @@ def _darken(self, hex_color: str) -> str: return color.name() def mousePressEvent(self, event): - self.clicked.emit() + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + event.accept() + return super().mousePressEvent(event) + def contextMenuEvent(self, event): + self.context_menu_requested.emit() + event.accept() + def keyPressEvent(self, event): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Space): self.clicked.emit() event.accept() + elif event.key() == Qt.Key.Key_Menu: + self.context_menu_requested.emit() + event.accept() else: super().keyPressEvent(event) @@ -104,13 +119,19 @@ class StatusBarWidget(QStatusBar): - Sync-Status """ + # Aktionen der Datenschutz-Ampel (vom Hauptfenster verbunden) + privacy_settings_requested = Signal() + privacy_reset_requested = Signal() + privacy_monitoring_toggled = Signal(bool) + def __init__(self, parent=None): super().__init__(parent) self._setup_ui() def _setup_ui(self): # Linker Bereich: Pfad und Datei-Count - self.path_label = QLabel("Bereit") + self.path_label = QLabel(t("Bereit")) + self.path_label.setProperty(NO_TRANSLATE, True) # zeigt Pfade (Nutzerdaten) self.path_label.setAccessibleName("Aktueller Ordnerpfad") self.path_label.setAccessibleDescription("Zeigt das aktuelle Arbeitsverzeichnis oder den Systemstatus an.") self.path_label.setToolTip("Aktueller Pfad / Status") @@ -153,6 +174,7 @@ def _setup_ui(self): # Datenschutz-Ampel self.privacy_indicator = PrivacyIndicator() self.privacy_indicator.clicked.connect(self._on_privacy_clicked) + self.privacy_indicator.context_menu_requested.connect(self.show_privacy_menu) self.addPermanentWidget(self.privacy_indicator) def update_path(self, path: str): @@ -161,15 +183,15 @@ def update_path(self, path: str): if len(path) > 60: path = "..." + path[-57:] self.path_label.setText(f"📁 {path}") - self.path_label.setToolTip(f"Aktueller Pfad: {full_path}") - self.path_label.setAccessibleDescription(f"Aktueller Ordnerpfad: {full_path}") + self.path_label.setToolTip(t("Aktueller Pfad: {path}").format(path=full_path)) + self.path_label.setAccessibleDescription(t("Aktueller Ordnerpfad: {path}").format(path=full_path)) def update_file_count(self, count: int, selected: int = 0): """Aktualisiert die Datei-Anzahl""" if selected > 0: - text = f"{selected} von {count} ausgewählt" + text = t("{selected} von {count} ausgewählt").format(selected=selected, count=count) else: - text = f"{count} Elemente" + text = t("{count} Elemente").format(count=count) self.file_count_label.setText(text) self.file_count_label.setToolTip(text) self.file_count_label.setAccessibleDescription(text) @@ -183,8 +205,8 @@ def update_space(self, used_bytes: int): else: text = f"💾 {gb:.2f} GB" self.space_label.setText(text) - self.space_label.setToolTip(f"Belegter Speicher: {text}") - self.space_label.setAccessibleDescription(f"Speicherplatzanzeige: {text}") + self.space_label.setToolTip(t("Belegter Speicher: {size}").format(size=text)) + self.space_label.setAccessibleDescription(t("Speicherplatzanzeige: {size}").format(size=text)) def set_privacy_status(self, status: str): """Setzt den Datenschutz-Status""" @@ -194,15 +216,40 @@ def set_sync_status(self, syncing: bool): """Setzt den Sync-Status""" if syncing: self.sync_label.setText("🔄") - self.sync_label.setToolTip("Synchronisierung läuft...") - self.sync_label.setAccessibleDescription("Synchronisation wird derzeit im Hintergrund ausgeführt.") + self.sync_label.setToolTip(t("Synchronisierung läuft...")) + self.sync_label.setAccessibleDescription(t("Synchronisation wird derzeit im Hintergrund ausgeführt.")) else: self.sync_label.setText("✓") - self.sync_label.setToolTip("Synchronisierung: Aktuell") - self.sync_label.setAccessibleDescription("Synchronisation ist aktuell und auf dem neuesten Stand.") + self.sync_label.setToolTip(t("Synchronisierung: Aktuell")) + self.sync_label.setAccessibleDescription(t("Synchronisation ist aktuell und auf dem neuesten Stand.")) def _on_privacy_clicked(self): - """Handler für Klick auf Ampel""" + """Handler für Klick auf Ampel: öffnet die Datenschutz-Einstellungen.""" + self.privacy_settings_requested.emit() + + def build_privacy_menu(self) -> QMenu: + """Schnellmenü der Ampel (Rechtsklick oder Menütaste).""" + menu = QMenu(self) status = self.privacy_indicator.status - # TODO: Privacy-Details-Dialog öffnen - self.showMessage(f"Datenschutz-Status: {status.upper()}", 3000) + title = menu.addAction(t("Datenschutz-Status: {status}").format(status=t(self.STATUS_NAMES.get(status, status)))) + title.setEnabled(False) + menu.addSeparator() + + monitoring = menu.addAction(t("Zwischenablage überwachen")) + monitoring.setCheckable(True) + monitoring.setChecked(status != "gray") + monitoring.toggled.connect(self.privacy_monitoring_toggled.emit) + + reset = menu.addAction(t("Ampel zurücksetzen")) + reset.setEnabled(status in ("yellow", "red")) + reset.triggered.connect(self.privacy_reset_requested.emit) + + menu.addSeparator() + settings = menu.addAction("🛡️ " + t("Datenschutz-Einstellungen") + "...") + settings.triggered.connect(self.privacy_settings_requested.emit) + return menu + + def show_privacy_menu(self): + self.build_privacy_menu().exec(QCursor.pos()) + + STATUS_NAMES = {"green": "Grün", "yellow": "Gelb", "red": "Rot", "gray": "Inaktiv"} diff --git a/src/main.py b/src/main.py index 46c99aa..b9f4eca 100644 --- a/src/main.py +++ b/src/main.py @@ -31,6 +31,7 @@ from PySide6.QtGui import QIcon from app import ExplorerProApp +from core.crash_log import install_crash_logging from version import __version__ from core.gui_gc import install_gui_gc from gui.sidebar.drive_capacity import shutdown_capacity_executor @@ -59,15 +60,32 @@ def load_app_icon() -> QIcon: def install_qt_translations(app: QApplication, lang: str): """Lädt Qts eigene Übersetzung (qtbase_.qm), damit Standard-Buttons - wie Ja/Nein, Speichern/Verwerfen/Abbrechen in der UI-Sprache erscheinen.""" + wie Ja/Nein, Speichern/Verwerfen/Abbrechen in der UI-Sprache erscheinen. + + Ein zuvor installierter Qt-Übersetzer wird entfernt, damit ein + Sprachwechsel zur Laufzeit nicht die alte Sprache weiter bevorzugt. + """ + previous = getattr(app, "_ep_qt_translator", None) + if previous is not None: + app.removeTranslator(previous) + app._ep_qt_translator = None translator = QTranslator(app) path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) if translator.load(f"qtbase_{lang}", path): app.installTranslator(translator) + app._ep_qt_translator = translator return translator return None +def install_runtime_translation(app: QApplication, translator) -> None: + """Übersetzt alle Widgets zur Laufzeit und folgt Sprachwechseln live.""" + from core.ui_translator import install_ui_translator + + install_ui_translator(app) + translator.add_language_listener(lambda lang: install_qt_translations(app, lang)) + + def set_application_version(app: QApplication) -> None: app.setApplicationVersion(__version__) @@ -90,6 +108,7 @@ def main(): Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) + install_crash_logging() app = QApplication(sys.argv) collector = install_gui_gc(app) try: @@ -97,6 +116,7 @@ def main(): app.setOrganizationName("ExplorerPro") translator = configure_application_language() install_qt_translations(app, translator.get_language()) + install_runtime_translation(app, translator) set_application_version(app) icon = load_app_icon() if not icon.isNull(): @@ -116,6 +136,9 @@ def main(): explorer.show() exit_code = app.exec() finally: + from core.ui_translator import uninstall_ui_translator + + uninstall_ui_translator(app) try: shutdown_capacity_executor() finally: diff --git a/src/modules/editor/quick_editor.py b/src/modules/editor/quick_editor.py index 5a01f66..47cdc64 100644 --- a/src/modules/editor/quick_editor.py +++ b/src/modules/editor/quick_editor.py @@ -23,6 +23,8 @@ QPainter, QColor, QTextFormat, QShortcut ) +from translator import t + from .syntax_highlighter import get_lexer_for_extension @@ -57,7 +59,7 @@ def _validate_json(text: str) -> tuple: json.loads(text) return True, "✓ Gültiges JSON" except json.JSONDecodeError as e: - return False, f"✗ Ungültiges JSON: {e}" + return False, t("✗ Ungültiges JSON: {error}").format(error=e) def _validate_toml(text: str) -> tuple: @@ -73,7 +75,7 @@ def _validate_toml(text: str) -> tuple: tomllib.loads(text) return True, "✓ Gültiges TOML" except Exception as e: - return False, f"✗ Ungültiges TOML: {e}" + return False, t("✗ Ungültiges TOML: {error}").format(error=e) class LineNumberArea(QPlainTextEdit): @@ -429,7 +431,7 @@ def _load_file(self, filepath: str): self.editor.set_highlighter(path.suffix) self.file_label.setText(path.name) - self.setWindowTitle(f"Quick Editor - {path.name}") + self.setWindowTitle(t("Quick Editor - {name}").format(name=path.name)) self.editor.document().setModified(False) self._modified = False @@ -441,7 +443,7 @@ def _load_file(self, filepath: str): self.editor.setTextCursor(cursor) except Exception as e: - QMessageBox.critical(self, "Fehler", f"Fehler beim Laden:\n{e}") + QMessageBox.critical(self, "Fehler", t("Fehler beim Laden:\n{error}").format(error=e)) def _save_file(self): """Speichert die Datei""" @@ -462,13 +464,13 @@ def _save_file(self): self._modified = False self.modified_label.setText("") self.file_label.setText(Path(self.filepath).name) - self.setWindowTitle(f"Quick Editor - {Path(self.filepath).name}") + self.setWindowTitle(t("Quick Editor - {name}").format(name=Path(self.filepath).name)) self.file_saved.emit(self.filepath) - self._add_output(f"✓ Gespeichert: {self.filepath}\n", "#4EC9B0") + self._add_output(t("✓ Gespeichert: {path}").format(path=self.filepath) + "\n", "#4EC9B0") except Exception as e: - QMessageBox.critical(self, "Fehler", f"Fehler beim Speichern:\n{e}") + QMessageBox.critical(self, "Fehler", t("Fehler beim Speichern:\n{error}").format(error=e)) def _validate_file(self): """Validiert den Editor-Inhalt als JSON oder TOML""" @@ -502,7 +504,7 @@ def _update_cursor_position(self): cursor = self.editor.textCursor() line = cursor.blockNumber() + 1 col = cursor.columnNumber() + 1 - self.line_label.setText(f"Zeile: {line}, Spalte: {col}") + self.line_label.setText(t("Zeile: {line}, Spalte: {column}").format(line=line, column=col)) def _run_code(self): """Führt den Code aus""" @@ -517,7 +519,7 @@ def _run_code(self): ext = Path(self.filepath).suffix.lower() self.output.clear() - self._add_output(f"▶ Starte: {self.filepath}\n", "#569CD6") + self._add_output(t("▶ Starte: {path}").format(path=self.filepath) + "\n", "#569CD6") self._add_output("-" * 50 + "\n", "#3C3C3C") # Process starten @@ -538,7 +540,7 @@ def _run_code(self): elif ext == '.js': self._process.start('node', [self.filepath]) else: - self._add_output(f"Keine Ausführung für {ext} unterstützt\n", "#F14C4C") + self._add_output(t("Keine Ausführung für {ext} unterstützt").format(ext=ext) + "\n", "#F14C4C") return self.btn_run.setEnabled(False) @@ -564,9 +566,9 @@ def _process_finished(self, exit_code, exit_status): """Handler für Prozess-Ende""" self._add_output("-" * 50 + "\n", "#3C3C3C") if exit_code == 0: - self._add_output(f"✓ Beendet (Exit-Code: {exit_code})\n", "#4EC9B0") + self._add_output(t("✓ Beendet (Exit-Code: {code})").format(code=exit_code) + "\n", "#4EC9B0") else: - self._add_output(f"✗ Beendet mit Fehler (Exit-Code: {exit_code})\n", "#F14C4C") + self._add_output(t("✗ Beendet mit Fehler (Exit-Code: {code})").format(code=exit_code) + "\n", "#F14C4C") self.btn_run.setEnabled(True) self.btn_stop.setEnabled(False) diff --git a/src/modules/indexer/duplicate_finder.py b/src/modules/indexer/duplicate_finder.py index b5a9656..35f8497 100644 --- a/src/modules/indexer/duplicate_finder.py +++ b/src/modules/indexer/duplicate_finder.py @@ -22,6 +22,7 @@ import subprocess from core.platform_utils import open_path_with_system +from translator import t class DuplicateScanWorker(QThread): @@ -528,7 +529,7 @@ def _on_progress(self, current: int, total: int, filename: str): if total > 0: percent = int(current / total * 100) self.progress_bar.setValue(percent) - self.progress_label.setText(f"Prüfe: {filename[:50]}...") + self.progress_label.setText(t("Prüfe: {name}...").format(name=filename[:50])) @Slot(dict) def _on_duplicates_found(self, duplicates: dict): @@ -542,11 +543,15 @@ def _on_scan_finished(self, total_files: int, groups: int): self._reset_ui() if groups == 0: - self.stats_label.setText(f"✅ Keine Duplikate gefunden ({total_files} Dateien geprüft)") + self.stats_label.setText( + t("✅ Keine Duplikate gefunden ({count} Dateien geprüft)").format(count=total_files) + ) else: total_duplicates = sum(len(p) - 1 for p in self.duplicate_groups.values()) self.stats_label.setText( - f"✅ {groups} Duplikat-Gruppen gefunden ({total_duplicates} doppelte Dateien)" + t("✅ {groups} Duplikat-Gruppen gefunden ({count} doppelte Dateien)").format( + groups=groups, count=total_duplicates + ) ) self._update_space_info() self._update_buttons(True) @@ -555,7 +560,7 @@ def _on_scan_finished(self, total_files: int, groups: int): def _on_scan_error(self, error: str): """Fehler beim Scan""" self._reset_ui() - self.stats_label.setText(f"❌ Fehler: {error}") + self.stats_label.setText(t("❌ Fehler: {error}").format(error=error)) def _reset_ui(self): """Setzt UI zurück""" @@ -575,7 +580,7 @@ def _populate_tree(self, duplicates: dict): # Gruppe size = self._get_file_size(paths[0]) group_item = QTreeWidgetItem([ - f"📁 {len(paths)} Dateien", + t("📁 {count} Dateien").format(count=len(paths)), self._format_size(size), f"Hash: {file_hash[:16]}..." ]) @@ -629,7 +634,7 @@ def _update_space_info(self): total_waste += size * (len(paths) - 1) self.space_label.setText( - f"💾 Verschwendeter Speicher: {self._format_size(total_waste)}" + t("💾 Verschwendeter Speicher: {size}").format(size=self._format_size(total_waste)) ) def _update_buttons(self, enabled: bool): @@ -740,9 +745,11 @@ def _delete_selected(self): reply = QMessageBox.question( self, "Duplikate löschen", - f"Möchten Sie {len(files_to_delete)} Dateien löschen?\n\n" - f"Gesamtgröße: {self._format_size(total_size)}\n\n" - f"Diese Aktion kann nicht rückgängig gemacht werden!", + t( + "Möchten Sie {count} Dateien löschen?\n\n" + "Gesamtgröße: {size}\n\n" + "Diese Aktion kann nicht rückgängig gemacht werden!" + ).format(count=len(files_to_delete), size=self._format_size(total_size)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) @@ -767,14 +774,16 @@ def _delete_selected(self): if errors: QMessageBox.warning( self, "Teilweise gelöscht", - f"{deleted} von {len(files_to_delete)} Dateien gelöscht.\n\n" - f"Fehler:\n" + "\n".join(errors[:5]) + t("{deleted} von {total} Dateien gelöscht.\n\nFehler:\n{errors}").format( + deleted=deleted, total=len(files_to_delete), errors="\n".join(errors[:5]) + ) ) else: QMessageBox.information( self, "Erfolgreich", - f"{deleted} Dateien wurden gelöscht.\n" - f"Freigegeben: {self._format_size(total_size)}" + t("{count} Dateien wurden gelöscht.\nFreigegeben: {size}").format( + count=deleted, size=self._format_size(total_size) + ) ) # Neu scannen @@ -823,7 +832,7 @@ def _open_path_with_system(self, path: str, title: str): QMessageBox.warning( self, title, - f"Der Pfad konnte nicht geöffnet werden:\n{path}\n\n{exc}" + t("Der Pfad konnte nicht geöffnet werden:\n{path}\n\n{error}").format(path=path, error=exc) ) def _copy_path(self, path: str): diff --git a/src/modules/launcher/apps_panel.py b/src/modules/launcher/apps_panel.py index 991dd06..d58eb7e 100644 --- a/src/modules/launcher/apps_panel.py +++ b/src/modules/launcher/apps_panel.py @@ -55,9 +55,11 @@ def _setup_ui(self): self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setCursor(Qt.CursorShape.PointingHandCursor) self.setToolTip(f"{self.app.name}\n{self.app.path}") - self.setAccessibleName(f"App {self.app.name}") + self.setAccessibleName(t("App {name}").format(name=self.app.name)) self.setAccessibleDescription( - f"Startet die Anwendung {self.app.name}. Dateipfad: {self.app.path}" + t("Startet die Anwendung {name}. Dateipfad: {path}").format( + name=self.app.name, path=self.app.path + ) ) layout = QVBoxLayout(self) @@ -118,7 +120,11 @@ def _open_folder(self): try: open_path_with_system(folder) except (OSError, subprocess.CalledProcessError) as exc: - QMessageBox.warning(self, "Ordner öffnen", f"Der Ordner konnte nicht geöffnet werden:\n{folder}\n\n{exc}") + QMessageBox.warning( + self, + "Ordner öffnen", + t("Der Ordner konnte nicht geöffnet werden:\n{path}\n\n{error}").format(path=folder, error=exc), + ) class AppEditDialog(QDialog): @@ -406,7 +412,7 @@ def _edit_app(self, app: AppEntry): def _delete_app(self, app: AppEntry): if QMessageBox.question( - self, "Entfernen", f"'{app.name}' entfernen?", + self, "Entfernen", t("'{name}' entfernen?").format(name=app.name), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) == QMessageBox.StandardButton.Yes: self.apps.remove(app) @@ -425,4 +431,4 @@ def _launch_app(self, app: AppEntry): subprocess.Popen([app.path] + arg_list) self.app_launched.emit(app.path) except Exception as e: - QMessageBox.warning(self, "Fehler", f"Konnte nicht starten:\n{e}") + QMessageBox.warning(self, "Fehler", t("Konnte nicht starten:\n{error}").format(error=e)) diff --git a/src/modules/privacy/privacy_monitor.py b/src/modules/privacy/privacy_monitor.py index e5aef17..c4b7b75 100644 --- a/src/modules/privacy/privacy_monitor.py +++ b/src/modules/privacy/privacy_monitor.py @@ -18,6 +18,8 @@ from PySide6.QtCore import QObject, Signal from PySide6.QtWidgets import QApplication +from translator import t + logging.basicConfig(level=logging.INFO) @@ -130,6 +132,7 @@ def __init__(self, config_dir: Optional[Path] = None): # Clipboard self.clipboard = None + self._connected = False # Laden & Kompilieren self._load_config() @@ -137,38 +140,67 @@ def __init__(self, config_dir: Optional[Path] = None): def start(self): """Startet die Clipboard-Überwachung""" + self._enabled = True if self.clipboard is None: app = QApplication.instance() if app: self.clipboard = app.clipboard() - self.clipboard.dataChanged.connect(self._on_clipboard_change) - logging.info("PrivacyMonitor gestartet") - self.status_changed.emit(self._current_status.value) + if self.clipboard is not None and not self._connected: + self.clipboard.dataChanged.connect(self._on_clipboard_change) + self._connected = True + logging.info("PrivacyMonitor gestartet") + if self._current_status == PrivacyStatus.GRAY: + self._current_status = PrivacyStatus.GREEN + self.status_changed.emit(self._current_status.value) def stop(self): """Stoppt die Überwachung""" - if self.clipboard: + if self.clipboard is not None and self._connected: try: self.clipboard.dataChanged.disconnect(self._on_clipboard_change) except (RuntimeError, TypeError): pass + self._connected = False self._enabled = False self._current_status = PrivacyStatus.GRAY self.status_changed.emit('gray') logging.info("PrivacyMonitor gestoppt") + def reset_status(self): + """Setzt die Ampel nach einer Warnung zurück (grün bzw. grau, wenn inaktiv).""" + self._current_status = PrivacyStatus.GREEN if self._enabled else PrivacyStatus.GRAY + self.status_changed.emit(self._current_status.value) + + def recheck_clipboard(self): + """Prüft den aktuellen Inhalt der Zwischenablage erneut (z. B. nach Listenänderung).""" + if not self._enabled or self.clipboard is None: + return + mime_data = self.clipboard.mimeData() + text = mime_data.text() if mime_data is not None and mime_data.hasText() else "" + alert = self.check_text(text) + if alert.status != self._current_status: + self._current_status = alert.status + self.status_changed.emit(alert.status.value) + @property def enabled(self) -> bool: return self._enabled @enabled.setter def enabled(self, value: bool): - self._enabled = value if value: self.start() else: self.stop() + @property + def auto_clear(self) -> bool: + return self._auto_clear + + @auto_clear.setter + def auto_clear(self, value: bool): + self._auto_clear = bool(value) + @property def status(self) -> str: return self._current_status.value @@ -194,7 +226,11 @@ def _load_config(self): else: self.whitelist = set() - self.pattern_enabled = cfg.get('patterns', self.pattern_enabled) + saved_patterns = cfg.get('patterns', {}) + if isinstance(saved_patterns, dict): + for key, value in saved_patterns.items(): + if key in BUILTIN_PATTERNS: + self.pattern_enabled[key] = bool(value) self.case_sensitive = cfg.get('case_sensitive', False) self.whole_words = cfg.get('whole_words', False) self._auto_clear = cfg.get('auto_clear', False) @@ -206,8 +242,8 @@ def _load_config(self): def save_config(self): """Speichert die Konfiguration""" cfg = { - 'blacklist': list(self.blacklist), - 'whitelist': list(self.whitelist), + 'blacklist': sorted(self.blacklist, key=str.lower), + 'whitelist': sorted(self.whitelist, key=str.lower), 'patterns': self.pattern_enabled, 'case_sensitive': self.case_sensitive, 'whole_words': self.whole_words, @@ -300,6 +336,13 @@ def remove_from_whitelist(self, term: str): self._compile_patterns() self.save_config() + def set_lists(self, blacklist, whitelist): + """Ersetzt Black- und Whitelist vollständig und speichert die Konfiguration.""" + self.blacklist = {str(t).strip() for t in blacklist if str(t).strip()} + self.whitelist = {str(t).strip() for t in whitelist if str(t).strip()} + self._compile_patterns() + self.save_config() + def import_blacklist(self, terms: List[str]): """Importiert mehrere Begriffe in die Blacklist""" for term in terms: @@ -333,7 +376,8 @@ def _collect_matches(self, text: str): if (m.group() if self.case_sensitive else m.group().lower()) not in whitelist_check ] if matches: - detected.append(f"{name}: {len(matches)}x") + label = name if severity == "blacklist" else t(name) + detected.append(f"{label}: {len(matches)}x") if severity == "high": has_high = True for m in matches: @@ -388,15 +432,15 @@ def check_text(self, text: str) -> PrivacyAlert: # Status bestimmen if not detected: status = PrivacyStatus.GREEN - message = "Keine sensiblen Daten erkannt" + message = t("Keine sensiblen Daten erkannt") else: # Prüfe Severity if has_high or len(detected) > 2: status = PrivacyStatus.RED - message = f"WARNUNG: {len(detected)} sensible Muster erkannt!" + message = t("WARNUNG: {count} sensible Muster erkannt!").format(count=len(detected)) else: status = PrivacyStatus.YELLOW - message = f"Hinweis: {len(detected)} Muster erkannt" + message = t("Hinweis: {count} Muster erkannt").format(count=len(detected)) return PrivacyAlert( status=status, @@ -406,6 +450,35 @@ def check_text(self, text: str) -> PrivacyAlert: anonymized_text=anonymized ) + def preview_check(self, text: str, *, blacklist=None, whitelist=None, patterns=None, + case_sensitive=None, whole_words=None) -> PrivacyAlert: + """Prüft *text* mit einer noch nicht gespeicherten Konfiguration. + + Der aktuelle Zustand des Monitors wird danach vollständig + wiederhergestellt (genutzt vom "Text testen"-Bereich im Dialog). + """ + saved = ( + self.blacklist, self.whitelist, self.pattern_enabled, + self.case_sensitive, self.whole_words, self.compiled_patterns, self._enabled, + ) + try: + if blacklist is not None: + self.blacklist = {str(t).strip() for t in blacklist if str(t).strip()} + if whitelist is not None: + self.whitelist = {str(t).strip() for t in whitelist if str(t).strip()} + if patterns is not None: + self.pattern_enabled = dict(patterns) + if case_sensitive is not None: + self.case_sensitive = bool(case_sensitive) + if whole_words is not None: + self.whole_words = bool(whole_words) + self._enabled = True + self._compile_patterns() + return self.check_text(text) + finally: + (self.blacklist, self.whitelist, self.pattern_enabled, self.case_sensitive, + self.whole_words, self.compiled_patterns, self._enabled) = saved + def anonymize(self, text: str) -> str: """Anonymisiert einen Text unter Berücksichtigung von Blacklist, Whitelist und Patterns""" if not text: diff --git a/src/modules/privacy/term_io.py b/src/modules/privacy/term_io.py new file mode 100644 index 0000000..4bb20e7 --- /dev/null +++ b/src/modules/privacy/term_io.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +term_io — Import/Export von Begriffslisten (Blacklist/Whitelist). + +Unterstützt TXT (ein Begriff pro Zeile), CSV (erste Spalte) und XLSX +(erste Spalte des aktiven Blatts, via openpyxl). Rein lokal, ohne Qt. +""" +from __future__ import annotations + +import csv +from pathlib import Path +from typing import Iterable, List + +MAX_TERMS = 100_000 + + +def _clean(terms: Iterable) -> List[str]: + seen = set() + result = [] + for term in terms: + if term is None: + continue + text = str(term).strip() + if not text or text.lower() == "nan" or text in seen: + continue + seen.add(text) + result.append(text) + if len(result) >= MAX_TERMS: + break + return result + + +def read_terms(path: str) -> List[str]: + """Liest Begriffe aus einer Datei; wirft OSError/ValueError bei Fehlern.""" + file_path = Path(path) + suffix = file_path.suffix.lower() + + if suffix == ".xlsx": + try: + import openpyxl + except ImportError as exc: # pragma: no cover - Abhängigkeit ist Pflicht + raise ValueError("openpyxl ist nicht installiert") from exc + workbook = openpyxl.load_workbook(file_path, read_only=True, data_only=True) + try: + sheet = workbook.active + values = (row[0] for row in sheet.iter_rows(min_col=1, max_col=1, values_only=True)) + return _clean(values) + finally: + workbook.close() + + raw = file_path.read_bytes() + for encoding in ("utf-8-sig", "cp1252"): + try: + text = raw.decode(encoding) + break + except UnicodeDecodeError: + continue + else: # pragma: no cover - cp1252 dekodiert praktisch alles + text = raw.decode("utf-8", errors="replace") + + if suffix == ".csv": + try: + dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t") + except csv.Error: + dialect = csv.excel + rows = csv.reader(text.splitlines(), dialect) + return _clean(row[0] for row in rows if row) + + return _clean(text.splitlines()) + + +def write_terms(path: str, terms: Iterable[str]) -> None: + """Schreibt Begriffe als TXT, CSV oder XLSX (nach Dateiendung).""" + file_path = Path(path) + suffix = file_path.suffix.lower() + items = sorted(_clean(terms), key=str.lower) + + if suffix == ".xlsx": + import openpyxl + + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.title = "Begriffe" + for item in items: + sheet.append([item]) + workbook.save(file_path) + return + + if suffix == ".csv": + with open(file_path, "w", encoding="utf-8-sig", newline="") as handle: + writer = csv.writer(handle) + for item in items: + writer.writerow([item]) + return + + file_path.write_text("\n".join(items) + ("\n" if items else ""), encoding="utf-8") diff --git a/src/modules/prompts/prompts_panel.py b/src/modules/prompts/prompts_panel.py index 3351a47..be3b0ed 100644 --- a/src/modules/prompts/prompts_panel.py +++ b/src/modules/prompts/prompts_panel.py @@ -179,7 +179,7 @@ def _update_display(self): if self.prompt.tags: tooltip += f"🏷️ {', '.join(self.prompt.tags)}
    " tooltip += f"📅 {self.prompt.modified[:10]}
    " - tooltip += f"📊 {self.prompt.use_count}x verwendet

    " + tooltip += t("📊 {count}x verwendet").format(count=self.prompt.use_count) + "

    " tooltip += f"{self.prompt.content[:200]}..." self.setToolTip(tooltip) @@ -454,8 +454,8 @@ def _copy_prompt(self, item: QListWidgetItem): # Variablen abfragen for var in set(variables): value, ok = QInputDialog.getText( - self, f"Variable: {var}", - f"Wert für '{var}' eingeben:" + self, t("Variable: {name}").format(name=var), + t("Wert für '{name}' eingeben:").format(name=var) ) if ok: content = content.replace(f"{{{{{var}}}}}", value) @@ -470,7 +470,7 @@ def _copy_prompt(self, item: QListWidgetItem): prompt.use_count += 1 self._save_prompts() - self.status_label.setText(f"✅ '{prompt.title}' kopiert!") + self.status_label.setText(t("✅ '{title}' kopiert!").format(title=prompt.title)) self.prompt_copied.emit(content) def _copy_selected(self): @@ -505,7 +505,7 @@ def _delete_prompt(self, prompt: Prompt): """Prompt löschen""" if QMessageBox.question( self, "Löschen", - f"'{prompt.title}' wirklich löschen?", + t("'{name}' wirklich löschen?").format(name=prompt.title), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) == QMessageBox.StandardButton.Yes: self.prompts.remove(prompt) @@ -543,4 +543,4 @@ def _update_status(self): """Aktualisiert Status-Anzeige""" total = len(self.prompts) shown = self.prompt_list.count() - self.status_label.setText(f"{shown} von {total} Prompts") + self.status_label.setText(t("{shown} von {total} Prompts").format(shown=shown, total=total)) diff --git a/src/modules/sync/sync_manager.py b/src/modules/sync/sync_manager.py index 28a5dca..f904814 100644 --- a/src/modules/sync/sync_manager.py +++ b/src/modules/sync/sync_manager.py @@ -22,6 +22,8 @@ import os import shutil +from translator import t + class SyncDirection(Enum): """Synchronisations-Richtung""" @@ -113,11 +115,11 @@ def _analyze(self): if direction == "target_to_source": if not target.exists(): - self.error.emit(f"Zielordner existiert nicht: {target}") + self.error.emit(t("Zielordner existiert nicht: {path}").format(path=target)) return else: if not source.exists(): - self.error.emit(f"Quellordner existiert nicht: {source}") + self.error.emit(t("Quellordner existiert nicht: {path}").format(path=source)) return # Alle Dateien sammeln @@ -734,7 +736,7 @@ def _edit_pair(self, pair: SyncPair): def _delete_pair(self, pair: SyncPair): if QMessageBox.question( self, "Löschen", - f"'{pair.name}' wirklich löschen?", + t("'{name}' wirklich löschen?").format(name=pair.name), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) == QMessageBox.StandardButton.Yes: self.sync_pairs.remove(pair) @@ -760,9 +762,9 @@ def _run_selected_sync(self): reply = QMessageBox.question( self, "Synchronisation starten", - f"'{pair.name}' synchronisieren?\n\n" - f"Quelle: {pair.source}\n" - f"Ziel: {pair.target}", + t("'{name}' synchronisieren?\n\nQuelle: {source}\nZiel: {target}").format( + name=pair.name, source=pair.source, target=pair.target + ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) @@ -784,8 +786,8 @@ def _start_sync(self, pair: SyncPair, dry_run: bool = True): self.progress_bar.show() self.sync_btn.setEnabled(False) - mode = "Vorschau" if dry_run else "Sync" - self.status_label.setText(f"{mode}: Analysiere...") + mode = t("Vorschau") if dry_run else t("Sync") + self.status_label.setText(t("{mode}: Analysiere...").format(mode=mode)) self.sync_started.emit(pair.name) self.sync_worker = SyncWorker(pair, dry_run) @@ -801,7 +803,7 @@ def _start_sync(self, pair: SyncPair, dry_run: bool = True): def _on_progress(self, current: int, total: int, filename: str): if total > 0: self.progress_bar.setValue(int(current / total * 100)) - self.status_label.setText(f"Prüfe: {Path(filename).name}") + self.status_label.setText(t("Prüfe: {name}").format(name=Path(filename).name)) def _on_scan_finished(self, actions: list, dry_run: bool, pair: SyncPair): if not actions: @@ -825,26 +827,26 @@ def _on_sync_finished(self, synced: int, errors: int): self.sync_btn.setEnabled(True) if errors: - self.status_label.setText(f"⚠️ {synced} synchronisiert, {errors} Fehler") + self.status_label.setText(t("⚠️ {synced} synchronisiert, {errors} Fehler").format(synced=synced, errors=errors)) else: - self.status_label.setText(f"✅ {synced} Dateien synchronisiert") + self.status_label.setText(t("✅ {count} Dateien synchronisiert").format(count=synced)) self.sync_finished.emit(synced) def _on_error(self, error: str): self.progress_bar.hide() self.sync_btn.setEnabled(True) - self.status_label.setText(f"❌ Fehler: {error}") + self.status_label.setText(t("❌ Fehler: {error}").format(error=error)) def _show_preview_dialog(self, actions: list, pair: SyncPair): """Zeigt Vorschau-Dialog""" dialog = QDialog(self) - dialog.setWindowTitle(f"Sync-Vorschau: {pair.name}") + dialog.setWindowTitle(t("Sync-Vorschau: {name}").format(name=pair.name)) dialog.setMinimumSize(600, 400) layout = QVBoxLayout(dialog) - summary = QLabel(f"📊 {len(actions)} Änderungen gefunden:") + summary = QLabel(t("📊 {count} Änderungen gefunden:").format(count=len(actions))) summary.setAccessibleName("Anzahl geplanter Synchronisationsänderungen") layout.addWidget(summary) diff --git a/tests/test_review_fixes_20261006.py b/tests/test_review_fixes_20261006.py new file mode 100644 index 0000000..2a1e619 --- /dev/null +++ b/tests/test_review_fixes_20261006.py @@ -0,0 +1,523 @@ +"""Regressionstests für das Review vom 2026-10-06. + +Abgedeckt: +* Cloud-Ordner (OneDrive & Co.) werden erkannt und im Browser nicht ausgefiltert +* Namensspalte der Dateiliste bleibt sichtbar +* Vorschau stürzt nicht ab und lädt keine Cloud-Platzhalter +* Black-/Whitelist sind in der GUI editierbar, Ampel ist bedienbar +* Spracheinstellung wirkt zur Laufzeit auf alle Widgets +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +import shiboken6 +from PySide6.QtCore import QDir +from PySide6.QtWidgets import QApplication, QCheckBox, QLabel, QMenu, QPushButton, QWidget + +import translator +from core import cloud_locations, file_attributes +from core.platform_utils import normalize_user_path +from core.settings_manager import SettingsManager + + +@pytest.fixture +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def isolated_settings(monkeypatch, tmp_path): + monkeypatch.setattr(SettingsManager, "_instance", None) + monkeypatch.setattr(SettingsManager, "_get_config_path", lambda self: tmp_path / "settings.json") + return SettingsManager.instance() + + +@pytest.fixture +def fresh_translator(monkeypatch): + ts = translator.TranslationSystem("de") + monkeypatch.setattr(translator, "_default_translator", ts) + return ts + + +# --------------------------------------------------------------------------- # +# Cloud-Ordner # +# --------------------------------------------------------------------------- # + +def test_cloud_locations_from_environment_and_home(tmp_path): + home = tmp_path / "home" + home.mkdir() + (home / "OneDrive - Contoso").mkdir() + (home / "Dropbox").mkdir() + (home / "Dokumente").mkdir() + (home / "Boxen").mkdir() # kein Cloud-Ordner trotz Präfix "box" + personal = home / "OneDrive" + personal.mkdir() + + found = cloud_locations.find_cloud_locations( + home=str(home), environ={"OneDrive": str(personal), "OneDriveConsumer": str(personal)}, + platform="linux", + ) + labels = [loc.label for loc in found] + assert labels == ["Dropbox", "OneDrive", "OneDrive - Contoso"] + assert len({os.path.realpath(loc.path) for loc in found}) == len(found) + + +def test_cloud_locations_macos_cloudstorage(tmp_path): + home = tmp_path + storage = home / "Library" / "CloudStorage" + (storage / "OneDrive-Persönlich").mkdir(parents=True) + (storage / "GoogleDrive-user@example.com").mkdir() + icloud = home / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + icloud.mkdir(parents=True) + + labels = {loc.label for loc in cloud_locations.find_cloud_locations( + home=str(home), environ={}, platform="darwin")} + assert {"OneDrive - Persönlich", "Google Drive (user@example.com)", "iCloud Drive"} <= labels + + +def test_cloud_placeholder_detection_uses_metadata_only(): + windows_online = SimpleNamespace(st_file_attributes=file_attributes.FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) + windows_local = SimpleNamespace(st_file_attributes=0x20) + assert file_attributes.is_cloud_placeholder("x", windows_online, platform="win32") + assert not file_attributes.is_cloud_placeholder("x", windows_local, platform="win32") + + import stat as stat_mod + mac = SimpleNamespace(st_flags=file_attributes.SF_DATALESS, st_mode=stat_mod.S_IFREG) + assert file_attributes.is_cloud_placeholder("x", mac, platform="darwin") + assert not file_attributes.is_cloud_placeholder("/does/not/exist") + + +def test_browser_shows_windows_system_entries(monkeypatch): + from gui.browser import file_browser + + monkeypatch.setattr(file_browser.sys, "platform", "win32") + assert file_browser.base_entry_filters() & QDir.Filter.System + monkeypatch.setattr(file_browser.sys, "platform", "linux") + assert not file_browser.base_entry_filters() & QDir.Filter.System + + +def test_sidebar_lists_cloud_storage(app, monkeypatch, tmp_path): + from gui.sidebar import sidebar_main + + cloud = tmp_path / "OneDrive" + cloud.mkdir() + (cloud / "Projekte").mkdir() + monkeypatch.setattr( + sidebar_main, "find_cloud_locations", + lambda: [cloud_locations.CloudLocation("OneDrive", str(cloud))], + ) + panel = sidebar_main.TreePanel() + try: + assert panel.cloud_item is not None + child = panel.cloud_item.child(0) + assert child.text(0) == "OneDrive" + panel._on_item_expanded(child) + assert [child.child(i).text(0) for i in range(child.childCount())] == ["Projekte"] + finally: + shiboken6.delete(panel) + + +# --------------------------------------------------------------------------- # +# Dateiliste # +# --------------------------------------------------------------------------- # + +def test_name_column_keeps_space_when_narrow(app, isolated_settings, tmp_path): + from gui.browser.file_browser import FileBrowser + + browser = FileBrowser() + try: + browser.resize(420, 300) + browser.show() + app.processEvents() + header = browser.table.horizontalHeader() + assert header.sectionSize(0) >= FileBrowser.MIN_NAME_WIDTH - 2 + for column in FileBrowser.COLUMN_WIDTHS: + assert header.sectionResizeMode(column).name == "Interactive" + finally: + browser.close() + shiboken6.delete(browser) + + +def test_browser_uses_configured_start_folder(app, isolated_settings, tmp_path): + from gui.browser.file_browser import FileBrowser + + start = tmp_path / "Start Ordner" + start.mkdir() + isolated_settings.set("general", "start_folder", f'"{start}"') + browser = FileBrowser() + try: + assert browser.current_path == str(start) + finally: + shiboken6.delete(browser) + + +def test_navigate_to_file_opens_parent_and_selects(app, isolated_settings, tmp_path): + from gui.browser.file_browser import FileBrowser + + target = tmp_path / "notiz.txt" + target.write_text("x", encoding="utf-8") + browser = FileBrowser() + selected = [] + browser.file_selected.connect(selected.append) + try: + browser.navigate_to(str(target)) + assert browser.current_path == str(tmp_path) + assert selected == [str(target)] + finally: + shiboken6.delete(browser) + + +def test_normalize_user_path(monkeypatch, tmp_path): + monkeypatch.setenv("EP_TEST_DIR", str(tmp_path)) + assert normalize_user_path(f'"{tmp_path}"') == str(tmp_path) + assert normalize_user_path("$EP_TEST_DIR") == str(tmp_path) + assert normalize_user_path(tmp_path.as_uri()) == str(tmp_path) + assert normalize_user_path(" ") == "" + + +# --------------------------------------------------------------------------- # +# Vorschau # +# --------------------------------------------------------------------------- # + +def test_preview_never_raises(app, isolated_settings, monkeypatch, tmp_path): + from gui.preview import preview_panel + + target = tmp_path / "bild.png" + target.write_bytes(b"not really a png") + panel = preview_panel.PreviewPanel() + try: + def boom(*_args, **_kwargs): + raise RuntimeError("Render kaputt") + + monkeypatch.setattr(panel.image_preview, "load_image", boom) + panel.show_preview(str(target)) # darf nicht werfen + assert panel.preview_stack.currentWidget() is panel.unsupported_label + assert "Render kaputt" in panel.unsupported_label.text() + assert panel.metadata_panel.name_label.text() == "bild.png" + finally: + shiboken6.delete(panel) + + +def test_preview_skips_cloud_placeholders(app, isolated_settings, monkeypatch, tmp_path): + from gui.preview import preview_panel + + target = tmp_path / "online.txt" + target.write_text("Inhalt", encoding="utf-8") + monkeypatch.setattr(preview_panel, "is_cloud_placeholder", lambda path: True) + panel = preview_panel.PreviewPanel() + try: + monkeypatch.setattr(panel.text_preview, "load_file", + lambda path: pytest.fail("Platzhalter darf nicht gelesen werden")) + panel.show_preview(str(target)) + assert "nur online verfügbar" in panel.unsupported_label.text() + finally: + shiboken6.delete(panel) + + +def test_preview_respects_size_limit_and_toggles(app, isolated_settings, tmp_path): + from gui.preview import preview_panel + + big = tmp_path / "gross.pdf" + big.write_bytes(b"%PDF-1.4" + b"0" * (2 * 1024 * 1024)) + isolated_settings.set("preview", "max_preview_size_mb", 1) + panel = preview_panel.PreviewPanel() + try: + panel.show_preview(str(big)) + assert "zu groß" in panel.unsupported_label.text() + + isolated_settings.set("preview", "preview_images", False) + image = tmp_path / "klein.png" + image.write_bytes(b"\x89PNG") + panel.show_preview(str(image)) + assert "deaktiviert" in panel.unsupported_label.text() + finally: + shiboken6.delete(panel) + + +def test_preview_detects_text_without_extension(app, isolated_settings, tmp_path): + from gui.preview import preview_panel + + readme = tmp_path / "README" + readme.write_text("Hallo Welt\n", encoding="utf-8") + panel = preview_panel.PreviewPanel() + try: + panel.show_preview(str(readme)) + assert panel.preview_stack.currentWidget() is panel.text_preview + assert "Hallo Welt" in panel.text_preview.toPlainText() + finally: + shiboken6.delete(panel) + + +def test_image_preview_downscales_large_images(app, tmp_path): + from PySide6.QtGui import QImage, QColor + from gui.preview.preview_panel import ImagePreview, MAX_IMAGE_EDGE + + path = tmp_path / "riesig.png" + image = QImage(MAX_IMAGE_EDGE * 2, 100, QImage.Format.Format_RGB32) + image.fill(QColor("red")) + assert image.save(str(path)) + preview = ImagePreview() + try: + preview.load_image(str(path)) + assert preview._original_pixmap.width() <= MAX_IMAGE_EDGE + finally: + shiboken6.delete(preview) + + +# --------------------------------------------------------------------------- # +# Datenschutz # +# --------------------------------------------------------------------------- # + +@pytest.fixture +def monitor(tmp_path): + from modules.privacy.privacy_monitor import PrivacyMonitor + + instance = PrivacyMonitor(tmp_path / "privacy") + yield instance + instance.stop() + shiboken6.delete(instance) + + +def test_privacy_dialog_edits_black_and_whitelist(app, isolated_settings, monitor): + from gui.privacy_dialog import PrivacySettingsDialog + + monitor.add_to_blacklist("Altbegriff") + dialog = PrivacySettingsDialog(monitor, settings=isolated_settings) + try: + assert dialog.blacklist_editor.terms() == ["Altbegriff"] + dialog.blacklist_editor.term_edit.setText("Projekt Phoenix; Kunde 4711") + dialog.blacklist_editor.add_from_input() + dialog.whitelist_editor.add_terms(["info@firma.de"]) + dialog.blacklist_editor.list_widget.setCurrentRow(0) # sortiert: Altbegriff + dialog.blacklist_editor.remove_selected() + dialog.apply_settings() + finally: + shiboken6.delete(dialog) + + assert monitor.blacklist == {"Projekt Phoenix", "Kunde 4711"} + assert monitor.whitelist == {"info@firma.de"} + saved = json.loads(monitor.config_path.read_text(encoding="utf-8")) + assert set(saved["blacklist"]) == {"Projekt Phoenix", "Kunde 4711"} + assert monitor.check_text("Treffen zu Projekt Phoenix").detected_patterns + assert not monitor.check_text("Mail an info@firma.de").detected_patterns + + +def test_privacy_dialog_test_tab_uses_unsaved_lists(app, isolated_settings, monitor): + from gui.privacy_dialog import PrivacySettingsDialog + + dialog = PrivacySettingsDialog(monitor, settings=isolated_settings) + try: + dialog.blacklist_editor.add_terms(["Geheimprojekt"]) + dialog.test_input.setPlainText("Status Geheimprojekt") + dialog.run_test() + assert "Geheimprojekt" in dialog.test_result.toPlainText() + # Ohne Speichern bleibt der Monitor unverändert. + assert monitor.blacklist == set() + assert not monitor.check_text("Status Geheimprojekt").detected_patterns + finally: + shiboken6.delete(dialog) + + +def test_privacy_dialog_toggles_monitoring(app, isolated_settings, monitor): + from gui.privacy_dialog import PrivacySettingsDialog + + monitor.start() + dialog = PrivacySettingsDialog(monitor, settings=isolated_settings) + try: + dialog.monitor_enabled_cb.setChecked(False) + dialog.apply_settings() + assert monitor.status == "gray" and not monitor.enabled + assert isolated_settings.get("privacy", "enable_clipboard_monitor") is False + dialog.monitor_enabled_cb.setChecked(True) + dialog.apply_settings() + assert monitor.enabled and monitor.status == "green" + finally: + monitor.stop() + shiboken6.delete(dialog) + + +def test_monitor_restart_reconnects_clipboard(app, monitor): + monitor.start() + monitor.stop() + monitor.start() + assert monitor.enabled and monitor._connected + monitor.add_to_blacklist("Codename") + QApplication.clipboard().setText("Codename Falke") + app.processEvents() + assert monitor.status in ("yellow", "red") + monitor.reset_status() + assert monitor.status == "green" + monitor.stop() + + +def test_term_io_roundtrip(tmp_path): + from modules.privacy.term_io import read_terms, write_terms + + terms = ["Müller", "Projekt X", "Müller", " ", "nan"] + for suffix in (".txt", ".csv", ".xlsx"): + path = tmp_path / f"liste{suffix}" + write_terms(str(path), terms) + assert sorted(read_terms(str(path))) == ["Müller", "Projekt X"] + + semicolon = tmp_path / "excel_export.csv" + semicolon.write_bytes("Begriff;Kommentar\nÄrger;x\n".encode("cp1252")) + assert read_terms(str(semicolon)) == ["Begriff", "Ärger"] + + +def test_status_bar_privacy_menu_emits_actions(app): + from gui.status_bar import StatusBarWidget + + bar = StatusBarWidget() + events = [] + bar.privacy_settings_requested.connect(lambda: events.append("settings")) + bar.privacy_reset_requested.connect(lambda: events.append("reset")) + bar.privacy_monitoring_toggled.connect(lambda on: events.append(("toggle", on))) + try: + bar.privacy_indicator.clicked.emit() + bar.set_privacy_status("red") + menu = bar.build_privacy_menu() + actions = {a.text(): a for a in menu.actions() if a.text()} + actions["Ampel zurücksetzen"].trigger() + actions["Zwischenablage überwachen"].trigger() + assert events == ["settings", "reset", ("toggle", False)] + finally: + shiboken6.delete(bar) + + +# --------------------------------------------------------------------------- # +# Sprache # +# --------------------------------------------------------------------------- # + +def test_translator_never_writes_catalog_at_runtime(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + catalog = locales / "translations.json" + catalog.write_text("{}", encoding="utf-8") + catalog.chmod(0o444) + try: + ts = translator.TranslationSystem("en", app_dir=tmp_path) + assert ts.t("Völlig neuer Text") == "Völlig neuer Text" + assert "Völlig neuer Text" in ts.missing_keys + assert catalog.read_text(encoding="utf-8") == "{}" + finally: + catalog.chmod(0o644) + + +def test_translate_text_handles_decorations(fresh_translator): + ts = fresh_translator + ts.set_language("en") + assert ts.translate_text("📂 Öffnen") == "📂 Open" + assert ts.translate_text("Neue Datei...") == "New File..." + assert ts.translate_text("Startordner:") == "Start folder:" + assert ts.translate_text("&Datei") == "&File" + assert ts.translate_text("Abbrechen\nAbbrechen") == "Cancel\nCancel" + assert ts.translate_text("Ein Ordnername ohne Eintrag") == "Ein Ordnername ohne Eintrag" + # Werte der aktiven Sprache bleiben unverändert (keine Doppelübersetzung) + assert ts.translate_text("Cancel") == "Cancel" + assert ts.translate_text_from("Cancel", "en") == "Cancel" + ts.set_language("es") + assert ts.translate_text_from("Cancel", "en") == ts.translations["Abbrechen"]["es"] + + +def test_ui_translator_translates_and_switches_live(app, fresh_translator): + from core.ui_translator import NO_TRANSLATE, UiTranslator + + ui = UiTranslator(app) + window = QWidget() + try: + label = QLabel("Startordner:", window) + button = QPushButton("Abbrechen", window) + data = QLabel("Abbrechen", window) + data.setProperty(NO_TRANSLATE, True) + check = QCheckBox("Versteckte Dateien anzeigen", window) + menu = QMenu("&Datei", window) + action = menu.addAction("Neue Datei...") + + fresh_translator.set_language("en") + window.show() + app.processEvents() + ui.translate_widget(menu) + assert label.text() == "Start folder:" + assert button.text() == "Cancel" + assert data.text() == "Abbrechen" + assert check.text() == fresh_translator.translations["Versteckte Dateien anzeigen"]["en"] + assert action.text() == "New File..." + + fresh_translator.set_language("es") # Live-Wechsel + assert button.text() == fresh_translator.translations["Abbrechen"]["es"] + assert label.text() == fresh_translator.translations["Startordner:"]["es"] + assert action.text() == fresh_translator.translations["Neue Datei"]["es"] + "..." + + fresh_translator.set_language("de") + assert button.text() == "Abbrechen" + assert action.text() == "Neue Datei..." + finally: + ui.shutdown() + shiboken6.delete(window) + shiboken6.delete(ui) + + +def test_settings_language_change_applies_live(app, isolated_settings, fresh_translator, monkeypatch): + from gui.main_window import MainWindow + + window = MainWindow() + try: + isolated_settings.set("general", "language", "en") + window._apply_settings() + assert fresh_translator.get_language() == "en" + finally: + fresh_translator.set_language("de") + window.close() + shiboken6.delete(window) + + +# --------------------------------------------------------------------------- # +# Indexer & Crash-Log # +# --------------------------------------------------------------------------- # + +def test_index_worker_respects_excludes_and_placeholders(app, tmp_path, monkeypatch): + from core import file_attributes as fa + from core.file_index import FileIndex, IndexWorker + + folder = tmp_path / "daten" + folder.mkdir() + (folder / "behalten.txt").write_text("hallo", encoding="utf-8") + (folder / "weg.tmp").write_text("x", encoding="utf-8") + (folder / "online.txt").write_text("cloud", encoding="utf-8") + index = FileIndex(str(tmp_path / "index.db")) + + monkeypatch.setattr(fa, "is_cloud_placeholder", lambda path: path.endswith("online.txt")) + read = [] + original = FileIndex.calculate_hash + monkeypatch.setattr(FileIndex, "calculate_hash", lambda self, p: read.append(p) or original(self, p)) + + worker = IndexWorker(index, str(folder), max_file_size_mb=1, excluded_patterns=["*.tmp"]) + worker.run() + + assert index.get_file(str(folder / "weg.tmp")) is None + assert index.get_file(str(folder / "online.txt")) is not None + assert not any(p.endswith("online.txt") for p in read) + + +def test_crash_log_records_unhandled_exceptions(tmp_path, monkeypatch): + from core import crash_log + + previous = sys.excepthook + # pytest-qt meldet weitergereichte Ausnahmen als Testfehler: Kette kappen. + monkeypatch.setattr(sys, "excepthook", lambda *args: None) + path = crash_log.install_crash_logging(tmp_path) + try: + try: + raise ValueError("Testfehler 42") + except ValueError: + sys.excepthook(*sys.exc_info()) + assert "Testfehler 42" in Path(path).read_text(encoding="utf-8") + finally: + crash_log.uninstall_crash_logging() + sys.excepthook = previous diff --git a/tests/test_shortcut_preview.py b/tests/test_shortcut_preview.py index 98e0a79..0bad27c 100644 --- a/tests/test_shortcut_preview.py +++ b/tests/test_shortcut_preview.py @@ -147,3 +147,66 @@ def test_preview_panel_reports_unresolved_shortcut(monkeypatch, tmp_path) -> Non assert panel.preview_stack.currentWidget() is panel.unsupported_label assert "konnte nicht aufgelöst werden" in panel.unsupported_label.text() assert panel.metadata_panel.name_label.text() == link.name + + +def _build_lnk(local_path: str, *, env_target: str | None = None) -> bytes: + """Erzeugt eine minimale MS-SHLLINK-Datei mit LinkInfo bzw. Env-Block.""" + import struct + + flags = 0x80 # IsUnicode + body = b"" + if env_target is None: + flags |= 0x02 # HasLinkInfo + volume = struct.pack("<4I", 0x11, 3, 0, 0x10) + b"\x00" + header_size = 0x1C + volume_offset = header_size + base_offset = volume_offset + len(volume) + base = local_path.encode("cp1252") + b"\x00" + suffix_offset = base_offset + len(base) + suffix = b"\x00" + size = suffix_offset + len(suffix) + body += struct.pack( + "<7I", size, header_size, 0x01, volume_offset, base_offset, 0, suffix_offset + ) + volume + base + suffix + else: + flags |= 0x200 # HasExpString + ansi = env_target.encode("cp1252").ljust(260, b"\x00") + wide = env_target.encode("utf-16-le").ljust(520, b"\x00") + body += struct.pack("<2I", 0x314, 0xA0000001) + ansi + wide + body += struct.pack(" None: + link = tmp_path / "Tool.lnk" + link.write_bytes(_build_lnk(r"C:\Tools\Täst\app.exe")) + assert shortcut_utils.read_shortcut_target_native(str(link)) == r"C:\Tools\Täst\app.exe" + + +def test_native_parser_reads_environment_block(tmp_path) -> None: + link = tmp_path / "Env.lnk" + link.write_bytes(_build_lnk("", env_target=r"%OneDrive%\Dokumente")) + assert shortcut_utils.read_shortcut_target_native(str(link)) == r"%OneDrive%\Dokumente" + + +def test_native_parser_rejects_non_shortcuts(tmp_path) -> None: + junk = tmp_path / "kaputt.lnk" + junk.write_bytes(b"stub") + assert shortcut_utils.read_shortcut_target_native(str(junk)) is None + assert shortcut_utils.parse_shortcut_target(b"\x4c\x00\x00\x00" + b"\x00" * 100) is None + + +def test_resolution_prefers_native_parser_over_powershell(monkeypatch, tmp_path) -> None: + link = tmp_path / "Tool.lnk" + link.write_bytes(_build_lnk(r"C:\Tools\app.exe")) + monkeypatch.setattr(shortcut_utils.sys, "platform", "win32", raising=False) + monkeypatch.setattr(shortcut_utils, "_read_shortcut_target_with_win32com", lambda path: None) + + def fail(path): + raise AssertionError("PowerShell darf nicht gestartet werden") + + monkeypatch.setattr(shortcut_utils, "_read_shortcut_target_with_powershell", fail) + assert shortcut_utils.resolve_windows_shortcut_target(str(link)) == r"C:\Tools\app.exe" diff --git a/tests/test_tooltip_language.py b/tests/test_tooltip_language.py index 61dc443..4c99a35 100644 --- a/tests/test_tooltip_language.py +++ b/tests/test_tooltip_language.py @@ -45,7 +45,7 @@ def test_language_survives_dialog_save_and_restart(isolated_settings, monkeypatc try: assert reopened.language_cb.currentData() == language assert reopened.start_folder_edit.toolTip() == tr.translations["Standardverzeichnis beim Programmstart"][language] - assert reopened.language_cb.toolTip().splitlines()[1] == tr.translations["Sprachänderungen werden beim nächsten Start übernommen."][language] + assert reopened.language_cb.toolTip().splitlines()[1] == tr.translations["Sprachänderungen werden sofort übernommen; einzelne Texte erst nach einem Neustart."][language] assert app is QApplication.instance() finally: shiboken6.delete(reopened) diff --git a/translator.py b/translator.py index 3a89091..f5439a3 100644 --- a/translator.py +++ b/translator.py @@ -43,6 +43,12 @@ } +# "📂 Öffnen", "⚠️ Hinweis", "• Eintrag": Symbolpräfix ohne Buchstaben/Ziffern. +_PREFIX_RE = re.compile(r"^([^\w(\[\"'„“]+\s+)(\S.*)$", re.S) +# "Name:", "Neue Datei...", "Zurück (Alt+Left)", "Suche …". +_SUFFIX_RE = re.compile(r"^(.*?)(\s*(?:\.\.\.|…|:|\([^()]*\))\s*)$", re.S) + + def detect_system_language() -> str: """Ermittelt die Systemsprache, Fallback auf 'de'.""" try: @@ -104,6 +110,9 @@ def __init__(self, default_lang: str = DEFAULT_LANGUAGE, app_dir: Optional[Path] ] self.translations: Dict[str, Dict[str, str]] = {} + self.missing_keys: Set[str] = set() + self._reverse_index: Optional[Dict[str, Dict[str, str]]] = None + self._listeners: list = [] self._load_translations() def _load_translations(self): @@ -111,12 +120,15 @@ def _load_translations(self): try: with open(self.translations_file, "r", encoding="utf-8") as f: self.translations = json.load(f) + if not isinstance(self.translations, dict): + self.translations = {} except Exception: self.translations = {} else: self.translations = {} def _save_translations(self): + self._reverse_index = None self.translations_file.parent.mkdir(parents=True, exist_ok=True) with open(self.translations_file, "w", encoding="utf-8") as f: json.dump(self.translations, f, indent=2, ensure_ascii=False) @@ -140,16 +152,147 @@ def t(self, key: str) -> str: return value return key + # Dekorierte Varianten ("🟢 Grün", "Name:", "Neue Datei...") über + # den Kern-Schlüssel auflösen. + decorated = self._translate_parts(key, 0, self.current_lang) + if decorated is not None: + return decorated + + # Fehlende Schlüssel nur im Speicher vormerken: Zur Laufzeit darf der + # Katalog nicht geschrieben werden (im Store-/EXE-Paket schreibgeschützt, + # ein PermissionError würde sonst die aufrufende GUI-Aktion abbrechen). + # Neue Strings pflegt `python manage_translations.py`. if self._is_german(key): - self.translations[key] = self._new_translation_entry(key, "") - self._save_translations() + self.missing_keys.add(key) + + return key + + # ===== Übersetzung zusammengesetzter GUI-Texte ===== + + def has_key(self, key: str) -> bool: + """True, wenn *key* als Eintrag im Katalog existiert.""" + return isinstance(self.translations.get(key), dict) + def _lookup(self, key: str) -> Optional[str]: + """Übersetzt *key* ohne Seiteneffekte; None, wenn kein Katalogeintrag existiert.""" + entry = self.translations.get(key) + if not isinstance(entry, dict): + return None + for lang in (self.current_lang, *FALLBACK_CHAIN): + value = entry.get(lang) + if isinstance(value, str) and value.strip(): + return value return key + def _source_for(self, text: str, source_lang: Optional[str] = None) -> Optional[str]: + """Findet den deutschen Schlüssel zu *text*. + + Akzeptiert den Schlüssel selbst oder seine Übersetzung in der *aktiven* + Sprache (z. B. bereits per ``t()`` übersetzte Tooltips). Werte anderer + Sprachen werden bewusst nicht zurückgeführt, damit Nutzerdaten wie + Ordnernamen in der aktiven Sprache unverändert bleiben. + """ + if self.has_key(text): + return text + lang = source_lang or self.current_lang + if self._reverse_index is None: + self._reverse_index = {} + reverse = self._reverse_index.get(lang) + if reverse is None: + reverse = {} + for key, entry in self.translations.items(): + if not isinstance(entry, dict): + continue + value = entry.get(lang) + if isinstance(value, str) and value.strip(): + reverse.setdefault(value, key) + self._reverse_index[lang] = reverse + return reverse.get(text) + + def source_text(self, text: str) -> Optional[str]: + """Öffentliche Variante von :meth:`_source_for` für den UI-Übersetzer.""" + return self._source_for(text) if text else None + + def translate_text(self, text: str) -> str: + """Übersetzt einen GUI-Text tolerant gegenüber Präfixen und Suffixen. + + Neben exakten Katalogtreffern werden typische Dekorationen erkannt und + beibehalten: Emoji-/Symbolpräfixe ("📂 Öffnen"), Doppelpunkte + ("Name:"), Auslassungspunkte ("Neue Datei..."), Tastenkürzel in + Klammern ("Zurück (Alt+Left)"), Mnemonics ("&Datei") und mehrzeilige + Texte. Ohne Treffer wird *text* unverändert zurückgegeben. + """ + return self.translate_text_from(text, self.current_lang) + + def translate_text_from(self, text: str, source_lang: str) -> str: + """Wie :meth:`translate_text`, wobei *text* in *source_lang* vorliegen darf.""" + result = self._translate_parts(text, 0, source_lang) + return text if result is None else result + + def _translate_parts(self, text: str, depth: int, source_lang: str) -> Optional[str]: + if not text or not text.strip() or depth > 4: + return None + + source = self._source_for(text, source_lang) + if source is not None: + return self._lookup(source) + + if "\n" in text: + lines = text.split("\n") + translated = [self._translate_parts(line, depth + 1, source_lang) for line in lines] + if any(item is not None for item in translated): + return "\n".join( + item if item is not None else line for line, item in zip(lines, translated) + ) + return None + + stripped = text.strip() + if stripped != text: + inner = self._translate_parts(stripped, depth + 1, source_lang) + if inner is None: + return None + start = text.index(stripped) + return text[:start] + inner + text[start + len(stripped):] + + if "&" in text and "&&" not in text: + plain = text.replace("&", "", 1) + inner = self._translate_parts(plain, depth + 1, source_lang) + if inner is not None: + return "&" + inner if not inner.startswith("&") else inner + + match = _PREFIX_RE.match(text) + if match: + inner = self._translate_parts(match.group(2), depth + 1, source_lang) + if inner is not None: + return match.group(1) + inner + + match = _SUFFIX_RE.match(text) + if match and match.group(1).strip(): + inner = self._translate_parts(match.group(1), depth + 1, source_lang) + if inner is not None: + return inner + match.group(2) + + return None + + def add_language_listener(self, callback) -> None: + """Registriert einen Callback, der nach jedem Sprachwechsel aufgerufen wird.""" + if callback not in self._listeners: + self._listeners.append(callback) + + def remove_language_listener(self, callback) -> None: + if callback in self._listeners: + self._listeners.remove(callback) + def set_language(self, lang: str): - """Setzt die aktive Zielsprache.""" - if lang in SUPPORTED_LANGUAGES: + """Setzt die aktive Zielsprache und benachrichtigt registrierte Listener.""" + if lang in SUPPORTED_LANGUAGES and lang != self.current_lang: self.current_lang = lang + for callback in list(self._listeners): + try: + callback(lang) + except Exception: + # Ein defekter Listener darf den Sprachwechsel nicht blockieren. + pass def get_language(self) -> str: """Liefert die aktive Zielsprache zurück."""