Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions JournalApp.Tests/ImportExportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("{}"));
}

Expand All @@ -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"));
}

Expand Down
56 changes: 28 additions & 28 deletions JournalApp/Data/AppDataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,24 @@
/// </summary>
public sealed class AppDataService(ILogger<AppDataService> logger, IDbContextFactory<AppDbContext> 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(
Expand All @@ -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.
/// </summary>
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(
Expand All @@ -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})",
Expand All @@ -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)
Expand All @@ -110,14 +110,14 @@ public IEnumerable<PreferenceBackup> GetPreferenceBackups()
}
}

public async Task<BackupFile> CreateBackup()
public async Task<BackupFile> 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();
Expand Down
8 changes: 4 additions & 4 deletions JournalApp/Data/AppDataUIService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -65,7 +65,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);
Expand Down Expand Up @@ -101,15 +101,15 @@ 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);

sw.Restart();

using (var memoryStream = new MemoryStream())
{
await backupFile.WriteArchive(memoryStream);
await backupFile.WriteArchive(memoryStream).ConfigureAwait(false);
archiveBytes = memoryStream.ToArray();
}

Expand Down
24 changes: 12 additions & 12 deletions JournalApp/Data/BackupFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ public class BackupFile
/// <summary>
/// Reads a backup file from the specified stream.
/// </summary>
public static async Task<BackupFile> ReadArchive(Stream stream)
public static async Task<BackupFile> ReadArchive(Stream stream, CancellationToken cancellationToken = default)
{
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
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 = entry.Open();
await using var entryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false);

return await JsonSerializer.DeserializeAsync<BackupFile>(entryStream, SerializerOptions);
return await JsonSerializer.DeserializeAsync<BackupFile>(entryStream, SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}

Expand All @@ -49,32 +49,32 @@ public static async Task<BackupFile> ReadArchive(Stream stream)
/// <summary>
/// Reads a backup file from the specified path.
/// </summary>
public static async Task<BackupFile> ReadArchive(string path)
public static async Task<BackupFile> 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);
}

/// <summary>
/// Writes the backup file to the specified stream.
/// </summary>
public async Task WriteArchive(Stream stream)
public async Task WriteArchive(Stream stream, CancellationToken cancellationToken = default)
{
using var archive = new ZipArchive(stream, ZipArchiveMode.Create);
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 = entry.Open();
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);
}

/// <summary>
/// Writes the backup file to the specified path.
/// </summary>
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);
}
}

Expand Down
7 changes: 7 additions & 0 deletions JournalApp/Pages/SettingsPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
@* TODO: Replace with separate page so we can change state of inline fields and have it updated properly in settings *@
<div class="loading-container">
<MudProgressCircular Class="loading-spinner" Color="Color.Primary" Indeterminate />
<MudText Typo="Typo.h6" Align="Align.Center" Style="margin-top: 1rem;">@_busyMessage</MudText>
<MudText Typo="Typo.body2" Align="Align.Center" Style="margin-top: 0.5rem; color: var(--mud-palette-warning);">⚠️ Do not close the app</MudText>
</div>
}
else
Expand Down Expand Up @@ -106,6 +108,7 @@ else

@code {
bool _busy;
string _busyMessage;
bool _creditsExpanded;

public string CreditsText { get; private set; }
Expand Down Expand Up @@ -164,6 +167,7 @@ else
try
{
_busy = true;
_busyMessage = "Importing data...";
StateHasChanged();

var path = App.ActivatedFilePath;
Expand All @@ -174,6 +178,7 @@ else
{
App.ActivatedFilePath = null;
_busy = false;
_busyMessage = null;
}
}

Expand All @@ -182,13 +187,15 @@ else
try
{
_busy = true;
_busyMessage = "Exporting data...";
StateHasChanged();

await AppDataUIService.StartExportWizard(DialogService);
}
finally
{
_busy = false;
_busyMessage = null;
}
}

Expand Down