using System.Data; using System.Text; using Dapper; using PlotLine.Models; namespace PlotLine.Data; public interface IImportRepository { Task ProjectTitleExistsAsync(string projectTitle); Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle); } public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository { private const string ImportMarker = "Imported via JSON Import Phase 1D"; public async Task ProjectTitleExistsAsync(string projectTitle) { using var connection = connectionFactory.CreateConnection(); var count = await connection.ExecuteScalarAsync( """ SELECT COUNT(1) FROM dbo.Projects WHERE IsArchived = 0 AND ProjectName = @ProjectName; """, new { ProjectName = projectTitle }); return count > 0; } public async Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle) { 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 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)) }, 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 || characterMap.ContainsKey(character.Name.Trim())) { 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, DefaultDescription = CombineText( character.Description, MetadataLine("Role", character.Role), MetadataLine("Importance", character.Importance?.ToString()), character.Notes, ImportMarker) }, transaction, commandType: CommandType.StoredProcedure); characterMap[character.Name.Trim()] = 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 || locationMap.ContainsKey(location.Name.Trim())) { 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); locationMap[location.Name.Trim()] = locationId; locationsCreated++; } foreach (var location in package.Locations.Where(x => !string.IsNullOrWhiteSpace(x.ParentLocationName))) { if (!locationMap.TryGetValue(location.Name.Trim(), out var locationId) || !locationMap.TryGetValue(location.ParentLocationName!.Trim(), out var parentLocationId)) { continue; } await connection.QuerySingleAsync( "dbo.Location_Save", new { LocationID = locationId, ProjectID = projectId, ParentLocationID = parentLocationId, 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++; foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) { var primaryLocationId = ResolveId(locationMap, scene.LocationName); var povCharacterId = ResolveId(characterMap, scene.PovCharacterName); var sceneId = await connection.QuerySingleAsync( "dbo.Scene_Save", new { SceneID = 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( "UPDATE dbo.Scenes SET POVCharacterID = @POVCharacterID, UpdatedDate = SYSUTCDATETIME() WHERE SceneID = @SceneID;", new { SceneID = sceneId, POVCharacterID = povCharacterId.Value }, transaction); } foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap)) { 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), Description = CombineText( plotLine.Description, MetadataLine("Type", plotLine.Type), MetadataLine("Importance", plotLine.Importance?.ToString()), plotLine.Notes, ImportMarker), ParentPlotLineID = (int?)null, SortOrder = (int?)null, Colour = "#2f6f63", IsMainPlot = string.Equals(plotLine.Type?.Trim(), "Main Plot", StringComparison.OrdinalIgnoreCase), 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++; } } } 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, SkippedOptionalFields = skippedFields, UnresolvedWarnings = unresolvedWarnings, Message = "Import complete." }; } catch { transaction.Rollback(); throw; } } private static async Task GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction) { var revisionStatusId = await connection.QuerySingleOrDefaultAsync( "SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder;", transaction: transaction); var timeModeId = await connection.QuerySingleOrDefaultAsync( "SELECT TOP (1) TimeModeID FROM dbo.TimeModes WHERE TimeModeName = N'Relative' ORDER BY SortOrder;", transaction: transaction); var timeConfidenceId = await connection.QuerySingleOrDefaultAsync( "SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences WHERE TimeConfidenceName = N'Unknown' ORDER BY SortOrder;", transaction: transaction); var locationTypes = (await connection.QueryAsync( "SELECT LocationTypeID AS LookupID, TypeName FROM dbo.LocationTypes WHERE IsActive = 1;", transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var roleTypes = (await connection.QueryAsync( "SELECT CharacterRoleInSceneTypeID AS LookupID, TypeName FROM dbo.CharacterRoleInSceneTypes WHERE IsActive = 1;", transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var presenceTypes = (await connection.QueryAsync( "SELECT PresenceTypeID AS LookupID, TypeName FROM dbo.PresenceTypes WHERE IsActive = 1;", transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var plotLineTypes = (await connection.QueryAsync( "SELECT PlotLineTypeID AS LookupID, TypeName FROM dbo.PlotLineTypes WHERE IsActive = 1;", transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var threadTypes = (await connection.QueryAsync( "SELECT ThreadTypeID AS LookupID, TypeName FROM dbo.ThreadTypes WHERE IsActive = 1;", transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var threadStatuses = (await connection.QueryAsync( "SELECT ThreadStatusID AS LookupID, StatusName FROM dbo.ThreadStatuses WHERE IsActive = 1;", transaction: transaction)).ToDictionary(x => x.StatusName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); var threadEventTypes = (await connection.QueryAsync( "SELECT ThreadEventTypeID AS LookupID, TypeName FROM dbo.ThreadEventTypes WHERE IsActive = 1;", transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue) { throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing."); } return new ImportLookupIds( revisionStatusId.Value, timeModeId.Value, timeConfidenceId.Value, locationTypes, roleTypes, presenceTypes, plotLineTypes, threadTypes, threadStatuses, threadEventTypes); } 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) ?? ImportMarker; } private static IReadOnlyList GetSkippedOptionalFields(PlotLineImportPackage package) { var skipped = new List(); var characterNames = package.Characters .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .Select(x => x.Name.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); var locationNames = package.Locations .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .Select(x => x.Name.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !characterNames.Contains(scene.PovCharacterName.Trim()))))) { 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) && !locationNames.Contains(scene.LocationName.Trim()))))) { 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."); } return skipped; } private static IReadOnlyList GetUnresolvedWarnings(PlotLineImportPackage package) { var warnings = new List(); var characterNames = package.Characters .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .Select(x => x.Name.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); var locationNames = package.Locations .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .Select(x => x.Name.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)).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) && !locationNames.Contains(name)).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) && !characterNames.Contains(name)).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}"); } return warnings; } private static IEnumerable BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary characterMap) { var appearances = new Dictionary(); foreach (var sceneCharacter in scene.SceneCharacters) { if (string.IsNullOrWhiteSpace(sceneCharacter.CharacterName) || !characterMap.TryGetValue(sceneCharacter.CharacterName.Trim(), out var characterId)) { continue; } 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) => !string.IsNullOrWhiteSpace(name) && map.TryGetValue(name.Trim(), out var id) ? id : null; 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.Equals("Present", StringComparison.OrdinalIgnoreCase) ? "Main Participant" : normalized.Equals("Supporting", 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 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 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 string SceneKey(int bookOrder, int chapterOrder, int sceneOrder) => $"{bookOrder}/{chapterOrder}/{sceneOrder}"; 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 ThreadTypeIds, IReadOnlyDictionary ThreadStatusIds, IReadOnlyDictionary ThreadEventTypeIds); 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 ImportStatusLookupRow { public int LookupID { get; set; } public string StatusName { get; set; } = string.Empty; } }