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