using System.Data; using System.Text; using Dapper; using PlotLine.Models; namespace PlotLine.Data; public interface IImportRepository { Task ProjectTitleExistsAsync(string projectTitle, int userId); Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle, int userId); Task> GetIdentityMaxValuesAsync(IEnumerable tableNames); Task GetRelationshipImportLookupsAsync(); Task GetRelationshipImportDiagnosticsAsync(int projectId); Task CreateProjectShellAsync(string projectName, string? description, int userId); Task ApplyProjectBackupJsonAsync(int projectId, string jsonData); } public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository { private const string ImportMarker = "Imported via JSON Import Phase 1F"; public async Task ProjectTitleExistsAsync(string projectTitle, int userId) { using var connection = connectionFactory.CreateConnection(); var count = await connection.ExecuteScalarAsync( "dbo.Project_ExistsByNameForUser", new { ProjectName = projectTitle, UserID = userId }, commandType: CommandType.StoredProcedure); return count > 0; } public async Task> GetIdentityMaxValuesAsync(IEnumerable tableNames) { using var connection = connectionFactory.CreateConnection(); var requestedTables = tableNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var rows = await connection.QueryAsync( "dbo.ProjectBackup_GetIdentityMaxValues", new { TableNames = string.Join(',', requestedTables) }, commandType: CommandType.StoredProcedure); return rows.ToDictionary(row => row.TableName, row => row.MaxID, StringComparer.OrdinalIgnoreCase); } public async Task GetRelationshipImportLookupsAsync() { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync( "dbo.ProjectBackup_GetRelationshipImportLookups", commandType: CommandType.StoredProcedure); return new ProjectBackupRelationshipImportLookups { RevisionStatuses = (await result.ReadAsync()).ToList(), TimeModes = (await result.ReadAsync()).ToList(), DurationUnits = (await result.ReadAsync()).ToList(), TimeConfidences = (await result.ReadAsync()).ToList(), LocationTypes = (await result.ReadAsync()).ToList(), LocationRelationshipTypes = (await result.ReadAsync()).ToList(), PlotLineTypes = (await result.ReadAsync()).ToList(), PlotImportance = (await result.ReadAsync()).ToList(), ThreadTypes = (await result.ReadAsync()).ToList(), ThreadStatuses = (await result.ReadAsync()).ToList(), ThreadEventTypes = (await result.ReadAsync()).ToList(), PlotEventTypes = (await result.ReadAsync()).ToList(), AssetKinds = (await result.ReadAsync()).ToList(), AssetStates = (await result.ReadAsync()).ToList(), AssetEventTypes = (await result.ReadAsync()).ToList(), AssetDependencyTypes = (await result.ReadAsync()).ToList(), AssetCustodyEventTypes = (await result.ReadAsync()).ToList(), CustodyRoles = (await result.ReadAsync()).ToList(), CharacterRoleTypes = (await result.ReadAsync()).ToList(), PresenceTypes = (await result.ReadAsync()).ToList(), KnowledgeStates = (await result.ReadAsync()).ToList(), SceneDependencyTypes = (await result.ReadAsync()).ToList(), SceneNoteTypes = (await result.ReadAsync()).ToList(), SceneAttachmentTypes = (await result.ReadAsync()).ToList(), ScenePurposeTypes = (await result.ReadAsync()).ToList(), SceneMetricTypes = (await result.ReadAsync()).ToList(), RelationshipTypes = (await result.ReadAsync()).ToList(), RelationshipStates = (await result.ReadAsync()).ToList() }; } public async Task GetRelationshipImportDiagnosticsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ProjectBackup_RelationshipImportDiagnostics", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task CreateProjectShellAsync(string projectName, string? description, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Project_Save", new { ProjectID = 0, ProjectName = projectName, Description = description, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task ApplyProjectBackupJsonAsync(int projectId, string jsonData) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.ProjectRestore_ApplyFromJson", new { ProjectID = projectId, SourceRestorePointID = (int?)null, SafetyRestorePointID = (int?)null, JsonData = jsonData }, commandType: CommandType.StoredProcedure, commandTimeout: 180); } public async Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle, int userId) { using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { var booksCreated = 0; var chaptersCreated = 0; var scenesCreated = 0; var charactersCreated = 0; var locationsCreated = 0; var appearancesCreated = 0; var plotLinesCreated = 0; var plotThreadsCreated = 0; var threadEventsCreated = 0; var storyAssetsCreated = 0; var assetStatesCreated = 0; var assetEventsCreated = 0; var assetCustodyEventsCreated = 0; var sceneDependenciesCreated = 0; var skippedFields = GetSkippedOptionalFields(package); var unresolvedWarnings = GetUnresolvedWarnings(package); var sceneMap = new Dictionary(StringComparer.OrdinalIgnoreCase); var projectName = string.IsNullOrWhiteSpace(importedProjectTitle) ? Clean(package.Project.Title) : Clean(importedProjectTitle); var lookupIds = await GetLookupIdsAsync(connection, transaction); var projectId = await connection.QuerySingleAsync( "dbo.Project_Save", new { ProjectID = 0, ProjectName = projectName, Description = CombineText( package.Project.Description, package.Project.Notes, ImportMetadata(package)), UserID = userId }, transaction, commandType: CommandType.StoredProcedure); var characterMap = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var character in package.Characters) { var characterName = Clean(DisplayName(character.DisplayName, character.Name)); if (characterName.Length == 0 || ResolveId(characterMap, character.Name, package.Aliases.Characters).HasValue) { continue; } var characterId = await connection.QuerySingleAsync( "dbo.Character_Save", new { CharacterID = 0, ProjectID = projectId, CharacterName = characterName, ShortName = Truncate(character.ShortName, 100), Sex = (string?)null, BirthDate = (DateTime?)null, AgeAtSeriesStart = (int?)null, AgeReferenceSceneID = (int?)null, Height = (string?)null, EyeColour = (string?)null, CharacterImportance = NormalizeCharacterImportance(character.Role, character.Importance), DefaultDescription = CombineText( character.Description, character.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); AddLookupKeys(characterMap, character.Name, characterId); AddLookupKeys(characterMap, character.DisplayName, characterId); charactersCreated++; } var locationMap = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var location in package.Locations) { var locationName = Clean(DisplayName(location.DisplayName, location.Name)); if (locationName.Length == 0 || ResolveId(locationMap, location.Name, package.Aliases.Locations).HasValue) { continue; } var locationId = await connection.QuerySingleAsync( "dbo.Location_Save", new { LocationID = 0, ProjectID = projectId, ParentLocationID = (int?)null, LocationName = locationName, LocationTypeID = LookupLocationTypeId(lookupIds, location.Type), Description = CombineText(location.Description, location.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); AddLookupKeys(locationMap, location.Name, locationId); AddLookupKeys(locationMap, location.DisplayName, locationId); locationsCreated++; } foreach (var location in package.Locations.Where(x => !string.IsNullOrWhiteSpace(x.ParentLocationName))) { var locationId = ResolveId(locationMap, location.Name, package.Aliases.Locations); var parentLocation = ResolveId(locationMap, location.ParentLocationName, package.Aliases.Locations); if (!locationId.HasValue || !parentLocation.HasValue) { continue; } await connection.QuerySingleAsync( "dbo.Location_Save", new { LocationID = locationId.Value, ProjectID = projectId, ParentLocationID = parentLocation.Value, LocationName = Clean(DisplayName(location.DisplayName, location.Name)), LocationTypeID = LookupLocationTypeId(lookupIds, location.Type), Description = CombineText(location.Description, location.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); } foreach (var book in package.Books.OrderBy(x => x.SeriesOrder)) { var bookId = await connection.QuerySingleAsync( "dbo.Book_Save", new { BookID = 0, ProjectID = projectId, BookTitle = Clean(FormatBookTitle(book)), BookNumber = book.SeriesOrder, Description = CombineText( book.Description, book.DependsOnPreviousBook ? "Depends on previous book." : null, book.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); booksCreated++; foreach (var chapter in book.Chapters.OrderBy(x => x.Order)) { var chapterId = await connection.QuerySingleAsync( "dbo.Chapter_Save", new { ChapterID = 0, BookID = bookId, chapter.ChapterNumber, ChapterTitle = Clean(chapter.Title), Summary = CombineText(chapter.Summary, chapter.Notes, ImportMarker), RevisionStatusID = lookupIds.RevisionStatusID }, transaction, commandType: CommandType.StoredProcedure); chaptersCreated++; var orderedScenes = chapter.Scenes.OrderBy(x => x.Order).ToList(); var defaultSceneId = 0; if (orderedScenes.Count > 0) { defaultSceneId = (await connection.QueryAsync( "dbo.Scene_ListByChapter", new { ChapterID = chapterId }, transaction, commandType: CommandType.StoredProcedure)) .OrderBy(x => x.SortOrder) .ThenBy(x => x.SceneID) .Select(x => x.SceneID) .FirstOrDefault(); } for (var sceneIndex = 0; sceneIndex < orderedScenes.Count; sceneIndex++) { var scene = orderedScenes[sceneIndex]; var primaryLocationId = ResolveId(locationMap, scene.LocationName, package.Aliases.Locations); var povCharacterId = ResolveId(characterMap, scene.PovCharacterName, package.Aliases.Characters); var sceneId = await connection.QuerySingleAsync( "dbo.Scene_Save", new { SceneID = sceneIndex == 0 ? defaultSceneId : 0, ChapterID = chapterId, scene.SceneNumber, SceneTitle = Clean(scene.Title), Summary = CombineText( scene.Summary, povCharacterId.HasValue ? null : MetadataLine("POV", scene.PovCharacterName), primaryLocationId.HasValue ? null : MetadataLine("Location", scene.LocationName), scene.Notes, ImportMarker), TimeModeID = lookupIds.TimeModeID, StartDateTime = (DateTime?)null, EndDateTime = (DateTime?)null, DurationAmount = (decimal?)null, DurationUnitID = (int?)null, RelativeTimeText = Truncate(scene.DateTimeText, 200), TimeConfidenceID = lookupIds.TimeConfidenceID, ScenePurposeNotes = CombineText(scene.PurposeText), SceneOutcomeNotes = CombineText(scene.OutcomeText), RevisionStatusID = lookupIds.RevisionStatusID, PrimaryLocationID = primaryLocationId }, transaction, commandType: CommandType.StoredProcedure); if (povCharacterId.HasValue) { await connection.ExecuteAsync( "dbo.Scene_UpdatePovCharacter", new { SceneID = sceneId, POVCharacterID = povCharacterId.Value }, transaction, commandType: CommandType.StoredProcedure); } foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap, package.Aliases.Characters)) { await connection.QuerySingleAsync( "dbo.SceneCharacter_Save", new { SceneCharacterID = 0, SceneID = sceneId, CharacterID = appearance.CharacterId, RoleInSceneTypeID = ResolveRoleTypeId(lookupIds, appearance.RoleInScene), PresenceTypeID = ResolvePresenceTypeId(lookupIds, appearance.RoleInScene), LocationID = primaryLocationId, EntryLocationID = (int?)null, ExitLocationID = (int?)null, AppearanceNotes = CombineText( MetadataLine("Imported role", appearance.RoleInScene), MetadataLine("Importance", appearance.Importance?.ToString()), appearance.Notes), OutfitDescription = (string?)null, PhysicalCondition = (string?)null, EmotionalState = (string?)null, KnowledgeNotes = (string?)null }, transaction, commandType: CommandType.StoredProcedure); appearancesCreated++; } scenesCreated++; sceneMap[SceneKey(book.SeriesOrder, chapter.Order, scene.Order)] = sceneId; } } } foreach (var plotLine in package.PlotLines) { if (string.IsNullOrWhiteSpace(plotLine.Name)) { continue; } var plotLineId = await connection.QuerySingleAsync( "dbo.PlotLine_Save", new { PlotLineID = 0, ProjectID = projectId, BookID = (int?)null, PlotLineName = Clean(DisplayName(plotLine.DisplayName, plotLine.Name)), PlotLineTypeID = ResolvePlotLineTypeId(lookupIds, plotLine.Type), PlotImportanceID = ResolvePlotImportanceId(lookupIds, plotLine), Description = CombineText( plotLine.Description, MetadataLine("Type", plotLine.Type), MetadataLine("Importance", plotLine.Importance?.ToString()), plotLine.Notes, ImportMarker), ParentPlotLineID = (int?)null, EmergesFromPlotLineID = (int?)null, SortOrder = (int?)null, Colour = "#2f6f63", IsVisibleOnTimeline = true }, transaction, commandType: CommandType.StoredProcedure); plotLinesCreated++; foreach (var thread in plotLine.Threads) { if (string.IsNullOrWhiteSpace(thread.Name)) { continue; } var threadId = await connection.QuerySingleAsync( "dbo.PlotThread_Save", new { PlotThreadID = 0, PlotLineID = plotLineId, ThreadTitle = Clean(DisplayName(thread.DisplayName, thread.Name)), ThreadTypeID = ResolveThreadTypeId(lookupIds, thread.Type), ThreadStatusID = ResolveThreadStatusId(lookupIds, thread.Status), Importance = ClampImportance(thread.Importance), Summary = CombineText(thread.Description, MetadataLine("Status", thread.Status), thread.Notes, ImportMarker), IntroducedSceneID = (int?)null, PlannedResolutionSceneID = (int?)null, ActualResolutionSceneID = (int?)null }, transaction, commandType: CommandType.StoredProcedure); plotThreadsCreated++; var importedEventSceneIds = new HashSet(); foreach (var threadEvent in thread.Events) { if (!TryResolveThreadEventScene(sceneMap, threadEvent, out var sceneId) || importedEventSceneIds.Contains(sceneId)) { continue; } await connection.QuerySingleAsync( "dbo.ThreadEvent_Save", new { ThreadEventID = 0, PlotThreadID = threadId, SceneID = sceneId, EventTypeID = ResolveThreadEventTypeId(lookupIds, threadEvent.EventType), EventTitle = Truncate(Clean(threadEvent.Title).Length == 0 ? Clean(threadEvent.Summary) : threadEvent.Title, 200) ?? "Imported thread event", EventDescription = CombineText( threadEvent.Summary, MetadataLine("Event type", threadEvent.EventType), MetadataLine("Impact", threadEvent.Impact), MetadataLine("Importance", threadEvent.Importance?.ToString()), threadEvent.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); importedEventSceneIds.Add(sceneId); threadEventsCreated++; } } } var firstSceneId = sceneMap.Values.FirstOrDefault(); foreach (var storyAsset in package.StoryAssets) { if (string.IsNullOrWhiteSpace(storyAsset.Name)) { continue; } var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName, package.Aliases.Locations); var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName, package.Aliases.Characters); var storyAssetId = await connection.QuerySingleAsync( "dbo.StoryAsset_Save", new { StoryAssetID = 0, ProjectID = projectId, AssetName = Clean(DisplayName(storyAsset.DisplayName, storyAsset.Name)), AssetKindID = ResolveAssetKindId(lookupIds, storyAsset.Type), Description = CombineText( storyAsset.Description, MetadataLine("Type", storyAsset.Type), initialOwnerCharacterId.HasValue ? null : MetadataLine("Initial owner", storyAsset.InitialOwnerCharacterName), initialLocationId.HasValue ? null : MetadataLine("Initial location", storyAsset.InitialLocationName), storyAsset.Notes, ImportMarker), Importance = ClampImportance(storyAsset.Importance), CurrentStateID = ResolveAssetStateId(lookupIds, storyAsset.States.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.StateName))?.StateName), CurrentLocationID = initialLocationId, IsResolved = false }, transaction, commandType: CommandType.StoredProcedure); storyAssetsCreated++; if (initialOwnerCharacterId.HasValue && firstSceneId > 0) { await connection.QuerySingleAsync( "dbo.AssetCustodyEvent_Save", new { AssetCustodyEventID = 0, StoryAssetID = storyAssetId, SceneID = firstSceneId, AssetCustodyEventTypeID = ResolveAssetCustodyEventTypeId(lookupIds, "Has"), Description = CombineText("Initial owner imported from JSON.", ImportMarker), CustodianNames = storyAsset.InitialOwnerCharacterName, CustodyRoleID = ResolveCustodyRoleId(lookupIds, "Has Custody") }, transaction, commandType: CommandType.StoredProcedure); assetCustodyEventsCreated++; } var importedAssetStateSceneIds = new HashSet(); foreach (var state in storyAsset.States) { if (!TryResolveAssetStateScene(sceneMap, state, out var sceneId) || importedAssetStateSceneIds.Contains(sceneId)) { continue; } await connection.QuerySingleAsync( "dbo.AssetEvent_Save", new { AssetEventID = 0, StoryAssetID = storyAssetId, SceneID = sceneId, AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, "State Changed"), FromStateID = (int?)null, ToStateID = ResolveAssetStateId(lookupIds, state.StateName), EventTitle = Truncate(Clean(state.StateName).Length == 0 ? "Imported asset state" : state.StateName, 200), EventDescription = CombineText(state.Description, state.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); importedAssetStateSceneIds.Add(sceneId); assetStatesCreated++; } var importedAssetEventSceneIds = new HashSet(); foreach (var assetEvent in storyAsset.Events) { if (!TryResolveAssetEventScene(sceneMap, assetEvent, out var sceneId) || importedAssetEventSceneIds.Contains(sceneId)) { continue; } var eventLocationId = ResolveId(locationMap, assetEvent.LocationName, package.Aliases.Locations); if (eventLocationId.HasValue) { await connection.QuerySingleAsync( "dbo.SceneAssetLocation_Save", new { SceneAssetLocationID = 0, SceneID = sceneId, StoryAssetID = storyAssetId, LocationID = eventLocationId.Value, Description = CombineText(assetEvent.Summary, assetEvent.Notes, ImportMarker), UpdateCurrentLocation = true }, transaction, commandType: CommandType.StoredProcedure); } await connection.QuerySingleAsync( "dbo.AssetEvent_Save", new { AssetEventID = 0, StoryAssetID = storyAssetId, SceneID = sceneId, AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, assetEvent.EventType), FromStateID = (int?)null, ToStateID = (int?)null, EventTitle = Truncate(Clean(assetEvent.Title).Length == 0 ? Clean(assetEvent.Summary) : assetEvent.Title, 200) ?? "Imported asset event", EventDescription = CombineText( assetEvent.Summary, MetadataLine("Event type", assetEvent.EventType), MetadataLine("Character", assetEvent.CharacterName), eventLocationId.HasValue ? null : MetadataLine("Location", assetEvent.LocationName), assetEvent.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); importedAssetEventSceneIds.Add(sceneId); assetEventsCreated++; } } var importedSceneDependencyKeys = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var dependency in package.SceneDependencies) { if (!TryResolveSceneReference(sceneMap, dependency.FromScene, out var sourceSceneId) || !TryResolveSceneReference(sceneMap, dependency.ToScene, out var targetSceneId) || sourceSceneId == targetSceneId) { continue; } var dependencyTypeId = ResolveSceneDependencyTypeId(lookupIds, dependency.DependencyType); var dependencyKey = $"{sourceSceneId}/{targetSceneId}/{dependencyTypeId}"; if (!importedSceneDependencyKeys.Add(dependencyKey)) { continue; } await connection.QuerySingleAsync( "dbo.SceneDependency_Save", new { SceneDependencyID = 0, ProjectID = projectId, SourceSceneID = sourceSceneId, TargetSceneID = targetSceneId, SceneDependencyTypeID = dependencyTypeId, Description = CombineText( dependency.Description, MetadataLine("Dependency type", dependency.DependencyType), MetadataLine("Strength", dependency.Strength), dependency.Notes, ImportMarker), IsHardDependency = ResolveSceneDependencyStrength(dependency.Strength) }, transaction, commandType: CommandType.StoredProcedure); sceneDependenciesCreated++; } transaction.Commit(); return new ImportResult { Succeeded = true, ProjectID = projectId, ProjectName = projectName, BooksCreated = booksCreated, ChaptersCreated = chaptersCreated, ScenesCreated = scenesCreated, CharactersCreated = charactersCreated, LocationsCreated = locationsCreated, SceneCharacterAppearancesCreated = appearancesCreated, PlotLinesCreated = plotLinesCreated, PlotThreadsCreated = plotThreadsCreated, ThreadEventsCreated = threadEventsCreated, StoryAssetsCreated = storyAssetsCreated, AssetStatesCreated = assetStatesCreated, AssetEventsCreated = assetEventsCreated, AssetCustodyEventsCreated = assetCustodyEventsCreated, SceneDependenciesCreated = sceneDependenciesCreated, SkippedItemCount = skippedFields.Count, UnresolvedReferenceCount = unresolvedWarnings.Count, SkippedOptionalFields = skippedFields, UnresolvedWarnings = unresolvedWarnings, Message = "Import complete." }; } catch { transaction.Rollback(); throw; } } private static async Task GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction) { using var result = await connection.QueryMultipleAsync( "dbo.Import_LookupIds", transaction: transaction, commandType: CommandType.StoredProcedure); var requiredLookups = await result.ReadSingleAsync(); var locationTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var roleTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var presenceTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var plotLineTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var plotImportance = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var threadTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var threadStatuses = (await result.ReadAsync()).ToDictionary(x => x.StatusName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var threadEventTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var sceneDependencyTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var assetKinds = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var assetStates = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var assetEventTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var assetCustodyEventTypes = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var custodyRoles = (await result.ReadAsync()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); if (!requiredLookups.RevisionStatusID.HasValue || !requiredLookups.TimeModeID.HasValue || !requiredLookups.TimeConfidenceID.HasValue) { throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing."); } return new ImportLookupIds( requiredLookups.RevisionStatusID.Value, requiredLookups.TimeModeID.Value, requiredLookups.TimeConfidenceID.Value, locationTypes, roleTypes, presenceTypes, plotLineTypes, plotImportance, threadTypes, threadStatuses, threadEventTypes, sceneDependencyTypes, assetKinds, assetStates, assetEventTypes, assetCustodyEventTypes, custodyRoles); } private static string FormatBookTitle(ImportBookDto book) => string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}"; private static string ImportMetadata(PlotLineImportPackage package) { var source = string.IsNullOrWhiteSpace(package.Source) ? null : $"Source: {package.Source.Trim()}"; return CombineText( ImportMarker, $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", source, MetadataLine("Package created by", package.CreatedBy), MetadataLine("Package created date", package.CreatedDate), MetadataLine("Package description", package.Description)) ?? ImportMarker; } private static IReadOnlyList GetSkippedOptionalFields(PlotLineImportPackage package) { var skipped = new List(); var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName })); var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName })); if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !ContainsNameKey(characterNames, ResolveAlias(scene.PovCharacterName, package.Aliases.Characters)))))) { skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created."); } if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !ContainsNameKey(locationNames, ResolveAlias(scene.LocationName, package.Aliases.Locations)))))) { skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created."); } if (package.PlotLines.Any(plotLine => plotLine.Importance.HasValue)) { skipped.Add("Plot line importance was stored as text in plot line descriptions; PlotLine records do not have an importance column."); } if (package.PlotLines.Any(plotLine => plotLine.Threads.Any(thread => thread.Events.Any(threadEvent => threadEvent.Importance.HasValue || !string.IsNullOrWhiteSpace(threadEvent.Impact))))) { skipped.Add("Thread event impact and importance were stored in event descriptions."); } if (package.StoryAssets.Any(asset => asset.Events.Any(assetEvent => !string.IsNullOrWhiteSpace(assetEvent.CharacterName)))) { skipped.Add("Asset event character names were stored in event descriptions."); } if (package.SceneDependencies.Any(dependency => !string.IsNullOrWhiteSpace(dependency.DependencyType) || !string.IsNullOrWhiteSpace(dependency.Strength))) { skipped.Add("Scene dependency type and strength labels were mapped to existing dependency fields and preserved in descriptions."); } return skipped; } private static IReadOnlyList GetUnresolvedWarnings(PlotLineImportPackage package) { var warnings = new List(); var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName })); var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName })); foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched POV character: {name}"); } foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.LocationName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations))).Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched scene location: {name}"); } foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).SelectMany(scene => scene.SceneCharacters).Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched scene character: {name}"); } var sceneKeys = package.Books .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => SceneKey(book.SeriesOrder, chapter.Order, scene.Order)))) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var reference in package.PlotLines.SelectMany(plotLine => plotLine.Threads.SelectMany(thread => thread.Events.Select((threadEvent, index) => new { PlotLineName = DisplayName(plotLine.DisplayName, plotLine.Name), ThreadName = DisplayName(thread.DisplayName, thread.Name), EventTitle = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {index + 1}" : threadEvent.Title.Trim(), Event = threadEvent }))) .Where(x => !x.Event.BookOrder.HasValue || !x.Event.ChapterOrder.HasValue || !x.Event.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Event.BookOrder.GetValueOrDefault(), x.Event.ChapterOrder.GetValueOrDefault(), x.Event.SceneOrder.GetValueOrDefault()))) .Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle}") .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unresolved thread event scene reference: {reference}"); } foreach (var name in package.StoryAssets.Select(asset => asset.InitialOwnerCharacterName?.Trim()) .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim()))) .Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched asset character: {name}"); } foreach (var name in package.StoryAssets.Select(asset => asset.InitialLocationName?.Trim()) .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim()))) .Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations))) .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched asset location: {name}"); } foreach (var reference in package.StoryAssets.SelectMany(asset => asset.States.Select((state, index) => new { AssetName = DisplayName(asset.DisplayName, asset.Name), Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(), State = state })) .Where(x => !x.State.BookOrder.HasValue || !x.State.ChapterOrder.HasValue || !x.State.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.State.BookOrder.GetValueOrDefault(), x.State.ChapterOrder.GetValueOrDefault(), x.State.SceneOrder.GetValueOrDefault()))) .Select(x => $"{x.AssetName} / state / {x.Label}") .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unresolved asset state scene reference: {reference}"); } foreach (var reference in package.StoryAssets.SelectMany(asset => asset.Events.Select((assetEvent, index) => new { AssetName = DisplayName(asset.DisplayName, asset.Name), Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(), Event = assetEvent })) .Where(x => !x.Event.BookOrder.HasValue || !x.Event.ChapterOrder.HasValue || !x.Event.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Event.BookOrder.GetValueOrDefault(), x.Event.ChapterOrder.GetValueOrDefault(), x.Event.SceneOrder.GetValueOrDefault()))) .Select(x => $"{x.AssetName} / event / {x.Label}") .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unresolved asset event scene reference: {reference}"); } foreach (var reference in package.SceneDependencies.SelectMany((dependency, index) => new[] { new { Label = $"Dependency {index + 1} fromScene", Reference = dependency.FromScene }, new { Label = $"Dependency {index + 1} toScene", Reference = dependency.ToScene } }) .Where(x => x.Reference?.BookOrder.HasValue != true || !x.Reference.ChapterOrder.HasValue || !x.Reference.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Reference.BookOrder.GetValueOrDefault(), x.Reference.ChapterOrder.GetValueOrDefault(), x.Reference.SceneOrder.GetValueOrDefault()))) .Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}") .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unresolved scene dependency reference: {reference}"); } return warnings; } private static IEnumerable BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary characterMap, Dictionary? aliases) { var appearances = new Dictionary(); foreach (var sceneCharacter in scene.SceneCharacters) { if (string.IsNullOrWhiteSpace(sceneCharacter.CharacterName) || !ResolveId(characterMap, sceneCharacter.CharacterName, aliases).HasValue) { continue; } var characterId = ResolveId(characterMap, sceneCharacter.CharacterName, aliases)!.Value; appearances[characterId] = new ImportAppearance(characterId, sceneCharacter.RoleInScene, sceneCharacter.Importance, sceneCharacter.Notes); } if (povCharacterId.HasValue && !appearances.ContainsKey(povCharacterId.Value)) { appearances[povCharacterId.Value] = new ImportAppearance(povCharacterId.Value, "POV", null, "Auto-added from povCharacterName."); } return appearances.Values; } private static int? ResolveId(IReadOnlyDictionary map, string? name, Dictionary? aliases) { var resolvedName = ResolveAlias(name, aliases); foreach (var key in CandidateNameKeys(resolvedName)) { if (map.TryGetValue(key, out var id) && id > 0) { return id; } } return null; } private static void AddLookupKeys(Dictionary map, string? name, int id) { foreach (var key in CandidateNameKeys(name)) { if (map.TryGetValue(key, out var existingId) && existingId != id) { map[key] = 0; } else { map[key] = id; } } } private static HashSet BuildNameKeySet(IEnumerable names) { var keys = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var name in names) { foreach (var key in CandidateNameKeys(name)) { keys.Add(key); } } return keys; } private static bool ContainsNameKey(HashSet keys, string? name) => CandidateNameKeys(name).Any(keys.Contains); private static string ResolveAlias(string? name, Dictionary? aliases) { if (string.IsNullOrWhiteSpace(name)) { return string.Empty; } var clean = CollapseSpaces(name); if (aliases is null || aliases.Count == 0) { return clean; } foreach (var key in CandidateNameKeys(clean)) { foreach (var alias in aliases) { if (CandidateNameKeys(alias.Key).Contains(key, StringComparer.OrdinalIgnoreCase)) { return CollapseSpaces(alias.Value); } } } return clean; } private static IEnumerable CandidateNameKeys(string? name) { var normalized = NormalizeNameKey(name); if (normalized.Length == 0) { yield break; } yield return normalized; var punctuationInsensitive = new string(normalized.Where(ch => !char.IsPunctuation(ch)).ToArray()); punctuationInsensitive = CollapseSpaces(punctuationInsensitive); if (punctuationInsensitive.Length > 0 && punctuationInsensitive != normalized) { yield return punctuationInsensitive; } } private static string NormalizeNameKey(string? value) => CollapseSpaces(value).ToUpperInvariant(); private static string CollapseSpaces(string? value) { var clean = Clean(value); if (clean.Length == 0) { return string.Empty; } var builder = new StringBuilder(clean.Length); var lastWasSpace = false; foreach (var ch in clean) { if (char.IsWhiteSpace(ch)) { if (!lastWasSpace) { builder.Append(' '); lastWasSpace = true; } } else { builder.Append(ch); lastWasSpace = false; } } return builder.ToString(); } private static int? LookupLocationTypeId(ImportLookupIds lookupIds, string? type) { if (!string.IsNullOrWhiteSpace(type) && lookupIds.LocationTypeIds.TryGetValue(type.Trim(), out var id)) { return id; } return lookupIds.LocationTypeIds.TryGetValue("Other", out var otherId) ? otherId : null; } private static int? ResolveRoleTypeId(ImportLookupIds lookupIds, string? role) { var normalized = Clean(role); if (normalized.Length == 0) { normalized = "Present"; } var roleName = normalized.Equals("POV", StringComparison.OrdinalIgnoreCase) ? "POV Character" : normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase) || normalized.Contains("main", StringComparison.OrdinalIgnoreCase) || normalized.Contains("major", StringComparison.OrdinalIgnoreCase) || normalized.Contains("lead", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Present", StringComparison.OrdinalIgnoreCase) ? "Main Participant" : normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase) || normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase) ? "Supporting Participant" : normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase) ? "Mentioned Only" : normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase) ? "Main Participant" : normalized; if (lookupIds.RoleTypeIds.TryGetValue(roleName, out var id)) { return id; } return lookupIds.RoleTypeIds.TryGetValue("Supporting Participant", out var supportingId) ? supportingId : null; } private static int? ResolvePresenceTypeId(ImportLookupIds lookupIds, string? role) { var normalized = Clean(role); var presenceName = normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase) ? "Mentioned Only" : normalized.Equals("Background", StringComparison.OrdinalIgnoreCase) ? "Present" : normalized.Equals("POV", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase) ? "Present" : normalized.Length == 0 ? "Present" : normalized; if (lookupIds.PresenceTypeIds.TryGetValue(presenceName, out var id)) { return id; } return lookupIds.PresenceTypeIds.TryGetValue("Present", out var presentId) ? presentId : null; } private static int ResolvePlotLineTypeId(ImportLookupIds lookupIds, string? type) { if (!string.IsNullOrWhiteSpace(type) && lookupIds.PlotLineTypeIds.TryGetValue(type.Trim(), out var id)) { return id; } return lookupIds.PlotLineTypeIds.TryGetValue("Side Plot", out var sidePlotId) ? sidePlotId : lookupIds.PlotLineTypeIds.Values.First(); } private static int ResolvePlotImportanceId(ImportLookupIds lookupIds, ImportPlotLineDto plotLine) { if (string.Equals(plotLine.Type?.Trim(), "Main Plot", StringComparison.OrdinalIgnoreCase) && lookupIds.PlotImportanceIds.TryGetValue("Primary", out var primaryId)) { return primaryId; } return lookupIds.PlotImportanceIds.TryGetValue("Secondary", out var secondaryId) ? secondaryId : lookupIds.PlotImportanceIds.Values.First(); } private static int ResolveThreadTypeId(ImportLookupIds lookupIds, string? type) { if (!string.IsNullOrWhiteSpace(type) && lookupIds.ThreadTypeIds.TryGetValue(type.Trim(), out var id)) { return id; } return lookupIds.ThreadTypeIds.TryGetValue("Question", out var questionId) ? questionId : lookupIds.ThreadTypeIds.Values.First(); } private static int ResolveThreadStatusId(ImportLookupIds lookupIds, string? status) { if (!string.IsNullOrWhiteSpace(status) && lookupIds.ThreadStatusIds.TryGetValue(status.Trim(), out var id)) { return id; } return lookupIds.ThreadStatusIds.TryGetValue("Planned", out var plannedId) ? plannedId : lookupIds.ThreadStatusIds.Values.First(); } private static int ResolveThreadEventTypeId(ImportLookupIds lookupIds, string? eventType) { if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.ThreadEventTypeIds.TryGetValue(eventType.Trim(), out var id)) { return id; } return lookupIds.ThreadEventTypeIds.TryGetValue("Mentioned", out var mentionedId) ? mentionedId : lookupIds.ThreadEventTypeIds.Values.First(); } private static int? NormalizeCharacterImportance(string? role, int? importance) { var normalized = Clean(role).ToLowerInvariant(); if (normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase) || normalized.Contains("main", StringComparison.OrdinalIgnoreCase) || normalized.Contains("major", StringComparison.OrdinalIgnoreCase) || normalized.Contains("lead", StringComparison.OrdinalIgnoreCase) || normalized.Contains("antagonist", StringComparison.OrdinalIgnoreCase)) { return 10; } if (normalized.Contains("minor", StringComparison.OrdinalIgnoreCase) || normalized.Contains("background", StringComparison.OrdinalIgnoreCase) || normalized.Contains("cameo", StringComparison.OrdinalIgnoreCase)) { return 2; } if (normalized.Contains("support", StringComparison.OrdinalIgnoreCase) || normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase)) { return 6; } if (importance >= 8) { return 10; } if (importance >= 5) { return 6; } return importance.HasValue ? 2 : null; } private static int ResolveSceneDependencyTypeId(ImportLookupIds lookupIds, string? dependencyType) { var normalized = Clean(dependencyType); if (normalized.Length > 0 && lookupIds.SceneDependencyTypeIds.TryGetValue(normalized, out var id)) { return id; } var mappedType = normalized.ToLowerInvariant() switch { "causes" or "requires" or "blocks" or "follows" => "Must Occur Before", "reveals" or "foreshadows" or "paysoff" or "pays off" => "Reveal Setup", "contradicts" => "Knowledge Dependency", "mirrors" => "Emotional Setup", _ => "Must Occur Before" }; return lookupIds.SceneDependencyTypeIds.TryGetValue(mappedType, out var mappedId) ? mappedId : lookupIds.SceneDependencyTypeIds.Values.First(); } private static bool ResolveSceneDependencyStrength(string? strength) { var normalized = Clean(strength); if (normalized.Length == 0) { return false; } if (int.TryParse(normalized, out var numeric)) { return numeric >= 7; } return normalized.Equals("Hard", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Strong", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Required", StringComparison.OrdinalIgnoreCase) || normalized.Equals("High", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Critical", StringComparison.OrdinalIgnoreCase); } private static int ResolveAssetKindId(ImportLookupIds lookupIds, string? type) { if (!string.IsNullOrWhiteSpace(type) && lookupIds.AssetKindIds.TryGetValue(type.Trim(), out var id)) { return id; } return lookupIds.AssetKindIds.TryGetValue("Object", out var objectId) ? objectId : lookupIds.AssetKindIds.Values.First(); } private static int? ResolveAssetStateId(ImportLookupIds lookupIds, string? state) { if (!string.IsNullOrWhiteSpace(state) && lookupIds.AssetStateIds.TryGetValue(state.Trim(), out var id)) { return id; } return null; } private static int ResolveAssetEventTypeId(ImportLookupIds lookupIds, string? eventType) { if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetEventTypeIds.TryGetValue(eventType.Trim(), out var id)) { return id; } return lookupIds.AssetEventTypeIds.TryGetValue("Mentioned", out var mentionedId) ? mentionedId : lookupIds.AssetEventTypeIds.Values.First(); } private static int ResolveAssetCustodyEventTypeId(ImportLookupIds lookupIds, string? eventType) { if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetCustodyEventTypeIds.TryGetValue(eventType.Trim(), out var id)) { return id; } return lookupIds.AssetCustodyEventTypesFallback(); } private static int ResolveCustodyRoleId(ImportLookupIds lookupIds, string? role) { if (!string.IsNullOrWhiteSpace(role) && lookupIds.CustodyRoleIds.TryGetValue(role.Trim(), out var id)) { return id; } return lookupIds.CustodyRoleIds.TryGetValue("Holder", out var holderId) ? holderId : lookupIds.CustodyRoleIds.Values.First(); } private static int ClampImportance(int? importance) => Math.Clamp(importance ?? 5, 1, 10); private static bool TryResolveThreadEventScene(IReadOnlyDictionary sceneMap, ImportThreadEventDto threadEvent, out int sceneId) { if (threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue) { return sceneMap.TryGetValue(SceneKey(threadEvent.BookOrder.Value, threadEvent.ChapterOrder.Value, threadEvent.SceneOrder.Value), out sceneId); } sceneId = 0; return false; } private static bool TryResolveAssetStateScene(IReadOnlyDictionary sceneMap, ImportAssetStateDto state, out int sceneId) { if (state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue) { return sceneMap.TryGetValue(SceneKey(state.BookOrder.Value, state.ChapterOrder.Value, state.SceneOrder.Value), out sceneId); } sceneId = 0; return false; } private static bool TryResolveAssetEventScene(IReadOnlyDictionary sceneMap, ImportAssetEventDto assetEvent, out int sceneId) { if (assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue) { return sceneMap.TryGetValue(SceneKey(assetEvent.BookOrder.Value, assetEvent.ChapterOrder.Value, assetEvent.SceneOrder.Value), out sceneId); } sceneId = 0; return false; } private static bool TryResolveSceneReference(IReadOnlyDictionary sceneMap, ImportSceneReferenceDto? reference, out int sceneId) { if (reference?.BookOrder.HasValue == true && reference.ChapterOrder.HasValue && reference.SceneOrder.HasValue) { return sceneMap.TryGetValue(SceneKey(reference.BookOrder.Value, reference.ChapterOrder.Value, reference.SceneOrder.Value), out sceneId); } sceneId = 0; return false; } private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder) => $"{bookOrder}/{chapterOrder}/{sceneOrder}"; private static string SceneReferenceKey(ImportSceneReferenceDto? reference) => reference is null ? "(missing)" : $"{reference.BookOrder?.ToString() ?? "?"}/{reference.ChapterOrder?.ToString() ?? "?"}/{reference.SceneOrder?.ToString() ?? "?"}"; private static string Clean(string? value) => (value ?? string.Empty).Trim(); private static string? MetadataLine(string label, string? value) => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}"; private static string? Truncate(string? value, int maxLength) { var clean = Clean(value); return clean.Length == 0 ? null : clean[..Math.Min(clean.Length, maxLength)]; } private static string? CombineText(params string?[] values) { var parts = values .Select(Clean) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (parts.Count == 0) { return null; } var builder = new StringBuilder(); foreach (var part in parts) { if (builder.Length > 0) { builder.AppendLine().AppendLine(); } builder.Append(part); } return builder.ToString(); } private static string DisplayName(string? displayName, string fallback) => string.IsNullOrWhiteSpace(displayName) ? fallback : displayName; private sealed record ImportLookupIds( int RevisionStatusID, int TimeModeID, int TimeConfidenceID, IReadOnlyDictionary LocationTypeIds, IReadOnlyDictionary RoleTypeIds, IReadOnlyDictionary PresenceTypeIds, IReadOnlyDictionary PlotLineTypeIds, IReadOnlyDictionary PlotImportanceIds, IReadOnlyDictionary ThreadTypeIds, IReadOnlyDictionary ThreadStatusIds, IReadOnlyDictionary ThreadEventTypeIds, IReadOnlyDictionary SceneDependencyTypeIds, IReadOnlyDictionary AssetKindIds, IReadOnlyDictionary AssetStateIds, IReadOnlyDictionary AssetEventTypeIds, IReadOnlyDictionary AssetCustodyEventTypeIds, IReadOnlyDictionary CustodyRoleIds) { public int AssetCustodyEventTypesFallback() => AssetCustodyEventTypeIds.TryGetValue("Has", out var hasId) ? hasId : AssetCustodyEventTypeIds.Values.First(); } private sealed record ImportAppearance(int CharacterId, string? RoleInScene, int? Importance, string? Notes); private sealed class ImportLookupRow { public int LookupID { get; set; } public string TypeName { get; set; } = string.Empty; } private sealed class ImportRequiredLookupRow { public int? RevisionStatusID { get; set; } public int? TimeModeID { get; set; } public int? TimeConfidenceID { get; set; } } private sealed class ImportStatusLookupRow { public int LookupID { get; set; } public string StatusName { get; set; } = string.Empty; } private sealed class IdentityMaxRow { public string TableName { get; set; } = string.Empty; public int MaxID { get; set; } } }