Initial SyncVito release with installer updater
This commit is contained in:
commit
b9e4c09f49
41 files changed
+4331
No files matched your search
@@ -0,0 +1,210 @@
|
||||
"""Domänenmodelle und validierte Konfigurationswerte."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
__all__ = [
|
||||
"AppConfig",
|
||||
"AppSettings",
|
||||
"ConflictMode",
|
||||
"DeleteMode",
|
||||
"SyncDirection",
|
||||
"SyncTask",
|
||||
"TaskRuntimeState",
|
||||
"TriggerMode",
|
||||
]
|
||||
|
||||
|
||||
class SyncDirection(StrEnum):
|
||||
"""Unterstützte Synchronisationsrichtungen."""
|
||||
|
||||
ONE_WAY = "one_way"
|
||||
BIDIRECTIONAL = "bidirectional"
|
||||
|
||||
|
||||
class TriggerMode(StrEnum):
|
||||
"""Unterstützte Auslöser für eine Aufgabe."""
|
||||
|
||||
REALTIME = "realtime"
|
||||
INTERVAL = "interval"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class DeleteMode(StrEnum):
|
||||
"""Verhalten bei erkannten Löschungen."""
|
||||
|
||||
MIRROR = "mirror"
|
||||
KEEP = "keep"
|
||||
|
||||
|
||||
class ConflictMode(StrEnum):
|
||||
"""Verhalten bei beidseitig geänderten Dateien."""
|
||||
|
||||
NEWER_WINS = "newer_wins"
|
||||
CONFLICT_COPY = "conflict_copy"
|
||||
|
||||
|
||||
class TaskRuntimeState(StrEnum):
|
||||
"""Nicht persistierter, tatsächlicher Laufzeitzustand einer Aufgabe."""
|
||||
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
WAITING_FOR_RELEASE = "waiting_for_release"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncTask:
|
||||
"""Persistierbare Konfiguration eines Ordnerpaars."""
|
||||
|
||||
name: str
|
||||
source: str
|
||||
target: str
|
||||
id: str = field(default_factory=lambda: str(uuid4()))
|
||||
direction: SyncDirection = SyncDirection.ONE_WAY
|
||||
trigger: TriggerMode = TriggerMode.MANUAL
|
||||
delete_mode: DeleteMode = DeleteMode.KEEP
|
||||
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
||||
interval_minutes: int = 30
|
||||
selected_paths: list[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
last_run: str | None = None
|
||||
|
||||
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))
|
||||
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 Zielordner dürfen nicht ineinander liegen.")
|
||||
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()})
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
||||
data = asdict(self)
|
||||
data["direction"] = self.direction.value
|
||||
data["trigger"] = self.trigger.value
|
||||
data["delete_mode"] = self.delete_mode.value
|
||||
data["conflict_mode"] = self.conflict_mode.value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SyncTask:
|
||||
"""Erzeugt eine Aufgabe aus persistierten Daten."""
|
||||
task = cls(
|
||||
id=str(data.get("id") or uuid4()),
|
||||
name=str(data.get("name", "Unbenannte Aufgabe")),
|
||||
source=str(data.get("source", "")),
|
||||
target=str(data.get("target", "")),
|
||||
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", [])],
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
last_run=data.get("last_run"),
|
||||
)
|
||||
task.validate()
|
||||
return task
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppSettings:
|
||||
"""Globale, persistierbare Anwendungseinstellungen."""
|
||||
|
||||
autostart: bool = False
|
||||
font_family: str = "Segoe UI Variable"
|
||||
font_size: int = 10
|
||||
font_weight: str = "regular"
|
||||
ui_scale: int = 100
|
||||
paused: bool = False
|
||||
check_updates_on_start: bool = True
|
||||
last_update_check: str | None = None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Begrenzt Darstellungswerte auf brauchbare Bereiche."""
|
||||
self.font_size = max(8, min(18, int(self.font_size)))
|
||||
self.ui_scale = max(80, min(150, int(self.ui_scale)))
|
||||
if self.font_weight not in {"regular", "bold"}:
|
||||
self.font_weight = "regular"
|
||||
if not self.font_family.strip():
|
||||
self.font_family = "Segoe UI Variable"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die Einstellungen."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AppSettings:
|
||||
"""Erzeugt Einstellungen aus einem Dictionary."""
|
||||
settings = cls(
|
||||
autostart=bool(data.get("autostart", False)),
|
||||
font_family=str(data.get("font_family", "Segoe UI Variable")),
|
||||
font_size=int(data.get("font_size", 10)),
|
||||
font_weight=str(data.get("font_weight", "regular")),
|
||||
ui_scale=int(data.get("ui_scale", 100)),
|
||||
paused=bool(data.get("paused", False)),
|
||||
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
||||
last_update_check=data.get("last_update_check"),
|
||||
)
|
||||
settings.validate()
|
||||
return settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
"""Wurzelobjekt der lokalen JSON-Konfiguration."""
|
||||
|
||||
tasks: list[SyncTask] = field(default_factory=list)
|
||||
settings: AppSettings = field(default_factory=AppSettings)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die vollständige Konfiguration."""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"tasks": [task.to_dict() for task in self.tasks],
|
||||
"settings": self.settings.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
||||
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
|
||||
return cls(
|
||||
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
|
||||
settings=AppSettings.from_dict(data.get("settings", {})),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_relative_path(value: str) -> str:
|
||||
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
|
||||
path = Path(normalized)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise ValueError(f"Ungültige Unterauswahl: {value}")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, base: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
Reference in new issue
Block a user