264 lines
11 KiB
Markdown
264 lines
11 KiB
Markdown
|
|
# Spec: Pro-Projekt Backup-Einstellungen
|
|||
|
|
|
|||
|
|
**Datum:** 2026-03-26
|
|||
|
|
**Status:** Draft v3
|
|||
|
|
**Scope:** EngineeringSync – Backup-Konfiguration pro Projekt im Setup-Wizard und TrayApp
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Übersicht
|
|||
|
|
|
|||
|
|
Beim Sync erstellt der `SyncManager` aktuell immer eine `.bak`-Datei im gleichen Ordner wie die Zieldatei, ohne dass der User Kontrolle darüber hat. Ziel ist es, dem User pro Projekt volle Kontrolle über das Backup-Verhalten zu geben: aktivieren/deaktivieren, eigenen Backup-Ordner wählen, und eine Aufbewahrungsregel (max. N Backups pro Datei) festlegen.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Anforderungen
|
|||
|
|
|
|||
|
|
### Funktional
|
|||
|
|
- Der User kann pro Projekt Backups **aktivieren oder deaktivieren**.
|
|||
|
|
- Bei aktivierten Backups kann der User wählen zwischen:
|
|||
|
|
- **Gleicher Ordner** wie die Zieldatei (bisheriges Verhalten)
|
|||
|
|
- **Eigener Backup-Ordner** (frei wählbarer Pfad)
|
|||
|
|
- Der User kann eine **Aufbewahrungsregel** festlegen:
|
|||
|
|
- `0` = unbegrenzt (alle Backups behalten)
|
|||
|
|
- `N > 0` = maximal N Backups pro Datei; älteste werden automatisch gelöscht
|
|||
|
|
- Die Einstellungen sind konfigurierbar:
|
|||
|
|
- Im **Setup-Wizard** (neue Seite zwischen „Erstes Projekt" und „Optionen")
|
|||
|
|
- Im **TrayApp `ProjectManagementWindow`** (neue BACKUP-Sektion beim Bearbeiten)
|
|||
|
|
|
|||
|
|
### Nicht-funktional
|
|||
|
|
- Standardwerte: `BackupEnabled = true`, `BackupPath = null` (gleicher Ordner), `MaxBackupsPerFile = 0`
|
|||
|
|
- Rückwärtskompatibilität: bestehende Projekte erhalten die Standardwerte per DB-Migration
|
|||
|
|
- Backup-Dateien folgen weiterhin dem Schema: `{name}_{yyyyMMdd_HHmmss}{ext}.bak`
|
|||
|
|
- Der Timestamp im Backup-Dateinamen verwendet `DateTime.Now` (Ortszeit), um für den User lesbar zu sein. Dies ist eine bewusste Abweichung von der UTC-Konvention im Rest der Anwendung.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Architektur
|
|||
|
|
|
|||
|
|
### Ausgewählter Ansatz: Backup-Felder direkt in `ProjectConfig`
|
|||
|
|
|
|||
|
|
Drei neue Felder in der bestehenden `ProjectConfig`-Entity – keine neue Tabelle. Konsistent mit dem Muster des Projekts (alle Projekteinstellungen zentral in einer Entity).
|
|||
|
|
|
|||
|
|
**Verworfene Alternativen:**
|
|||
|
|
- Separate `ProjectBackupSettings`-Entity (1:1): Mehr Boilerplate für nur 3 Felder
|
|||
|
|
- JSON-Column: Verlust von EF-Typsicherheit, Overkill für 3 Felder
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Komponenten & Änderungen
|
|||
|
|
|
|||
|
|
### 1. Domain – `ProjectConfig`
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
public bool BackupEnabled { get; set; } = true;
|
|||
|
|
public string? BackupPath { get; set; } = null; // null = gleicher Ordner wie Zieldatei
|
|||
|
|
public int MaxBackupsPerFile { get; set; } = 0; // 0 = unbegrenzt
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. Infrastructure – EF Migration
|
|||
|
|
|
|||
|
|
- Migration: `AddBackupSettingsToProjectConfig`
|
|||
|
|
- Standardwerte per `HasDefaultValue()` in `OnModelCreating` für bestehende Rows:
|
|||
|
|
- `BackupEnabled`: `true`
|
|||
|
|
- `BackupPath`: `null`
|
|||
|
|
- `MaxBackupsPerFile`: `0`
|
|||
|
|
|
|||
|
|
### 3. Service – `SyncManager`
|
|||
|
|
|
|||
|
|
**`BackupFile()` wird von `static` zur Instanzmethode** und erhält `project` als zweiten Parameter: `BackupFile(string targetPath, ProjectConfig project)`.
|
|||
|
|
|
|||
|
|
**Ablauf in `BackupFile(targetPath, project)`:**
|
|||
|
|
1. `if (!project.BackupEnabled) return;` — kein Backup, sofort zurück
|
|||
|
|
2. `backupDir = project.BackupPath ?? Path.GetDirectoryName(targetPath)`
|
|||
|
|
3. `Directory.CreateDirectory(backupDir)` — **nur** Backup-Verzeichnis (unabhängig vom Ziel-Verzeichnis)
|
|||
|
|
4. Backup-Dateiname: `{name}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}.bak`
|
|||
|
|
5. Backup-Datei verschieben (laufwerk-übergreifend sicher):
|
|||
|
|
- Versuch: `File.Move(targetPath, backupPath)` (schnell, gleiche Volume)
|
|||
|
|
- Falls `IOException` (z.B. andere Volume, Netzlaufwerk): `File.Copy` + `File.Delete` als Fallback
|
|||
|
|
6. `if (project.MaxBackupsPerFile > 0) CleanupOldBackups(backupDir, baseName, ext, project.MaxBackupsPerFile)`
|
|||
|
|
|
|||
|
|
**`ProcessChangeAsync(change, ct)` – beide Call-Sites werden aktualisiert:**
|
|||
|
|
|
|||
|
|
Die Methode hat zwei Stellen, an denen `BackupFile` aufgerufen wird:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
// Stelle 1: Deleted-Branch (Zeile ~57)
|
|||
|
|
if (File.Exists(targetPath))
|
|||
|
|
{
|
|||
|
|
BackupFile(targetPath, project); // ← project hinzufügen
|
|||
|
|
File.Delete(targetPath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Stelle 2: Overwrite-Branch (Zeile ~69)
|
|||
|
|
if (File.Exists(targetPath))
|
|||
|
|
BackupFile(targetPath, project); // ← project hinzufügen
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!)` bleibt unverändert** (Zeile ~66 im aktuellen Code) – dies ist die Zielverzeichnis-Erstellung und ist von der Backup-Verzeichnis-Erstellung in `BackupFile()` unabhängig.
|
|||
|
|
|
|||
|
|
**Verhalten bei `ChangeType.Deleted`:**
|
|||
|
|
- `BackupEnabled = true`: Zieldatei wird **gesichert** (`.bak`), dann gelöscht. (Konsistent mit bisherigem Verhalten.)
|
|||
|
|
- `BackupEnabled = false`: Zieldatei wird **unwiederbringlich gelöscht**. Dies ist gewünschtes Verhalten – der User hat Backups explizit deaktiviert.
|
|||
|
|
|
|||
|
|
**Neue `CleanupOldBackups()`-Methode:**
|
|||
|
|
```
|
|||
|
|
CleanupOldBackups(backupDir, baseName, ext, max):
|
|||
|
|
1. Suche alle Dateien: {baseName}_*{ext}.bak im backupDir
|
|||
|
|
2. Sortiere alphabetisch aufsteigend nach Dateiname
|
|||
|
|
(Format yyyyMMdd_HHmmss ist lexikografisch geordnet → korrekte zeitliche Reihenfolge)
|
|||
|
|
NICHT nach Filesystem-Erstelldatum sortieren (File.Move behält das Original-Erstelldatum)
|
|||
|
|
3. Lösche alle Einträge über dem Limit (älteste zuerst = vorne in der sortierten Liste)
|
|||
|
|
4. Fehler beim Löschen: LogWarning, kein Abbruch
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Rename-Szenario (`ChangeType.Renamed`):**
|
|||
|
|
Backups, die unter dem alten Dateinamen angelegt wurden, werden von `CleanupOldBackups` nicht erfasst (anderer `baseName`). Diese orphaned Backups akkumulieren sich unter dem alten Namen. Dieses Verhalten ist **explizit akzeptiert** und liegt außerhalb des Scopes dieser Änderung.
|
|||
|
|
|
|||
|
|
### 4. Service – API-Modelle
|
|||
|
|
|
|||
|
|
`CreateProjectRequest` und `UpdateProjectRequest` erhalten:
|
|||
|
|
```csharp
|
|||
|
|
bool BackupEnabled = true
|
|||
|
|
string? BackupPath = null
|
|||
|
|
int MaxBackupsPerFile = 0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Validierungsregel für `BackupPath` in `ProjectsApi`:**
|
|||
|
|
`BackupPath` wird bei POST/PUT **nicht** auf Existenz geprüft. Fehlende Verzeichnisse werden beim ersten Sync per `Directory.CreateDirectory()` automatisch erzeugt. (Abweichend von `EngineeringPath`/`SimulationPath`, die auf Existenz geprüft werden.)
|
|||
|
|
|
|||
|
|
`ProjectsApi` mappt die neuen Felder beim Erstellen/Aktualisieren der `ProjectConfig`.
|
|||
|
|
|
|||
|
|
### 5. Setup-Wizard
|
|||
|
|
|
|||
|
|
**Neue Datei:** `EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml` + `.cs`
|
|||
|
|
|
|||
|
|
**`WizardState` – neue Properties:**
|
|||
|
|
```csharp
|
|||
|
|
[ObservableProperty] private bool _backupEnabled = true;
|
|||
|
|
[ObservableProperty] private bool _backupUseCustomPath = false;
|
|||
|
|
[ObservableProperty] private string _backupCustomPath = string.Empty;
|
|||
|
|
[ObservableProperty] private int _maxBackupsPerFile = 0;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`WizardViewModel` – Wizard-Schritte steigen von 7 auf 8:**
|
|||
|
|
```
|
|||
|
|
Index 0: Willkommen
|
|||
|
|
Index 1: Installation
|
|||
|
|
Index 2: Erstes Projekt
|
|||
|
|
Index 3: Backup ← NEU WizardStep("Backup", "\uE72E")
|
|||
|
|
Index 4: Optionen
|
|||
|
|
Index 5: Zusammenfassung
|
|||
|
|
Index 6: Installation
|
|||
|
|
Index 7: Fertig
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`WizardViewModel.Steps` erhält `WizardStep("Backup", "\uE72E")` an Position 3. `_pageFactories` erhält `() => new BackupOptionsPage(this)` an Position 3. `IsLastStep = Steps.Count - 2` bleibt unverändert (ergibt mit 8 Schritten korrekt Index 6 = „Zusammenfassung").
|
|||
|
|
|
|||
|
|
**`BackupOptionsPage`-Inhalt:**
|
|||
|
|
- `OptionCard` / `CheckBox`: „Backups vor dem Überschreiben aktivieren" (Standard: an)
|
|||
|
|
- Wenn aktiviert (Visibility via `BoolToVisibilityConverter`):
|
|||
|
|
- Radio: „Gleicher Ordner wie Simulationsdatei" *(Standard)*
|
|||
|
|
- Radio: „Eigener Backup-Ordner" → TextBox + Browse-Button (`FolderBrowserDialog`)
|
|||
|
|
- TextBox + Label: „Maximal ___ Backups pro Datei (0 = unbegrenzt)"
|
|||
|
|
|
|||
|
|
**`InstallerService.WriteFirstRunConfig()`** schreibt Backup-Felder als neuen `Backup`-Abschnitt in `firstrun-config.json`:
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"FirstRun": { ... },
|
|||
|
|
"Backup": {
|
|||
|
|
"BackupEnabled": true,
|
|||
|
|
"BackupPath": null,
|
|||
|
|
"MaxBackupsPerFile": 0
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`Program.cs` – `ProcessFirstRunConfigAsync()`-Funktion** (NICHT `Worker.cs` – die Verarbeitung der `firstrun-config.json` liegt als lokale Funktion in `Program.cs`, Zeile ~42):
|
|||
|
|
- Liest den neuen `Backup`-Abschnitt via `JsonDocument` (mit `TryGetProperty` für Rückwärtskompatibilität, falls `Backup`-Abschnitt fehlt → Standardwerte)
|
|||
|
|
- Setzt `BackupEnabled`, `BackupPath`, `MaxBackupsPerFile` beim Erstellen der `ProjectConfig`
|
|||
|
|
|
|||
|
|
**`SummaryPage`** zeigt:
|
|||
|
|
- `SummaryBoolRow`: „Backups aktiviert"
|
|||
|
|
- `SummaryRow` (conditional, wenn `BackupUseCustomPath`): „Backup-Ordner: {Pfad}"
|
|||
|
|
- `SummaryRow` (conditional, wenn `MaxBackupsPerFile > 0`): „Max. Backups pro Datei: {N}"
|
|||
|
|
|
|||
|
|
### 6. TrayApp – `ProjectManagementWindow`
|
|||
|
|
|
|||
|
|
**`ProjectManagementViewModel` – neue Properties:**
|
|||
|
|
```csharp
|
|||
|
|
[ObservableProperty] private bool _backupEnabled;
|
|||
|
|
[ObservableProperty] private bool _backupUseCustomPath;
|
|||
|
|
[ObservableProperty] private string _backupCustomPath = string.Empty;
|
|||
|
|
[ObservableProperty] private int _maxBackupsPerFile;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**XAML – neue BACKUP-Sektion** im Bearbeitungs-Formular (analog zu bestehenden Sektionen):
|
|||
|
|
```
|
|||
|
|
BACKUP
|
|||
|
|
├── CheckBox: „Backups vor dem Überschreiben erstellen"
|
|||
|
|
├── (sichtbar wenn aktiviert, via BoolToVisibilityConverter):
|
|||
|
|
│ ├── RadioButton: „Gleicher Ordner wie Simulationsdatei"
|
|||
|
|
│ ├── RadioButton: „Eigener Ordner:" + TextBox + [Durchsuchen]
|
|||
|
|
│ └── TextBox: „Maximal ___ Backups pro Datei (0 = unbegrenzt)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**TrayApp `ApiClient` – DTOs:**
|
|||
|
|
`CreateProjectDto` und `UpdateProjectDto` in `ApiClient.cs` erhalten:
|
|||
|
|
```csharp
|
|||
|
|
bool BackupEnabled = true
|
|||
|
|
string? BackupPath = null
|
|||
|
|
int MaxBackupsPerFile = 0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`ApiClient.CreateProjectAsync()` und `UpdateProjectAsync()` senden die Backup-Felder mit.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Datenfluss
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[Wizard BackupOptionsPage]
|
|||
|
|
↓ WizardState
|
|||
|
|
[InstallerService] → firstrun-config.json (Backup-Abschnitt)
|
|||
|
|
↓
|
|||
|
|
[Program.cs ProcessFirstRunConfigAsync()] → ProjectConfig (mit Backup-Feldern) in SQLite
|
|||
|
|
|
|||
|
|
[TrayApp ProjectManagementWindow]
|
|||
|
|
↓ PUT /api/projects/{id} (mit Backup-Feldern in UpdateProjectDto)
|
|||
|
|
[ProjectsApi] → ProjectConfig in SQLite
|
|||
|
|
↓
|
|||
|
|
[SyncManager.ProcessChangeAsync]
|
|||
|
|
├─ Directory.CreateDirectory(targetDir) ← Zielverzeichnis
|
|||
|
|
└─ BackupFile(targetPath, project)
|
|||
|
|
├─ if !BackupEnabled → return
|
|||
|
|
├─ backupDir = BackupPath ?? targetDir
|
|||
|
|
├─ Directory.CreateDirectory(backupDir) ← Backup-Verzeichnis
|
|||
|
|
├─ File.Move(targetPath, backupPath)
|
|||
|
|
└─ CleanupOldBackups() wenn MaxBackupsPerFile > 0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Fehlerbehandlung
|
|||
|
|
|
|||
|
|
| Szenario | Verhalten |
|
|||
|
|
|---|---|
|
|||
|
|
| Backup-Ordner existiert nicht | `Directory.CreateDirectory()` in `BackupFile()` erzeugt ihn beim ersten Sync |
|
|||
|
|
| Backup-Ordner nicht beschreibbar | Exception → von `ProcessChangeAsync` gefangen → `failed`-Zähler |
|
|||
|
|
| Cleanup schlägt fehl | `logger.LogWarning`, kein Sync-Abbruch |
|
|||
|
|
| `BackupEnabled=false` + `Deleted` | Zieldatei wird unwiederbringlich gelöscht (gewünscht) |
|
|||
|
|
| `BackupEnabled=true` + `Deleted` | Backup wird erstellt, dann Zieldatei gelöscht (bisheriges Verhalten) |
|
|||
|
|
| `BackupPath` bei POST/PUT nicht existent | Kein API-Fehler; Verzeichnis wird beim ersten Sync erstellt |
|
|||
|
|
| Rename-Szenario: alte Backups | Akkumulieren unter altem Dateinamen; kein automatisches Cleanup (out of scope) |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Nicht im Scope
|
|||
|
|
|
|||
|
|
- Backups wiederherstellen (Restore-UI)
|
|||
|
|
- Backup-Komprimierung (.zip)
|
|||
|
|
- Backup-Statistiken / Speicherplatz-Anzeige
|
|||
|
|
- Globale (projektübergreifende) Backup-Einstellungen
|
|||
|
|
- Cleanup von Backups unter altem Dateinamen nach Rename
|