9 Commits
Author SHA1 Message Date
Davito 2c31f6eb85 Release SyncVito 1.0.18 2026-10-02 07:08:29 +02:00
Davito f83a85ed40 Release SyncVito 1.0.17 2026-10-01 16:58:50 +02:00
Davito 8218cfd9d5 Release SyncVito 1.0.16 2026-10-01 14:53:08 +02:00
Davito 6c677f0161 Release SyncVito 1.0.15 2026-09-29 05:08:56 +02:00
Davito 1d0b1b2d38 Release SyncVito 1.0.14 2026-09-29 04:53:04 +02:00
Davito cdcd03d7b0 Release SyncVito 1.0.13 2026-09-28 11:08:14 +02:00
Davito b6e32a1b18 Release SyncVito 1.0.12 2026-09-27 23:48:19 +02:00
Davito 786e3ae9b3 Release SyncVito 1.0.12 2026-09-27 23:46:11 +02:00
Davito ccb89191b5 Release SyncVito 1.0.11 2026-09-27 22:42:23 +02:00
21 changed files with 1369 additions and 81 deletions

No files matched your search

+62
View File
@@ -1,5 +1,67 @@
# Änderungsprotokoll
## 1.0.18 – 2026-10-02
### Behoben
- **Automatische Aufgabenwiederherstellung:** Wenn die Konfiguration fehlt oder leer ist, stellt SyncVito Aufgaben aus der neuesten lesbaren Sicherung wieder her. Die Sicherung bleibt erhalten; bereits geprüfte Sicherungen werden markiert, damit absichtlich geleerte Aufgabenlisten nicht später erneut erscheinen.
## 1.0.17 – 2026-10-01
### Neu
- **Aufgaben importieren und exportieren:** Einzelne Aufgaben und die vollständige Aufgabenliste lassen sich als JSON an frei gewählten Speicherorten sichern und laden. Exporte enthalten sämtliche Aufgabenpfade und Einstellungen. Ein Einzelimport fügt eine neue Aufgabe hinzu; ein Gesamtimport ersetzt nach Bestätigung die vorhandene Liste.
- **Einzelne Dateien synchronisieren:** Neben Ordnerpaaren kann eine Quelldatei ausgewählt und in einen frei gewählten Zielordner synchronisiert werden. Der Dateiname bleibt erhalten; Richtung, Zeitplan, Lösch- und Konfliktverhalten gelten wie bei Ordneraufgaben.
## 1.0.16 – 2026-10-01
### Behoben
- **Verständliche Meldung bei abgelehnter NAS-Anmeldung:** Der Zugriff auf Quell- und Zielordner fängt Windows-Fehler 1326 jetzt bereits bei der Pfadprüfung ab und erklärt, dass die gespeicherten Windows-Anmeldeinformationen für den NAS-Server geprüft werden müssen.
## 1.0.15 – 2026-09-29
### Neu
- **Automatisches und stilles Ignorieren blockierter Dateien:** Werden Dateien während eines Laufs als gesperrt (`BlockedFile`), zugriffsverweigert (`PermissionError`) oder fehlerhaft erkannt, merkt sich SyncVito diese relativen Dateipfade automatisch pro Aufgabe. Bei allen nachfolgenden Durchläufen werden diese Dateien vollständig ignoriert – sie werden weder erneut versucht, noch erscheinen sie im Protokoll oder in Fehlermeldungen, sodass der Abgleich sauber und ohne Unterbrechungen mit dem Status „Erfolg“ abschließt.
- **Verwaltung ignorierter Dateien im Aufgabeneditor:** In den Aufgabeneinstellungen unter „Konflikte und Löschungen“ wird die Anzahl aktuell ignorierter Fehlerdateien angezeigt. Über die Schaltfläche „Ignorier-Liste leeren“ kann die Liste jederzeit mit einem Klick zurückgesetzt werden, falls Dateien wieder synchronisiert werden sollen.
## 1.0.14 – 2026-09-29
### Behoben
- **Netzwerk- und Boot-Resilienz bei der Konfigurationsprüfung:** Beim Windows-Start wird die Aufgabenvalidierung von Quell- und Zielordnern nicht mehr durch noch nicht initialisierte Netzwerkverbindungen oder vorübergehend nicht erreichbare UNC-Pfade (`\\Server\Freigabe`) unterbrochen. Fehler bei der Pfadauflösung werden sicher abgefangen.
- **Schutz vor fälschlichem Konfigurations-Reset:** Kurzzeitige Windows-Dateisperren (z. B. durch Hintergrund- oder Suchdienste beim Systemstart) führen nicht mehr zum irrtümlichen Umbenennen der Konfiguration in eine defekte Sicherung. Lesezugriffe auf die Konfigurationsdatei werden bei Sperren automatisch mehrfach wiederholt. Eine Sicherung als `config.defekt` erfolgt ausschließlich bei tatsächlichen Syntaxfehlern in der JSON-Datei.
- **Fehlertolerantes Laden von Aufgaben:** Sollte eine einzelne gespeicherte Aufgabe ungültige Pfade oder Parameter enthalten, bleibt die Gesamtheit aller anderen Aufgaben unberührt. Die betroffene Aufgabe wird sicherheitsrelevanterweise deaktiviert in der Liste geladen, statt den Start zu blockieren oder andere Aufgaben zu verwerfen.
## 1.0.13 – 2026-09-28
### Behoben
- **Zuverlässiger Windows-Autostart via Aufgabenplanung:** Da SyncVito mit Administratorrechten (`requireAdministrator`) läuft, blockierte der Windows Explorer beim Systemstart den Aufruf aus dem Registry-`Run`-Schlüssel. Der Autostart wird nun über eine geplante Windows-Aufgabe (`RunLevel=HighestAvailable`) bei Benutzeranmeldung ausgeführt, sodass SyncVito nach dem Hochfahren zuverlässig und ohne UAC-Dialog im Infobereich startet.
- **Warteschleife für den Windows-Infobereich:** Falls der Windows Explorer beim Booten den Benachrichtigungsbereich noch nicht initialisiert hat, wartet SyncVito bis zu 15 Sekunden auf den Tray, statt sich sofort mit einem Fehler zu beenden.
- **Automatische Autostart-Migration:** Bestehende Konfigurationen mit aktiviertem Autostart werden beim Programmstart automatisch auf die geplante Aufgabe umgestellt; alte Einträge im Run-Schlüssel werden bereinigt.
- **Saubere Deinstallation:** Bei einer Deinstallation über Inno Setup wird die registrierte Windows-Aufgabe für den Autostart automatisch entfernt.
## 1.0.12 – 2026-09-27
### Behoben
- **Endlosschleife bei Wiederholungen beseitigt:** Aufgaben in Wiederholungsschleifen werden nach dem Einreihen sauber aus der Retry-Warteliste entfernt und bei reinen Fehlern ohne gesperrte Dateien nicht mehr dauerhaft im 10-Sekunden-Takt neu gestartet.
- **Wiederholungs-Backoff und Versuchslimit:** Für gesperrte Dateien greift ein exponentielles Backoff (30s, 60s, 120s bis max. 300s) sowie ein Limit von 5 automatischen Versuchen, um Festplatte, Netzwerk und Prozessor vor unnötigen Dauerläufen zu schützen.
- **Flüchtige Cache-Dateien ohne Fehlermeldung:** Dateien, die zwischen Inventarerstellung und Kopiervorgang gelöscht wurden (z. B. dynamische Browser- oder Anwendungs-Caches), werden sauber als übersprungen gewertet und brechen die Sicherung nicht mehr mit `[WinError 2]` ab.
- **Kein Einfrieren der Benutzeroberfläche:** Das automatische Weiterscrollen der Protokolltabelle bei vielen gleichzeitigen Meldungen wird gebündelt entprellt, die Abfrage des Windows Restart Managers für gesperrte Ordner wird pro Verzeichnis zwischengespeichert und Fortschrittsmeldungen werden im GUI-Thread gedrosselt.
## 1.0.11 – 2026-09-27
### Behoben
- Der Button „Pausieren“ ist im Leerlauf deaktiviert, damit SyncVito nicht versehentlich ohne aktive Aufgabe in den Pausenzustand versetzt werden kann.
- Beim Klick auf „Alle jetzt synchronisieren“ oder „Auswahl synchronisieren“ wird ein eventuell aktiver Pausenzustand automatisch aufgehoben und der Lauf sofort gestartet.
- Klickt man auf „Fortsetzen“, wenn zuvor noch keine Aufgabe eingereiht war, startet SyncVito direkt alle aktiven Aufgaben, statt wirkungslos zu bleiben.
- Die automatische Hintergrund-Updateprüfung setzt die Fortschrittsleiste im Leerlauf nicht mehr dauerhaft auf 100 % mit „SyncVito ist aktuell“, sondern die Leiste bleibt ordnungsgemäß auf „Bereit“.
- Die Tray-Benachrichtigung beim Schließen des Fensters lautet nun unmissverständlich „SyncVito minimiert“ statt „SyncVito läuft weiter“.
## 1.0.10 – 2026-09-27
### Neu
+17 -1
View File
@@ -150,7 +150,23 @@ VSVersionInfo(
if ($LASTEXITCODE -ne 0) { throw "Der Release-Commit ist fehlgeschlagen." }
}
$sourceCommitted = $true
git push origin main
$pushTarget = "origin"
$tokenPath = @(
(Join-Path $env:LOCALAPPDATA "SyncVito\developer\gitea-token.dpapi"),
(Join-Path $env:LOCALAPPDATA "MusicBrain\developer\gitea-token.dpapi")
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not [string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
$pushTarget = "https://Davito:$($env:GITEA_TOKEN.Trim())@gitea.davito.synology.me/Davito/SyncVito.git"
}
elseif ($tokenPath) {
try {
$sec = (Get-Content $tokenPath -Raw).Trim() | ConvertTo-SecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
$t = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
$pushTarget = "https://Davito:$t@gitea.davito.synology.me/Davito/SyncVito.git"
} catch { }
}
git push $pushTarget main
if ($LASTEXITCODE -ne 0) { throw "Der Quellstand konnte nicht nach Gitea gepusht werden." }
}
+3
View File
@@ -55,3 +55,6 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent runascurrentuser
Filename: "{app}\{#MyAppExeName}"; Parameters: "--updated"; Flags: nowait skipifnotsilent runascurrentuser
[UninstallRun]
Filename: "schtasks.exe"; Parameters: "/delete /tn ""{#MyAppName}"" /f"; Flags: runhidden
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "syncvito"
version = "1.0.10"
version = "1.0.18"
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
readme = "README.md"
requires-python = ">=3.11"
+2
View File
@@ -6,6 +6,7 @@ from syncvito.models import (
ConflictMode,
DeleteMode,
SyncDirection,
SyncItemType,
SyncTask,
TaskRuntimeState,
TriggerMode,
@@ -19,6 +20,7 @@ __all__ = [
"ConflictMode",
"DeleteMode",
"SyncDirection",
"SyncItemType",
"SyncTask",
"TaskRuntimeState",
"TriggerMode",
+20 -2
View File
@@ -2,12 +2,15 @@
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
from PyQt6.QtCore import QLockFile, QStandardPaths
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from syncvito.autostart import set_autostart
from syncvito.config_store import ConfigError, ConfigStore
from syncvito.icons import app_icon
from syncvito.main_window import MainWindow
@@ -18,6 +21,19 @@ from syncvito.version import __version__
__all__ = ["main"]
def _wait_for_system_tray(application: QApplication, timeout_seconds: float = 15.0) -> bool:
"""Wartet beim Windows-Start auf die Verfügbarkeit des Infobereichs."""
if QSystemTrayIcon.isSystemTrayAvailable():
return True
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
application.processEvents()
time.sleep(0.25)
if QSystemTrayIcon.isSystemTrayAvailable():
return True
return False
def main() -> int:
"""Startet SyncVito als einzelne Windows-Instanz."""
if "--version" in sys.argv:
@@ -33,11 +49,11 @@ def main() -> int:
lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock"
lock = QLockFile(str(lock_path))
lock.setStaleLockTime(0)
lock.setStaleLockTime(30000)
if not lock.tryLock(100):
QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.")
return 0
if not QSystemTrayIcon.isSystemTrayAvailable():
if not _wait_for_system_tray(application):
QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.")
return 1
@@ -47,6 +63,8 @@ def main() -> int:
except ConfigError as exc:
QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc))
config = AppConfig()
if os.name == "nt" and config.settings.autostart:
set_autostart(True)
apply_theme(application, config.settings)
window = MainWindow(config, store)
if "--minimized" not in sys.argv:
+140 -15
View File
@@ -3,29 +3,154 @@
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
from contextlib import suppress
from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
__all__ = ["set_autostart"]
__all__ = ["RUN_KEY_PATH", "TASK_NAME", "generate_task_xml", "set_autostart"]
TASK_NAME = "SyncVito"
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
def set_autostart(enabled: bool) -> None:
"""Setzt oder entfernt SyncVito unter HKCU\\...\\Run."""
def generate_task_xml(command_exe: Path, arguments: str = "--minimized") -> str:
"""Erzeugt die XML-Definition für die Windows-Aufgabenplanung."""
escaped_exe = xml_escape(str(command_exe.resolve()))
escaped_args = xml_escape(arguments)
return (
'<?xml version="1.0" encoding="UTF-16"?>\n'
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n'
" <RegistrationInfo>\n"
" <Description>SyncVito Windows Autostart</Description>\n"
" </RegistrationInfo>\n"
" <Triggers>\n"
" <LogonTrigger>\n"
" <Enabled>true</Enabled>\n"
" <Delay>PT5S</Delay>\n"
" </LogonTrigger>\n"
" </Triggers>\n"
" <Principals>\n"
' <Principal id="Author">\n'
" <LogonType>InteractiveToken</LogonType>\n"
" <RunLevel>HighestAvailable</RunLevel>\n"
" </Principal>\n"
" </Principals>\n"
" <Settings>\n"
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n"
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n"
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n"
" <AllowHardTerminate>true</AllowHardTerminate>\n"
" <StartWhenAvailable>true</StartWhenAvailable>\n"
" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n"
" <IdleSettings>\n"
" <StopOnIdleEnd>false</StopOnIdleEnd>\n"
" <RestartOnIdle>false</RestartOnIdle>\n"
" </IdleSettings>\n"
" <AllowStartOnDemand>true</AllowStartOnDemand>\n"
" <Enabled>true</Enabled>\n"
" <Hidden>false</Hidden>\n"
" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n"
" <WakeToRun>false</WakeToRun>\n"
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n"
" <Priority>7</Priority>\n"
" </Settings>\n"
' <Actions Context="Author">\n'
" <Exec>\n"
f" <Command>{escaped_exe}</Command>\n"
f" <Arguments>{escaped_args}</Arguments>\n"
" </Exec>\n"
" </Actions>\n"
"</Task>"
)
def _run_schtasks(args: list[str]) -> subprocess.CompletedProcess[str]:
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
return subprocess.run(
["schtasks", *args],
capture_output=True,
text=True,
creationflags=flags,
check=False,
)
def _delete_run_key_entry() -> None:
if os.name != "nt":
return
import winreg
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key:
if enabled:
executable = Path(sys.executable)
if getattr(sys, "frozen", False):
command = f'"{executable}" --minimized'
else:
entry = Path(__file__).resolve().parents[2] / "main.py"
command = f'"{executable}" "{entry}" --minimized'
winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command)
with (
suppress(FileNotFoundError, OSError),
winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key,
):
winreg.DeleteValue(key, TASK_NAME)
def _set_run_key_entry(command: str) -> None:
if os.name != "nt":
return
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, TASK_NAME, 0, winreg.REG_SZ, command)
def _register_scheduled_task(command_exe: Path, arguments: str) -> bool:
xml_content = generate_task_xml(command_exe, arguments)
fd, tmp_file = tempfile.mkstemp(suffix=".xml")
tmp_path = Path(tmp_file)
try:
with os.fdopen(fd, "w", encoding="utf-16") as f:
f.write(xml_content)
result = _run_schtasks(["/create", "/tn", TASK_NAME, "/xml", str(tmp_path), "/f"])
return result.returncode == 0
except OSError:
return False
finally:
with suppress(OSError):
tmp_path.unlink()
def _delete_scheduled_task() -> bool:
result = _run_schtasks(["/delete", "/tn", TASK_NAME, "/f"])
return result.returncode == 0
def set_autostart(enabled: bool) -> bool:
"""Aktiviert oder entfernt SyncVito im Windows-Autostart.
Da SyncVito Administratorrechte (requireAdministrator) benötigt,
ignoriert der Windows-Explorer Einträge unter HKCU\\...\\Run beim Systemstart
vollständig, um unerwünschte UAC-Abfragen beim Logon zu verhindern.
Für erhöhte Anwendungen muss daher ein Scheduled Task in der Windows-
Aufgabenplanung mit 'RunLevel=HighestAvailable' verwendet werden.
"""
if os.name != "nt":
return False
if enabled:
if getattr(sys, "frozen", False):
command_exe = Path(sys.executable)
arguments = "--minimized"
reg_command = f'"{command_exe}" --minimized'
else:
with suppress(FileNotFoundError):
winreg.DeleteValue(key, "SyncVito")
command_exe = Path(sys.executable)
entry = Path(__file__).resolve().parents[2] / "main.py"
arguments = f'"{entry}" --minimized'
reg_command = f'"{command_exe}" "{entry}" --minimized'
if _register_scheduled_task(command_exe, arguments):
_delete_run_key_entry()
return True
# Fallback auf Run-Schlüssel (z. B. nicht-erhöhte Entwicklungsumgebung)
_set_run_key_entry(reg_command)
return False
else:
_delete_scheduled_task()
_delete_run_key_entry()
return True
+143 -5
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json
import os
import shutil
import time
from contextlib import suppress
from datetime import datetime
from pathlib import Path
@@ -28,24 +30,160 @@ class ConfigStore:
def __init__(self, path: Path | None = None) -> None:
self.path = path or default_data_directory() / "config.json"
self._recovery_marker = self.path.with_name("config.recovery.json")
def load(self) -> AppConfig:
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
if not self.path.exists():
return AppConfig()
return self._recover_from_backup(None) or AppConfig()
content: str | None = None
last_io_exc: OSError | None = None
for _ in range(5):
try:
content = self.path.read_text(encoding="utf-8")
break
except OSError as exc:
last_io_exc = exc
time.sleep(0.1)
if content is None:
raise ConfigError(
f"Konfigurationsdatei '{self.path.name}' konnte nicht gelesen werden: {last_io_exc}"
) from last_io_exc
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
raw = json.loads(content)
if not isinstance(raw, dict):
raise ValueError("Das JSON-Wurzelelement ist kein Objekt.")
return AppConfig.from_dict(raw)
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
except (ValueError, TypeError, json.JSONDecodeError) as exc:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
backup_created = False
try:
self.path.replace(recovery_path)
backup_created = True
except OSError:
recovery_path = self.path
raise ConfigError(f"Die Konfiguration war nicht lesbar. Sicherung: {recovery_path}") from exc
if backup_created:
recovered = self._recover_from_backup(None)
if recovered is not None:
return recovered
raise ConfigError(
f"Die Konfiguration war syntaktisch beschädigt ({exc}). Sicherung: {recovery_path}"
) from exc
try:
config = AppConfig.from_dict(raw)
except Exception as exc:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = self.path.with_name(f"config.bak-{timestamp}.json")
try:
shutil.copy2(self.path, backup_path)
except OSError:
pass
else:
recovered = self._recover_from_backup(None)
if recovered is not None:
return recovered
raise ConfigError(
f"Die Konfigurationsdaten konnten nicht verarbeitet werden ({exc}). Sicherungskopie: {backup_path}"
) from exc
if config.tasks:
self._mark_backups_handled(self._recovery_backups())
return config
return self._recover_from_backup(config) or config
def _recovery_backups(self) -> list[Path]:
"""Findet mögliche Konfigurationssicherungen in absteigender Zeitfolge."""
try:
backups = [
*self.path.parent.glob("config.defekt-*.json"),
*self.path.parent.glob("config.bak-*.json"),
]
except OSError:
return []
return sorted(
backups,
key=lambda path: path.stem.removeprefix("config.defekt-").removeprefix("config.bak-"),
reverse=True,
)
def _latest_recovery_backup(self) -> tuple[Path, AppConfig] | None:
"""Liest die neueste syntaktisch gültige Sicherung."""
for backup_path in self._recovery_backups():
try:
raw = json.loads(backup_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
continue
return backup_path, AppConfig.from_dict(raw)
except (OSError, UnicodeError, ValueError, TypeError, AttributeError, OverflowError):
continue
return None
def _handled_backup_names(self) -> set[str]:
"""Lädt die Namen bereits geprüfter Sicherungen."""
try:
raw = json.loads(self._recovery_marker.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return set()
if not isinstance(raw, dict):
return set()
names = raw.get("handled_backups", [])
if not isinstance(names, list):
return set()
return {name for name in names if isinstance(name, str) and Path(name).name == name}
def _mark_backups_handled(self, backups: list[Path]) -> None:
"""Speichert, welche Sicherungen nicht erneut automatisch geladen werden sollen."""
if not backups:
return
handled_names = self._handled_backup_names()
handled_names.update(path.name for path in backups)
temporary = self._recovery_marker.with_suffix(".json.tmp")
try:
temporary.write_text(
json.dumps({"handled_backups": sorted(handled_names)}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
os.replace(temporary, self._recovery_marker)
except OSError:
with suppress(OSError):
temporary.unlink(missing_ok=True)
def _recover_from_backup(self, current_config: AppConfig | None) -> AppConfig | None:
"""Stellt Aufgaben aus der neuesten Sicherung wieder her, wenn die Liste leer ist."""
latest = self._latest_recovery_backup()
if latest is None:
return None
backup_path, backup_config = latest
handled_names = self._handled_backup_names()
if backup_path.name in handled_names:
return None
if not backup_config.tasks:
self._mark_backups_handled(self._recovery_backups())
return None
if current_config is None:
recovered_config = backup_config
else:
snapshot_path = self.path.with_name(
f"config.vor-wiederherstellung-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.json"
)
if self.path.exists():
try:
shutil.copy2(self.path, snapshot_path)
except OSError:
return None
recovered_config = AppConfig(tasks=backup_config.tasks, settings=current_config.settings)
try:
self.save(recovered_config)
except ConfigError:
return recovered_config
self._mark_backups_handled(self._recovery_backups())
return recovered_config
def save(self, config: AppConfig) -> None:
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
+65 -7
View File
@@ -13,7 +13,7 @@ from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
from syncvito.config_store import ConfigStore
from syncvito.file_locks import is_file_locked
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode, _normalize_relative_path
from syncvito.realtime import RealtimeMonitor
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
@@ -88,6 +88,8 @@ class SyncCoordinator(QObject):
self._queue: deque[str] = deque()
self._running_task_id: str | None = None
self._retry_tasks: dict[str, list[Path]] = {}
self._retry_attempts: dict[str, int] = {}
self._retry_cooldowns: dict[str, datetime] = {}
self._lock_messages: dict[str, str] = {}
self._task_states: dict[str, TaskRuntimeState] = {
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
@@ -142,6 +144,8 @@ class SyncCoordinator(QObject):
known_ids = {task.id for task in self.config.tasks}
self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids}
self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
self._retry_attempts = {task_id: att for task_id, att in self._retry_attempts.items() if task_id in known_ids}
self._retry_cooldowns = {task_id: cd for task_id, cd in self._retry_cooldowns.items() if task_id in known_ids}
self._lock_messages = {
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
}
@@ -152,6 +156,8 @@ class SyncCoordinator(QObject):
self._set_task_state(task.id, desired)
if not task.enabled:
self._retry_tasks.pop(task.id, None)
self._retry_attempts.pop(task.id, None)
self._retry_cooldowns.pop(task.id, None)
self._lock_messages.pop(task.id, None)
enabled_ids = {task.id for task in self.config.tasks if task.enabled}
self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids)
@@ -179,6 +185,8 @@ class SyncCoordinator(QObject):
task = self._task(task_id)
if task is None or not task.enabled:
return
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
if self.config.settings.paused:
if task_id != self._running_task_id and task_id not in self._queue:
self._queue.append(task_id)
@@ -194,6 +202,8 @@ class SyncCoordinator(QObject):
def enqueue_all(self) -> None:
"""Stellt alle aktiven Aufgaben zur manuellen Ausführung an."""
if self.config.settings.paused:
self.set_paused(False)
for task in self.config.tasks:
if task.enabled:
self.enqueue(task.id)
@@ -213,6 +223,10 @@ class SyncCoordinator(QObject):
for tid in self.config.settings.pending_queue:
if tid not in self._queue and any(t.id == tid and t.enabled for t in self.config.tasks):
self._queue.append(tid)
if not self._queue and not self._running_task_id:
for task in self.config.tasks:
if task.enabled:
self._queue.append(task.id)
self._start_next()
with suppress(Exception):
self.config_store.save(self.config)
@@ -223,7 +237,9 @@ class SyncCoordinator(QObject):
"""Aktualisiert alle Dateisystemüberwachungen."""
errors = self.monitor.rebuild(self.config.tasks)
for task_id, message in errors:
self._retry_tasks[task_id] = []
self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
self._set_task_state(task_id, TaskRuntimeState.ERROR)
self.log_created.emit("Echtzeit", "Fehler", message)
self._emit_overall_status()
@@ -284,6 +300,15 @@ class SyncCoordinator(QObject):
task.last_run = datetime.now().isoformat(timespec="seconds")
if task_id in self.config.settings.pending_queue:
self.config.settings.pending_queue.remove(task_id)
if result.failed_relatives:
new_ignored = set(task.ignored_paths)
for rel in result.failed_relatives:
with suppress(ValueError):
new_ignored.add(_normalize_relative_path(rel))
if new_ignored != set(task.ignored_paths):
task.ignored_paths = sorted(new_ignored)
with suppress(Exception):
self.config_store.save(self.config)
@@ -292,8 +317,7 @@ class SyncCoordinator(QObject):
self._retry_tasks[task_id] = paths
message = (
f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. "
"Andere Dateien wurden weiterverarbeitet. Automatischer Wiederholungsversuch nach Freigabe; "
"ggf. betroffene Programme schließen. Details im Protokoll."
"Betroffene Dateien werden künftig automatisch ignoriert. Details im Protokoll."
)
self._lock_messages[task_id] = message
self._emit_lock_summary()
@@ -309,12 +333,17 @@ class SyncCoordinator(QObject):
self._emit_lock_summary()
if result.errors:
# Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten.
self._retry_tasks[task_id] = []
if not result.blocked:
self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
for error in result.errors:
self.log_created.emit(task.name, "Fehler", error)
self._set_task_state(task_id, TaskRuntimeState.ERROR)
elif not result.blocked:
self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
self.log_created.emit(task.name, "Erfolg", summary)
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
@@ -323,7 +352,9 @@ class SyncCoordinator(QObject):
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
task = self._task(task_id)
name = task.name if task else "Aufgabe"
self._retry_tasks[task_id] = []
self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
self._lock_messages.pop(task_id, None)
self._emit_lock_summary()
self._set_task_state(task_id, TaskRuntimeState.ERROR)
@@ -366,9 +397,36 @@ class SyncCoordinator(QObject):
def _check_retries(self) -> None:
if self.config.settings.paused or self.is_running:
return
now = datetime.now()
ready_tasks: list[str] = []
for task_id, paths in list(self._retry_tasks.items()):
if paths and all(is_file_locked(path) for path in paths):
cooldown_until = self._retry_cooldowns.get(task_id)
if cooldown_until and now < cooldown_until:
continue
attempts = self._retry_attempts.get(task_id, 0)
if attempts >= 5:
if attempts == 5:
task = self._task(task_id)
name = task.name if task else task_id
self.log_created.emit(
name,
"Hinweis",
"Automatische Wiederholungen vorübergehend ausgesetzt. Bitte sperrende Programme schließen.",
)
self._retry_attempts[task_id] = attempts + 1
continue
check_paths = paths[:10] if len(paths) > 10 else paths
if paths and all(is_file_locked(path) for path in check_paths):
continue
ready_tasks.append(task_id)
for task_id in ready_tasks:
self._retry_tasks.pop(task_id, None)
attempts = self._retry_attempts.get(task_id, 0) + 1
self._retry_attempts[task_id] = attempts
backoff_seconds = min(300, 30 * (2 ** min(attempts - 1, 4)))
self._retry_cooldowns[task_id] = now + timedelta(seconds=backoff_seconds)
self.log_created.emit(
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
)
+58 -4
View File
@@ -27,7 +27,7 @@ from PyQt6.QtWidgets import (
QWidget,
)
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncItemType, SyncTask, TriggerMode
__all__ = ["TaskDialog"]
@@ -35,6 +35,8 @@ __all__ = ["TaskDialog"]
class _PathField(QWidget):
def __init__(self, title: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._title = title
self._select_file = False
self.edit = QLineEdit()
self.edit.setPlaceholderText(title)
self.button = QPushButton("Auswählen …")
@@ -45,7 +47,17 @@ class _PathField(QWidget):
layout.addWidget(self.edit, 1)
layout.addWidget(self.button)
def set_file_selection(self, enabled: bool) -> None:
"""Schaltet zwischen Ordner- und Dateiauswahl um."""
self._select_file = enabled
self.edit.setPlaceholderText("Einzelne Quelldatei wählen" if enabled else self._title)
def _browse(self) -> None:
if self._select_file:
selected, _ = QFileDialog.getOpenFileName(self, "Quelldatei auswählen", self.edit.text(), "Alle Dateien (*)")
if selected:
self.edit.setText(QDir.toNativeSeparators(selected))
return
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
if selected:
self.edit.setText(QDir.toNativeSeparators(selected))
@@ -133,6 +145,7 @@ class TaskDialog(QDialog):
super().__init__(parent)
self._original = task
self._selected_paths = list(task.selected_paths) if task else []
self._ignored_paths = list(task.ignored_paths) if task else []
self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
self.setMinimumWidth(690)
self.resize(720, 610)
@@ -141,9 +154,13 @@ class TaskDialog(QDialog):
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
self.source_field = _PathField("Lokalen Quellordner wählen")
self.target_field = _PathField("NAS- oder Zielordner wählen")
self.item_type_combo = QComboBox()
self.item_type_combo.addItem("Ordnerpaar", SyncItemType.FOLDER)
self.item_type_combo.addItem("Einzelne Datei → Zielordner", SyncItemType.FILE)
if task:
self.source_field.edit.setText(task.source)
self.target_field.edit.setText(task.target)
self._set_combo(self.item_type_combo, task.item_type)
self.direction_combo = QComboBox()
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
@@ -178,6 +195,8 @@ class TaskDialog(QDialog):
selection_row = QHBoxLayout()
selection_row.addWidget(self.selection_label, 1)
selection_row.addWidget(selection_button)
self.selection_row_widget = QWidget()
self.selection_row_widget.setLayout(selection_row)
base_page = QWidget()
base_form = QFormLayout(base_page)
@@ -185,12 +204,15 @@ class TaskDialog(QDialog):
base_form.setHorizontalSpacing(18)
base_form.setVerticalSpacing(14)
base_form.addRow("Name", self.name_edit)
base_form.addRow("Aufgabentyp", self.item_type_combo)
base_form.addRow("Quellordner", self.source_field)
self.source_label = base_form.labelForField(self.source_field)
base_form.addRow("Zielordner", self.target_field)
base_form.addRow("Richtung", self.direction_combo)
base_form.addRow("Auslösung", self.trigger_combo)
base_form.addRow("Intervall", self.interval_spin)
base_form.addRow("Umfang", selection_row)
base_form.addRow("Umfang", self.selection_row_widget)
self.selection_row_label = base_form.labelForField(self.selection_row_widget)
base_form.addRow("", self.enabled_check)
behavior_page = QWidget()
@@ -208,8 +230,21 @@ class TaskDialog(QDialog):
warning.setProperty("muted", True)
behavior_layout.addRow("", warning)
ignored_count = len(self._ignored_paths)
self.ignored_label = QLabel(
f"{ignored_count} Datei(en) werden künftig ignoriert." if ignored_count else "Keine Dateien ignoriert."
)
self.ignored_label.setProperty("muted", True)
self.clear_ignored_button = QPushButton("Ignorier-Liste leeren")
self.clear_ignored_button.setEnabled(ignored_count > 0)
self.clear_ignored_button.clicked.connect(self._clear_ignored)
ignored_row = QHBoxLayout()
ignored_row.addWidget(self.ignored_label, 1)
ignored_row.addWidget(self.clear_ignored_button)
behavior_layout.addRow("Gesperrte Dateien", ignored_row)
tabs = QTabWidget()
tabs.addTab(base_page, "Ordner und Zeitplan")
tabs.addTab(base_page, "Pfade und Zeitplan")
tabs.addTab(behavior_page, "Konflikte und Löschungen")
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
@@ -224,10 +259,16 @@ class TaskDialog(QDialog):
layout.addWidget(buttons)
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
self.item_type_combo.currentIndexChanged.connect(self._update_visibility)
self.source_field.edit.textChanged.connect(self._update_selection_label)
self._update_visibility()
self._update_selection_label()
def _clear_ignored(self) -> None:
self._ignored_paths.clear()
self.ignored_label.setText("Keine Dateien ignoriert.")
self.clear_ignored_button.setEnabled(False)
def task(self) -> SyncTask:
"""Erzeugt das validierte Modell aus den Formulardaten."""
task = SyncTask(
@@ -235,12 +276,14 @@ class TaskDialog(QDialog):
name=self.name_edit.text(),
source=self.source_field.edit.text(),
target=self.target_field.edit.text(),
item_type=self.item_type_combo.currentData(),
direction=self.direction_combo.currentData(),
trigger=self.trigger_combo.currentData(),
delete_mode=self.delete_combo.currentData(),
conflict_mode=self.conflict_combo.currentData(),
interval_minutes=self.interval_spin.value(),
selected_paths=self._selected_paths,
ignored_paths=self._ignored_paths,
enabled=self.enabled_check.isChecked(),
last_run=self._original.last_run if self._original else None,
)
@@ -248,6 +291,8 @@ class TaskDialog(QDialog):
return task
def _choose_selection(self) -> None:
if self.item_type_combo.currentData() is SyncItemType.FILE:
return
root = Path(self.source_field.edit.text())
if not root.is_dir():
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
@@ -266,11 +311,20 @@ class TaskDialog(QDialog):
self.accept()
def _update_visibility(self) -> None:
is_file_task = self.item_type_combo.currentData() is SyncItemType.FILE
self.source_field.set_file_selection(is_file_task)
if self.source_label is not None:
self.source_label.setText("Quelldatei" if is_file_task else "Quellordner")
self.selection_row_widget.setVisible(not is_file_task)
if self.selection_row_label is not None:
self.selection_row_label.setVisible(not is_file_task)
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
def _update_selection_label(self) -> None:
if self._selected_paths:
if self.item_type_combo.currentData() is SyncItemType.FILE:
self.selection_label.setText("Nur die ausgewählte Quelldatei")
elif self._selected_paths:
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
else:
self.selection_label.setText("Gesamter Quellordner")
+184 -8
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
import sys
from datetime import datetime
from pathlib import Path
@@ -13,6 +14,7 @@ from PyQt6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFileDialog,
QFrame,
QHBoxLayout,
QHeaderView,
@@ -40,6 +42,7 @@ 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
@@ -140,8 +143,8 @@ class MainWindow(QMainWindow):
event.ignore()
self.hide()
self.tray.showMessage(
"SyncVito läuft weiter",
"Das Fenster wurde in den Infobereich verschoben.",
"SyncVito minimiert",
"SyncVito bleibt im Infobereich aktiv.",
QSystemTrayIcon.MessageIcon.Information,
2500,
)
@@ -251,6 +254,15 @@ class MainWindow(QMainWindow):
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)
@@ -259,6 +271,7 @@ class MainWindow(QMainWindow):
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)
@@ -567,23 +580,155 @@ class MainWindow(QMainWindow):
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) -> None:
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
return False
self._refresh_tasks()
self._append_log("Aufgaben", "Erfolg", message)
return True
def _save_settings(self) -> None:
settings = self.config.settings
@@ -652,10 +797,18 @@ class MainWindow(QMainWindow):
self._mark_update_checked()
self._set_update_indicator(None)
self._notified_update_version = None
self._update_progress(100, "SyncVito ist aktuell")
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")
@@ -740,18 +893,32 @@ class MainWindow(QMainWindow):
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"
else:
btn_text = "⏸ Pausieren" if self.coordinator.is_running else "Pausieren"
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.setProperty("primary", is_paused)
btn.setEnabled(btn_enabled)
btn.setProperty("primary", primary)
btn.style().unpolish(btn)
btn.style().polish(btn)
@@ -779,6 +946,15 @@ class MainWindow(QMainWindow):
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:
+68 -14
View File
@@ -15,6 +15,7 @@ __all__ = [
"ConflictMode",
"DeleteMode",
"SyncDirection",
"SyncItemType",
"SyncTask",
"TaskRuntimeState",
"TriggerMode",
@@ -28,6 +29,13 @@ class SyncDirection(StrEnum):
BIDIRECTIONAL = "bidirectional"
class SyncItemType(StrEnum):
"""Art der zu synchronisierenden Quelle."""
FOLDER = "folder"
FILE = "file"
class TriggerMode(StrEnum):
"""Unterstützte Auslöser für eine Aufgabe."""
@@ -61,7 +69,7 @@ class TaskRuntimeState(StrEnum):
@dataclass(slots=True)
class SyncTask:
"""Persistierbare Konfiguration eines Ordnerpaars."""
"""Persistierbare Konfiguration eines Ordner- oder Dateiabgleichs."""
name: str
source: str
@@ -73,37 +81,59 @@ class SyncTask:
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
interval_minutes: int = 30
selected_paths: list[str] = field(default_factory=list)
ignored_paths: list[str] = field(default_factory=list)
enabled: bool = True
last_run: str | None = None
item_type: SyncItemType = SyncItemType.FOLDER
def validate(self) -> None:
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
if not self.name.strip():
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
if not self.source.strip() or not self.target.strip():
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
source = Path(self.source).expanduser().resolve(strict=False)
target = Path(self.target).expanduser().resolve(strict=False)
source_norm = os.path.normcase(str(source))
raise ValueError("Quell- und Zielpfade müssen angegeben werden.")
self.item_type = SyncItemType(self.item_type)
source = _safe_resolve(Path(self.source).expanduser())
target = _safe_resolve(Path(self.target).expanduser())
source_root = source.parent if self.item_type is SyncItemType.FILE else source
source_norm = os.path.normcase(str(source_root))
target_norm = os.path.normcase(str(target))
if source_norm == target_norm:
raise ValueError("Quell- und Zielordner dürfen nicht identisch sein.")
if _is_relative_to(source, target) or _is_relative_to(target, source):
raise ValueError("Quell- und Zielpfade dürfen nicht identisch sein oder ineinander liegen.")
if _is_relative_to(source_root, target) or _is_relative_to(target, source_root):
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
if self.item_type is SyncItemType.FILE:
if source.is_dir():
raise ValueError("Die Quelle einer Dateiaufgabe muss eine Datei sein.")
if target.exists() and not target.is_dir():
raise ValueError("Das Ziel einer Dateiaufgabe muss ein Ordner sein.")
else:
if source.exists() and not source.is_dir():
raise ValueError("Die Quelle einer Ordneraufgabe muss ein Ordner sein.")
if target.exists() and not target.is_dir():
raise ValueError("Das Ziel einer Ordneraufgabe muss ein Ordner sein.")
if not 1 <= self.interval_minutes <= 10080:
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
self.name = self.name.strip()
self.source = str(source)
self.target = str(target)
self.selected_paths = sorted({_normalize_relative_path(item) for item in self.selected_paths if item.strip()})
if self.item_type is SyncItemType.FILE:
self.selected_paths = [source.name]
else:
self.selected_paths = sorted(
{_normalize_relative_path(item) for item in self.selected_paths if item.strip()}
)
self.ignored_paths = sorted({_normalize_relative_path(item) for item in self.ignored_paths if item.strip()})
def to_dict(self) -> dict[str, Any]:
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
data = asdict(self)
data["item_type"] = self.item_type.value
data["direction"] = self.direction.value
data["trigger"] = self.trigger.value
data["delete_mode"] = self.delete_mode.value
data["conflict_mode"] = self.conflict_mode.value
data["ignored_paths"] = list(self.ignored_paths)
return data
@classmethod
@@ -114,16 +144,21 @@ class SyncTask:
name=str(data.get("name", "Unbenannte Aufgabe")),
source=str(data.get("source", "")),
target=str(data.get("target", "")),
item_type=SyncItemType(data.get("item_type", SyncItemType.FOLDER.value)),
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
interval_minutes=int(data.get("interval_minutes", 30)),
selected_paths=[str(item) for item in data.get("selected_paths", [])],
ignored_paths=[str(item) for item in data.get("ignored_paths", [])],
enabled=bool(data.get("enabled", True)),
last_run=data.get("last_run"),
)
task.validate()
try:
task.validate()
except ValueError:
task.enabled = False
return task
@@ -191,12 +226,28 @@ class AppConfig:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
tasks: list[SyncTask] = []
for item in data.get("tasks", []):
if not isinstance(item, dict):
continue
try:
tasks.append(SyncTask.from_dict(item))
except Exception:
continue
return cls(
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
tasks=tasks,
settings=AppSettings.from_dict(data.get("settings", {})),
)
def _safe_resolve(path: Path) -> Path:
"""Löst Pfade sicher auf; fängt Netzwerkfehler und unvollständige Pfade ab."""
try:
return path.resolve(strict=False)
except OSError:
return path.absolute()
def _normalize_relative_path(value: str) -> str:
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
path = Path(normalized)
@@ -207,7 +258,10 @@ def _normalize_relative_path(value: str) -> str:
def _is_relative_to(path: Path, base: Path) -> bool:
try:
path.relative_to(base)
except ValueError:
return False
return True
return path.is_relative_to(base)
except (ValueError, OSError, AttributeError):
try:
path.relative_to(base)
return True
except ValueError:
return False
+32 -7
View File
@@ -2,23 +2,38 @@
from __future__ import annotations
import os
from collections.abc import Callable
from pathlib import Path
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from syncvito.models import SyncDirection, SyncTask, TriggerMode
from syncvito.models import SyncDirection, SyncItemType, SyncTask, TriggerMode
__all__ = ["RealtimeMonitor"]
class _TaskEventHandler(FileSystemEventHandler):
def __init__(self, task_id: str, callback: Callable[[str], None]) -> None:
def __init__(
self,
task_id: str,
callback: Callable[[str], None],
watched_files: set[str] | None = None,
) -> None:
self.task_id = task_id
self.callback = callback
self.watched_files = watched_files
def on_any_event(self, event: FileSystemEvent) -> None:
if self.watched_files is not None:
event_paths = {event.src_path}
destination = getattr(event, "dest_path", None)
if destination:
event_paths.add(destination)
normalized_paths = {os.path.normcase(os.path.abspath(path)) for path in event_paths}
if not self.watched_files.intersection(normalized_paths):
return
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
self.callback(self.task_id)
@@ -39,16 +54,26 @@ class RealtimeMonitor:
for task in tasks:
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
continue
handler = _TaskEventHandler(task.id, self.callback)
paths = [Path(task.source)]
if task.direction is SyncDirection.BIDIRECTIONAL:
paths.append(Path(task.target))
watched_files: set[str] | None = None
if task.item_type is SyncItemType.FILE:
source_file = Path(task.source)
watched_files = {os.path.normcase(os.path.abspath(str(source_file)))}
paths = [source_file.parent]
if task.direction is SyncDirection.BIDIRECTIONAL:
target_file = Path(task.target) / source_file.name
watched_files.add(os.path.normcase(os.path.abspath(str(target_file))))
paths.append(Path(task.target))
else:
paths = [Path(task.source)]
if task.direction is SyncDirection.BIDIRECTIONAL:
paths.append(Path(task.target))
handler = _TaskEventHandler(task.id, self.callback, watched_files)
for path in paths:
if not path.is_dir():
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
continue
try:
observer.schedule(handler, str(path), recursive=True)
observer.schedule(handler, str(path), recursive=task.item_type is SyncItemType.FOLDER)
scheduled = True
except OSError as exc:
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {exc}"))
+113 -14
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from time import monotonic
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncItemType, SyncTask, _normalize_relative_path
from syncvito.state_store import StateStore
__all__ = [
@@ -22,6 +22,7 @@ __all__ = [
"SyncEngine",
"SyncResult",
"UnavailablePathError",
"is_relative_path_ignored",
]
ProgressCallback = Callable[[int, str], None]
@@ -31,6 +32,36 @@ class UnavailablePathError(RuntimeError):
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
def _unavailable_path_message(label: str, path: Path, exc: OSError) -> str:
"""Erklärt typische NAS-Anmeldefehler verständlich und erhält den Pfadkontext."""
if getattr(exc, "winerror", None) == 1326:
return (
f"{label} nicht erreichbar: Windows hat die NAS-Anmeldung abgelehnt "
"(WinError 1326: Benutzername oder Kennwort falsch). Prüfe die gespeicherten "
"Windows-Anmeldeinformationen für den NAS-Server und teste den Zugriff im Explorer. "
f"Pfad: {path}"
)
return f"{label} nicht erreichbar: {path}"
def is_relative_path_ignored(relative: str, ignored_set: set[str]) -> bool:
"""Prüft, ob ein relativer Pfad oder eines seiner Elternverzeichnisse ignoriert wird."""
if not ignored_set or not relative.strip():
return False
try:
norm = _normalize_relative_path(relative)
except ValueError:
return False
if norm in ignored_set:
return True
rel_path = Path(norm)
for parent in rel_path.parents:
parent_str = str(parent)
if parent_str != "." and parent_str in ignored_set:
return True
return False
@dataclass(frozen=True, slots=True)
class FileRecord:
"""Kompakte Dateisystemmetadaten für Vergleiche."""
@@ -64,6 +95,7 @@ class SyncResult:
paused: bool = False
blocked: list[BlockedFile] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
failed_relatives: set[str] = field(default_factory=set)
inaccessible: bool = False
@property
@@ -95,20 +127,32 @@ class SyncEngine:
) -> SyncResult:
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
task.validate()
source_root = Path(task.source)
source_path = Path(task.source)
source_root = source_path.parent if task.item_type is SyncItemType.FILE else source_path
target_root = Path(task.target)
selected_paths = [source_path.name] if task.item_type is SyncItemType.FILE else task.selected_paths
if cancel_check and cancel_check():
return SyncResult(paused=True)
if progress:
progress(0, f"{task.name}: Ordner werden geprüft …")
self._validate_roots(source_root, target_root)
source_files, source_dirs = self._inventory(
source_root, task.selected_paths, progress, "Quelle", cancel_check=cancel_check
source_root,
selected_paths,
progress,
"Quelle",
cancel_check=cancel_check,
ignored_paths=task.ignored_paths,
)
if cancel_check and cancel_check():
return SyncResult(paused=True)
target_files, target_dirs = self._inventory(
target_root, task.selected_paths, progress, "Ziel", cancel_check=cancel_check
target_root,
selected_paths,
progress,
"Ziel",
cancel_check=cancel_check,
ignored_paths=task.ignored_paths,
)
if cancel_check and cancel_check():
return SyncResult(paused=True)
@@ -137,15 +181,20 @@ class SyncEngine:
stages: list[str] = []
for op in operations:
is_dir = op.kind in {"Ordner anlegen", "Ordner löschen"}
stage_name = _operation_stage(op.relative, task.selected_paths, is_dir=is_dir)
stage_name = _operation_stage(op.relative, selected_paths, is_dir=is_dir)
if stage_name not in stages:
stages.append(stage_name)
known_locking_processes: dict[Path, tuple[LockingProcess, ...]] = {}
rm_query_count = 0
last_progress_time = 0.0
current_stage = ""
if progress:
progress(0, "Änderungen werden vorbereitet …")
for index, operation in enumerate(operations, start=1):
is_dir = operation.kind in {"Ordner anlegen", "Ordner löschen"}
stage = _operation_stage(operation.relative, task.selected_paths, is_dir=is_dir)
stage = _operation_stage(operation.relative, selected_paths, is_dir=is_dir)
if cancel_check and cancel_check():
result.paused = True
if progress:
@@ -154,20 +203,40 @@ class SyncEngine:
try:
self._execute(operation, result)
except (OSError, shutil.Error) as exc:
if isinstance(exc, FileNotFoundError) and operation.source and not operation.source.exists():
result.skipped += 1
continue
failed_path = (
Path(exc.filename2 or exc.filename)
if isinstance(exc, OSError) and (exc.filename2 or exc.filename)
else operation.target
)
if operation.relative and operation.relative.strip():
result.failed_relatives.add(operation.relative)
blocked_path = _blocked_path(operation, exc)
if blocked_path is not None:
processes = tuple(find_locking_processes(blocked_path))
parent_dir = blocked_path.parent
if parent_dir in known_locking_processes:
processes = known_locking_processes[parent_dir]
elif rm_query_count < 10:
discovered = tuple(find_locking_processes(blocked_path))
rm_query_count += 1
if discovered:
known_locking_processes[parent_dir] = discovered
processes = discovered
else:
processes = next(iter(known_locking_processes.values()), ())
result.blocked.append(BlockedFile(blocked_path, processes))
elif isinstance(exc, PermissionError):
result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})")
else:
result.errors.append(f"{failed_path}: {exc}")
if progress:
now = monotonic()
is_last = index == total
stage_changed = stage != current_stage
if progress and (is_last or stage_changed or now - last_progress_time >= 0.05):
last_progress_time = now
current_stage = stage
if len(stages) > 1:
stage_idx = stages.index(stage) + 1
detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}"
@@ -179,21 +248,34 @@ class SyncEngine:
progress(100, "Keine Änderungen gefunden")
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
refreshed_target, _ = self._inventory(target_root, task.selected_paths)
refreshed_source, _ = self._inventory(source_root, selected_paths, ignored_paths=task.ignored_paths)
refreshed_target, _ = self._inventory(target_root, selected_paths, ignored_paths=task.ignored_paths)
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
return result
@staticmethod
def _validate_roots(source_root: Path, target_root: Path) -> None:
if not source_root.is_dir():
try:
source_is_dir = source_root.is_dir()
except OSError as exc:
raise UnavailablePathError(_unavailable_path_message("Quellordner", source_root, exc)) from exc
if not source_is_dir:
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
if not target_root.exists():
try:
target_exists = target_root.exists()
except OSError as exc:
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
if not target_exists:
try:
target_root.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
if not target_root.is_dir():
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
try:
target_is_dir = target_root.is_dir()
except OSError as exc:
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
if not target_is_dir:
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
@staticmethod
@@ -203,10 +285,15 @@ class SyncEngine:
progress: ProgressCallback | None = None,
label: str = "Ordner",
cancel_check: Callable[[], bool] | None = None,
ignored_paths: list[str] | set[str] | None = None,
) -> tuple[dict[str, FileRecord], set[str]]:
files: dict[str, FileRecord] = {}
directories: set[str] = set()
selections = tuple(Path(item).parts for item in selected_paths)
ignored_set: set[str] = set()
for item in ignored_paths or ():
with suppress(ValueError):
ignored_set.add(_normalize_relative_path(item))
last_report = monotonic()
if progress:
progress(0, f"{label} wird eingelesen: {root}")
@@ -221,6 +308,7 @@ class SyncEngine:
for name in dir_names
if _is_selected((relative_dir / name).parts, selections)
and not _is_directory_link(current_path / name)
and not is_relative_path_ignored(str(relative_dir / name), ignored_set)
]
if progress and monotonic() - last_report >= 0.25:
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
@@ -234,6 +322,8 @@ class SyncEngine:
if path.is_symlink():
continue
relative = path.relative_to(root)
if is_relative_path_ignored(str(relative), ignored_set):
continue
if not _is_selected(relative.parts, selections):
continue
try:
@@ -291,7 +381,13 @@ class SyncEngine:
_Operation("Ordner anlegen", None, source_root / relative, relative)
for relative in sorted(target_dirs - source_dirs)
)
ignored_set: set[str] = set()
for item in task.ignored_paths or ():
with suppress(ValueError):
ignored_set.add(_normalize_relative_path(item))
all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys())
if ignored_set:
all_paths = [p for p in all_paths if not is_relative_path_ignored(p, ignored_set)]
for relative in all_paths:
source = source_files.get(relative)
target = target_files.get(relative)
@@ -344,6 +440,9 @@ class SyncEngine:
return
if operation.source is None:
return
if not operation.source.exists():
result.skipped += 1
return
operation.target.parent.mkdir(parents=True, exist_ok=True)
if operation.conflict_copy and operation.target.exists():
conflict_path = _conflict_path(operation.target)
+155
View File
@@ -0,0 +1,155 @@
"""Versionierter JSON-Import und -Export für Synchronisationsaufgaben."""
from __future__ import annotations
import json
import os
import tempfile
from contextlib import suppress
from pathlib import Path
from typing import Any
from uuid import uuid4
from syncvito.models import SyncTask
__all__ = ["TaskTransferError", "export_task", "export_tasks", "import_task", "import_tasks"]
_FORMAT_VERSION = 1
_SINGLE_TASK_FORMAT = "syncvito.task"
_TASKS_FORMAT = "syncvito.tasks"
_TASK_FIELDS = {
"id",
"name",
"source",
"target",
"item_type",
"direction",
"trigger",
"delete_mode",
"conflict_mode",
"interval_minutes",
"selected_paths",
"ignored_paths",
"enabled",
"last_run",
}
class TaskTransferError(ValueError):
"""Fehlerhafte oder nicht lesbare Aufgaben-Transferdatei."""
def export_task(path: Path, task: SyncTask) -> None:
"""Schreibt eine einzelne Aufgabe mit allen Einstellungen als JSON."""
_write_payload(
path,
{"format": _SINGLE_TASK_FORMAT, "format_version": _FORMAT_VERSION, "task": task.to_dict()},
)
def export_tasks(path: Path, tasks: list[SyncTask]) -> None:
"""Schreibt alle Aufgaben mit ihren Einstellungen und Pfaden als JSON."""
_write_payload(
path,
{
"format": _TASKS_FORMAT,
"format_version": _FORMAT_VERSION,
"tasks": [task.to_dict() for task in tasks],
},
)
def import_task(path: Path) -> SyncTask:
"""Lädt eine einzelne Aufgabe und vergibt eine neue lokale Kennung."""
payload = _read_payload(path, _SINGLE_TASK_FORMAT)
raw_task = payload.get("task")
task = _parse_task(raw_task)
task.id = str(uuid4())
task.last_run = None
return task
def import_tasks(path: Path) -> list[SyncTask]:
"""Lädt und validiert vollständig einen Export der Aufgabenliste."""
payload = _read_payload(path, _TASKS_FORMAT)
raw_tasks = payload.get("tasks")
if not isinstance(raw_tasks, list):
raise TaskTransferError("Die Aufgabenliste in der Datei ist ungültig.")
tasks = [_parse_task(raw_task) for raw_task in raw_tasks]
task_ids = [task.id for task in tasks]
if len(task_ids) != len(set(task_ids)):
raise TaskTransferError("Die Datei enthält doppelte Aufgabenkennungen.")
return tasks
def _read_payload(path: Path, expected_format: str) -> dict[str, Any]:
try:
raw: Any = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TaskTransferError(f"Die Datei konnte nicht gelesen werden: {exc}") from exc
if not isinstance(raw, dict):
raise TaskTransferError("Die Datei enthält kein gültiges SyncVito-JSON-Objekt.")
if raw.get("format") != expected_format:
raise TaskTransferError("Der Dateityp passt nicht zu dieser Importaktion.")
if type(raw.get("format_version")) is not int or raw["format_version"] != _FORMAT_VERSION:
raise TaskTransferError("Diese Version des Aufgabenformats wird nicht unterstützt.")
return raw
def _parse_task(raw_task: Any) -> SyncTask:
if not isinstance(raw_task, dict):
raise TaskTransferError("Eine Aufgabe in der Datei ist kein gültiges JSON-Objekt.")
missing_fields = (_TASK_FIELDS - {"item_type"}).difference(raw_task)
if missing_fields:
fields = ", ".join(sorted(missing_fields))
raise TaskTransferError(f"In einer Aufgabe fehlen erforderliche Daten: {fields}.")
for field in ("id", "name", "source", "target", "direction", "trigger", "delete_mode", "conflict_mode"):
if not isinstance(raw_task[field], str):
raise TaskTransferError(f"Das Feld „{field}“ einer Aufgabe muss Text enthalten.")
if "item_type" in raw_task and not isinstance(raw_task["item_type"], str):
raise TaskTransferError("Der Aufgabentyp ist ungültig.")
if not raw_task["id"].strip():
raise TaskTransferError("Eine Aufgabe hat keine gültige Kennung.")
if type(raw_task["interval_minutes"]) is not int:
raise TaskTransferError("Das Intervall einer Aufgabe muss eine ganze Zahl sein.")
if type(raw_task["enabled"]) is not bool:
raise TaskTransferError("Der Aktivierungszustand einer Aufgabe ist ungültig.")
for field in ("selected_paths", "ignored_paths"):
if not isinstance(raw_task[field], list) or any(not isinstance(item, str) for item in raw_task[field]):
raise TaskTransferError(f"Die Pfadliste „{field}“ einer Aufgabe ist ungültig.")
if raw_task["last_run"] is not None and not isinstance(raw_task["last_run"], str):
raise TaskTransferError("Der letzte Lauf einer Aufgabe ist ungültig.")
try:
task = SyncTask.from_dict(raw_task)
task.validate()
except (TypeError, ValueError, OSError) as exc:
raise TaskTransferError(f"Eine Aufgabe enthält ungültige Einstellungen oder Pfade: {exc}") from exc
return task
def _write_payload(path: Path, payload: dict[str, Any]) -> None:
target = Path(path)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_path = Path(temporary_file.name)
json.dump(payload, temporary_file, ensure_ascii=False, indent=2)
temporary_file.write("\n")
os.replace(temporary_path, target)
except (OSError, TypeError, ValueError) as exc:
raise TaskTransferError(f"Die Exportdatei konnte nicht gespeichert werden: {exc}") from exc
finally:
if temporary_path is not None:
with suppress(OSError):
temporary_path.unlink(missing_ok=True)
+101
View File
@@ -0,0 +1,101 @@
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import syncvito.autostart as autostart
from syncvito.autostart import generate_task_xml, set_autostart
def test_generate_task_xml_structure() -> None:
cmd = Path("C:/Program Files/SyncVito/SyncVito.exe")
args = "--minimized"
xml = generate_task_xml(cmd, args)
assert '<?xml version="1.0" encoding="UTF-16"?>' in xml
assert "<RunLevel>HighestAvailable</RunLevel>" in xml
assert "<LogonType>InteractiveToken</LogonType>" in xml
assert "<Delay>PT5S</Delay>" in xml
assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml
assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml
assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml
assert "<Command>C:\\Program Files\\SyncVito\\SyncVito.exe</Command>" in xml
assert "<Arguments>--minimized</Arguments>" in xml
def test_set_autostart_non_windows(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "posix")
assert not set_autostart(True)
assert not set_autostart(False)
def test_set_autostart_enable_scheduled_task_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "nt")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe")
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0))
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
deleted_run_key = False
def mock_delete_run_key() -> None:
nonlocal deleted_run_key
deleted_run_key = True
monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key)
result = set_autostart(True)
assert result is True
assert mock_run.called
create_call_args = mock_run.call_args[0][0]
assert create_call_args[0] == "/create"
assert create_call_args[1] == "/tn"
assert create_call_args[2] == "SyncVito"
assert deleted_run_key is True
def test_set_autostart_enable_fallback_registry(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "nt")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe")
# schtasks fails (e.g. non-elevated prompt)
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=1))
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
registry_command = None
def mock_set_run_key(cmd: str) -> None:
nonlocal registry_command
registry_command = cmd
monkeypatch.setattr(autostart, "_set_run_key_entry", mock_set_run_key)
result = set_autostart(True)
assert result is False
assert registry_command == r'"C:\Program Files\SyncVito\SyncVito.exe" --minimized'
def test_set_autostart_disable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "nt")
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0))
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
deleted_run_key = False
def mock_delete_run_key() -> None:
nonlocal deleted_run_key
deleted_run_key = True
monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key)
result = set_autostart(False)
assert result is True
assert mock_run.called
delete_call_args = mock_run.call_args[0][0]
assert delete_call_args == ["/delete", "/tn", "SyncVito", "/f"]
assert deleted_run_key is True
+65 -1
View File
@@ -1,6 +1,9 @@
from pathlib import Path
from unittest.mock import patch
from syncvito.config_store import ConfigStore
import pytest
from syncvito.config_store import ConfigError, ConfigStore
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
@@ -28,3 +31,64 @@ def test_config_roundtrip(tmp_path: Path) -> None:
assert loaded.tasks[0].name == "Archiv"
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
assert loaded.tasks[0].selected_paths == ["Dokumente"]
def test_config_corrupt_json_creates_backup(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text("{ unvollstaendiges json...", encoding="utf-8")
store = ConfigStore(path)
with pytest.raises(ConfigError, match="syntaktisch beschädigt"):
store.load()
defekt_files = list(tmp_path.glob("config.defekt-*.json"))
assert len(defekt_files) == 1
assert not path.exists()
def test_config_read_locked_does_not_rename_file(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text('{"schema_version": 1, "tasks": [], "settings": {}}', encoding="utf-8")
store = ConfigStore(path)
with (
patch.object(Path, "read_text", side_effect=PermissionError(13, "Permission denied")),
pytest.raises(ConfigError, match="konnte nicht gelesen werden"),
):
store.load()
# Originaldatei darf bei Lese-/Sperrfehler NICHT umbenannt/gelöscht werden!
assert path.exists()
assert list(tmp_path.glob("config.defekt-*.json")) == []
def test_config_load_with_invalid_task_keeps_task_disabled() -> None:
data = {
"schema_version": 1,
"tasks": [
{
"name": "Ungültig",
"source": "C:\\Ordner",
"target": "C:\\Ordner", # Gleiche Quelle und Ziel -> ValueError bei validate()
"interval_minutes": 30,
}
],
"settings": {},
}
config = AppConfig.from_dict(data)
assert len(config.tasks) == 1
assert config.tasks[0].name == "Ungültig"
assert config.tasks[0].enabled is False
def test_sync_task_safe_resolve_network_oserror(tmp_path: Path) -> None:
task = SyncTask(
name="Netzwerk-Task",
source=str(tmp_path / "lokal"),
target=r"\\UnerreichbarerServer\Freigabe\Ordner",
)
with patch.object(Path, "resolve", side_effect=OSError("Network unreachable")):
task.validate()
assert task.source == str((tmp_path / "lokal").resolve())
assert r"\\UnerreichbarerServer\Freigabe\Ordner" in task.target
+54
View File
@@ -73,6 +73,30 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == ["first", "second"]
assert "first" not in coordinator._retry_tasks
assert "second" not in coordinator._retry_tasks
def test_task_with_only_errors_is_not_queued_for_auto_retry(
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch
) -> None:
task = coordinator.config.tasks[0]
coordinator._on_finished(task.id, SyncResult(errors=["Zugriff verweigert"]))
assert task.id not in coordinator._retry_tasks
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == []
def test_retry_max_attempts_cap(coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch) -> None:
task = coordinator.config.tasks[0]
coordinator._retry_tasks[task.id] = []
coordinator._retry_attempts[task.id] = 5
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == []
def test_start_resets_stale_progress_before_worker_starts(
@@ -137,3 +161,33 @@ def test_stop_persists_running_task_and_queue_for_restart(coordinator: SyncCoord
assert coordinator.config.settings.paused is True
assert coordinator.config.settings.pending_queue == [task1.id, task2.id]
def test_enqueue_all_unpauses_and_runs(coordinator: SyncCoordinator) -> None:
from types import SimpleNamespace
coordinator.set_paused(True)
workers = []
coordinator.pool = SimpleNamespace(start=workers.append)
coordinator.enqueue_all()
assert not coordinator.config.settings.paused
assert len(workers) == 1
assert coordinator.running_task_id == coordinator.config.tasks[0].id
def test_failed_files_automatically_added_to_task_ignored_paths_and_persisted(
coordinator: SyncCoordinator,
) -> None:
task = coordinator.config.tasks[0]
assert task.ignored_paths == []
res = SyncResult(copied=5, errors=["Zugriff verweigert: lock.dat"], failed_relatives={"lock.dat", "sub/cache.bin"})
coordinator._on_finished(task.id, res)
assert "lock.dat" in task.ignored_paths
loaded = coordinator.config_store.load()
loaded_task = next(t for t in loaded.tasks if t.id == task.id)
assert "lock.dat" in loaded_task.ignored_paths
+9 -1
View File
@@ -3,9 +3,17 @@ from pathlib import Path
def test_installer_launches_admin_application_with_elevated_setup_token() -> None:
script = Path("installer/SyncVito.iss").read_text(encoding="utf-8")
run_entries = [line for line in script.splitlines() if line.startswith("Filename:")]
run_section = script.split("[Run]")[1].split("[")[0] if "[Run]" in script else ""
run_entries = [line for line in run_section.splitlines() if line.startswith("Filename:")]
assert len(run_entries) == 2
assert all("runascurrentuser" in entry for entry in run_entries)
assert "postinstall" in run_entries[0]
assert 'Parameters: "--updated"' in run_entries[1]
def test_installer_cleans_up_scheduled_task_on_uninstall() -> None:
script = Path("installer/SyncVito.iss").read_text(encoding="utf-8")
uninstall_section = script.split("[UninstallRun]")[1].split("[")[0] if "[UninstallRun]" in script else ""
assert "schtasks.exe" in uninstall_section
assert "{#MyAppName}" in uninstall_section
+76
View File
@@ -269,3 +269,79 @@ def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None:
assert (target / "a.txt").exists()
assert (target / "b.txt").exists()
assert (target / "c.txt").exists()
def test_concurrently_deleted_source_file_is_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "ephemeral.txt").write_text("wird gelöscht")
task = SyncTask(name="Transient", source=str(source), target=str(target))
engine = _engine(tmp_path)
# Simuliere, dass die Datei kurz vor der Ausführung gelöscht wurde
def delete_before_execute(operation, result):
if operation.source and operation.source.name == "ephemeral.txt":
operation.source.unlink(missing_ok=True)
return SyncEngine._execute(operation, result)
monkeypatch.setattr(engine, "_execute", delete_before_execute)
result = engine.synchronize(task)
assert result.successful
assert result.skipped == 1
assert not result.errors
assert not (target / "ephemeral.txt").exists()
def test_ignored_paths_are_completely_skipped(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "normal.txt").write_text("kopieren", encoding="utf-8")
(source / "locked.db").write_text("gesperrt", encoding="utf-8")
(source / "subdir").mkdir()
(source / "subdir" / "ignored_file.txt").write_text("in ordner", encoding="utf-8")
task = SyncTask(
name="IgnoredTest",
source=str(source),
target=str(target),
ignored_paths=["locked.db", "subdir/ignored_file.txt"],
)
engine = _engine(tmp_path)
result = engine.synchronize(task)
assert result.successful
assert result.copied == 1
assert (target / "normal.txt").exists()
assert not (target / "locked.db").exists()
assert not (target / "subdir" / "ignored_file.txt").exists()
def test_failed_files_added_to_failed_relatives(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "ok.txt").write_text("ok", encoding="utf-8")
(source / "fail.txt").write_text("fail", encoding="utf-8")
task = SyncTask(name="FailTest", source=str(source), target=str(target))
engine = _engine(tmp_path)
real_execute = SyncEngine._execute
def mock_execute(operation, result):
if operation.relative == "fail.txt":
raise PermissionError("Access denied")
return real_execute(operation, result)
monkeypatch.setattr(engine, "_execute", mock_execute)
result = engine.synchronize(task)
assert not result.successful
assert "fail.txt" in result.failed_relatives
assert "ok.txt" not in result.failed_relatives
assert (target / "ok.txt").exists()
+1 -1
View File
@@ -1 +1 @@
1.0.10
1.0.18