"""Hauptfenster mit Aufgaben-, Protokoll- und Einstellungsansicht.""" from __future__ import annotations import re import sys from datetime import datetime from pathlib import Path from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase, QResizeEvent from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QFileDialog, QFrame, QHBoxLayout, QHeaderView, QLabel, QMainWindow, QMenu, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider, QSpinBox, QStackedWidget, QSystemTrayIcon, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from syncvito.autostart import set_autostart from syncvito.config_store import ConfigError, ConfigStore from syncvito.coordinator import SyncCoordinator from syncvito.dialogs import TaskDialog from syncvito.icons import app_icon, status_icon from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode from syncvito.state_store import StateStore from syncvito.task_transfer import TaskTransferError, export_task, export_tasks, import_task, import_tasks from syncvito.theme import apply_theme from syncvito.update_controller import UpdateController from syncvito.update_dialog import UpdateDialog from syncvito.updater import UpdateInfo from syncvito.version import __version__ __all__ = ["MainWindow"] UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000 UPDATE_RETRY_INTERVAL_MS = 60 * 1000 PROGRESS_TEXT_WIDTH = 300 PROGRESS_BAR_WIDTH = 450 class _ElidedLabel(QLabel): """Hält dynamische Texte in einer festen Breite vollständig zugänglich.""" def __init__(self, text: str = "", parent: QWidget | None = None) -> None: super().__init__(parent) self._full_text = "" self.setText(text) def setText(self, text: str) -> None: # noqa: N802 - Qt API self._full_text = text self.setToolTip(text) self.setAccessibleName(text) self._refresh_elided_text() def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API super().resizeEvent(event) self._refresh_elided_text() def _refresh_elided_text(self) -> None: visible_text = self.fontMetrics().elidedText( self._full_text, Qt.TextElideMode.ElideMiddle, max(0, self.contentsRect().width()), ) super().setText(visible_text) class MainWindow(QMainWindow): """Zentrale SyncVito-Benutzeroberfläche.""" quit_requested = pyqtSignal() def __init__(self, config: AppConfig, config_store: ConfigStore) -> None: super().__init__() self.config = config self.config_store = config_store self._allow_close = False self._notified_update_version: str | None = None self.setWindowTitle("SyncVito") self.setWindowIcon(app_icon()) self.setMinimumSize(1040, 680) self.resize(1220, 800) self.coordinator = SyncCoordinator(config, config_store, self) self.updater = UpdateController(__version__, self) self._build_ui() self._build_tray() self._connect_coordinator() self._refresh_tasks() self._set_global_status(*self.coordinator.current_overall_status()) self.update_check_timer = QTimer(self) self.update_check_timer.setInterval(UPDATE_CHECK_INTERVAL_MS) self.update_check_timer.timeout.connect(self._automatic_update_check) self.update_check_timer.start() self.update_retry_timer = QTimer(self) self.update_retry_timer.setSingleShot(True) self.update_retry_timer.setInterval(UPDATE_RETRY_INTERVAL_MS) self.update_retry_timer.timeout.connect(self._automatic_update_check) QTimer.singleShot(3000, self._automatic_update_check) QTimer.singleShot(1000, self._show_previous_update_error) cleanup_attempts = 6 if "--updated" in sys.argv else 1 QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts)) if "--updated" in sys.argv: QTimer.singleShot(900, self._show_update_completed) def show_from_tray(self) -> None: """Stellt das Fenster aus dem Tray wieder her.""" self.showNormal() self.activateWindow() self.raise_() def request_quit(self) -> None: """Beendet Observer und Anwendung explizit über das Tray-Menü.""" self._allow_close = True self.coordinator.stop() self.tray.hide() QApplication.instance().quit() def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API if self._allow_close: event.accept() return event.ignore() self.hide() self.tray.showMessage( "SyncVito minimiert", "SyncVito bleibt im Infobereich aktiv.", QSystemTrayIcon.MessageIcon.Information, 2500, ) def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API super().changeEvent(event) if event.type() == QEvent.Type.WindowStateChange and self.isMinimized(): QApplication.processEvents() self.hide() def _build_ui(self) -> None: root = QWidget() self.setCentralWidget(root) root_layout = QHBoxLayout(root) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) root_layout.addWidget(self._build_sidebar()) right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(0) right_layout.addWidget(self._build_header()) self.stack = QStackedWidget() self.tasks_page = self._build_tasks_page() self.log_page = self._build_log_page() self.settings_page = self._build_settings_page() self.stack.addWidget(self.tasks_page) self.stack.addWidget(self.log_page) self.stack.addWidget(self.settings_page) right_layout.addWidget(self.stack, 1) right_layout.addWidget(self._build_progress_dock()) root_layout.addWidget(right, 1) def _build_sidebar(self) -> QWidget: sidebar = QWidget() sidebar.setObjectName("Sidebar") sidebar.setFixedWidth(226) layout = QVBoxLayout(sidebar) layout.setContentsMargins(18, 22, 18, 18) layout.setSpacing(8) eyebrow = QLabel("DATEISYNCHRONISATION") eyebrow.setObjectName("Eyebrow") brand = QLabel("SyncVito") brand.setObjectName("Brand") layout.addWidget(eyebrow) layout.addWidget(brand) layout.addSpacing(28) self.nav_buttons: list[QPushButton] = [] for title, index in (("Aufgaben", 0), ("Protokoll", 1), ("Einstellungen", 2)): button = QPushButton(title) button.setCheckable(True) button.setProperty("nav", True) button.setMinimumHeight(42) button.clicked.connect(lambda checked=False, page=index: self._select_page(page)) layout.addWidget(button) self.nav_buttons.append(button) self.nav_buttons[0].setChecked(True) self.settings_nav_button = self.nav_buttons[2] layout.addStretch(1) version = QLabel(f"Version {__version__}\nby Davito") version.setProperty("muted", True) layout.addWidget(version) return sidebar def _build_header(self) -> QWidget: header = QWidget() header.setObjectName("Header") header.setFixedHeight(82) layout = QHBoxLayout(header) layout.setContentsMargins(28, 0, 28, 0) title_area = QVBoxLayout() title_area.setSpacing(2) self.page_title = QLabel("Synchronisationsaufgaben") self.page_title.setObjectName("PageTitle") self.page_subtitle = QLabel("Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten") self.page_subtitle.setObjectName("PageSubtitle") title_area.addWidget(self.page_title) title_area.addWidget(self.page_subtitle) layout.addLayout(title_area) layout.addStretch(1) self.status_label = QLabel("Bereit") self.status_label.setObjectName("StatusPill") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) layout.addWidget(self.status_label, 0, Qt.AlignmentFlag.AlignVCenter) self.sync_all_button = QPushButton("Alle jetzt synchronisieren") self.sync_all_button.setProperty("primary", True) self.sync_all_button.clicked.connect(self.coordinator.enqueue_all) layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter) self.header_pause_button = QPushButton("Pausieren") self.header_pause_button.clicked.connect(self._toggle_pause) layout.addWidget(self.header_pause_button, 0, Qt.AlignmentFlag.AlignVCenter) return header def _build_tasks_page(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) layout.setContentsMargins(28, 24, 28, 24) layout.setSpacing(14) toolbar = QHBoxLayout() add_button = QPushButton("Neue Aufgabe") add_button.setProperty("primary", True) edit_button = QPushButton("Bearbeiten") delete_button = QPushButton("Löschen") delete_button.setProperty("danger", True) transfer_button = QPushButton("Import / Export") transfer_menu = QMenu(transfer_button) transfer_menu.addSection("Exportieren") transfer_menu.addAction("Ausgewählte Aufgabe exportieren …", self._export_selected_task) transfer_menu.addAction("Alle Aufgaben exportieren …", self._export_all_tasks) transfer_menu.addSection("Importieren") transfer_menu.addAction("Einzelne Aufgabe laden …", self._import_single_task) transfer_menu.addAction("Alle Aufgaben ersetzen …", self._import_all_tasks) transfer_button.setMenu(transfer_menu) self.sync_selected_button = QPushButton("Auswahl synchronisieren") add_button.clicked.connect(self._add_task) edit_button.clicked.connect(self._edit_task) delete_button.clicked.connect(self._delete_task) self.sync_selected_button.clicked.connect(self._sync_selected) toolbar.addWidget(add_button) toolbar.addWidget(edit_button) toolbar.addWidget(delete_button) toolbar.addWidget(transfer_button) toolbar.addStretch(1) toolbar.addWidget(self.sync_selected_button) layout.addLayout(toolbar) self.task_table = QTableWidget(0, 5) self.task_table.setHorizontalHeaderLabels(["Aufgabe", "Ordnerpaar", "Richtung", "Auslösung", "Status"]) self.task_table.setAlternatingRowColors(True) self.task_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.task_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.task_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.task_table.verticalHeader().setVisible(False) self.task_table.verticalHeader().setDefaultSectionSize(58) header = self.task_table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) self.task_table.doubleClicked.connect(self._edit_task) layout.addWidget(self.task_table, 1) self.lock_banner = QLabel() self.lock_banner.setObjectName("LockBanner") self.lock_banner.setWordWrap(True) self.lock_banner.hide() layout.addWidget(self.lock_banner) return page def _build_log_page(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) layout.setContentsMargins(28, 24, 28, 24) layout.setSpacing(12) self.log_table = QTableWidget(0, 4) self.log_table.setHorizontalHeaderLabels(["Zeitpunkt", "Aktion", "Ergebnis", "Details"]) self.log_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.log_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.log_table.setAlternatingRowColors(True) self.log_table.verticalHeader().setVisible(False) header = self.log_table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) clear_button = QPushButton("Ansicht leeren") clear_button.clicked.connect(lambda: self.log_table.setRowCount(0)) button_row = QHBoxLayout() button_row.addStretch(1) button_row.addWidget(clear_button) layout.addLayout(button_row) layout.addWidget(self.log_table, 1) return page def _build_settings_page(self) -> QWidget: page = QWidget() outer = QVBoxLayout(page) outer.setContentsMargins(28, 24, 28, 24) outer.setSpacing(16) card = QFrame() card.setProperty("card", True) layout = QVBoxLayout(card) layout.setContentsMargins(22, 22, 22, 22) layout.setSpacing(16) heading = QLabel("Darstellung und Systemstart") heading.setObjectName("PageTitle") layout.addWidget(heading) self.autostart_check = QCheckBox("SyncVito mit Windows starten") self.autostart_check.setChecked(self.config.settings.autostart) layout.addWidget(self.autostart_check) layout.addSpacing(6) font_row = QHBoxLayout() font_row.addWidget(QLabel("Schriftart")) self.font_combo = QComboBox() self.font_combo.addItems(QFontDatabase.families()) font_index = self.font_combo.findText(self.config.settings.font_family) self.font_combo.setCurrentIndex(max(0, font_index)) font_row.addWidget(self.font_combo, 1) font_row.addWidget(QLabel("Größe")) self.font_size_spin = QSpinBox() self.font_size_spin.setRange(8, 18) self.font_size_spin.setValue(self.config.settings.font_size) font_row.addWidget(self.font_size_spin) font_row.addWidget(QLabel("Schnitt")) self.font_weight_combo = QComboBox() self.font_weight_combo.addItem("Regulär", "regular") self.font_weight_combo.addItem("Fett", "bold") self.font_weight_combo.setCurrentIndex(1 if self.config.settings.font_weight == "bold" else 0) font_row.addWidget(self.font_weight_combo) layout.addLayout(font_row) scale_row = QHBoxLayout() scale_row.addWidget(QLabel("UI-Skalierung")) self.scale_slider = QSlider(Qt.Orientation.Horizontal) self.scale_slider.setRange(80, 150) self.scale_slider.setTickInterval(10) self.scale_slider.setValue(self.config.settings.ui_scale) self.scale_value = QLabel(f"{self.config.settings.ui_scale} %") self.scale_value.setMinimumWidth(52) self.scale_slider.valueChanged.connect(lambda value: self.scale_value.setText(f"{value} %")) scale_row.addWidget(self.scale_slider, 1) scale_row.addWidget(self.scale_value) layout.addLayout(scale_row) hint = QLabel("Änderungen an Schrift und Skalierung werden sofort angewendet und lokal gespeichert.") hint.setProperty("muted", True) layout.addWidget(hint) save_button = QPushButton("Einstellungen übernehmen") save_button.setProperty("primary", True) save_button.clicked.connect(self._save_settings) action_row = QHBoxLayout() action_row.addStretch(1) action_row.addWidget(save_button) layout.addLayout(action_row) outer.addWidget(card) update_card = QFrame() update_card.setProperty("card", True) update_layout = QVBoxLayout(update_card) update_layout.setContentsMargins(22, 22, 22, 22) update_layout.setSpacing(12) update_heading = QLabel("Programmupdates") update_heading.setObjectName("PageTitle") update_layout.addWidget(update_heading) version_label = QLabel(f"Installierte Version: {__version__}") version_label.setProperty("muted", True) update_layout.addWidget(version_label) self.update_check_box = QCheckBox("Beim Start und danach alle 30 Minuten automatisch nach Updates suchen") self.update_check_box.setChecked(self.config.settings.check_updates_on_start) update_layout.addWidget(self.update_check_box) update_hint = QLabel( "Updates werden ausschließlich aus den öffentlichen Releases des SyncVito-Gitea-Repositories geladen " "und vor der Installation per SHA-256 geprüft." ) update_hint.setWordWrap(True) update_hint.setProperty("muted", True) update_layout.addWidget(update_hint) self.update_button = QPushButton("Nach Updates suchen") self.update_button.clicked.connect(lambda: self._check_updates(manual=True)) update_actions = QHBoxLayout() update_actions.addStretch(1) update_actions.addWidget(self.update_button) update_layout.addLayout(update_actions) outer.addWidget(update_card) outer.addStretch(1) return page def _build_progress_dock(self) -> QWidget: dock = QWidget() dock.setObjectName("ProgressDock") dock.setFixedHeight(66) layout = QHBoxLayout(dock) layout.setContentsMargins(28, 12, 28, 12) self.progress_text = _ElidedLabel("Bereit") self.progress_text.setFixedWidth(PROGRESS_TEXT_WIDTH) self.progress_text.setProperty("muted", True) self.progress_bar = QProgressBar() self.progress_bar.setFixedWidth(PROGRESS_BAR_WIDTH) self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) self.progress_bar.setFormat("%p %") self.dock_pause_button = QPushButton("Pausieren") self.dock_pause_button.setObjectName("DockPauseButton") self.dock_pause_button.setFixedHeight(34) if hasattr(self, "_toggle_pause"): self.dock_pause_button.clicked.connect(self._toggle_pause) layout.addWidget(self.progress_text) layout.addWidget(self.progress_bar) layout.addWidget(self.dock_pause_button) layout.addStretch(1) return dock def _build_tray(self) -> None: self.tray = QSystemTrayIcon(status_icon("active"), self) self.tray.setToolTip("SyncVito – Bereit") menu = QMenu() open_action = QAction("Öffnen", self) self.tray_sync_action = QAction("Sync jetzt", self) self.tray_update_action = QAction("Nach Updates suchen", self) self.pause_action = QAction("Pausieren", self) quit_action = QAction("Beenden", self) open_action.triggered.connect(self.show_from_tray) self.tray_sync_action.triggered.connect(self.coordinator.enqueue_all) self.tray_update_action.triggered.connect(lambda: self._check_updates(manual=True)) self.pause_action.triggered.connect(self._toggle_pause) quit_action.triggered.connect(self.request_quit) menu.addAction(open_action) menu.addAction(self.tray_sync_action) menu.addAction(self.pause_action) menu.addAction(self.tray_update_action) menu.addSeparator() menu.addAction(quit_action) self.tray.setContextMenu(menu) self.tray.activated.connect( lambda reason: self.show_from_tray() if reason == QSystemTrayIcon.ActivationReason.DoubleClick else None ) self.tray.show() self._update_pause_action() def _connect_coordinator(self) -> None: self.coordinator.progress_changed.connect(self._update_progress) self.coordinator.log_created.connect(self._append_log) self.coordinator.overall_status_changed.connect(self._set_global_status) self.coordinator.task_state_changed.connect(self._set_task_state) self.coordinator.lock_changed.connect(self._set_lock_message) self.coordinator.idle_changed.connect(lambda _idle: self._refresh_action_availability()) self.updater.update_available.connect(self._on_update_available) self.updater.no_update.connect(self._on_no_update) self.updater.error.connect(self._on_update_error) self.updater.progress.connect(self._update_progress) self.updater.install_ready.connect(self._install_update) self.updater.busy_changed.connect(lambda _busy: self._refresh_action_availability()) def _select_page(self, index: int) -> None: titles = [ ("Synchronisationsaufgaben", "Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten"), ("Aktivitätsprotokoll", "Erfolge, Konflikte und Fehler transparent nachvollziehen"), ("Einstellungen", "Darstellung und Windows-Integration anpassen"), ] self.stack.setCurrentIndex(index) self.page_title.setText(titles[index][0]) self.page_subtitle.setText(titles[index][1]) for button_index, button in enumerate(self.nav_buttons): button.setChecked(button_index == index) def _refresh_tasks(self) -> None: self.task_table.setRowCount(len(self.config.tasks)) running_id = self.coordinator.running_task_id queued_ids = self.coordinator.queued_task_ids paused_id = self.coordinator.paused_task_id is_globally_paused = self.config.settings.paused for row, task in enumerate(self.config.tasks): name = QTableWidgetItem(task.name) name.setData(Qt.ItemDataRole.UserRole, task.id) pair = QTableWidgetItem(f"{task.source}\n→ {task.target}") direction = QTableWidgetItem("Einweg" if task.direction is SyncDirection.ONE_WAY else "Bidirektional") trigger_text = { TriggerMode.MANUAL: "Manuell", TriggerMode.REALTIME: "Echtzeit", TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.", }[task.trigger] trigger = QTableWidgetItem(trigger_text) base_state = self.coordinator.task_state(task.id) if task.id == running_id: val = self.progress_bar.value() status_text = f"🔄 Läuft ({val}%)" tip = f"Wird synchronisiert ({val}%)\n{self.progress_text.toolTip()}" elif is_globally_paused and (task.id == paused_id or task.id in queued_ids): status_text = "⏸ Pausiert" tip = "Aufgabe pausiert – setzt beim Klick auf Fortsetzen fort." elif task.id in queued_ids: pos = queued_ids.index(task.id) + 1 status_text = f"⏳ In Warteschlange (#{pos})" tip = f"Steht an Position {pos} der Warteschlange" else: status_text = self._runtime_state_text(base_state) tip = status_text status = QTableWidgetItem(status_text) for column, item in enumerate((name, pair, direction, trigger, status)): item.setToolTip(tip if column == 4 else item.text()) self.task_table.setItem(row, column, item) def _selected_task(self) -> SyncTask | None: row = self.task_table.currentRow() if row < 0: return None item = self.task_table.item(row, 0) task_id = item.data(Qt.ItemDataRole.UserRole) return next((task for task in self.config.tasks if task.id == task_id), None) def _add_task(self) -> None: dialog = TaskDialog(parent=self) if dialog.exec() == TaskDialog.DialogCode.Accepted: self.config.tasks.append(dialog.task()) self._persist_tasks("Aufgabe angelegt") def _edit_task(self) -> None: task = self._selected_task() if task is None: QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.") return dialog = TaskDialog(task, self) if dialog.exec() == TaskDialog.DialogCode.Accepted: updated = dialog.task() index = self.config.tasks.index(task) self.config.tasks[index] = updated self._persist_tasks("Aufgabe aktualisiert") def _delete_task(self) -> None: task = self._selected_task() if task is None: QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.") return answer = QMessageBox.question( self, "Aufgabe löschen", f"Soll „{task.name}“ wirklich gelöscht werden? Dateien werden dabei nicht entfernt.", ) if answer != QMessageBox.StandardButton.Yes: return self.config.tasks.remove(task) StateStore().remove(task.id) self._persist_tasks("Aufgabe gelöscht") def _export_selected_task(self) -> None: task = self._selected_task() if task is None: QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.") return safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", task.name).strip(" .")[:80] or "Aufgabe" suggested_path = Path.home() / f"SyncVito-{safe_name}.json" filename, _ = QFileDialog.getSaveFileName( self, "Aufgabe exportieren", str(suggested_path), "JSON-Dateien (*.json)", ) if not filename: return export_path = self._json_export_path(filename) try: export_task(export_path, task) except TaskTransferError as exc: QMessageBox.critical(self, "Export fehlgeschlagen", str(exc)) self._append_log("Aufgabe exportiert", "Fehler", str(exc)) return self._append_log("Aufgabe exportiert", "Erfolg", f"{task.name}: {export_path}") QMessageBox.information(self, "Export abgeschlossen", f"Die Aufgabe wurde gespeichert unter:\n{export_path}") def _export_all_tasks(self) -> None: suggested_path = Path.home() / "SyncVito-Aufgaben.json" filename, _ = QFileDialog.getSaveFileName( self, "Alle Aufgaben exportieren", str(suggested_path), "JSON-Dateien (*.json)", ) if not filename: return export_path = self._json_export_path(filename) try: export_tasks(export_path, self.config.tasks) except TaskTransferError as exc: QMessageBox.critical(self, "Export fehlgeschlagen", str(exc)) self._append_log("Alle Aufgaben exportiert", "Fehler", str(exc)) return detail = f"{len(self.config.tasks)} Aufgaben: {export_path}" self._append_log("Alle Aufgaben exportiert", "Erfolg", detail) QMessageBox.information(self, "Export abgeschlossen", f"Alle Aufgaben wurden gespeichert unter:\n{export_path}") def _import_single_task(self) -> None: filename, _ = QFileDialog.getOpenFileName( self, "Einzelne Aufgabe laden", str(Path.home()), "JSON-Dateien (*.json);;Alle Dateien (*)", ) if not filename: return try: task = import_task(Path(filename)) except TaskTransferError as exc: QMessageBox.critical(self, "Import fehlgeschlagen", str(exc)) self._append_log("Aufgabe importiert", "Fehler", str(exc)) return self.config.tasks.append(task) if not self._persist_tasks("Aufgabe importiert"): self.config.tasks.remove(task) self._refresh_tasks() return QMessageBox.information(self, "Import abgeschlossen", f"„{task.name}“ wurde als neue Aufgabe hinzugefügt.") def _import_all_tasks(self) -> None: if self._has_pending_sync_work(): QMessageBox.information( self, "Synchronisation läuft", "Alle Aufgaben können erst ersetzt werden, wenn keine Synchronisation läuft oder wartet.", ) return filename, _ = QFileDialog.getOpenFileName( self, "Alle Aufgaben laden", str(Path.home()), "JSON-Dateien (*.json);;Alle Dateien (*)", ) if not filename: return try: imported_tasks = import_tasks(Path(filename)) except TaskTransferError as exc: QMessageBox.critical(self, "Import fehlgeschlagen", str(exc)) self._append_log("Alle Aufgaben importiert", "Fehler", str(exc)) return answer = QMessageBox.question( self, "Alle Aufgaben ersetzen", f"Die Datei enthält {len(imported_tasks)} Aufgaben. " f"Damit werden alle {len(self.config.tasks)} vorhandenen Aufgaben ersetzt. Fortfahren?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if answer != QMessageBox.StandardButton.Yes: return if self._has_pending_sync_work(): QMessageBox.information( self, "Synchronisation läuft", "Während des Imports wurde eine Synchronisation gestartet. Bitte den Import danach wiederholen.", ) return previous_tasks = self.config.tasks[:] previous_queue = self.config.settings.pending_queue[:] self.config.tasks[:] = imported_tasks self.config.settings.pending_queue = [] if not self._persist_tasks("Alle Aufgaben importiert"): self.config.tasks[:] = previous_tasks self.config.settings.pending_queue = previous_queue self._refresh_tasks() return QMessageBox.information(self, "Import abgeschlossen", f"{len(imported_tasks)} Aufgaben wurden geladen.") @staticmethod def _json_export_path(filename: str) -> Path: path = Path(filename) return path if path.suffix else path.with_suffix(".json") def _has_pending_sync_work(self) -> bool: return self.coordinator.is_running or bool(self.coordinator.queued_task_ids) def _sync_selected(self) -> None: task = self._selected_task() if task is None: QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.") return if self.config.settings.paused: self.coordinator.set_paused(False) self.coordinator.enqueue(task.id) def _persist_tasks(self, message: str) -> bool: try: self.config_store.save(self.config) self.coordinator.refresh_task_states() self.coordinator.rebuild_monitors() except ConfigError as exc: QMessageBox.critical(self, "Speicherfehler", str(exc)) return False self._refresh_tasks() self._append_log("Aufgaben", "Erfolg", message) return True def _save_settings(self) -> None: settings = self.config.settings old_autostart = settings.autostart old_update_check = settings.check_updates_on_start settings.autostart = self.autostart_check.isChecked() settings.font_family = self.font_combo.currentText() settings.font_size = self.font_size_spin.value() settings.font_weight = self.font_weight_combo.currentData() settings.ui_scale = self.scale_slider.value() settings.check_updates_on_start = self.update_check_box.isChecked() settings.validate() try: if old_autostart != settings.autostart: set_autostart(settings.autostart) self.config_store.save(self.config) except (ConfigError, OSError) as exc: QMessageBox.critical(self, "Einstellungen konnten nicht gespeichert werden", str(exc)) settings.autostart = old_autostart return apply_theme(QApplication.instance(), settings) self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.") if settings.check_updates_on_start and not old_update_check: QTimer.singleShot(0, self._automatic_update_check) elif not settings.check_updates_on_start: self.update_retry_timer.stop() def _automatic_update_check(self) -> None: settings = self.config.settings if not settings.check_updates_on_start: self.update_retry_timer.stop() return if self.coordinator.is_running or self.updater.busy: if not self.update_retry_timer.isActive(): self.update_retry_timer.start() return self.update_retry_timer.stop() self.update_check_timer.start(UPDATE_CHECK_INTERVAL_MS) self._check_updates(manual=False) def _check_updates(self, manual: bool) -> None: if self.coordinator.is_running: if manual: QMessageBox.information( self, "Synchronisation läuft", "Bitte die laufende Synchronisation abschließen lassen und die Updateprüfung erneut starten.", ) return self._update_progress(0, "Update wird geprüft …") self.updater.check(manual) def _on_update_available(self, info: UpdateInfo, manual: bool) -> None: self._mark_update_checked() self._update_progress(100, f"Version {info.version} ist verfügbar") self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.") self._set_update_indicator(info.version) if not manual and self._notified_update_version == info.version: return self._notified_update_version = info.version dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self) if dialog.exec() == UpdateDialog.DialogCode.Accepted: self.updater.download(info) def _on_no_update(self, manual: bool) -> None: self._mark_update_checked() self._set_update_indicator(None) self._notified_update_version = None self._append_log("Update", "Erfolg", "Die installierte Version ist aktuell.") if manual: self._update_progress(100, "SyncVito ist aktuell") QMessageBox.information(self, "Keine Updates", "Du verwendest bereits die aktuelle SyncVito-Version.") elif not self.coordinator.is_running: self.progress_text.setText("SyncVito ist aktuell") QTimer.singleShot(3000, self._reset_idle_progress) def _reset_idle_progress(self) -> None: if not self.coordinator.is_running and not self.config.settings.paused: self.progress_text.setText("Bereit") self.progress_bar.setValue(0) def _on_update_error(self, message: str, manual: bool) -> None: self._update_progress(0, "Updateprüfung fehlgeschlagen") self._append_log("Update", "Fehler", message) if manual: QMessageBox.warning( self, "Update nicht möglich", f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.", ) def _set_update_indicator(self, version: str | None) -> None: available = bool(version) self.settings_nav_button.setText("Einstellungen • UPDATE" if available else "Einstellungen") self.settings_nav_button.setProperty("updateAvailable", available) description = f"SyncVito {version} ist verfügbar" if version else "" self.settings_nav_button.setToolTip(description) self.settings_nav_button.setAccessibleDescription(description) style = self.settings_nav_button.style() style.unpolish(self.settings_nav_button) style.polish(self.settings_nav_button) self.settings_nav_button.update() def _install_update(self, path: Path, info: UpdateInfo) -> None: try: self.updater.service.launch_installer(path, info.version) except Exception as exc: # noqa: BLE001 - UI-Grenze verhindert einen Anwendungsabbruch. self._on_update_error(str(exc), True) return self._append_log("Update", "Erfolg", f"Version {info.version} wird installiert.") QMessageBox.information( self, "Update wird installiert", "SyncVito wird jetzt geschlossen, aktualisiert und automatisch neu gestartet.", ) self.request_quit() def _show_update_completed(self) -> None: message = f"Update abgeschlossen – SyncVito {__version__} wurde erfolgreich gestartet." self._append_log("Update", "Erfolg", message) self.tray.showMessage("SyncVito aktualisiert", message, QSystemTrayIcon.MessageIcon.Information, 5000) def _show_previous_update_error(self) -> None: message = self.updater.service.take_install_error() if message: self._append_log("Update", "Fehler", message) QMessageBox.warning( self, "Update nicht abgeschlossen", f"{message}\n\nGestartete Version: {__version__}\nProtokolle: {self.updater.service.update_directory}", ) def _cleanup_update_installers(self, attempts_remaining: int = 1) -> None: _removed, pending = self.updater.service.cleanup_installers() if pending and attempts_remaining > 1: QTimer.singleShot( 3000, lambda: self._cleanup_update_installers(attempts_remaining - 1), ) def _mark_update_checked(self) -> None: self.config.settings.last_update_check = datetime.now().isoformat(timespec="seconds") try: self.config_store.save(self.config) except ConfigError as exc: self._append_log("Update", "Fehler", str(exc)) def _refresh_action_availability(self) -> None: update_idle = not self.updater.busy sync_idle = not self.coordinator.is_running self.sync_all_button.setEnabled(update_idle and sync_idle) self.sync_selected_button.setEnabled(update_idle and sync_idle) self.tray_sync_action.setEnabled(update_idle and sync_idle) self.update_button.setEnabled(update_idle and sync_idle) self.tray_update_action.setEnabled(update_idle and sync_idle) self._update_pause_action() self._refresh_tasks() def _toggle_pause(self) -> None: self.coordinator.set_paused(not self.config.settings.paused) self._update_pause_action() def _update_pause_action(self) -> None: is_paused = self.config.settings.paused is_running = self.coordinator.is_running has_queue = bool(self.coordinator.queued_task_ids) if is_paused: btn_text = "▶ Fortsetzen" tray_text = "Fortsetzen" btn_enabled = True primary = True elif is_running or has_queue: btn_text = "⏸ Pausieren" tray_text = "Pausieren" btn_enabled = True primary = False else: btn_text = "Pausieren" tray_text = "Pausieren" btn_enabled = False primary = False self.pause_action.setText(tray_text) self.pause_action.setEnabled(btn_enabled) for btn in (getattr(self, "header_pause_button", None), getattr(self, "dock_pause_button", None)): if btn is not None: btn.setText(btn_text) btn.setEnabled(btn_enabled) btn.setProperty("primary", primary) btn.style().unpolish(btn) btn.style().polish(btn) def _update_progress(self, value: int, detail: str) -> None: self.progress_bar.setValue(value) self.progress_text.setText(detail) running_id = self.coordinator.running_task_id if running_id: for row in range(self.task_table.rowCount()): item = self.task_table.item(row, 0) if item and item.data(Qt.ItemDataRole.UserRole) == running_id: status_item = self.task_table.item(row, 4) if status_item: status_item.setText(f"🔄 Läuft ({value}%)") status_item.setToolTip(detail) break def _append_log(self, action: str, level: str, detail: str) -> None: row = self.log_table.rowCount() self.log_table.insertRow(row) values = (datetime.now().strftime("%d.%m.%Y %H:%M:%S"), action, level, detail) for column, value in enumerate(values): item = QTableWidgetItem(value) item.setToolTip(value) self.log_table.setItem(row, column, item) if self.log_table.rowCount() > 1000: self.log_table.removeRow(0) self._schedule_log_scroll() def _schedule_log_scroll(self) -> None: if not getattr(self, "_log_scroll_pending", False): self._log_scroll_pending = True QTimer.singleShot(50, self._do_scroll_log) def _do_scroll_log(self) -> None: self._log_scroll_pending = False self.log_table.scrollToBottom() def _set_global_status(self, status: str, text: str) -> None: colors = {"active": "#35C77A", "paused": "#E0525D", "error": "#F0B84B"} self.status_label.setText(text) self.status_label.setStyleSheet(f"color: {colors.get(status, '#E8EDF4')};") self.tray.setIcon(status_icon(status)) self.tray.setToolTip(f"SyncVito – {text}") self._update_pause_action() def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None: self._refresh_tasks() def _set_lock_message(self, message: str) -> None: self.lock_banner.setText(message) self.lock_banner.setVisible(bool(message)) @staticmethod def _runtime_state_text(state: TaskRuntimeState) -> str: return { TaskRuntimeState.ACTIVE: "Aktiv", TaskRuntimeState.PAUSED: "Pausiert", TaskRuntimeState.WAITING_FOR_RELEASE: "Wartet auf Freigabe", TaskRuntimeState.ERROR: "Fehler", }[state]