From 5e0ad197f05fea88d396ea37130372b96e9cfe42 Mon Sep 17 00:00:00 2001 From: troogs Date: Mon, 2 Nov 2026 06:23:59 +0100 Subject: [PATCH] fix(files): report success cancellation and errors with safe next actions (U02) --- docs/usability-umsetzung.md | 4 + .../Components/OperationNotice.razor | 40 +++++++ .../Pages/Accounts/AccountDetail.razor | 5 +- .../Pages/Accounts/AccountDetail.razor.cs | 26 ++++- src/Duempelkas.App/Pages/Dashboard.razor | 15 +-- src/Duempelkas.App/Pages/Dashboard.razor.cs | 65 +++++++---- src/Duempelkas.App/Services/IBackupService.cs | 6 +- .../Services/IFileDialogService.cs | 7 ++ .../Services/IFileSaveService.cs | 7 +- .../Services/Models/FileOperationResult.cs | 10 ++ .../DependencyInjection.cs | 1 + .../Services/BackupService.cs | 110 ++++-------------- .../Services/FileDialogService.cs | 44 +++++++ .../Services/FileSaveService.cs | 107 ++++++----------- tests/Duempelkas.Tests/FileOperationTests.cs | 41 +++++++ 15 files changed, 288 insertions(+), 200 deletions(-) create mode 100644 src/Duempelkas.App/Components/OperationNotice.razor create mode 100644 src/Duempelkas.App/Services/IFileDialogService.cs create mode 100644 src/Duempelkas.App/Services/Models/FileOperationResult.cs create mode 100644 src/Duempelkas.Infrastructure/Services/FileDialogService.cs create mode 100644 tests/Duempelkas.Tests/FileOperationTests.cs diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index 7fcfbba..0ee010a 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -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. 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. diff --git a/src/Duempelkas.App/Components/OperationNotice.razor b/src/Duempelkas.App/Components/OperationNotice.razor new file mode 100644 index 0000000..46c03cf --- /dev/null +++ b/src/Duempelkas.App/Components/OperationNotice.razor @@ -0,0 +1,40 @@ +@inject IFileSaveService FileSaveService + +@if (Result != null) +{ +
+

@Result.Message

+ @if (Result.Path != null) + { +

@Result.Path

+ + } + @if (Result.Details != null) + { +
Technische Details

@Result.Details

+ } + @if (openResult != null) + { +

@openResult.Message

+ @if (openResult.Details != null) {
Technische Details@openResult.Details
} + } +
+} + +@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; } + } +} diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index 7b08de9..7d836fb 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -7,6 +7,9 @@ @inject NavigationManager Navigation
+ + @if (fileBusy) {

Dateiaktion läuft … Bitte warten.

} + @if (account == null) {
@@ -35,7 +38,7 @@ - | diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs index bc295ce..80139e9 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs @@ -32,6 +32,8 @@ public partial class AccountDetail private string? confirmRestoreEntryTitle; private string? savedPdfPath; + private FileOperationResult? fileResult; + private bool fileBusy; private EntryDto? editingEntry; private EntryDto? editingTransferEntry; @@ -178,19 +180,39 @@ public partial class AccountDetail private async Task HandleExport() { + if (fileBusy) return; + fileBusy = true; + fileResult = null; + try + { var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear); 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() { + if (fileBusy) return; + fileBusy = true; + fileResult = null; + try + { if (!string.IsNullOrWhiteSpace(savedPdfPath)) { - await FileSaveService.OpenFileAsync(savedPdfPath); + fileResult = await FileSaveService.OpenFileAsync(savedPdfPath); } savedPdfPath = null; + + } + catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } + finally { fileBusy = false; } } private void CancelOpenSavedPdf() diff --git a/src/Duempelkas.App/Pages/Dashboard.razor b/src/Duempelkas.App/Pages/Dashboard.razor index 9a04784..638eda9 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor +++ b/src/Duempelkas.App/Pages/Dashboard.razor @@ -8,6 +8,9 @@ @inject NavigationManager NavigationManager
+ + @if (fileBusy) {

Dateiaktion läuft … Bitte warten.

} +
@@ -25,14 +28,14 @@ Neues Konto | - - | - | @@ -54,12 +57,6 @@
} - @if (!string.IsNullOrWhiteSpace(operationMessage)) - { - - } @if (accounts == null) { diff --git a/src/Duempelkas.App/Pages/Dashboard.razor.cs b/src/Duempelkas.App/Pages/Dashboard.razor.cs index 6bb3fc1..fed810d 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor.cs +++ b/src/Duempelkas.App/Pages/Dashboard.razor.cs @@ -17,9 +17,9 @@ public partial class Dashboard private int? selectedYear = DateTime.Now.Year; private List availableYears = []; private string clubName = string.Empty; - private string? operationMessage; - private string operationMessageClass = "alert-info"; private string? savedPdfPath; + private FileOperationResult? fileResult; + private bool fileBusy; private string currentTheme = "light"; #endregion @@ -100,11 +100,16 @@ public partial class Dashboard private async Task HandleBackupAsync() { - var message = await BackupService.CreateBackupAsync(); - if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase)) + if (fileBusy) return; + 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() @@ -120,25 +125,36 @@ public partial class Dashboard private async Task HandleConfirmRestore() { - showRestoreConfirm = false; - var message = await BackupService.RestoreBackupAsync(); - var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase); + if (fileBusy) return; + fileBusy = true; + fileResult = null; + try + { + showRestoreConfirm = false; + fileResult = await BackupService.RestoreBackupAsync(); + 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() { + if (fileBusy) return; + fileBusy = true; + fileResult = null; + try + { var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear); 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() @@ -160,12 +176,21 @@ public partial class Dashboard private async Task HandleOpenSavedPdf() { + if (fileBusy) return; + fileBusy = true; + fileResult = null; + try + { if (!string.IsNullOrWhiteSpace(savedPdfPath)) { - await FileSaveService.OpenFileAsync(savedPdfPath); + fileResult = await FileSaveService.OpenFileAsync(savedPdfPath); } savedPdfPath = null; + + } + catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } + finally { fileBusy = false; } } private void CancelOpenSavedPdf() @@ -177,12 +202,6 @@ public partial class Dashboard #region Helpers - private void SetOperationMessage(string message, bool success) - { - operationMessage = message; - operationMessageClass = success ? "alert-success" : "alert-danger"; - } - private static string FormatCurrency(decimal amount) { return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €"; diff --git a/src/Duempelkas.App/Services/IBackupService.cs b/src/Duempelkas.App/Services/IBackupService.cs index a6452db..2344df3 100644 --- a/src/Duempelkas.App/Services/IBackupService.cs +++ b/src/Duempelkas.App/Services/IBackupService.cs @@ -1,7 +1,9 @@ +using Duempelkas.App.Services.Models; + namespace Duempelkas.App.Services; public interface IBackupService { - Task CreateBackupAsync(); - Task RestoreBackupAsync(); + Task CreateBackupAsync(); + Task RestoreBackupAsync(); } \ No newline at end of file diff --git a/src/Duempelkas.App/Services/IFileDialogService.cs b/src/Duempelkas.App/Services/IFileDialogService.cs new file mode 100644 index 0000000..55b04f8 --- /dev/null +++ b/src/Duempelkas.App/Services/IFileDialogService.cs @@ -0,0 +1,7 @@ +namespace Duempelkas.App.Services; + +public interface IFileDialogService +{ + Task SaveAsync(string suggestedFileName); + Task OpenBackupAsync(); +} diff --git a/src/Duempelkas.App/Services/IFileSaveService.cs b/src/Duempelkas.App/Services/IFileSaveService.cs index 6137e1e..85f429c 100644 --- a/src/Duempelkas.App/Services/IFileSaveService.cs +++ b/src/Duempelkas.App/Services/IFileSaveService.cs @@ -1,7 +1,10 @@ +using Duempelkas.App.Services.Models; + namespace Duempelkas.App.Services; public interface IFileSaveService { - Task SaveFileAsync(byte[] content, string suggestedFileName); - Task OpenFileAsync(string filePath); + Task SaveFileAsync(byte[] content, string suggestedFileName); + Task OpenFileAsync(string filePath); + Task OpenFolderAsync(string filePath); } diff --git a/src/Duempelkas.App/Services/Models/FileOperationResult.cs b/src/Duempelkas.App/Services/Models/FileOperationResult.cs new file mode 100644 index 0000000..67b1870 --- /dev/null +++ b/src/Duempelkas.App/Services/Models/FileOperationResult.cs @@ -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); +} diff --git a/src/Duempelkas.Infrastructure/DependencyInjection.cs b/src/Duempelkas.Infrastructure/DependencyInjection.cs index acd070d..f2e7348 100644 --- a/src/Duempelkas.Infrastructure/DependencyInjection.cs +++ b/src/Duempelkas.Infrastructure/DependencyInjection.cs @@ -20,6 +20,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Duempelkas.Infrastructure/Services/BackupService.cs b/src/Duempelkas.Infrastructure/Services/BackupService.cs index 62621fb..c24ed8d 100644 --- a/src/Duempelkas.Infrastructure/Services/BackupService.cs +++ b/src/Duempelkas.Infrastructure/Services/BackupService.cs @@ -1,3 +1,4 @@ +using Duempelkas.App.Services.Models; using System.Diagnostics; using System.Runtime.InteropServices; using Duempelkas.App.Services; @@ -9,30 +10,35 @@ public class BackupService : IBackupService { private readonly DatabaseLocation location; - public BackupService(DatabaseLocation location) => this.location = location; - - public async Task CreateBackupAsync() + private readonly IFileDialogService dialogs; + public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null) { - try - { - var path = await ShowSaveDialogAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak"); - if (string.IsNullOrWhiteSpace(path)) return "Sicherung wurde abgebrochen."; - await CreateBackupToAsync(path); - return $"Sicherung gespeichert: {path}"; - } - catch (Exception ex) { return $"Sicherung fehlgeschlagen: {ex.Message}"; } + this.location = location; + this.dialogs = dialogs ?? new FileDialogService(); } - public async Task RestoreBackupAsync() + public async Task CreateBackupAsync() { try { - var path = await ShowOpenDialogAsync(); - if (string.IsNullOrWhiteSpace(path)) return "Wiederherstellung wurde abgebrochen."; - var fallback = await RestoreFromAsync(path); - return $"Wiederherstellung erfolgreich. Rückfallsicherung: {fallback}"; + var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak"); + if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled(); + await CreateBackupToAsync(path); + return new(FileOperationStatus.Success, "Sicherung gespeichert.", path); } - catch (Exception ex) { return $"Wiederherstellung fehlgeschlagen: {ex.Message}"; } + catch (Exception ex) { return FileOperationResult.Error(ex); } + } + + public async Task RestoreBackupAsync() + { + try + { + var path = await dialogs.OpenBackupAsync(); + if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled(); + var fallback = await RestoreFromAsync(path); + return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback); + } + catch (Exception ex) { return FileOperationResult.Error(ex); } } public async Task CreateBackupToAsync(string path) @@ -124,74 +130,4 @@ public class BackupService : IBackupService _ = db.TransferLinks.ToArray(); } - private static Task 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 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; - } } diff --git a/src/Duempelkas.Infrastructure/Services/FileDialogService.cs b/src/Duempelkas.Infrastructure/Services/FileDialogService.cs new file mode 100644 index 0000000..174ea2a --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/FileDialogService.cs @@ -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 SaveAsync(string suggestedFileName) => ShowAsync(suggestedFileName); + public Task OpenBackupAsync() => ShowAsync(null); + + private static Task 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; + }); +} diff --git a/src/Duempelkas.Infrastructure/Services/FileSaveService.cs b/src/Duempelkas.Infrastructure/Services/FileSaveService.cs index 55033fd..a47ebf0 100644 --- a/src/Duempelkas.Infrastructure/Services/FileSaveService.cs +++ b/src/Duempelkas.Infrastructure/Services/FileSaveService.cs @@ -1,90 +1,49 @@ +using Duempelkas.App.Services.Models; using System.Diagnostics; using System.Runtime.InteropServices; using Duempelkas.App.Services; namespace Duempelkas.Infrastructure.Services; -public class FileSaveService : IFileSaveService +public class FileSaveService(IFileDialogService dialogs) : IFileSaveService { - public async Task SaveFileAsync(byte[] content, string suggestedFileName) + public async Task SaveFileAsync(byte[] content, string suggestedFileName) { - var filePath = await ShowSaveDialogAsync(suggestedFileName); - if (filePath == null) return null; - - await File.WriteAllBytesAsync(filePath, content); - return filePath; - } - - public Task OpenFileAsync(string filePath) - { - if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + string? staged = null; + try { - return Task.CompletedTask; + var path = await dialogs.SaveAsync(suggestedFileName); + 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); } - - return Task.Run(() => - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Process.Start(new ProcessStartInfo - { - FileName = filePath, - UseShellExecute = true - }); - - return; - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Process.Start("open", filePath); - return; - } - - Process.Start("xdg-open", filePath); - }); + catch (Exception ex) { return FileOperationResult.Error(ex); } + finally { try { if (staged != null && File.Exists(staged)) File.Delete(staged); } catch { } } } - private static Task ShowSaveDialogAsync(string suggestedFileName) + public Task OpenFileAsync(string filePath) => OpenAsync(filePath, false); + public Task OpenFolderAsync(string filePath) => OpenAsync(filePath, true); + + private static Task OpenAsync(string filePath, bool folder) => Task.Run(() => { - return Task.Run(() => + try { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Fallback: save to Documents folder - var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - return Path.Combine(documentsPath, suggestedFileName); - } + 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) + { + return new FileOperationResult(FileOperationStatus.Error, + "Der Pfad konnte nicht geöffnet werden. Die gespeicherte Datei bleibt erhalten; bitte den Speicherort im Datei-Explorer öffnen.", filePath, ex.Message); + } + }); - 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; - }); - } } diff --git a/tests/Duempelkas.Tests/FileOperationTests.cs b/tests/Duempelkas.Tests/FileOperationTests.cs new file mode 100644 index 0000000..fd1ff25 --- /dev/null +++ b/tests/Duempelkas.Tests/FileOperationTests.cs @@ -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 SaveAsync(string suggestedFileName) => Task.FromResult(path); + public Task OpenBackupAsync() => Task.FromResult(path); + } +}