commit 87ab7ee73c22fba75b4e870019c206b25a691371 Author: Hirnwunde Date: Thu Feb 26 08:42:12 2026 +0100 first diff --git a/README.md b/README.md new file mode 100644 index 0000000..41470c0 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Photo Sorter (PyQt6) + +Einfache Desktop-Anwendung zum schnellen Sortieren von Fotos per Tastatur. + +## Features +- Quellverzeichnis auswählen und alle Bilder nacheinander anzeigen +- Bis zu 10 Zielverzeichnisse (Tasten 1–0) mit optionalen Labels +- Verschieben oder Kopieren (global konfigurierbar, Standard: Verschieben) +- Undo-Funktion für die letzte Aktion + +## Installation + +```bash +cd pyPhotoSorter +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +## Start + +```bash +python -m photo_sorter.main +``` + +Oder direkt: + +```bash +python photo_sorter/main.py +``` diff --git a/cursor_pyqt6_image_sorting_application.md b/cursor_pyqt6_image_sorting_application.md new file mode 100644 index 0000000..74bbc6f --- /dev/null +++ b/cursor_pyqt6_image_sorting_application.md @@ -0,0 +1,510 @@ +# PyQt6 image sorting application +_Exported on 26.2.2026 at 8:40:33 MEZ from Cursor (2.5.25)_ + +--- + +**User** + +🎯 Ziel +Entwickle eine PyQt6-Desktop-Anwendung, mit der Bilder aus einem Quellverzeichnis per Tastendruck in konfigurierbare Zielverzeichnisse verschoben oder kopiert werden können. + +⚙️ Funktionale Anforderungen +1. Quellverzeichnis + • Beim Start kann der Benutzer ein Quellverzeichnis auswählen (via QFileDialog) + • Die App lädt alle Bilddateien (.jpg, .jpeg, .png, .webp, .bmp, .gif) aus diesem Verzeichnis + • Die Bilder werden der Reihe nach angezeigt +2. Bildanzeige + • Das aktuelle Bild wird zentriert und skaliert (aspect-ratio-erhaltend) im Hauptbereich angezeigt (QLabel mit QPixmap) + • Dateiname und Index (z. B. Bild 42 / 1337) werden oberhalb des Bildes angezeigt +3. Zielverzeichnisse konfigurieren + • Es gibt 1 bis 10 Zielverzeichnisse, die den Tasten 1 bis 0 (auf der Haupttastatur) zugeordnet sind + • Jedes Ziel besteht aus: + ◦ Einer Taste (1–0) + ◦ Einem Verzeichnispfad (auswählbar via QFileDialog) + ◦ Einem optionalen Kurznamen/Label (z. B. "Urlaub", "Löschen") + • Die Konfiguration kann über einen separaten Dialog oder ein Panel bearbeitet werden + • Ziele werden in einer JSON-Konfigurationsdatei gespeichert und beim nächsten Start geladen +4. Tastatursteuerung + • Taste 1–0: Bild in das entsprechende Zielverzeichnis verschieben oder kopieren (konfigurierbar, Standard: verschieben) → nächstes Bild wird automatisch geladen + • Taste → oder Leertaste: Bild überspringen (nächstes Bild, ohne Aktion) + • Taste ←: Vorheriges Bild anzeigen + • Taste Z oder Ctrl+Z: Letzte Aktion rückgängig machen (Bild zurückverschieben) +5. Zielanzeige (Statusleiste) + • Unterhalb des Bildes wird eine Leiste angezeigt mit allen konfigurierten Zielen, z. B.: + Urlaub Familie Löschen Landschaft + • Nicht belegte Tasten werden nicht angezeigt + • Beim Drücken einer Taste wird das entsprechende Ziel kurz visuell hervorgehoben + +🖥️ UI-Struktur +┌─────────────────────────────────────────┐ +│ [Menü: Datei | Einstellungen] │ +├─────────────────────────────────────────┤ +│ Bild 42 / 1337 – IMG_4567.jpg │ +├─────────────────────────────────────────┤ +│ │ +│ [ Bildanzeige ] │ +│ │ +├─────────────────────────────────────────┤ +│ Urlaub Familie Löschen │ +└─────────────────────────────────────────┘ + +🗂️ Projektstruktur +photo_sorter/ +├── main.py # Einstiegspunkt +├── config.json # Gespeicherte Zielverzeichnisse +├── ui/ +│ ├── main_window.py # Hauptfenster +│ ├── image_viewer.py # Widget für Bildanzeige +│ ├── target_bar.py # Widget für Zielanzeige +│ └── settings_dialog.py # Dialog zum Konfigurieren der Ziele +└── core/ + ├── image_loader.py # Laden & Iterieren der Bilder + ├── file_operations.py # Verschieben/Kopieren/Undo-Logik + └── config_manager.py # Lesen/Schreiben der JSON-Konfiguration + +📦 Abhängigkeiten +text +Kopieren +PyQt6 +Pillow (optional, für bessere Bildunterstützung / EXIF-Rotation) + +🔲 Nicht im Scope (bewusst ausgelassen) + • Keine Datenbankanbindung + • Kein Cloud-Upload + • Kein automatisches Tagging / KI-Klassifizierung + + +--- + +**Cursor** + +Ich erstelle dir ein neues PyQt6-Projekt gemäß deiner Struktur, inkl. `requirements.txt` und aller Module, dann implementiere ich schrittweise Bildnavigation, Dateioperationen, Konfiguration und Keyboard-Steuerung. Anschließend fasse ich kurz zusammen, wie du die App startest. + +Ich lege jetzt Projektstruktur, `requirements.txt` und Basisdateien an und implementiere danach die Kernlogik und UI. + +Grundstruktur, `requirements.txt`, `README` und Paketordner sind angelegt; als Nächstes kommen Kern-Module in `core/` und dann die UI in `ui/`. + +Core-Module für Konfiguration, Bildliste und Dateioperationen sind implementiert; jetzt baue ich die PyQt6-UI (MainWindow, ImageViewer, TargetBar, Settings-Dialog) und verdrahte die Tastatursteuerung. + +### Übersicht + +Ich habe dir die komplette Anwendung gemäß deiner Spezifikation implementiert – inkl. Projektstruktur, Kernlogik, UI, Konfiguration und Undo. + +### Projektstruktur + +In deinem Workspace liegt jetzt: + +- **Top-Level** + - `requirements.txt` + - `README.md` + - `photo_sorter/` + - `__init__.py` + - `main.py` + - `config.json` (wird bei Bedarf erstellt) + - `core/` + - `config_manager.py` + - `image_loader.py` + - `file_operations.py` + - `ui/` + - `main_window.py` + - `image_viewer.py` + - `target_bar.py` + - `settings_dialog.py` + +--- + +### Kernmodule (`core/`) + +#### `config_manager.py` + +- **Datenklassen** + +```python +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Dict, List, Literal +import json + +ActionType = Literal["move", "copy"] + + +@dataclass +class TargetConfig: + key: str # "1" .. "0" + path: str + label: str + + +@dataclass +class AppConfig: + targets: List[TargetConfig] + default_action: ActionType = "move" + + @classmethod + def empty(cls) -> "AppConfig": + return cls(targets=[], default_action="move") +``` + +- **ConfigManager** + - `load()`: + - Liest `config.json` (falls vorhanden) + - Baut `AppConfig` mit `targets` und `default_action` (`"move"` oder `"copy"`) + - Fällt bei Fehlern auf `AppConfig.empty()` zurück. + - `save(config: AppConfig)`: + - Schreibt JSON mit: + - `targets`: Liste von `{key, path, label}` + - `default_action`: `"move"`/`"copy"` + +Damit werden die Zielverzeichnisse und die globale Move/Copy-Einstellung persistiert. + +#### `image_loader.py` + +- Kümmert sich um: + - Laden der Bildliste aus einem Quellverzeichnis + - Navigation (`next`, `previous`, `set_index`) + - Zugriff auf aktuelles Bild und Gesamtanzahl + +Wichtige Punkte: + +```python +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"} + +class ImageLoader: + def __init__(self) -> None: + self._images: List[Path] = [] + self._current_index: int = -1 + self._source_dir: Optional[Path] = None + + def load_from_directory(self, directory: Path) -> None: + directory = Path(directory) + self._source_dir = directory + # Filter und sortiere Bilddateien + files = sorted( + p for p in directory.iterdir() + if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS + ) + self._images = files + self._current_index = 0 if self._images else -1 + + def get_current(self) -> Optional[Path]: ... + def next(self) -> Optional[Path]: ... + def previous(self) -> Optional[Path]: ... + def set_index(self, index: int) -> Optional[Path]: ... + def update_current_path(self, new_path: Path) -> None: + # Wird nach Move/Undo genutzt, damit Zurückblättern funktioniert +``` + +#### `file_operations.py` + +- Implementiert Move/Copy mit Undo-Stack. + +```python +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional +import shutil + +from .config_manager import ActionType + + +@dataclass +class FileAction: + original_path: Path + new_path: Path + action: ActionType # "move" oder "copy" + index: int # Bildindex zum Zeitpunkt der Aktion +``` + +- **FileOperations**: + +```python +class FileOperations: + def __init__(self) -> None: + self._history: List[FileAction] = [] + + @staticmethod + def _unique_destination(dest_dir: Path, filename: str) -> Path: + # Erzeugt bei Namenskollisionen foo_1.jpg, foo_2.jpg, ... +``` + +- `move_or_copy(path, dest_dir, action, index) -> Path`: + - Ermittelt freien Zielpfad (keine Überschreibung) + - `move`: `shutil.move` + - `copy`: `shutil.copy2` + - Schiebt `FileAction` auf den History-Stack + - Gibt den finalen Zielpfad zurück (wichtig, um den Pfad in der Bildliste zu aktualisieren) + +- `undo_last() -> Optional[FileAction]`: + - Holt letzte Aktion vom Stack + - Bei `"move"`: Datei wieder an `original_path` zurückverschieben + - Bei `"copy"`: nur Kopie (`new_path`) löschen, Original bleibt + - Liefert die `FileAction` zurück, damit das UI den Index und Pfad zurücksetzen kann + +--- + +### UI-Module (`ui/`) + +#### `image_viewer.py` + +- `ImageViewer` ist ein `QWidget`, das das aktuelle Bild zentriert und mit erhaltenem Seitenverhältnis skaliert anzeigt. +- Nutzt optional **Pillow**, um EXIF-Orientation zu respektieren: + +```python +try: + from PIL import Image, ExifTags +except Exception: + Image = None + ExifTags = None +``` + +- `set_image(path: Optional[Path])`: + - Wenn Pillow verfügbar: + - Bild öffnen, EXIF-Orientation (3, 6, 8) berücksichtigen + - In RGBA konvertieren und in `QPixmap` überführen + - Fallback: `QPixmap(str(path))` +- `resizeEvent`: + - Skaliert `_original_pixmap` auf die Größe des Labels: + +```python +scaled = self._original_pixmap.scaled( + target_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, +) +self._label.setPixmap(scaled) +``` + +#### `target_bar.py` + +- `TargetDisplay` (Dataclass): `key`, `label` +- `TargetBar(QWidget)`: + - `set_targets(List[TargetDisplay])`: + - Baut eine horizontale Leiste mit `QLabel`s für alle belegten Ziele. + - Nicht belegte Tasten werden nicht angezeigt (da keine `TargetDisplay` erzeugt wird). + - `highlight_target(key, duration_ms=200)`: + - Findet das Label zur Taste `key` + - Setzt kurz einen hervorgehobenen Style (blauer Rand, hellblauer Hintergrund) + - Setzt via `QTimer.singleShot` danach wieder den ursprünglichen Style + +#### `settings_dialog.py` + +- `SettingsDialog(AppConfig)`: + - Stellt eine Tabelle für Tasten 1–0 dar: + - Spalten: Taste, Label, Verzeichnis, Button „Wählen…“ + - Für jede Taste gibt es `QLineEdit` für Label und Pfad. + - „Wählen…“ öffnet `QFileDialog.getExistingDirectory`. + - Lädt bestehende Konfiguration pro Taste vor (Label + Pfad). + - Unten: + - Radiobuttons „Verschieben“ / „Kopieren“ als **globale** Standardaktion (`default_action`). + - `get_config() -> AppConfig`: + - Sammelt alle Tasten, bei denen ein Pfad gesetzt ist. + - Erzeugt `TargetConfig(key, path, label)`-Liste. + - Liest Aktion aus Radio-Buttons → `"move"` oder `"copy"`. + +--- + +### Hauptfenster & Steuerung (`main_window.py`) + +#### Aufbau + +- `MainWindow(QMainWindow)`: + - Hält: + - `ConfigManager` + `AppConfig` + - `ImageLoader` + - `FileOperations` + - Mapping `key -> TargetConfig` + - UI: + - Oben: `QLabel` mit `Bild X / Y – Dateiname` + - Mitte: `ImageViewer` + - Unten: `TargetBar` mit allen konfigurierten Zielen + - Statusbar (optional, aktuell ohne speziellen Text) + +- Menü: + - **Datei** + - „Quellverzeichnis öffnen…“ (`Ctrl+O`): ruft `_select_source_directory()` + - „Beenden“ (`Ctrl+Q`): `self.close()` + - **Einstellungen** + - „Ziele konfigurieren…“: öffnet `SettingsDialog` + +#### Konfiguration & Zielleiste + +- Beim Start: + +```python +self._config: AppConfig = self._config_manager.load() +self._targets_by_key = {t.key: t for t in self._config.targets} +self._apply_config_to_ui() +self._select_source_directory(initial=True) +``` + +- `_apply_config_to_ui()`: + - Aktualisiert Mapping `key -> TargetConfig` + - Baut `TargetDisplay`-Liste und übergibt sie an `TargetBar.set_targets`. + +- `_open_settings()`: + - Erstellt `SettingsDialog` mit aktueller Config + - Wenn `Accepted`: + - `new_config = dialog.get_config()` + - Speichert via `ConfigManager.save(new_config)` + - Wendet neue Zielleiste an (`_apply_config_to_ui()`) + +#### Quellverzeichnis & Bildanzeige + +- `_select_source_directory(initial: bool = False)`: + - Öffnet `QFileDialog.getExistingDirectory`. + - Wenn beim Initial-Start kein Verzeichnis gewählt wird, passiert nichts weiter (leeres Fenster). + - Lädt Bilder aus dem Ordner via `ImageLoader.load_from_directory`. + - Wenn keine Bilder gefunden: + - `QMessageBox.information` mit Hinweis + - `ImageViewer.clear()` und Info-Label „Keine Bilder gefunden“ + - Sonst: `_show_current_image()`. + +- `_show_current_image()`: + - Holt aktuelles `Path` und Index/Count aus `ImageLoader`. + - Aktualisiert `ImageViewer` und Info-Label: + - „Bild {index+1} / {count} – {filename}“. + +#### Tastatursteuerung + +`keyPressEvent` implementiert genau deine Vorgaben: + +- **Skip**: + - Rechts-Pfeil (`Qt.Key_Right`) oder Leertaste (`Qt.Key_Space`): + - `_skip_forward()`: `ImageLoader.next()` und `_show_current_image()` +- **Zurück**: + - Links-Pfeil (`Qt.Key_Left`): + - `_go_back()`: `ImageLoader.previous()` und `_show_current_image()` +- **Undo**: + - Taste `Z` **oder** `Ctrl+Z`: + - `_undo_action()` +- **Ziele 1–0**: + - `Qt.Key_1` … `Qt.Key_0`: + - Mappen auf `"1"` … `"0"` + - `_apply_target_action(key_char)` + +#### Zielaktion (Move/Copy) + Undo + +- `_apply_target_action(key: str)`: + + - Prüft: + - Gibt es Bilder? + - Ist für diese Taste ein Ziel konfiguriert? + - Holt aktuelles Bild via `ImageLoader.get_current()`. + - Ruft: + + ```python + new_path = self._file_ops.move_or_copy( + current, + Path(target.path), + self._config.default_action, # "move" oder "copy" + self._image_loader.current_index, + ) + ``` + + - Aktualisiert den Pfad in der Bildliste: + - `self._image_loader.update_current_path(new_path)` + - Wichtig, damit das Bild bei „Zurück“ vom neuen Ort geladen wird. + - Hebt das Ziel kurz hervor: + - `self._target_bar.highlight_target(key)` + - Lädt automatisch das nächste Bild: + - `self._image_loader.next()` + - `_show_current_image()` + +- `_undo_action()`: + + - Ruft `action = self._file_ops.undo_last()`. + - Wenn `None`: nichts zu tun. + - Sonst: + - Setzt den Bildindex auf den ursprünglichen Index der Aktion: + - `self._image_loader.set_index(action.index)` + - Wenn die Aktion ein **Move** war: + - Pfad in der Bildliste wieder auf den Originalpfad aktualisieren: + - `self._image_loader.update_current_path(action.original_path)` + - Zeigt das Bild wieder an: `_show_current_image()`. + + - Verhalten: + - **Move**: + - Datei wird im FS an Ursprungsort zurückgeschoben + - Bildliste zeigt wieder auf Ursprungsdatei + - **Copy**: + - Nur die Kopie wird gelöscht, Original bleibt + - Bildliste zeigt weiterhin auf das Original (Pfad ändert sich nicht) + +--- + +### Einstiegspunkt (`main.py`) + +- `photo_sorter/main.py`: + +```python +from PyQt6.QtWidgets import QApplication +from photo_sorter.core.config_manager import ConfigManager +from photo_sorter.ui.main_window import MainWindow + +def main() -> int: + app = QApplication(sys.argv) + + base_dir = Path(__file__).resolve().parent + config_path = base_dir / "config.json" + config_manager = ConfigManager(config_path) + + window = MainWindow(config_manager) + window.show() + + return app.exec() + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +--- + +### Installation & Start + +1. **Abhängigkeiten installieren** + +```bash +cd /home/obk/src/cursor_ai_kram/pyPhotoSorter +python -m venv .venv +source .venv/bin/activate # unter Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +2. **App starten** + +```bash +python -m photo_sorter.main +# oder +python photo_sorter/main.py +``` + +--- + +### Was ist umgesetzt im Verhältnis zu deinen Anforderungen? + +- **Quellverzeichnis**: + - Wird beim Start via Dialog abgefragt; weitere Auswahl jederzeit über Menü „Datei → Quellverzeichnis öffnen…“ möglich. +- **Bildanzeige**: + - `ImageViewer` zentriert & skaliert das Bild (Seitenverhältnis bleibt erhalten). + - Oben steht „Bild X / Y – Dateiname“. +- **Zielverzeichnisse**: + - Bis zu 10 Ziele (Tasten 1–0) konfigurierbar im `SettingsDialog`. + - Jedes Ziel hat Taste, Pfad, optional Label. + - Konfiguration wird in `config.json` gespeichert und beim Start geladen. +- **Tastatursteuerung**: + - 1–0: Move/Copy in Ziel, dann automatisches Weiterblättern. + - `→` / Leertaste: Skip (nächstes Bild). + - `←`: Vorheriges Bild. + - `Z` oder `Ctrl+Z`: Undo der letzten Aktion (inkl. Zurückverschieben). +- **Zielleiste**: + - Zeigt nur konfigurierte Ziele (ohne leere Tasten). + - Ziel wird beim Drücken kurz visuell hervorgehoben. + +Wenn du möchtest, kann ich als nächsten Schritt noch kleine UX-Details anpassen (z. B. Theme, größere Buttons/Labels) oder dir gezielt Codeausschnitte erläutern, die du anpassen willst (z. B. Verhalten bei Ende der Bildliste, zusätzliche Shortcuts). + +--- + +**User** + +könntest du bitte daraus ein git-repo machen, eine .gitignore erstellen, die alle gängigen IDE-Dateien, .venv's und so weiter exkludiert und eine cursor.md erstellen, die deinen promt abspeichert? + diff --git a/photo_sorter/__init__.py b/photo_sorter/__init__.py new file mode 100644 index 0000000..6ea7032 --- /dev/null +++ b/photo_sorter/__init__.py @@ -0,0 +1,5 @@ +""" +Photo Sorter Paket. + +Siehe `main.py` für den Einstiegspunkt der Anwendung. +""" diff --git a/photo_sorter/__pycache__/__init__.cpython-312.pyc b/photo_sorter/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b4d9a29 Binary files /dev/null and b/photo_sorter/__pycache__/__init__.cpython-312.pyc differ diff --git a/photo_sorter/config.json b/photo_sorter/config.json new file mode 100644 index 0000000..39718c9 --- /dev/null +++ b/photo_sorter/config.json @@ -0,0 +1,20 @@ +{ + "targets": [ + { + "key": "1", + "path": "/home/obk/Bilder/sorter_versuch/fam", + "label": "Family" + }, + { + "key": "2", + "path": "/home/obk/Bilder/sorter_versuch/priv", + "label": "Privat" + }, + { + "key": "3", + "path": "/home/obk/Bilder/sorter_versuch/work", + "label": "Work" + } + ], + "default_action": "move" +} \ No newline at end of file diff --git a/photo_sorter/core/__pycache__/config_manager.cpython-312.pyc b/photo_sorter/core/__pycache__/config_manager.cpython-312.pyc new file mode 100644 index 0000000..f63e7bf Binary files /dev/null and b/photo_sorter/core/__pycache__/config_manager.cpython-312.pyc differ diff --git a/photo_sorter/core/config_manager.py b/photo_sorter/core/config_manager.py new file mode 100644 index 0000000..c1523ce --- /dev/null +++ b/photo_sorter/core/config_manager.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Dict, List, Literal, Optional +import json + + +ActionType = Literal["move", "copy"] + + +@dataclass +class TargetConfig: + key: str # z.B. "1" .. "0" + path: str + label: str + + +@dataclass +class AppConfig: + targets: List[TargetConfig] + default_action: ActionType = "move" + + @classmethod + def empty(cls) -> "AppConfig": + return cls(targets=[], default_action="move") + + +class ConfigManager: + """Liest und schreibt die JSON-Konfiguration.""" + + def __init__(self, config_path: Path) -> None: + self._config_path = Path(config_path) + + @property + def path(self) -> Path: + return self._config_path + + def load(self) -> AppConfig: + if not self._config_path.exists(): + return AppConfig.empty() + + try: + with self._config_path.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + # Fallback auf Default-Konfiguration bei Fehlern + return AppConfig.empty() + + targets_data = data.get("targets", []) + targets: List[TargetConfig] = [] + for t in targets_data: + key = str(t.get("key", "")).strip() + path = str(t.get("path", "")).strip() + if not key or not path: + continue + label = str(t.get("label", "")).strip() + targets.append(TargetConfig(key=key, path=path, label=label)) + + default_action: ActionType = "move" + if data.get("default_action") == "copy": + default_action = "copy" + + return AppConfig(targets=targets, default_action=default_action) + + def save(self, config: AppConfig) -> None: + self._config_path.parent.mkdir(parents=True, exist_ok=True) + data: Dict[str, object] = { + "targets": [asdict(t) for t in config.targets], + "default_action": config.default_action, + } + with self._config_path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) diff --git a/photo_sorter/core/file_operations.py b/photo_sorter/core/file_operations.py new file mode 100644 index 0000000..6b91ac1 --- /dev/null +++ b/photo_sorter/core/file_operations.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional +import shutil + +from .config_manager import ActionType + + +@dataclass +class FileAction: + """Repräsentiert eine einzelne Dateiaktion für Undo.""" + + original_path: Path + new_path: Path + action: ActionType + index: int # Bildindex in der Liste zum Zeitpunkt der Aktion + + +class FileOperationError(Exception): + pass + + +class FileOperations: + """Kapselt Move/Copy-Logik und Undo-Stack.""" + + def __init__(self) -> None: + self._history: List[FileAction] = [] + + @staticmethod + def _unique_destination(dest_dir: Path, filename: str) -> Path: + dest_dir.mkdir(parents=True, exist_ok=True) + base = Path(filename).stem + suffix = Path(filename).suffix + candidate = dest_dir / filename + counter = 1 + while candidate.exists(): + candidate = dest_dir / f"{base}_{counter}{suffix}" + counter += 1 + return candidate + + def move_or_copy( + self, path: Path, dest_dir: Path, action: ActionType, index: int + ) -> Path: + path = Path(path) + dest_dir = Path(dest_dir) + if not path.exists(): + raise FileOperationError(f"Datei nicht gefunden: {path}") + + dest = self._unique_destination(dest_dir, path.name) + + if action == "move": + shutil.move(str(path), str(dest)) + else: + shutil.copy2(str(path), str(dest)) + + file_action = FileAction( + original_path=path, + new_path=dest, + action=action, + index=index, + ) + self._history.append(file_action) + return dest + + def undo_last(self) -> Optional[FileAction]: + if not self._history: + return None + + action = self._history.pop() + + if action.action == "move": + # Zurück an den Originalort verschieben + action.original_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(action.new_path), str(action.original_path)) + else: + # Copy rückgängig: Kopie löschen, Original bleibt unberührt + if action.new_path.exists(): + action.new_path.unlink() + + return action diff --git a/photo_sorter/core/image_loader.py b/photo_sorter/core/image_loader.py new file mode 100644 index 0000000..88fb872 --- /dev/null +++ b/photo_sorter/core/image_loader.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional + + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"} + + +class ImageLoader: + """Verwaltet die Liste der Bilder und den aktuellen Index.""" + + def __init__(self) -> None: + self._images: List[Path] = [] + self._current_index: int = -1 + self._source_dir: Optional[Path] = None + + @property + def source_dir(self) -> Optional[Path]: + return self._source_dir + + @property + def images(self) -> List[Path]: + return list(self._images) + + @property + def current_index(self) -> int: + return self._current_index + + @property + def count(self) -> int: + return len(self._images) + + def load_from_directory(self, directory: Path) -> None: + directory = Path(directory) + self._source_dir = directory + if not directory.is_dir(): + self._images = [] + self._current_index = -1 + return + + files = sorted( + p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS + ) + self._images = files + self._current_index = 0 if self._images else -1 + + def get_current(self) -> Optional[Path]: + if 0 <= self._current_index < len(self._images): + return self._images[self._current_index] + return None + + def next(self) -> Optional[Path]: + if not self._images: + return None + if self._current_index < len(self._images) - 1: + self._current_index += 1 + return self.get_current() + + def previous(self) -> Optional[Path]: + if not self._images: + return None + if self._current_index > 0: + self._current_index -= 1 + return self.get_current() + + def update_current_path(self, new_path: Path) -> None: + """Aktualisiert den Pfad des aktuellen Bildes (z.B. nach Move/Undo).""" + if 0 <= self._current_index < len(self._images): + self._images[self._current_index] = Path(new_path) + + def set_index(self, index: int) -> Optional[Path]: + if not self._images: + self._current_index = -1 + return None + if 0 <= index < len(self._images): + self._current_index = index + return self.get_current() diff --git a/photo_sorter/main.py b/photo_sorter/main.py new file mode 100644 index 0000000..6302c1b --- /dev/null +++ b/photo_sorter/main.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PyQt6.QtWidgets import QApplication + +from photo_sorter.core.config_manager import ConfigManager +from photo_sorter.ui.main_window import MainWindow + + +def main() -> int: + app = QApplication(sys.argv) + + base_dir = Path(__file__).resolve().parent + config_path = base_dir / "config.json" + config_manager = ConfigManager(config_path) + + window = MainWindow(config_manager) + window.show() + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/photo_sorter/ui/__pycache__/main_window.cpython-312.pyc b/photo_sorter/ui/__pycache__/main_window.cpython-312.pyc new file mode 100644 index 0000000..94da310 Binary files /dev/null and b/photo_sorter/ui/__pycache__/main_window.cpython-312.pyc differ diff --git a/photo_sorter/ui/image_viewer.py b/photo_sorter/ui/image_viewer.py new file mode 100644 index 0000000..ef03a7c --- /dev/null +++ b/photo_sorter/ui/image_viewer.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from PyQt6.QtCore import Qt, QSize +from PyQt6.QtGui import QPixmap, QImage +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget + +try: + from PIL import Image, ExifTags # type: ignore +except Exception: # Pillow ist optional + Image = None + ExifTags = None + + +class ImageViewer(QWidget): + """Widget zur Anzeige eines Bildes, skaliert mit korrektem Seitenverhältnis.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._original_pixmap: Optional[QPixmap] = None + + self._label = QLabel(self) + self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._label.setBackgroundRole(self._label.backgroundRole()) + self._label.setScaledContents(False) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._label) + + def clear(self) -> None: + self._original_pixmap = None + self._label.clear() + + def _load_pixmap_with_exif(self, path: Path) -> Optional[QPixmap]: + if Image is None: + pixmap = QPixmap(str(path)) + return pixmap if not pixmap.isNull() else None + + try: + img = Image.open(path) + # EXIF-Orientation anwenden, falls vorhanden + try: + exif = img._getexif() # type: ignore[attr-defined] + if exif and ExifTags: + orientation_key = next( + (k for k, v in ExifTags.TAGS.items() if v == "Orientation"), None + ) + if orientation_key and orientation_key in exif: + orientation = exif[orientation_key] + if orientation == 3: + img = img.rotate(180, expand=True) + elif orientation == 6: + img = img.rotate(270, expand=True) + elif orientation == 8: + img = img.rotate(90, expand=True) + except Exception: + pass + + img = img.convert("RGBA") + data = img.tobytes("raw", "RGBA") + qimg = QImage( + data, + img.width, + img.height, + QImage.Format.Format_RGBA8888, + ) + pixmap = QPixmap.fromImage(qimg) + return pixmap + except Exception: + pixmap = QPixmap(str(path)) + return pixmap if not pixmap.isNull() else None + + def set_image(self, path: Optional[Path]) -> None: + if path is None: + self.clear() + + if path is None: + return + + pixmap = self._load_pixmap_with_exif(path) + if pixmap is None or pixmap.isNull(): + self.clear() + return + + self._original_pixmap = pixmap + self._update_scaled_pixmap() + + def resizeEvent(self, event) -> None: # type: ignore[override] + super().resizeEvent(event) + self._update_scaled_pixmap() + + def _update_scaled_pixmap(self) -> None: + if self._original_pixmap is None: + return + target_size = self._label.size() + if not target_size.isValid(): + return + scaled = self._original_pixmap.scaled( + target_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self._label.setPixmap(scaled) diff --git a/photo_sorter/ui/main_window.py b/photo_sorter/ui/main_window.py new file mode 100644 index 0000000..dd8eae4 --- /dev/null +++ b/photo_sorter/ui/main_window.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Optional + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction, QKeySequence +from PyQt6.QtWidgets import ( + QFileDialog, + QLabel, + QMainWindow, + QStatusBar, + QVBoxLayout, + QWidget, + QMessageBox, +) + +from photo_sorter.core.config_manager import AppConfig, ConfigManager, TargetConfig +from photo_sorter.core.file_operations import FileOperations, FileOperationError +from photo_sorter.core.image_loader import ImageLoader +from photo_sorter.ui.image_viewer import ImageViewer +from photo_sorter.ui.settings_dialog import SettingsDialog, KEYS +from photo_sorter.ui.target_bar import TargetBar, TargetDisplay + + +class MainWindow(QMainWindow): + def __init__(self, config_manager: ConfigManager, parent: Optional[QWidget] = None) -> None: # type: ignore[name-defined] + super().__init__(parent) + self.setWindowTitle("Photo Sorter") + self.resize(1000, 700) + + self._config_manager = config_manager + self._config: AppConfig = self._config_manager.load() + + self._image_loader = ImageLoader() + self._file_ops = FileOperations() + + self._targets_by_key: Dict[str, TargetConfig] = {t.key: t for t in self._config.targets} + + self._setup_ui() + self._apply_config_to_ui() + + # Beim Start direkt Quellverzeichnis wählen + self._select_source_directory(initial=True) + + # --- UI Setup --- + + def _setup_ui(self) -> None: + central = QWidget(self) + self.setCentralWidget(central) + + layout = QVBoxLayout(central) + layout.setContentsMargins(8, 8, 8, 4) + layout.setSpacing(8) + + self._info_label = QLabel("Kein Bild geladen", self) + self._info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self._info_label) + + self._viewer = ImageViewer(self) + layout.addWidget(self._viewer, stretch=1) + + self._target_bar = TargetBar(self) + layout.addWidget(self._target_bar) + + status = QStatusBar(self) + self.setStatusBar(status) + + self._create_menus() + + def _create_menus(self) -> None: + menubar = self.menuBar() + + file_menu = menubar.addMenu("Datei") + + open_src_action = QAction("Quellverzeichnis öffnen…", self) + open_src_action.setShortcut(QKeySequence.StandardKey.Open) + open_src_action.triggered.connect(self._select_source_directory) # type: ignore[arg-type] + file_menu.addAction(open_src_action) + + file_menu.addSeparator() + + exit_action = QAction("Beenden", self) + exit_action.setShortcut(QKeySequence.StandardKey.Quit) + exit_action.triggered.connect(self.close) # type: ignore[arg-type] + file_menu.addAction(exit_action) + + settings_menu = menubar.addMenu("Einstellungen") + + targets_action = QAction("Ziele konfigurieren…", self) + targets_action.triggered.connect(self._open_settings) # type: ignore[arg-type] + settings_menu.addAction(targets_action) + + # --- Konfiguration & Zielleiste --- + + def _apply_config_to_ui(self) -> None: + self._targets_by_key = {t.key: t for t in self._config.targets} + displays = [TargetDisplay(key=t.key, label=t.label) for t in self._config.targets] + self._target_bar.set_targets(displays) + + def _open_settings(self) -> None: + dialog = SettingsDialog(self._config, self) + if dialog.exec() == dialog.DialogCode.Accepted: # type: ignore[comparison-overlap] + new_config = dialog.get_config() + self._config = new_config + self._config_manager.save(new_config) + self._apply_config_to_ui() + + # --- Quellverzeichnis & Bildanzeige --- + + def _select_source_directory(self, initial: bool = False) -> None: + directory = QFileDialog.getExistingDirectory(self, "Quellverzeichnis wählen") + if not directory: + if initial: + # Wenn beim ersten Start nichts gewählt wird, bleibt die App einfach leer geöffnet. + return + return + + self._image_loader.load_from_directory(Path(directory)) + if self._image_loader.count == 0: + QMessageBox.information( + self, + "Keine Bilder", + "Im gewählten Verzeichnis wurden keine unterstützten Bilddateien gefunden.", + ) + self._viewer.clear() + self._info_label.setText("Keine Bilder gefunden") + return + + self._show_current_image() + + def _show_current_image(self) -> None: + path = self._image_loader.get_current() + count = self._image_loader.count + index = self._image_loader.current_index + if path is None: + self._viewer.clear() + self._info_label.setText("Kein Bild geladen") + return + + self._viewer.set_image(path) + self._info_label.setText(f"Bild {index + 1} / {count} – {path.name}") + + # --- Tastatursteuerung --- + + def keyPressEvent(self, event) -> None: # type: ignore[override] + key = event.key() + + if key in (Qt.Key.Key_Right, Qt.Key.Key_Space): + self._skip_forward() + return + if key == Qt.Key.Key_Left: + self._go_back() + return + if key in (Qt.Key.Key_Z,) and (event.modifiers() & Qt.KeyboardModifier.ControlModifier): + self._undo_action() + return + if key == Qt.Key.Key_Z: + self._undo_action() + return + + # Zieltasten 1–0 + key_char = None + if key == Qt.Key.Key_1: + key_char = "1" + elif key == Qt.Key.Key_2: + key_char = "2" + elif key == Qt.Key.Key_3: + key_char = "3" + elif key == Qt.Key.Key_4: + key_char = "4" + elif key == Qt.Key.Key_5: + key_char = "5" + elif key == Qt.Key.Key_6: + key_char = "6" + elif key == Qt.Key.Key_7: + key_char = "7" + elif key == Qt.Key.Key_8: + key_char = "8" + elif key == Qt.Key.Key_9: + key_char = "9" + elif key == Qt.Key.Key_0: + key_char = "0" + + if key_char is not None: + self._apply_target_action(key_char) + return + + super().keyPressEvent(event) + + def _skip_forward(self) -> None: + if self._image_loader.count == 0: + return + self._image_loader.next() + self._show_current_image() + + def _go_back(self) -> None: + if self._image_loader.count == 0: + return + self._image_loader.previous() + self._show_current_image() + + def _apply_target_action(self, key: str) -> None: + if self._image_loader.count == 0: + return + target = self._targets_by_key.get(key) + if not target: + return + + current = self._image_loader.get_current() + if current is None: + return + + try: + new_path = self._file_ops.move_or_copy( + current, + Path(target.path), + self._config.default_action, + self._image_loader.current_index, + ) + except FileOperationError as e: + QMessageBox.warning(self, "Fehler beim Verschieben/Kopieren", str(e)) + return + + # Pfad in der Bildliste aktualisieren (für Zurückblättern etc.) + self._image_loader.update_current_path(new_path) + + # Visuelles Highlight des Ziels + self._target_bar.highlight_target(key) + + # Nächstes Bild laden + self._image_loader.next() + self._show_current_image() + + def _undo_action(self) -> None: + from photo_sorter.core.file_operations import FileAction + + action = self._file_ops.undo_last() + if action is None: + return + + # Bildliste wieder auf den Index der Aktion setzen + self._image_loader.set_index(action.index) + # Pfad in der Liste anpassen (Move: wieder Originalpfad, Copy: Original bleibt, Liste ändert sich nicht) + if action.action == "move": + self._image_loader.update_current_path(action.original_path) + + self._show_current_image() diff --git a/photo_sorter/ui/settings_dialog.py b/photo_sorter/ui/settings_dialog.py new file mode 100644 index 0000000..9cfc843 --- /dev/null +++ b/photo_sorter/ui/settings_dialog.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QButtonGroup, + QDialog, + QDialogButtonBox, + QFileDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QVBoxLayout, +) + +from photo_sorter.core.config_manager import AppConfig, TargetConfig, ActionType + + +KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] + + +class SettingsDialog(QDialog): + """Dialog zur Konfiguration der Ziele und der Standard-Aktion.""" + + def __init__(self, config: AppConfig, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("Ziele konfigurieren") + + self._config = config + self._target_rows: Dict[str, Dict[str, object]] = {} + self._action: ActionType = config.default_action + + main_layout = QVBoxLayout(self) + + grid = QGridLayout() + grid.addWidget(QLabel("Taste"), 0, 0) + grid.addWidget(QLabel("Label"), 0, 1) + grid.addWidget(QLabel("Verzeichnis"), 0, 2) + grid.addWidget(QLabel("…"), 0, 3) + + existing_by_key: Dict[str, TargetConfig] = {t.key: t for t in config.targets} + + for row, key in enumerate(KEYS, start=1): + key_label = QLabel(key) + label_edit = QLineEdit() + path_edit = QLineEdit() + path_edit.setReadOnly(True) + browse_btn = QPushButton("Wählen…") + + def make_browse(k: str, pe: QLineEdit): + def browse() -> None: + directory = QFileDialog.getExistingDirectory(self, "Zielverzeichnis wählen") + if directory: + pe.setText(directory) + + return browse + + browse_btn.clicked.connect(make_browse(key, path_edit)) # type: ignore[arg-type] + + grid.addWidget(key_label, row, 0) + grid.addWidget(label_edit, row, 1) + grid.addWidget(path_edit, row, 2) + grid.addWidget(browse_btn, row, 3) + + existing = existing_by_key.get(key) + if existing: + label_edit.setText(existing.label) + path_edit.setText(existing.path) + + self._target_rows[key] = { + "label_edit": label_edit, + "path_edit": path_edit, + } + + main_layout.addLayout(grid) + + # Move/Copy Auswahl + action_layout = QHBoxLayout() + action_layout.addWidget(QLabel("Aktion:")) + self._move_radio = QRadioButton("Verschieben") + self._copy_radio = QRadioButton("Kopieren") + + bg = QButtonGroup(self) + bg.addButton(self._move_radio) + bg.addButton(self._copy_radio) + + if config.default_action == "copy": + self._copy_radio.setChecked(True) + else: + self._move_radio.setChecked(True) + + action_layout.addWidget(self._move_radio) + action_layout.addWidget(self._copy_radio) + action_layout.addStretch(1) + main_layout.addLayout(action_layout) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + parent=self, + ) + buttons.accepted.connect(self.accept) # type: ignore[arg-type] + buttons.rejected.connect(self.reject) # type: ignore[arg-type] + main_layout.addWidget(buttons) + + def get_config(self) -> AppConfig: + targets: List[TargetConfig] = [] + for key in KEYS: + row = self._target_rows[key] + label = row["label_edit"].text().strip() # type: ignore[assignment] + path = row["path_edit"].text().strip() # type: ignore[assignment] + if not path: + continue + targets.append(TargetConfig(key=key, path=path, label=label)) + + action: ActionType = "move" + if self._copy_radio.isChecked(): + action = "copy" + + return AppConfig(targets=targets, default_action=action) diff --git a/photo_sorter/ui/target_bar.py b/photo_sorter/ui/target_bar.py new file mode 100644 index 0000000..1a0cb7b --- /dev/null +++ b/photo_sorter/ui/target_bar.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import QLabel, QHBoxLayout, QWidget + + +@dataclass +class TargetDisplay: + key: str + label: str + + +class TargetBar(QWidget): + """Zeigt die konfigurierten Ziele als Leiste an und erlaubt visuelles Highlighting.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(8, 4, 8, 4) + self._layout.setSpacing(12) + + self._labels: Dict[str, QLabel] = {} + + def set_targets(self, targets: List[TargetDisplay]) -> None: + # Alte Widgets entfernen + while self._layout.count(): + item = self._layout.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + self._labels.clear() + + for t in targets: + text = t.label or f"Ziel {t.key}" + lbl = QLabel(text, self) + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setProperty("targetKey", t.key) + lbl.setStyleSheet( + "QLabel { padding: 4px 10px; border-radius: 4px; border: 1px solid #888; }" + ) + self._layout.addWidget(lbl) + self._labels[t.key] = lbl + + self._layout.addStretch(1) + + def highlight_target(self, key: str, duration_ms: int = 200) -> None: + lbl = self._labels.get(key) + if not lbl: + return + + original = lbl.styleSheet() + lbl.setStyleSheet( + "QLabel { padding: 4px 10px; border-radius: 4px; border: 2px solid #0078d7; " + "background-color: #e0f0ff; }" + ) + + def reset() -> None: + lbl.setStyleSheet(original) + + QTimer.singleShot(duration_ms, reset) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..173e806 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyQt6>=6.6 +Pillow>=10.0