Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83a85ed40 | ||
|
|
8218cfd9d5 | ||
|
|
6c677f0161 |
No files matched your search
@@ -1,5 +1,25 @@
|
|||||||
# Änderungsprotokoll
|
# Ä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
|
## 1.0.14 – 2026-09-29
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "syncvito"
|
name = "syncvito"
|
||||||
version = "1.0.14"
|
version = "1.0.17"
|
||||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from syncvito.models import (
|
|||||||
ConflictMode,
|
ConflictMode,
|
||||||
DeleteMode,
|
DeleteMode,
|
||||||
SyncDirection,
|
SyncDirection,
|
||||||
|
SyncItemType,
|
||||||
SyncTask,
|
SyncTask,
|
||||||
TaskRuntimeState,
|
TaskRuntimeState,
|
||||||
TriggerMode,
|
TriggerMode,
|
||||||
@@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"ConflictMode",
|
"ConflictMode",
|
||||||
"DeleteMode",
|
"DeleteMode",
|
||||||
"SyncDirection",
|
"SyncDirection",
|
||||||
|
"SyncItemType",
|
||||||
"SyncTask",
|
"SyncTask",
|
||||||
"TaskRuntimeState",
|
"TaskRuntimeState",
|
||||||
"TriggerMode",
|
"TriggerMode",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
|
|||||||
|
|
||||||
from syncvito.config_store import ConfigStore
|
from syncvito.config_store import ConfigStore
|
||||||
from syncvito.file_locks import is_file_locked
|
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.realtime import RealtimeMonitor
|
||||||
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
|
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
|
||||||
|
|
||||||
@@ -300,6 +300,15 @@ class SyncCoordinator(QObject):
|
|||||||
task.last_run = datetime.now().isoformat(timespec="seconds")
|
task.last_run = datetime.now().isoformat(timespec="seconds")
|
||||||
if task_id in self.config.settings.pending_queue:
|
if task_id in self.config.settings.pending_queue:
|
||||||
self.config.settings.pending_queue.remove(task_id)
|
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):
|
with suppress(Exception):
|
||||||
self.config_store.save(self.config)
|
self.config_store.save(self.config)
|
||||||
|
|
||||||
@@ -308,8 +317,7 @@ class SyncCoordinator(QObject):
|
|||||||
self._retry_tasks[task_id] = paths
|
self._retry_tasks[task_id] = paths
|
||||||
message = (
|
message = (
|
||||||
f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. "
|
f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. "
|
||||||
"Andere Dateien wurden weiterverarbeitet. Automatischer Wiederholungsversuch nach Freigabe; "
|
"Betroffene Dateien werden künftig automatisch ignoriert. Details im Protokoll."
|
||||||
"ggf. betroffene Programme schließen. Details im Protokoll."
|
|
||||||
)
|
)
|
||||||
self._lock_messages[task_id] = message
|
self._lock_messages[task_id] = message
|
||||||
self._emit_lock_summary()
|
self._emit_lock_summary()
|
||||||
|
|||||||
+58
-4
@@ -27,7 +27,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncItemType, SyncTask, TriggerMode
|
||||||
|
|
||||||
__all__ = ["TaskDialog"]
|
__all__ = ["TaskDialog"]
|
||||||
|
|
||||||
@@ -35,6 +35,8 @@ __all__ = ["TaskDialog"]
|
|||||||
class _PathField(QWidget):
|
class _PathField(QWidget):
|
||||||
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._title = title
|
||||||
|
self._select_file = False
|
||||||
self.edit = QLineEdit()
|
self.edit = QLineEdit()
|
||||||
self.edit.setPlaceholderText(title)
|
self.edit.setPlaceholderText(title)
|
||||||
self.button = QPushButton("Auswählen …")
|
self.button = QPushButton("Auswählen …")
|
||||||
@@ -45,7 +47,17 @@ class _PathField(QWidget):
|
|||||||
layout.addWidget(self.edit, 1)
|
layout.addWidget(self.edit, 1)
|
||||||
layout.addWidget(self.button)
|
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:
|
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())
|
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
|
||||||
if selected:
|
if selected:
|
||||||
self.edit.setText(QDir.toNativeSeparators(selected))
|
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||||
@@ -133,6 +145,7 @@ class TaskDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._original = task
|
self._original = task
|
||||||
self._selected_paths = list(task.selected_paths) if task else []
|
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.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
|
||||||
self.setMinimumWidth(690)
|
self.setMinimumWidth(690)
|
||||||
self.resize(720, 610)
|
self.resize(720, 610)
|
||||||
@@ -141,9 +154,13 @@ class TaskDialog(QDialog):
|
|||||||
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
||||||
self.source_field = _PathField("Lokalen Quellordner wählen")
|
self.source_field = _PathField("Lokalen Quellordner wählen")
|
||||||
self.target_field = _PathField("NAS- oder Zielordner 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:
|
if task:
|
||||||
self.source_field.edit.setText(task.source)
|
self.source_field.edit.setText(task.source)
|
||||||
self.target_field.edit.setText(task.target)
|
self.target_field.edit.setText(task.target)
|
||||||
|
self._set_combo(self.item_type_combo, task.item_type)
|
||||||
|
|
||||||
self.direction_combo = QComboBox()
|
self.direction_combo = QComboBox()
|
||||||
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
||||||
@@ -178,6 +195,8 @@ class TaskDialog(QDialog):
|
|||||||
selection_row = QHBoxLayout()
|
selection_row = QHBoxLayout()
|
||||||
selection_row.addWidget(self.selection_label, 1)
|
selection_row.addWidget(self.selection_label, 1)
|
||||||
selection_row.addWidget(selection_button)
|
selection_row.addWidget(selection_button)
|
||||||
|
self.selection_row_widget = QWidget()
|
||||||
|
self.selection_row_widget.setLayout(selection_row)
|
||||||
|
|
||||||
base_page = QWidget()
|
base_page = QWidget()
|
||||||
base_form = QFormLayout(base_page)
|
base_form = QFormLayout(base_page)
|
||||||
@@ -185,12 +204,15 @@ class TaskDialog(QDialog):
|
|||||||
base_form.setHorizontalSpacing(18)
|
base_form.setHorizontalSpacing(18)
|
||||||
base_form.setVerticalSpacing(14)
|
base_form.setVerticalSpacing(14)
|
||||||
base_form.addRow("Name", self.name_edit)
|
base_form.addRow("Name", self.name_edit)
|
||||||
|
base_form.addRow("Aufgabentyp", self.item_type_combo)
|
||||||
base_form.addRow("Quellordner", self.source_field)
|
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("Zielordner", self.target_field)
|
||||||
base_form.addRow("Richtung", self.direction_combo)
|
base_form.addRow("Richtung", self.direction_combo)
|
||||||
base_form.addRow("Auslösung", self.trigger_combo)
|
base_form.addRow("Auslösung", self.trigger_combo)
|
||||||
base_form.addRow("Intervall", self.interval_spin)
|
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)
|
base_form.addRow("", self.enabled_check)
|
||||||
|
|
||||||
behavior_page = QWidget()
|
behavior_page = QWidget()
|
||||||
@@ -208,8 +230,21 @@ class TaskDialog(QDialog):
|
|||||||
warning.setProperty("muted", True)
|
warning.setProperty("muted", True)
|
||||||
behavior_layout.addRow("", warning)
|
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 = QTabWidget()
|
||||||
tabs.addTab(base_page, "Ordner und Zeitplan")
|
tabs.addTab(base_page, "Pfade und Zeitplan")
|
||||||
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||||
@@ -224,10 +259,16 @@ class TaskDialog(QDialog):
|
|||||||
layout.addWidget(buttons)
|
layout.addWidget(buttons)
|
||||||
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
||||||
self.direction_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.source_field.edit.textChanged.connect(self._update_selection_label)
|
||||||
self._update_visibility()
|
self._update_visibility()
|
||||||
self._update_selection_label()
|
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:
|
def task(self) -> SyncTask:
|
||||||
"""Erzeugt das validierte Modell aus den Formulardaten."""
|
"""Erzeugt das validierte Modell aus den Formulardaten."""
|
||||||
task = SyncTask(
|
task = SyncTask(
|
||||||
@@ -235,12 +276,14 @@ class TaskDialog(QDialog):
|
|||||||
name=self.name_edit.text(),
|
name=self.name_edit.text(),
|
||||||
source=self.source_field.edit.text(),
|
source=self.source_field.edit.text(),
|
||||||
target=self.target_field.edit.text(),
|
target=self.target_field.edit.text(),
|
||||||
|
item_type=self.item_type_combo.currentData(),
|
||||||
direction=self.direction_combo.currentData(),
|
direction=self.direction_combo.currentData(),
|
||||||
trigger=self.trigger_combo.currentData(),
|
trigger=self.trigger_combo.currentData(),
|
||||||
delete_mode=self.delete_combo.currentData(),
|
delete_mode=self.delete_combo.currentData(),
|
||||||
conflict_mode=self.conflict_combo.currentData(),
|
conflict_mode=self.conflict_combo.currentData(),
|
||||||
interval_minutes=self.interval_spin.value(),
|
interval_minutes=self.interval_spin.value(),
|
||||||
selected_paths=self._selected_paths,
|
selected_paths=self._selected_paths,
|
||||||
|
ignored_paths=self._ignored_paths,
|
||||||
enabled=self.enabled_check.isChecked(),
|
enabled=self.enabled_check.isChecked(),
|
||||||
last_run=self._original.last_run if self._original else None,
|
last_run=self._original.last_run if self._original else None,
|
||||||
)
|
)
|
||||||
@@ -248,6 +291,8 @@ class TaskDialog(QDialog):
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
def _choose_selection(self) -> None:
|
def _choose_selection(self) -> None:
|
||||||
|
if self.item_type_combo.currentData() is SyncItemType.FILE:
|
||||||
|
return
|
||||||
root = Path(self.source_field.edit.text())
|
root = Path(self.source_field.edit.text())
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
||||||
@@ -266,11 +311,20 @@ class TaskDialog(QDialog):
|
|||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def _update_visibility(self) -> None:
|
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.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
|
||||||
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
||||||
|
|
||||||
def _update_selection_label(self) -> None:
|
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")
|
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
|
||||||
else:
|
else:
|
||||||
self.selection_label.setText("Gesamter Quellordner")
|
self.selection_label.setText("Gesamter Quellordner")
|
||||||
|
|||||||
+145
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,6 +14,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QApplication,
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QFileDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
@@ -40,6 +42,7 @@ from syncvito.dialogs import TaskDialog
|
|||||||
from syncvito.icons import app_icon, status_icon
|
from syncvito.icons import app_icon, status_icon
|
||||||
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
||||||
from syncvito.state_store import StateStore
|
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.theme import apply_theme
|
||||||
from syncvito.update_controller import UpdateController
|
from syncvito.update_controller import UpdateController
|
||||||
from syncvito.update_dialog import UpdateDialog
|
from syncvito.update_dialog import UpdateDialog
|
||||||
@@ -251,6 +254,15 @@ class MainWindow(QMainWindow):
|
|||||||
edit_button = QPushButton("Bearbeiten")
|
edit_button = QPushButton("Bearbeiten")
|
||||||
delete_button = QPushButton("Löschen")
|
delete_button = QPushButton("Löschen")
|
||||||
delete_button.setProperty("danger", True)
|
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")
|
self.sync_selected_button = QPushButton("Auswahl synchronisieren")
|
||||||
add_button.clicked.connect(self._add_task)
|
add_button.clicked.connect(self._add_task)
|
||||||
edit_button.clicked.connect(self._edit_task)
|
edit_button.clicked.connect(self._edit_task)
|
||||||
@@ -259,6 +271,7 @@ class MainWindow(QMainWindow):
|
|||||||
toolbar.addWidget(add_button)
|
toolbar.addWidget(add_button)
|
||||||
toolbar.addWidget(edit_button)
|
toolbar.addWidget(edit_button)
|
||||||
toolbar.addWidget(delete_button)
|
toolbar.addWidget(delete_button)
|
||||||
|
toolbar.addWidget(transfer_button)
|
||||||
toolbar.addStretch(1)
|
toolbar.addStretch(1)
|
||||||
toolbar.addWidget(self.sync_selected_button)
|
toolbar.addWidget(self.sync_selected_button)
|
||||||
layout.addLayout(toolbar)
|
layout.addLayout(toolbar)
|
||||||
@@ -567,6 +580,135 @@ class MainWindow(QMainWindow):
|
|||||||
StateStore().remove(task.id)
|
StateStore().remove(task.id)
|
||||||
self._persist_tasks("Aufgabe gelöscht")
|
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:
|
def _sync_selected(self) -> None:
|
||||||
task = self._selected_task()
|
task = self._selected_task()
|
||||||
if task is None:
|
if task is None:
|
||||||
@@ -576,16 +718,17 @@ class MainWindow(QMainWindow):
|
|||||||
self.coordinator.set_paused(False)
|
self.coordinator.set_paused(False)
|
||||||
self.coordinator.enqueue(task.id)
|
self.coordinator.enqueue(task.id)
|
||||||
|
|
||||||
def _persist_tasks(self, message: str) -> None:
|
def _persist_tasks(self, message: str) -> bool:
|
||||||
try:
|
try:
|
||||||
self.config_store.save(self.config)
|
self.config_store.save(self.config)
|
||||||
self.coordinator.refresh_task_states()
|
self.coordinator.refresh_task_states()
|
||||||
self.coordinator.rebuild_monitors()
|
self.coordinator.rebuild_monitors()
|
||||||
except ConfigError as exc:
|
except ConfigError as exc:
|
||||||
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
||||||
return
|
return False
|
||||||
self._refresh_tasks()
|
self._refresh_tasks()
|
||||||
self._append_log("Aufgaben", "Erfolg", message)
|
self._append_log("Aufgaben", "Erfolg", message)
|
||||||
|
return True
|
||||||
|
|
||||||
def _save_settings(self) -> None:
|
def _save_settings(self) -> None:
|
||||||
settings = self.config.settings
|
settings = self.config.settings
|
||||||
|
|||||||
+38
-6
@@ -15,6 +15,7 @@ __all__ = [
|
|||||||
"ConflictMode",
|
"ConflictMode",
|
||||||
"DeleteMode",
|
"DeleteMode",
|
||||||
"SyncDirection",
|
"SyncDirection",
|
||||||
|
"SyncItemType",
|
||||||
"SyncTask",
|
"SyncTask",
|
||||||
"TaskRuntimeState",
|
"TaskRuntimeState",
|
||||||
"TriggerMode",
|
"TriggerMode",
|
||||||
@@ -28,6 +29,13 @@ class SyncDirection(StrEnum):
|
|||||||
BIDIRECTIONAL = "bidirectional"
|
BIDIRECTIONAL = "bidirectional"
|
||||||
|
|
||||||
|
|
||||||
|
class SyncItemType(StrEnum):
|
||||||
|
"""Art der zu synchronisierenden Quelle."""
|
||||||
|
|
||||||
|
FOLDER = "folder"
|
||||||
|
FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
class TriggerMode(StrEnum):
|
class TriggerMode(StrEnum):
|
||||||
"""Unterstützte Auslöser für eine Aufgabe."""
|
"""Unterstützte Auslöser für eine Aufgabe."""
|
||||||
|
|
||||||
@@ -61,7 +69,7 @@ class TaskRuntimeState(StrEnum):
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SyncTask:
|
class SyncTask:
|
||||||
"""Persistierbare Konfiguration eines Ordnerpaars."""
|
"""Persistierbare Konfiguration eines Ordner- oder Dateiabgleichs."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
source: str
|
source: str
|
||||||
@@ -73,37 +81,59 @@ class SyncTask:
|
|||||||
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
||||||
interval_minutes: int = 30
|
interval_minutes: int = 30
|
||||||
selected_paths: list[str] = field(default_factory=list)
|
selected_paths: list[str] = field(default_factory=list)
|
||||||
|
ignored_paths: list[str] = field(default_factory=list)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
last_run: str | None = None
|
last_run: str | None = None
|
||||||
|
item_type: SyncItemType = SyncItemType.FOLDER
|
||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
||||||
if not self.name.strip():
|
if not self.name.strip():
|
||||||
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
|
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
|
||||||
if not self.source.strip() or not self.target.strip():
|
if not self.source.strip() or not self.target.strip():
|
||||||
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
|
raise ValueError("Quell- und Zielpfade müssen angegeben werden.")
|
||||||
|
self.item_type = SyncItemType(self.item_type)
|
||||||
source = _safe_resolve(Path(self.source).expanduser())
|
source = _safe_resolve(Path(self.source).expanduser())
|
||||||
target = _safe_resolve(Path(self.target).expanduser())
|
target = _safe_resolve(Path(self.target).expanduser())
|
||||||
source_norm = os.path.normcase(str(source))
|
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))
|
target_norm = os.path.normcase(str(target))
|
||||||
if source_norm == target_norm:
|
if source_norm == target_norm:
|
||||||
raise ValueError("Quell- und Zielordner dürfen nicht identisch sein.")
|
raise ValueError("Quell- und Zielpfade dürfen nicht identisch sein oder ineinander liegen.")
|
||||||
if _is_relative_to(source, target) or _is_relative_to(target, source):
|
if _is_relative_to(source_root, target) or _is_relative_to(target, source_root):
|
||||||
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
|
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:
|
if not 1 <= self.interval_minutes <= 10080:
|
||||||
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
||||||
self.name = self.name.strip()
|
self.name = self.name.strip()
|
||||||
self.source = str(source)
|
self.source = str(source)
|
||||||
self.target = str(target)
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
||||||
data = asdict(self)
|
data = asdict(self)
|
||||||
|
data["item_type"] = self.item_type.value
|
||||||
data["direction"] = self.direction.value
|
data["direction"] = self.direction.value
|
||||||
data["trigger"] = self.trigger.value
|
data["trigger"] = self.trigger.value
|
||||||
data["delete_mode"] = self.delete_mode.value
|
data["delete_mode"] = self.delete_mode.value
|
||||||
data["conflict_mode"] = self.conflict_mode.value
|
data["conflict_mode"] = self.conflict_mode.value
|
||||||
|
data["ignored_paths"] = list(self.ignored_paths)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -114,12 +144,14 @@ class SyncTask:
|
|||||||
name=str(data.get("name", "Unbenannte Aufgabe")),
|
name=str(data.get("name", "Unbenannte Aufgabe")),
|
||||||
source=str(data.get("source", "")),
|
source=str(data.get("source", "")),
|
||||||
target=str(data.get("target", "")),
|
target=str(data.get("target", "")),
|
||||||
|
item_type=SyncItemType(data.get("item_type", SyncItemType.FOLDER.value)),
|
||||||
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
||||||
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
||||||
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
||||||
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
|
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
|
||||||
interval_minutes=int(data.get("interval_minutes", 30)),
|
interval_minutes=int(data.get("interval_minutes", 30)),
|
||||||
selected_paths=[str(item) for item in data.get("selected_paths", [])],
|
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)),
|
enabled=bool(data.get("enabled", True)),
|
||||||
last_run=data.get("last_run"),
|
last_run=data.get("last_run"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,23 +2,38 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
from syncvito.models import SyncDirection, SyncTask, TriggerMode
|
from syncvito.models import SyncDirection, SyncItemType, SyncTask, TriggerMode
|
||||||
|
|
||||||
__all__ = ["RealtimeMonitor"]
|
__all__ = ["RealtimeMonitor"]
|
||||||
|
|
||||||
|
|
||||||
class _TaskEventHandler(FileSystemEventHandler):
|
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.task_id = task_id
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
|
self.watched_files = watched_files
|
||||||
|
|
||||||
def on_any_event(self, event: FileSystemEvent) -> None:
|
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"}:
|
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
|
||||||
self.callback(self.task_id)
|
self.callback(self.task_id)
|
||||||
|
|
||||||
@@ -39,16 +54,26 @@ class RealtimeMonitor:
|
|||||||
for task in tasks:
|
for task in tasks:
|
||||||
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
||||||
continue
|
continue
|
||||||
handler = _TaskEventHandler(task.id, self.callback)
|
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)]
|
paths = [Path(task.source)]
|
||||||
if task.direction is SyncDirection.BIDIRECTIONAL:
|
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||||
paths.append(Path(task.target))
|
paths.append(Path(task.target))
|
||||||
|
handler = _TaskEventHandler(task.id, self.callback, watched_files)
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
observer.schedule(handler, str(path), recursive=True)
|
observer.schedule(handler, str(path), recursive=task.item_type is SyncItemType.FOLDER)
|
||||||
scheduled = True
|
scheduled = True
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {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 time import monotonic
|
||||||
|
|
||||||
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
|
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
|
from syncvito.state_store import StateStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -22,6 +22,7 @@ __all__ = [
|
|||||||
"SyncEngine",
|
"SyncEngine",
|
||||||
"SyncResult",
|
"SyncResult",
|
||||||
"UnavailablePathError",
|
"UnavailablePathError",
|
||||||
|
"is_relative_path_ignored",
|
||||||
]
|
]
|
||||||
|
|
||||||
ProgressCallback = Callable[[int, str], None]
|
ProgressCallback = Callable[[int, str], None]
|
||||||
@@ -31,6 +32,36 @@ class UnavailablePathError(RuntimeError):
|
|||||||
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
"""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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FileRecord:
|
class FileRecord:
|
||||||
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
||||||
@@ -64,6 +95,7 @@ class SyncResult:
|
|||||||
paused: bool = False
|
paused: bool = False
|
||||||
blocked: list[BlockedFile] = field(default_factory=list)
|
blocked: list[BlockedFile] = field(default_factory=list)
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
|
failed_relatives: set[str] = field(default_factory=set)
|
||||||
inaccessible: bool = False
|
inaccessible: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -95,20 +127,32 @@ class SyncEngine:
|
|||||||
) -> SyncResult:
|
) -> SyncResult:
|
||||||
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||||
task.validate()
|
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)
|
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():
|
if cancel_check and cancel_check():
|
||||||
return SyncResult(paused=True)
|
return SyncResult(paused=True)
|
||||||
if progress:
|
if progress:
|
||||||
progress(0, f"{task.name}: Ordner werden geprüft …")
|
progress(0, f"{task.name}: Ordner werden geprüft …")
|
||||||
self._validate_roots(source_root, target_root)
|
self._validate_roots(source_root, target_root)
|
||||||
source_files, source_dirs = self._inventory(
|
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():
|
if cancel_check and cancel_check():
|
||||||
return SyncResult(paused=True)
|
return SyncResult(paused=True)
|
||||||
target_files, target_dirs = self._inventory(
|
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():
|
if cancel_check and cancel_check():
|
||||||
return SyncResult(paused=True)
|
return SyncResult(paused=True)
|
||||||
@@ -137,7 +181,7 @@ class SyncEngine:
|
|||||||
stages: list[str] = []
|
stages: list[str] = []
|
||||||
for op in operations:
|
for op in operations:
|
||||||
is_dir = op.kind in {"Ordner anlegen", "Ordner löschen"}
|
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:
|
if stage_name not in stages:
|
||||||
stages.append(stage_name)
|
stages.append(stage_name)
|
||||||
|
|
||||||
@@ -150,7 +194,7 @@ class SyncEngine:
|
|||||||
progress(0, "Änderungen werden vorbereitet …")
|
progress(0, "Änderungen werden vorbereitet …")
|
||||||
for index, operation in enumerate(operations, start=1):
|
for index, operation in enumerate(operations, start=1):
|
||||||
is_dir = operation.kind in {"Ordner anlegen", "Ordner löschen"}
|
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():
|
if cancel_check and cancel_check():
|
||||||
result.paused = True
|
result.paused = True
|
||||||
if progress:
|
if progress:
|
||||||
@@ -167,6 +211,8 @@ class SyncEngine:
|
|||||||
if isinstance(exc, OSError) and (exc.filename2 or exc.filename)
|
if isinstance(exc, OSError) and (exc.filename2 or exc.filename)
|
||||||
else operation.target
|
else operation.target
|
||||||
)
|
)
|
||||||
|
if operation.relative and operation.relative.strip():
|
||||||
|
result.failed_relatives.add(operation.relative)
|
||||||
blocked_path = _blocked_path(operation, exc)
|
blocked_path = _blocked_path(operation, exc)
|
||||||
if blocked_path is not None:
|
if blocked_path is not None:
|
||||||
parent_dir = blocked_path.parent
|
parent_dir = blocked_path.parent
|
||||||
@@ -202,21 +248,34 @@ class SyncEngine:
|
|||||||
progress(100, "Keine Änderungen gefunden")
|
progress(100, "Keine Änderungen gefunden")
|
||||||
|
|
||||||
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
||||||
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
|
refreshed_source, _ = self._inventory(source_root, selected_paths, ignored_paths=task.ignored_paths)
|
||||||
refreshed_target, _ = self._inventory(target_root, task.selected_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))
|
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
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}")
|
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:
|
try:
|
||||||
target_root.mkdir(parents=True, exist_ok=True)
|
target_root.mkdir(parents=True, exist_ok=True)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
|
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
|
||||||
if not target_root.is_dir():
|
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}")
|
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -226,10 +285,15 @@ class SyncEngine:
|
|||||||
progress: ProgressCallback | None = None,
|
progress: ProgressCallback | None = None,
|
||||||
label: str = "Ordner",
|
label: str = "Ordner",
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
ignored_paths: list[str] | set[str] | None = None,
|
||||||
) -> tuple[dict[str, FileRecord], set[str]]:
|
) -> tuple[dict[str, FileRecord], set[str]]:
|
||||||
files: dict[str, FileRecord] = {}
|
files: dict[str, FileRecord] = {}
|
||||||
directories: set[str] = set()
|
directories: set[str] = set()
|
||||||
selections = tuple(Path(item).parts for item in selected_paths)
|
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()
|
last_report = monotonic()
|
||||||
if progress:
|
if progress:
|
||||||
progress(0, f"{label} wird eingelesen: {root}")
|
progress(0, f"{label} wird eingelesen: {root}")
|
||||||
@@ -244,6 +308,7 @@ class SyncEngine:
|
|||||||
for name in dir_names
|
for name in dir_names
|
||||||
if _is_selected((relative_dir / name).parts, selections)
|
if _is_selected((relative_dir / name).parts, selections)
|
||||||
and not _is_directory_link(current_path / name)
|
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:
|
if progress and monotonic() - last_report >= 0.25:
|
||||||
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
|
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
|
||||||
@@ -257,6 +322,8 @@ class SyncEngine:
|
|||||||
if path.is_symlink():
|
if path.is_symlink():
|
||||||
continue
|
continue
|
||||||
relative = path.relative_to(root)
|
relative = path.relative_to(root)
|
||||||
|
if is_relative_path_ignored(str(relative), ignored_set):
|
||||||
|
continue
|
||||||
if not _is_selected(relative.parts, selections):
|
if not _is_selected(relative.parts, selections):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -314,7 +381,13 @@ class SyncEngine:
|
|||||||
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
||||||
for relative in sorted(target_dirs - source_dirs)
|
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())
|
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:
|
for relative in all_paths:
|
||||||
source = source_files.get(relative)
|
source = source_files.get(relative)
|
||||||
target = target_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)
|
||||||
@@ -175,3 +175,19 @@ def test_enqueue_all_unpauses_and_runs(coordinator: SyncCoordinator) -> None:
|
|||||||
assert not coordinator.config.settings.paused
|
assert not coordinator.config.settings.paused
|
||||||
assert len(workers) == 1
|
assert len(workers) == 1
|
||||||
assert coordinator.running_task_id == coordinator.config.tasks[0].id
|
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 result.skipped == 1
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert not (target / "ephemeral.txt").exists()
|
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.17
|
||||||
Reference in new issue
Block a user