From c7fe49b8d778ed63ec642b4e18530d676d00297f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:01:47 +0000 Subject: [PATCH 1/4] Initial plan From 535b7d133e03fbcb20a67fe34a3f50f4ebfc7efd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:07:57 +0000 Subject: [PATCH 2/4] Migrate to .NET 10 async ZIP APIs Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com> --- JournalApp.Tests/ImportExportTests.cs | 12 ++++++------ JournalApp/Data/BackupFile.cs | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/JournalApp.Tests/ImportExportTests.cs b/JournalApp.Tests/ImportExportTests.cs index 5a94617c..30806fd4 100644 --- a/JournalApp.Tests/ImportExportTests.cs +++ b/JournalApp.Tests/ImportExportTests.cs @@ -119,12 +119,12 @@ public async Task ReadArchive_ThrowsOnMissingInternalFile() try { - using (var stream = File.Create(zipPath)) - using (var archive = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create)) + await using (var stream = File.Create(zipPath)) + await using (var archive = await System.IO.Compression.ZipArchive.CreateAsync(stream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: false, entryNameEncoding: null)) { // Create an entry with wrong name var entry = archive.CreateEntry("wrong-name.json"); - using var entryStream = entry.Open(); + await using var entryStream = await entry.OpenAsync(); await entryStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes("{}")); } @@ -148,11 +148,11 @@ public async Task ReadArchive_ThrowsOnCorruptedJSON() try { - using (var stream = File.Create(zipPath)) - using (var archive = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create)) + await using (var stream = File.Create(zipPath)) + await using (var archive = await System.IO.Compression.ZipArchive.CreateAsync(stream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: false, entryNameEncoding: null)) { var entry = archive.CreateEntry("journalapp-data.json"); - using var entryStream = entry.Open(); + await using var entryStream = await entry.OpenAsync(); await entryStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes("{invalid json content")); } diff --git a/JournalApp/Data/BackupFile.cs b/JournalApp/Data/BackupFile.cs index 0fddb160..63dfcf8e 100644 --- a/JournalApp/Data/BackupFile.cs +++ b/JournalApp/Data/BackupFile.cs @@ -31,13 +31,13 @@ public class BackupFile /// public static async Task ReadArchive(Stream stream) { - using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + await using var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, leaveOpen: false, entryNameEncoding: null); foreach (var entry in archive.Entries) { if (entry.FullName == InternalBackupFileName) { - await using var entryStream = entry.Open(); + await using var entryStream = await entry.OpenAsync(); return await JsonSerializer.DeserializeAsync(entryStream, SerializerOptions); } @@ -60,10 +60,10 @@ public static async Task ReadArchive(string path) /// public async Task WriteArchive(Stream stream) { - using var archive = new ZipArchive(stream, ZipArchiveMode.Create); + await using var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Create, leaveOpen: false, entryNameEncoding: null); var entry = archive.CreateEntry(InternalBackupFileName); - await using var entryStream = entry.Open(); + await using var entryStream = await entry.OpenAsync(); await JsonSerializer.SerializeAsync(entryStream, this, SerializerOptions); } From 405325097d5ac6a5e3d21568859f064a6f3dcddd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:28:31 +0000 Subject: [PATCH 3/4] Add ConfigureAwait and CancellationToken support, improve import warning Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com> --- JournalApp/Data/AppDataService.cs | 56 ++++++++++++++--------------- JournalApp/Data/AppDataUIService.cs | 11 +++--- JournalApp/Data/BackupFile.cs | 24 ++++++------- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/JournalApp/Data/AppDataService.cs b/JournalApp/Data/AppDataService.cs index 3e28d026..d8c5b0cc 100644 --- a/JournalApp/Data/AppDataService.cs +++ b/JournalApp/Data/AppDataService.cs @@ -5,24 +5,24 @@ /// public sealed class AppDataService(ILogger logger, IDbContextFactory dbFactory, PreferenceService preferences) { - public async Task DeleteDbSets() + public async Task DeleteDbSets(CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); - await using var db = await dbFactory.CreateDbContextAsync(); - await DeleteDbSetsInternal(db); - await db.SaveChangesAsync(); + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await DeleteDbSetsInternal(db, cancellationToken).ConfigureAwait(false); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); sw.Stop(); logger.LogInformation("Cleared data sets in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds); } - public async Task RestoreDbSets(BackupFile backup) + public async Task RestoreDbSets(BackupFile backup, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); - await using var db = await dbFactory.CreateDbContextAsync(); - await RestoreDbSetsInternal(db, backup); + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await RestoreDbSetsInternal(db, backup, cancellationToken).ConfigureAwait(false); sw.Stop(); logger.LogInformation( @@ -37,23 +37,23 @@ public async Task RestoreDbSets(BackupFile backup) /// Atomically deletes all existing data and restores from backup in a single transaction. /// This prevents database corruption if the operation is interrupted. /// - public async Task ReplaceDbSets(BackupFile backup) + public async Task ReplaceDbSets(BackupFile backup, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); - await using var db = await dbFactory.CreateDbContextAsync(); - await using var transaction = await db.Database.BeginTransactionAsync(); + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { // Delete all existing data - await DeleteDbSetsInternal(db); + await DeleteDbSetsInternal(db, cancellationToken).ConfigureAwait(false); // Restore from backup - await RestoreDbSetsInternal(db, backup); + await RestoreDbSetsInternal(db, backup, cancellationToken).ConfigureAwait(false); // Commit the transaction - both delete and restore succeed or fail together - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); sw.Stop(); logger.LogInformation( @@ -66,16 +66,16 @@ public async Task ReplaceDbSets(BackupFile backup) catch { // Rollback on any error - database remains in original state - await transaction.RollbackAsync(); + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); throw; } } - private async Task DeleteDbSetsInternal(AppDbContext db) + private async Task DeleteDbSetsInternal(AppDbContext db, CancellationToken cancellationToken = default) { - var pointsDeleted = await db.Points.ExecuteDeleteAsync(); - var daysDeleted = await db.Days.ExecuteDeleteAsync(); - var categoriesDeleted = await db.Categories.ExecuteDeleteAsync(); + var pointsDeleted = await db.Points.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + var daysDeleted = await db.Days.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + var categoriesDeleted = await db.Categories.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); logger.LogDebug( "Cleared data sets (points: {PointsDeleted}, days: {DaysDeleted}, categories: {CategoriesDeleted})", @@ -84,12 +84,12 @@ private async Task DeleteDbSetsInternal(AppDbContext db) categoriesDeleted); } - private async Task RestoreDbSetsInternal(AppDbContext db, BackupFile backup) + private async Task RestoreDbSetsInternal(AppDbContext db, BackupFile backup, CancellationToken cancellationToken = default) { - await db.Days.AddRangeAsync(backup.Days); - await db.Categories.AddRangeAsync(backup.Categories); - await db.Points.AddRangeAsync(backup.Points); - await db.SaveChangesAsync(); + await db.Days.AddRangeAsync(backup.Days, cancellationToken).ConfigureAwait(false); + await db.Categories.AddRangeAsync(backup.Categories, cancellationToken).ConfigureAwait(false); + await db.Points.AddRangeAsync(backup.Points, cancellationToken).ConfigureAwait(false); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public void SetPreferences(BackupFile backup) @@ -110,14 +110,14 @@ public IEnumerable GetPreferenceBackups() } } - public async Task CreateBackup() + public async Task CreateBackup(CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); - await using var db = await dbFactory.CreateDbContextAsync(); + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - var days = await db.Days.Include(d => d.Points).ToListAsync(); - var categories = await db.Categories.Include(c => c.Points).ToListAsync(); - var points = await db.Points.ToListAsync(); + var days = await db.Days.Include(d => d.Points).ToListAsync(cancellationToken).ConfigureAwait(false); + var categories = await db.Categories.Include(c => c.Points).ToListAsync(cancellationToken).ConfigureAwait(false); + var points = await db.Points.ToListAsync(cancellationToken).ConfigureAwait(false); var preferencesBackup = GetPreferenceBackups().ToList(); sw.Stop(); diff --git a/JournalApp/Data/AppDataUIService.cs b/JournalApp/Data/AppDataUIService.cs index 85ce530b..443411dd 100644 --- a/JournalApp/Data/AppDataUIService.cs +++ b/JournalApp/Data/AppDataUIService.cs @@ -29,7 +29,7 @@ await dialogService.ShowJaMessageBox("Recommended: Back up your current data fir BackupFile backup; try { - backup = await BackupFile.ReadArchive(path); + backup = await BackupFile.ReadArchive(path).ConfigureAwait(false); readStopwatch.Stop(); logger.LogInformation("Backup file read successfully in {ElapsedMilliseconds}ms - contains {DayCount} days, {CategoryCount} categories, {PointCount} points", readStopwatch.ElapsedMilliseconds, backup.Days.Count, backup.Categories.Count, backup.Points.Count); @@ -51,7 +51,8 @@ await dialogService.ShowJaMessageBox("Recommended: Back up your current data fir readStopwatch.Restart(); if (await dialogService.ShowJaMessageBox( $"Contains {backup.Days.Count} days, {backup.Categories.Count} categories, {backup.Points.Count} points, {backup.PreferenceBackups.Count} preferences.\n\n" + - "⚠️ This will replace ALL current data and cannot be undone.", + "⚠️ This will replace ALL current data and cannot be undone.\n\n" + + "⚠️ Do not close the app during import.", yesText: "Import", cancelText: "Cancel") == null) { total.Stop(); @@ -65,7 +66,7 @@ await dialogService.ShowJaMessageBox("Recommended: Back up your current data fir { // Import data from backup atomically - delete and restore in a single transaction. // If this fails, the database will be rolled back to its original state. - await appDataService.ReplaceDbSets(backup); + await appDataService.ReplaceDbSets(backup).ConfigureAwait(false); // Only restore preferences after database operations succeed. appDataService.SetPreferences(backup); @@ -101,7 +102,7 @@ public async Task StartExportWizard(IDialogService dialogService) { var sw = Stopwatch.StartNew(); - backupFile = await appDataService.CreateBackup(); + backupFile = await appDataService.CreateBackup().ConfigureAwait(false); logger.LogInformation("Backup created in {ElapsedMilliseconds}ms - {DayCount} days, {CategoryCount} categories, {PointCount} points", sw.ElapsedMilliseconds, backupFile.Days.Count, backupFile.Categories.Count, backupFile.Points.Count); @@ -109,7 +110,7 @@ public async Task StartExportWizard(IDialogService dialogService) using (var memoryStream = new MemoryStream()) { - await backupFile.WriteArchive(memoryStream); + await backupFile.WriteArchive(memoryStream).ConfigureAwait(false); archiveBytes = memoryStream.ToArray(); } diff --git a/JournalApp/Data/BackupFile.cs b/JournalApp/Data/BackupFile.cs index 63dfcf8e..b3dec426 100644 --- a/JournalApp/Data/BackupFile.cs +++ b/JournalApp/Data/BackupFile.cs @@ -29,17 +29,17 @@ public class BackupFile /// /// Reads a backup file from the specified stream. /// - public static async Task ReadArchive(Stream stream) + public static async Task ReadArchive(Stream stream, CancellationToken cancellationToken = default) { - await using var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, leaveOpen: false, entryNameEncoding: null); + await using var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, leaveOpen: false, entryNameEncoding: null, cancellationToken).ConfigureAwait(false); foreach (var entry in archive.Entries) { if (entry.FullName == InternalBackupFileName) { - await using var entryStream = await entry.OpenAsync(); + await using var entryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(entryStream, SerializerOptions); + return await JsonSerializer.DeserializeAsync(entryStream, SerializerOptions, cancellationToken).ConfigureAwait(false); } } @@ -49,32 +49,32 @@ public static async Task ReadArchive(Stream stream) /// /// Reads a backup file from the specified path. /// - public static async Task ReadArchive(string path) + public static async Task ReadArchive(string path, CancellationToken cancellationToken = default) { await using var fs = File.Open(path, FileMode.Open); - return await ReadArchive(fs); + return await ReadArchive(fs, cancellationToken).ConfigureAwait(false); } /// /// Writes the backup file to the specified stream. /// - public async Task WriteArchive(Stream stream) + public async Task WriteArchive(Stream stream, CancellationToken cancellationToken = default) { - await using var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Create, leaveOpen: false, entryNameEncoding: null); + await using var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Create, leaveOpen: false, entryNameEncoding: null, cancellationToken).ConfigureAwait(false); var entry = archive.CreateEntry(InternalBackupFileName); - await using var entryStream = await entry.OpenAsync(); + await using var entryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - await JsonSerializer.SerializeAsync(entryStream, this, SerializerOptions); + await JsonSerializer.SerializeAsync(entryStream, this, SerializerOptions, cancellationToken).ConfigureAwait(false); } /// /// Writes the backup file to the specified path. /// - public async Task WriteArchive(string path) + public async Task WriteArchive(string path, CancellationToken cancellationToken = default) { await using var stream = File.Create(path); - await WriteArchive(stream); + await WriteArchive(stream, cancellationToken).ConfigureAwait(false); } } From 769808b0e032d718c0486a4b35ada42c48d9accd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:54:35 +0000 Subject: [PATCH 4/4] Move warning to loading screen, add messages for import/export Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com> --- JournalApp/Data/AppDataUIService.cs | 3 +-- JournalApp/Pages/SettingsPage.razor | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/JournalApp/Data/AppDataUIService.cs b/JournalApp/Data/AppDataUIService.cs index 443411dd..fb7b65ad 100644 --- a/JournalApp/Data/AppDataUIService.cs +++ b/JournalApp/Data/AppDataUIService.cs @@ -51,8 +51,7 @@ await dialogService.ShowJaMessageBox("Recommended: Back up your current data fir readStopwatch.Restart(); if (await dialogService.ShowJaMessageBox( $"Contains {backup.Days.Count} days, {backup.Categories.Count} categories, {backup.Points.Count} points, {backup.PreferenceBackups.Count} preferences.\n\n" + - "⚠️ This will replace ALL current data and cannot be undone.\n\n" + - "⚠️ Do not close the app during import.", + "⚠️ This will replace ALL current data and cannot be undone.", yesText: "Import", cancelText: "Cancel") == null) { total.Stop(); diff --git a/JournalApp/Pages/SettingsPage.razor b/JournalApp/Pages/SettingsPage.razor index 1f863558..b0c07c9a 100644 --- a/JournalApp/Pages/SettingsPage.razor +++ b/JournalApp/Pages/SettingsPage.razor @@ -11,6 +11,8 @@ @* TODO: Replace with separate page so we can change state of inline fields and have it updated properly in settings *@
+ @_busyMessage + ⚠️ Do not close the app
} else @@ -106,6 +108,7 @@ else @code { bool _busy; + string _busyMessage; bool _creditsExpanded; public string CreditsText { get; private set; } @@ -164,6 +167,7 @@ else try { _busy = true; + _busyMessage = "Importing data..."; StateHasChanged(); var path = App.ActivatedFilePath; @@ -174,6 +178,7 @@ else { App.ActivatedFilePath = null; _busy = false; + _busyMessage = null; } } @@ -182,6 +187,7 @@ else try { _busy = true; + _busyMessage = "Exporting data..."; StateHasChanged(); await AppDataUIService.StartExportWizard(DialogService); @@ -189,6 +195,7 @@ else finally { _busy = false; + _busyMessage = null; } }