From 0062ddcbf32ec12bc66381f8fbc982f624010abf Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 13 Mar 2026 11:05:31 +0100 Subject: [PATCH] Update ItemService.cs to ensure .avux import maintains timestamps if set during import (#773) --- .../AliasVault.Client/Services/ItemService.cs | 220 ++++++++++-------- 1 file changed, 120 insertions(+), 100 deletions(-) diff --git a/apps/server/AliasVault.Client/Services/ItemService.cs b/apps/server/AliasVault.Client/Services/ItemService.cs index 16a2c3ba9..ddef95fd5 100644 --- a/apps/server/AliasVault.Client/Services/ItemService.cs +++ b/apps/server/AliasVault.Client/Services/ItemService.cs @@ -182,16 +182,14 @@ public sealed class ItemService(HttpClient httpClient, DbService dbService, Conf var currentDateTime = DateTime.UtcNow; item.Id = Guid.NewGuid(); - item.CreatedAt = currentDateTime; - item.UpdatedAt = currentDateTime; + SetInsertTimestamps(item, currentDateTime); // Set timestamps on all field values and their FieldDefinitions foreach (var fv in item.FieldValues) { fv.Id = Guid.NewGuid(); fv.ItemId = item.Id; - fv.CreatedAt = currentDateTime; - fv.UpdatedAt = currentDateTime; + SetInsertTimestamps(fv, currentDateTime); // If this field value has a new FieldDefinition (custom field), ensure its timestamps are set if (fv.FieldDefinition != null) @@ -215,16 +213,21 @@ public sealed class ItemService(HttpClient httpClient, DbService dbService, Conf foreach (var attachment in item.Attachments) { attachment.ItemId = item.Id; - attachment.CreatedAt = currentDateTime; - attachment.UpdatedAt = currentDateTime; + SetInsertTimestamps(attachment, currentDateTime); } // Set timestamps on TOTP codes foreach (var totpCode in item.TotpCodes) { totpCode.ItemId = item.Id; - totpCode.CreatedAt = currentDateTime; - totpCode.UpdatedAt = currentDateTime; + SetInsertTimestamps(totpCode, currentDateTime); + } + + // Set timestamps on passkeys + foreach (var passkey in item.Passkeys) + { + passkey.ItemId = item.Id; + SetInsertTimestamps(passkey, currentDateTime); } // Create history records for fields with EnableHistory=true @@ -1280,96 +1283,6 @@ public sealed class ItemService(HttpClient httpClient, DbService dbService, Conf } } - /// - /// Extract favicon from service URL if available. If successful, links the item to the logo. - /// Checks for existing logo first to avoid unnecessary API calls (deduplication). - /// If URL is empty or just the placeholder, clears any existing logo from the item. - /// - /// The Item to extract the favicon for. - /// Task. - private async Task ExtractFaviconAsync(Item item) - { - // Try to extract favicon from service URL - var url = GetFieldValue(item, FieldKey.LoginUrl); - if (url != null && !string.IsNullOrEmpty(url) && url != DefaultServiceUrl) - { - try - { - // Extract and normalize domain for deduplication - var domain = new Uri(url).Host.ToLowerInvariant(); - if (domain.StartsWith("www.")) - { - domain = domain[4..]; - } - - var context = await dbService.GetDbContextAsync(); - - // Check if logo already exists for this source (deduplication) - var existingLogo = await context.Logos.FirstOrDefaultAsync(l => l.Source == domain); - - if (existingLogo != null) - { - // Reuse existing logo - no need to fetch - item.LogoId = existingLogo.Id; - return; - } - - // No existing logo - fetch from API - var apiReturn = await httpClient.GetFromJsonAsync($"v1/Favicon/Extract?url={Uri.EscapeDataString(url)}"); - if (apiReturn?.Image is not null) - { - // Create new logo - var newLogo = new Logo - { - Id = Guid.NewGuid(), - Source = domain, - FileData = apiReturn.Image, - MimeType = "image/png", - FetchedAt = DateTime.UtcNow, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - context.Logos.Add(newLogo); - item.LogoId = newLogo.Id; - } - } - catch - { - // Ignore favicon extraction errors - } - } - else - { - // URL is empty or just the placeholder - clear any existing logo - item.LogoId = null; - } - } - - /// - /// Gets the effective identity generator language to use. - /// If user has explicitly set a language preference, use that. - /// Otherwise, intelligently match the UI language to an available identity generator language. - /// Falls back to "en" if no match is found. - /// - /// The identity generator language code to use. - private async Task GetEffectiveIdentityLanguageAsync() - { - var explicitLanguage = dbService.Settings.DefaultIdentityLanguage; - - // If user has explicitly set a language preference, use it - if (!string.IsNullOrWhiteSpace(explicitLanguage)) - { - return explicitLanguage; - } - - // Otherwise, try to match UI language to an identity generator language - var uiLanguage = dbService.Settings.AppLanguage; - var mappedLanguage = await jsInteropService.MapUiLanguageToIdentityLanguageAsync(uiLanguage); - - // Return the mapped language, or fall back to "en" if no match found - return mappedLanguage ?? "en"; - } - /// /// Track field history for fields with EnableHistory=true. /// Compares old values with new values and creates history records. @@ -1384,9 +1297,7 @@ public sealed class ItemService(HttpClient httpClient, DbService dbService, Conf /// The new item with updated field values. /// The timestamp for updates. /// Task. -#pragma warning disable SA1204 // Static members should appear before non-static members private static async Task TrackFieldHistoryAsync(AliasClientDbContext context, Item existingItem, Item newItem, DateTime updateDateTime) -#pragma warning restore SA1204 { // Maximum number of history records to keep per field const int MaxFieldHistoryRecords = 10; @@ -1527,4 +1438,113 @@ public sealed class ItemService(HttpClient httpClient, DbService dbService, Conf } } } + + /// + /// Sets timestamps on an entity during insert only if they're not already set. + /// This preserves timestamps from imported data while setting defaults for new entities. + /// + /// The entity to set timestamps on (must have CreatedAt and UpdatedAt properties). + /// The default timestamp to use if not already set. + private static void SetInsertTimestamps(dynamic entity, DateTime currentDateTime) + { + if (entity.CreatedAt == default(DateTime)) + { + entity.CreatedAt = currentDateTime; + } + + if (entity.UpdatedAt == default(DateTime)) + { + entity.UpdatedAt = currentDateTime; + } + } + + /// + /// Extract favicon from service URL if available. If successful, links the item to the logo. + /// Checks for existing logo first to avoid unnecessary API calls (deduplication). + /// If URL is empty or just the placeholder, clears any existing logo from the item. + /// + /// The Item to extract the favicon for. + /// Task. + private async Task ExtractFaviconAsync(Item item) + { + // Try to extract favicon from service URL + var url = GetFieldValue(item, FieldKey.LoginUrl); + if (url != null && !string.IsNullOrEmpty(url) && url != DefaultServiceUrl) + { + try + { + // Extract and normalize domain for deduplication + var domain = new Uri(url).Host.ToLowerInvariant(); + if (domain.StartsWith("www.")) + { + domain = domain[4..]; + } + + var context = await dbService.GetDbContextAsync(); + + // Check if logo already exists for this source (deduplication) + var existingLogo = await context.Logos.FirstOrDefaultAsync(l => l.Source == domain); + + if (existingLogo != null) + { + // Reuse existing logo - no need to fetch + item.LogoId = existingLogo.Id; + return; + } + + // No existing logo - fetch from API + var apiReturn = await httpClient.GetFromJsonAsync($"v1/Favicon/Extract?url={Uri.EscapeDataString(url)}"); + if (apiReturn?.Image is not null) + { + // Create new logo + var newLogo = new Logo + { + Id = Guid.NewGuid(), + Source = domain, + FileData = apiReturn.Image, + MimeType = "image/png", + FetchedAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + context.Logos.Add(newLogo); + item.LogoId = newLogo.Id; + } + } + catch + { + // Ignore favicon extraction errors + } + } + else + { + // URL is empty or just the placeholder - clear any existing logo + item.LogoId = null; + } + } + + /// + /// Gets the effective identity generator language to use. + /// If user has explicitly set a language preference, use that. + /// Otherwise, intelligently match the UI language to an available identity generator language. + /// Falls back to "en" if no match is found. + /// + /// The identity generator language code to use. + private async Task GetEffectiveIdentityLanguageAsync() + { + var explicitLanguage = dbService.Settings.DefaultIdentityLanguage; + + // If user has explicitly set a language preference, use it + if (!string.IsNullOrWhiteSpace(explicitLanguage)) + { + return explicitLanguage; + } + + // Otherwise, try to match UI language to an identity generator language + var uiLanguage = dbService.Settings.AppLanguage; + var mappedLanguage = await jsInteropService.MapUiLanguageToIdentityLanguageAsync(uiLanguage); + + // Return the mapped language, or fall back to "en" if no match found + return mappedLanguage ?? "en"; + } }