Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):**
Expand Down
26 changes: 26 additions & 0 deletions LIBRARY_SAFETY.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 10 additions & 13 deletions src/board_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand All @@ -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
Expand Down Expand Up @@ -403,26 +407,17 @@ 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

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):
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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ü:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -700,4 +703,3 @@ def create_prompt(self):
bus.promptsChanged.emit()



9 changes: 7 additions & 2 deletions src/library_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/pdf_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ["<html><body>"]
for p in prompts or []:
if not p:
Expand Down
9 changes: 8 additions & 1 deletion src/profiprompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,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:
Expand Down Expand Up @@ -290,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:
Expand Down Expand Up @@ -412,6 +418,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
Expand Down
38 changes: 21 additions & 17 deletions src/prompt_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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()
Expand Down
Loading
Loading