Files
SyncVito/src/syncvito/main_window.py
T
2026-10-01 16:58:50 +02:00

983 lines
42 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.
"""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]