Dateiaktion läuft … Bitte warten.
@@ -25,14 +28,14 @@
Neues Konto
|
-
+
Backup
-
+
Restore
|
-
+
PDF
|
@@ -54,12 +57,6 @@
}
- @if (!string.IsNullOrWhiteSpace(operationMessage))
- {
-
- @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);
+ }
+}