303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""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)
|