From 9581a323a4efd70bace50ef9fa7c626cd970a9a4 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Fri, 2 Oct 2026 00:29:12 +0200 Subject: [PATCH 1/2] fix: preserve libraries and backups after failed reads --- CHANGELOG.md | 9 + LIBRARY_SAFETY.md | 26 +++ src/board_manager.py | 23 ++- src/dashboard.py | 4 +- src/library_export.py | 9 +- src/pdf_exporter.py | 5 +- src/profiprompt.py | 2 + src/prompt_dialog.py | 38 ++-- src/storage.py | 148 ++++++++++++---- src/storage_actions.py | 19 ++ tests/test_bugsweep_storage_20260628.py | 9 +- tests/test_library_load_safety.py | 223 ++++++++++++++++++++++++ 12 files changed, 441 insertions(+), 74 deletions(-) create mode 100644 LIBRARY_SAFETY.md create mode 100644 src/storage_actions.py create mode 100644 tests/test_library_load_safety.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 300bb70..e98ceac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Bibliotheksdaten nach Ladefehlern erhalten (2026-10-02) + +- Änderungen und direkte Speicher-APIs brechen bei unlesbaren oder beschädigten Bibliotheksdateien ab, statt vorhandene Daten als leere Bibliothek zu überschreiben. +- Prompt-/Versionslöschungen lesen Prompt- und Board-Datei vor dem ersten Schreiben. Änderungen desselben Storage-Objekts halten einen gemeinsamen Lock. +- JSON- und PDF-Gesamtexporte verwenden vollständig gelesene Bibliotheken; vorhandene Sicherungen bleiben bei Ladefehlern erhalten. +- Speicherdialoge bleiben bei Fehlern offen und verändern geteilte Modelle erst nach erfolgreichem Speichern. Entfernte Prompts führen nicht zu einer falschen Erfolgsmeldung für Versionen. +- Der fehlende Speicherpfad zum Entfernen einer Board-Kachel ist ergänzt; beide Bedienwege verwenden ihn. +- Grenzen und manuelle Wiederherstellung sind in `LIBRARY_SAFETY.md` beschrieben. Zwei JSON-Dateien bilden keine gemeinsame Transaktion; keine neue EXE oder Store-Abnahme. + ### Atomare Exporte, Fsync-Durability, Storage-Schutz & PDF-Validierung (2026-10-01) - **Atomare Datei-Operationen & Fsync-Durability (`src/atomic_io.py`):** diff --git a/LIBRARY_SAFETY.md b/LIBRARY_SAFETY.md new file mode 100644 index 0000000..54bc1c2 --- /dev/null +++ b/LIBRARY_SAFETY.md @@ -0,0 +1,26 @@ +# Bibliothek nach einem Lesefehler + +ProfiPrompt bricht Änderungen und Bibliotheksexporte ab, wenn eine benötigte +Bibliotheksdatei nicht vollständig gelesen werden kann. Das gilt für fehlende +Dateien, Zugriffsfehler, ungültiges UTF-8, beschädigtes JSON sowie ungültige +Listen und verschachtelte Einträge. Beschädigte Dateien werden nicht automatisch +als leere Bibliothek überschrieben. Ein vorhandener Bibliotheksexport bleibt +bei einem solchen Ladefehler erhalten. + +Die Anzeige kann bei einem Ladefehler weiterhin leer bleiben. Leere Anzeige ist +kein Beleg für eine leere Bibliothek. Beim Speichern erscheint eine Fehlermeldung; +der Dialog bleibt geöffnet und die eingegebenen Änderungen bleiben verfügbar. + +Prüfen Sie die angegebene Datei und ihre Zugriffsrechte. Sichern Sie beschädigte +Originaldateien, bevor Sie sie bewusst reparieren oder aus einer Sicherung +wiederherstellen. Eine Änderung lässt sich nach der Reparatur erneut versuchen. + +Beim Löschen eines Prompts oder einer Version werden Prompt- und Board-Datei +vor dem ersten Schreiben gelesen. Ein Prozess hält Änderungen desselben +Storage-Objekts über einen gemeinsamen Lock zusammen. Zwei getrennte JSON-Dateien +bilden damit keine gemeinsame Transaktion: Ein späterer Schreibfehler kann eine +teilweise ausgeführte Löschung hinterlassen. Gleichzeitige Änderungen anderer +Storage-Objekte oder Prozesse und Ausfälle des Rechners sind nicht abgesichert. + +Diese Änderung erstellt keine neue EXE und ersetzt keine Geräte-, Browser- oder +Store-Abnahme. Tests verwenden ausschließlich eigene temporäre Bibliotheken. diff --git a/src/board_manager.py b/src/board_manager.py index 0e4f01e..0ed92b8 100644 --- a/src/board_manager.py +++ b/src/board_manager.py @@ -8,6 +8,7 @@ from models import Board, Prompt, Version, BoardItem, gen_id from storage import Storage +from storage_actions import report_storage_errors from settings_manager import SettingsManager from event_bus import bus from clipboard_manager import ClipboardManager @@ -293,6 +294,7 @@ def reload(self, select_board_id: Optional[str] = None): self.reload_items() + @report_storage_errors def remove_tile_item(self, prompt_id: str, version_id: Optional[str] = None): board = self.current_board() if not board: @@ -361,6 +363,7 @@ def reload_items(self): self.grid.addItem(spacer, row + 1, 0) # --- Actions --- + @report_storage_errors def create_board(self): title, ok = QtWidgets.QInputDialog.getText(self, "Neues Board", "Name:") if ok and title.strip(): @@ -369,6 +372,7 @@ def create_board(self): self._pending_select_board_id = b.id bus.boardsChanged.emit() + @report_storage_errors def delete_current_board(self): b = self.current_board() if not b: return @@ -403,6 +407,7 @@ def _on_tile_context_menu(self, tile, gpos): menu.addAction("Vom Board entfernen", lambda: self._remove_item_from_board(tile)) menu.exec(gpos) + @report_storage_errors def _remove_item_from_board(self, tile): board = self.current_board() if not board: return @@ -410,19 +415,9 @@ def _remove_item_from_board(self, tile): pid = tile.prompt.id vid = tile.version.id if tile.version else None - # Nur das ERSTE passende Item entfernen (pop by index), Duplikate bleiben erhalten - removed_one = False - new_items = [] - for i in board.items: - if not removed_one and i.prompt_id == pid and i.version_id == vid: - removed_one = True - continue - new_items.append(i) - - board.items = new_items - self.storage.upsert_board(board) - self.reload_items() - bus.boardsChanged.emit() + if self.storage.remove_item_from_board(board.id, pid, vid): + self.reload_items() + bus.boardsChanged.emit() # --- Drag & Drop --- def dragEnterEvent(self, event: QtGui.QDragEnterEvent): @@ -433,7 +428,9 @@ def dragMoveEvent(self, event: QtGui.QDragMoveEvent): if event.mimeData().hasFormat(self.MIME) or event.mimeData().hasText(): event.acceptProposedAction() + @report_storage_errors def dropEvent(self, event: QtGui.QDropEvent): + event.ignore() md = event.mimeData() board = self.current_board() if not board: diff --git a/src/dashboard.py b/src/dashboard.py index 5704d3a..3ba83d5 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -8,6 +8,7 @@ from PySide6 import QtWidgets, QtCore, QtGui from models import Prompt, Version, now_iso from storage import Storage +from storage_actions import report_storage_errors from settings_manager import SettingsManager from event_bus import bus from prompt_dialog import PromptDialog, VersionDialog @@ -357,6 +358,7 @@ def safe_date(s: Optional[str]) -> str: for col in range(self.tree.columnCount()): self.tree.resizeColumnToContents(col) + @report_storage_errors def open_context_menu(self, pos: QtCore.QPoint): """ Zeigt Kontextmenü: @@ -517,6 +519,7 @@ def edit_current_item(self, item: Optional[QtWidgets.QTreeWidgetItem] = None): if target: self._on_item_double_clicked(target, 0) + @report_storage_errors def delete_current_item(self, item: Optional[QtWidgets.QTreeWidgetItem] = None): target = item or self.tree.currentItem() if not target: @@ -700,4 +703,3 @@ def create_prompt(self): bus.promptsChanged.emit() - diff --git a/src/library_export.py b/src/library_export.py index 09d8443..e1c248a 100644 --- a/src/library_export.py +++ b/src/library_export.py @@ -17,8 +17,13 @@ def build_library_export(storage, exported_at: str | None = None) -> dict[str, Any]: """Build a portable export payload from the current Storage state.""" - prompts = storage.load_prompts() - boards = storage.load_boards() + # Production Storage must not replace a backup with an empty/partial export + # after a failed read. Small in-memory providers retain the existing protocol. + if callable(getattr(storage, "load_library", None)): + prompts, boards = storage.load_library() + else: + prompts = storage.load_prompts() + boards = storage.load_boards() exported_at = exported_at or datetime.now(timezone.utc).isoformat() return { diff --git a/src/pdf_exporter.py b/src/pdf_exporter.py index 3f9a614..772a7db 100644 --- a/src/pdf_exporter.py +++ b/src/pdf_exporter.py @@ -86,7 +86,10 @@ def export_all_prompts(storage, settings, path: str, parent=None, protected_path protected.append(storage.boards_file) protected_paths = protected - prompts = storage.load_prompts() + if callable(getattr(storage, "load_library", None)): + prompts, _ = storage.load_library() + else: + prompts = storage.load_prompts() html = [""] for p in prompts or []: if not p: diff --git a/src/profiprompt.py b/src/profiprompt.py index 5dadd3e..30ac027 100644 --- a/src/profiprompt.py +++ b/src/profiprompt.py @@ -12,6 +12,7 @@ from settings_manager import SettingsManager from storage import Storage +from storage_actions import report_storage_errors from event_bus import bus from dashboard import DashboardWidget, sanitize_export_filename from board_manager import BoardManager @@ -412,6 +413,7 @@ def handle_copy_request(self, kind, item_id, parent): from PySide6.QtCore import QTimer QTimer.singleShot(1500, lambda: parent.setToolTip("")) + @report_storage_errors def handle_drag_request(self, kind, ids): # ids ist tuple (prompt_id, version_id) pid, vid = ids diff --git a/src/prompt_dialog.py b/src/prompt_dialog.py index 6791799..6108083 100644 --- a/src/prompt_dialog.py +++ b/src/prompt_dialog.py @@ -3,6 +3,8 @@ from typing import Optional, List from models import Prompt, Version, gen_id, now_iso from storage import Storage +from storage_actions import report_storage_errors +from dataclasses import replace class PromptDialog(QtWidgets.QDialog): def __init__(self, storage: Storage, prompt: Optional[Prompt] = None, parent=None): @@ -68,6 +70,7 @@ def _populate(self): item.setToolTip(v.text or "") self.versions_list.addItem(item) + @report_storage_errors def on_save(self): title = self.title_edit.text().strip() text = self.text_edit.toPlainText().strip() @@ -79,13 +82,10 @@ def on_save(self): result = self.result_edit.toPlainText().strip() if self.prompt: - self.prompt.title = title - self.prompt.purpose = purpose - self.prompt.tags = tags - self.prompt.text = text - self.prompt.last_result = result - self.prompt.updated_at = now_iso() - self.storage.upsert_prompt(self.prompt) + edited = replace(self.prompt, title=title, purpose=purpose, tags=tags, + text=text, last_result=result, updated_at=now_iso()) + self.storage.upsert_prompt(edited) + self.prompt = edited else: from models import Prompt as P p = P( @@ -179,18 +179,21 @@ def _validate(self) -> Optional[tuple[str, list[str], str, str]]: result = self.result_edit.toPlainText().strip() return title, tags, text, result + @report_storage_errors def _on_save_update(self): data = self._validate() if not data: return title, tags, text, result = data - # In-place bearbeiten - v = self.version - v.title = title - v.tags = tags - v.text = text - v.result = result - v.updated_at = now_iso() + # Erst nach erfolgreichem Speichern geteilte Modelle aktualisieren. + v = replace(self.version, title=title, tags=tags, text=text, + result=result, updated_at=now_iso()) + + pid = self.prompt.id if self.prompt else v.prompt_id + if not self.storage.upsert_version(pid, v): + QtWidgets.QMessageBox.warning(self, "Speichern fehlgeschlagen", "Der zugehörige Prompt wurde nicht gefunden.") + return + self.version = v # Bugsweep 2026-09-18 BUG-VD01: self.prompt.versions synchronisieren, falls # self.version als separates Objekt geladen wurde (z.B. get_prompt vs get_version) @@ -202,10 +205,9 @@ def _on_save_update(self): self.prompt.versions.append(v) self.prompt.updated_at = now_iso() - pid = self.prompt.id if self.prompt else v.prompt_id - self.storage.upsert_version(pid, v) self.accept() + @report_storage_errors def _on_save_create(self): data = self._validate() if not data: @@ -224,7 +226,9 @@ def _on_save_create(self): created_at=now_iso(), updated_at=now_iso(), ) - self.storage.add_version(self.prompt.id, new_v) + if not self.storage.add_version(self.prompt.id, new_v): + QtWidgets.QMessageBox.warning(self, "Speichern fehlgeschlagen", "Der zugehörige Prompt wurde nicht gefunden.") + return if self.prompt: self.prompt.versions.append(new_v) self.prompt.updated_at = now_iso() diff --git a/src/storage.py b/src/storage.py index f69fe1b..4d57374 100644 --- a/src/storage.py +++ b/src/storage.py @@ -1,19 +1,49 @@ # storage.py import json -import os import threading +from functools import wraps from pathlib import Path from typing import List, Optional, Tuple from atomic_io import atomic_write_json from models import Prompt, Version, Board, BoardItem, prompt_from_dict, prompt_to_dict, board_from_dict, board_to_dict, gen_id, now_iso +class StorageReadError(OSError): + """A library could not be read completely; a mutation must not replace it.""" + + def __init__(self, path: Path): + super().__init__(f"Bibliotheksdatei konnte nicht vollständig gelesen werden: {path}") + + +def _locked(method): + @wraps(method) + def run(self, *args, **kwargs): + with self._lock: + return method(self, *args, **kwargs) + return run + + +def _validate_records(data, key, nested_key): + if not isinstance(data, dict) or key not in data or not isinstance(data[key], list): + raise ValueError(f"Ungültige Bibliotheksstruktur: {key}") + for record in data[key]: + if not isinstance(record, dict): + raise ValueError(f"Ungültiger Eintrag: {key}") + nested = record.get(nested_key, []) + # Existing models support null placeholders as empty entries. Reject + # informative malformed entries rather than silently dropping them. + if nested is None and nested_key == "versions": + nested = [] + if not isinstance(nested, list) or any(item is not None and not isinstance(item, dict) for item in nested): + raise ValueError(f"Ungültige Bibliotheksstruktur: {nested_key}") + + class Storage: def __init__(self, data_dir: Path): self.data_dir = Path(data_dir) self.data_dir.mkdir(parents=True, exist_ok=True) self.prompts_file = self.data_dir / "prompts.json" self.boards_file = self.data_dir / "boards.json" - self._lock = threading.Lock() + self._lock = threading.RLock() self._ensure_files() def _ensure_files(self): @@ -23,16 +53,20 @@ def _ensure_files(self): self._atomic_write(self.boards_file, {"boards": []}) # --- Prompts --- - def load_prompts(self) -> List[Prompt]: + def load_prompts(self, *, strict: bool = False) -> List[Prompt]: # Bugsweep 28 BUG-PS02: OSError (inkl. FileNotFoundError/PermissionError) war # ungefangen — z.B. wenn prompts.json zwischen _ensure_files und dem Lesen # gelöscht wird (fehlgeschlagener .tmp-Rename, OneDrive-Lock). UnicodeDecodeError # ist bereits via ValueError abgedeckt; OSError fehlte komplett. try: data = json.loads(self.prompts_file.read_text(encoding="utf-8")) - except (json.JSONDecodeError, ValueError, OSError): - data = {} - return [prompt_from_dict(p) for p in data.get("prompts", [])] + if strict: + _validate_records(data, "prompts", "versions") + return [prompt_from_dict(p) for p in data.get("prompts", [])] + except (json.JSONDecodeError, ValueError, OSError, TypeError, KeyError, AttributeError) as exc: + if strict: + raise StorageReadError(self.prompts_file) from exc + return [] def get_prompt(self, prompt_id: str) -> Optional[Prompt]: for p in self.load_prompts(): @@ -46,24 +80,30 @@ def _atomic_write(self, target: Path, data: dict): atomic_write_json(target, data, indent=2) def save_prompts(self, prompts: List[Prompt]): - data = {"prompts": [prompt_to_dict(p) for p in prompts]} with self._lock: - self._atomic_write(self.prompts_file, data) + self.load_prompts(strict=True) + self._write_prompts(prompts) + + def _write_prompts(self, prompts: List[Prompt]): + """Private write: caller holds the lock and has read all required inputs.""" + self._atomic_write(self.prompts_file, {"prompts": [prompt_to_dict(p) for p in prompts]}) + @_locked def upsert_prompt(self, prompt: Prompt): - prompts = self.load_prompts() + prompts = self.load_prompts(strict=True) idx = next((i for i, p in enumerate(prompts) if p.id == prompt.id), -1) if idx >= 0: prompts[idx] = prompt else: prompts.append(prompt) - self.save_prompts(prompts) + self._write_prompts(prompts) + @_locked def delete_prompt(self, prompt_id: str): - prompts = [p for p in self.load_prompts() if p.id != prompt_id] - self.save_prompts(prompts) + prompts = [p for p in self.load_prompts(strict=True) if p.id != prompt_id] + boards = self.load_boards(strict=True) + self._write_prompts(prompts) # Bugsweep 2026-09-18 BUG-VD03: Verwaiste Board-Referenzen entfernen - boards = self.load_boards() changed = False for b in boards: orig_len = len(b.items) @@ -71,20 +111,23 @@ def delete_prompt(self, prompt_id: str): if len(b.items) != orig_len: changed = True if changed: - self.save_boards(boards) + self._write_boards(boards) - def add_version(self, prompt_id: str, version: Version): - prompts = self.load_prompts() + @_locked + def add_version(self, prompt_id: str, version: Version) -> bool: + prompts = self.load_prompts(strict=True) for p in prompts: if p.id == prompt_id: p.versions.append(version) p.updated_at = now_iso() - break - self.save_prompts(prompts) + self._write_prompts(prompts) + return True + return False + @_locked def upsert_version(self, prompt_id: str, version: Version) -> bool: """Aktualisiert eine existierende Version oder fügt sie hinzu (BUG-VD02).""" - prompts = self.load_prompts() + prompts = self.load_prompts(strict=True) for p in prompts: if p.id == prompt_id: idx = next((i for i, v in enumerate(p.versions) if v.id == version.id), -1) @@ -93,13 +136,14 @@ def upsert_version(self, prompt_id: str, version: Version) -> bool: else: p.versions.append(version) p.updated_at = now_iso() - self.save_prompts(prompts) + self._write_prompts(prompts) return True return False + @_locked def delete_version(self, prompt_id: str, version_id: str) -> bool: """Löscht eine Version und bereinigt zugehörige BoardItems (BUG-VD03).""" - prompts = self.load_prompts() + prompts = self.load_prompts(strict=True) found = False for p in prompts: if p.id == prompt_id: @@ -109,8 +153,8 @@ def delete_version(self, prompt_id: str, version_id: str) -> bool: break if not found: return False - self.save_prompts(prompts) - boards = self.load_boards() + boards = self.load_boards(strict=True) + self._write_prompts(prompts) changed = False for b in boards: orig_len = len(b.items) @@ -118,7 +162,7 @@ def delete_version(self, prompt_id: str, version_id: str) -> bool: if len(b.items) != orig_len: changed = True if changed: - self.save_boards(boards) + self._write_boards(boards) return True def get_version(self, prompt_id: str, version_id: str) -> Optional[Version]: @@ -128,7 +172,7 @@ def get_version(self, prompt_id: str, version_id: str) -> Optional[Version]: return next((v for v in p.versions if v.id == version_id), None) def next_version_number(self, prompt_id: str) -> int: - p = self.get_prompt(prompt_id) + p = next((p for p in self.load_prompts(strict=True) if p.id == prompt_id), None) if not p or not p.versions: return 1 nums = [ @@ -139,43 +183,59 @@ def next_version_number(self, prompt_id: str) -> int: return (max(nums) + 1) if nums else 1 # --- Boards --- - def load_boards(self) -> List[Board]: + def load_boards(self, *, strict: bool = False) -> List[Board]: # Bugsweep 28 BUG-PS02: identisch zu load_prompts — OSError ungefangen. try: data = json.loads(self.boards_file.read_text(encoding="utf-8")) - except (json.JSONDecodeError, ValueError, OSError): - data = {} - return [board_from_dict(b) for b in data.get("boards", [])] + if strict: + _validate_records(data, "boards", "items") + return [board_from_dict(b) for b in data.get("boards", [])] + except (json.JSONDecodeError, ValueError, OSError, TypeError, KeyError, AttributeError) as exc: + if strict: + raise StorageReadError(self.boards_file) from exc + return [] + + @_locked + def load_library(self) -> Tuple[List[Prompt], List[Board]]: + """Read both library files without hiding errors, e.g. before exporting.""" + return self.load_prompts(strict=True), self.load_boards(strict=True) def save_boards(self, boards: List[Board]): - data = {"boards": [board_to_dict(b) for b in boards]} with self._lock: - self._atomic_write(self.boards_file, data) + self.load_boards(strict=True) + self._write_boards(boards) + + def _write_boards(self, boards: List[Board]): + """Private write: caller holds the lock and has read all required inputs.""" + self._atomic_write(self.boards_file, {"boards": [board_to_dict(b) for b in boards]}) + @_locked def upsert_board(self, board: Board): - boards = self.load_boards() + boards = self.load_boards(strict=True) idx = next((i for i, b in enumerate(boards) if b.id == board.id), -1) if idx >= 0: boards[idx] = board else: boards.append(board) - self.save_boards(boards) + self._write_boards(boards) + @_locked def delete_board(self, board_id: str): - boards = [b for b in self.load_boards() if b.id != board_id] - self.save_boards(boards) + boards = [b for b in self.load_boards(strict=True) if b.id != board_id] + self._write_boards(boards) + @_locked def add_item_to_board(self, board_id: str, prompt_id: str, version_id: Optional[str] = None, validate_prompt: bool = False) -> Tuple[bool, Optional[str]]: if not prompt_id or not str(prompt_id).strip(): return False, None if validate_prompt: - p = self.get_prompt(prompt_id) + p = next((p for p in self.load_prompts(strict=True) if p.id == prompt_id), None) if not p: return False, None if version_id and not any(v.id == version_id for v in p.versions): return False, None - boards = self.load_boards() + boards = self.load_boards(strict=True) for b in boards: if b.id == board_id: # Verhindere Duplikate @@ -184,6 +244,18 @@ def add_item_to_board(self, board_id: str, prompt_id: str, version_id: Optional[ return False, None item = BoardItem(id=gen_id(), board_id=board_id, prompt_id=prompt_id, version_id=version_id) b.items.append(item) - self.save_boards(boards) + self._write_boards(boards) return True, item.id return False, None + + @_locked + def remove_item_from_board(self, board_id: str, prompt_id: str, version_id: Optional[str] = None) -> bool: + boards = self.load_boards(strict=True) + for board in boards: + if board.id == board_id: + for index, item in enumerate(board.items): + if item.prompt_id == prompt_id and item.version_id == version_id: + board.items.pop(index) + self._write_boards(boards) + return True + return False diff --git a/src/storage_actions.py b/src/storage_actions.py new file mode 100644 index 0000000..53c450c --- /dev/null +++ b/src/storage_actions.py @@ -0,0 +1,19 @@ +"""Keep Qt actions open and report unsuccessful library operations.""" +from functools import wraps +from PySide6 import QtWidgets + + +def report_storage_errors(action): + @wraps(action) + def run(self, *args, **kwargs): + try: + return action(self, *args, **kwargs) + except OSError as error: + QtWidgets.QMessageBox.critical( + self, "Bibliothek nicht verfügbar", + "Die Bibliothek konnte nicht gelesen oder gespeichert werden.\n" + "Prüfen Sie die Datei und ihre Zugriffsrechte und versuchen Sie es erneut.\n\n" + f"{error}", + ) + return None + return run diff --git a/tests/test_bugsweep_storage_20260628.py b/tests/test_bugsweep_storage_20260628.py index 9d6da3c..484aab9 100644 --- a/tests/test_bugsweep_storage_20260628.py +++ b/tests/test_bugsweep_storage_20260628.py @@ -161,7 +161,8 @@ def test_ps02_load_boards_survives_deleted_file(tmp_path): def test_ps02_load_prompts_recovers_after_corrupt_file(tmp_path): - """Nach Reparatur (upsert) einer korrupten Datei werden Prompts korrekt geladen.""" + """Eine Änderung darf beschädigte Daten nicht als leere Bibliothek ersetzen.""" + import pytest importlib.reload(_models_mod) importlib.reload(_storage_mod) s = _storage_mod.Storage(tmp_path) @@ -169,7 +170,7 @@ def test_ps02_load_prompts_recovers_after_corrupt_file(tmp_path): s.prompts_file.write_bytes(b"\xff\xfe invalid utf-8") empty = s.load_prompts() assert empty == [] - # 2. Reparatur über upsert → Datei wird korrekt (neu) geschrieben + # 2. Automatisches Überschreiben wäre Datenverlust. Erst explizit reparieren. p = _models_mod.Prompt( id=_models_mod.gen_id(), title="Wiederhergestellt", @@ -177,6 +178,10 @@ def test_ps02_load_prompts_recovers_after_corrupt_file(tmp_path): text="", tags=[], ) + with pytest.raises(OSError): + s.upsert_prompt(p) + assert s.prompts_file.read_bytes() == b"\xff\xfe invalid utf-8" + s.prompts_file.write_text(json.dumps({"prompts": []}), encoding="utf-8") s.upsert_prompt(p) result = s.load_prompts() assert len(result) == 1 diff --git a/tests/test_library_load_safety.py b/tests/test_library_load_safety.py new file mode 100644 index 0000000..a10fb86 --- /dev/null +++ b/tests/test_library_load_safety.py @@ -0,0 +1,223 @@ +import json +from pathlib import Path +import pytest +from models import Board, BoardItem, Prompt, Version +from storage import Storage +from library_export import write_library_export + + +@pytest.fixture +def store(tmp_path): + s = Storage(tmp_path/'library') + p = Prompt('p','Grüße','','Vorhandener Text',versions=[Version('v','p',1,'Version','Inhalt')]) + s.save_prompts([p]) + s.save_boards([Board('b','Übersicht',items=[BoardItem('i','b','p','v')])]) + return s + + +def mutate(s, action): + p = Prompt('new','Neu','','Text') + v = Version('v','p',2,'Neu','Text') + return { + 'upsert_prompt':lambda:s.upsert_prompt(p), + 'delete_prompt':lambda:s.delete_prompt('p'), + 'add_version':lambda:s.add_version('p',v), + 'upsert_version':lambda:s.upsert_version('p',v), + 'delete_version':lambda:s.delete_version('p','v'), + 'save_prompts':lambda:s.save_prompts([p]), + 'upsert_board':lambda:s.upsert_board(Board('new','Neu')), + 'delete_board':lambda:s.delete_board('b'), + 'add_item_to_board':lambda:s.add_item_to_board('b','new'), + 'save_boards':lambda:s.save_boards([]), + 'remove_item_from_board':lambda:s.remove_item_from_board('b','p','v'), + }[action]() + + +PROMPT_ACTIONS = ['upsert_prompt','delete_prompt','add_version','upsert_version','delete_version','save_prompts'] +BOARD_ACTIONS = ['upsert_board','delete_board','add_item_to_board','save_boards','remove_item_from_board'] + + +@pytest.mark.parametrize('action',PROMPT_ACTIONS+BOARD_ACTIONS) +@pytest.mark.parametrize('failure',['permission','missing','utf8','json']) +def test_mutation_keeps_both_files_on_failed_load(store,monkeypatch,action,failure): + target=store.prompts_file if action in PROMPT_ACTIONS else store.boards_file + if failure=='utf8':target.write_bytes(b'\xffunreadable') + elif failure=='json':target.write_bytes(b'{"incomplete":') + before={f:f.read_bytes() for f in (store.prompts_file,store.boards_file)} + original=Path.read_text + if failure in ('permission','missing'): + error=PermissionError if failure=='permission' else FileNotFoundError + def read(path,*args,**kwargs): + if path==target:raise error('synthetic library read failure') + return original(path,*args,**kwargs) + monkeypatch.setattr(Path,'read_text',read) + with pytest.raises(OSError):mutate(store,action) + assert {f:f.read_bytes() for f in before}==before + + +@pytest.mark.parametrize('action',['delete_prompt','delete_version']) +def test_delete_preflights_boards_before_writing_prompts(store,action): + store.boards_file.write_bytes(b'{broken board data') + before=[f.read_bytes() for f in (store.prompts_file,store.boards_file)] + with pytest.raises(OSError):mutate(store,action) + assert [f.read_bytes() for f in (store.prompts_file,store.boards_file)]==before + + +@pytest.mark.parametrize('key,value',[('prompts',None),('prompts',{}),('prompts',[None]),('prompts',[{'id':'p','versions':['unparsed version']}]),('prompts',[{'id':'p','versions':{}}]),('boards',None),('boards',[{'id':'b','items':['unparsed item']}]),('boards',[{'id':'b','items':{}}])]) +def test_malformed_structure_is_not_silently_discarded(store,key,value): + target=store.prompts_file if key=='prompts' else store.boards_file + target.write_text(json.dumps({key:value}),encoding='utf-8') + before=target.read_bytes() + with pytest.raises(OSError):mutate(store,'upsert_prompt' if key=='prompts' else 'upsert_board') + assert target.read_bytes()==before + + +@pytest.mark.parametrize('key',['prompts','boards']) +def test_export_keeps_previous_backup_when_library_is_unreadable(store,tmp_path,key): + target=store.prompts_file if key=='prompts' else store.boards_file + target.write_bytes(b'not valid JSON') + backup=tmp_path/'backup.json' + backup.write_bytes(b'previous backup') + with pytest.raises(OSError):write_library_export(store,backup) + assert backup.read_bytes()==b'previous backup' + + +def test_missing_file_after_creation_is_not_recreated_by_save(store): + store.prompts_file.unlink() + with pytest.raises(OSError):mutate(store,'upsert_prompt') + assert not store.prompts_file.exists() + + +def test_normal_mutations_keep_existing_entries_and_cleanup_references(store): + mutate(store,'upsert_prompt') + assert {p.id for p in store.load_prompts()}=={'p','new'} + mutate(store,'delete_version') + assert store.get_prompt('p').versions==[] + assert store.load_boards()[0].items==[] + assert store.remove_item_from_board('b','missing') is False + + +def test_recovery_requires_repaired_source_and_succeeds_without_restart(store): + old=store.prompts_file.read_bytes() + store.prompts_file.write_bytes(b'{damaged') + with pytest.raises(OSError):mutate(store,'upsert_prompt') + assert store.prompts_file.read_bytes()==b'{damaged' + store.prompts_file.write_bytes(old) + mutate(store,'upsert_prompt') + assert {p.id for p in store.load_prompts()}=={'p','new'} + + +@pytest.mark.parametrize('action',['delete_prompt','delete_version']) +def test_delete_uses_one_preflight_snapshot_for_boards(store,monkeypatch,action): + original=Path.read_text + reads=[] + def read(path,*args,**kwargs): + if path==store.boards_file: + reads.append(path) + if len(reads)>1:raise PermissionError('second board read failed') + return original(path,*args,**kwargs) + monkeypatch.setattr(Path,'read_text',read) + mutate(store,action) + assert len(reads)==1 + assert json.loads(store.boards_file.read_bytes())['boards'][0]['items']==[] + + +def test_pdf_export_does_not_render_an_unreadable_library(store,tmp_path,monkeypatch): + import pdf_exporter + store.prompts_file.write_bytes(b'{invalid') + destination=tmp_path/'backup.pdf' + destination.write_bytes(b'%PDF-previous') + def render(*args,**kwargs):raise AssertionError('renderer must not run') + monkeypatch.setattr(pdf_exporter,'_safe_export_html_to_pdf',render) + with pytest.raises(OSError):pdf_exporter.export_all_prompts(store,None,str(destination)) + assert destination.read_bytes()==b'%PDF-previous' + + +def test_same_storage_object_serializes_parallel_updates(store): + from concurrent.futures import ThreadPoolExecutor + def insert(index):store.upsert_prompt(Prompt(str(index),'Titel','','Text')) + with ThreadPoolExecutor(max_workers=4) as pool:list(pool.map(insert,range(12))) + assert {p.id for p in store.load_prompts()}=={'p',*map(str,range(12))} + + +@pytest.mark.parametrize('mode',['prompt-new','prompt-edit','version-new','version-edit']) +def test_dialog_stays_open_without_mutating_shared_models_on_read_failure(store,qapp,monkeypatch,mode): + from dataclasses import asdict + from PySide6 import QtWidgets + from prompt_dialog import PromptDialog,VersionDialog + p=store.get_prompt('p') + before=asdict(p) + if mode.startswith('prompt'): + dialog=PromptDialog(store,p if mode.endswith('edit') else None) + label='Speichern' + else: + dialog=VersionDialog(store,p,p.versions[0] if mode.endswith('edit') else None) + label='Speichern' if mode.endswith('edit') else 'Version erstellen' + button=next(b for b in dialog.findChildren(QtWidgets.QPushButton) if b.text()==label) + dialog.title_edit.setText('Neue Grüße') + dialog.text_edit.setPlainText('Geänderter Text') + messages=[] + accepted=[] + monkeypatch.setattr(QtWidgets.QMessageBox,'critical',lambda *args:messages.append(args)) + dialog.accepted.connect(lambda:accepted.append(True)) + store.prompts_file.write_bytes(b'{unreadable original') + dialog.show() + try: + button.click() + assert dialog.isVisible() + assert dialog.title_edit.text()=='Neue Grüße' + assert accepted==[] and len(messages)==1 + assert asdict(p)==before + assert store.prompts_file.read_bytes()==b'{unreadable original' + finally:dialog.close() + + +@pytest.mark.parametrize('edit',[False,True]) +def test_version_dialog_does_not_accept_when_prompt_was_deleted(store,qapp,monkeypatch,edit): + from PySide6 import QtWidgets + from prompt_dialog import VersionDialog + p=store.get_prompt('p') + dialog=VersionDialog(store,p,p.versions[0] if edit else None) + dialog.title_edit.setText('Neue Version') + dialog.text_edit.setPlainText('Text') + store.delete_prompt('p') + messages=[] + monkeypatch.setattr(QtWidgets.QMessageBox,'warning',lambda *args:messages.append(args)) + (dialog._on_save_update if edit else dialog._on_save_create)() + assert dialog.result()!=QtWidgets.QDialog.DialogCode.Accepted + assert len(messages)==1 + assert store.load_prompts()==[] + dialog.close() + + +def test_supported_null_placeholders_do_not_block_legacy_library(store): + data=json.loads(store.prompts_file.read_bytes()) + data['prompts'][0]['versions'].insert(0,None) + store.prompts_file.write_text(json.dumps(data),encoding='utf-8') + mutate(store,'upsert_prompt') + existing=store.get_prompt('p') + assert existing.text=='Vorhandener Text' + assert existing.versions[0].text=='Inhalt' + assert {p.id for p in store.load_prompts()}=={'p','new'} + + +@pytest.mark.parametrize('mode',['prompt-new','prompt-edit','version-new','version-edit']) +def test_save_buttons_commit_and_accept_on_healthy_library(store,qapp,mode): + from PySide6 import QtWidgets + from prompt_dialog import PromptDialog,VersionDialog + p=store.get_prompt('p') + if mode.startswith('prompt'): + dialog=PromptDialog(store,p if mode.endswith('edit') else None) + label='Speichern' + else: + dialog=VersionDialog(store,p,p.versions[0] if mode.endswith('edit') else None) + label='Speichern' if mode.endswith('edit') else 'Version erstellen' + dialog.title_edit.setText('Geänderte Grüße') + dialog.text_edit.setPlainText('Gespeicherter Text') + next(b for b in dialog.findChildren(QtWidgets.QPushButton) if b.text()==label).click() + assert dialog.result()==QtWidgets.QDialog.DialogCode.Accepted + if mode.startswith('prompt'): + assert store.get_prompt(dialog.prompt.id).text=='Gespeicherter Text' + else: + assert any(v.text=='Gespeicherter Text' for v in store.get_prompt('p').versions) + dialog.close() From db46765069b1fa303563e3e23a70370d2ae9bada Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Fri, 2 Oct 2026 00:33:53 +0200 Subject: [PATCH 2/2] fix: protect TXT exports and report PDF load failures --- .github/workflows/tests.yml | 2 +- src/profiprompt.py | 7 ++++++- tests/test_library_load_safety.py | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fef5b57..ba83d03 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 + python -m pip install -r requirements.txt pytest Pillow - name: Run Python tests run: python -m pytest -ra -v - name: Compile entry points diff --git a/src/profiprompt.py b/src/profiprompt.py index 30ac027..7695053 100644 --- a/src/profiprompt.py +++ b/src/profiprompt.py @@ -249,11 +249,15 @@ def _show_about(self): QMessageBox.information(self, "Über", "Prompt Manager v1.0.1\nModern Dark Edition") # --- Exports --- + @report_storage_errors def export_all_txt(self): path, _ = QFileDialog.getSaveFileName(self, "Export TXT", "alle_prompts.txt", "Text (*.txt)") if not path: return - prompts = self.storage.load_prompts() + if callable(getattr(self.storage, "load_library", None)): + prompts, _ = self.storage.load_library() + else: + prompts = self.storage.load_prompts() parts = [] for p in prompts or []: if not p: @@ -291,6 +295,7 @@ def _protected_paths(self): protected.append(storage.boards_file) return protected + @report_storage_errors def export_all_pdf(self): path, _ = QFileDialog.getSaveFileName(self, "Export PDF", "alle_prompts.pdf", "PDF (*.pdf)") if path: diff --git a/tests/test_library_load_safety.py b/tests/test_library_load_safety.py index a10fb86..06275f8 100644 --- a/tests/test_library_load_safety.py +++ b/tests/test_library_load_safety.py @@ -221,3 +221,26 @@ def test_save_buttons_commit_and_accept_on_healthy_library(store,qapp,mode): else: assert any(v.text=='Gespeicherter Text' for v in store.get_prompt('p').versions) dialog.close() + + +@pytest.mark.parametrize('kind',['txt','pdf','json']) +@pytest.mark.parametrize('source',['prompts','boards']) +def test_mainwindow_exports_preserve_backups_and_report_read_errors(store,qapp,monkeypatch,tmp_path,kind,source): + import profiprompt + from profiprompt import MainWindow + target=store.prompts_file if source=='prompts' else store.boards_file + target.write_bytes(b'{damaged library') + backup=tmp_path/f'backup.{kind}' + backup.write_bytes(b'previous complete backup') + errors=[] + successes=[] + monkeypatch.setattr(profiprompt.QFileDialog,'getSaveFileName',lambda *args,**kwargs:(str(backup),'')) + monkeypatch.setattr(profiprompt.QMessageBox,'critical',lambda *args,**kwargs:errors.append(args)) + monkeypatch.setattr(profiprompt.QMessageBox,'information',lambda *args,**kwargs:successes.append(args)) + window=MainWindow.__new__(MainWindow) + window.storage=store + window.settings=None + action={'txt':MainWindow.export_all_txt,'pdf':MainWindow.export_all_pdf,'json':MainWindow.export_library_json}[kind] + action(window) + assert backup.read_bytes()==b'previous complete backup' + assert len(errors)==1 and successes==[]