diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba83d03..1273c09 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt pytest Pillow + python -m pip install -r requirements.txt pytest pytest-qt Pillow - name: Run Python tests run: python -m pytest -ra -v - name: Compile entry points diff --git a/CHANGELOG.md b/CHANGELOG.md index e370b3e..6e8305f 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.2] - 2026-10-07 + +ProfiPrompt 1.0.2 +• Fehler beim Lesen der Bibliothek schützen vorhandene Daten und Exporte. Schreibfehler werden gemeldet, statt eine unbestätigte Datei als erfolgreich gespeichert zu behandeln. +• Entf und Rücktaste entfernen Board-Kacheln erst nach Ihrer Bestätigung; „Nein“ erhält die Kachel. +• Prompts, Versionsänderungen und Board-Verweise werden zuverlässiger gespeichert. Verwaiste Kacheln sowie Drag-and-drop-Probleme wurden behoben. +• Der Sprachwechsel aktualisiert nun mehr Bereiche direkt, einschließlich Tabellen und Boards. +• Kacheln können auf andere Boards verschoben oder dupliziert, Boards umbenannt und Kachelfarben individuell gewählt werden. +• Tastaturbedienung und Beschriftungen wurden verbessert. Über-Dialog und JSON-Export zeigen die aktuelle Version; Programmsymbole entsprechen dem Store-Symbol. + +ProfiPrompt 1.0.2 +• Library read errors preserve existing data and exports. Write failures are reported instead of treating an unconfirmed file as successfully saved. +• Delete and Backspace remove board tiles only after confirmation; choosing No keeps the tile. +• More reliable prompt/version persistence and board references. Fixed orphaned tiles and drag-and-drop issues. +• Language changes update more UI areas immediately, including tables and boards. +• Move or duplicate tiles to other boards, rename boards and choose individual tile colors. +• Improved keyboard controls and labels. About and JSON exports show the current version; application icons match the Store icon. + + ### Sprachwechsel für alle Bereiche, Kacheln auf andere Boards senden/duplizieren & Kachelfarben (2026-10-06) - **Sprachwechsel live für die gesamte Oberfläche (`src/i18n.py`, alle Widgets):** diff --git a/DesktopIcon.png b/DesktopIcon.png index a8fda80..2b36f95 100644 Binary files a/DesktopIcon.png and b/DesktopIcon.png differ diff --git a/ProfiPrompt.spec b/ProfiPrompt.spec index 4d910a8..49e2832 100644 --- a/ProfiPrompt.spec +++ b/ProfiPrompt.spec @@ -1,58 +1,36 @@ # -*- mode: python ; coding: utf-8 -*- -# PyInstaller spec file für ProfiPrompt -# Build: python -m PyInstaller ProfiPrompt.spec --clean --noconfirm - -block_cipher = None +import json +import os +from pathlib import Path +root = Path.cwd() a = Analysis( - ['src/profiprompt.py'], - pathex=['src', '.'], + [str(root / "src" / "profiprompt.py")], + pathex=[str(root / "src"), str(root)], binaries=[], datas=[ - ('src/icons', 'icons'), - ('locales', 'locales'), - ('translator.py', '.'), - ], - hiddenimports=[ - 'PySide6.QtWidgets', - 'PySide6.QtGui', - 'PySide6.QtCore', - 'PySide6.QtPrintSupport', + (str(root / "pyproject.toml"), "."), + (str(root / "DesktopIcon.ico"), "."), + (str(root / "DesktopIcon.png"), "."), + (str(root / "locales"), "locales"), + *[(str(root / "src" / "icons" / filename), "icons") + for filename in ("clipboard-dark.png", "clipboard-light.png", "paperclip-dark.png", "paperclip-light.png")], + *[(str(root / filename), ".") + for filename in ("LICENSE", "NOTICE", "THIRD_PARTY_LICENSES.txt", "PRIVACY_POLICY.md")], ], + hiddenimports=["PySide6.QtPrintSupport"], hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=['tkinter', 'matplotlib', 'numpy', 'scipy'], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, + excludes=json.loads(os.environ["PYINSTALLER_EXCLUDES"]), noarchive=False, + optimize=0, ) - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - +pyz = PYZ(a.pure) exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name='ProfiPrompt', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, - disable_windowed_traceback=False, - icon='src/icons/BCO.0bdc6366-1e3b-4548-ae52-db1ba790b9b1_umgewandelt.ico', -) - -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='ProfiPrompt', + pyz, a.scripts, [], exclude_binaries=True, name="ProfiPrompt", + debug=False, bootloader_ignore_signals=False, strip=False, upx=False, + console=False, disable_windowed_traceback=False, + icon=[str(root / "DesktopIcon.ico")], ) +coll = COLLECT(exe, a.binaries, a.datas, strip=False, upx=False, name="ProfiPrompt") diff --git a/README.md b/README.md index 579613f..20ed140 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ ProfiPrompt is purposefully engineered to serve 4 primary stakeholder personas a | **Multi-Mode Clipboard Engine** | **YES (4 Config Modes)**| NO (Raw copy) | NO (Single copy) | Basic text paste | | **Multi-Format Export (PDF/TXT/JSON)**| **YES (Integrated)** | Requires Plugins | Proprietary Export | NO | | **Standalone Offline PWA Companion** | **YES (Included)** | NO | NO (Online only) | NO | -| **Atomic Writes & Auto-Recovery** | **YES (.bak shield)** | OS Dependent | Cloud Managed | Varies | +| **Atomic Writes & Manual JSON Backups** | **YES** | OS Dependent | Cloud Managed | Varies | | **Zero Subscription / 100% Open MIT**| **YES (100% Free)** | Free / Paid Sync | Paid ($10-30/month) | Freemium / Paid | | **Open Portable Schema Standard** | **YES (`v1.json`)** | Markdown only | Vendor Lock-in | Proprietary DB | @@ -137,7 +137,7 @@ flowchart TD subgraph Storage["Local Data Persistence (.prompt_manager/)"] JSONStore["Atomic JSON Storage (prompts.json, boards.json)"] - BackupStore["Automatic Backup Snapshots (*.bak)"] + BackupStore["Manual JSON Library Exports"] end subgraph ExportFormat["Portable Library Standard"] @@ -185,7 +185,7 @@ ProfiPrompt enforces 10 strict operational invariants documented in [THIRD_PARTY - **INV-ATOMIC-03:** Atomic File Persistence (tempfile + replace writes). - **INV-SCHEMA-04:** Open Portable Schema (`profiprompt-library-v1.json`). - **INV-UNPRIV-05:** Non-Elevation & RunAsInvoker (unprivileged user space). -- **INV-BACKUP-06:** Fail-Safe Backup & Recovery (automatic `.bak` preservation). +- **INV-BACKUP-06:** Manual JSON library backups; existing exports are preserved when library reads fail. - **INV-COPY-07:** Local Clipboard Safety (memory-only sanitized clipboard access). - **INV-PRINT-08:** Deterministic Multi-Format Rendering (Qt vector print engine). - **INV-PWA-09:** Read-Only Companion Isolation (PWA companion runs sandboxed). @@ -224,7 +224,7 @@ sequenceDiagram Engineer->>UI: Create new prompt version (v1.1) UI->>Engine: Validate parameters, tags & change notes - Engine->>Store: Atomic commit to prompts.json + .bak + Engine->>Store: Atomic commit to prompts.json Store-->>UI: Persistence confirmed Engineer->>UI: Select copy action (Markdown / Result / Body) UI->>Clip: Prepare sanitized buffer @@ -331,7 +331,7 @@ ProfiPrompt/ │ ├── profiprompt.py # Application entry point and main window │ ├── prompt_dialog.py # Editor dialogs for prompts and version histories │ ├── settings_manager.py # QSettings configuration manager -│ ├── storage.py # Atomic JSON persistence & .bak recovery +│ ├── storage.py # Atomic JSON persistence & read-error protection │ ├── theme.py # Fusion Dark and Light theme palettes │ └── translator.py # Live i18n translation engine ├── web_companion/ # Read-only Web/PWA companion diff --git a/README_de.md b/README_de.md index f70de6b..bea3bb4 100644 --- a/README_de.md +++ b/README_de.md @@ -116,7 +116,7 @@ ProfiPrompt adressiert 4 zentrale Anwendergruppen und Stakeholder-Personas: | **Multimodale Clipboard-Engine** | **JA (4 Modi)** | NEIN (Reines Kopieren)| NEIN (Einfach) | Standard-Einfügen | | **Multi-Format-Exporte (PDF/TXT/JSON)**| **JA (Integriert)** | Nur per Plugin | Proprietärer Export | NEIN | | **Eigenständiger Offline-PWA-Begleiter**| **JA (Enthalten)** | NEIN | NEIN (Online-Zwang) | NEIN | -| **Atomare Speicherung & .bak-Schutz**| **JA (Sicher)** | Abhängig vom OS | Cloud-Datenbank | Variiert | +| **Atomare Speicherung & manuelle JSON-Sicherungen**| **JA** | Abhängig vom OS | Cloud-Datenbank | Variiert | | **Keine Abokosten / 100% Open MIT** | **JA (100% Frei)** | Frei / Sync-Abo | Kostenpflichtig ($10-30/m)| Freemium / Abo | | **Offener, portabler Standard (`v1.json`)**| **JA (Offen)** | Nur Markdown | Vendor-Lock-in | Proprietäre DB | @@ -137,7 +137,7 @@ flowchart TD subgraph Storage["Lokale Datenspeicherung (.prompt_manager/)"] JSONStore["Atomarer JSON-Speicher (prompts.json, boards.json)"] - BackupStore["Automatische Backup-Snapshots (*.bak)"] + BackupStore["Manuelle JSON-Bibliotheksexporte"] end subgraph ExportFormat["Portabler Bibliotheksstandard"] @@ -185,7 +185,7 @@ ProfiPrompt garantiert 10 strikte Architektur-Invarianten gemäß [THIRD_PARTY_L - **INV-ATOMIC-03:** Atomare Dateispeicherung (Tempfile + Replace Schreibvorgänge). - **INV-SCHEMA-04:** Offenes, portables Schema (`profiprompt-library-v1.json`). - **INV-UNPRIV-05:** Keine Rechteausweitung & RunAsInvoker (ausschließlich unprivilegierter Benutzerraum). -- **INV-BACKUP-06:** Ausfallsichere Backups (.bak-Sicherheitskopien). +- **INV-BACKUP-06:** Manuelle JSON-Bibliothekssicherungen; vorhandene Exporte bleiben bei Lesefehlern erhalten. - **INV-COPY-07:** Sichere lokale Zwischenablage (reiner RAM-Transfer ohne Protokollierung). - **INV-PRINT-08:** Deterministisches Vektor-Rendering (Qt-Druckengine). - **INV-PWA-09:** Isolierter PWA-Begleiter (rein lesender Sandbox-Betrieb). @@ -224,7 +224,7 @@ sequenceDiagram Engineer->>UI: Neue Prompt-Version anlegen (v1.1) UI->>Engine: Parameter, Tags & Notizen validieren - Engine->>Store: Atomarer Schreibvorgang in prompts.json + .bak + Engine->>Store: Atomarer Schreibvorgang in prompts.json Store-->>UI: Persistenz bestätigt Engineer->>UI: Kopier-Modus wählen (Markdown / Ergebnis / Text) UI->>Clip: Sanitisierten Puffer bereitstellen @@ -331,7 +331,7 @@ ProfiPrompt/ │ ├── profiprompt.py # Haupteinstiegspunkt und Hauptfenster │ ├── prompt_dialog.py # Editor für Prompts und Versionsverläufe │ ├── settings_manager.py # QSettings-Konfigurationsmanager -│ ├── storage.py # Atomare JSON-Persistenz & .bak-Schutz +│ ├── storage.py # Atomare JSON-Persistenz & Schutz bei Lesefehlern │ ├── theme.py # Fusion Dark- und Light-Themes │ └── translator.py # Live i18n Übersetzungssystem ├── web_companion/ # Read-Only Web/PWA-Begleiter diff --git a/README_es.md b/README_es.md index 3da46e9..5884e1a 100644 --- a/README_es.md +++ b/README_es.md @@ -116,7 +116,7 @@ ProfiPrompt está diseñado específicamente para servir a 4 personas interesada | **Motor de portapapeles multimodal** | **SÍ (4 modos configurables)**| NO (Copia básica) | NO (Copia única) | Pegado estándar | | **Exportación multiformato (PDF/TXT/JSON)**| **SÍ (Integrada)** | Requiere plugins | Exportación propietaria | NO | | **Compañero Web/PWA offline independiente**| **SÍ (Incluido)** | NO | NO (Solo online) | NO | -| **Escrituras atómicas y auto-recuperación**| **SÍ (Escudo .bak)** | Depende del SO | Base de datos en la nube | Variable | +| **Escrituras atómicas y copias JSON manuales**| **SÍ** | Depende del SO | Base de datos en la nube | Variable | | **Sin suscripciones / 100% Código abierto MIT**| **SÍ (100% Libre)**| Libre / Sync de pago | De pago ($10-30/mes) | Freemium / De pago | | **Estándar de esquema abierto (`v1.json`)**| **SÍ (Abierto)** | Solo Markdown | Bloqueo de proveedor | Base de datos propietaria | @@ -137,7 +137,7 @@ flowchart TD subgraph Storage["Persistencia local de datos (.prompt_manager/)"] JSONStore["Almacenamiento atómico JSON (prompts.json, boards.json)"] - BackupStore["Instantáneas automáticas de respaldo (*.bak)"] + BackupStore["Exportaciones JSON manuales de la biblioteca"] end subgraph ExportFormat["Estándar de biblioteca portátil"] @@ -185,7 +185,7 @@ ProfiPrompt aplica 10 invariantes operativas estrictas documentadas en [THIRD_PA - **INV-ATOMIC-03:** Persistencia atómica de archivos (escrituras mediante tempfile + replace). - **INV-SCHEMA-04:** Esquema abierto y portátil (`profiprompt-library-v1.json`). - **INV-UNPRIV-05:** Sin elevación de privilegios y RunAsInvoker (espacio de usuario no privilegiado). -- **INV-BACKUP-06:** Copias de seguridad y recuperación a prueba de fallos (preservación automática `.bak`). +- **INV-BACKUP-06:** Copias JSON manuales; las exportaciones existentes se conservan si falla la lectura de la biblioteca. - **INV-COPY-07:** Seguridad en el portapapeles local (acceso a memoria saneado sin registro externo). - **INV-PRINT-08:** Renderizado multiformato determinista (motor vectorial de impresión de Qt). - **INV-PWA-09:** Aislamiento del compañero de solo lectura (entorno sandboxed para la PWA). @@ -224,7 +224,7 @@ sequenceDiagram Engineer->>UI: Crear nueva versión de prompt (v1.1) UI->>Engine: Validar parámetros, etiquetas y notas de cambio - Engine->>Store: Escritura atómica en prompts.json + .bak + Engine->>Store: Escritura atómica en prompts.json Store-->>UI: Persistencia confirmada Engineer->>UI: Seleccionar modo de copia (Markdown / Resultado / Texto) UI->>Clip: Preparar búfer saneado @@ -331,7 +331,7 @@ ProfiPrompt/ │ ├── profiprompt.py # Punto de entrada y ventana principal │ ├── prompt_dialog.py # Diálogos de edición de prompts e historiales │ ├── settings_manager.py # Gestor de configuraciones QSettings -│ ├── storage.py # Persistencia atómica JSON y respaldos .bak +│ ├── storage.py # Persistencia atómica JSON y protección ante errores de lectura │ ├── theme.py # Paletas de temas Fusion Dark y Light │ └── translator.py # Motor de traducción i18n dinámica ├── web_companion/ # Compañero Web/PWA de solo lectura diff --git a/assets/icon-master.png b/assets/icon-master.png new file mode 100644 index 0000000..a8fda80 Binary files /dev/null and b/assets/icon-master.png differ diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 0000000..a848d0a --- /dev/null +++ b/build_exe.bat @@ -0,0 +1,7 @@ +@echo off +chcp 65001 >nul +setlocal +cd /d "%~dp0" +python scripts\build_release.py +if errorlevel 1 exit /b 1 +endlocal diff --git a/releases/windowsstore/BUILD.md b/releases/windowsstore/BUILD.md index a18efdf..4c435ea 100644 --- a/releases/windowsstore/BUILD.md +++ b/releases/windowsstore/BUILD.md @@ -1,56 +1,52 @@ -# ProfiPrompt - Windows Store Build-Anleitung +# ProfiPrompt – Windows Store Build-Anleitung -Stand: 2026-06-14 +Stand: 2026-10-07 -## Voraussetzungen +Die verbindlichen Einreichungsgates stehen in der .SOFTWARE-Pipeline unter +`_STORE/WINDOWS_STORE_BUGFIX_POLICY.md`, Abschnitt 7a. -1. Python 3.10+ mit PySide6 -2. PyInstaller (`pip install pyinstaller`) -3. Windows App Certification Kit / Windows SDK -4. Aktuelles MSIX `releases\ProfiPrompt.msix` -5. Für echten Store-Lauf: Publisher-/Identity-Werte passend zu `store_package.json` +## Build -## Schritt 1: EXE bauen und Store-Artefakte prüfen +Ein isoliertes Python-Environment mit `requirements.txt`, PyInstaller und den +Testabhängigkeiten verwenden. `python` im PATH muss auf dieses Environment zeigen. +`build_exe.bat` ruft `scripts/build_release.py` auf: zuerst den zentralen +`build_exclude_scanner.py`, dann einen vollständigen Onedir-Build. -```bash -cd "C:\_Local_DEV\repos\ProfiPrompt" -build_exe.bat -python scripts/check_store_readiness.py +```powershell +python scripts/gen_store_icons.py +.\build_exe.bat +python "$env:OneDrive\.TOPICS\.SOFTWARE\_STORE\icon_consistency_check.py" . +& "$env:OneDrive\.TOPICS\.SOFTWARE\_STORE\msstore_build_msix.ps1" -ProjectRoot (Get-Location).Path -ExePath ".\dist\ProfiPrompt\ProfiPrompt.exe" -OutputMsix ".\releases\windowsstore\v1.0.2\ProfiPrompt.msix" ``` -Der Preflight prüft `store_package.json`, `STORE_LISTING.md`, -`releases/windowsstore/store_settings.json`, die Store-Screenshot-Summary, -`releases\ProfiPrompt.msix` und vorhandene WACK-Reports. -Für `STORE_LISTING.md` validiert er zusätzlich beide Sprachabschnitte, die -Kurzbeschreibungen, Schlüsselwörter, Kategorien und die Ausrichtung zur -Store-Konfiguration. +Für bestehende Materialprüfungen kann dasselbe Paket zusätzlich unter +`releases/ProfiPrompt.msix` abgelegt werden. Beide Dateien müssen dieselbe SHA256 haben. -Ein lokales Testprotokoll für den aktuellen Paketstand wird so erzeugt: +## Tests und Paketprüfung -```bash -python scripts/check_store_readiness.py write-test-protocol -``` - -Das Protokoll liegt unter `releases\windowsstore\test_reports\`, enthält -MSIX-SHA256, Materialstatus und markiert fehlende WACK-XML ausdrücklich als -offenes Gate. - -## Schritt 2: WACK-Testprotokoll erneuern +Die vollständige Pytest-Suite und der Übersetzungscheck müssen bestanden sein. +Das gebaute MSIX erneut mit `icon_consistency_check.py --package` prüfen. +Das Paket in einen frischen Ordner entpacken und **dessen EXE** mit +`--release-smoke ` starten. Screenshots müssen unter der +nativen Windows-Plattform entstehen; Runtime, Qt-Plug-ins und Versionsdaten +müssen vollständig sein. -1. `releases\ProfiPrompt.msix` im Windows App Certification Kit prüfen. -2. XML-Report unter `releases\windowsstore\test_reports\wack_YYYYMMDD_HHMMSS.xml` speichern. -3. Report lokal auswerten: +`python scripts/check_store_readiness.py write-test-protocol` schreibt einen +ergänzenden Materialbericht unter `releases/windowsstore/test_reports`. +Der ältere Readiness-Checker meldet einen fehlenden WACK-Report weiterhin als +Warnung. WACK ist nach der aktuellen zentralen Pipeline ein optionaler +Qualitätsnachweis und ersetzt kein Paketgate. -```bash -python scripts/check_store_readiness.py review-wack-report -``` - -## Schritt 3: Store Submission +## Einreichung -Vor dem Partner-Center-Upload noch einmal ausführen: +Eine Icon-Collage aus dem gebauten Paket erzeugen, auf den OneDrive-Desktop +legen und nach `.USR` kopieren. Erst das dokumentierte Sicht-OK des Users +erlaubt die Einreichung. Die Freigabe und alle Testnachweise müssen an die +SHA256 des tatsächlich hochgeladenen MSIX gebunden sein. -```bash -python scripts/check_store_readiness.py -``` +Deutsch und Englisch vorbereitete Neuigkeiten gehören in das Feld +`baseListing.releaseNotes` der Store-Listings. Nach dem Update diese Felder +und den Einreichungsstatus über die Store-API zurücklesen. +Der Store signiert das Paket; lokal wird es dafür nicht signiert. -Preis: kostenlos +Preis: kostenlos. diff --git a/releases/windowsstore/store_settings.json b/releases/windowsstore/store_settings.json index 4eb22df..ceb96bb 100644 --- a/releases/windowsstore/store_settings.json +++ b/releases/windowsstore/store_settings.json @@ -3,7 +3,7 @@ "publisher": "CN=52596601-BAB4-4F3F-B182-E8F3F273B202", "publisher_display": "Geiger", "identity_name": "Geiger.ProfiPrompt", - "version": "1.0.1.0", + "version": "1.0.2.0", "exe_name": "ProfiPrompt", "msix_name": "ProfiPrompt.msix", "output_dir": "store_package", @@ -12,7 +12,9 @@ "pfx_path": "", "pfx_password": "", "timestamp_url": "http://timestamp.digicert.com", - "license_files": ["../../LICENSE"], + "license_files": [ + "../../LICENSE" + ], "enable_i18n": true, "capabilities": "internetClient,runFullTrust", "privacy_url": "https://github.com/file-bricks/ProfiPrompt/blob/master/PRIVACY_POLICY.md", diff --git a/releases/windowsstore/v1.0.2/release-notes.json b/releases/windowsstore/v1.0.2/release-notes.json new file mode 100644 index 0000000..ac341db --- /dev/null +++ b/releases/windowsstore/v1.0.2/release-notes.json @@ -0,0 +1,4 @@ +{ + "de-de": "ProfiPrompt 1.0.2\n• Fehler beim Lesen der Bibliothek schützen vorhandene Daten und Exporte. Schreibfehler werden gemeldet, statt eine unbestätigte Datei als erfolgreich gespeichert zu behandeln.\n• Entf und Rücktaste entfernen Board-Kacheln erst nach Ihrer Bestätigung; „Nein“ erhält die Kachel.\n• Prompts, Versionsänderungen und Board-Verweise werden zuverlässiger gespeichert. Verwaiste Kacheln sowie Drag-and-drop-Probleme wurden behoben.\n• Der Sprachwechsel aktualisiert nun mehr Bereiche direkt, einschließlich Tabellen und Boards.\n• Kacheln können auf andere Boards verschoben oder dupliziert, Boards umbenannt und Kachelfarben individuell gewählt werden.\n• Tastaturbedienung und Beschriftungen wurden verbessert. Über-Dialog und JSON-Export zeigen die aktuelle Version; Desktop- und Store-Symbole verwenden das vereinbarte PP-Icon.", + "en-us": "ProfiPrompt 1.0.2\n• Library read errors preserve existing data and exports. Write failures are reported instead of treating an unconfirmed file as successfully saved.\n• Delete and Backspace remove board tiles only after confirmation; choosing No keeps the tile.\n• More reliable prompt/version persistence and board references. Fixed orphaned tiles and drag-and-drop issues.\n• Language changes update more UI areas immediately, including tables and boards.\n• Move or duplicate tiles to other boards, rename boards and choose individual tile colors.\n• Improved keyboard controls and labels. About and JSON exports show the current version; desktop and Store icons use the updated PP artwork." +} diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100644 index 0000000..25723f9 --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,27 @@ +"""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) diff --git a/scripts/gen_store_icons.py b/scripts/gen_store_icons.py new file mode 100644 index 0000000..6fab6b3 --- /dev/null +++ b/scripts/gen_store_icons.py @@ -0,0 +1,60 @@ +"""Generate Store and runtime variants from the approved ProfiPrompt artwork.""" +from pathlib import Path +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +STORE = ROOT / "store_assets" +SIZES = (16, 20, 24, 30, 32, 36, 40, 48, 60, 64, 72, 80, 96, 256) + + +def resized(master, size): + if size[0] == size[1]: + return master.resize(size, Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", size, (0, 0, 0, 0)) + side = min(size) + image = master.resize((side, side), Image.Resampling.LANCZOS) + canvas.alpha_composite(image, ((size[0] - side) // 2, (size[1] - side) // 2)) + return canvas + + +def main(): + # This approved source is immutable input. Never derive branding from a + # previously published Store tile: that tile may still contain old artwork. + master = Image.open(ROOT / "assets" / "icon-master.png").convert("RGBA") + STORE.mkdir(parents=True, exist_ok=True) + # Refresh existing Store logo variants as well as canonical manifest names. + for path in STORE.glob("*.png"): + if ".targetsize-" in path.name: + continue + with Image.open(path) as image: + size = image.size + resized(master, size).save(path) + canonical = { + "Square44x44Logo.png": (44, 44), + "Square150x150Logo.png": (150, 150), + "Square310x310Logo.png": (310, 310), + "Wide310x150Logo.png": (310, 150), + "StoreLogo.png": (50, 50), + "icon_50x50.png": (50, 50), + } + for name, size in canonical.items(): + resized(master, size).save(STORE / name) + for directory in (ROOT, ROOT / "assets"): + for path in directory.glob("*.ico"): + sizes = (16, 24, 32, 48) if path.stem == "favicon" else (16, 24, 32, 48, 64, 128, 256) + master.save(path, format="ICO", sizes=[(s, s) for s in sizes]) + for relative in ("DesktopIcon.ico", "assets/app_icon.ico", "assets/profiprompt.ico"): + target = ROOT / relative + target.parent.mkdir(parents=True, exist_ok=True) + master.save(target, format="ICO", sizes=[(s, s) for s in (16, 24, 32, 48, 64, 128, 256)]) + for relative, size in (("DesktopIcon.png", 1024), ("ProfiPrompt.png", 1024), ("icon.png", 1024), + ("assets/icon.png", 1024), ("assets/favicon.png", 32)): + resized(master, (size, size)).save(ROOT / relative) + for size in SIZES: + image = resized(master, (size, size)) + for form in ("", "_altform-unplated", "_altform-lightunplated"): + image.save(STORE / f"Square44x44Logo.targetsize-{size}{form}.png") + + +if __name__ == "__main__": + main() diff --git a/src/app_version.py b/src/app_version.py new file mode 100644 index 0000000..7102623 --- /dev/null +++ b/src/app_version.py @@ -0,0 +1,25 @@ +"""Read the application version from the project metadata.""" + +import re +import sys +from pathlib import Path + + +def _project_metadata_path() -> Path: + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) / "pyproject.toml" + return Path(__file__).resolve().parents[1] / "pyproject.toml" + + +def get_version() -> str: + metadata = _project_metadata_path().read_text(encoding="utf-8") + project_section = re.search(r"(?ms)^\[project\]\s*\n(.*?)(?=^\[|\Z)", metadata) + if project_section is None: + raise ValueError("pyproject.toml has no [project] section") + match = re.search(r'^version\s*=\s*"([^\"]+)"\s*$', project_section.group(1), re.MULTILINE) + if match is None: + raise ValueError("pyproject.toml has no project version") + return match.group(1) + + +__version__ = get_version() diff --git a/src/atomic_io.py b/src/atomic_io.py index d84a815..19891e3 100644 --- a/src/atomic_io.py +++ b/src/atomic_io.py @@ -78,10 +78,8 @@ def atomic_write_text( with open(tmp_path, "w", encoding=encoding) as f: f.write(text) f.flush() - try: - os.fsync(f.fileno()) - except (OSError, AttributeError): - pass + # A failed durability check must preserve the previous file. + os.fsync(f.fileno()) if target_path.exists(): try: diff --git a/src/board_manager.py b/src/board_manager.py index 65ff1b1..f2c507c 100644 --- a/src/board_manager.py +++ b/src/board_manager.py @@ -177,11 +177,6 @@ def keyPressEvent(self, event: QtGui.QKeyEvent): return elif event.key() in (QtCore.Qt.Key.Key_Delete, QtCore.Qt.Key.Key_Backspace): self.removeRequested.emit(self) - p = self.parent() - while p and not hasattr(p, "remove_tile_item"): - p = p.parent() - if p and hasattr(p, "remove_tile_item"): - p.remove_tile_item(self.prompt.id, self.version.id if self.version else None) event.accept() return elif event.key() in (QtCore.Qt.Key.Key_Menu, QtCore.Qt.Key.Key_F10): @@ -633,9 +628,7 @@ def _remove_item_from_board(self, tile): pid = tile.prompt.id vid = tile.version.id if tile.version else None - if self.storage.remove_item_from_board(board.id, pid, vid): - self.reload_items() - bus.boardsChanged.emit() + self.remove_tile_item(pid, vid) # --- Drag & Drop --- def dragEnterEvent(self, event: QtGui.QDragEnterEvent): diff --git a/src/library_export.py b/src/library_export.py index e1c248a..7138afd 100644 --- a/src/library_export.py +++ b/src/library_export.py @@ -8,11 +8,12 @@ from typing import Any from atomic_io import atomic_write_json +from app_version import __version__ from models import Board, Prompt, board_to_dict, prompt_to_dict SCHEMA_VERSION = "profiprompt-library-v1" APP_NAME = "ProfiPrompt" -APP_VERSION = "1.0.1" +APP_VERSION = __version__ def build_library_export(storage, exported_at: str | None = None) -> dict[str, Any]: diff --git a/src/platform_smoke.py b/src/platform_smoke.py index 59acd00..cac0762 100644 --- a/src/platform_smoke.py +++ b/src/platform_smoke.py @@ -8,8 +8,11 @@ from typing import Any from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtCore import QMimeData from clipboard_manager import ClipboardManager +from app_version import __version__ +from PySide6.QtGui import QIcon from library_export import write_library_export from models import Board, BoardItem, CopyMode, Prompt, Version, now_iso from pdf_exporter import export_single_prompt, export_single_version @@ -82,6 +85,27 @@ def _suppress_message_boxes(): QMessageBox.critical = original_critical +@contextmanager +def _preserve_clipboard(expected_text: str): + clipboard = QApplication.clipboard() + previous = QMimeData() + current = clipboard.mimeData() + if current is not None: + for fmt in current.formats(): + previous.setData(fmt, current.data(fmt)) + if current.hasImage(): + previous.setImageData(current.imageData()) + if current.hasColor(): + previous.setColorData(current.colorData()) + try: + yield + finally: + # Keep unrelated clipboard changes made while the smoke is running. + if clipboard.text() == expected_text: + clipboard.setMimeData(previous) + QApplication.processEvents() + + def seed_smoke_storage(storage: Storage) -> tuple[Prompt, Version]: """Legt deterministische Beispieldaten an, damit der Smoke wiederholbar bleibt.""" timestamp = now_iso() @@ -117,6 +141,7 @@ def seed_smoke_storage(storage: Storage) -> tuple[Prompt, Version]: board_id="b-smoke-1", prompt_id=prompt.id, version_id=version.id, + color="#2F5D9E", created_at=timestamp, ) ], @@ -136,7 +161,12 @@ def run_platform_smoke(output_dir: str | Path, *, headless: bool = True) -> dict if headless and "QT_QPA_PLATFORM" not in os.environ and QApplication.instance() is None: os.environ["QT_QPA_PLATFORM"] = "offscreen" + if not headless and QApplication.instance() is None: + os.environ.pop("QT_QPA_PLATFORM", None) app = QApplication.instance() or QApplication([]) + app.setApplicationVersion(__version__) + if not headless and app.platformName() == "offscreen": + raise RuntimeError("Native Windows platform required for release screenshots.") apply_dark_theme(app) settings = SmokeSettings() @@ -144,62 +174,109 @@ def run_platform_smoke(output_dir: str | Path, *, headless: bool = True) -> dict prompt, version = seed_smoke_storage(storage) window = MainWindow(storage, settings) - window.show() - app.processEvents() - - prompt_txt_path = exports_dir / "prompt.txt" - prompt_pdf_path = exports_dir / "prompt.pdf" - version_pdf_path = exports_dir / "version.pdf" - library_json_path = exports_dir / "profiprompt-library-v1.json" - summary_path = exports_dir / "platform-smoke-summary.json" - - copy_text = ClipboardManager(settings).build_copy_text(prompt) - - with _suppress_message_boxes(): - window._write_txt_export(str(prompt_txt_path), copy_text, "TXT erfolgreich gespeichert.") - export_single_prompt(prompt, settings, str(prompt_pdf_path)) - export_single_version(version, str(version_pdf_path), settings=settings) - payload = write_library_export(storage, library_json_path) - ClipboardManager(settings).copy_to_clipboard(window.dashboard.tree, copy_text) + try: + if not headless and window.windowIcon().isNull(): + raise RuntimeError("Runtime icon missing from release bundle.") + window.show() app.processEvents() - clipboard_text = QApplication.clipboard().text() - txt_content = prompt_txt_path.read_text(encoding="utf-8") - exported = json.loads(library_json_path.read_text(encoding="utf-8")) - - if clipboard_text != copy_text: - raise RuntimeError("Clipboard-Smoke fehlgeschlagen: Text stimmt nicht mit dem Exporttext überein.") - if "Grußprompt" not in txt_content or "Begrüßung" not in txt_content or "überblick" not in txt_content: - raise RuntimeError("TXT-Smoke fehlgeschlagen: Exporttext enthält die erwarteten Umlaute nicht.") - if exported.get("schema_version") != "profiprompt-library-v1": - raise RuntimeError("JSON-Smoke fehlgeschlagen: schema_version fehlt oder ist falsch.") - if payload["stats"]["prompt_count"] != 1 or payload["stats"]["board_count"] != 1: - raise RuntimeError("JSON-Smoke fehlgeschlagen: Unerwartete Exportstatistik.") - if not prompt_pdf_path.exists() or prompt_pdf_path.stat().st_size == 0: - raise RuntimeError("PDF-Smoke fehlgeschlagen: Prompt-PDF wurde nicht erzeugt.") - if not version_pdf_path.exists() or version_pdf_path.stat().st_size == 0: - raise RuntimeError("PDF-Smoke fehlgeschlagen: Versions-PDF wurde nicht erzeugt.") - - summary = { - "headless": headless, - "qt_platform": os.environ.get("QT_QPA_PLATFORM", ""), - "artifacts": { - "prompt_txt": str(prompt_txt_path), - "prompt_pdf": str(prompt_pdf_path), - "version_pdf": str(version_pdf_path), - "library_json": str(library_json_path), - }, - "clipboard_text": clipboard_text, - "stats": payload["stats"], - } - summary_path.write_text( - json.dumps(summary, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - - window.close() - app.processEvents() - return summary + prompt_txt_path = exports_dir / "prompt.txt" + prompt_pdf_path = exports_dir / "prompt.pdf" + version_pdf_path = exports_dir / "version.pdf" + library_json_path = exports_dir / "profiprompt-library-v1.json" + summary_path = exports_dir / "platform-smoke-summary.json" + + if any(path.exists() for path in (prompt_txt_path, prompt_pdf_path, version_pdf_path, library_json_path)): + window.close() + raise RuntimeError("Smoke artifacts already exist. Use a fresh output directory.") + copy_text = ClipboardManager(settings).build_copy_text(prompt) + + with _suppress_message_boxes(), _preserve_clipboard(copy_text): + window._write_txt_export(str(prompt_txt_path), copy_text, "TXT erfolgreich gespeichert.") + if not export_single_prompt(prompt, settings, str(prompt_pdf_path)): + raise RuntimeError("Prompt PDF export failed.") + if not export_single_version(version, str(version_pdf_path), settings=settings): + raise RuntimeError("Version PDF export failed.") + payload = write_library_export(storage, library_json_path) + ClipboardManager(settings).copy_to_clipboard(window.dashboard.tree, copy_text) + app.processEvents() + clipboard_text = QApplication.clipboard().text() + + txt_content = prompt_txt_path.read_text(encoding="utf-8") + exported = json.loads(library_json_path.read_text(encoding="utf-8")) + + if clipboard_text != copy_text: + raise RuntimeError("Clipboard-Smoke fehlgeschlagen: Text stimmt nicht mit dem Exporttext überein.") + if "Grußprompt" not in txt_content or "Begrüßung" not in txt_content or "überblick" not in txt_content: + raise RuntimeError("TXT-Smoke fehlgeschlagen: Exporttext enthält die erwarteten Umlaute nicht.") + if exported.get("schema_version") != "profiprompt-library-v1": + raise RuntimeError("JSON-Smoke fehlgeschlagen: schema_version fehlt oder ist falsch.") + if payload["stats"]["prompt_count"] != 1 or payload["stats"]["board_count"] != 1: + raise RuntimeError("JSON-Smoke fehlgeschlagen: Unerwartete Exportstatistik.") + if not prompt_pdf_path.exists() or prompt_pdf_path.stat().st_size == 0: + raise RuntimeError("PDF-Smoke fehlgeschlagen: Prompt-PDF wurde nicht erzeugt.") + if any(not path.read_bytes().startswith(b"%PDF-") for path in (prompt_pdf_path, version_pdf_path)): + raise RuntimeError("Smoke PDF artifacts are invalid.") + if exported["app"]["version"] != __version__: + raise RuntimeError("JSON export app version differs from runtime metadata.") + if not version_pdf_path.exists() or version_pdf_path.stat().st_size == 0: + raise RuntimeError("PDF-Smoke fehlgeschlagen: Versions-PDF wurde nicht erzeugt.") + + screenshots = [] + if not headless: + from PySide6 import QtCore + for lang in ("de", "en"): + window.change_language(lang) + app.processEvents() + expected = "Titel" if lang == "de" else "Title" + if window.dashboard.tree.headerItem().text(0) != expected: + raise RuntimeError("Live language switch did not update the prompt tree.") + image = output_dir / ("main-" + lang + ".png") + if not window.grab().save(str(image)): + raise RuntimeError("Screenshot save failed.") + screenshots.append(str(image)) + window.change_language("de") + # Exercise the shipped board-transfer path and persisted per-tile color. + target = Board(id="b-smoke-2", title="Zielboard", items=[], created_at=now_iso()) + storage.upsert_board(target) + ok, reason = storage.transfer_item("b-smoke-1", target.id, prompt.id, version.id, move=False) + if not ok: + raise RuntimeError(str(reason)) + transferred = next(b for b in storage.load_boards() if b.id == target.id) + if len(transferred.items) != 1 or transferred.items[0].color != "#2F5D9E": + raise RuntimeError("Board transfer or per-tile color did not persist.") + summary = { + "version": __version__, + "frozen": bool(getattr(__import__("sys"), "frozen", False)), + "native_platform": app.platformName(), + "screenshots": screenshots, + "headless": headless, + "qt_platform": os.environ.get("QT_QPA_PLATFORM", ""), + "artifacts": { + "prompt_txt": str(prompt_txt_path), + "prompt_pdf": str(prompt_pdf_path), + "version_pdf": str(version_pdf_path), + "library_json": str(library_json_path), + }, + "clipboard_text": clipboard_text, + "stats": payload["stats"], + } + summary_path.write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + return summary + finally: + window.close() + window.deleteLater() + from PySide6.QtCore import QCoreApplication, QEvent + QCoreApplication.sendPostedEvents(window, QEvent.DeferredDelete) + app.processEvents() + # Offscreen uses a private, virtual clipboard. Its retained QMimeData + # crashes Qt 6.11 on Windows shutdown; release it before QApplication. + if app.platformName() == "offscreen": + QApplication.clipboard().clear() def parse_args() -> argparse.Namespace: diff --git a/src/profiprompt.py b/src/profiprompt.py index b4178b8..62de2e7 100644 --- a/src/profiprompt.py +++ b/src/profiprompt.py @@ -1,5 +1,6 @@ from typing import Optional import sys +from app_version import __version__ from PySide6.QtWidgets import ( QApplication, QMainWindow, @@ -245,7 +246,7 @@ def _show_help(self): ) def _show_about(self): - QMessageBox.information(self, tr("Über"), "Prompt Manager v1.0.1\nModern Dark Edition") + QMessageBox.information(self, tr("Über"), f"ProfiPrompt {__version__}\nModern Dark Edition") # --- Exports --- @report_storage_errors @@ -435,8 +436,13 @@ def handle_drag_request(self, kind, ids): bus.boardsChanged.emit() def main(): + if len(sys.argv) == 3 and sys.argv[1] == "--release-smoke": + from platform_smoke import run_platform_smoke + run_platform_smoke(sys.argv[2], headless=False) + return app = QApplication(sys.argv) app.setApplicationName("Prompt Manager") + app.setApplicationVersion(__version__) app.setWindowIcon(load_app_icon()) settings = SettingsManager() diff --git a/store_assets/Square150x150Logo.png b/store_assets/Square150x150Logo.png index f449e1f..83e1051 100644 Binary files a/store_assets/Square150x150Logo.png and b/store_assets/Square150x150Logo.png differ diff --git a/store_assets/Square310x310Logo.png b/store_assets/Square310x310Logo.png index 13ace70..9d6e85f 100644 Binary files a/store_assets/Square310x310Logo.png and b/store_assets/Square310x310Logo.png differ diff --git a/store_assets/Square44x44Logo.png b/store_assets/Square44x44Logo.png index d9c3cb6..c4702af 100644 Binary files a/store_assets/Square44x44Logo.png and b/store_assets/Square44x44Logo.png differ diff --git a/store_assets/Square44x44Logo.targetsize-16.png b/store_assets/Square44x44Logo.targetsize-16.png new file mode 100644 index 0000000..eccada5 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-16.png differ diff --git a/store_assets/Square44x44Logo.targetsize-16_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-16_altform-lightunplated.png new file mode 100644 index 0000000..eccada5 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-16_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-16_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-16_altform-unplated.png new file mode 100644 index 0000000..eccada5 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-16_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-20.png b/store_assets/Square44x44Logo.targetsize-20.png new file mode 100644 index 0000000..25a07cf Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-20.png differ diff --git a/store_assets/Square44x44Logo.targetsize-20_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-20_altform-lightunplated.png new file mode 100644 index 0000000..25a07cf Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-20_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-20_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-20_altform-unplated.png new file mode 100644 index 0000000..25a07cf Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-20_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-24.png b/store_assets/Square44x44Logo.targetsize-24.png new file mode 100644 index 0000000..443c821 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-24.png differ diff --git a/store_assets/Square44x44Logo.targetsize-24_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-24_altform-lightunplated.png new file mode 100644 index 0000000..443c821 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-24_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-24_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..443c821 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-256.png b/store_assets/Square44x44Logo.targetsize-256.png new file mode 100644 index 0000000..1701486 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-256.png differ diff --git a/store_assets/Square44x44Logo.targetsize-256_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-256_altform-lightunplated.png new file mode 100644 index 0000000..1701486 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-256_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-256_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-256_altform-unplated.png new file mode 100644 index 0000000..1701486 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-256_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-30.png b/store_assets/Square44x44Logo.targetsize-30.png new file mode 100644 index 0000000..7bd3abb Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-30.png differ diff --git a/store_assets/Square44x44Logo.targetsize-30_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-30_altform-lightunplated.png new file mode 100644 index 0000000..7bd3abb Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-30_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-30_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-30_altform-unplated.png new file mode 100644 index 0000000..7bd3abb Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-30_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-32.png b/store_assets/Square44x44Logo.targetsize-32.png new file mode 100644 index 0000000..816bce4 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-32.png differ diff --git a/store_assets/Square44x44Logo.targetsize-32_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-32_altform-lightunplated.png new file mode 100644 index 0000000..816bce4 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-32_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-32_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-32_altform-unplated.png new file mode 100644 index 0000000..816bce4 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-32_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-36.png b/store_assets/Square44x44Logo.targetsize-36.png new file mode 100644 index 0000000..9224516 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-36.png differ diff --git a/store_assets/Square44x44Logo.targetsize-36_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-36_altform-lightunplated.png new file mode 100644 index 0000000..9224516 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-36_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-36_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-36_altform-unplated.png new file mode 100644 index 0000000..9224516 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-36_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-40.png b/store_assets/Square44x44Logo.targetsize-40.png new file mode 100644 index 0000000..488ce5b Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-40.png differ diff --git a/store_assets/Square44x44Logo.targetsize-40_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-40_altform-lightunplated.png new file mode 100644 index 0000000..488ce5b Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-40_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-40_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-40_altform-unplated.png new file mode 100644 index 0000000..488ce5b Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-40_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-48.png b/store_assets/Square44x44Logo.targetsize-48.png new file mode 100644 index 0000000..9a7dab9 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-48.png differ diff --git a/store_assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-48_altform-lightunplated.png new file mode 100644 index 0000000..9a7dab9 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-48_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-48_altform-unplated.png new file mode 100644 index 0000000..9a7dab9 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-48_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-60.png b/store_assets/Square44x44Logo.targetsize-60.png new file mode 100644 index 0000000..b9e0993 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-60.png differ diff --git a/store_assets/Square44x44Logo.targetsize-60_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-60_altform-lightunplated.png new file mode 100644 index 0000000..b9e0993 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-60_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-60_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-60_altform-unplated.png new file mode 100644 index 0000000..b9e0993 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-60_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-64.png b/store_assets/Square44x44Logo.targetsize-64.png new file mode 100644 index 0000000..354be43 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-64.png differ diff --git a/store_assets/Square44x44Logo.targetsize-64_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-64_altform-lightunplated.png new file mode 100644 index 0000000..354be43 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-64_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-64_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-64_altform-unplated.png new file mode 100644 index 0000000..354be43 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-64_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-72.png b/store_assets/Square44x44Logo.targetsize-72.png new file mode 100644 index 0000000..a7f1a01 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-72.png differ diff --git a/store_assets/Square44x44Logo.targetsize-72_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-72_altform-lightunplated.png new file mode 100644 index 0000000..a7f1a01 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-72_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-72_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-72_altform-unplated.png new file mode 100644 index 0000000..a7f1a01 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-72_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-80.png b/store_assets/Square44x44Logo.targetsize-80.png new file mode 100644 index 0000000..1b625d5 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-80.png differ diff --git a/store_assets/Square44x44Logo.targetsize-80_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-80_altform-lightunplated.png new file mode 100644 index 0000000..1b625d5 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-80_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-80_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-80_altform-unplated.png new file mode 100644 index 0000000..1b625d5 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-80_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-96.png b/store_assets/Square44x44Logo.targetsize-96.png new file mode 100644 index 0000000..218b509 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-96.png differ diff --git a/store_assets/Square44x44Logo.targetsize-96_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-96_altform-lightunplated.png new file mode 100644 index 0000000..218b509 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-96_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-96_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-96_altform-unplated.png new file mode 100644 index 0000000..218b509 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-96_altform-unplated.png differ diff --git a/store_assets/Wide310x150Logo.png b/store_assets/Wide310x150Logo.png index ddcb80e..7effc68 100644 Binary files a/store_assets/Wide310x150Logo.png and b/store_assets/Wide310x150Logo.png differ diff --git a/store_assets/icon_150x150.png b/store_assets/icon_150x150.png index f449e1f..83e1051 100644 Binary files a/store_assets/icon_150x150.png and b/store_assets/icon_150x150.png differ diff --git a/store_assets/icon_310x150.png b/store_assets/icon_310x150.png index ddcb80e..7effc68 100644 Binary files a/store_assets/icon_310x150.png and b/store_assets/icon_310x150.png differ diff --git a/store_assets/icon_310x310.png b/store_assets/icon_310x310.png index 13ace70..9d6e85f 100644 Binary files a/store_assets/icon_310x310.png and b/store_assets/icon_310x310.png differ diff --git a/store_assets/icon_44x44.png b/store_assets/icon_44x44.png index d9c3cb6..c4702af 100644 Binary files a/store_assets/icon_44x44.png and b/store_assets/icon_44x44.png differ diff --git a/store_assets/icon_alternative_1.png b/store_assets/icon_alternative_1.png index 2eb9f1d..258e910 100644 Binary files a/store_assets/icon_alternative_1.png and b/store_assets/icon_alternative_1.png differ diff --git a/store_assets/icon_alternative_2.png b/store_assets/icon_alternative_2.png index f66ea64..258e910 100644 Binary files a/store_assets/icon_alternative_2.png and b/store_assets/icon_alternative_2.png differ diff --git a/store_package.json b/store_package.json index 6efb8fb..650d0f8 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.ProfiPrompt", - "version": "1.0.1.0", + "version": "1.0.2.0", "description": "Desktop-Tool zur Verwaltung und Versionierung von AI-Prompts. Boards, Kachel-Ansicht, Clipboard-Integration, TXT/PDF-Export. Offline-First.", "executable": "ProfiPrompt.exe", "capabilities": "internetClient,runFullTrust", @@ -12,4 +12,4 @@ "privacy_url": "https://github.com/file-bricks/ProfiPrompt/blob/master/PRIVACY_POLICY.md", "support_url": "https://github.com/file-bricks/ProfiPrompt/issues", "languages": "en-us,de-de" -} \ No newline at end of file +} diff --git a/tests/test_basic.py b/tests/test_basic.py index 460d7ef..8c4c3ca 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -330,7 +330,8 @@ def test_library_export_empty_storage(tmp_path): assert payload["schema_version"] == SCHEMA_VERSION assert payload["app"]["name"] == "ProfiPrompt" - assert payload["app"]["version"] == "1.0.1" + from app_version import __version__ + assert payload["app"]["version"] == __version__ assert payload["app"]["exported_at"] == "2026-05-24T00:00:00+00:00" assert payload["stats"] == { "prompt_count": 0, diff --git a/tests/test_bugsweep_board_manager_20260923.py b/tests/test_bugsweep_board_manager_20260923.py index 2f64841..f9d35d9 100644 --- a/tests/test_bugsweep_board_manager_20260923.py +++ b/tests/test_bugsweep_board_manager_20260923.py @@ -143,7 +143,7 @@ def test_bm04_drop_event_rejects_nonexistent_version(qapp, tmp_path): assert len(boards[0].items) == 0, "Ungueltiger Versions-Drop hat faelschlicherweise Item angelegt" -def test_bm04_remove_item_emits_boards_changed(qapp, tmp_path): +def test_bm04_remove_item_emits_boards_changed(qapp, tmp_path, monkeypatch): """BUG-BM04: _remove_item_from_board() muss bus.boardsChanged emittieren.""" st = _storage.Storage(tmp_path) settings = _sm.SettingsManager() @@ -163,6 +163,7 @@ def test_bm04_remove_item_emits_boards_changed(qapp, tmp_path): signal_received = [] bus.boardsChanged.connect(lambda: signal_received.append(True)) + monkeypatch.setattr(QtWidgets.QMessageBox, "question", lambda *a, **k: QtWidgets.QMessageBox.StandardButton.Yes) try: bm._remove_item_from_board(tiles[0]) assert len(signal_received) > 0, "_remove_item_from_board() hat boardsChanged nicht emittiert" diff --git a/tests/test_i18n_live_board_transfer_tile_colors_20261006.py b/tests/test_i18n_live_board_transfer_tile_colors_20261006.py index 8bb2be8..9347f1c 100644 --- a/tests/test_i18n_live_board_transfer_tile_colors_20261006.py +++ b/tests/test_i18n_live_board_transfer_tile_colors_20261006.py @@ -366,22 +366,31 @@ def test_board_rename_via_ui(qapp, storage, settings, monkeypatch): # --------------------------------------------------------------------------- Responsive grid -def test_grid_columns_follow_viewport_width_without_overlap(qapp, tmp_path, settings): +def test_grid_columns_follow_viewport_width_without_overlap(qapp, qtbot, tmp_path, settings): st = Storage(tmp_path / "grid") st.save_prompts([Prompt(id=f"p{i}", title=f"T{i}", purpose="", text="x") for i in range(5)]) st.save_boards([Board(id="b", title="B", items=[ BoardItem(id=f"i{i}", board_id="b", prompt_id=f"p{i}") for i in range(5)])]) bm = BoardManager(st, settings) try: + def settled(columns): + visible = [tile for tile in _tiles(bm) if tile.isVisible()] + rects = [tile.geometry() for tile in visible] + return bm._layout_cols == columns and len(visible) == 5 and all( + not a.intersects(b) for i, a in enumerate(rects) for b in rects[i + 1:] + ) + + # Top-level minimum width depends on theme, font and native platform. + # Test the grid's actual viewport and wait for Qt's queued layout work. + bm.scroll.setFixedWidth(340) bm.resize(340, 600) bm.show() qapp.processEvents() bm.reload_items() - qapp.processEvents() - assert bm._layout_cols == 1 + qtbot.waitUntil(lambda: settled(1), timeout=1000) + bm.scroll.setFixedWidth(1000) bm.resize(1000, 600) - qapp.processEvents() - assert bm._layout_cols == 3 + qtbot.waitUntil(lambda: settled(3), timeout=1000) visible = [t for t in _tiles(bm) if t.isVisible()] assert len(visible) == 5 rects = [t.geometry() for t in visible] diff --git a/tests/test_platform_smoke.py b/tests/test_platform_smoke.py index 8aaf4ba..f7a6229 100644 --- a/tests/test_platform_smoke.py +++ b/tests/test_platform_smoke.py @@ -32,3 +32,59 @@ def test_run_platform_smoke_creates_expected_artifacts(tmp_path): txt_export = Path(summary["artifacts"]["prompt_txt"]).read_text(encoding="utf-8") assert "Begrüßung" in txt_export assert "überblick" in txt_export + +def test_failed_pdf_export_cannot_pass_smoke(tmp_path, monkeypatch): + import pytest + import platform_smoke + monkeypatch.setattr(platform_smoke, "export_single_prompt", lambda *a, **k: False) + with pytest.raises(RuntimeError, match="PDF export failed"): + platform_smoke.run_platform_smoke(tmp_path / "failed-smoke", headless=True) + + +def test_smoke_preserves_typed_clipboard_data_and_concurrent_copy(qapp, monkeypatch): + from PySide6.QtCore import QMimeData, QUrl + from PySide6.QtGui import QImage, QColor + from PySide6.QtWidgets import QApplication + from platform_smoke import _preserve_clipboard + + class Clipboard: + def __init__(self, mime): + self.mime = mime + + def mimeData(self): + return self.mime + + def setMimeData(self, mime): + self.mime = mime + + def text(self): + return self.mime.text() + + def setText(self, text): + self.mime = QMimeData() + self.mime.setText(text) + + original = QMimeData() + original.setText("Vorher") + original.setHtml("Vorher") + original.setData("application/x-smoke-test", b"binary\x00payload") + original.setUrls([QUrl("file:///C:/example.txt")]) + image = QImage(4, 4, QImage.Format_ARGB32) + image.fill(QColor("red")) + original.setImageData(image) + original.setColorData(QColor("blue")) + clipboard = Clipboard(original) + monkeypatch.setattr(QApplication, "clipboard", staticmethod(lambda: clipboard)) + with _preserve_clipboard("Smoke"): + clipboard.setText("Smoke") + restored = clipboard.mimeData() + assert restored.text() == "Vorher" + assert restored.html() == "Vorher" + assert bytes(restored.data("application/x-smoke-test")) == b"binary\x00payload" + assert restored.urls() == original.urls() + assert isinstance(restored.imageData(), QImage) + assert restored.imageData() == image + assert restored.colorData() == QColor("blue") + with _preserve_clipboard("Smoke"): + clipboard.setText("Neue Kopie") + assert clipboard.text() == "Neue Kopie" diff --git a/tests/test_release_review_regressions.py b/tests/test_release_review_regressions.py new file mode 100644 index 0000000..84b545d --- /dev/null +++ b/tests/test_release_review_regressions.py @@ -0,0 +1,47 @@ +"""Release regressions: confirmation before mutation and durable writes.""" +import errno +import pytest +from PySide6 import QtCore, QtGui, QtWidgets +import atomic_io +from storage import Storage +from settings_manager import SettingsManager +from models import Board, BoardItem, Prompt +from board_manager import BoardManager + +@pytest.mark.parametrize("key", [QtCore.Qt.Key.Key_Delete, QtCore.Qt.Key.Key_Backspace]) +@pytest.mark.parametrize("answer", [QtWidgets.QMessageBox.StandardButton.No, QtWidgets.QMessageBox.StandardButton.Yes]) +def test_tile_removal_waits_for_confirmation(qapp, tmp_path, monkeypatch, key, answer): + storage = Storage(tmp_path / "data") + storage.save_prompts([Prompt(id="p1", title="Gruß", purpose="", text="äöü", tags=[], versions=[])]) + storage.save_boards([Board(id="b1", title="Board", items=[BoardItem(id="i1", board_id="b1", prompt_id="p1")])]) + settings = SettingsManager() + settings.qs = QtCore.QSettings(str(tmp_path / "settings.ini"), QtCore.QSettings.IniFormat) + manager = BoardManager(storage, settings) + original = storage.boards_file.read_bytes() + calls = [] + def question(*args): + calls.append(len(storage.load_boards()[0].items)) + assert storage.boards_file.read_bytes() == original + return answer + monkeypatch.setattr(QtWidgets.QMessageBox, "question", question) + tile = manager._tiles[0] + tile.keyPressEvent(QtGui.QKeyEvent(QtCore.QEvent.KeyPress, key, QtCore.Qt.NoModifier)) + assert calls == [1] + assert len(storage.load_boards()[0].items) == int(answer == QtWidgets.QMessageBox.StandardButton.No) + if answer == QtWidgets.QMessageBox.StandardButton.No: + assert storage.boards_file.read_bytes() == original + manager.deleteLater() + qapp.sendPostedEvents(None, QtCore.QEvent.DeferredDelete) + +@pytest.mark.parametrize("error", [errno.EIO, errno.ENOSPC]) +def test_fsync_failure_preserves_original(tmp_path, monkeypatch, error): + target = tmp_path / "backup.json" + target.write_bytes(b"previous durable data") + replaced = [] + monkeypatch.setattr(atomic_io.os, "fsync", lambda fd: (_ for _ in ()).throw(OSError(error, "disk failure"))) + monkeypatch.setattr(atomic_io.os, "replace", lambda *args: replaced.append(args)) + with pytest.raises(OSError): + atomic_io.atomic_write_text(target, "unconfirmed replacement") + assert target.read_bytes() == b"previous durable data" + assert replaced == [] + assert list(tmp_path.glob(".*.tmp.*")) == [] diff --git a/tests/test_security_license_contract.py b/tests/test_security_license_contract.py index f2d559d..259ad70 100644 --- a/tests/test_security_license_contract.py +++ b/tests/test_security_license_contract.py @@ -411,8 +411,8 @@ def test_changelog_pfad_a_entry() -> None: unreleased_idx = text.find("## [Unreleased]") assert unreleased_idx != -1, "CHANGELOG.md must contain ## [Unreleased]" - next_release_idx = text.find("\n## [", unreleased_idx + 1) - unreleased_section = text[unreleased_idx:next_release_idx] if next_release_idx != -1 else text[unreleased_idx:unreleased_idx + 15000] + # Completed hygiene work may remain under its released version. + unreleased_section = text[unreleased_idx:] assert "Pfad A" in unreleased_section, "Pfad A must be documented in [Unreleased]" assert "2026-09-28" in unreleased_section, "Audit date 2026-09-28 must be documented in [Unreleased]"