3 Commits
Author SHA1 Message Date
Davito 2c31f6eb85 Release SyncVito 1.0.18 2026-10-02 07:08:29 +02:00
Davito f83a85ed40 Release SyncVito 1.0.17 2026-10-01 16:58:50 +02:00
Davito 8218cfd9d5 Release SyncVito 1.0.16 2026-10-01 14:53:08 +02:00
11 changed files with 577 additions and 36 deletions

No files matched your search

+19
View File
@@ -1,5 +1,24 @@
# Änderungsprotokoll # Änderungsprotokoll
## 1.0.18 – 2026-10-02
### Behoben
- **Automatische Aufgabenwiederherstellung:** Wenn die Konfiguration fehlt oder leer ist, stellt SyncVito Aufgaben aus der neuesten lesbaren Sicherung wieder her. Die Sicherung bleibt erhalten; bereits geprüfte Sicherungen werden markiert, damit absichtlich geleerte Aufgabenlisten nicht später erneut erscheinen.
## 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 ## 1.0.15 – 2026-09-29
### Neu ### Neu
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "syncvito" name = "syncvito"
version = "1.0.15" version = "1.0.18"
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"
+2
View File
@@ -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",
+111 -3
View File
@@ -30,11 +30,12 @@ class ConfigStore:
def __init__(self, path: Path | None = None) -> None: def __init__(self, path: Path | None = None) -> None:
self.path = path or default_data_directory() / "config.json" self.path = path or default_data_directory() / "config.json"
self._recovery_marker = self.path.with_name("config.recovery.json")
def load(self) -> AppConfig: def load(self) -> AppConfig:
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert.""" """Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
if not self.path.exists(): if not self.path.exists():
return AppConfig() return self._recover_from_backup(None) or AppConfig()
content: str | None = None content: str | None = None
last_io_exc: OSError | None = None last_io_exc: OSError | None = None
@@ -58,25 +59,132 @@ class ConfigStore:
except (ValueError, TypeError, json.JSONDecodeError) as exc: except (ValueError, TypeError, json.JSONDecodeError) as exc:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json") recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
backup_created = False
try: try:
self.path.replace(recovery_path) self.path.replace(recovery_path)
backup_created = True
except OSError: except OSError:
recovery_path = self.path recovery_path = self.path
if backup_created:
recovered = self._recover_from_backup(None)
if recovered is not None:
return recovered
raise ConfigError( raise ConfigError(
f"Die Konfiguration war syntaktisch beschädigt ({exc}). Sicherung: {recovery_path}" f"Die Konfiguration war syntaktisch beschädigt ({exc}). Sicherung: {recovery_path}"
) from exc ) from exc
try: try:
return AppConfig.from_dict(raw) config = AppConfig.from_dict(raw)
except Exception as exc: except Exception as exc:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = self.path.with_name(f"config.bak-{timestamp}.json") backup_path = self.path.with_name(f"config.bak-{timestamp}.json")
with suppress(OSError): try:
shutil.copy2(self.path, backup_path) shutil.copy2(self.path, backup_path)
except OSError:
pass
else:
recovered = self._recover_from_backup(None)
if recovered is not None:
return recovered
raise ConfigError( raise ConfigError(
f"Die Konfigurationsdaten konnten nicht verarbeitet werden ({exc}). Sicherungskopie: {backup_path}" f"Die Konfigurationsdaten konnten nicht verarbeitet werden ({exc}). Sicherungskopie: {backup_path}"
) from exc ) from exc
if config.tasks:
self._mark_backups_handled(self._recovery_backups())
return config
return self._recover_from_backup(config) or config
def _recovery_backups(self) -> list[Path]:
"""Findet mögliche Konfigurationssicherungen in absteigender Zeitfolge."""
try:
backups = [
*self.path.parent.glob("config.defekt-*.json"),
*self.path.parent.glob("config.bak-*.json"),
]
except OSError:
return []
return sorted(
backups,
key=lambda path: path.stem.removeprefix("config.defekt-").removeprefix("config.bak-"),
reverse=True,
)
def _latest_recovery_backup(self) -> tuple[Path, AppConfig] | None:
"""Liest die neueste syntaktisch gültige Sicherung."""
for backup_path in self._recovery_backups():
try:
raw = json.loads(backup_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
continue
return backup_path, AppConfig.from_dict(raw)
except (OSError, UnicodeError, ValueError, TypeError, AttributeError, OverflowError):
continue
return None
def _handled_backup_names(self) -> set[str]:
"""Lädt die Namen bereits geprüfter Sicherungen."""
try:
raw = json.loads(self._recovery_marker.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return set()
if not isinstance(raw, dict):
return set()
names = raw.get("handled_backups", [])
if not isinstance(names, list):
return set()
return {name for name in names if isinstance(name, str) and Path(name).name == name}
def _mark_backups_handled(self, backups: list[Path]) -> None:
"""Speichert, welche Sicherungen nicht erneut automatisch geladen werden sollen."""
if not backups:
return
handled_names = self._handled_backup_names()
handled_names.update(path.name for path in backups)
temporary = self._recovery_marker.with_suffix(".json.tmp")
try:
temporary.write_text(
json.dumps({"handled_backups": sorted(handled_names)}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
os.replace(temporary, self._recovery_marker)
except OSError:
with suppress(OSError):
temporary.unlink(missing_ok=True)
def _recover_from_backup(self, current_config: AppConfig | None) -> AppConfig | None:
"""Stellt Aufgaben aus der neuesten Sicherung wieder her, wenn die Liste leer ist."""
latest = self._latest_recovery_backup()
if latest is None:
return None
backup_path, backup_config = latest
handled_names = self._handled_backup_names()
if backup_path.name in handled_names:
return None
if not backup_config.tasks:
self._mark_backups_handled(self._recovery_backups())
return None
if current_config is None:
recovered_config = backup_config
else:
snapshot_path = self.path.with_name(
f"config.vor-wiederherstellung-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.json"
)
if self.path.exists():
try:
shutil.copy2(self.path, snapshot_path)
except OSError:
return None
recovered_config = AppConfig(tasks=backup_config.tasks, settings=current_config.settings)
try:
self.save(recovered_config)
except ConfigError:
return recovered_config
self._mark_backups_handled(self._recovery_backups())
return recovered_config
def save(self, config: AppConfig) -> None: def save(self, config: AppConfig) -> None:
"""Speichert die Konfiguration atomar und UTF-8-kodiert.""" """Speichert die Konfiguration atomar und UTF-8-kodiert."""
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
+38 -4
View File
@@ -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))
@@ -142,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)
@@ -179,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)
@@ -186,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()
@@ -223,7 +244,7 @@ class TaskDialog(QDialog):
behavior_layout.addRow("Gesperrte Dateien", ignored_row) 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)
@@ -238,6 +259,7 @@ 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()
@@ -254,6 +276,7 @@ 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(),
@@ -268,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.")
@@ -286,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
View File
@@ -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
+34 -6
View File
@@ -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
@@ -76,32 +84,51 @@ class SyncTask:
ignored_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()}) 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
@@ -117,6 +144,7 @@ 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)),
+32 -7
View File
@@ -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
paths = [Path(task.source)] if task.item_type is SyncItemType.FILE:
if task.direction is SyncDirection.BIDIRECTIONAL: source_file = Path(task.source)
paths.append(Path(task.target)) 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: 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}"))
+39 -12
View File
@@ -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, _normalize_relative_path 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__ = [
@@ -32,6 +32,18 @@ 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: def is_relative_path_ignored(relative: str, ignored_set: set[str]) -> bool:
"""Prüft, ob ein relativer Pfad oder eines seiner Elternverzeichnisse ignoriert wird.""" """Prüft, ob ein relativer Pfad oder eines seiner Elternverzeichnisse ignoriert wird."""
if not ignored_set or not relative.strip(): if not ignored_set or not relative.strip():
@@ -115,8 +127,10 @@ 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:
@@ -124,7 +138,7 @@ class SyncEngine:
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, source_root,
task.selected_paths, selected_paths,
progress, progress,
"Quelle", "Quelle",
cancel_check=cancel_check, cancel_check=cancel_check,
@@ -134,7 +148,7 @@ class SyncEngine:
return SyncResult(paused=True) return SyncResult(paused=True)
target_files, target_dirs = self._inventory( target_files, target_dirs = self._inventory(
target_root, target_root,
task.selected_paths, selected_paths,
progress, progress,
"Ziel", "Ziel",
cancel_check=cancel_check, cancel_check=cancel_check,
@@ -167,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)
@@ -180,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:
@@ -234,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, ignored_paths=task.ignored_paths) refreshed_source, _ = self._inventory(source_root, selected_paths, ignored_paths=task.ignored_paths)
refreshed_target, _ = self._inventory(target_root, task.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)) 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
+155
View File
@@ -0,0 +1,155 @@
"""Versionierter JSON-Import und -Export für Synchronisationsaufgaben."""
from __future__ import annotations
import json
import os
import tempfile
from contextlib import suppress
from pathlib import Path
from typing import Any
from uuid import uuid4
from syncvito.models import SyncTask
__all__ = ["TaskTransferError", "export_task", "export_tasks", "import_task", "import_tasks"]
_FORMAT_VERSION = 1
_SINGLE_TASK_FORMAT = "syncvito.task"
_TASKS_FORMAT = "syncvito.tasks"
_TASK_FIELDS = {
"id",
"name",
"source",
"target",
"item_type",
"direction",
"trigger",
"delete_mode",
"conflict_mode",
"interval_minutes",
"selected_paths",
"ignored_paths",
"enabled",
"last_run",
}
class TaskTransferError(ValueError):
"""Fehlerhafte oder nicht lesbare Aufgaben-Transferdatei."""
def export_task(path: Path, task: SyncTask) -> None:
"""Schreibt eine einzelne Aufgabe mit allen Einstellungen als JSON."""
_write_payload(
path,
{"format": _SINGLE_TASK_FORMAT, "format_version": _FORMAT_VERSION, "task": task.to_dict()},
)
def export_tasks(path: Path, tasks: list[SyncTask]) -> None:
"""Schreibt alle Aufgaben mit ihren Einstellungen und Pfaden als JSON."""
_write_payload(
path,
{
"format": _TASKS_FORMAT,
"format_version": _FORMAT_VERSION,
"tasks": [task.to_dict() for task in tasks],
},
)
def import_task(path: Path) -> SyncTask:
"""Lädt eine einzelne Aufgabe und vergibt eine neue lokale Kennung."""
payload = _read_payload(path, _SINGLE_TASK_FORMAT)
raw_task = payload.get("task")
task = _parse_task(raw_task)
task.id = str(uuid4())
task.last_run = None
return task
def import_tasks(path: Path) -> list[SyncTask]:
"""Lädt und validiert vollständig einen Export der Aufgabenliste."""
payload = _read_payload(path, _TASKS_FORMAT)
raw_tasks = payload.get("tasks")
if not isinstance(raw_tasks, list):
raise TaskTransferError("Die Aufgabenliste in der Datei ist ungültig.")
tasks = [_parse_task(raw_task) for raw_task in raw_tasks]
task_ids = [task.id for task in tasks]
if len(task_ids) != len(set(task_ids)):
raise TaskTransferError("Die Datei enthält doppelte Aufgabenkennungen.")
return tasks
def _read_payload(path: Path, expected_format: str) -> dict[str, Any]:
try:
raw: Any = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TaskTransferError(f"Die Datei konnte nicht gelesen werden: {exc}") from exc
if not isinstance(raw, dict):
raise TaskTransferError("Die Datei enthält kein gültiges SyncVito-JSON-Objekt.")
if raw.get("format") != expected_format:
raise TaskTransferError("Der Dateityp passt nicht zu dieser Importaktion.")
if type(raw.get("format_version")) is not int or raw["format_version"] != _FORMAT_VERSION:
raise TaskTransferError("Diese Version des Aufgabenformats wird nicht unterstützt.")
return raw
def _parse_task(raw_task: Any) -> SyncTask:
if not isinstance(raw_task, dict):
raise TaskTransferError("Eine Aufgabe in der Datei ist kein gültiges JSON-Objekt.")
missing_fields = (_TASK_FIELDS - {"item_type"}).difference(raw_task)
if missing_fields:
fields = ", ".join(sorted(missing_fields))
raise TaskTransferError(f"In einer Aufgabe fehlen erforderliche Daten: {fields}.")
for field in ("id", "name", "source", "target", "direction", "trigger", "delete_mode", "conflict_mode"):
if not isinstance(raw_task[field], str):
raise TaskTransferError(f"Das Feld „{field}“ einer Aufgabe muss Text enthalten.")
if "item_type" in raw_task and not isinstance(raw_task["item_type"], str):
raise TaskTransferError("Der Aufgabentyp ist ungültig.")
if not raw_task["id"].strip():
raise TaskTransferError("Eine Aufgabe hat keine gültige Kennung.")
if type(raw_task["interval_minutes"]) is not int:
raise TaskTransferError("Das Intervall einer Aufgabe muss eine ganze Zahl sein.")
if type(raw_task["enabled"]) is not bool:
raise TaskTransferError("Der Aktivierungszustand einer Aufgabe ist ungültig.")
for field in ("selected_paths", "ignored_paths"):
if not isinstance(raw_task[field], list) or any(not isinstance(item, str) for item in raw_task[field]):
raise TaskTransferError(f"Die Pfadliste „{field}“ einer Aufgabe ist ungültig.")
if raw_task["last_run"] is not None and not isinstance(raw_task["last_run"], str):
raise TaskTransferError("Der letzte Lauf einer Aufgabe ist ungültig.")
try:
task = SyncTask.from_dict(raw_task)
task.validate()
except (TypeError, ValueError, OSError) as exc:
raise TaskTransferError(f"Eine Aufgabe enthält ungültige Einstellungen oder Pfade: {exc}") from exc
return task
def _write_payload(path: Path, payload: dict[str, Any]) -> None:
target = Path(path)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_path = Path(temporary_file.name)
json.dump(payload, temporary_file, ensure_ascii=False, indent=2)
temporary_file.write("\n")
os.replace(temporary_path, target)
except (OSError, TypeError, ValueError) as exc:
raise TaskTransferError(f"Die Exportdatei konnte nicht gespeichert werden: {exc}") from exc
finally:
if temporary_path is not None:
with suppress(OSError):
temporary_path.unlink(missing_ok=True)
+1 -1
View File
@@ -1 +1 @@
1.0.15 1.0.18