diff --git a/CHANGELOG.md b/CHANGELOG.md index 0475883..e8f9317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Ä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 diff --git a/pyproject.toml b/pyproject.toml index 5813919..71b5009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syncvito" -version = "1.0.9" +version = "1.0.10" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" readme = "README.md" requires-python = ">=3.11" diff --git a/src/syncvito/coordinator.py b/src/syncvito/coordinator.py index 0f3f496..12386f0 100644 --- a/src/syncvito/coordinator.py +++ b/src/syncvito/coordinator.py @@ -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 @@ -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,16 +163,33 @@ 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: @@ -156,13 +201,23 @@ 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) 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 +231,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,11 +249,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) @@ -201,8 +267,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 @@ -251,6 +335,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) diff --git a/src/syncvito/main_window.py b/src/syncvito/main_window.py index 34aa1b2..463f0bb 100644 --- a/src/syncvito/main_window.py +++ b/src/syncvito/main_window.py @@ -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: @@ -700,17 +731,43 @@ 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 + 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: 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() diff --git a/src/syncvito/models.py b/src/syncvito/models.py index 92ceb29..5088481 100644 --- a/src/syncvito/models.py +++ b/src/syncvito/models.py @@ -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"), ) diff --git a/src/syncvito/sync_engine.py b/src/syncvito/sync_engine.py index abf259a..a3678d3 100644 --- a/src/syncvito/sync_engine.py +++ b/src/syncvito/sync_engine.py @@ -61,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 @@ -68,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) @@ -86,16 +87,31 @@ 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, progress, "Quelle") - target_files, target_dirs = self._inventory(target_root, task.selected_paths, progress, "Ziel") + 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) @@ -118,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: @@ -138,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: @@ -168,6 +202,7 @@ class SyncEngine: 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() @@ -177,6 +212,8 @@ class SyncEngine: 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[:] = [ @@ -384,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" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 8918f9d..b7f7271 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -93,3 +93,47 @@ def test_start_resets_stale_progress_before_worker_starts( 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] diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py index 8fbdca7..f18d9a3 100644 --- a/tests/test_sync_engine.py +++ b/tests/test_sync_engine.py @@ -223,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() diff --git a/version.txt b/version.txt index 66c4c22..7ee7020 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.9 +1.0.10