first
This commit is contained in:
30
README.md
Normal file
30
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
510
cursor_pyqt6_image_sorting_application.md
Normal file
510
cursor_pyqt6_image_sorting_application.md
Normal file
@@ -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?
|
||||||
|
|
||||||
5
photo_sorter/__init__.py
Normal file
5
photo_sorter/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Photo Sorter Paket.
|
||||||
|
|
||||||
|
Siehe `main.py` für den Einstiegspunkt der Anwendung.
|
||||||
|
"""
|
||||||
BIN
photo_sorter/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
photo_sorter/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
20
photo_sorter/config.json
Normal file
20
photo_sorter/config.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
BIN
photo_sorter/core/__pycache__/config_manager.cpython-312.pyc
Normal file
BIN
photo_sorter/core/__pycache__/config_manager.cpython-312.pyc
Normal file
Binary file not shown.
73
photo_sorter/core/config_manager.py
Normal file
73
photo_sorter/core/config_manager.py
Normal file
@@ -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)
|
||||||
82
photo_sorter/core/file_operations.py
Normal file
82
photo_sorter/core/file_operations.py
Normal file
@@ -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
|
||||||
78
photo_sorter/core/image_loader.py
Normal file
78
photo_sorter/core/image_loader.py
Normal file
@@ -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()
|
||||||
26
photo_sorter/main.py
Normal file
26
photo_sorter/main.py
Normal file
@@ -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())
|
||||||
BIN
photo_sorter/ui/__pycache__/main_window.cpython-312.pyc
Normal file
BIN
photo_sorter/ui/__pycache__/main_window.cpython-312.pyc
Normal file
Binary file not shown.
106
photo_sorter/ui/image_viewer.py
Normal file
106
photo_sorter/ui/image_viewer.py
Normal file
@@ -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)
|
||||||
248
photo_sorter/ui/main_window.py
Normal file
248
photo_sorter/ui/main_window.py
Normal file
@@ -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()
|
||||||
124
photo_sorter/ui/settings_dialog.py
Normal file
124
photo_sorter/ui/settings_dialog.py
Normal file
@@ -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)
|
||||||
64
photo_sorter/ui/target_bar.py
Normal file
64
photo_sorter/ui/target_bar.py
Normal file
@@ -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)
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PyQt6>=6.6
|
||||||
|
Pillow>=10.0
|
||||||
Reference in New Issue
Block a user