diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd76c3..843d116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Änderungsprotokoll +## 1.0.15 – 2026-09-29 + +### Neu + +- **Automatisches und stilles Ignorieren blockierter Dateien:** Werden Dateien während eines Laufs als gesperrt (`BlockedFile`), zugriffsverweigert (`PermissionError`) oder fehlerhaft erkannt, merkt sich SyncVito diese relativen Dateipfade automatisch pro Aufgabe. Bei allen nachfolgenden Durchläufen werden diese Dateien vollständig ignoriert – sie werden weder erneut versucht, noch erscheinen sie im Protokoll oder in Fehlermeldungen, sodass der Abgleich sauber und ohne Unterbrechungen mit dem Status „Erfolg“ abschließt. +- **Verwaltung ignorierter Dateien im Aufgabeneditor:** In den Aufgabeneinstellungen unter „Konflikte und Löschungen“ wird die Anzahl aktuell ignorierter Fehlerdateien angezeigt. Über die Schaltfläche „Ignorier-Liste leeren“ kann die Liste jederzeit mit einem Klick zurückgesetzt werden, falls Dateien wieder synchronisiert werden sollen. + ## 1.0.14 – 2026-09-29 ### Behoben diff --git a/pyproject.toml b/pyproject.toml index 7e2b0ca..ac76489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syncvito" -version = "1.0.14" +version = "1.0.15" 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 27fc9a9..67add13 100644 --- a/src/syncvito/coordinator.py +++ b/src/syncvito/coordinator.py @@ -13,7 +13,7 @@ from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal from syncvito.config_store import ConfigStore from syncvito.file_locks import is_file_locked -from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode +from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode, _normalize_relative_path from syncvito.realtime import RealtimeMonitor from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError @@ -300,6 +300,15 @@ class SyncCoordinator(QObject): task.last_run = datetime.now().isoformat(timespec="seconds") if task_id in self.config.settings.pending_queue: self.config.settings.pending_queue.remove(task_id) + + if result.failed_relatives: + new_ignored = set(task.ignored_paths) + for rel in result.failed_relatives: + with suppress(ValueError): + new_ignored.add(_normalize_relative_path(rel)) + if new_ignored != set(task.ignored_paths): + task.ignored_paths = sorted(new_ignored) + with suppress(Exception): self.config_store.save(self.config) @@ -308,8 +317,7 @@ class SyncCoordinator(QObject): self._retry_tasks[task_id] = paths message = ( f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. " - "Andere Dateien wurden weiterverarbeitet. Automatischer Wiederholungsversuch nach Freigabe; " - "ggf. betroffene Programme schließen. Details im Protokoll." + "Betroffene Dateien werden künftig automatisch ignoriert. Details im Protokoll." ) self._lock_messages[task_id] = message self._emit_lock_summary() diff --git a/src/syncvito/dialogs.py b/src/syncvito/dialogs.py index 3febc77..ccb8a17 100644 --- a/src/syncvito/dialogs.py +++ b/src/syncvito/dialogs.py @@ -133,6 +133,7 @@ class TaskDialog(QDialog): super().__init__(parent) self._original = task self._selected_paths = list(task.selected_paths) if task else [] + self._ignored_paths = list(task.ignored_paths) if task else [] self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe") self.setMinimumWidth(690) self.resize(720, 610) @@ -208,6 +209,19 @@ class TaskDialog(QDialog): warning.setProperty("muted", True) behavior_layout.addRow("", warning) + ignored_count = len(self._ignored_paths) + self.ignored_label = QLabel( + f"{ignored_count} Datei(en) werden künftig ignoriert." if ignored_count else "Keine Dateien ignoriert." + ) + self.ignored_label.setProperty("muted", True) + self.clear_ignored_button = QPushButton("Ignorier-Liste leeren") + self.clear_ignored_button.setEnabled(ignored_count > 0) + self.clear_ignored_button.clicked.connect(self._clear_ignored) + ignored_row = QHBoxLayout() + ignored_row.addWidget(self.ignored_label, 1) + ignored_row.addWidget(self.clear_ignored_button) + behavior_layout.addRow("Gesperrte Dateien", ignored_row) + tabs = QTabWidget() tabs.addTab(base_page, "Ordner und Zeitplan") tabs.addTab(behavior_page, "Konflikte und Löschungen") @@ -228,6 +242,11 @@ class TaskDialog(QDialog): self._update_visibility() self._update_selection_label() + def _clear_ignored(self) -> None: + self._ignored_paths.clear() + self.ignored_label.setText("Keine Dateien ignoriert.") + self.clear_ignored_button.setEnabled(False) + def task(self) -> SyncTask: """Erzeugt das validierte Modell aus den Formulardaten.""" task = SyncTask( @@ -241,6 +260,7 @@ class TaskDialog(QDialog): conflict_mode=self.conflict_combo.currentData(), interval_minutes=self.interval_spin.value(), selected_paths=self._selected_paths, + ignored_paths=self._ignored_paths, enabled=self.enabled_check.isChecked(), last_run=self._original.last_run if self._original else None, ) diff --git a/src/syncvito/models.py b/src/syncvito/models.py index 397dc51..aa09f91 100644 --- a/src/syncvito/models.py +++ b/src/syncvito/models.py @@ -73,6 +73,7 @@ class SyncTask: conflict_mode: ConflictMode = ConflictMode.NEWER_WINS interval_minutes: int = 30 selected_paths: list[str] = field(default_factory=list) + ignored_paths: list[str] = field(default_factory=list) enabled: bool = True last_run: str | None = None @@ -96,6 +97,7 @@ class SyncTask: self.source = str(source) self.target = str(target) self.selected_paths = sorted({_normalize_relative_path(item) for item in self.selected_paths if item.strip()}) + self.ignored_paths = sorted({_normalize_relative_path(item) for item in self.ignored_paths if item.strip()}) def to_dict(self) -> dict[str, Any]: """Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary.""" @@ -104,6 +106,7 @@ class SyncTask: data["trigger"] = self.trigger.value data["delete_mode"] = self.delete_mode.value data["conflict_mode"] = self.conflict_mode.value + data["ignored_paths"] = list(self.ignored_paths) return data @classmethod @@ -120,6 +123,7 @@ class SyncTask: conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)), interval_minutes=int(data.get("interval_minutes", 30)), selected_paths=[str(item) for item in data.get("selected_paths", [])], + ignored_paths=[str(item) for item in data.get("ignored_paths", [])], enabled=bool(data.get("enabled", True)), last_run=data.get("last_run"), ) diff --git a/src/syncvito/sync_engine.py b/src/syncvito/sync_engine.py index 78f7fee..9d940da 100644 --- a/src/syncvito/sync_engine.py +++ b/src/syncvito/sync_engine.py @@ -13,7 +13,7 @@ from pathlib import Path from time import monotonic from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked -from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask +from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, _normalize_relative_path from syncvito.state_store import StateStore __all__ = [ @@ -22,6 +22,7 @@ __all__ = [ "SyncEngine", "SyncResult", "UnavailablePathError", + "is_relative_path_ignored", ] ProgressCallback = Callable[[int, str], None] @@ -31,6 +32,24 @@ class UnavailablePathError(RuntimeError): """Ein für die Synchronisation benötigter Pfad ist nicht erreichbar.""" +def is_relative_path_ignored(relative: str, ignored_set: set[str]) -> bool: + """Prüft, ob ein relativer Pfad oder eines seiner Elternverzeichnisse ignoriert wird.""" + if not ignored_set or not relative.strip(): + return False + try: + norm = _normalize_relative_path(relative) + except ValueError: + return False + if norm in ignored_set: + return True + rel_path = Path(norm) + for parent in rel_path.parents: + parent_str = str(parent) + if parent_str != "." and parent_str in ignored_set: + return True + return False + + @dataclass(frozen=True, slots=True) class FileRecord: """Kompakte Dateisystemmetadaten für Vergleiche.""" @@ -64,6 +83,7 @@ class SyncResult: paused: bool = False blocked: list[BlockedFile] = field(default_factory=list) errors: list[str] = field(default_factory=list) + failed_relatives: set[str] = field(default_factory=set) inaccessible: bool = False @property @@ -103,12 +123,22 @@ class SyncEngine: progress(0, f"{task.name}: Ordner werden geprüft …") self._validate_roots(source_root, target_root) source_files, source_dirs = self._inventory( - source_root, task.selected_paths, progress, "Quelle", cancel_check=cancel_check + source_root, + task.selected_paths, + progress, + "Quelle", + cancel_check=cancel_check, + ignored_paths=task.ignored_paths, ) if cancel_check and cancel_check(): return SyncResult(paused=True) target_files, target_dirs = self._inventory( - target_root, task.selected_paths, progress, "Ziel", cancel_check=cancel_check + target_root, + task.selected_paths, + progress, + "Ziel", + cancel_check=cancel_check, + ignored_paths=task.ignored_paths, ) if cancel_check and cancel_check(): return SyncResult(paused=True) @@ -167,6 +197,8 @@ class SyncEngine: if isinstance(exc, OSError) and (exc.filename2 or exc.filename) else operation.target ) + if operation.relative and operation.relative.strip(): + result.failed_relatives.add(operation.relative) blocked_path = _blocked_path(operation, exc) if blocked_path is not None: parent_dir = blocked_path.parent @@ -202,8 +234,8 @@ class SyncEngine: progress(100, "Keine Änderungen gefunden") if task.direction is SyncDirection.BIDIRECTIONAL and result.successful: - refreshed_source, _ = self._inventory(source_root, task.selected_paths) - refreshed_target, _ = self._inventory(target_root, task.selected_paths) + refreshed_source, _ = self._inventory(source_root, task.selected_paths, ignored_paths=task.ignored_paths) + refreshed_target, _ = self._inventory(target_root, task.selected_paths, ignored_paths=task.ignored_paths) self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target)) return result @@ -226,10 +258,15 @@ class SyncEngine: progress: ProgressCallback | None = None, label: str = "Ordner", cancel_check: Callable[[], bool] | None = None, + ignored_paths: list[str] | set[str] | None = None, ) -> tuple[dict[str, FileRecord], set[str]]: files: dict[str, FileRecord] = {} directories: set[str] = set() selections = tuple(Path(item).parts for item in selected_paths) + ignored_set: set[str] = set() + for item in ignored_paths or (): + with suppress(ValueError): + ignored_set.add(_normalize_relative_path(item)) last_report = monotonic() if progress: progress(0, f"{label} wird eingelesen: {root}") @@ -244,6 +281,7 @@ class SyncEngine: for name in dir_names if _is_selected((relative_dir / name).parts, selections) and not _is_directory_link(current_path / name) + and not is_relative_path_ignored(str(relative_dir / name), ignored_set) ] if progress and monotonic() - last_report >= 0.25: progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}") @@ -257,6 +295,8 @@ class SyncEngine: if path.is_symlink(): continue relative = path.relative_to(root) + if is_relative_path_ignored(str(relative), ignored_set): + continue if not _is_selected(relative.parts, selections): continue try: @@ -314,7 +354,13 @@ class SyncEngine: _Operation("Ordner anlegen", None, source_root / relative, relative) for relative in sorted(target_dirs - source_dirs) ) + ignored_set: set[str] = set() + for item in task.ignored_paths or (): + with suppress(ValueError): + ignored_set.add(_normalize_relative_path(item)) all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys()) + if ignored_set: + all_paths = [p for p in all_paths if not is_relative_path_ignored(p, ignored_set)] for relative in all_paths: source = source_files.get(relative) target = target_files.get(relative) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 9a3e62a..c3f7112 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -175,3 +175,19 @@ def test_enqueue_all_unpauses_and_runs(coordinator: SyncCoordinator) -> None: assert not coordinator.config.settings.paused assert len(workers) == 1 assert coordinator.running_task_id == coordinator.config.tasks[0].id + + +def test_failed_files_automatically_added_to_task_ignored_paths_and_persisted( + coordinator: SyncCoordinator, +) -> None: + task = coordinator.config.tasks[0] + assert task.ignored_paths == [] + + res = SyncResult(copied=5, errors=["Zugriff verweigert: lock.dat"], failed_relatives={"lock.dat", "sub/cache.bin"}) + coordinator._on_finished(task.id, res) + + assert "lock.dat" in task.ignored_paths + + loaded = coordinator.config_store.load() + loaded_task = next(t for t in loaded.tasks if t.id == task.id) + assert "lock.dat" in loaded_task.ignored_paths diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py index 0b55222..f6cada2 100644 --- a/tests/test_sync_engine.py +++ b/tests/test_sync_engine.py @@ -293,3 +293,55 @@ def test_concurrently_deleted_source_file_is_skipped(tmp_path: Path, monkeypatch assert result.skipped == 1 assert not result.errors assert not (target / "ephemeral.txt").exists() + + +def test_ignored_paths_are_completely_skipped(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + target.mkdir() + (source / "normal.txt").write_text("kopieren", encoding="utf-8") + (source / "locked.db").write_text("gesperrt", encoding="utf-8") + (source / "subdir").mkdir() + (source / "subdir" / "ignored_file.txt").write_text("in ordner", encoding="utf-8") + + task = SyncTask( + name="IgnoredTest", + source=str(source), + target=str(target), + ignored_paths=["locked.db", "subdir/ignored_file.txt"], + ) + engine = _engine(tmp_path) + result = engine.synchronize(task) + + assert result.successful + assert result.copied == 1 + assert (target / "normal.txt").exists() + assert not (target / "locked.db").exists() + assert not (target / "subdir" / "ignored_file.txt").exists() + + +def test_failed_files_added_to_failed_relatives(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + target.mkdir() + (source / "ok.txt").write_text("ok", encoding="utf-8") + (source / "fail.txt").write_text("fail", encoding="utf-8") + task = SyncTask(name="FailTest", source=str(source), target=str(target)) + engine = _engine(tmp_path) + + real_execute = SyncEngine._execute + + def mock_execute(operation, result): + if operation.relative == "fail.txt": + raise PermissionError("Access denied") + return real_execute(operation, result) + + monkeypatch.setattr(engine, "_execute", mock_execute) + result = engine.synchronize(task) + + assert not result.successful + assert "fail.txt" in result.failed_relatives + assert "ok.txt" not in result.failed_relatives + assert (target / "ok.txt").exists() diff --git a/version.txt b/version.txt index 5b09c67..a970716 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.14 +1.0.15