diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8f32b..122f35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Ä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 diff --git a/pyproject.toml b/pyproject.toml index 155a04a..359390e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syncvito" -version = "1.0.16" +version = "1.0.17" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" readme = "README.md" requires-python = ">=3.11" diff --git a/src/syncvito/__init__.py b/src/syncvito/__init__.py index b97aff8..d61a83c 100644 --- a/src/syncvito/__init__.py +++ b/src/syncvito/__init__.py @@ -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", diff --git a/src/syncvito/dialogs.py b/src/syncvito/dialogs.py index ccb8a17..ba6d344 100644 --- a/src/syncvito/dialogs.py +++ b/src/syncvito/dialogs.py @@ -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)) @@ -142,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) @@ -179,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) @@ -186,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() @@ -223,7 +244,7 @@ class TaskDialog(QDialog): 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) @@ -238,6 +259,7 @@ 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() @@ -254,6 +276,7 @@ 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(), @@ -268,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.") @@ -286,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") diff --git a/src/syncvito/main_window.py b/src/syncvito/main_window.py index 74dabaf..7cf7e79 100644 --- a/src/syncvito/main_window.py +++ b/src/syncvito/main_window.py @@ -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 diff --git a/src/syncvito/models.py b/src/syncvito/models.py index aa09f91..4574c02 100644 --- a/src/syncvito/models.py +++ b/src/syncvito/models.py @@ -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 @@ -76,32 +84,51 @@ class SyncTask: 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.") + 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_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)) 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 @@ -117,6 +144,7 @@ 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)), diff --git a/src/syncvito/realtime.py b/src/syncvito/realtime.py index 10927a7..e95decb 100644 --- a/src/syncvito/realtime.py +++ b/src/syncvito/realtime.py @@ -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}")) diff --git a/src/syncvito/sync_engine.py b/src/syncvito/sync_engine.py index 461078d..7b4c347 100644 --- a/src/syncvito/sync_engine.py +++ b/src/syncvito/sync_engine.py @@ -13,7 +13,7 @@ from pathlib import Path from time import monotonic from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked -from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, _normalize_relative_path +from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncItemType, SyncTask, _normalize_relative_path from syncvito.state_store import StateStore __all__ = [ @@ -127,8 +127,10 @@ 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: @@ -136,7 +138,7 @@ class SyncEngine: self._validate_roots(source_root, target_root) source_files, source_dirs = self._inventory( source_root, - task.selected_paths, + selected_paths, progress, "Quelle", cancel_check=cancel_check, @@ -146,7 +148,7 @@ class SyncEngine: return SyncResult(paused=True) target_files, target_dirs = self._inventory( target_root, - task.selected_paths, + selected_paths, progress, "Ziel", cancel_check=cancel_check, @@ -179,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) @@ -192,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: @@ -246,8 +248,8 @@ class SyncEngine: progress(100, "Keine Änderungen gefunden") if task.direction is SyncDirection.BIDIRECTIONAL and result.successful: - refreshed_source, _ = self._inventory(source_root, task.selected_paths, ignored_paths=task.ignored_paths) - refreshed_target, _ = self._inventory(target_root, task.selected_paths, ignored_paths=task.ignored_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 diff --git a/src/syncvito/task_transfer.py b/src/syncvito/task_transfer.py new file mode 100644 index 0000000..80699e4 --- /dev/null +++ b/src/syncvito/task_transfer.py @@ -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) diff --git a/version.txt b/version.txt index b668c3b..8fc77d0 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.16 +1.0.17