From dfea7b7bc4cd929eecc0185a1ea6179cba40d5e6 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Wed, 7 Oct 2026 23:33:00 +0200 Subject: [PATCH 1/2] Release ExplorerPro 1.0.8 with safe properties queries and privacy export fixes --- CHANGELOG.md | 19 ++ CLAUDE.md | 2 +- ExplorerPro.spec | 15 +- README.md | 4 +- README_de.md | 4 +- build_exe.bat | 31 +-- llms.txt | 2 +- locales/translations.json | 8 + pyproject.toml | 2 +- .../windowsstore/v1.0.8/release-notes.json | 4 + scripts/build_release.py | 32 +++ src/core/properties_details.py | 69 +++++++ src/core/ui_translator.py | 3 + src/gui/properties_dialog.py | 189 ++++++++++-------- src/main.py | 8 + src/modules/privacy/term_io.py | 6 +- src/release_smoke.py | 145 ++++++++++++++ store_package.json | 2 +- tests/test_metadata_contract.py | 13 +- tests/test_properties_and_terminal.py | 5 +- tests/test_release_review_regressions.py | 81 ++++++++ tests/test_store_materials.py | 10 +- 22 files changed, 511 insertions(+), 143 deletions(-) create mode 100644 releases/windowsstore/v1.0.8/release-notes.json create mode 100644 scripts/build_release.py create mode 100644 src/core/properties_details.py create mode 100644 src/release_smoke.py create mode 100644 tests/test_release_review_regressions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 261cf8d..aec0c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +## [1.0.8] - 2026-10-07 + +ExplorerPro 1.0.8 +• Cloud-Ordner und Windows-Verknüpfungen erscheinen wieder in der Dateiliste; lange Ordnernamen bleiben lesbar. +• Die Vorschau ist stabiler und lädt Dateien, die nur online verfügbar sind, nicht ungefragt herunter. +• Blacklist und Whitelist lassen sich bearbeiten sowie als TXT, CSV oder XLSX importieren und exportieren. Begriffe mit führendem „=“ bleiben im XLSX-Export erhalten. +• Die Spracheinstellung wirkt auf mehr Bedienelemente. Gespeicherte Kategorien werden beim Sprachwechsel nicht mehr verändert. +• Eigenschaften öffnen ohne automatische Inhaltsprüfung. Prüfsummen und Text-/Ordnerstatistik starten auf Wunsch; Ordnerabfragen laufen mit Zeitlimit. Unvollständige Statistiken sind gekennzeichnet. +• Mehrfachumbenennung, Löschbestätigung und das Beenden laufender Prüfsummen wurden robuster. + +ExplorerPro 1.0.8 +• Cloud folders and Windows shortcuts appear in the file list again; long folder names remain readable. +• Preview is more stable and does not automatically download online-only files. +• Edit and import/export privacy blacklists and whitelists as TXT, CSV or XLSX. XLSX exports preserve terms starting with '='. +• The language setting updates more controls. Switching languages no longer changes saved categories. +• Properties open without automatic content reads. Start checksums and text/folder statistics when needed; folder queries have a time limit and incomplete statistics are marked. +• More reliable batch renaming, deletion confirmation and checksum shutdown. + + ### 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. diff --git a/CLAUDE.md b/CLAUDE.md index 15379e3..f9aa0b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ name: ExplorerPro type: project-docs profile: STANDARD -version: 1.0.7 +version: 1.0.8 created: 2026-07-22 updated: 2026-09-26 reason_last_change: "T-20260926-912169808: Kontextmenü-Tags/Metadaten, Editor-Dirty-Flag (modificationChanged), qtbase-Übersetzung, 359 Tests" diff --git a/ExplorerPro.spec b/ExplorerPro.spec index 57470d8..2d043a4 100644 --- a/ExplorerPro.spec +++ b/ExplorerPro.spec @@ -1,6 +1,8 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path +import json +import os project_root = Path.cwd() src_dir = project_root / 'src' @@ -25,18 +27,7 @@ a = Analysis( hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=[ - 'IPython', - 'black', - 'blib2to3', - 'cv2', - 'matplotlib', - 'notebook', - 'pytest', - 'scipy', - 'sklearn', - 'torch', - ], + excludes=json.loads(os.environ["PYINSTALLER_EXCLUDES"]), noarchive=False, optimize=0, ) diff --git a/README.md b/README.md index 97ce8aa..95f3f70 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ [![Umbrella: open--bricks](https://img.shields.io/badge/umbrella-open--bricks-blue.svg)](https://github.com/open-bricks) [![Microsoft Store](https://img.shields.io/badge/Microsoft%20Store-Live-0078D7.svg?logo=windows)](https://apps.microsoft.com/detail/9P0X52WSHZ3Q) [![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-success.svg)](llms.txt) -[![Version: 1.0.7](https://img.shields.io/badge/version-1.0.7-orange.svg)](CHANGELOG.md) +[![Version: 1.0.8](https://img.shields.io/badge/version-1.0.8-orange.svg)](CHANGELOG.md) [![Last Checked](https://img.shields.io/badge/Last--Checked-2026--09--30-blue?style=flat-square)](CHANGELOG.md) > [!NOTE] @@ -64,7 +64,7 @@ ## 1. Features & Core Capabilities -The source version includes per-drive capacity bars in the folder sidebar, with used percentage and free/used/total space in GiB/TiB. Capacity requests run in the background; use **Refresh drive usage** to update them. Unavailable devices show an explanatory message. This addition is not yet part of a newly packaged release. +The source version includes per-drive capacity bars in the folder sidebar, with used percentage and free/used/total space in GiB/TiB. Capacity requests run in the background; use **Refresh drive usage** to update them. Unavailable devices show an explanatory message. This functionality is included in the 1.0.8 release package. Standard operating system file managers are built for casual browsing and lack the heavy-lifting tools developers, researchers, and power users require daily. ExplorerPro addresses this gap by packaging pro-grade productivity utilities into a cohesive, responsive desktop interface with zero telemetry and 100% Local-First data isolation: diff --git a/README_de.md b/README_de.md index ce9d3e0..d19a8ca 100644 --- a/README_de.md +++ b/README_de.md @@ -2,7 +2,7 @@ # ExplorerPro Suite -Der aktuelle Quellcode zeigt in der Ordner-Seitenleiste die Speicherbelegung je Laufwerk: Balken, Prozentanteil und freier/belegter/gesamter Speicher in GiB/TiB. Die Abfrage läuft im Hintergrund; **Laufwerksbelegung aktualisieren** erneuert die Werte. Nicht verfügbare Geräte werden verständlich gekennzeichnet. Diese Ergänzung ist noch nicht Bestandteil eines neu gebauten Releases. +Der aktuelle Quellcode zeigt in der Ordner-Seitenleiste die Speicherbelegung je Laufwerk: Balken, Prozentanteil und freier/belegter/gesamter Speicher in GiB/TiB. Die Abfrage läuft im Hintergrund; **Laufwerksbelegung aktualisieren** erneuert die Werte. Nicht verfügbare Geräte werden verständlich gekennzeichnet. Diese Funktion ist im Release-Paket 1.0.8 enthalten. [English](README.md) | **[Deutsch](README_de.md)** | [Maschinenlesbarer Kontext (llms.txt)](llms.txt) @@ -23,7 +23,7 @@ Der aktuelle Quellcode zeigt in der Ordner-Seitenleiste die Speicherbelegung je [![Dachverband: open--bricks](https://img.shields.io/badge/Dachverband-open--bricks-blue.svg)](https://github.com/open-bricks) [![Microsoft Store](https://img.shields.io/badge/Microsoft%20Store-Live-0078D7.svg?logo=windows)](https://apps.microsoft.com/detail/9P0X52WSHZ3Q) [![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-success.svg)](llms.txt) -[![Version: 1.0.7](https://img.shields.io/badge/Version-1.0.7-orange.svg)](CHANGELOG.md) +[![Version: 1.0.8](https://img.shields.io/badge/Version-1.0.8-orange.svg)](CHANGELOG.md) [![Stand](https://img.shields.io/badge/Stand-2026--09--30-blue?style=flat-square)](CHANGELOG.md) > [!NOTE] diff --git a/build_exe.bat b/build_exe.bat index 0d11bd8..a848d0a 100644 --- a/build_exe.bat +++ b/build_exe.bat @@ -1,34 +1,7 @@ @echo off chcp 65001 >nul setlocal - cd /d "%~dp0" - -set "BUILD_ROOT=C:\_Local_DEV\codex_build\explorerpro" -set "WORK_PATH=%BUILD_ROOT%\work" - -if not exist "%BUILD_ROOT%" mkdir "%BUILD_ROOT%" -if not exist "%WORK_PATH%" mkdir "%WORK_PATH%" - -echo [INFO] Baue ExplorerPro.exe mit lokalem PyInstaller-Workpath... -python -m PyInstaller --clean --noconfirm --workpath "%WORK_PATH%" --distpath "%CD%\dist" ExplorerPro.spec -if errorlevel 1 ( - echo [FEHLER] PyInstaller-Build fehlgeschlagen. - exit /b 1 -) - -if not exist "%CD%\dist\ExplorerPro\ExplorerPro.exe" ( - echo [FEHLER] Erwartete EXE nicht gefunden: dist\ExplorerPro\ExplorerPro.exe - exit /b 1 -) - -for %%F in (LICENSE THIRD_PARTY_LICENSES.txt PRIVACY_POLICY.md SUPPORT.md) do ( - copy /Y "%CD%\%%F" "%CD%\dist\ExplorerPro\%%F" >nul - if errorlevel 1 ( - echo [FEHLER] Notice-Datei konnte nicht ins Bundle kopiert werden: %%F - exit /b 1 - ) -) - -echo [OK] EXE erstellt: dist\ExplorerPro\ExplorerPro.exe +python scripts\build_release.py +if errorlevel 1 exit /b 1 endlocal diff --git a/llms.txt b/llms.txt index 3a2509a..12ad92a 100644 --- a/llms.txt +++ b/llms.txt @@ -8,7 +8,7 @@ License: AGPL-3.0 Microsoft Store Identity: Geiger.ExplorerPro (Store-ID: 9P0X52WSHZ3Q) Last-checked: 2026-09-30 (Previous: 2026-09-29) -Version: 1.0.7 +Version: 1.0.8 ## Search Phrases diff --git a/locales/translations.json b/locales/translations.json index f347423..8ea3ebc 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -8398,5 +8398,13 @@ "zh": "密码提示", "ja": "パスワードの手がかり", "ru": "Признаки паролей" + }, + "Nur online verfügbar. Datei zuerst lokal verfügbar machen.": { + "de": "Nur online verfügbar. Datei zuerst lokal verfügbar machen.", + "en": "Available online only. Make the file available locally first.", + "es": "Disponible solo en línea. Haga que el archivo esté disponible localmente primero.", + "zh": "仅在线可用。请先将文件下载到本地。", + "ja": "オンラインでのみ利用可能です。先にファイルをローカルで利用できるようにしてください。", + "ru": "Доступно только онлайн. Сначала сделайте файл доступным локально." } } diff --git a/pyproject.toml b/pyproject.toml index 9bdf87a..669aeb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "explorerpro" -version = "1.0.7" +version = "1.0.8" description = "Datei-Explorer-Suite mit Editor, PDF-Viewer und Datenanalyse" authors = [{ name = "Lukas Geiger", email = "support@lukasgeiger.com" }] requires-python = ">=3.10" diff --git a/releases/windowsstore/v1.0.8/release-notes.json b/releases/windowsstore/v1.0.8/release-notes.json new file mode 100644 index 0000000..a0bf440 --- /dev/null +++ b/releases/windowsstore/v1.0.8/release-notes.json @@ -0,0 +1,4 @@ +{ + "de-de": "ExplorerPro 1.0.8\n• Cloud-Ordner und Windows-Verknüpfungen erscheinen wieder in der Dateiliste; lange Ordnernamen bleiben lesbar.\n• Die Vorschau ist stabiler und lädt Dateien, die nur online verfügbar sind, nicht ungefragt herunter.\n• Blacklist und Whitelist lassen sich bearbeiten sowie als TXT, CSV oder XLSX importieren und exportieren. Begriffe mit führendem „=“ bleiben im XLSX-Export erhalten.\n• Die Spracheinstellung wirkt auf mehr Bedienelemente. Gespeicherte Kategorien werden beim Sprachwechsel nicht mehr verändert.\n• Eigenschaften öffnen ohne automatische Inhaltsprüfung. Prüfsummen und Text-/Ordnerstatistik starten auf Wunsch; Ordnerabfragen laufen mit Zeitlimit. Unvollständige Statistiken sind gekennzeichnet.\n• Mehrfachumbenennung, Löschbestätigung und das Beenden laufender Prüfsummen wurden robuster.", + "en-us": "ExplorerPro 1.0.8\n• Cloud folders and Windows shortcuts appear in the file list again; long folder names remain readable.\n• Preview is more stable and does not automatically download online-only files.\n• Edit and import/export privacy blacklists and whitelists as TXT, CSV or XLSX. XLSX exports preserve terms starting with '='.\n• The language setting updates more controls. Switching languages no longer changes saved categories.\n• Properties open without automatic content reads. Start checksums and text/folder statistics when needed; folder queries have a time limit and incomplete statistics are marked.\n• More reliable batch renaming, deletion confirmation and checksum shutdown." +} diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100644 index 0000000..8a5ea7c --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,32 @@ +"""Build via the pipeline scanner and the same Python as PyInstaller.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +root = Path(__file__).resolve().parents[1] +config = json.loads((root / "store_package.json").read_text(encoding="utf-8")) +app_name = config["app_name"] +tools_root = Path(os.environ.get("SOFTWARE_TOOLS_ROOT", + str(Path(os.environ.get("OneDrive", str(Path.home() / "OneDrive"))) / ".TOPICS" / ".SOFTWARE" / "_tools"))) +scanner = tools_root / "build_exclude_scanner.py" +if not scanner.is_file(): + raise SystemExit("Missing required pipeline scanner: " + str(scanner)) +result = subprocess.run([sys.executable, str(scanner), "--project", str(root), "--emit", "json"], + check=True, capture_output=True, text=True, encoding="utf-8") +print(result.stderr, file=sys.stderr) +report = json.loads(result.stdout) +build_root = Path(os.environ.get("BUILD_ROOT", str(Path("C:/_Local_DEV/codex_build") / app_name.lower()))) +build_root.mkdir(parents=True, exist_ok=True) +(build_root / "exclude-report.json").write_text(result.stdout, encoding="utf-8") +env = os.environ.copy() +env["PYINSTALLER_EXCLUDES"] = json.dumps(sorted(set(report["excludes"]) | {"pytest", "ruff", "PyInstaller"})) +subprocess.run([sys.executable, "-m", "PyInstaller", "--clean", "--noconfirm", + "--workpath", str(build_root / "work"), "--distpath", str(root / "dist"), + str(root / (app_name + ".spec"))], cwd=root, env=env, check=True) + +# Notices must also be visible beside the EXE in the distributable Onedir folder. +import shutil +for notice in ("LICENSE", "THIRD_PARTY_LICENSES.txt", "PRIVACY_POLICY.md", "SUPPORT.md"): + shutil.copy2(root / notice, root / "dist" / app_name / notice) diff --git a/src/core/properties_details.py b/src/core/properties_details.py new file mode 100644 index 0000000..b292582 --- /dev/null +++ b/src/core/properties_details.py @@ -0,0 +1,69 @@ +"""Bounded subprocess queries for properties, without Qt or GUI-thread reads.""" +import json +import os +from pathlib import Path +from core.file_attributes import is_cloud_placeholder + +MAX_ENTRIES = 100_000 + +def _folder_details(folder_path, max_files=MAX_ENTRIES): + files_count = dirs_count = total = 0 + partial = False + def inaccessible(error): + nonlocal partial + partial = True + for root, dirs, files in os.walk(folder_path, followlinks=False, onerror=inaccessible): + dirs_count += len(dirs) + traversable = [] + for name in dirs: + path = os.path.join(root, name) + if (os.path.islink(path) or getattr(os.path, "isjunction", lambda p: False)(path) + or is_cloud_placeholder(path)): + partial = True + else: + traversable.append(name) + dirs[:] = traversable + for name in files: + files_count += 1 + try: + total += os.lstat(os.path.join(root, name)).st_size + except OSError: + partial = True + if files_count + dirs_count >= max_files: + partial = True + break + if files_count + dirs_count >= max_files: + partial = True + break + return {"files": files_count, "folders": dirs_count, "bytes": total, "partial": partial} + +def calculate_folder_stats(folder_path, max_files=MAX_ENTRIES): + result = _folder_details(folder_path, max_files) + return result["files"], result["folders"], result["bytes"] + +def query_details(kind, target): + if is_cloud_placeholder(target): + raise ValueError("Nur online verfügbar. Datei zuerst lokal verfügbar machen.") + if kind == "folder": + if not os.path.isdir(target): + raise NotADirectoryError(target) + return _folder_details(target) + if kind == "text": + # Bound the read even if the file grows after the metadata check. + with open(target, "rb") as handle: + raw = handle.read(5 * 1024 * 1024 + 1) + if len(raw) > 5 * 1024 * 1024: + raise ValueError("Datei > 5 MB") + text = raw.decode("utf-8", errors="ignore") + return {"lines": len(text.splitlines()), "words": len(text.split()), "chars": len(text)} + raise ValueError("Unknown properties query") + +def details_query_main(kind, target, output): + try: + payload = {"result": query_details(kind, target)} + code = 0 + except Exception as exc: + payload = {"error": str(exc)} + code = 1 + Path(output).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + return code diff --git a/src/core/ui_translator.py b/src/core/ui_translator.py index 30e3ba1..375fee7 100644 --- a/src/core/ui_translator.py +++ b/src/core/ui_translator.py @@ -183,6 +183,9 @@ def translate_action(self, action: QAction) -> None: action.setProperty(_RECORD, record) def _translate_combo(self, combo: QComboBox, record: dict) -> bool: + # Editable choices are persisted user data, including catalog-like names. + if combo.isEditable(): + return False changed = False blocked = combo.blockSignals(True) try: diff --git a/src/gui/properties_dialog.py b/src/gui/properties_dialog.py index 3eaf986..6d3469a 100644 --- a/src/gui/properties_dialog.py +++ b/src/gui/properties_dialog.py @@ -14,9 +14,10 @@ import sys from datetime import datetime from pathlib import Path -from typing import Tuple +import json +import tempfile -from PySide6.QtCore import Qt, QSize +from PySide6.QtCore import Qt, QSize, QProcess, QTimer from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QApplication, @@ -33,7 +34,9 @@ QWidget, ) -from core.checksum_service import compute_file_hashes +from core.file_attributes import is_cloud_placeholder +from core.properties_details import calculate_folder_stats # noqa: F401 - public compatibility export +from gui.checksum_dialog import ChecksumDialog from core.file_icon_helper import get_file_icon from core.platform_utils import open_path_with_system from translator import t @@ -55,38 +58,6 @@ def format_size(num_bytes: int) -> str: return f"{num_bytes:,} Bytes ({val_str} TB)".replace(",", ".") -def calculate_folder_stats(folder_path: str, max_files: int = 100_000) -> Tuple[int, int, int]: - """ - Berechnet rekursiv Dateianzahl, Ordneranzahl und Gesamtgröße eines Verzeichnisses. - Gibt (file_count, dir_count, total_bytes) zurück. - """ - file_count = 0 - dir_count = 0 - total_bytes = 0 - - if not os.path.exists(folder_path) or not os.path.isdir(folder_path): - return (0, 0, 0) - - try: - for root, dirs, files in os.walk(folder_path, followlinks=False): - dir_count += len(dirs) - for f in files: - file_count += 1 - fp = os.path.join(root, f) - try: - total_bytes += os.path.getsize(fp) - except (OSError, PermissionError): - pass - if file_count >= max_files: - break - if file_count >= max_files: - break - except (OSError, PermissionError): - pass - - return (file_count, dir_count, total_bytes) - - class FilePropertiesDialog(QDialog): """Eigenschaften-Dialog für Dateien und Ordner mit Detailreitern.""" @@ -106,6 +77,11 @@ def __init__(self, target_path: str, parent: QWidget | None = None): t("Zeigt detaillierte Datei- und Ordnereigenschaften, Zeitstempel, Prüfsummen und Berechtigungen an.") ) + self._details_process = None + self._details_temp = None + self._details_timer = QTimer(self) + self._details_timer.setSingleShot(True) + self._details_timer.timeout.connect(self._details_timeout) self._setup_ui() self._load_properties() @@ -207,13 +183,10 @@ def _load_properties(self) -> None: self.form_general.addRow(QLabel(t("Speicherort:")), location_edit) if self.is_dir: - file_count, dir_count, total_bytes = calculate_folder_stats(self.target_path) - size_str = format_size(total_bytes) - 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) + self.folder_size_label = QLabel("—", self) + self.folder_count_label = QLabel("—", self) + self.form_general.addRow(QLabel(t("Größe:")), self.folder_size_label) + self.form_general.addRow(QLabel(t("Inhalt:")), self.folder_count_label) else: self._add_form_row(t("Größe:"), format_size(st.st_size)) @@ -299,54 +272,102 @@ def _setup_file_checksums(self, file_size: int) -> None: self.layout_checksums.addWidget(group_hash) - # Hashes berechnen (falls Datei <= 50 MB direkt, sonst per Button) - if file_size <= 50 * 1024 * 1024: - try: - hashes = compute_file_hashes(self.target_path, algorithms=("sha256", "md5")) - self.sha256_edit.setText(hashes.get("sha256", "")) - self.md5_edit.setText(hashes.get("md5", "")) - except Exception as 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")) - calc_btn = QPushButton(t("Prüfsummen jetzt berechnen"), self) - calc_btn.clicked.connect(self._compute_large_hashes) - self.layout_checksums.addWidget(calc_btn) - - # Text-Dateistatistik + # Opening properties only reads metadata. Content reads require consent. + self.calc_btn = QPushButton(t("Prüfsummen jetzt berechnen"), self) + self.calc_btn.clicked.connect(self._compute_large_hashes) + self.layout_checksums.addWidget(self.calc_btn) ext = Path(self.target_path).suffix.lower() text_extensions = {".txt", ".py", ".md", ".json", ".xml", ".html", ".css", ".js", ".yaml", ".yml", ".ini", ".log"} if ext in text_extensions and file_size <= 5 * 1024 * 1024: - self._setup_text_stats() + self.text_stats_btn = QPushButton(t("Text-Metriken"), self) + self.text_stats_btn.clicked.connect(lambda: self._start_details("text")) + self.layout_checksums.addWidget(self.text_stats_btn) + if is_cloud_placeholder(self.target_path): + message = t("Nur online verfügbar. Datei zuerst lokal verfügbar machen.") + self.sha256_edit.setPlaceholderText(message) + self.md5_edit.setPlaceholderText(message) + self.calc_btn.setEnabled(False) + if hasattr(self, "text_stats_btn"): + self.text_stats_btn.setEnabled(False) self.layout_checksums.addStretch() def _compute_large_hashes(self) -> None: + if is_cloud_placeholder(self.target_path): + return + dialog = ChecksumDialog(self.target_path, self) + dialog.exec() + hashes = dialog._calculated_hashes + self.sha256_edit.setText(hashes.get("sha256", "")) + self.md5_edit.setText(hashes.get("md5", "")) + dialog.deleteLater() + + def _start_details(self, kind: str) -> None: + if self._details_process is not None or is_cloud_placeholder(self.target_path): + return + self._details_temp = tempfile.TemporaryDirectory(prefix="explorerpro-properties-") + self._details_result = Path(self._details_temp.name) / "result.json" + self._details_kind = kind + process = QProcess(self) + self._details_process = process + process.finished.connect(self._details_finished) + process.errorOccurred.connect(self._details_failed) + args = [] if getattr(sys, "frozen", False) else [str(Path(__file__).resolve().parents[1] / "main.py")] + process.start(sys.executable, args + ["--properties-details-query", kind, self.target_path, str(self._details_result)]) + self._details_timer.start(10_000) + + def _details_finished(self, *args) -> None: + self._details_timer.stop() try: - hashes = compute_file_hashes(self.target_path, algorithms=("sha256", "md5")) - self.sha256_edit.setText(hashes.get("sha256", "")) - self.md5_edit.setText(hashes.get("md5", "")) - except Exception as exc: - QMessageBox.warning(self, t("Prüfsummen-Fehler"), str(exc)) - - def _setup_text_stats(self) -> None: - try: - with open(self.target_path, "r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines() - line_count = len(lines) - word_count = sum(len(line.split()) for line in lines) - char_count = sum(len(line) for line in lines) - - group_text = QGroupBox(t("Text-Metriken"), self) - form_text = QFormLayout(group_text) - form_text.addRow(QLabel(t("Zeilen:")), QLabel(f"{line_count:,}".replace(",", "."))) - form_text.addRow(QLabel(t("Wörter:")), QLabel(f"{word_count:,}".replace(",", "."))) - form_text.addRow(QLabel(t("Zeichen:")), QLabel(f"{char_count:,}".replace(",", "."))) - self.layout_checksums.addWidget(group_text) - except Exception: - pass + payload = json.loads(self._details_result.read_text(encoding="utf-8")) + if "error" in payload: + raise ValueError(payload["error"]) + result = payload["result"] + if self._details_kind == "folder": + prefix = "≥ " if result["partial"] else "" + self.folder_size_label.setText(prefix + format_size(result["bytes"])) + self.folder_count_label.setText(prefix + t("{files} Dateien, {folders} Ordner").format(files=result["files"], folders=result["folders"])) + else: + group = QGroupBox(t("Text-Metriken"), self) + form = QFormLayout(group) + for key, label in (("lines", "Zeilen:"), ("words", "Wörter:"), ("chars", "Zeichen:")): + form.addRow(QLabel(t(label)), QLabel(str(result[key]))) + self.layout_checksums.insertWidget(self.layout_checksums.count() - 1, group) + self.text_stats_btn.setEnabled(False) + except (OSError, ValueError, KeyError) as exc: + self.type_subtitle.setText(t("Fehler: {error}").format(error=exc)) + finally: + self._cleanup_details() + + def _details_failed(self, *args) -> None: + self._details_timer.stop() + self.type_subtitle.setText(t("Fehler: {error}").format(error="properties query failed")) + self._cleanup_details() + + def _details_timeout(self) -> None: + self.type_subtitle.setText(t("Fehler: {error}").format(error="properties query timeout")) + self._cleanup_details() + + def _cleanup_details(self) -> None: + self._details_timer.stop() + process, self._details_process = self._details_process, None + if process is not None: + process.blockSignals(True) + if process.state() != QProcess.ProcessState.NotRunning: + process.kill() + process.waitForFinished(1000) + process.deleteLater() + if self._details_temp is not None: + self._details_temp.cleanup() + self._details_temp = None + + def done(self, result) -> None: + self._cleanup_details() + super().done(result) + + def closeEvent(self, event) -> None: + self._cleanup_details() + super().closeEvent(event) def _setup_folder_details(self) -> None: group_info = QGroupBox(t("Ordner-Struktur"), self) @@ -354,6 +375,10 @@ def _setup_folder_details(self) -> None: rel_path = os.path.relpath(self.target_path, os.path.dirname(self.target_path)) form_info.addRow(QLabel(t("Relativer Pfad:")), QLabel(rel_path)) self.layout_checksums.addWidget(group_info) + self.folder_stats_btn = QPushButton(t("Ordner-Struktur"), self) + self.folder_stats_btn.clicked.connect(lambda: self._start_details("folder")) + self.folder_stats_btn.setEnabled(not is_cloud_placeholder(self.target_path)) + self.layout_checksums.addWidget(self.folder_stats_btn) self.layout_checksums.addStretch() def _copy_full_path(self) -> None: diff --git a/src/main.py b/src/main.py index b9f4eca..674cc26 100644 --- a/src/main.py +++ b/src/main.py @@ -26,6 +26,10 @@ from core.drive_usage import capacity_query_main sys.exit(capacity_query_main(sys.argv[2], sys.argv[3])) +if __name__ == '__main__' and len(sys.argv) == 5 and sys.argv[1] == '--properties-details-query': + from core.properties_details import details_query_main + sys.exit(details_query_main(sys.argv[2], sys.argv[3], sys.argv[4])) + from PySide6.QtWidgets import QApplication from PySide6.QtCore import Qt, QTranslator, QLibraryInfo from PySide6.QtGui import QIcon @@ -103,6 +107,10 @@ def configure_application_language(): def main(): """Haupteinstiegspunkt für ExplorerPro""" + if len(sys.argv) == 3 and sys.argv[1] == "--release-smoke": + from release_smoke import run_release_smoke + run_release_smoke(sys.argv[2]) + return # High DPI Support QApplication.setHighDpiScaleFactorRoundingPolicy( Qt.HighDpiScaleFactorRoundingPolicy.PassThrough diff --git a/src/modules/privacy/term_io.py b/src/modules/privacy/term_io.py index 4bb20e7..c04fc91 100644 --- a/src/modules/privacy/term_io.py +++ b/src/modules/privacy/term_io.py @@ -82,8 +82,10 @@ def write_terms(path: str, terms: Iterable[str]) -> None: workbook = openpyxl.Workbook() sheet = workbook.active sheet.title = "Begriffe" - for item in items: - sheet.append([item]) + for row, item in enumerate(items, start=1): + cell = sheet.cell(row=row, column=1, value=item) + # Terms are literal data, including leading "=" characters. + cell.data_type = "s" workbook.save(file_path) return diff --git a/src/release_smoke.py b/src/release_smoke.py new file mode 100644 index 0000000..b9a7d62 --- /dev/null +++ b/src/release_smoke.py @@ -0,0 +1,145 @@ +"""Native acceptance of the built app using only isolated fixture data.""" +import json +import os +import sys +import time +from pathlib import Path + +def run_release_smoke(output): + root = Path(output).resolve() + root.mkdir(parents=True, exist_ok=True) + profile = root / "profile" + profile.mkdir(exist_ok=True) + for name, value in (("USERPROFILE", profile), ("HOME", profile), + ("APPDATA", profile / "AppData/Roaming"), + ("LOCALAPPDATA", profile / "AppData/Local")): + os.environ[name] = str(value) + os.environ.pop("QT_QPA_PLATFORM", None) + from PySide6.QtCore import QSettings + from PySide6.QtWidgets import QApplication + QSettings.setDefaultFormat(QSettings.IniFormat) + for scope in (QSettings.UserScope, QSettings.SystemScope): + QSettings.setPath(QSettings.IniFormat, scope, str(root / "qsettings")) + from core.settings_manager import SettingsManager + SettingsManager._instance = None + SettingsManager._get_config_path = lambda self: root / "settings.json" + fixtures = root / "fixtures" + fixtures.mkdir(exist_ok=True) + txt = fixtures / "Überblick äöü.txt" + txt.write_text("Projektüberblick\nÄpfel, Öl und Grüße bleiben lokal.\n", encoding="utf-8") + (root / "settings.json").write_text(json.dumps({ + "general": {"start_folder": str(fixtures), "language": "de"}, + "privacy": {"enable_clipboard_monitor": False}, + "index": {"auto_index": False, "index_on_startup": False}, + }), encoding="utf-8") + from main import (load_app_icon, install_qt_translations, install_runtime_translation, + configure_application_language, set_application_version) + from core.gui_gc import install_gui_gc + from core.ui_translator import uninstall_ui_translator + from gui.sidebar.drive_capacity import shutdown_capacity_executor + from app import ExplorerProApp + from version import __version__ + app = QApplication.instance() or QApplication([]) + if app.platformName() != "windows": + raise RuntimeError("Native Windows Qt required.") + app.setApplicationName("ExplorerPro") + app.setOrganizationName("ExplorerPro") + set_application_version(app) + translator = configure_application_language() + install_qt_translations(app, "de") + install_runtime_translation(app, translator) + collector = install_gui_gc(app) + icon = load_app_icon() + if icon.isNull(): + raise RuntimeError("Runtime icon missing.") + app.setWindowIcon(icon) + window = None + checks, screenshots = [], [] + def events(seconds=0.1): + until = time.monotonic() + seconds + while time.monotonic() < until: + app.processEvents() + time.sleep(0.005) + def shot(name): + target = root / (name + ".png") + if not window.grab().save(str(target)): + raise RuntimeError("Screenshot could not be saved.") + screenshots.append(str(target)) + try: + window = ExplorerProApp() + window.setWindowIcon(icon) + window.show() + events(1) + window.file_browser.navigate_to(str(fixtures)) + events(1) + window.preview_panel.show_preview(str(txt)) + events() + if not window.sidebar.isVisible() or window.sidebar.width() == 0: + raise RuntimeError("Sidebar is collapsed.") + if not window.preview_panel.isVisible() or window.preview_panel.width() == 0: + raise RuntimeError("Preview is collapsed.") + if "Äpfel" not in window.preview_panel.text_preview.toPlainText(): + raise RuntimeError("Text preview lost Unicode content.") + checks += ["sidebar", "text-preview", "umlauts"] + shot("text-preview") + import fitz + pdf = fixtures / "Übersicht.pdf" + with fitz.open() as doc: + page = doc.new_page() + page.insert_text((72, 72), "ExplorerPro PDF smoke") + doc.save(pdf) + window.preview_panel.show_preview(str(pdf)) + events() + if window.preview_panel.pdf_preview.content.pixmap().isNull(): + raise RuntimeError("PDF preview is empty.") + checks.append("pdf-preview") + shot("pdf-preview") + from openpyxl import Workbook + xlsx = fixtures / "Äpfel.xlsx" + book = Workbook() + book.active.append(["Grüße", 42]) + book.save(xlsx) + window.preview_panel.show_preview(str(xlsx)) + events() + excel = window.preview_panel.excel_preview + if window.preview_panel.preview_stack.currentWidget() is not excel: + raise RuntimeError("XLSX preview fell back instead of rendering.") + if excel.table.rowCount() != 1 or excel.table.item(0, 0).text() != "Grüße" or excel.table.item(0, 1).text() != "42": + raise RuntimeError("XLSX preview lost fixture cell contents.") + checks.append("xlsx-preview") + shot("xlsx-preview") + translator.set_language("en") + events() + shot("language-en") + translator.set_language("de") + from gui.privacy_dialog import PrivacySettingsDialog + dialog = PrivacySettingsDialog(window.privacy_monitor, window, SettingsManager.instance()) + dialog.blacklist_editor.set_terms(["=ProjektX", "Äpfel"]) + dialog.whitelist_editor.set_terms(["Öffentlich"]) + dialog.apply_settings() + if set(window.privacy_monitor.blacklist) != {"=ProjektX", "Äpfel"}: + raise RuntimeError("Blacklist did not persist.") + dialog.close() + checks.append("privacy-lists") + from gui.properties_dialog import FilePropertiesDialog + props = FilePropertiesDialog(str(fixtures), window) + props.folder_stats_btn.click() + until = time.monotonic() + 15 + while props._details_process is not None and time.monotonic() < until: + events() + if props._details_process is not None or "—" in props.folder_size_label.text(): + raise RuntimeError("Properties subprocess did not return statistics.") + props.close() + checks.append("properties-helper") + summary = {"version": __version__, "qt_platform": app.platformName(), + "frozen": bool(getattr(sys, "frozen", False)), + "checks": checks, "screenshots": screenshots} + (root / "release-smoke.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + return summary + finally: + if window is not None: + window.close() + app.processEvents() + uninstall_ui_translator(app) + shutdown_capacity_executor() + collector.close() diff --git a/store_package.json b/store_package.json index a8564e1..7da318d 100644 --- a/store_package.json +++ b/store_package.json @@ -3,7 +3,7 @@ "publisher": "CN=52596601-BAB4-4F3F-B182-E8F3F273B202", "publisher_display": "Geiger", "identity_name": "Geiger.ExplorerPro", - "version": "1.0.7.0", + "version": "1.0.8.0", "description": "Lokale Explorer-Suite mit Mehrtab-Dateibrowser, Vorschau, Datenschutzprüfung, Duplikatsuche und Offline-Workflow.", "executable": "ExplorerPro.exe", "capabilities": "runFullTrust", diff --git a/tests/test_metadata_contract.py b/tests/test_metadata_contract.py index 00c09e6..29b0e1c 100644 --- a/tests/test_metadata_contract.py +++ b/tests/test_metadata_contract.py @@ -17,6 +17,7 @@ """ import json +import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -228,7 +229,9 @@ def test_llms_txt_structure(): assert "file-bricks/ExplorerPro" in content assert "PySide6" in content assert any(d in content for d in ("Last-checked: 2026-09-30", "Last-checked: 2026-09-29")) - assert "1.0.7" in content + pyproject_text = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + current_version = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_text, re.MULTILINE).group(1) + assert f"Version: {current_version}" in content assert "ci.yml" in content @@ -270,9 +273,11 @@ def test_version_parity(): claude_text = (REPO_ROOT / "CLAUDE.md").read_text(encoding="utf-8") changelog_text = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - assert 'version = "1.0.7"' in pyproject_text - assert "version: 1.0.7" in claude_text - assert "## [1.0.7]" in changelog_text + current_version = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_text, re.MULTILINE).group(1) + assert f"version: {current_version}" in claude_text + assert f"## [{current_version}]" in changelog_text + package = json.loads((REPO_ROOT / "store_package.json").read_text(encoding="utf-8")) + assert package["version"] == current_version + ".0" def test_readme_18_point_navigation_parity(): diff --git a/tests/test_properties_and_terminal.py b/tests/test_properties_and_terminal.py index b04695c..e2ac109 100644 --- a/tests/test_properties_and_terminal.py +++ b/tests/test_properties_and_terminal.py @@ -213,8 +213,9 @@ def test_dialog_for_file(self, tmp_path): dlg = FilePropertiesDialog(str(f)) assert dlg.name_edit.text() == "notiz.txt" assert dlg.tabs.count() == 2 - assert dlg.sha256_edit.text() != "" - assert dlg.md5_edit.text() != "" + assert dlg.sha256_edit.text() == "" + assert dlg.md5_edit.text() == "" + assert dlg.calc_btn.isEnabled() dlg.close() def test_dialog_for_directory(self, tmp_path): diff --git a/tests/test_release_review_regressions.py b/tests/test_release_review_regressions.py new file mode 100644 index 0000000..50b4f52 --- /dev/null +++ b/tests/test_release_review_regressions.py @@ -0,0 +1,81 @@ +"""Properties must not read data on opening; translations preserve user data.""" +from unittest.mock import Mock +from PySide6.QtWidgets import QComboBox, QApplication +from core.ui_translator import UiTranslator +from translator import get_translator +from gui import properties_dialog +from modules.privacy.term_io import read_terms, write_terms + +def test_properties_do_not_read_cloud_contents(tmp_path, monkeypatch): + path = tmp_path / "nur online.txt" + path.write_text("Fixture", encoding="utf-8") + monkeypatch.setattr(properties_dialog, "is_cloud_placeholder", lambda p: True) + checksum = Mock(side_effect=AssertionError("must not read cloud contents")) + monkeypatch.setattr(properties_dialog, "ChecksumDialog", checksum) + dialog = properties_dialog.FilePropertiesDialog(str(path)) + assert not dialog.calc_btn.isEnabled() + assert not dialog.text_stats_btn.isEnabled() + dialog._compute_large_hashes() + checksum.assert_not_called() + dialog.close() + +def test_properties_folder_query_is_explicit_and_async(tmp_path, monkeypatch, qtbot): + path = tmp_path / "folder" + path.mkdir() + (path / "file.txt").write_bytes(b"abc") + dialog = properties_dialog.FilePropertiesDialog(str(path)) + qtbot.addWidget(dialog) + assert dialog.folder_size_label.text() == "—" + dialog.folder_stats_btn.click() + assert dialog._details_process is not None + qtbot.waitUntil(lambda: dialog._details_process is None, timeout=15_000) + assert "3 Bytes" in dialog.folder_size_label.text() + assert "1" in dialog.folder_count_label.text() + dialog.close() + +def test_properties_close_stops_owned_query(tmp_path, qtbot): + dialog = properties_dialog.FilePropertiesDialog(str(tmp_path)) + dialog.folder_stats_btn.click() + process = dialog._details_process + dialog.close() + assert dialog._details_process is None + assert process.state() == process.ProcessState.NotRunning + +def test_editable_categories_survive_translation(): + translator = get_translator("en") + bridge = UiTranslator(QApplication.instance()) + combo = QComboBox() + combo.setEditable(True) + combo.addItems(["Allgemein", "Datei", "Meine Kategorie"]) + combo.setCurrentText("Allgemein") + try: + bridge.translate_widget(combo) + assert combo.currentText() == "Allgemein" + assert [combo.itemText(i) for i in range(combo.count())] == ["Allgemein", "Datei", "Meine Kategorie"] + translator.set_language("de") + assert combo.currentText() == "Allgemein" + finally: + bridge.shutdown() + combo.deleteLater() + +def test_xlsx_terms_are_literal_not_formulas(tmp_path): + import openpyxl + target = tmp_path / "terms.xlsx" + terms = ["=ProjectX", "=1+2", "+Äpfel", "Grüße"] + write_terms(target, terms) + assert set(read_terms(target)) == set(terms) + book = openpyxl.load_workbook(target) + try: + assert all(cell.data_type == "s" for row in book.active for cell in row) + finally: + book.close() + +def test_skipped_cloud_subtree_reports_partial_statistics(tmp_path, monkeypatch): + from core import properties_details + cloud = tmp_path / "online" + cloud.mkdir() + (cloud / "unavailable.txt").write_bytes(b"hidden fixture content") + monkeypatch.setattr(properties_details, "is_cloud_placeholder", lambda p: str(p) == str(cloud)) + result = properties_details.query_details("folder", str(tmp_path)) + assert result["partial"] is True + assert result["files"] == 0 diff --git a/tests/test_store_materials.py b/tests/test_store_materials.py index 004a21c..69ace7d 100644 --- a/tests/test_store_materials.py +++ b/tests/test_store_materials.py @@ -74,7 +74,9 @@ def test_pyinstaller_spec_includes_project_and_license_notices() -> None: def test_build_script_promotes_notices_to_bundle_root() -> None: - build_script = (PROJECT_ROOT / "build_exe.bat").read_text(encoding="utf-8") - - assert "for %%F in (LICENSE THIRD_PARTY_LICENSES.txt PRIVACY_POLICY.md SUPPORT.md)" in build_script - assert 'copy /Y "%CD%\\%%F" "%CD%\\dist\\ExplorerPro\\%%F"' in build_script + bundle = PROJECT_ROOT / "dist" / "ExplorerPro" + if not bundle.is_dir(): + import pytest + pytest.skip("Checks the real distributable after a local Onedir build.") + for notice in ("LICENSE", "THIRD_PARTY_LICENSES.txt", "PRIVACY_POLICY.md", "SUPPORT.md"): + assert (bundle / notice).read_bytes() == (PROJECT_ROOT / notice).read_bytes() From affe7387a0f81622a9db321979d95140720c7c4d Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Wed, 7 Oct 2026 23:45:49 +0200 Subject: [PATCH 2/2] Wait for delivered checksum results in asynchronous GUI test --- tests/test_checksum_dialog.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_checksum_dialog.py b/tests/test_checksum_dialog.py index 6ce1e6a..e4ae03a 100644 --- a/tests/test_checksum_dialog.py +++ b/tests/test_checksum_dialog.py @@ -4,6 +4,7 @@ test_checksum_dialog.py - UI- und Funktionstests für den ChecksumDialog """ +import hashlib import os from pathlib import Path from PySide6.QtWidgets import QApplication @@ -33,14 +34,16 @@ def test_checksum_dialog_initialization(tmp_path: Path, qtbot): assert "sha256" in dlg.hash_edits assert "md5" in dlg.hash_edits - # Warten auf Abschluss des Workers - if dlg.worker and dlg.worker.isRunning(): - dlg.worker.wait(2000) - QApplication.processEvents() - - assert dlg.hash_edits["sha256"].text() != "" - assert dlg.hash_edits["md5"].text() != "" - assert dlg.copy_all_btn.isEnabled() + # Warten auf das sichtbare Ergebnis, einschließlich GUI-Signalzustellung. + content = sample.read_bytes() + expected_sha256 = hashlib.sha256(content).hexdigest() + expected_md5 = hashlib.md5(content).hexdigest() + qtbot.waitUntil( + lambda: dlg.hash_edits["sha256"].text() == expected_sha256 + and dlg.hash_edits["md5"].text() == expected_md5 + and dlg.copy_all_btn.isEnabled(), + timeout=5000, + ) # Verifikation testen: exakter SHA-256 computed_sha256 = dlg.hash_edits["sha256"].text()