Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7098fa1c0c | ||
|
|
4efc1efa18 |
No files matched your search
@@ -1,5 +1,23 @@
|
|||||||
# Änderungsprotokoll
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
## 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
|
## 1.0.8 – 2026-09-24
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "syncvito"
|
name = "syncvito"
|
||||||
version = "1.0.8"
|
version = "1.0.10"
|
||||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+99
-11
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from contextlib import suppress
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -25,12 +27,12 @@ def derive_overall_status(
|
|||||||
if TaskRuntimeState.ERROR in states:
|
if TaskRuntimeState.ERROR in states:
|
||||||
return "error", "Fehler"
|
return "error", "Fehler"
|
||||||
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
|
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:
|
if globally_paused:
|
||||||
return "paused", "Pausiert"
|
return "paused", "Pausiert"
|
||||||
if TaskRuntimeState.ACTIVE not in states:
|
if TaskRuntimeState.ACTIVE not in states:
|
||||||
return "paused", "Inaktiv"
|
return "paused", "Inaktiv"
|
||||||
return "active", "Synchronisiert" if running else "Bereit"
|
return "active", "Synchronisierung läuft" if running else "Bereit"
|
||||||
|
|
||||||
|
|
||||||
class _WorkerSignals(QObject):
|
class _WorkerSignals(QObject):
|
||||||
@@ -40,15 +42,17 @@ class _WorkerSignals(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class _SyncWorker(QRunnable):
|
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__()
|
super().__init__()
|
||||||
self.task = task
|
self.task = task
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
|
self.pause_event = pause_event
|
||||||
self.signals = _WorkerSignals()
|
self.signals = _WorkerSignals()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
try:
|
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:
|
except UnavailablePathError as exc:
|
||||||
self.signals.failed.emit(self.task.id, str(exc), True)
|
self.signals.failed.emit(self.task.id, str(exc), True)
|
||||||
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
|
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
|
||||||
@@ -79,6 +83,8 @@ class SyncCoordinator(QObject):
|
|||||||
self.engine = SyncEngine()
|
self.engine = SyncEngine()
|
||||||
self.pool = QThreadPool.globalInstance()
|
self.pool = QThreadPool.globalInstance()
|
||||||
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
|
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._queue: deque[str] = deque()
|
||||||
self._running_task_id: str | None = None
|
self._running_task_id: str | None = None
|
||||||
self._retry_tasks: dict[str, list[Path]] = {}
|
self._retry_tasks: dict[str, list[Path]] = {}
|
||||||
@@ -91,6 +97,13 @@ class SyncCoordinator(QObject):
|
|||||||
self._bridge.changed.connect(self._on_realtime_event)
|
self._bridge.changed.connect(self._on_realtime_event)
|
||||||
self.monitor = RealtimeMonitor(self._bridge.changed.emit)
|
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 = QTimer(self)
|
||||||
self._schedule_timer.setInterval(30_000)
|
self._schedule_timer.setInterval(30_000)
|
||||||
self._schedule_timer.timeout.connect(self._check_intervals)
|
self._schedule_timer.timeout.connect(self._check_intervals)
|
||||||
@@ -101,6 +114,21 @@ class SyncCoordinator(QObject):
|
|||||||
self._retry_timer.start()
|
self._retry_timer.start()
|
||||||
self.rebuild_monitors()
|
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:
|
def task_state(self, task_id: str) -> TaskRuntimeState:
|
||||||
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
|
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
|
||||||
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
|
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
|
||||||
@@ -135,16 +163,33 @@ class SyncCoordinator(QObject):
|
|||||||
"""Zeigt an, ob momentan ein Worker aktiv ist."""
|
"""Zeigt an, ob momentan ein Worker aktiv ist."""
|
||||||
return self._running_task_id is not None
|
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:
|
def enqueue(self, task_id: str) -> None:
|
||||||
"""Stellt eine Aufgabe dedupliziert in die serielle Warteschlange."""
|
"""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)
|
task = self._task(task_id)
|
||||||
if task is None or not task.enabled:
|
if task is None or not task.enabled:
|
||||||
return
|
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:
|
if task_id != self._running_task_id and task_id not in self._queue:
|
||||||
self._queue.append(task_id)
|
self._queue.append(task_id)
|
||||||
|
self._save_pending_queue()
|
||||||
|
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
|
||||||
self._start_next()
|
self._start_next()
|
||||||
|
|
||||||
def enqueue_all(self) -> None:
|
def enqueue_all(self) -> None:
|
||||||
@@ -156,13 +201,23 @@ class SyncCoordinator(QObject):
|
|||||||
def set_paused(self, paused: bool) -> None:
|
def set_paused(self, paused: bool) -> None:
|
||||||
"""Ändert den globalen Pausenzustand."""
|
"""Ändert den globalen Pausenzustand."""
|
||||||
self.config.settings.paused = paused
|
self.config.settings.paused = paused
|
||||||
self.config_store.save(self.config)
|
|
||||||
if paused:
|
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:
|
else:
|
||||||
|
self._pause_event.clear()
|
||||||
|
self._paused_task_id = None
|
||||||
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
|
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)
|
||||||
self._start_next()
|
self._start_next()
|
||||||
|
with suppress(Exception):
|
||||||
|
self.config_store.save(self.config)
|
||||||
self._emit_overall_status()
|
self._emit_overall_status()
|
||||||
|
self.refresh_task_states()
|
||||||
|
|
||||||
def rebuild_monitors(self) -> None:
|
def rebuild_monitors(self) -> None:
|
||||||
"""Aktualisiert alle Dateisystemüberwachungen."""
|
"""Aktualisiert alle Dateisystemüberwachungen."""
|
||||||
@@ -176,6 +231,14 @@ class SyncCoordinator(QObject):
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stoppt die Dateisystemüberwachung beim Beenden."""
|
"""Stoppt die Dateisystemüberwachung beim Beenden."""
|
||||||
self.monitor.stop()
|
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:
|
def _start_next(self) -> None:
|
||||||
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
|
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
|
||||||
@@ -186,10 +249,14 @@ class SyncCoordinator(QObject):
|
|||||||
self._start_next()
|
self._start_next()
|
||||||
return
|
return
|
||||||
self._running_task_id = task_id
|
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.idle_changed.emit(False)
|
||||||
self._emit_overall_status()
|
self._emit_overall_status()
|
||||||
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
|
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.progress.connect(self.progress_changed)
|
||||||
worker.signals.finished.connect(self._on_finished)
|
worker.signals.finished.connect(self._on_finished)
|
||||||
worker.signals.failed.connect(self._on_failed)
|
worker.signals.failed.connect(self._on_failed)
|
||||||
@@ -200,8 +267,26 @@ class SyncCoordinator(QObject):
|
|||||||
if task is None:
|
if task is None:
|
||||||
self._complete_current()
|
self._complete_current()
|
||||||
return
|
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")
|
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:
|
if result.blocked:
|
||||||
paths = [blocked.path for blocked in result.blocked]
|
paths = [blocked.path for blocked in result.blocked]
|
||||||
self._retry_tasks[task_id] = paths
|
self._retry_tasks[task_id] = paths
|
||||||
@@ -250,6 +335,9 @@ class SyncCoordinator(QObject):
|
|||||||
self.progress_changed.emit(100, "Abgeschlossen")
|
self.progress_changed.emit(100, "Abgeschlossen")
|
||||||
if not self._queue:
|
if not self._queue:
|
||||||
self.idle_changed.emit(True)
|
self.idle_changed.emit(True)
|
||||||
|
self.config.settings.pending_queue = []
|
||||||
|
with suppress(Exception):
|
||||||
|
self.config_store.save(self.config)
|
||||||
self._emit_overall_status()
|
self._emit_overall_status()
|
||||||
QTimer.singleShot(200, self._start_next)
|
QTimer.singleShot(200, self._start_next)
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.sync_all_button.setProperty("primary", True)
|
self.sync_all_button.setProperty("primary", True)
|
||||||
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
||||||
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
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
|
return header
|
||||||
|
|
||||||
def _build_tasks_page(self) -> QWidget:
|
def _build_tasks_page(self) -> QWidget:
|
||||||
@@ -418,8 +421,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.progress_bar.setRange(0, 100)
|
self.progress_bar.setRange(0, 100)
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
self.progress_bar.setFormat("%p %")
|
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_text)
|
||||||
layout.addWidget(self.progress_bar)
|
layout.addWidget(self.progress_bar)
|
||||||
|
layout.addWidget(self.dock_pause_button)
|
||||||
layout.addStretch(1)
|
layout.addStretch(1)
|
||||||
return dock
|
return dock
|
||||||
|
|
||||||
@@ -478,6 +487,11 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _refresh_tasks(self) -> None:
|
def _refresh_tasks(self) -> None:
|
||||||
self.task_table.setRowCount(len(self.config.tasks))
|
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):
|
for row, task in enumerate(self.config.tasks):
|
||||||
name = QTableWidgetItem(task.name)
|
name = QTableWidgetItem(task.name)
|
||||||
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
||||||
@@ -489,9 +503,26 @@ class MainWindow(QMainWindow):
|
|||||||
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
||||||
}[task.trigger]
|
}[task.trigger]
|
||||||
trigger = QTableWidgetItem(trigger_text)
|
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)):
|
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)
|
self.task_table.setItem(row, column, item)
|
||||||
|
|
||||||
def _selected_task(self) -> SyncTask | None:
|
def _selected_task(self) -> SyncTask | None:
|
||||||
@@ -700,17 +731,43 @@ class MainWindow(QMainWindow):
|
|||||||
self.tray_sync_action.setEnabled(update_idle and sync_idle)
|
self.tray_sync_action.setEnabled(update_idle and sync_idle)
|
||||||
self.update_button.setEnabled(update_idle and sync_idle)
|
self.update_button.setEnabled(update_idle and sync_idle)
|
||||||
self.tray_update_action.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:
|
def _toggle_pause(self) -> None:
|
||||||
self.coordinator.set_paused(not self.config.settings.paused)
|
self.coordinator.set_paused(not self.config.settings.paused)
|
||||||
self._update_pause_action()
|
self._update_pause_action()
|
||||||
|
|
||||||
def _update_pause_action(self) -> None:
|
def _update_pause_action(self) -> None:
|
||||||
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren")
|
is_paused = self.config.settings.paused
|
||||||
|
if is_paused:
|
||||||
|
btn_text = "▶ Fortsetzen"
|
||||||
|
tray_text = "Fortsetzen"
|
||||||
|
else:
|
||||||
|
btn_text = "⏸ Pausieren" if self.coordinator.is_running else "Pausieren"
|
||||||
|
tray_text = "Pausieren"
|
||||||
|
|
||||||
|
self.pause_action.setText(tray_text)
|
||||||
|
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.style().unpolish(btn)
|
||||||
|
btn.style().polish(btn)
|
||||||
|
|
||||||
def _update_progress(self, value: int, detail: str) -> None:
|
def _update_progress(self, value: int, detail: str) -> None:
|
||||||
self.progress_bar.setValue(value)
|
self.progress_bar.setValue(value)
|
||||||
self.progress_text.setText(detail)
|
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:
|
def _append_log(self, action: str, level: str, detail: str) -> None:
|
||||||
row = self.log_table.rowCount()
|
row = self.log_table.rowCount()
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class AppSettings:
|
|||||||
font_weight: str = "regular"
|
font_weight: str = "regular"
|
||||||
ui_scale: int = 100
|
ui_scale: int = 100
|
||||||
paused: bool = False
|
paused: bool = False
|
||||||
|
pending_queue: list[str] = field(default_factory=list)
|
||||||
check_updates_on_start: bool = True
|
check_updates_on_start: bool = True
|
||||||
last_update_check: str | None = None
|
last_update_check: str | None = None
|
||||||
|
|
||||||
@@ -148,6 +149,7 @@ class AppSettings:
|
|||||||
self.font_weight = "regular"
|
self.font_weight = "regular"
|
||||||
if not self.font_family.strip():
|
if not self.font_family.strip():
|
||||||
self.font_family = "Segoe UI Variable"
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Serialisiert die Einstellungen."""
|
"""Serialisiert die Einstellungen."""
|
||||||
@@ -163,6 +165,7 @@ class AppSettings:
|
|||||||
font_weight=str(data.get("font_weight", "regular")),
|
font_weight=str(data.get("font_weight", "regular")),
|
||||||
ui_scale=int(data.get("ui_scale", 100)),
|
ui_scale=int(data.get("ui_scale", 100)),
|
||||||
paused=bool(data.get("paused", False)),
|
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)),
|
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
||||||
last_update_check=data.get("last_update_check"),
|
last_update_check=data.get("last_update_check"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
|
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, SyncTask
|
||||||
@@ -59,6 +61,7 @@ class SyncResult:
|
|||||||
deleted: int = 0
|
deleted: int = 0
|
||||||
conflicts: int = 0
|
conflicts: int = 0
|
||||||
skipped: int = 0
|
skipped: int = 0
|
||||||
|
paused: bool = False
|
||||||
blocked: list[BlockedFile] = field(default_factory=list)
|
blocked: list[BlockedFile] = field(default_factory=list)
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
inaccessible: bool = False
|
inaccessible: bool = False
|
||||||
@@ -66,7 +69,7 @@ class SyncResult:
|
|||||||
@property
|
@property
|
||||||
def successful(self) -> bool:
|
def successful(self) -> bool:
|
||||||
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
|
"""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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -84,14 +87,33 @@ class SyncEngine:
|
|||||||
def __init__(self, state_store: StateStore | None = None) -> None:
|
def __init__(self, state_store: StateStore | None = None) -> None:
|
||||||
self.state_store = state_store or StateStore()
|
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."""
|
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||||
task.validate()
|
task.validate()
|
||||||
source_root = Path(task.source)
|
source_root = Path(task.source)
|
||||||
target_root = Path(task.target)
|
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)
|
self._validate_roots(source_root, target_root)
|
||||||
source_files, source_dirs = self._inventory(source_root, task.selected_paths)
|
source_files, source_dirs = self._inventory(
|
||||||
target_files, target_dirs = self._inventory(target_root, task.selected_paths)
|
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)
|
previous = self.state_store.get(task.id)
|
||||||
|
|
||||||
if task.direction is SyncDirection.ONE_WAY:
|
if task.direction is SyncDirection.ONE_WAY:
|
||||||
@@ -112,9 +134,23 @@ class SyncEngine:
|
|||||||
|
|
||||||
result = SyncResult()
|
result = SyncResult()
|
||||||
total = max(1, len(operations))
|
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:
|
if progress:
|
||||||
progress(0, "Änderungen werden vorbereitet …")
|
progress(0, "Änderungen werden vorbereitet …")
|
||||||
for index, operation in enumerate(operations, start=1):
|
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:
|
try:
|
||||||
self._execute(operation, result)
|
self._execute(operation, result)
|
||||||
except (OSError, shutil.Error) as exc:
|
except (OSError, shutil.Error) as exc:
|
||||||
@@ -132,10 +168,14 @@ class SyncEngine:
|
|||||||
else:
|
else:
|
||||||
result.errors.append(f"{failed_path}: {exc}")
|
result.errors.append(f"{failed_path}: {exc}")
|
||||||
if progress:
|
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)
|
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")
|
progress(100, "Keine Änderungen gefunden")
|
||||||
|
|
||||||
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
||||||
@@ -157,15 +197,34 @@ class SyncEngine:
|
|||||||
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||||
|
|
||||||
@staticmethod
|
@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] = {}
|
files: dict[str, FileRecord] = {}
|
||||||
directories: set[str] = set()
|
directories: set[str] = set()
|
||||||
selections = tuple(Path(item).parts for item in selected_paths)
|
selections = tuple(Path(item).parts for item in selected_paths)
|
||||||
|
last_report = monotonic()
|
||||||
|
if progress:
|
||||||
|
progress(0, f"{label} wird eingelesen: {root}")
|
||||||
try:
|
try:
|
||||||
for current, dir_names, file_names in os.walk(root):
|
for current, dir_names, file_names in os.walk(root):
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
break
|
||||||
current_path = Path(current)
|
current_path = Path(current)
|
||||||
relative_dir = current_path.relative_to(root)
|
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:
|
for directory_name in dir_names:
|
||||||
relative = relative_dir / directory_name
|
relative = relative_dir / directory_name
|
||||||
if _is_selected(relative.parts, selections):
|
if _is_selected(relative.parts, selections):
|
||||||
@@ -182,6 +241,9 @@ class SyncEngine:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
|
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:
|
except OSError as exc:
|
||||||
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
||||||
return files, directories
|
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:
|
def _blocked_path(operation: _Operation, exc: OSError | shutil.Error) -> Path | None:
|
||||||
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
|
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
|
||||||
winerror = getattr(exc, "winerror", None)
|
winerror = getattr(exc, "winerror", None)
|
||||||
@@ -353,3 +421,17 @@ def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]
|
|||||||
if not selections:
|
if not selections:
|
||||||
return True
|
return True
|
||||||
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
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"
|
||||||
@@ -73,3 +73,67 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
|
|||||||
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||||
coordinator._check_retries()
|
coordinator._check_retries()
|
||||||
assert queued == ["first", "second"]
|
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]
|
||||||
@@ -20,7 +20,7 @@ def test_waiting_lock_has_priority_over_active_task() -> None:
|
|||||||
def test_waiting_task_does_not_hide_running_sync() -> None:
|
def test_waiting_task_does_not_hide_running_sync() -> None:
|
||||||
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
|
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
|
||||||
"error",
|
"error",
|
||||||
"Synchronisiert",
|
"Synchronisierung läuft",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,72 @@ def _engine(tmp_path: Path) -> SyncEngine:
|
|||||||
return SyncEngine(StateStore(tmp_path / "state.json"))
|
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])
|
@pytest.mark.parametrize("winerror", [None, 5])
|
||||||
def test_access_denied_is_not_reported_as_file_lock(
|
def test_access_denied_is_not_reported_as_file_lock(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
|
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 result.successful
|
||||||
assert not (source / "entfernen.txt").exists()
|
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
@@ -1 +1 @@
|
|||||||
1.0.8
|
1.0.10
|
||||||
Reference in new issue
Block a user