This commit is contained in:
Hirnwunde
2026-02-26 08:42:12 +01:00
commit 87ab7ee73c
16 changed files with 1368 additions and 0 deletions

5
photo_sorter/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
Photo Sorter Paket.
Siehe `main.py` für den Einstiegspunkt der Anwendung.
"""

Binary file not shown.

20
photo_sorter/config.json Normal file
View 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"
}

View 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)

View 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

View 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
View 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())

View 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)

View 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 10
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()

View 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)

View 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)