3 Commits
Author SHA1 Message Date
Davito ccb89191b5 Release SyncVito 1.0.11 2026-09-27 22:42:23 +02:00
Davito 7098fa1c0c Release SyncVito 1.0.10 2026-09-27 22:26:13 +02:00
Davito 4efc1efa18 Release SyncVito 1.0.9 2026-09-24 16:46:22 +02:00
10 changed files with 506 additions and 28 deletions

No files matched your search

+28
View File
@@ -1,5 +1,33 @@
# Änderungsprotokoll
## 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
- **Etappen- und Bereichsanzeige:** Beim Synchronisieren wird der aktuelle Hauptordner (z. B. AppData, Dokumente oder definierte Unterauswahlen) zusammen mit der aktuellen Etappe (z. B. `[2/4 AppData]`) im Fortschrittsdock angezeigt, sodass stets ersichtlich ist, in welchem Bereich SyncVito gerade arbeitet und wie viele Ordner noch ausstehen.
- **Live-Status in der Aufgabenliste:** Die Aufgaben-Tabelle zeigt laufende Vorgänge live mit aktuellem Prozentsatz (z. B. `🔄 Läuft (45 %)`), wartende Aufgaben mit ihrer Listenposition (`⏳ In Warteschlange (#2)`) sowie pausierte Aufgaben an.
- **Pause- und Fortsetzen-Funktion:** Synchronisationsläufe können jederzeit über Buttons im Header, im Progress-Dock sowie im Tray-Menü pausiert und fortgesetzt werden.
- **Nahtlose Wiederaufnahme nach Neustart:** Der Pausenzustand und die verbleibende Aufgaben-Warteschlange werden dauerhaft gesichert. Nach einem Neustart des Tools oder des Rechners setzt SyncVito genau an der unterbrochenen Stelle fort; bereits kopierte Dateien werden anhand ihrer Signatur sofort übersprungen.
## 1.0.9 – 2026-09-24
### Behoben
- Beim Start einer Synchronisation wird die Fortschrittsanzeige sofort zurückgesetzt. Bereits beim Einlesen von Quelle und Ziel erscheinen der aktuelle Ordner und die Anzahl gefundener Dateien; eine vorherige Updateanzeige bleibt nicht mehr unverändert stehen.
- Der Gesamtstatus kennzeichnet einen laufenden Vorgang eindeutig mit „Synchronisierung läuft“.
- Windows-Verzeichnisverknüpfungen (Junctions) werden beim Einlesen übersprungen, damit insbesondere AppData nicht mehrfach oder zyklisch durchsucht wird.
- Bei einer Unterauswahl werden nicht ausgewählte Verzeichniszweige nicht mehr vollständig durchsucht.
## 1.0.8 – 2026-09-24
### Behoben
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "syncvito"
version = "1.0.8"
version = "1.0.11"
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
readme = "README.md"
requires-python = ">=3.11"
+105 -11
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import threading
import traceback
from collections import deque
from contextlib import suppress
from datetime import datetime, timedelta
from pathlib import Path
@@ -25,12 +27,12 @@ def derive_overall_status(
if TaskRuntimeState.ERROR in states:
return "error", "Fehler"
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
return "error", "Synchronisiert" if running else "Unvollständig – Dateien gesperrt"
return "error", "Synchronisierung läuft" if running else "Unvollständig – Dateien gesperrt"
if globally_paused:
return "paused", "Pausiert"
if TaskRuntimeState.ACTIVE not in states:
return "paused", "Inaktiv"
return "active", "Synchronisiert" if running else "Bereit"
return "active", "Synchronisierung läuft" if running else "Bereit"
class _WorkerSignals(QObject):
@@ -40,15 +42,17 @@ class _WorkerSignals(QObject):
class _SyncWorker(QRunnable):
def __init__(self, task: SyncTask, engine: SyncEngine) -> None:
def __init__(self, task: SyncTask, engine: SyncEngine, pause_event: threading.Event | None = None) -> None:
super().__init__()
self.task = task
self.engine = engine
self.pause_event = pause_event
self.signals = _WorkerSignals()
def run(self) -> None:
try:
result = self.engine.synchronize(self.task, self.signals.progress.emit)
cancel_check = self.pause_event.is_set if self.pause_event else None
result = self.engine.synchronize(self.task, self.signals.progress.emit, cancel_check=cancel_check)
except UnavailablePathError as exc:
self.signals.failed.emit(self.task.id, str(exc), True)
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
@@ -79,6 +83,8 @@ class SyncCoordinator(QObject):
self.engine = SyncEngine()
self.pool = QThreadPool.globalInstance()
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
self._pause_event = threading.Event()
self._paused_task_id: str | None = None
self._queue: deque[str] = deque()
self._running_task_id: str | None = None
self._retry_tasks: dict[str, list[Path]] = {}
@@ -91,6 +97,13 @@ class SyncCoordinator(QObject):
self._bridge.changed.connect(self._on_realtime_event)
self.monitor = RealtimeMonitor(self._bridge.changed.emit)
if self.config.settings.pending_queue:
for task_id in self.config.settings.pending_queue:
if any(t.id == task_id and t.enabled for t in self.config.tasks) and task_id not in self._queue:
self._queue.append(task_id)
if self.config.settings.paused and self._queue:
self._paused_task_id = self._queue[0]
self._schedule_timer = QTimer(self)
self._schedule_timer.setInterval(30_000)
self._schedule_timer.timeout.connect(self._check_intervals)
@@ -101,6 +114,21 @@ class SyncCoordinator(QObject):
self._retry_timer.start()
self.rebuild_monitors()
@property
def running_task_id(self) -> str | None:
"""ID der aktuell ausgeführten Aufgabe."""
return self._running_task_id
@property
def queued_task_ids(self) -> list[str]:
"""IDs aller Aufgaben in der aktuellen Warteschlange."""
return list(self._queue)
@property
def paused_task_id(self) -> str | None:
"""ID der zuletzt unterbrochenen Aufgabe."""
return self._paused_task_id
def task_state(self, task_id: str) -> TaskRuntimeState:
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
@@ -135,20 +163,39 @@ class SyncCoordinator(QObject):
"""Zeigt an, ob momentan ein Worker aktiv ist."""
return self._running_task_id is not None
def _save_pending_queue(self) -> None:
pending: list[str] = []
if self._running_task_id is not None:
pending.append(self._running_task_id)
for tid in self._queue:
if tid not in pending:
pending.append(tid)
self.config.settings.pending_queue = pending
with suppress(Exception):
self.config_store.save(self.config)
def enqueue(self, task_id: str) -> None:
"""Stellt eine Aufgabe dedupliziert in die serielle Warteschlange."""
if self.config.settings.paused:
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert.")
return
task = self._task(task_id)
if task is None or not task.enabled:
return
if self.config.settings.paused:
if task_id != self._running_task_id and task_id not in self._queue:
self._queue.append(task_id)
self._save_pending_queue()
self._set_task_state(task_id, TaskRuntimeState.PAUSED)
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert. Aufgabe eingereiht.")
return
if task_id != self._running_task_id and task_id not in self._queue:
self._queue.append(task_id)
self._save_pending_queue()
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
self._start_next()
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)
@@ -156,13 +203,27 @@ class SyncCoordinator(QObject):
def set_paused(self, paused: bool) -> None:
"""Ändert den globalen Pausenzustand."""
self.config.settings.paused = paused
self.config_store.save(self.config)
if paused:
self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.")
self._pause_event.set()
self._save_pending_queue()
self.log_created.emit("Status", "Hinweis", "Synchronisation pausiert.")
else:
self._pause_event.clear()
self._paused_task_id = None
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
if not self._queue and self.config.settings.pending_queue:
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)
self._emit_overall_status()
self.refresh_task_states()
def rebuild_monitors(self) -> None:
"""Aktualisiert alle Dateisystemüberwachungen."""
@@ -176,6 +237,14 @@ class SyncCoordinator(QObject):
def stop(self) -> None:
"""Stoppt die Dateisystemüberwachung beim Beenden."""
self.monitor.stop()
if self._running_task_id is not None:
self._pause_event.set()
self._save_pending_queue()
self.config.settings.paused = True
with suppress(Exception):
self.config_store.save(self.config)
if hasattr(self.pool, "waitForDone"):
self.pool.waitForDone(1500)
def _start_next(self) -> None:
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
@@ -186,10 +255,14 @@ class SyncCoordinator(QObject):
self._start_next()
return
self._running_task_id = task_id
self._paused_task_id = None
self._save_pending_queue()
self.progress_changed.emit(0, f"{task.name}: Synchronisation wird gestartet …")
self.idle_changed.emit(False)
self._emit_overall_status()
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
worker = _SyncWorker(task, self.engine)
self._pause_event.clear()
worker = _SyncWorker(task, self.engine, self._pause_event)
worker.signals.progress.connect(self.progress_changed)
worker.signals.finished.connect(self._on_finished)
worker.signals.failed.connect(self._on_failed)
@@ -200,8 +273,26 @@ class SyncCoordinator(QObject):
if task is None:
self._complete_current()
return
if result.paused:
self._paused_task_id = task_id
if task_id not in self._queue:
self._queue.appendleft(task_id)
self._save_pending_queue()
self._set_task_state(task_id, TaskRuntimeState.PAUSED)
self.log_created.emit(task.name, "Pausiert", f"Synchronisation bei {result.copied} Dateien angehalten.")
self._running_task_id = None
self.progress_changed.emit(0, f"{task.name}: Pausiert – zum Fortsetzen klicken")
self.idle_changed.emit(True)
self._emit_overall_status()
return
task.last_run = datetime.now().isoformat(timespec="seconds")
self.config_store.save(self.config)
if task_id in self.config.settings.pending_queue:
self.config.settings.pending_queue.remove(task_id)
with suppress(Exception):
self.config_store.save(self.config)
if result.blocked:
paths = [blocked.path for blocked in result.blocked]
self._retry_tasks[task_id] = paths
@@ -250,6 +341,9 @@ class SyncCoordinator(QObject):
self.progress_changed.emit(100, "Abgeschlossen")
if not self._queue:
self.idle_changed.emit(True)
self.config.settings.pending_queue = []
with suppress(Exception):
self.config_store.save(self.config)
self._emit_overall_status()
QTimer.singleShot(200, self._start_next)
+87 -6
View File
@@ -140,8 +140,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,
)
@@ -235,6 +235,9 @@ class MainWindow(QMainWindow):
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:
@@ -418,8 +421,14 @@ class MainWindow(QMainWindow):
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
@@ -478,6 +487,11 @@ class MainWindow(QMainWindow):
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)
@@ -489,9 +503,26 @@ class MainWindow(QMainWindow):
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
}[task.trigger]
trigger = QTableWidgetItem(trigger_text)
status = QTableWidgetItem(self._runtime_state_text(self.coordinator.task_state(task.id)))
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(item.text())
item.setToolTip(tip if column == 4 else item.text())
self.task_table.setItem(row, column, item)
def _selected_task(self) -> SyncTask | None:
@@ -541,6 +572,8 @@ class MainWindow(QMainWindow):
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:
@@ -621,10 +654,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")
@@ -700,17 +741,57 @@ class MainWindow(QMainWindow):
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:
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren")
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()
+3
View File
@@ -137,6 +137,7 @@ class AppSettings:
font_weight: str = "regular"
ui_scale: int = 100
paused: bool = False
pending_queue: list[str] = field(default_factory=list)
check_updates_on_start: bool = True
last_update_check: str | None = None
@@ -148,6 +149,7 @@ class AppSettings:
self.font_weight = "regular"
if not self.font_family.strip():
self.font_family = "Segoe UI Variable"
self.pending_queue = [str(item) for item in self.pending_queue if str(item).strip()]
def to_dict(self) -> dict[str, Any]:
"""Serialisiert die Einstellungen."""
@@ -163,6 +165,7 @@ class AppSettings:
font_weight=str(data.get("font_weight", "regular")),
ui_scale=int(data.get("ui_scale", 100)),
paused=bool(data.get("paused", False)),
pending_queue=[str(item) for item in data.get("pending_queue", []) if str(item).strip()],
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
last_update_check=data.get("last_update_check"),
)
+90 -8
View File
@@ -4,11 +4,13 @@ from __future__ import annotations
import os
import shutil
import stat
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime
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
@@ -59,6 +61,7 @@ class SyncResult:
deleted: int = 0
conflicts: int = 0
skipped: int = 0
paused: bool = False
blocked: list[BlockedFile] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
inaccessible: bool = False
@@ -66,7 +69,7 @@ class SyncResult:
@property
def successful(self) -> bool:
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
return not self.errors and not self.blocked and not self.inaccessible
return not self.errors and not self.blocked and not self.inaccessible and not self.paused
@dataclass(frozen=True, slots=True)
@@ -84,14 +87,33 @@ class SyncEngine:
def __init__(self, state_store: StateStore | None = None) -> None:
self.state_store = state_store or StateStore()
def synchronize(self, task: SyncTask, progress: ProgressCallback | None = None) -> SyncResult:
def synchronize(
self,
task: SyncTask,
progress: ProgressCallback | None = None,
cancel_check: Callable[[], bool] | None = None,
) -> SyncResult:
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
task.validate()
source_root = Path(task.source)
target_root = Path(task.target)
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)
target_files, target_dirs = self._inventory(target_root, task.selected_paths)
source_files, source_dirs = self._inventory(
source_root, task.selected_paths, progress, "Quelle", cancel_check=cancel_check
)
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
)
if cancel_check and cancel_check():
return SyncResult(paused=True)
if progress:
progress(0, f"{task.name}: Änderungen werden verglichen …")
previous = self.state_store.get(task.id)
if task.direction is SyncDirection.ONE_WAY:
@@ -112,9 +134,23 @@ class SyncEngine:
result = SyncResult()
total = max(1, len(operations))
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)
if stage_name not in stages:
stages.append(stage_name)
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)
if cancel_check and cancel_check():
result.paused = True
if progress:
progress(round((index - 1) / total * 100), f"Pausiert bei: {stage} – {operation.relative}")
break
try:
self._execute(operation, result)
except (OSError, shutil.Error) as exc:
@@ -132,10 +168,14 @@ class SyncEngine:
else:
result.errors.append(f"{failed_path}: {exc}")
if progress:
detail = f"{operation.kind}: {operation.relative}"
if len(stages) > 1:
stage_idx = stages.index(stage) + 1
detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}"
else:
detail = f"{operation.kind}: {operation.relative}"
progress(round(index / total * 100), detail)
if not operations and progress:
if not operations and progress and not result.paused:
progress(100, "Keine Änderungen gefunden")
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
@@ -157,15 +197,34 @@ class SyncEngine:
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
@staticmethod
def _inventory(root: Path, selected_paths: list[str]) -> tuple[dict[str, FileRecord], set[str]]:
def _inventory(
root: Path,
selected_paths: list[str],
progress: ProgressCallback | None = None,
label: str = "Ordner",
cancel_check: Callable[[], bool] | 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)
last_report = monotonic()
if progress:
progress(0, f"{label} wird eingelesen: {root}")
try:
for current, dir_names, file_names in os.walk(root):
if cancel_check and cancel_check():
break
current_path = Path(current)
relative_dir = current_path.relative_to(root)
dir_names[:] = [name for name in dir_names if not (current_path / name).is_symlink()]
dir_names[:] = [
name
for name in dir_names
if _is_selected((relative_dir / name).parts, selections)
and not _is_directory_link(current_path / name)
]
if progress and monotonic() - last_report >= 0.25:
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
last_report = monotonic()
for directory_name in dir_names:
relative = relative_dir / directory_name
if _is_selected(relative.parts, selections):
@@ -182,6 +241,9 @@ class SyncEngine:
except OSError:
continue
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
if progress and monotonic() - last_report >= 0.25:
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
last_report = monotonic()
except OSError as exc:
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
return files, directories
@@ -303,6 +365,12 @@ class SyncEngine:
}
def _is_directory_link(path: Path) -> bool:
"""Erkennt auch Windows-Junctions unter Python 3.11 ohne Path.is_junction."""
metadata = path.lstat()
return stat.S_ISLNK(metadata.st_mode) or getattr(metadata, "st_reparse_tag", 0) == stat.IO_REPARSE_TAG_MOUNT_POINT
def _blocked_path(operation: _Operation, exc: OSError | shutil.Error) -> Path | None:
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
winerror = getattr(exc, "winerror", None)
@@ -353,3 +421,17 @@ def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]
if not selections:
return True
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
def _operation_stage(relative: str, selected_paths: list[str], is_dir: bool = False) -> str:
"""Ermittelt den relevanten Hauptordner oder die gewählte Unterauswahl."""
parts = Path(relative).parts
if not parts:
return "Hauptordner"
for selected in selected_paths:
selected_parts = Path(selected).parts
if parts[: len(selected_parts)] == selected_parts:
return "/".join(selected_parts)
if len(parts) > 1:
return parts[0]
return parts[0] if is_dir else "Hauptordner"
+78
View File
@@ -73,3 +73,81 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == ["first", "second"]
def test_start_resets_stale_progress_before_worker_starts(
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch
) -> None:
from types import SimpleNamespace
messages = []
workers = []
coordinator.progress_changed.connect(lambda *entry: messages.append(entry))
def start(worker) -> None:
assert messages and messages[-1][0] == 0
assert "AppData" in messages[-1][1]
workers.append(worker)
monkeypatch.setattr(coordinator, "pool", SimpleNamespace(start=start))
coordinator.enqueue(coordinator.config.tasks[0].id)
assert len(workers) == 1
assert coordinator.current_overall_status() == ("active", "Synchronisierung läuft")
def test_pause_and_resume_preserves_queue_and_state(coordinator: SyncCoordinator) -> None:
task1 = coordinator.config.tasks[0]
task2 = SyncTask(name="Documents", source="c:/src2", target="c:/dst2")
coordinator.config.tasks.append(task2)
# Global pausieren
coordinator.set_paused(True)
assert coordinator.config.settings.paused
assert coordinator.current_overall_status() == ("paused", "Pausiert")
# Aufgaben in Warteschlange einreihen
coordinator.enqueue(task1.id)
coordinator.enqueue(task2.id)
assert coordinator.queued_task_ids == [task1.id, task2.id]
assert coordinator.config.settings.pending_queue == [task1.id, task2.id]
# Fortsetzen
workers = []
from types import SimpleNamespace
coordinator.pool = SimpleNamespace(start=workers.append)
coordinator.set_paused(False)
assert not coordinator.config.settings.paused
assert len(workers) == 1
assert coordinator.running_task_id == task1.id
assert coordinator.queued_task_ids == [task2.id]
def test_stop_persists_running_task_and_queue_for_restart(coordinator: SyncCoordinator) -> None:
task1 = coordinator.config.tasks[0]
task2 = SyncTask(name="Documents", source="c:/src2", target="c:/dst2")
coordinator.config.tasks.append(task2)
coordinator._running_task_id = task1.id
coordinator._queue.append(task2.id)
coordinator.stop()
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
+1 -1
View File
@@ -20,7 +20,7 @@ def test_waiting_lock_has_priority_over_active_task() -> None:
def test_waiting_task_does_not_hide_running_sync() -> None:
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
"error",
"Synchronisiert",
"Synchronisierung läuft",
)
+112
View File
@@ -12,6 +12,72 @@ def _engine(tmp_path: Path) -> SyncEngine:
return SyncEngine(StateStore(tmp_path / "state.json"))
def test_progress_reports_scan_before_copying(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from itertools import count
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
(source / "datei.txt").write_text("Inhalt")
ticks = count()
monkeypatch.setattr("syncvito.sync_engine.monotonic", lambda: float(next(ticks)))
messages = []
def progress(value: int, detail: str) -> None:
messages.append((value, detail))
if "eingelesen" in detail or "Dateien gefunden" in detail:
assert value == 0
assert not (target / "datei.txt").exists()
result = _engine(tmp_path).synchronize(SyncTask(name="Test", source=str(source), target=str(target)), progress)
assert result.successful
assert "Ordner werden geprüft" in messages[0][1]
assert any("Quelle wird eingelesen" in detail for _, detail in messages)
assert any("Ziel wird eingelesen" in detail for _, detail in messages)
assert any("1 Dateien gefunden" in detail for _, detail in messages)
assert messages[-1][0] == 100
@pytest.mark.skipif(os.name != "nt", reason="Windows-Junctions")
def test_inventory_does_not_follow_windows_junctions(tmp_path: Path) -> None:
import _winapi
source = tmp_path / "source"
outside = tmp_path / "outside"
source.mkdir()
outside.mkdir()
(outside / "extern.txt").write_text("Nicht synchronisieren")
(source / "lokal.txt").write_text("Synchronisieren")
junction = source / "verknuepfung"
_winapi.CreateJunction(str(outside), str(junction))
try:
files, directories = SyncEngine._inventory(source, [])
assert set(files) == {"lokal.txt"}
assert "verknuepfung" not in directories
finally:
junction.rmdir()
def test_inventory_prunes_unselected_subtrees(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = tmp_path / "source"
(source / "auswahl").mkdir(parents=True)
(source / "ignorieren" / "unterordner").mkdir(parents=True)
(source / "auswahl" / "ja.txt").write_text("ja")
visited = []
original_walk = os.walk
def walk(*args, **kwargs):
for entry in original_walk(*args, **kwargs):
visited.append(Path(entry[0]))
yield entry
monkeypatch.setattr("syncvito.sync_engine.os.walk", walk)
files, _ = SyncEngine._inventory(source, ["auswahl"])
assert set(files) == {str(Path("auswahl/ja.txt"))}
assert visited == [source, source / "auswahl"]
@pytest.mark.parametrize("winerror", [None, 5])
def test_access_denied_is_not_reported_as_file_lock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
@@ -157,3 +223,49 @@ def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
assert result.successful
assert not (source / "entfernen.txt").exists()
def test_stage_progress_reporting_for_multiple_folders(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
(source / "AppData").mkdir(parents=True)
(source / "Documents").mkdir(parents=True)
(source / "AppData" / "config.json").write_text("{}", encoding="utf-8")
(source / "Documents" / "doc.txt").write_text("text", encoding="utf-8")
task = SyncTask(name="Backup", source=str(source), target=str(target))
messages: list[tuple[int, str]] = []
result = _engine(tmp_path).synchronize(task, progress=lambda v, d: messages.append((v, d)))
assert result.successful
assert any("[1/2 AppData]" in detail for _, detail in messages)
assert any("[2/2 Documents]" in detail for _, detail in messages)
def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
(source / "a.txt").write_text("a", encoding="utf-8")
(source / "b.txt").write_text("b", encoding="utf-8")
(source / "c.txt").write_text("c", encoding="utf-8")
task = SyncTask(name="ResumeTest", source=str(source), target=str(target))
engine = _engine(tmp_path)
# 1. Unterbrechung nach der ersten Datei
def cancel_after_one() -> bool:
return (target / "a.txt").exists()
result_paused = engine.synchronize(task, cancel_check=cancel_after_one)
assert result_paused.paused
assert not result_paused.successful
assert (target / "a.txt").exists()
assert not (target / "c.txt").exists()
# 2. Fortsetzen: Bereits kopierte Datei a.txt wird übersprungen, b und c werden kopiert
result_resumed = engine.synchronize(task)
assert result_resumed.successful
assert result_resumed.copied == 2
assert (target / "a.txt").exists()
assert (target / "b.txt").exists()
assert (target / "c.txt").exists()
+1 -1
View File
@@ -1 +1 @@
1.0.8
1.0.11