using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.WebAPI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace LightlessSync.LightlessConfiguration; public class ConfigurationMigrator(ILogger logger, TransientConfigService transientConfigService, ServerConfigService serverConfigService, TempCollectionConfigService tempCollectionConfigService, LightlessConfigService lightlessConfigService) : IHostedService { private readonly ILogger _logger = logger; public void Migrate() { if (transientConfigService.Current.Version == 0) { _logger.LogInformation("Migrating Transient Config V0 => V1"); transientConfigService.Current.TransientConfigs.Clear(); transientConfigService.Current.Version = 1; transientConfigService.Save(); } if (transientConfigService.Current.Version == 1) { _logger.LogInformation("Migrating Transient Config V1 => V2"); var totalRemoved = 0; var configCount = 0; var changedCount = 0; foreach (var config in transientConfigService.Current.TransientConfigs.Values) { if (config.NormalizePaths(out var removed)) changedCount++; totalRemoved += removed; configCount++; } _logger.LogInformation("Transient config normalization: processed {count} entries, updated {updated}, removed {removed} paths", configCount, changedCount, totalRemoved); transientConfigService.Current.Version = 2; transientConfigService.Save(); } if (serverConfigService.Current.Version == 1) { _logger.LogInformation("Migrating Server Config V1 => V2"); var centralServer = serverConfigService.Current.ServerStorage.Find(f => f.ServerName.Equals("Follow the light (Central Server EU)", StringComparison.Ordinal)); if (centralServer != null) { centralServer.ServerName = ApiController.MainServer; } serverConfigService.Current.Version = 2; serverConfigService.Save(); } MigrateTempCollectionConfig(tempCollectionConfigService, lightlessConfigService); } public Task StartAsync(CancellationToken cancellationToken) { Migrate(); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } private void MigrateTempCollectionConfig(TempCollectionConfigService tempCollectionConfigService, LightlessConfigService lightlessConfigService) { var now = DateTime.UtcNow; TempCollectionConfig tempConfig = tempCollectionConfigService.Current; var tempChanged = false; var tempNeedsSave = false; if (TryReadTempCollectionData(lightlessConfigService.ConfigurationPath, out var root, out var ids, out var entries)) { tempChanged |= MergeTempCollectionData(tempConfig, ids, entries, now); var removed = root.Remove("OrphanableTempCollections"); removed |= root.Remove("OrphanableTempCollectionEntries"); if (removed) { try { string updatedJson = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(lightlessConfigService.ConfigurationPath, updatedJson); lightlessConfigService.UpdateLastWriteTime(); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to rewrite {config} after temp collection migration", lightlessConfigService.ConfigurationPath); return; } } if (ids.Count > 0 || entries.Count > 0) { _logger.LogInformation("Migrated {ids} temp collection ids and {entries} entries to {configName}", ids.Count, entries.Count, tempCollectionConfigService.ConfigurationName); } } if (TryReadTempCollectionData(tempCollectionConfigService.ConfigurationPath, out var tempRoot, out var tempIds, out var tempEntries)) { tempChanged |= MergeTempCollectionData(tempConfig, tempIds, tempEntries, now); if (tempRoot.Remove("OrphanableTempCollections")) { tempNeedsSave = true; } } if (tempChanged || tempNeedsSave) { tempCollectionConfigService.Save(); } } private bool TryReadTempCollectionData(string configPath, out JsonObject root, out HashSet ids, out List entries) { root = new JsonObject(); ids = []; entries = []; if (!File.Exists(configPath)) { return false; } try { root = JsonNode.Parse(File.ReadAllText(configPath)) as JsonObject ?? new JsonObject(); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to read temp collection data from {config}", configPath); return false; } root.TryGetPropertyValue("OrphanableTempCollections", out JsonNode? idsNode); root.TryGetPropertyValue("OrphanableTempCollectionEntries", out JsonNode? entriesNode); if (idsNode == null && entriesNode == null) { return false; } ids = ParseGuidSet(idsNode); entries = ParseEntries(entriesNode); return true; } private static HashSet ParseGuidSet(JsonNode? node) { HashSet ids = []; if (node is not JsonArray array) { return ids; } foreach (JsonNode? item in array) { Guid id = ParseGuid(item); if (id != Guid.Empty) { ids.Add(id); } } return ids; } private static List ParseEntries(JsonNode? node) { List entries = []; if (node is not JsonArray array) { return entries; } foreach (JsonNode? item in array) { if (item is not JsonObject obj) { continue; } Guid id = ParseGuid(obj["Id"]); if (id == Guid.Empty) { continue; } DateTime registeredAtUtc = DateTime.MinValue; if (TryParseDateTime(obj["RegisteredAtUtc"], out DateTime parsed)) { registeredAtUtc = parsed; } entries.Add(new OrphanableTempCollectionEntry { Id = id, RegisteredAtUtc = registeredAtUtc }); } return entries; } private static Guid ParseGuid(JsonNode? node) { if (node is JsonValue value) { if (value.TryGetValue(out string? stringValue) && Guid.TryParse(stringValue, out Guid parsed)) { return parsed; } } return Guid.Empty; } private static bool TryParseDateTime(JsonNode? node, out DateTime value) { value = DateTime.MinValue; if (node is not JsonValue val) { return false; } if (val.TryGetValue(out DateTime direct)) { value = direct; return true; } if (val.TryGetValue(out string? stringValue) && DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsed)) { value = parsed; return true; } return false; } private static bool MergeTempCollectionData(TempCollectionConfig config, HashSet ids, List entries, DateTime now) { bool changed = false; Dictionary entryLookup = new(); for (var i = config.OrphanableTempCollectionEntries.Count - 1; i >= 0; i--) { var entry = config.OrphanableTempCollectionEntries[i]; if (entry.Id == Guid.Empty) { config.OrphanableTempCollectionEntries.RemoveAt(i); changed = true; continue; } if (entryLookup.TryGetValue(entry.Id, out var existing)) { if (entry.RegisteredAtUtc != DateTime.MinValue && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) { existing.RegisteredAtUtc = entry.RegisteredAtUtc; changed = true; } config.OrphanableTempCollectionEntries.RemoveAt(i); changed = true; continue; } entryLookup[entry.Id] = entry; } foreach (OrphanableTempCollectionEntry entry in entries) { if (entry.Id == Guid.Empty) { continue; } if (!entryLookup.TryGetValue(entry.Id, out OrphanableTempCollectionEntry? existing)) { var added = new OrphanableTempCollectionEntry { Id = entry.Id, RegisteredAtUtc = entry.RegisteredAtUtc }; config.OrphanableTempCollectionEntries.Add(added); entryLookup[entry.Id] = added; changed = true; continue; } if (entry.RegisteredAtUtc != DateTime.MinValue && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) { existing.RegisteredAtUtc = entry.RegisteredAtUtc; changed = true; } } foreach (Guid id in ids) { if (id == Guid.Empty) { continue; } if (!entryLookup.TryGetValue(id, out OrphanableTempCollectionEntry? existing)) { var added = new OrphanableTempCollectionEntry { Id = id, RegisteredAtUtc = now }; config.OrphanableTempCollectionEntries.Add(added); entryLookup[id] = added; changed = true; continue; } if (existing.RegisteredAtUtc == DateTime.MinValue) { existing.RegisteredAtUtc = now; changed = true; } } return changed; } }