Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8218cfd9d5 | ||
|
|
6c677f0161 |
No files matched your search
@@ -1,5 +1,18 @@
|
||||
# Änderungsprotokoll
|
||||
|
||||
## 1.0.16 – 2026-10-01
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Verständliche Meldung bei abgelehnter NAS-Anmeldung:** Der Zugriff auf Quell- und Zielordner fängt Windows-Fehler 1326 jetzt bereits bei der Pfadprüfung ab und erklärt, dass die gespeicherten Windows-Anmeldeinformationen für den NAS-Server geprüft werden müssen.
|
||||
|
||||
## 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
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "syncvito"
|
||||
version = "1.0.14"
|
||||
version = "1.0.16"
|
||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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,36 @@ class UnavailablePathError(RuntimeError):
|
||||
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
||||
|
||||
|
||||
def _unavailable_path_message(label: str, path: Path, exc: OSError) -> str:
|
||||
"""Erklärt typische NAS-Anmeldefehler verständlich und erhält den Pfadkontext."""
|
||||
if getattr(exc, "winerror", None) == 1326:
|
||||
return (
|
||||
f"{label} nicht erreichbar: Windows hat die NAS-Anmeldung abgelehnt "
|
||||
"(WinError 1326: Benutzername oder Kennwort falsch). Prüfe die gespeicherten "
|
||||
"Windows-Anmeldeinformationen für den NAS-Server und teste den Zugriff im Explorer. "
|
||||
f"Pfad: {path}"
|
||||
)
|
||||
return f"{label} nicht erreichbar: {path}"
|
||||
|
||||
|
||||
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 +95,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 +135,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 +209,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,21 +246,34 @@ 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
|
||||
|
||||
@staticmethod
|
||||
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
||||
if not source_root.is_dir():
|
||||
try:
|
||||
source_is_dir = source_root.is_dir()
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(_unavailable_path_message("Quellordner", source_root, exc)) from exc
|
||||
if not source_is_dir:
|
||||
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
|
||||
if not target_root.exists():
|
||||
|
||||
try:
|
||||
target_exists = target_root.exists()
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
|
||||
if not target_exists:
|
||||
try:
|
||||
target_root.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
|
||||
if not target_root.is_dir():
|
||||
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
|
||||
try:
|
||||
target_is_dir = target_root.is_dir()
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
|
||||
if not target_is_dir:
|
||||
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||
|
||||
@staticmethod
|
||||
@@ -226,10 +283,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 +306,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 +320,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 +379,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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.14
|
||||
1.0.16
|
||||
Reference in new issue
Block a user