diff --git a/AudioCuesheetEditor.End2EndTests/AudioCuesheetEditor.End2EndTests.csproj b/AudioCuesheetEditor.End2EndTests/AudioCuesheetEditor.End2EndTests.csproj
index 3922ee65..d982e6bb 100644
--- a/AudioCuesheetEditor.End2EndTests/AudioCuesheetEditor.End2EndTests.csproj
+++ b/AudioCuesheetEditor.End2EndTests/AudioCuesheetEditor.End2EndTests.csproj
@@ -13,8 +13,8 @@
-
-
+
+
diff --git a/AudioCuesheetEditor.End2EndTests/Tests/PlaywrightTestBase.cs b/AudioCuesheetEditor.End2EndTests/Tests/PlaywrightTestBase.cs
index 95eb0794..98ebc9f6 100644
--- a/AudioCuesheetEditor.End2EndTests/Tests/PlaywrightTestBase.cs
+++ b/AudioCuesheetEditor.End2EndTests/Tests/PlaywrightTestBase.cs
@@ -42,7 +42,7 @@ public async Task TestInitializeAsync()
TestPage = Page;
}
await _testContextInstance.ClearCookiesAsync();
- await _testContextInstance.Tracing.StartAsync(new()
+ _ = await _testContextInstance.Tracing.StartAsync(new()
{
Title = $"{TestContext.FullyQualifiedTestClassName}.{TestContext.TestName}",
Screenshots = true,
diff --git a/AudioCuesheetEditor.Tests/AudioCuesheetEditor.Tests.csproj b/AudioCuesheetEditor.Tests/AudioCuesheetEditor.Tests.csproj
index 9bfee3fd..18e49aa0 100644
--- a/AudioCuesheetEditor.Tests/AudioCuesheetEditor.Tests.csproj
+++ b/AudioCuesheetEditor.Tests/AudioCuesheetEditor.Tests.csproj
@@ -9,12 +9,12 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/AudioCuesheetEditor/AudioCuesheetEditor.csproj b/AudioCuesheetEditor/AudioCuesheetEditor.csproj
index 23277956..ff365df4 100644
--- a/AudioCuesheetEditor/AudioCuesheetEditor.csproj
+++ b/AudioCuesheetEditor/AudioCuesheetEditor.csproj
@@ -16,16 +16,16 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/AudioCuesheetEditor/Program.cs b/AudioCuesheetEditor/Program.cs
index d9ee4a8f..25b2dc6f 100644
--- a/AudioCuesheetEditor/Program.cs
+++ b/AudioCuesheetEditor/Program.cs
@@ -40,6 +40,8 @@
builder.Services.AddBlazorDownloadFile();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -70,6 +72,14 @@
var host = builder.Build();
+// Run migrations
+using (var scope = host.Services.CreateScope())
+{
+ var migrationService = scope.ServiceProvider.GetRequiredService();
+
+ await migrationService.ApplyMigrationsAsync();
+}
+
await host.SetCultureFromConfigurationAsync();
await host.InitializeSessionStateContainer();
-await host.RunAsync();
\ No newline at end of file
+await host.RunAsync();
diff --git a/AudioCuesheetEditor/Services/Options/LocalStorageMigrationService.cs b/AudioCuesheetEditor/Services/Options/LocalStorageMigrationService.cs
new file mode 100644
index 00000000..28c38123
--- /dev/null
+++ b/AudioCuesheetEditor/Services/Options/LocalStorageMigrationService.cs
@@ -0,0 +1,30 @@
+using System.Globalization;
+using Microsoft.JSInterop;
+
+namespace AudioCuesheetEditor.Services.Options;
+
+public class LocalStorageMigrationService(IJSRuntime jsRuntime, IEnumerable migrations)
+{
+ readonly IJSRuntime _jsRuntime = jsRuntime;
+ readonly IEnumerable _migrations = migrations;
+ const string _versionKey = "OptionsMigrationVersion";
+
+ public async Task ApplyMigrationsAsync()
+ {
+ var storedVersion = await _jsRuntime.InvokeAsync("AppSettings.get", _versionKey);
+ var currentVersion = int.TryParse(storedVersion, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVersion) ? parsedVersion : 0;
+ foreach (var migration in _migrations.OrderBy(x => x.Version))
+ {
+ if (migration.Version <= currentVersion)
+ {
+ continue;
+ }
+
+ await migration.ApplyAsync();
+
+ await _jsRuntime.InvokeVoidAsync("AppSettings.set", _versionKey, migration.Version.ToString(CultureInfo.InvariantCulture));
+
+ currentVersion = migration.Version;
+ }
+ }
+}
diff --git a/AudioCuesheetEditor/Services/Options/Migrations/ILocalStorageMigration.cs b/AudioCuesheetEditor/Services/Options/Migrations/ILocalStorageMigration.cs
new file mode 100644
index 00000000..ce0cdf7c
--- /dev/null
+++ b/AudioCuesheetEditor/Services/Options/Migrations/ILocalStorageMigration.cs
@@ -0,0 +1,17 @@
+namespace AudioCuesheetEditor.Services.Options;
+
+///
+/// Interface for local storage migrations
+///
+public interface ILocalStorageMigration
+{
+ ///
+ /// Version of this migration (used for ordering updates)
+ ///
+ int Version { get; }
+ ///
+ /// Apply updates from this migration
+ ///
+ ///
+ Task ApplyAsync();
+}
diff --git a/AudioCuesheetEditor/Services/Options/Migrations/LocalStorageMigrationV1.cs b/AudioCuesheetEditor/Services/Options/Migrations/LocalStorageMigrationV1.cs
new file mode 100644
index 00000000..28af5552
--- /dev/null
+++ b/AudioCuesheetEditor/Services/Options/Migrations/LocalStorageMigrationV1.cs
@@ -0,0 +1,77 @@
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using Microsoft.JSInterop;
+
+namespace AudioCuesheetEditor.Services.Options;
+
+///
+public class LocalStorageMigrationV1(IJSRuntime jsRuntime) : ILocalStorageMigration
+{
+ readonly IJSRuntime _jsRuntime = jsRuntime;
+ ///
+ public int Version => 1;
+
+ ///
+ public async Task ApplyAsync()
+ {
+ await MigrateKeyAsync("ExportOptions", value => value.Replace("%Track.End%", "%Track.Finish%", StringComparison.Ordinal));
+ await MigrateKeyAsync("ImportOptions", value => Regex.Replace(value, @"\bEnd\b", "Finish"));
+ }
+
+ async Task MigrateKeyAsync(string key, Func replace)
+ {
+ var storedJson = await _jsRuntime.InvokeAsync("AppSettings.get", key);
+ if (string.IsNullOrWhiteSpace(storedJson))
+ {
+ return;
+ }
+ var root = JsonNode.Parse(storedJson);
+ if (root is null)
+ {
+ return;
+ }
+
+ if (ReplaceSchemeTracks(root, replace))
+ {
+ await _jsRuntime.InvokeVoidAsync("AppSettings.set", key, root.ToJsonString());
+ }
+ }
+
+ static bool ReplaceSchemeTracks(JsonNode node, Func replace)
+ {
+ var changed = false;
+
+ if (node is JsonObject jsonObject)
+ {
+ foreach (var property in jsonObject.ToList())
+ {
+ if (property.Key == "SchemeTracks"
+ && property.Value is JsonValue jsonValue
+ && jsonValue.TryGetValue(out var oldValue)
+ && oldValue is not null)
+ {
+ var newValue = replace(oldValue);
+
+ if (newValue != oldValue)
+ {
+ jsonObject[property.Key] = newValue;
+ changed = true;
+ }
+ }
+ else
+ {
+ changed |= ReplaceSchemeTracks(property.Value, replace);
+ }
+ }
+ }
+ else if (node is JsonArray jsonArray)
+ {
+ foreach (var item in jsonArray)
+ {
+ changed |= ReplaceSchemeTracks(item, replace);
+ }
+ }
+
+ return changed;
+ }
+}
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 00000000..ea5aae50
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,22 @@
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file