Files
SyncVito/src/syncvito/dialogs.py
T
2026-09-29 05:08:56 +02:00

303 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dialoge zur Aufgabenbearbeitung und Unterauswahl."""
from __future__ import annotations
from pathlib import Path
from PyQt6.QtCore import QDir
from PyQt6.QtGui import QFileSystemModel
from PyQt6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QMessageBox,
QPushButton,
QSpinBox,
QTabWidget,
QTreeView,
QVBoxLayout,
QWidget,
)
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
__all__ = ["TaskDialog"]
class _PathField(QWidget):
def __init__(self, title: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.edit = QLineEdit()
self.edit.setPlaceholderText(title)
self.button = QPushButton("Auswählen …")
self.button.clicked.connect(self._browse)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(self.edit, 1)
layout.addWidget(self.button)
def _browse(self) -> None:
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
if selected:
self.edit.setText(QDir.toNativeSeparators(selected))
class _SelectionDialog(QDialog):
def __init__(self, root: Path, selected: list[str], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Dateien und Ordner auswählen")
self.resize(820, 560)
self.root = root
self.model = QFileSystemModel(self)
self.model.setRootPath(str(root))
self.model.setFilter(QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot)
self.tree = QTreeView()
self.tree.setModel(self.model)
self.tree.setRootIndex(self.model.index(str(root)))
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.tree.setAlternatingRowColors(True)
self.tree.setSortingEnabled(True)
for column in (1, 2, 3):
self.tree.hideColumn(column)
self.chosen = QListWidget()
self.chosen.addItems(selected)
add_button = QPushButton("Markierte übernehmen")
remove_button = QPushButton("Aus Liste entfernen")
add_button.clicked.connect(self._add_marked)
remove_button.clicked.connect(self._remove_chosen)
hint = QLabel(
"Ohne Unterauswahl wird der gesamte Quellordner synchronisiert. "
"Ordnerauswahlen schließen deren gesamten Inhalt ein."
)
hint.setWordWrap(True)
hint.setProperty("muted", True)
columns = QHBoxLayout()
tree_column = QVBoxLayout()
tree_column.addWidget(QLabel("Quellordner"))
tree_column.addWidget(self.tree, 1)
tree_column.addWidget(add_button)
chosen_column = QVBoxLayout()
chosen_column.addWidget(QLabel("Aktive Unterauswahl"))
chosen_column.addWidget(self.chosen, 1)
chosen_column.addWidget(remove_button)
columns.addLayout(tree_column, 3)
columns.addLayout(chosen_column, 2)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(12)
layout.addWidget(hint)
layout.addLayout(columns, 1)
layout.addWidget(buttons)
def selected_paths(self) -> list[str]:
"""Liefert die gespeicherten relativen Pfade."""
return sorted({self.chosen.item(index).text() for index in range(self.chosen.count())})
def _add_marked(self) -> None:
existing = set(self.selected_paths())
for index in self.tree.selectionModel().selectedRows(0):
path = Path(self.model.filePath(index))
try:
relative = str(path.relative_to(self.root))
except ValueError:
continue
if relative not in existing and relative != ".":
self.chosen.addItem(relative)
existing.add(relative)
def _remove_chosen(self) -> None:
for item in self.chosen.selectedItems():
self.chosen.takeItem(self.chosen.row(item))
class TaskDialog(QDialog):
"""Vollständiger Editor für eine Synchronisationsaufgabe."""
def __init__(self, task: SyncTask | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._original = task
self._selected_paths = list(task.selected_paths) if task else []
self._ignored_paths = list(task.ignored_paths) if task else []
self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
self.setMinimumWidth(690)
self.resize(720, 610)
self.name_edit = QLineEdit(task.name if task else "")
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
self.source_field = _PathField("Lokalen Quellordner wählen")
self.target_field = _PathField("NAS- oder Zielordner wählen")
if task:
self.source_field.edit.setText(task.source)
self.target_field.edit.setText(task.target)
self.direction_combo = QComboBox()
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
self.direction_combo.addItem("Bidirektional – beide Seiten", SyncDirection.BIDIRECTIONAL)
self.trigger_combo = QComboBox()
self.trigger_combo.addItem("Manuell", TriggerMode.MANUAL)
self.trigger_combo.addItem("Echtzeit überwachen", TriggerMode.REALTIME)
self.trigger_combo.addItem("Zeitgesteuert", TriggerMode.INTERVAL)
self.interval_spin = QSpinBox()
self.interval_spin.setRange(1, 10080)
self.interval_spin.setSuffix(" Minuten")
self.interval_spin.setValue(task.interval_minutes if task else 30)
self.delete_combo = QComboBox()
self.delete_combo.addItem("Nie automatisch löschen", DeleteMode.KEEP)
self.delete_combo.addItem("Löschungen spiegeln", DeleteMode.MIRROR)
self.conflict_combo = QComboBox()
self.conflict_combo.addItem("Neuerer Zeitstempel gewinnt", ConflictMode.NEWER_WINS)
self.conflict_combo.addItem("Unterlegene Version als _conflict-Kopie behalten", ConflictMode.CONFLICT_COPY)
self.enabled_check = QCheckBox("Aufgabe ist aktiv")
self.enabled_check.setChecked(task.enabled if task else True)
if task:
self._set_combo(self.direction_combo, task.direction)
self._set_combo(self.trigger_combo, task.trigger)
self._set_combo(self.delete_combo, task.delete_mode)
self._set_combo(self.conflict_combo, task.conflict_mode)
self.selection_label = QLabel()
self.selection_label.setProperty("muted", True)
selection_button = QPushButton("Unterauswahl festlegen …")
selection_button.clicked.connect(self._choose_selection)
selection_row = QHBoxLayout()
selection_row.addWidget(self.selection_label, 1)
selection_row.addWidget(selection_button)
base_page = QWidget()
base_form = QFormLayout(base_page)
base_form.setContentsMargins(18, 18, 18, 18)
base_form.setHorizontalSpacing(18)
base_form.setVerticalSpacing(14)
base_form.addRow("Name", self.name_edit)
base_form.addRow("Quellordner", self.source_field)
base_form.addRow("Zielordner", self.target_field)
base_form.addRow("Richtung", self.direction_combo)
base_form.addRow("Auslösung", self.trigger_combo)
base_form.addRow("Intervall", self.interval_spin)
base_form.addRow("Umfang", selection_row)
base_form.addRow("", self.enabled_check)
behavior_page = QWidget()
behavior_layout = QFormLayout(behavior_page)
behavior_layout.setContentsMargins(18, 18, 18, 18)
behavior_layout.setHorizontalSpacing(18)
behavior_layout.setVerticalSpacing(14)
behavior_layout.addRow("Löschverhalten", self.delete_combo)
behavior_layout.addRow("Dateikonflikte", self.conflict_combo)
warning = QLabel(
"Beim Spiegeln von Löschungen wird der letzte erfolgreiche Abgleich als Referenz verwendet. "
"So werden neu hinzugefügte Dateien nicht versehentlich als Löschung interpretiert."
)
warning.setWordWrap(True)
warning.setProperty("muted", True)
behavior_layout.addRow("", warning)
ignored_count = len(self._ignored_paths)
self.ignored_label = QLabel(
f"{ignored_count} Datei(en) werden künftig ignoriert." if ignored_count else "Keine Dateien ignoriert."
)
self.ignored_label.setProperty("muted", True)
self.clear_ignored_button = QPushButton("Ignorier-Liste leeren")
self.clear_ignored_button.setEnabled(ignored_count > 0)
self.clear_ignored_button.clicked.connect(self._clear_ignored)
ignored_row = QHBoxLayout()
ignored_row.addWidget(self.ignored_label, 1)
ignored_row.addWidget(self.clear_ignored_button)
behavior_layout.addRow("Gesperrte Dateien", ignored_row)
tabs = QTabWidget()
tabs.addTab(base_page, "Ordner und Zeitplan")
tabs.addTab(behavior_page, "Konflikte und Löschungen")
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
buttons.button(QDialogButtonBox.StandardButton.Save).setText("Speichern")
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen")
buttons.accepted.connect(self._validate_and_accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.addWidget(tabs, 1)
layout.addWidget(buttons)
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
self.source_field.edit.textChanged.connect(self._update_selection_label)
self._update_visibility()
self._update_selection_label()
def _clear_ignored(self) -> None:
self._ignored_paths.clear()
self.ignored_label.setText("Keine Dateien ignoriert.")
self.clear_ignored_button.setEnabled(False)
def task(self) -> SyncTask:
"""Erzeugt das validierte Modell aus den Formulardaten."""
task = SyncTask(
id=self._original.id if self._original else SyncTask("", "", "").id,
name=self.name_edit.text(),
source=self.source_field.edit.text(),
target=self.target_field.edit.text(),
direction=self.direction_combo.currentData(),
trigger=self.trigger_combo.currentData(),
delete_mode=self.delete_combo.currentData(),
conflict_mode=self.conflict_combo.currentData(),
interval_minutes=self.interval_spin.value(),
selected_paths=self._selected_paths,
ignored_paths=self._ignored_paths,
enabled=self.enabled_check.isChecked(),
last_run=self._original.last_run if self._original else None,
)
task.validate()
return task
def _choose_selection(self) -> None:
root = Path(self.source_field.edit.text())
if not root.is_dir():
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
return
dialog = _SelectionDialog(root, self._selected_paths, self)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._selected_paths = dialog.selected_paths()
self._update_selection_label()
def _validate_and_accept(self) -> None:
try:
self.task()
except ValueError as exc:
QMessageBox.warning(self, "Eingaben prüfen", str(exc))
return
self.accept()
def _update_visibility(self) -> None:
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
def _update_selection_label(self) -> None:
if self._selected_paths:
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
else:
self.selection_label.setText("Gesamter Quellordner")
@staticmethod
def _set_combo(combo: QComboBox, value: object) -> None:
index = combo.findData(value)
if index >= 0:
combo.setCurrentIndex(index)