Release SyncVito 1.0.18

This commit is contained in:
Davito committed 2026-10-02 07:08:29 +02:00
1 parent f83a85ed40
commit 2c31f6eb85
4 files changed
+119 -5

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Ä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 ## 1.0.17 – 2026-10-01
### Neu ### Neu
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "syncvito" name = "syncvito"
version = "1.0.17" 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"
+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)
+1 -1
View File
@@ -1 +1 @@
1.0.17 1.0.18