Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83a85ed40 | ||
|
|
8218cfd9d5 | ||
|
|
6c677f0161 | ||
|
|
1d0b1b2d38 |
No files matched your search
@@ -1,5 +1,33 @@
|
||||
# Änderungsprotokoll
|
||||
|
||||
## 1.0.17 – 2026-10-01
|
||||
|
||||
### Neu
|
||||
|
||||
- **Aufgaben importieren und exportieren:** Einzelne Aufgaben und die vollständige Aufgabenliste lassen sich als JSON an frei gewählten Speicherorten sichern und laden. Exporte enthalten sämtliche Aufgabenpfade und Einstellungen. Ein Einzelimport fügt eine neue Aufgabe hinzu; ein Gesamtimport ersetzt nach Bestätigung die vorhandene Liste.
|
||||
- **Einzelne Dateien synchronisieren:** Neben Ordnerpaaren kann eine Quelldatei ausgewählt und in einen frei gewählten Zielordner synchronisiert werden. Der Dateiname bleibt erhalten; Richtung, Zeitplan, Lösch- und Konfliktverhalten gelten wie bei Ordneraufgaben.
|
||||
|
||||
## 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
|
||||
|
||||
- **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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "syncvito"
|
||||
version = "1.0.13"
|
||||
version = "1.0.17"
|
||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -6,6 +6,7 @@ from syncvito.models import (
|
||||
ConflictMode,
|
||||
DeleteMode,
|
||||
SyncDirection,
|
||||
SyncItemType,
|
||||
SyncTask,
|
||||
TaskRuntimeState,
|
||||
TriggerMode,
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"ConflictMode",
|
||||
"DeleteMode",
|
||||
"SyncDirection",
|
||||
"SyncItemType",
|
||||
"SyncTask",
|
||||
"TaskRuntimeState",
|
||||
"TriggerMode",
|
||||
|
||||
@@ -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:
|
||||
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(self.path.read_text(encoding="utf-8"))
|
||||
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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
+58
-4
@@ -27,7 +27,7 @@ from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncItemType, SyncTask, TriggerMode
|
||||
|
||||
__all__ = ["TaskDialog"]
|
||||
|
||||
@@ -35,6 +35,8 @@ __all__ = ["TaskDialog"]
|
||||
class _PathField(QWidget):
|
||||
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._title = title
|
||||
self._select_file = False
|
||||
self.edit = QLineEdit()
|
||||
self.edit.setPlaceholderText(title)
|
||||
self.button = QPushButton("Auswählen …")
|
||||
@@ -45,7 +47,17 @@ class _PathField(QWidget):
|
||||
layout.addWidget(self.edit, 1)
|
||||
layout.addWidget(self.button)
|
||||
|
||||
def set_file_selection(self, enabled: bool) -> None:
|
||||
"""Schaltet zwischen Ordner- und Dateiauswahl um."""
|
||||
self._select_file = enabled
|
||||
self.edit.setPlaceholderText("Einzelne Quelldatei wählen" if enabled else self._title)
|
||||
|
||||
def _browse(self) -> None:
|
||||
if self._select_file:
|
||||
selected, _ = QFileDialog.getOpenFileName(self, "Quelldatei auswählen", self.edit.text(), "Alle Dateien (*)")
|
||||
if selected:
|
||||
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||
return
|
||||
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
|
||||
if selected:
|
||||
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||
@@ -133,6 +145,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)
|
||||
@@ -141,9 +154,13 @@ class TaskDialog(QDialog):
|
||||
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
||||
self.source_field = _PathField("Lokalen Quellordner wählen")
|
||||
self.target_field = _PathField("NAS- oder Zielordner wählen")
|
||||
self.item_type_combo = QComboBox()
|
||||
self.item_type_combo.addItem("Ordnerpaar", SyncItemType.FOLDER)
|
||||
self.item_type_combo.addItem("Einzelne Datei → Zielordner", SyncItemType.FILE)
|
||||
if task:
|
||||
self.source_field.edit.setText(task.source)
|
||||
self.target_field.edit.setText(task.target)
|
||||
self._set_combo(self.item_type_combo, task.item_type)
|
||||
|
||||
self.direction_combo = QComboBox()
|
||||
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
||||
@@ -178,6 +195,8 @@ class TaskDialog(QDialog):
|
||||
selection_row = QHBoxLayout()
|
||||
selection_row.addWidget(self.selection_label, 1)
|
||||
selection_row.addWidget(selection_button)
|
||||
self.selection_row_widget = QWidget()
|
||||
self.selection_row_widget.setLayout(selection_row)
|
||||
|
||||
base_page = QWidget()
|
||||
base_form = QFormLayout(base_page)
|
||||
@@ -185,12 +204,15 @@ class TaskDialog(QDialog):
|
||||
base_form.setHorizontalSpacing(18)
|
||||
base_form.setVerticalSpacing(14)
|
||||
base_form.addRow("Name", self.name_edit)
|
||||
base_form.addRow("Aufgabentyp", self.item_type_combo)
|
||||
base_form.addRow("Quellordner", self.source_field)
|
||||
self.source_label = base_form.labelForField(self.source_field)
|
||||
base_form.addRow("Zielordner", self.target_field)
|
||||
base_form.addRow("Richtung", self.direction_combo)
|
||||
base_form.addRow("Auslösung", self.trigger_combo)
|
||||
base_form.addRow("Intervall", self.interval_spin)
|
||||
base_form.addRow("Umfang", selection_row)
|
||||
base_form.addRow("Umfang", self.selection_row_widget)
|
||||
self.selection_row_label = base_form.labelForField(self.selection_row_widget)
|
||||
base_form.addRow("", self.enabled_check)
|
||||
|
||||
behavior_page = QWidget()
|
||||
@@ -208,8 +230,21 @@ 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(base_page, "Pfade und Zeitplan")
|
||||
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||
@@ -224,10 +259,16 @@ class TaskDialog(QDialog):
|
||||
layout.addWidget(buttons)
|
||||
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
||||
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
|
||||
self.item_type_combo.currentIndexChanged.connect(self._update_visibility)
|
||||
self.source_field.edit.textChanged.connect(self._update_selection_label)
|
||||
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(
|
||||
@@ -235,12 +276,14 @@ class TaskDialog(QDialog):
|
||||
name=self.name_edit.text(),
|
||||
source=self.source_field.edit.text(),
|
||||
target=self.target_field.edit.text(),
|
||||
item_type=self.item_type_combo.currentData(),
|
||||
direction=self.direction_combo.currentData(),
|
||||
trigger=self.trigger_combo.currentData(),
|
||||
delete_mode=self.delete_combo.currentData(),
|
||||
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,
|
||||
)
|
||||
@@ -248,6 +291,8 @@ class TaskDialog(QDialog):
|
||||
return task
|
||||
|
||||
def _choose_selection(self) -> None:
|
||||
if self.item_type_combo.currentData() is SyncItemType.FILE:
|
||||
return
|
||||
root = Path(self.source_field.edit.text())
|
||||
if not root.is_dir():
|
||||
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
||||
@@ -266,11 +311,20 @@ class TaskDialog(QDialog):
|
||||
self.accept()
|
||||
|
||||
def _update_visibility(self) -> None:
|
||||
is_file_task = self.item_type_combo.currentData() is SyncItemType.FILE
|
||||
self.source_field.set_file_selection(is_file_task)
|
||||
if self.source_label is not None:
|
||||
self.source_label.setText("Quelldatei" if is_file_task else "Quellordner")
|
||||
self.selection_row_widget.setVisible(not is_file_task)
|
||||
if self.selection_row_label is not None:
|
||||
self.selection_row_label.setVisible(not is_file_task)
|
||||
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
|
||||
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
||||
|
||||
def _update_selection_label(self) -> None:
|
||||
if self._selected_paths:
|
||||
if self.item_type_combo.currentData() is SyncItemType.FILE:
|
||||
self.selection_label.setText("Nur die ausgewählte Quelldatei")
|
||||
elif self._selected_paths:
|
||||
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
|
||||
else:
|
||||
self.selection_label.setText("Gesamter Quellordner")
|
||||
|
||||
+145
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,7 @@ from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
@@ -40,6 +42,7 @@ from syncvito.dialogs import TaskDialog
|
||||
from syncvito.icons import app_icon, status_icon
|
||||
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
||||
from syncvito.state_store import StateStore
|
||||
from syncvito.task_transfer import TaskTransferError, export_task, export_tasks, import_task, import_tasks
|
||||
from syncvito.theme import apply_theme
|
||||
from syncvito.update_controller import UpdateController
|
||||
from syncvito.update_dialog import UpdateDialog
|
||||
@@ -251,6 +254,15 @@ class MainWindow(QMainWindow):
|
||||
edit_button = QPushButton("Bearbeiten")
|
||||
delete_button = QPushButton("Löschen")
|
||||
delete_button.setProperty("danger", True)
|
||||
transfer_button = QPushButton("Import / Export")
|
||||
transfer_menu = QMenu(transfer_button)
|
||||
transfer_menu.addSection("Exportieren")
|
||||
transfer_menu.addAction("Ausgewählte Aufgabe exportieren …", self._export_selected_task)
|
||||
transfer_menu.addAction("Alle Aufgaben exportieren …", self._export_all_tasks)
|
||||
transfer_menu.addSection("Importieren")
|
||||
transfer_menu.addAction("Einzelne Aufgabe laden …", self._import_single_task)
|
||||
transfer_menu.addAction("Alle Aufgaben ersetzen …", self._import_all_tasks)
|
||||
transfer_button.setMenu(transfer_menu)
|
||||
self.sync_selected_button = QPushButton("Auswahl synchronisieren")
|
||||
add_button.clicked.connect(self._add_task)
|
||||
edit_button.clicked.connect(self._edit_task)
|
||||
@@ -259,6 +271,7 @@ class MainWindow(QMainWindow):
|
||||
toolbar.addWidget(add_button)
|
||||
toolbar.addWidget(edit_button)
|
||||
toolbar.addWidget(delete_button)
|
||||
toolbar.addWidget(transfer_button)
|
||||
toolbar.addStretch(1)
|
||||
toolbar.addWidget(self.sync_selected_button)
|
||||
layout.addLayout(toolbar)
|
||||
@@ -567,6 +580,135 @@ class MainWindow(QMainWindow):
|
||||
StateStore().remove(task.id)
|
||||
self._persist_tasks("Aufgabe gelöscht")
|
||||
|
||||
def _export_selected_task(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", task.name).strip(" .")[:80] or "Aufgabe"
|
||||
suggested_path = Path.home() / f"SyncVito-{safe_name}.json"
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Aufgabe exportieren",
|
||||
str(suggested_path),
|
||||
"JSON-Dateien (*.json)",
|
||||
)
|
||||
if not filename:
|
||||
return
|
||||
export_path = self._json_export_path(filename)
|
||||
try:
|
||||
export_task(export_path, task)
|
||||
except TaskTransferError as exc:
|
||||
QMessageBox.critical(self, "Export fehlgeschlagen", str(exc))
|
||||
self._append_log("Aufgabe exportiert", "Fehler", str(exc))
|
||||
return
|
||||
self._append_log("Aufgabe exportiert", "Erfolg", f"{task.name}: {export_path}")
|
||||
QMessageBox.information(self, "Export abgeschlossen", f"Die Aufgabe wurde gespeichert unter:\n{export_path}")
|
||||
|
||||
def _export_all_tasks(self) -> None:
|
||||
suggested_path = Path.home() / "SyncVito-Aufgaben.json"
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Alle Aufgaben exportieren",
|
||||
str(suggested_path),
|
||||
"JSON-Dateien (*.json)",
|
||||
)
|
||||
if not filename:
|
||||
return
|
||||
export_path = self._json_export_path(filename)
|
||||
try:
|
||||
export_tasks(export_path, self.config.tasks)
|
||||
except TaskTransferError as exc:
|
||||
QMessageBox.critical(self, "Export fehlgeschlagen", str(exc))
|
||||
self._append_log("Alle Aufgaben exportiert", "Fehler", str(exc))
|
||||
return
|
||||
detail = f"{len(self.config.tasks)} Aufgaben: {export_path}"
|
||||
self._append_log("Alle Aufgaben exportiert", "Erfolg", detail)
|
||||
QMessageBox.information(self, "Export abgeschlossen", f"Alle Aufgaben wurden gespeichert unter:\n{export_path}")
|
||||
|
||||
def _import_single_task(self) -> None:
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Einzelne Aufgabe laden",
|
||||
str(Path.home()),
|
||||
"JSON-Dateien (*.json);;Alle Dateien (*)",
|
||||
)
|
||||
if not filename:
|
||||
return
|
||||
try:
|
||||
task = import_task(Path(filename))
|
||||
except TaskTransferError as exc:
|
||||
QMessageBox.critical(self, "Import fehlgeschlagen", str(exc))
|
||||
self._append_log("Aufgabe importiert", "Fehler", str(exc))
|
||||
return
|
||||
|
||||
self.config.tasks.append(task)
|
||||
if not self._persist_tasks("Aufgabe importiert"):
|
||||
self.config.tasks.remove(task)
|
||||
self._refresh_tasks()
|
||||
return
|
||||
QMessageBox.information(self, "Import abgeschlossen", f"„{task.name}“ wurde als neue Aufgabe hinzugefügt.")
|
||||
|
||||
def _import_all_tasks(self) -> None:
|
||||
if self._has_pending_sync_work():
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Synchronisation läuft",
|
||||
"Alle Aufgaben können erst ersetzt werden, wenn keine Synchronisation läuft oder wartet.",
|
||||
)
|
||||
return
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Alle Aufgaben laden",
|
||||
str(Path.home()),
|
||||
"JSON-Dateien (*.json);;Alle Dateien (*)",
|
||||
)
|
||||
if not filename:
|
||||
return
|
||||
try:
|
||||
imported_tasks = import_tasks(Path(filename))
|
||||
except TaskTransferError as exc:
|
||||
QMessageBox.critical(self, "Import fehlgeschlagen", str(exc))
|
||||
self._append_log("Alle Aufgaben importiert", "Fehler", str(exc))
|
||||
return
|
||||
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Alle Aufgaben ersetzen",
|
||||
f"Die Datei enthält {len(imported_tasks)} Aufgaben. "
|
||||
f"Damit werden alle {len(self.config.tasks)} vorhandenen Aufgaben ersetzt. Fortfahren?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
if self._has_pending_sync_work():
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Synchronisation läuft",
|
||||
"Während des Imports wurde eine Synchronisation gestartet. Bitte den Import danach wiederholen.",
|
||||
)
|
||||
return
|
||||
|
||||
previous_tasks = self.config.tasks[:]
|
||||
previous_queue = self.config.settings.pending_queue[:]
|
||||
self.config.tasks[:] = imported_tasks
|
||||
self.config.settings.pending_queue = []
|
||||
if not self._persist_tasks("Alle Aufgaben importiert"):
|
||||
self.config.tasks[:] = previous_tasks
|
||||
self.config.settings.pending_queue = previous_queue
|
||||
self._refresh_tasks()
|
||||
return
|
||||
QMessageBox.information(self, "Import abgeschlossen", f"{len(imported_tasks)} Aufgaben wurden geladen.")
|
||||
|
||||
@staticmethod
|
||||
def _json_export_path(filename: str) -> Path:
|
||||
path = Path(filename)
|
||||
return path if path.suffix else path.with_suffix(".json")
|
||||
|
||||
def _has_pending_sync_work(self) -> bool:
|
||||
return self.coordinator.is_running or bool(self.coordinator.queued_task_ids)
|
||||
|
||||
def _sync_selected(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
@@ -576,16 +718,17 @@ class MainWindow(QMainWindow):
|
||||
self.coordinator.set_paused(False)
|
||||
self.coordinator.enqueue(task.id)
|
||||
|
||||
def _persist_tasks(self, message: str) -> None:
|
||||
def _persist_tasks(self, message: str) -> bool:
|
||||
try:
|
||||
self.config_store.save(self.config)
|
||||
self.coordinator.refresh_task_states()
|
||||
self.coordinator.rebuild_monitors()
|
||||
except ConfigError as exc:
|
||||
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
||||
return
|
||||
return False
|
||||
self._refresh_tasks()
|
||||
self._append_log("Aufgaben", "Erfolg", message)
|
||||
return True
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
settings = self.config.settings
|
||||
|
||||
+68
-14
@@ -15,6 +15,7 @@ __all__ = [
|
||||
"ConflictMode",
|
||||
"DeleteMode",
|
||||
"SyncDirection",
|
||||
"SyncItemType",
|
||||
"SyncTask",
|
||||
"TaskRuntimeState",
|
||||
"TriggerMode",
|
||||
@@ -28,6 +29,13 @@ class SyncDirection(StrEnum):
|
||||
BIDIRECTIONAL = "bidirectional"
|
||||
|
||||
|
||||
class SyncItemType(StrEnum):
|
||||
"""Art der zu synchronisierenden Quelle."""
|
||||
|
||||
FOLDER = "folder"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
class TriggerMode(StrEnum):
|
||||
"""Unterstützte Auslöser für eine Aufgabe."""
|
||||
|
||||
@@ -61,7 +69,7 @@ class TaskRuntimeState(StrEnum):
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncTask:
|
||||
"""Persistierbare Konfiguration eines Ordnerpaars."""
|
||||
"""Persistierbare Konfiguration eines Ordner- oder Dateiabgleichs."""
|
||||
|
||||
name: str
|
||||
source: str
|
||||
@@ -73,37 +81,59 @@ 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
|
||||
item_type: SyncItemType = SyncItemType.FOLDER
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
||||
if not self.name.strip():
|
||||
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_norm = os.path.normcase(str(source))
|
||||
raise ValueError("Quell- und Zielpfade müssen angegeben werden.")
|
||||
self.item_type = SyncItemType(self.item_type)
|
||||
source = _safe_resolve(Path(self.source).expanduser())
|
||||
target = _safe_resolve(Path(self.target).expanduser())
|
||||
source_root = source.parent if self.item_type is SyncItemType.FILE else source
|
||||
source_norm = os.path.normcase(str(source_root))
|
||||
target_norm = os.path.normcase(str(target))
|
||||
if source_norm == target_norm:
|
||||
raise ValueError("Quell- und Zielordner dürfen nicht identisch sein.")
|
||||
if _is_relative_to(source, target) or _is_relative_to(target, source):
|
||||
raise ValueError("Quell- und Zielpfade dürfen nicht identisch sein oder ineinander liegen.")
|
||||
if _is_relative_to(source_root, target) or _is_relative_to(target, source_root):
|
||||
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
|
||||
if self.item_type is SyncItemType.FILE:
|
||||
if source.is_dir():
|
||||
raise ValueError("Die Quelle einer Dateiaufgabe muss eine Datei sein.")
|
||||
if target.exists() and not target.is_dir():
|
||||
raise ValueError("Das Ziel einer Dateiaufgabe muss ein Ordner sein.")
|
||||
else:
|
||||
if source.exists() and not source.is_dir():
|
||||
raise ValueError("Die Quelle einer Ordneraufgabe muss ein Ordner sein.")
|
||||
if target.exists() and not target.is_dir():
|
||||
raise ValueError("Das Ziel einer Ordneraufgabe muss ein Ordner sein.")
|
||||
if not 1 <= self.interval_minutes <= 10080:
|
||||
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
||||
self.name = self.name.strip()
|
||||
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()})
|
||||
if self.item_type is SyncItemType.FILE:
|
||||
self.selected_paths = [source.name]
|
||||
else:
|
||||
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."""
|
||||
data = asdict(self)
|
||||
data["item_type"] = self.item_type.value
|
||||
data["direction"] = self.direction.value
|
||||
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
|
||||
@@ -114,16 +144,21 @@ class SyncTask:
|
||||
name=str(data.get("name", "Unbenannte Aufgabe")),
|
||||
source=str(data.get("source", "")),
|
||||
target=str(data.get("target", "")),
|
||||
item_type=SyncItemType(data.get("item_type", SyncItemType.FOLDER.value)),
|
||||
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
||||
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
||||
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
||||
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"),
|
||||
)
|
||||
task.validate()
|
||||
try:
|
||||
task.validate()
|
||||
except ValueError:
|
||||
task.enabled = False
|
||||
return task
|
||||
|
||||
|
||||
@@ -191,12 +226,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)
|
||||
@@ -207,7 +258,10 @@ def _normalize_relative_path(value: str) -> str:
|
||||
|
||||
def _is_relative_to(path: Path, base: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
return path.is_relative_to(base)
|
||||
except (ValueError, OSError, AttributeError):
|
||||
try:
|
||||
path.relative_to(base)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -2,23 +2,38 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from syncvito.models import SyncDirection, SyncTask, TriggerMode
|
||||
from syncvito.models import SyncDirection, SyncItemType, SyncTask, TriggerMode
|
||||
|
||||
__all__ = ["RealtimeMonitor"]
|
||||
|
||||
|
||||
class _TaskEventHandler(FileSystemEventHandler):
|
||||
def __init__(self, task_id: str, callback: Callable[[str], None]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
task_id: str,
|
||||
callback: Callable[[str], None],
|
||||
watched_files: set[str] | None = None,
|
||||
) -> None:
|
||||
self.task_id = task_id
|
||||
self.callback = callback
|
||||
self.watched_files = watched_files
|
||||
|
||||
def on_any_event(self, event: FileSystemEvent) -> None:
|
||||
if self.watched_files is not None:
|
||||
event_paths = {event.src_path}
|
||||
destination = getattr(event, "dest_path", None)
|
||||
if destination:
|
||||
event_paths.add(destination)
|
||||
normalized_paths = {os.path.normcase(os.path.abspath(path)) for path in event_paths}
|
||||
if not self.watched_files.intersection(normalized_paths):
|
||||
return
|
||||
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
|
||||
self.callback(self.task_id)
|
||||
|
||||
@@ -39,16 +54,26 @@ class RealtimeMonitor:
|
||||
for task in tasks:
|
||||
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
||||
continue
|
||||
handler = _TaskEventHandler(task.id, self.callback)
|
||||
paths = [Path(task.source)]
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||
paths.append(Path(task.target))
|
||||
watched_files: set[str] | None = None
|
||||
if task.item_type is SyncItemType.FILE:
|
||||
source_file = Path(task.source)
|
||||
watched_files = {os.path.normcase(os.path.abspath(str(source_file)))}
|
||||
paths = [source_file.parent]
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||
target_file = Path(task.target) / source_file.name
|
||||
watched_files.add(os.path.normcase(os.path.abspath(str(target_file))))
|
||||
paths.append(Path(task.target))
|
||||
else:
|
||||
paths = [Path(task.source)]
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||
paths.append(Path(task.target))
|
||||
handler = _TaskEventHandler(task.id, self.callback, watched_files)
|
||||
for path in paths:
|
||||
if not path.is_dir():
|
||||
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
||||
continue
|
||||
try:
|
||||
observer.schedule(handler, str(path), recursive=True)
|
||||
observer.schedule(handler, str(path), recursive=task.item_type is SyncItemType.FOLDER)
|
||||
scheduled = True
|
||||
except OSError as exc:
|
||||
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {exc}"))
|
||||
|
||||
+85
-12
@@ -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, SyncItemType, 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
|
||||
@@ -95,20 +127,32 @@ class SyncEngine:
|
||||
) -> SyncResult:
|
||||
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||
task.validate()
|
||||
source_root = Path(task.source)
|
||||
source_path = Path(task.source)
|
||||
source_root = source_path.parent if task.item_type is SyncItemType.FILE else source_path
|
||||
target_root = Path(task.target)
|
||||
selected_paths = [source_path.name] if task.item_type is SyncItemType.FILE else task.selected_paths
|
||||
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", cancel_check=cancel_check
|
||||
source_root,
|
||||
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,
|
||||
selected_paths,
|
||||
progress,
|
||||
"Ziel",
|
||||
cancel_check=cancel_check,
|
||||
ignored_paths=task.ignored_paths,
|
||||
)
|
||||
if cancel_check and cancel_check():
|
||||
return SyncResult(paused=True)
|
||||
@@ -137,7 +181,7 @@ class SyncEngine:
|
||||
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)
|
||||
stage_name = _operation_stage(op.relative, selected_paths, is_dir=is_dir)
|
||||
if stage_name not in stages:
|
||||
stages.append(stage_name)
|
||||
|
||||
@@ -150,7 +194,7 @@ class SyncEngine:
|
||||
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)
|
||||
stage = _operation_stage(operation.relative, selected_paths, is_dir=is_dir)
|
||||
if cancel_check and cancel_check():
|
||||
result.paused = True
|
||||
if progress:
|
||||
@@ -167,6 +211,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 +248,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, selected_paths, ignored_paths=task.ignored_paths)
|
||||
refreshed_target, _ = self._inventory(target_root, 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 +285,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 +308,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 +322,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 +381,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)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Versionierter JSON-Import und -Export für Synchronisationsaufgaben."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from syncvito.models import SyncTask
|
||||
|
||||
__all__ = ["TaskTransferError", "export_task", "export_tasks", "import_task", "import_tasks"]
|
||||
|
||||
_FORMAT_VERSION = 1
|
||||
_SINGLE_TASK_FORMAT = "syncvito.task"
|
||||
_TASKS_FORMAT = "syncvito.tasks"
|
||||
_TASK_FIELDS = {
|
||||
"id",
|
||||
"name",
|
||||
"source",
|
||||
"target",
|
||||
"item_type",
|
||||
"direction",
|
||||
"trigger",
|
||||
"delete_mode",
|
||||
"conflict_mode",
|
||||
"interval_minutes",
|
||||
"selected_paths",
|
||||
"ignored_paths",
|
||||
"enabled",
|
||||
"last_run",
|
||||
}
|
||||
|
||||
|
||||
class TaskTransferError(ValueError):
|
||||
"""Fehlerhafte oder nicht lesbare Aufgaben-Transferdatei."""
|
||||
|
||||
|
||||
def export_task(path: Path, task: SyncTask) -> None:
|
||||
"""Schreibt eine einzelne Aufgabe mit allen Einstellungen als JSON."""
|
||||
_write_payload(
|
||||
path,
|
||||
{"format": _SINGLE_TASK_FORMAT, "format_version": _FORMAT_VERSION, "task": task.to_dict()},
|
||||
)
|
||||
|
||||
|
||||
def export_tasks(path: Path, tasks: list[SyncTask]) -> None:
|
||||
"""Schreibt alle Aufgaben mit ihren Einstellungen und Pfaden als JSON."""
|
||||
_write_payload(
|
||||
path,
|
||||
{
|
||||
"format": _TASKS_FORMAT,
|
||||
"format_version": _FORMAT_VERSION,
|
||||
"tasks": [task.to_dict() for task in tasks],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def import_task(path: Path) -> SyncTask:
|
||||
"""Lädt eine einzelne Aufgabe und vergibt eine neue lokale Kennung."""
|
||||
payload = _read_payload(path, _SINGLE_TASK_FORMAT)
|
||||
raw_task = payload.get("task")
|
||||
task = _parse_task(raw_task)
|
||||
task.id = str(uuid4())
|
||||
task.last_run = None
|
||||
return task
|
||||
|
||||
|
||||
def import_tasks(path: Path) -> list[SyncTask]:
|
||||
"""Lädt und validiert vollständig einen Export der Aufgabenliste."""
|
||||
payload = _read_payload(path, _TASKS_FORMAT)
|
||||
raw_tasks = payload.get("tasks")
|
||||
if not isinstance(raw_tasks, list):
|
||||
raise TaskTransferError("Die Aufgabenliste in der Datei ist ungültig.")
|
||||
|
||||
tasks = [_parse_task(raw_task) for raw_task in raw_tasks]
|
||||
task_ids = [task.id for task in tasks]
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise TaskTransferError("Die Datei enthält doppelte Aufgabenkennungen.")
|
||||
return tasks
|
||||
|
||||
|
||||
def _read_payload(path: Path, expected_format: str) -> dict[str, Any]:
|
||||
try:
|
||||
raw: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TaskTransferError(f"Die Datei konnte nicht gelesen werden: {exc}") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise TaskTransferError("Die Datei enthält kein gültiges SyncVito-JSON-Objekt.")
|
||||
if raw.get("format") != expected_format:
|
||||
raise TaskTransferError("Der Dateityp passt nicht zu dieser Importaktion.")
|
||||
if type(raw.get("format_version")) is not int or raw["format_version"] != _FORMAT_VERSION:
|
||||
raise TaskTransferError("Diese Version des Aufgabenformats wird nicht unterstützt.")
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_task(raw_task: Any) -> SyncTask:
|
||||
if not isinstance(raw_task, dict):
|
||||
raise TaskTransferError("Eine Aufgabe in der Datei ist kein gültiges JSON-Objekt.")
|
||||
missing_fields = (_TASK_FIELDS - {"item_type"}).difference(raw_task)
|
||||
if missing_fields:
|
||||
fields = ", ".join(sorted(missing_fields))
|
||||
raise TaskTransferError(f"In einer Aufgabe fehlen erforderliche Daten: {fields}.")
|
||||
|
||||
for field in ("id", "name", "source", "target", "direction", "trigger", "delete_mode", "conflict_mode"):
|
||||
if not isinstance(raw_task[field], str):
|
||||
raise TaskTransferError(f"Das Feld „{field}“ einer Aufgabe muss Text enthalten.")
|
||||
if "item_type" in raw_task and not isinstance(raw_task["item_type"], str):
|
||||
raise TaskTransferError("Der Aufgabentyp ist ungültig.")
|
||||
if not raw_task["id"].strip():
|
||||
raise TaskTransferError("Eine Aufgabe hat keine gültige Kennung.")
|
||||
if type(raw_task["interval_minutes"]) is not int:
|
||||
raise TaskTransferError("Das Intervall einer Aufgabe muss eine ganze Zahl sein.")
|
||||
if type(raw_task["enabled"]) is not bool:
|
||||
raise TaskTransferError("Der Aktivierungszustand einer Aufgabe ist ungültig.")
|
||||
for field in ("selected_paths", "ignored_paths"):
|
||||
if not isinstance(raw_task[field], list) or any(not isinstance(item, str) for item in raw_task[field]):
|
||||
raise TaskTransferError(f"Die Pfadliste „{field}“ einer Aufgabe ist ungültig.")
|
||||
if raw_task["last_run"] is not None and not isinstance(raw_task["last_run"], str):
|
||||
raise TaskTransferError("Der letzte Lauf einer Aufgabe ist ungültig.")
|
||||
|
||||
try:
|
||||
task = SyncTask.from_dict(raw_task)
|
||||
task.validate()
|
||||
except (TypeError, ValueError, OSError) as exc:
|
||||
raise TaskTransferError(f"Eine Aufgabe enthält ungültige Einstellungen oder Pfade: {exc}") from exc
|
||||
return task
|
||||
|
||||
|
||||
def _write_payload(path: Path, payload: dict[str, Any]) -> None:
|
||||
target = Path(path)
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=target.parent,
|
||||
prefix=f".{target.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary_file:
|
||||
temporary_path = Path(temporary_file.name)
|
||||
json.dump(payload, temporary_file, ensure_ascii=False, indent=2)
|
||||
temporary_file.write("\n")
|
||||
os.replace(temporary_path, target)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
raise TaskTransferError(f"Die Exportdatei konnte nicht gespeichert werden: {exc}") from exc
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
with suppress(OSError):
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
@@ -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
|
||||
@@ -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.13
|
||||
1.0.17
|
||||
Reference in new issue
Block a user