2 Commits
Author SHA1 Message Date
Davito 6c677f0161 Release SyncVito 1.0.15 2026-09-29 05:08:56 +02:00
Davito 1d0b1b2d38 Release SyncVito 1.0.14 2026-09-29 04:53:04 +02:00
11 changed files with 296 additions and 19 deletions

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# Ä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
- **Netzwerk- und Boot-Resilienz bei der Konfigurationsprüfung:** Beim Windows-Start wird die Aufgabenvalidierung von Quell- und Zielordnern nicht mehr durch noch nicht initialisierte Netzwerkverbindungen oder vorübergehend nicht erreichbare UNC-Pfade (`\\Server\Freigabe`) unterbrochen. Fehler bei der Pfadauflösung werden sicher abgefangen.
- **Schutz vor fälschlichem Konfigurations-Reset:** Kurzzeitige Windows-Dateisperren (z. B. durch Hintergrund- oder Suchdienste beim Systemstart) führen nicht mehr zum irrtümlichen Umbenennen der Konfiguration in eine defekte Sicherung. Lesezugriffe auf die Konfigurationsdatei werden bei Sperren automatisch mehrfach wiederholt. Eine Sicherung als `config.defekt` erfolgt ausschließlich bei tatsächlichen Syntaxfehlern in der JSON-Datei.
- **Fehlertolerantes Laden von Aufgaben:** Sollte eine einzelne gespeicherte Aufgabe ungültige Pfade oder Parameter enthalten, bleibt die Gesamtheit aller anderen Aufgaben unberührt. Die betroffene Aufgabe wird sicherheitsrelevanterweise deaktiviert in der Liste geladen, statt den Start zu blockieren oder andere Aufgaben zu verwerfen.
## 1.0.13 – 2026-09-28
### Behoben
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "syncvito"
version = "1.0.13"
version = "1.0.15"
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
readme = "README.md"
requires-python = ">=3.11"
+34 -4
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json
import os
import shutil
import time
from contextlib import suppress
from datetime import datetime
from pathlib import Path
@@ -33,19 +35,47 @@ class ConfigStore:
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
if not self.path.exists():
return AppConfig()
content: str | None = None
last_io_exc: OSError | None = None
for _ in range(5):
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
content = self.path.read_text(encoding="utf-8")
break
except OSError as exc:
last_io_exc = exc
time.sleep(0.1)
if content is None:
raise ConfigError(
f"Konfigurationsdatei '{self.path.name}' konnte nicht gelesen werden: {last_io_exc}"
) from last_io_exc
try:
raw = json.loads(content)
if not isinstance(raw, dict):
raise ValueError("Das JSON-Wurzelelement ist kein Objekt.")
return AppConfig.from_dict(raw)
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
except (ValueError, TypeError, json.JSONDecodeError) as exc:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
try:
self.path.replace(recovery_path)
except OSError:
recovery_path = self.path
raise ConfigError(f"Die Konfiguration war nicht lesbar. Sicherung: {recovery_path}") from exc
raise ConfigError(
f"Die Konfiguration war syntaktisch beschädigt ({exc}). Sicherung: {recovery_path}"
) from exc
try:
return AppConfig.from_dict(raw)
except Exception as exc:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = self.path.with_name(f"config.bak-{timestamp}.json")
with suppress(OSError):
shutil.copy2(self.path, backup_path)
raise ConfigError(
f"Die Konfigurationsdaten konnten nicht verarbeitet werden ({exc}). Sicherungskopie: {backup_path}"
) from exc
def save(self, config: AppConfig) -> None:
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
+11 -3
View File
@@ -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()
+20
View File
@@ -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,
)
+30 -4
View File
@@ -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
@@ -82,8 +83,8 @@ class SyncTask:
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
if not self.source.strip() or not self.target.strip():
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
source = Path(self.source).expanduser().resolve(strict=False)
target = Path(self.target).expanduser().resolve(strict=False)
source = _safe_resolve(Path(self.source).expanduser())
target = _safe_resolve(Path(self.target).expanduser())
source_norm = os.path.normcase(str(source))
target_norm = os.path.normcase(str(target))
if source_norm == target_norm:
@@ -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,10 +123,14 @@ 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"),
)
try:
task.validate()
except ValueError:
task.enabled = False
return task
@@ -191,12 +198,28 @@ class AppConfig:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
tasks: list[SyncTask] = []
for item in data.get("tasks", []):
if not isinstance(item, dict):
continue
try:
tasks.append(SyncTask.from_dict(item))
except Exception:
continue
return cls(
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
tasks=tasks,
settings=AppSettings.from_dict(data.get("settings", {})),
)
def _safe_resolve(path: Path) -> Path:
"""Löst Pfade sicher auf; fängt Netzwerkfehler und unvollständige Pfade ab."""
try:
return path.resolve(strict=False)
except OSError:
return path.absolute()
def _normalize_relative_path(value: str) -> str:
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
path = Path(normalized)
@@ -206,8 +229,11 @@ def _normalize_relative_path(value: str) -> str:
def _is_relative_to(path: Path, base: Path) -> bool:
try:
return path.is_relative_to(base)
except (ValueError, OSError, AttributeError):
try:
path.relative_to(base)
return True
except ValueError:
return False
return True
+51 -5
View File
@@ -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)
+65 -1
View File
@@ -1,6 +1,9 @@
from pathlib import Path
from unittest.mock import patch
from syncvito.config_store import ConfigStore
import pytest
from syncvito.config_store import ConfigError, ConfigStore
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
@@ -28,3 +31,64 @@ def test_config_roundtrip(tmp_path: Path) -> None:
assert loaded.tasks[0].name == "Archiv"
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
assert loaded.tasks[0].selected_paths == ["Dokumente"]
def test_config_corrupt_json_creates_backup(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text("{ unvollstaendiges json...", encoding="utf-8")
store = ConfigStore(path)
with pytest.raises(ConfigError, match="syntaktisch beschädigt"):
store.load()
defekt_files = list(tmp_path.glob("config.defekt-*.json"))
assert len(defekt_files) == 1
assert not path.exists()
def test_config_read_locked_does_not_rename_file(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text('{"schema_version": 1, "tasks": [], "settings": {}}', encoding="utf-8")
store = ConfigStore(path)
with (
patch.object(Path, "read_text", side_effect=PermissionError(13, "Permission denied")),
pytest.raises(ConfigError, match="konnte nicht gelesen werden"),
):
store.load()
# Originaldatei darf bei Lese-/Sperrfehler NICHT umbenannt/gelöscht werden!
assert path.exists()
assert list(tmp_path.glob("config.defekt-*.json")) == []
def test_config_load_with_invalid_task_keeps_task_disabled() -> None:
data = {
"schema_version": 1,
"tasks": [
{
"name": "Ungültig",
"source": "C:\\Ordner",
"target": "C:\\Ordner", # Gleiche Quelle und Ziel -> ValueError bei validate()
"interval_minutes": 30,
}
],
"settings": {},
}
config = AppConfig.from_dict(data)
assert len(config.tasks) == 1
assert config.tasks[0].name == "Ungültig"
assert config.tasks[0].enabled is False
def test_sync_task_safe_resolve_network_oserror(tmp_path: Path) -> None:
task = SyncTask(
name="Netzwerk-Task",
source=str(tmp_path / "lokal"),
target=r"\\UnerreichbarerServer\Freigabe\Ordner",
)
with patch.object(Path, "resolve", side_effect=OSError("Network unreachable")):
task.validate()
assert task.source == str((tmp_path / "lokal").resolve())
assert r"\\UnerreichbarerServer\Freigabe\Ordner" in task.target
+16
View File
@@ -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
+52
View File
@@ -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
View File
@@ -1 +1 @@
1.0.13
1.0.15