Release SyncVito 1.0.18
This commit is contained in:
1 parent
f83a85ed40
commit
2c31f6eb85
4 files changed
+119
-5
No files matched your search
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
1.0.17
|
1.0.18
|
||||||
Reference in new issue
Block a user