fix(files): report success cancellation and errors with safe next actions (U02)

This commit is contained in:
2026-11-02 06:23:59 +01:00
parent a3c1bee09e
commit 5e0ad197f0
15 changed files with 288 additions and 200 deletions
+4
View File
@@ -19,3 +19,7 @@ Die Sicherung enthält Konten, Anfangsbestände, Buchungen einschließlich gelö
Wiederherstellen akzeptiert nur vollständige Datenbanken derselben Schemaversion. Vorher wird die ausgewählte Datei als konsistenter SQLite-Snapshot zwischengespeichert und auf Integrität, Fremdschlüssel, Migrationen und alle Modellspalten geprüft. Die bestehende Datenbank erhält eine eindeutige `.before-restore-…bak` daneben. Diese Rückfallsicherungen werden nicht automatisch gelöscht; nach geprüfter Wiederherstellung bewusst archivieren. Anwendungsschreibzugriffe warten während der Wiederherstellung, SQLite koordiniert offene Leser. Der Austausch erfolgt mit der SQLite-Backup-API im bestehenden Datenbankhandle. Keine automatische Sicherung beim Beenden. Wiederherstellen akzeptiert nur vollständige Datenbanken derselben Schemaversion. Vorher wird die ausgewählte Datei als konsistenter SQLite-Snapshot zwischengespeichert und auf Integrität, Fremdschlüssel, Migrationen und alle Modellspalten geprüft. Die bestehende Datenbank erhält eine eindeutige `.before-restore-…bak` daneben. Diese Rückfallsicherungen werden nicht automatisch gelöscht; nach geprüfter Wiederherstellung bewusst archivieren. Anwendungsschreibzugriffe warten während der Wiederherstellung, SQLite koordiniert offene Leser. Der Austausch erfolgt mit der SQLite-Backup-API im bestehenden Datenbankhandle. Keine automatische Sicherung beim Beenden.
Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buchungsstand und Rückfallsicherung, beschädigte/fremde SQLite-Datei und fehlende Quelle. Kein echter Vereinsdatenbestand wurde zurückgespielt. Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buchungsstand und Rückfallsicherung, beschädigte/fremde SQLite-Datei und fehlende Quelle. Kein echter Vereinsdatenbestand wurde zurückgespielt.
## U02 – Dateiaktionen
Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen Details, Speicherpfad und „Ordner öffnen“. Abbruch ist neutral. Dateiaktionen sperren erneutes Auslösen; Fehler beim Erzeugen oder Öffnen einer PDF bleiben in der aktuellen Ansicht sichtbar. Native Dateidialoge melden Start-/Prozessfehler und erhalten Unicode-Pfade. Gespeichert wird über eine temporäre Datei, damit Schreibfehler eine bestehende Datei nicht verstümmeln. Prüfung: 18/18 Debug-Tests bestanden.
@@ -0,0 +1,40 @@
@inject IFileSaveService FileSaveService
@if (Result != null)
{
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
<p>@Result.Message</p>
@if (Result.Path != null)
{
<p class="text-break">@Result.Path</p>
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
}
@if (Result.Details != null)
{
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
}
@if (openResult != null)
{
<p role="alert">@openResult.Message</p>
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
}
</div>
}
@code {
[Parameter] public FileOperationResult? Result { get; set; }
private FileOperationResult? openResult;
private FileOperationResult? previous;
private bool busy;
protected override void OnParametersSet()
{
if (previous != Result) { openResult = null; previous = Result; }
}
private async Task OpenFolder()
{
if (busy || Result?.Path == null) return;
busy = true;
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
finally { busy = false; }
}
}
@@ -7,6 +7,9 @@
@inject NavigationManager Navigation @inject NavigationManager Navigation
<div class="container-fluid"> <div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
@if (account == null) @if (account == null)
{ {
<div class="text-center py-5"> <div class="text-center py-5">
@@ -35,7 +38,7 @@
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true"> <button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
<i class="bi bi-arrow-left-right"></i> Umbuchung <i class="bi bi-arrow-left-right"></i> Umbuchung
</button> </button>
<button class="btn btn-dark btn-nav" @onclick="HandleExport"> <button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF <i class="bi bi-file-earmark-pdf"></i> PDF
</button> </button>
| |
@@ -32,6 +32,8 @@ public partial class AccountDetail
private string? confirmRestoreEntryTitle; private string? confirmRestoreEntryTitle;
private string? savedPdfPath; private string? savedPdfPath;
private FileOperationResult? fileResult;
private bool fileBusy;
private EntryDto? editingEntry; private EntryDto? editingEntry;
private EntryDto? editingTransferEntry; private EntryDto? editingTransferEntry;
@@ -177,20 +179,40 @@ public partial class AccountDetail
} }
private async Task HandleExport() private async Task HandleExport()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{ {
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear); var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt"; var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf"); fileResult = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
} }
private async Task HandleOpenSavedPdf() private async Task HandleOpenSavedPdf()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{ {
if (!string.IsNullOrWhiteSpace(savedPdfPath)) if (!string.IsNullOrWhiteSpace(savedPdfPath))
{ {
await FileSaveService.OpenFileAsync(savedPdfPath); fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
} }
savedPdfPath = null; savedPdfPath = null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
} }
private void CancelOpenSavedPdf() private void CancelOpenSavedPdf()
+6 -9
View File
@@ -8,6 +8,9 @@
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="container-fluid"> <div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;"> <div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
@@ -25,14 +28,14 @@
<i class="bi bi-plus-lg"></i> Neues Konto <i class="bi bi-plus-lg"></i> Neues Konto
</button> </button>
| |
<button class="btn-nav btn-success" @onclick="HandleBackupAsync"> <button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
<i class="bi bi-save"></i> Backup <i class="bi bi-save"></i> Backup
</button> </button>
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync"> <button class="btn-nav btn-warning" @onclick="HandleRestoreAsync" disabled="@fileBusy">
<i class="bi bi-arrow-counterclockwise"></i> Restore <i class="bi bi-arrow-counterclockwise"></i> Restore
</button> </button>
| |
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync"> <button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF <i class="bi bi-file-earmark-pdf"></i> PDF
</button> </button>
| |
@@ -54,12 +57,6 @@
</div> </div>
} }
@if (!string.IsNullOrWhiteSpace(operationMessage))
{
<div class="alert @operationMessageClass mb-3" role="alert">
@operationMessage
</div>
}
@if (accounts == null) @if (accounts == null)
{ {
+41 -22
View File
@@ -17,9 +17,9 @@ public partial class Dashboard
private int? selectedYear = DateTime.Now.Year; private int? selectedYear = DateTime.Now.Year;
private List<int> availableYears = []; private List<int> availableYears = [];
private string clubName = string.Empty; private string clubName = string.Empty;
private string? operationMessage;
private string operationMessageClass = "alert-info";
private string? savedPdfPath; private string? savedPdfPath;
private FileOperationResult? fileResult;
private bool fileBusy;
private string currentTheme = "light"; private string currentTheme = "light";
#endregion #endregion
@@ -100,11 +100,16 @@ public partial class Dashboard
private async Task HandleBackupAsync() private async Task HandleBackupAsync()
{ {
var message = await BackupService.CreateBackupAsync(); if (fileBusy) return;
if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase)) fileBusy = true;
fileResult = null;
try
{ {
SetOperationMessage(message, false); fileResult = await BackupService.CreateBackupAsync();
} }
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
} }
private Task HandleRestoreAsync() private Task HandleRestoreAsync()
@@ -119,26 +124,37 @@ public partial class Dashboard
} }
private async Task HandleConfirmRestore() private async Task HandleConfirmRestore()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{ {
showRestoreConfirm = false; showRestoreConfirm = false;
var message = await BackupService.RestoreBackupAsync(); fileResult = await BackupService.RestoreBackupAsync();
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase); if (fileResult.Status == FileOperationStatus.Success) await LoadAll();
if (isSuccess)
{
NavigationManager.NavigateTo("/", forceLoad: true);
}
else
{
SetOperationMessage(message, isSuccess);
} }
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
} }
private async Task HandleDashboardExportAsync() private async Task HandleDashboardExportAsync()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{ {
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear); var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt"; var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf"); fileResult = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
} }
private void OpenYearFilterDialog() private void OpenYearFilterDialog()
@@ -159,13 +175,22 @@ public partial class Dashboard
} }
private async Task HandleOpenSavedPdf() private async Task HandleOpenSavedPdf()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{ {
if (!string.IsNullOrWhiteSpace(savedPdfPath)) if (!string.IsNullOrWhiteSpace(savedPdfPath))
{ {
await FileSaveService.OpenFileAsync(savedPdfPath); fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
} }
savedPdfPath = null; savedPdfPath = null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
} }
private void CancelOpenSavedPdf() private void CancelOpenSavedPdf()
@@ -177,12 +202,6 @@ public partial class Dashboard
#region Helpers #region Helpers
private void SetOperationMessage(string message, bool success)
{
operationMessage = message;
operationMessageClass = success ? "alert-success" : "alert-danger";
}
private static string FormatCurrency(decimal amount) private static string FormatCurrency(decimal amount)
{ {
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €"; return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
@@ -1,7 +1,9 @@
using Duempelkas.App.Services.Models;
namespace Duempelkas.App.Services; namespace Duempelkas.App.Services;
public interface IBackupService public interface IBackupService
{ {
Task<string> CreateBackupAsync(); Task<FileOperationResult> CreateBackupAsync();
Task<string> RestoreBackupAsync(); Task<FileOperationResult> RestoreBackupAsync();
} }
@@ -0,0 +1,7 @@
namespace Duempelkas.App.Services;
public interface IFileDialogService
{
Task<string?> SaveAsync(string suggestedFileName);
Task<string?> OpenBackupAsync();
}
@@ -1,7 +1,10 @@
using Duempelkas.App.Services.Models;
namespace Duempelkas.App.Services; namespace Duempelkas.App.Services;
public interface IFileSaveService public interface IFileSaveService
{ {
Task<string?> SaveFileAsync(byte[] content, string suggestedFileName); Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName);
Task OpenFileAsync(string filePath); Task<FileOperationResult> OpenFileAsync(string filePath);
Task<FileOperationResult> OpenFolderAsync(string filePath);
} }
@@ -0,0 +1,10 @@
namespace Duempelkas.App.Services.Models;
public enum FileOperationStatus { Success, Cancelled, Error }
public sealed record FileOperationResult(FileOperationStatus Status, string Message, string? Path = null, string? Details = null)
{
public static FileOperationResult Cancelled() => new(FileOperationStatus.Cancelled, "Vorgang abgebrochen. Es wurde nichts geändert.");
public static FileOperationResult Error(Exception error) => new(FileOperationStatus.Error,
"Die Dateiaktion konnte nicht abgeschlossen werden. Bitte den Speicherort und die Zugriffsrechte prüfen oder einen anderen Speicherort wählen.", Details: error.Message);
}
@@ -20,6 +20,7 @@ public static class DependencyInjection
services.AddScoped<IEntryService, EntryService>(); services.AddScoped<IEntryService, EntryService>();
services.AddScoped<IBalanceQueryService, BalanceQueryService>(); services.AddScoped<IBalanceQueryService, BalanceQueryService>();
services.AddScoped<IPdfStatementService, PdfStatementService>(); services.AddScoped<IPdfStatementService, PdfStatementService>();
services.AddSingleton<IFileDialogService, FileDialogService>();
services.AddScoped<IFileSaveService, FileSaveService>(); services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>(); services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>(); services.AddSingleton<ISettingsService, SettingsService>();
@@ -1,3 +1,4 @@
using Duempelkas.App.Services.Models;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Duempelkas.App.Services; using Duempelkas.App.Services;
@@ -9,30 +10,35 @@ public class BackupService : IBackupService
{ {
private readonly DatabaseLocation location; private readonly DatabaseLocation location;
public BackupService(DatabaseLocation location) => this.location = location; private readonly IFileDialogService dialogs;
public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null)
{
this.location = location;
this.dialogs = dialogs ?? new FileDialogService();
}
public async Task<string> CreateBackupAsync() public async Task<FileOperationResult> CreateBackupAsync()
{ {
try try
{ {
var path = await ShowSaveDialogAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak"); var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
if (string.IsNullOrWhiteSpace(path)) return "Sicherung wurde abgebrochen."; if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
await CreateBackupToAsync(path); await CreateBackupToAsync(path);
return $"Sicherung gespeichert: {path}"; return new(FileOperationStatus.Success, "Sicherung gespeichert.", path);
} }
catch (Exception ex) { return $"Sicherung fehlgeschlagen: {ex.Message}"; } catch (Exception ex) { return FileOperationResult.Error(ex); }
} }
public async Task<string> RestoreBackupAsync() public async Task<FileOperationResult> RestoreBackupAsync()
{ {
try try
{ {
var path = await ShowOpenDialogAsync(); var path = await dialogs.OpenBackupAsync();
if (string.IsNullOrWhiteSpace(path)) return "Wiederherstellung wurde abgebrochen."; if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
var fallback = await RestoreFromAsync(path); var fallback = await RestoreFromAsync(path);
return $"Wiederherstellung erfolgreich. Rückfallsicherung: {fallback}"; return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback);
} }
catch (Exception ex) { return $"Wiederherstellung fehlgeschlagen: {ex.Message}"; } catch (Exception ex) { return FileOperationResult.Error(ex); }
} }
public async Task CreateBackupToAsync(string path) public async Task CreateBackupToAsync(string path)
@@ -124,74 +130,4 @@ public class BackupService : IBackupService
_ = db.TransferLinks.ToArray(); _ = db.TransferLinks.ToArray();
} }
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
}
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.DefaultExt = 'bak'
$dialog.AddExtension = $true
$dialog.OverwritePrompt = $true
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Backup speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
return RunPowerShellDialog(script);
});
}
private static Task<string?> ShowOpenDialogAsync()
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}
var script = @"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.Multiselect = $false
$dialog.Title = 'Backup auswählen'
if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
";
return RunPowerShellDialog(script);
});
}
private static string? RunPowerShellDialog(string script)
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"") }\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null)
{
return null;
}
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return string.IsNullOrWhiteSpace(result) ? null : result;
}
} }
@@ -0,0 +1,44 @@
using System.Diagnostics;
using System.Text;
using Duempelkas.App.Services;
namespace Duempelkas.Infrastructure.Services;
public sealed class FileDialogService : IFileDialogService
{
public Task<string?> SaveAsync(string suggestedFileName) => ShowAsync(suggestedFileName);
public Task<string?> OpenBackupAsync() => ShowAsync(null);
private static Task<string?> ShowAsync(string? suggestedFileName) => Task.Run(() =>
{
if (!OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("Der Dateidialog ist auf diesem System nicht verfügbar.");
var save = suggestedFileName != null;
var extension = save ? (Path.GetExtension(suggestedFileName) ?? "") : ".bak";
var script = $$"""
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.{{(save ? "SaveFileDialog" : "OpenFileDialog")}}
$dialog.Filter = 'Dateien (*{{extension.Replace("'", "''")}})|*{{extension.Replace("'", "''")}}'
$dialog.Title = '{{(save ? "Datei speichern" : "Sicherung auswählen")}}'
{{(save ? "$dialog.OverwritePrompt = $true\n$dialog.AddExtension = $true\n$dialog.DefaultExt = '" + extension.TrimStart('.').Replace("'", "''") + "'\n$dialog.FileName = '" + suggestedFileName!.Replace("'", "''") + "'" : "$dialog.Multiselect = $false")}}
try { if ($dialog.ShowDialog() -eq 'OK') { [Console]::Write($dialog.FileName) } } finally { $dialog.Dispose() }
""";
var start = new ProcessStartInfo("powershell.exe")
{
RedirectStandardOutput = true, RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8, UseShellExecute = false, CreateNoWindow = true
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-STA");
start.ArgumentList.Add("-EncodedCommand");
start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
using var process = Process.Start(start) ?? throw new IOException("Der Dateidialog konnte nicht gestartet werden.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0) throw new IOException("Der Dateidialog konnte nicht geöffnet werden. " + error);
return string.IsNullOrWhiteSpace(output) ? null : output;
});
}
@@ -1,90 +1,49 @@
using Duempelkas.App.Services.Models;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Duempelkas.App.Services; using Duempelkas.App.Services;
namespace Duempelkas.Infrastructure.Services; namespace Duempelkas.Infrastructure.Services;
public class FileSaveService : IFileSaveService public class FileSaveService(IFileDialogService dialogs) : IFileSaveService
{ {
public async Task<string?> SaveFileAsync(byte[] content, string suggestedFileName) public async Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName)
{ {
var filePath = await ShowSaveDialogAsync(suggestedFileName); string? staged = null;
if (filePath == null) return null; try
{
await File.WriteAllBytesAsync(filePath, content); var path = await dialogs.SaveAsync(suggestedFileName);
return filePath; if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
staged = path + $".{Guid.NewGuid():N}.tmp";
await File.WriteAllBytesAsync(staged, content);
File.Move(staged, path, overwrite: true);
return new(FileOperationStatus.Success, "Datei gespeichert.", path);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
finally { try { if (staged != null && File.Exists(staged)) File.Delete(staged); } catch { } }
} }
public Task OpenFileAsync(string filePath) public Task<FileOperationResult> OpenFileAsync(string filePath) => OpenAsync(filePath, false);
{ public Task<FileOperationResult> OpenFolderAsync(string filePath) => OpenAsync(filePath, true);
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
return Task.CompletedTask;
}
return Task.Run(() => private static Task<FileOperationResult> OpenAsync(string filePath, bool folder) => Task.Run(() =>
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) try
{ {
Process.Start(new ProcessStartInfo var path = folder ? Path.GetDirectoryName(Path.GetFullPath(filePath))! : filePath;
if (folder ? !Directory.Exists(path) : !File.Exists(path))
throw new FileNotFoundException("Der gespeicherte Pfad ist nicht mehr vorhanden.");
var start = new ProcessStartInfo { UseShellExecute = true };
if (OperatingSystem.IsWindows()) start.FileName = path;
else { start.FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; start.ArgumentList.Add(path); }
using var process = Process.Start(start) ?? throw new IOException("Kein Programm zum Öffnen gefunden.");
return new FileOperationResult(FileOperationStatus.Success, folder ? "Ordner geöffnet." : "Datei geöffnet.");
}
catch (Exception ex)
{ {
FileName = filePath, return new FileOperationResult(FileOperationStatus.Error,
UseShellExecute = true "Der Pfad konnte nicht geöffnet werden. Die gespeicherte Datei bleibt erhalten; bitte den Speicherort im Datei-Explorer öffnen.", filePath, ex.Message);
}
}); });
return;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", filePath);
return;
}
Process.Start("xdg-open", filePath);
});
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Fallback: save to Documents folder
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
}
var extension = Path.GetExtension(suggestedFileName);
var filterName = extension == ".pdf" ? "PDF-Dateien" : "Dateien";
var filter = $"{filterName} (*{extension})|*{extension}|Alle Dateien (*.*)|*.*";
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = '{filter}'
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Datei speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"\"")}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return null;
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return string.IsNullOrWhiteSpace(result) ? null : result;
});
}
} }
@@ -0,0 +1,41 @@
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Infrastructure.Services;
using Xunit;
namespace Duempelkas.Tests;
public class FileOperationTests
{
[Fact]
public async Task Cancel_IsNeutral()
{
var service = new FileSaveService(new Dialog(null));
Assert.Equal(FileOperationStatus.Cancelled, (await service.SaveFileAsync([1], "test.pdf")).Status);
}
[Fact]
public async Task SaveAndFailure_AreDistinct_AndOpeningMissingFileIsError()
{
var folder = Path.Combine(Path.GetTempPath(), "duempelkas-files-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
try
{
var path = Path.Combine(folder, "Prüfung.pdf");
var service = new FileSaveService(new Dialog(path));
Assert.Equal(FileOperationStatus.Success, (await service.SaveFileAsync([1, 2, 3], "test.pdf")).Status);
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
// A directory cannot be overwritten as a file; the existing file is preserved.
Assert.Equal(FileOperationStatus.Error, (await new FileSaveService(new Dialog(folder)).SaveFileAsync([4], "test.pdf")).Status);
Assert.Equal(FileOperationStatus.Error, (await service.OpenFileAsync(Path.Combine(folder, "missing.pdf"))).Status);
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
}
finally { Directory.Delete(folder, true); }
}
private sealed class Dialog(string? path) : IFileDialogService
{
public Task<string?> SaveAsync(string suggestedFileName) => Task.FromResult(path);
public Task<string?> OpenBackupAsync() => Task.FromResult(path);
}
}