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