using System.Text; using System.Text.Json; using System.Diagnostics; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IImportService { Task PreviewAsync(ImportIndexViewModel model); Task ImportAsync(ImportIndexViewModel model); Task BuildValidationReportAsync(ImportIndexViewModel model); } public sealed class ImportService(IImportRepository imports, ICurrentUserService currentUser, ILogger logger) : IImportService { private const int TitleMaxLength = 200; private const int RelativeTimeMaxLength = 200; private const int LargeTextWarningLength = 100_000; private const int SuspiciousBookCount = 20; private const int SuspiciousChapterCount = 250; private const int SuspiciousSceneCount = 2_000; private enum ProjectImportFormat { Unsupported, LegacyPackage, ProjectBackup } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; private static readonly JsonSerializerOptions RestoreJsonOptions = new() { WriteIndented = false }; public async Task PreviewAsync(ImportIndexViewModel model) { var timer = Stopwatch.StartNew(); model.JsonText = await ReadJsonTextAsync(model); model.Preview = await BuildPreviewAsync(model.JsonText); timer.Stop(); logger.LogInformation("JSON import preview completed in {ElapsedMilliseconds} ms with {ErrorCount} errors, {WarningCount} warnings, and {UnresolvedReferenceCount} unresolved references.", timer.ElapsedMilliseconds, model.Preview.ErrorCount, model.Preview.WarningCount, model.Preview.UnresolvedReferenceCount); return model; } public async Task ImportAsync(ImportIndexViewModel model) { var userId = currentUser.UserId; if (!userId.HasValue) { return new ImportResult { Succeeded = false, Message = "Sign in before importing a project." }; } model.JsonText = await ReadJsonTextAsync(model); var formatValidation = new ImportValidationResult(); var format = DetectImportFormat(model.JsonText, formatValidation); if (format == ProjectImportFormat.ProjectBackup && TryReadProjectBackup(model.JsonText, out var backup, out var backupValidation) && backup is not null) { return await ImportProjectBackupAsync(model, backup, backupValidation, userId.Value); } if (format == ProjectImportFormat.Unsupported) { model.Preview = new ImportPreview { Validation = formatValidation }; return new ImportResult { Succeeded = false, Message = formatValidation.Errors.FirstOrDefault() ?? "The uploaded JSON format is not supported." }; } var preview = await BuildPreviewAsync(model.JsonText); model.Preview = preview; if (!preview.Validation.IsValid || preview.Package is null) { return new ImportResult { Succeeded = false, Message = "The JSON needs to pass validation before it can be imported." }; } if (!model.ConfirmImport) { return new ImportResult { Succeeded = false, Message = "Confirm the import summary before creating records." }; } if (preview.Validation.Warnings.Any() && !model.AcknowledgeWarnings) { return new ImportResult { Succeeded = false, Message = "Review and acknowledge the validation warnings before importing." }; } if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle) { return new ImportResult { Succeeded = false, Message = "A project with this title already exists. Confirm that you want to import another project with the same title." }; } if (model.DryRun) { logger.LogInformation("JSON import dry run completed for project {ProjectTitle} with {WarningCount} warnings and {UnresolvedReferenceCount} unresolved references.", preview.Package.Project.Title, preview.WarningCount, preview.UnresolvedReferenceCount); return BuildDryRunResult(preview); } try { var timer = Stopwatch.StartNew(); var result = await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null, userId.Value); timer.Stop(); logger.LogInformation("JSON import committed in {ElapsedMilliseconds} ms for project {ProjectName}. Created {SceneCount} scenes and skipped/unresolved count {SkippedCount}/{UnresolvedCount}.", timer.ElapsedMilliseconds, result.ProjectName, result.ScenesCreated, result.SkippedItemCount, result.UnresolvedReferenceCount); return result with { ErrorCount = preview.ErrorCount, WarningCount = preview.WarningCount, AutoCorrectionCount = preview.AutoCorrectionCount, SkippedItemCount = preview.SkippedItemCount, UnresolvedReferenceCount = preview.UnresolvedReferenceCount, AutoCorrections = preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList() }; } catch (Exception ex) { logger.LogError(ex, "JSON import failed for project title {ProjectTitle}", preview.Package.Project.Title); return new ImportResult { Succeeded = false, Message = "The import could not be completed, so no data was saved.", TechnicalDetail = ex.Message }; } } public async Task BuildValidationReportAsync(ImportIndexViewModel model) { model.JsonText = await ReadJsonTextAsync(model); var preview = await BuildPreviewAsync(model.JsonText); var builder = new StringBuilder(); builder.AppendLine("PlotDirector JSON Import Validation Report"); builder.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"); builder.AppendLine(); builder.AppendLine($"Project: {preview.Package?.Project.Title ?? "(unreadable)"}"); builder.AppendLine($"Source: {preview.Package?.Source ?? "(not supplied)"}"); builder.AppendLine($"Created by: {preview.Package?.CreatedBy ?? "(not supplied)"}"); builder.AppendLine($"Created date: {preview.Package?.CreatedDate ?? "(not supplied)"}"); builder.AppendLine($"Description: {preview.Package?.Description ?? "(not supplied)"}"); builder.AppendLine(); builder.AppendLine("Health"); builder.AppendLine($"Errors: {preview.ErrorCount}"); builder.AppendLine($"Warnings: {preview.WarningCount}"); builder.AppendLine($"Auto-corrections: {preview.AutoCorrectionCount}"); builder.AppendLine($"Skipped items: {preview.SkippedItemCount}"); builder.AppendLine($"Unresolved references: {preview.UnresolvedReferenceCount}"); builder.AppendLine(); builder.AppendLine($"Characters: {preview.CharacterCount}"); builder.AppendLine($"Locations: {preview.LocationCount}"); builder.AppendLine($"Plot lines: {preview.PlotLineCount}"); builder.AppendLine($"Plot threads: {preview.PlotThreadCount}"); builder.AppendLine($"Thread events: {preview.ThreadEventCount}"); builder.AppendLine($"Story assets: {preview.StoryAssetCount}"); builder.AppendLine($"Asset states: {preview.AssetStateCount}"); builder.AppendLine($"Asset events: {preview.AssetEventCount}"); builder.AppendLine($"Asset custody links: {preview.AssetCustodyCount}"); builder.AppendLine($"Scene dependencies: {preview.SceneDependencyCount}"); builder.AppendLine($"Books: {preview.BookCount}"); builder.AppendLine($"Chapters: {preview.ChapterCount}"); builder.AppendLine($"Scenes: {preview.SceneCount}"); builder.AppendLine($"Scene character appearances: {preview.SceneCharacterAppearanceCount}"); builder.AppendLine(); AppendReportSection(builder, "Errors", preview.Validation.Errors); AppendReportSection(builder, "Warnings", preview.Validation.Warnings); AppendReportSection(builder, "Auto-corrections", preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList()); AppendReportSection(builder, "Skipped items", preview.SkippedItems.Concat(preview.Validation.SkippedItems).Distinct(StringComparer.OrdinalIgnoreCase).ToList()); AppendReportSection(builder, "Unmatched POV names", preview.UnmatchedPovNames); AppendReportSection(builder, "Unmatched location names", preview.UnmatchedLocationNames); AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames); AppendReportSection(builder, "Unresolved thread event references", preview.UnresolvedThreadEventReferences); AppendReportSection(builder, "Unresolved asset references", preview.UnresolvedAssetReferences); AppendReportSection(builder, "Unmatched asset owners", preview.UnmatchedAssetOwnerNames); AppendReportSection(builder, "Unmatched asset locations", preview.UnmatchedAssetLocationNames); AppendReportSection(builder, "Unresolved scene dependency references", preview.UnresolvedSceneDependencyReferences); AppendReportSection(builder, "Alias warnings", preview.AliasWarnings); return builder.ToString(); } private async Task BuildPreviewAsync(string jsonText) { if (string.IsNullOrWhiteSpace(jsonText)) { var emptyValidation = new ImportValidationResult(); emptyValidation.Errors.Add("Paste JSON or upload a JSON file before previewing."); return new ImportPreview { Validation = emptyValidation }; } var formatValidation = new ImportValidationResult(); var format = DetectImportFormat(jsonText, formatValidation); if (format == ProjectImportFormat.ProjectBackup && TryReadProjectBackup(jsonText, out var backup, out var backupValidation)) { if (backup is null) { return new ImportPreview { Validation = backupValidation }; } return await BuildProjectBackupPreviewAsync(backup, backupValidation); } if (format == ProjectImportFormat.Unsupported) { return new ImportPreview { Validation = formatValidation }; } PlotLineImportPackage? package; try { package = JsonSerializer.Deserialize(jsonText, JsonOptions); } catch (JsonException ex) { var invalidJson = new ImportValidationResult(); invalidJson.Errors.Add($"The JSON could not be read: {ex.Message}"); return new ImportPreview { Validation = invalidJson }; } if (package is null) { var invalidPackage = new ImportValidationResult(); invalidPackage.Errors.Add("The JSON import package is empty."); return new ImportPreview { Validation = invalidPackage }; } var validation = Validate(package); AddMetadataWarnings(package, validation); var aliasWarnings = FindAliasWarnings(package); foreach (var warning in aliasWarnings) { validation.Warnings.Add(warning); } var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title) && currentUser.UserId.HasValue && await imports.ProjectTitleExistsAsync(package.Project.Title.Trim(), currentUser.UserId.Value); var duplicateTitle = duplicate ? $"{package.Project.Title.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})" : null; if (duplicate) { validation.Warnings.Add($"A project with the same title already exists. If imported anyway, the new project will be named \"{duplicateTitle}\"."); } return new ImportPreview { Package = package, Validation = validation, HasDuplicateProjectTitle = duplicate, DuplicateImportProjectTitle = duplicateTitle, BookPreviews = BuildBookPreviews(package), UnmatchedPovNames = FindUnmatchedPovNames(package), UnmatchedLocationNames = FindUnmatchedLocationNames(package), UnmatchedSceneCharacterNames = FindUnmatchedSceneCharacterNames(package), UnresolvedThreadEventReferences = FindUnresolvedThreadEventReferences(package), UnresolvedAssetReferences = FindUnresolvedAssetReferences(package), UnmatchedAssetOwnerNames = FindUnmatchedAssetOwnerNames(package), UnmatchedAssetLocationNames = FindUnmatchedAssetLocationNames(package), UnresolvedSceneDependencyReferences = FindUnresolvedSceneDependencyReferences(package), AliasWarnings = aliasWarnings, AutoCorrections = FindAutoCorrections(package), SkippedItems = FindSkippedItems(package) }; } private async Task BuildProjectBackupPreviewAsync(ProjectBackupExport backup, ImportValidationResult validation) { var package = BuildPreviewPackageFromBackup(backup); var projectTitle = package.Project.Title; var duplicate = !string.IsNullOrWhiteSpace(projectTitle) && currentUser.UserId.HasValue && await imports.ProjectTitleExistsAsync(projectTitle.Trim(), currentUser.UserId.Value); var duplicateTitle = duplicate ? $"{projectTitle.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})" : null; validation.Warnings.Add("This is a PlotDirector project backup. It will be imported as a new project, not restored over an existing project."); if (duplicate) { validation.Warnings.Add($"A project with the same title already exists. If imported anyway, the new project will be named \"{duplicateTitle}\"."); } return new ImportPreview { Package = package, Validation = validation, HasDuplicateProjectTitle = duplicate, DuplicateImportProjectTitle = duplicateTitle, BookPreviews = BuildBookPreviews(package) }; } private async Task ImportProjectBackupAsync(ImportIndexViewModel model, ProjectBackupExport backup, ImportValidationResult validation, int userId) { var preview = await BuildProjectBackupPreviewAsync(backup, validation); model.Preview = preview; if (!preview.Validation.IsValid) { return new ImportResult { Succeeded = false, Message = "The project backup needs to pass validation before it can be imported." }; } if (!model.ConfirmImport) { return new ImportResult { Succeeded = false, Message = "Confirm the import summary before creating records." }; } if (preview.Validation.Warnings.Any() && !model.AcknowledgeWarnings) { return new ImportResult { Succeeded = false, Message = "Review and acknowledge the validation warnings before importing." }; } if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle) { return new ImportResult { Succeeded = false, Message = "A project with this title already exists. Confirm that you want to import another project with the adjusted imported title." }; } if (model.DryRun) { return BuildBackupDryRunResult(preview); } try { var projectName = preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle! : preview.Package!.Project.Title; var projectId = await imports.CreateProjectShellAsync(projectName, backup.Project?.Description, userId); var maxIds = await imports.GetIdentityMaxValuesAsync(ProjectBackupIdentityTables); var relationshipLookups = await imports.GetRelationshipImportLookupsAsync(); var remapWarnings = new List(); RemapProjectBackupForImport(backup, projectId, projectName, maxIds, relationshipLookups, remapWarnings); foreach (var warning in remapWarnings) { logger.LogWarning("Project backup relationship import warning for {ProjectName}: {Warning}", projectName, warning); } var restoreJson = JsonSerializer.Serialize(backup, RestoreJsonOptions); await imports.ApplyProjectBackupJsonAsync(projectId, restoreJson); var relationshipDiagnostics = await imports.GetRelationshipImportDiagnosticsAsync(projectId); logger.LogInformation( "Project backup relationship import diagnostics for project {ProjectID}: characters {CharacterCount}, relationships {RelationshipCount}, events {RelationshipEventCount}, map-compatible relationships {MapRelationshipCount}, missing source {MissingSourceCharacterCount}, missing target {MissingTargetCharacterCount}, missing type {MissingRelationshipTypeCount}, missing initial state {MissingInitialStateCount}, missing event relationship {MissingEventRelationshipCount}, missing event scene {MissingEventSceneCount}, missing event state {MissingEventStateCount}, archived relationships {ArchivedRelationshipCount}, archived endpoints {ArchivedCharacterEndpointCount}.", projectId, relationshipDiagnostics.CharacterCount, relationshipDiagnostics.RelationshipCount, relationshipDiagnostics.RelationshipEventCount, relationshipDiagnostics.MapRelationshipCount, relationshipDiagnostics.MissingSourceCharacterCount, relationshipDiagnostics.MissingTargetCharacterCount, relationshipDiagnostics.MissingRelationshipTypeCount, relationshipDiagnostics.MissingInitialStateCount, relationshipDiagnostics.MissingEventRelationshipCount, relationshipDiagnostics.MissingEventSceneCount, relationshipDiagnostics.MissingEventStateCount, relationshipDiagnostics.ArchivedRelationshipCount, relationshipDiagnostics.ArchivedCharacterEndpointCount); if (relationshipDiagnostics.RelationshipCount > 0 && relationshipDiagnostics.MapRelationshipCount + relationshipDiagnostics.ArchivedRelationshipCount < relationshipDiagnostics.RelationshipCount) { logger.LogWarning("Imported project {ProjectID} has relationship records that are not compatible with Relationship Map joins. Check relationship import diagnostics for missing endpoint/type/state counts.", projectId); } return new ImportResult { Succeeded = true, ProjectID = projectId, ProjectName = projectName, BooksCreated = backup.Data.Books.Count, ChaptersCreated = backup.Data.Chapters.Count, ScenesCreated = backup.Data.Scenes.Count, CharactersCreated = backup.Data.Characters.Count, LocationsCreated = backup.Data.Locations.Count, SceneCharacterAppearancesCreated = backup.Data.SceneCharacters.Count, PlotLinesCreated = backup.Data.PlotLines.Count, PlotThreadsCreated = backup.Data.PlotThreads.Count, ThreadEventsCreated = backup.Data.ThreadEvents.Count, StoryAssetsCreated = backup.Data.StoryAssets.Count, AssetStatesCreated = backup.Data.AssetStates.Count(x => x.ProjectID == projectId), AssetEventsCreated = backup.Data.AssetEvents.Count, AssetCustodyEventsCreated = backup.Data.AssetCustodyEvents.Count, SceneDependenciesCreated = backup.Data.SceneDependencies.Count, WarningCount = preview.WarningCount, Message = "Project backup imported as a new project." }; } catch (Exception ex) { logger.LogError(ex, "Project backup import failed for project title {ProjectTitle}.", preview.Package?.Project.Title); return new ImportResult { Succeeded = false, Message = "The project backup could not be imported, so no project data was saved.", TechnicalDetail = ex.Message }; } } private static ImportResult BuildBackupDryRunResult(ImportPreview preview) => new() { Succeeded = true, IsDryRun = true, ProjectName = preview.Package?.Project.Title ?? "Dry run", BooksCreated = preview.BookCount, ChaptersCreated = preview.ChapterCount, ScenesCreated = preview.SceneCount, CharactersCreated = preview.CharacterCount, LocationsCreated = preview.LocationCount, SceneCharacterAppearancesCreated = preview.SceneCharacterAppearanceCount, PlotLinesCreated = preview.PlotLineCount, PlotThreadsCreated = preview.PlotThreadCount, ThreadEventsCreated = preview.ThreadEventCount, StoryAssetsCreated = preview.StoryAssetCount, AssetStatesCreated = preview.AssetStateCount, AssetEventsCreated = preview.AssetEventCount, AssetCustodyEventsCreated = preview.AssetCustodyCount, SceneDependenciesCreated = preview.SceneDependencyCount, WarningCount = preview.WarningCount, Message = "Dry run complete. No data was saved." }; private static bool TryReadProjectBackup(string jsonText, out ProjectBackupExport? backup, out ImportValidationResult validation) { backup = null; validation = new ImportValidationResult(); try { using var document = JsonDocument.Parse(jsonText, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); if (!TryGetString(document.RootElement, "format", out var format) && !TryGetString(document.RootElement, "Format", out format)) { return false; } if (!string.Equals(format, "PlotWeaver.ProjectBackup", StringComparison.Ordinal)) { validation.Errors.Add("The project backup format is not recognised."); return true; } } catch (JsonException ex) { validation.Errors.Add($"The JSON could not be read: {ex.Message}"); return true; } try { backup = JsonSerializer.Deserialize(jsonText, JsonOptions); } catch (JsonException ex) { validation.Errors.Add($"The project backup could not be read: {ex.Message}"); return true; } if (backup is null) { validation.Errors.Add("The project backup is empty."); return true; } if (!string.Equals(backup.Format, "PlotWeaver.ProjectBackup", StringComparison.Ordinal)) { validation.Errors.Add("The project backup format is not recognised."); } if (!string.Equals(backup.FormatVersion, "1.0", StringComparison.Ordinal)) { validation.Errors.Add($"Project backup format version \"{backup.FormatVersion}\" is not supported."); } if (backup.Project is null || string.IsNullOrWhiteSpace(backup.Project.ProjectName)) { validation.Errors.Add("The project backup does not contain a project name."); } return true; } private static ProjectImportFormat DetectImportFormat(string jsonText, ImportValidationResult validation) { try { using var document = JsonDocument.Parse(jsonText, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); var root = document.RootElement; if (TryGetString(root, "format", out var format) || TryGetString(root, "Format", out format)) { if (string.Equals(format, "PlotWeaver.ProjectBackup", StringComparison.Ordinal)) { return ProjectImportFormat.ProjectBackup; } validation.Errors.Add($"The project import format \"{format}\" is not supported."); return ProjectImportFormat.Unsupported; } if ((TryGetString(root, "packageVersion", out _) || TryGetString(root, "PackageVersion", out _)) && (root.TryGetProperty("books", out _) || root.TryGetProperty("Books", out _) || root.TryGetProperty("characters", out _) || root.TryGetProperty("Characters", out _) || root.TryGetProperty("locations", out _) || root.TryGetProperty("Locations", out _))) { return ProjectImportFormat.LegacyPackage; } validation.Errors.Add("The uploaded JSON is not a recognised PlotDirector import package or project backup."); return ProjectImportFormat.Unsupported; } catch (JsonException ex) { validation.Errors.Add($"The JSON could not be read: {ex.Message}"); return ProjectImportFormat.Unsupported; } } private static bool TryGetString(JsonElement element, string propertyName, out string? value) { value = null; if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) { return false; } value = property.GetString(); return true; } private static PlotLineImportPackage BuildPreviewPackageFromBackup(ProjectBackupExport backup) { var package = new PlotLineImportPackage { PackageVersion = backup.FormatVersion, Source = backup.Format, CreatedBy = backup.SourceApplication, CreatedDate = backup.ExportedAtUtc.ToString("yyyy-MM-dd HH:mm 'UTC'"), Description = "Imported from a PlotDirector project backup.", Project = new ImportProjectDto { Title = backup.Project?.ProjectName ?? string.Empty, Description = backup.Project?.Description }, Characters = backup.Data.Characters .Select(character => new ImportCharacterDto { Name = character.CharacterName, DisplayName = character.CharacterName, ShortName = character.ShortName, Importance = character.CharacterImportance, Description = character.DefaultDescription }) .ToList(), Locations = backup.Data.Locations .Select(location => new ImportLocationDto { Name = location.LocationName, DisplayName = location.LocationName, Type = location.LocationTypeName, ParentLocationName = location.ParentLocationName, Description = location.Description }) .ToList(), PlotLines = backup.Data.PlotLines .Select(plotLine => new ImportPlotLineDto { Name = plotLine.PlotLineName, DisplayName = plotLine.PlotLineName, Type = plotLine.PlotLineTypeName, Description = plotLine.Description, Threads = backup.Data.PlotThreads .Where(thread => thread.PlotLineID == plotLine.PlotLineID) .Select(thread => new ImportPlotThreadDto { Name = thread.ThreadTitle, DisplayName = thread.ThreadTitle, Type = thread.ThreadTypeName, Status = thread.ThreadStatusName, Importance = thread.Importance, Description = thread.Summary, Events = backup.Data.ThreadEvents .Where(threadEvent => threadEvent.PlotThreadID == thread.PlotThreadID) .Select(threadEvent => new ImportThreadEventDto { Title = threadEvent.EventTitle, EventType = threadEvent.EventTypeName, Summary = threadEvent.EventDescription }) .ToList() }) .ToList() }) .ToList(), StoryAssets = backup.Data.StoryAssets .Select(asset => new ImportStoryAssetDto { Name = asset.AssetName, DisplayName = asset.AssetName, Type = asset.KindName, Importance = asset.Importance, Description = asset.Description, InitialLocationName = asset.CurrentLocationName, States = backup.Data.AssetEvents .Where(assetEvent => assetEvent.StoryAssetID == asset.StoryAssetID) .Select(assetEvent => new ImportAssetStateDto { StateName = assetEvent.ToStateName, Description = assetEvent.EventDescription }) .ToList(), Events = backup.Data.AssetEvents .Where(assetEvent => assetEvent.StoryAssetID == asset.StoryAssetID) .Select(assetEvent => new ImportAssetEventDto { Title = assetEvent.EventTitle, EventType = assetEvent.AssetEventTypeName, Summary = assetEvent.EventDescription }) .ToList() }) .ToList(), Books = backup.Data.Books .OrderBy(book => book.SortOrder) .ThenBy(book => book.BookNumber) .Select(book => new ImportBookDto { Title = book.BookTitle, SeriesOrder = book.BookNumber, Description = book.Description, Chapters = backup.Data.Chapters .Where(chapter => chapter.BookID == book.BookID) .OrderBy(chapter => chapter.SortOrder) .Select(chapter => new ImportChapterDto { ChapterNumber = chapter.ChapterNumber, Title = chapter.ChapterTitle, Order = chapter.SortOrder, Summary = chapter.Summary, Scenes = backup.Data.Scenes .Where(scene => scene.ChapterID == chapter.ChapterID) .OrderBy(scene => scene.SortOrder) .Select(scene => new ImportSceneDto { SceneNumber = scene.SceneNumber, Title = scene.SceneTitle, Order = scene.SortOrder, Summary = scene.Summary, PovCharacterName = scene.POVCharacterID.HasValue ? backup.Data.Characters.FirstOrDefault(character => character.CharacterID == scene.POVCharacterID.Value)?.CharacterName : null, LocationName = scene.PrimaryLocationName, DateTimeText = scene.RelativeTimeText, PurposeText = scene.ScenePurposeNotes, OutcomeText = scene.SceneOutcomeNotes, SceneCharacters = backup.Data.SceneCharacters .Where(appearance => appearance.SceneID == scene.SceneID) .Select(appearance => new ImportSceneCharacterDto { CharacterName = appearance.CharacterName, RoleInScene = appearance.RoleInSceneTypeName, Notes = appearance.AppearanceNotes }) .ToList() }) .ToList() }) .ToList() }) .ToList() }; return package; } private static readonly string[] ProjectBackupIdentityTables = [ "ProjectTimelineSettings", "TimelineViewPresets", "SceneMetricTypes", "Books", "Chapters", "Scenes", "SceneOutcomes", "PlotLines", "PlotThreads", "ThreadEvents", "AssetStates", "StoryAssets", "AssetEvents", "AssetDependencies", "AssetCustodyEvents", "AssetCustodyEventCharacters", "Characters", "SceneCharacters", "CharacterAttributeTypes", "CharacterAttributeEvents", "CharacterKnowledge", "CharacterRelationships", "RelationshipEvents", "Locations", "LocationRelationships", "SceneAssetLocations", "SceneDependencies", "ValidationRuns", "ContinuityWarnings", "SceneNotes", "SceneWorkflow", "SceneChecklistItems", "SceneAttachments", "ChapterGoals", "Scenarios", "ScenarioSceneOrder", "ScenarioValidationRuns", "ScenarioWarnings" ]; private static void RemapProjectBackupForImport( ProjectBackupExport backup, int projectId, string projectName, IReadOnlyDictionary maxIds, ProjectBackupRelationshipImportLookups relationshipLookups, ICollection warnings) { if (backup.Project is null) { return; } var maps = new Dictionary>(StringComparer.OrdinalIgnoreCase); var nextIds = ProjectBackupIdentityTables.ToDictionary(table => table, table => maxIds.TryGetValue(table, out var maxId) ? maxId : 0, StringComparer.OrdinalIgnoreCase); int Next(string table) => nextIds[table] = nextIds[table] + 1; Dictionary Map(string table) => maps.TryGetValue(table, out var map) ? map : maps[table] = new Dictionary(); int NewId(string table, int oldId) { var map = Map(table); if (!map.TryGetValue(oldId, out var newId)) { newId = Next(table); map[oldId] = newId; } return newId; } int ExistingOrNew(string table, int oldId) => maps.TryGetValue(table, out var map) && map.TryGetValue(oldId, out var newId) ? newId : oldId; int? ExistingOrNewNullable(string table, int? oldId) => oldId.HasValue ? ExistingOrNew(table, oldId.Value) : null; static Dictionary LookupByName(IEnumerable rows) => rows.Where(x => !string.IsNullOrWhiteSpace(x.LookupName)) .GroupBy(x => x.LookupName.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First().LookupID, StringComparer.OrdinalIgnoreCase); var revisionStatusIds = LookupByName(relationshipLookups.RevisionStatuses); var timeModeIds = LookupByName(relationshipLookups.TimeModes); var durationUnitIds = LookupByName(relationshipLookups.DurationUnits); var timeConfidenceIds = LookupByName(relationshipLookups.TimeConfidences); var locationTypeIds = LookupByName(relationshipLookups.LocationTypes); var locationRelationshipTypeIds = LookupByName(relationshipLookups.LocationRelationshipTypes); var plotLineTypeIds = LookupByName(relationshipLookups.PlotLineTypes); var threadTypeIds = LookupByName(relationshipLookups.ThreadTypes); var threadStatusIds = LookupByName(relationshipLookups.ThreadStatuses); var threadEventTypeIds = LookupByName(relationshipLookups.ThreadEventTypes); var assetKindIds = LookupByName(relationshipLookups.AssetKinds); var assetStateIds = LookupByName(relationshipLookups.AssetStates); var assetEventTypeIds = LookupByName(relationshipLookups.AssetEventTypes); var assetDependencyTypeIds = LookupByName(relationshipLookups.AssetDependencyTypes); var assetCustodyEventTypeIds = LookupByName(relationshipLookups.AssetCustodyEventTypes); var custodyRoleIds = LookupByName(relationshipLookups.CustodyRoles); var characterRoleTypeIds = LookupByName(relationshipLookups.CharacterRoleTypes); var presenceTypeIds = LookupByName(relationshipLookups.PresenceTypes); var knowledgeStateIds = LookupByName(relationshipLookups.KnowledgeStates); var sceneDependencyTypeIds = LookupByName(relationshipLookups.SceneDependencyTypes); var sceneNoteTypeIds = LookupByName(relationshipLookups.SceneNoteTypes); var sceneAttachmentTypeIds = LookupByName(relationshipLookups.SceneAttachmentTypes); var scenePurposeTypeIds = LookupByName(relationshipLookups.ScenePurposeTypes); var sceneMetricTypeIds = LookupByName(relationshipLookups.SceneMetricTypes); var relationshipTypeIdsByName = relationshipLookups.RelationshipTypes .Where(x => !string.IsNullOrWhiteSpace(x.TypeName)) .GroupBy(x => x.TypeName.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First().RelationshipTypeID, StringComparer.OrdinalIgnoreCase); var relationshipStateIdsByName = relationshipLookups.RelationshipStates .Where(x => !string.IsNullOrWhiteSpace(x.StateName)) .GroupBy(x => x.StateName.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First().RelationshipStateID, StringComparer.OrdinalIgnoreCase); int ResolveLookupID(Dictionary lookup, int currentId, string? lookupName, string context) { if (!string.IsNullOrWhiteSpace(lookupName) && lookup.TryGetValue(lookupName.Trim(), out var resolvedId)) { return resolvedId; } warnings.Add($"{context} has lookup value \"{lookupName}\" which was not found in the target database. The exported ID {currentId} was retained."); return currentId; } int? ResolveNullableLookupID(Dictionary lookup, int? currentId, string? lookupName, string context) { if (!currentId.HasValue) { return null; } return ResolveLookupID(lookup, currentId.Value, lookupName, context); } int? ResolveMappedOrLookupID(string table, Dictionary lookup, int? currentId, string? lookupName, string context) { if (!currentId.HasValue) { return null; } if (maps.TryGetValue(table, out var map) && map.TryGetValue(currentId.Value, out var mappedId)) { return mappedId; } return ResolveLookupID(lookup, currentId.Value, lookupName, context); } int ResolveRelationshipTypeID(CharacterRelationship relationship) { if (!string.IsNullOrWhiteSpace(relationship.RelationshipTypeName) && relationshipTypeIdsByName.TryGetValue(relationship.RelationshipTypeName.Trim(), out var relationshipTypeId)) { return relationshipTypeId; } warnings.Add($"Relationship {relationship.CharacterRelationshipID} between \"{relationship.CharacterAName}\" and \"{relationship.CharacterBName}\" has relationship type \"{relationship.RelationshipTypeName}\" which was not found in the target database. The exported ID {relationship.RelationshipTypeID} was retained."); return relationship.RelationshipTypeID; } int? ResolveRelationshipStateID(int? stateId, string? stateName, string context) { if (!stateId.HasValue) { return null; } if (!string.IsNullOrWhiteSpace(stateName) && relationshipStateIdsByName.TryGetValue(stateName.Trim(), out var relationshipStateId)) { return relationshipStateId; } warnings.Add($"{context} has relationship state \"{stateName}\" which was not found in the target database. The exported ID {stateId.Value} was retained."); return stateId; } backup.Project.ProjectID = projectId; backup.Project.ProjectName = projectName; backup.Project.IsArchived = false; backup.Project.ArchivedDate = null; backup.Project.ArchivedReason = null; foreach (var settings in backup.Data.TimelineSettings) { settings.ProjectTimelineSettingsID = NewId("ProjectTimelineSettings", settings.ProjectTimelineSettingsID); settings.ProjectID = projectId; } foreach (var metric in backup.Data.SceneMetricTypes.Where(metric => metric.ProjectID.HasValue)) { metric.MetricTypeID = NewId("SceneMetricTypes", metric.MetricTypeID); metric.ProjectID = projectId; } foreach (var assetState in backup.Data.AssetStates.Where(state => state.ProjectID.HasValue)) { assetState.AssetStateID = NewId("AssetStates", assetState.AssetStateID); assetState.ProjectID = projectId; assetState.AssetKindID = ResolveNullableLookupID(assetKindIds, assetState.AssetKindID, assetState.KindName, $"Asset state \"{assetState.StateName}\" kind"); } foreach (var attributeType in backup.Data.CharacterAttributeTypes.Where(type => type.ProjectID.HasValue)) { attributeType.CharacterAttributeTypeID = NewId("CharacterAttributeTypes", attributeType.CharacterAttributeTypeID); attributeType.ProjectID = projectId; } foreach (var book in backup.Data.Books) { book.BookID = NewId("Books", book.BookID); book.ProjectID = projectId; } foreach (var character in backup.Data.Characters) { character.CharacterID = NewId("Characters", character.CharacterID); character.ProjectID = projectId; character.AgeReferenceSceneID = ExistingOrNewNullable("Scenes", character.AgeReferenceSceneID); } foreach (var location in backup.Data.Locations) { location.LocationID = NewId("Locations", location.LocationID); location.ProjectID = projectId; location.LocationTypeID = ResolveNullableLookupID(locationTypeIds, location.LocationTypeID, location.LocationTypeName, $"Location \"{location.LocationName}\" type"); } foreach (var chapter in backup.Data.Chapters) { chapter.ChapterID = NewId("Chapters", chapter.ChapterID); chapter.BookID = ExistingOrNew("Books", chapter.BookID); chapter.POVCharacterID = ExistingOrNewNullable("Characters", chapter.POVCharacterID); chapter.RevisionStatusID = ResolveLookupID(revisionStatusIds, chapter.RevisionStatusID, chapter.RevisionStatusName, $"Chapter \"{chapter.ChapterTitle}\" revision status"); } foreach (var scene in backup.Data.Scenes) { scene.SceneID = NewId("Scenes", scene.SceneID); scene.ChapterID = ExistingOrNew("Chapters", scene.ChapterID); scene.POVCharacterID = ExistingOrNewNullable("Characters", scene.POVCharacterID); scene.PrimaryLocationID = ExistingOrNewNullable("Locations", scene.PrimaryLocationID); scene.TimeModeID = ResolveLookupID(timeModeIds, scene.TimeModeID, scene.TimeModeName, $"Scene \"{scene.SceneTitle}\" time mode"); scene.DurationUnitID = ResolveNullableLookupID(durationUnitIds, scene.DurationUnitID, scene.DurationUnitName, $"Scene \"{scene.SceneTitle}\" duration unit"); scene.TimeConfidenceID = ResolveLookupID(timeConfidenceIds, scene.TimeConfidenceID, scene.TimeConfidenceName, $"Scene \"{scene.SceneTitle}\" time confidence"); scene.RevisionStatusID = ResolveLookupID(revisionStatusIds, scene.RevisionStatusID, scene.RevisionStatusName, $"Scene \"{scene.SceneTitle}\" revision status"); } foreach (var location in backup.Data.Locations) { location.ParentLocationID = ExistingOrNewNullable("Locations", location.ParentLocationID); } foreach (var character in backup.Data.Characters) { character.AgeReferenceSceneID = ExistingOrNewNullable("Scenes", character.AgeReferenceSceneID); } foreach (var purpose in backup.Data.ScenePurposes) { purpose.SceneID = ExistingOrNew("Scenes", purpose.SceneID); purpose.ScenePurposeTypeID = ResolveLookupID(scenePurposeTypeIds, purpose.ScenePurposeTypeID, purpose.PurposeName, $"Scene purpose \"{purpose.PurposeName}\""); } foreach (var outcome in backup.Data.SceneOutcomes) { outcome.SceneOutcomeID = NewId("SceneOutcomes", outcome.SceneOutcomeID); outcome.SceneID = ExistingOrNew("Scenes", outcome.SceneID); } foreach (var metricValue in backup.Data.SceneMetricValues) { metricValue.SceneID = ExistingOrNew("Scenes", metricValue.SceneID); metricValue.MetricTypeID = ResolveMappedOrLookupID("SceneMetricTypes", sceneMetricTypeIds, metricValue.MetricTypeID, metricValue.MetricName, $"Scene metric value \"{metricValue.MetricName}\"") ?? metricValue.MetricTypeID; } foreach (var metricSetting in backup.Data.TimelineMetricSettings) { metricSetting.ProjectID = projectId; metricSetting.MetricTypeID = ResolveMappedOrLookupID("SceneMetricTypes", sceneMetricTypeIds, metricSetting.MetricTypeID, metricSetting.MetricName, $"Timeline metric setting \"{metricSetting.MetricName}\"") ?? metricSetting.MetricTypeID; } foreach (var preset in backup.Data.TimelineViewPresets) { preset.TimelineViewPresetID = NewId("TimelineViewPresets", preset.TimelineViewPresetID); preset.ProjectID = projectId; preset.BookID = ExistingOrNewNullable("Books", preset.BookID); } foreach (var plotLine in backup.Data.PlotLines) { plotLine.PlotLineID = NewId("PlotLines", plotLine.PlotLineID); plotLine.ProjectID = projectId; plotLine.BookID = ExistingOrNewNullable("Books", plotLine.BookID); plotLine.PlotLineTypeID = ResolveLookupID(plotLineTypeIds, plotLine.PlotLineTypeID, plotLine.PlotLineTypeName, $"Plot line \"{plotLine.PlotLineName}\" type"); } foreach (var plotLine in backup.Data.PlotLines) { plotLine.ParentPlotLineID = ExistingOrNewNullable("PlotLines", plotLine.ParentPlotLineID); } foreach (var thread in backup.Data.PlotThreads) { thread.PlotThreadID = NewId("PlotThreads", thread.PlotThreadID); thread.PlotLineID = ExistingOrNew("PlotLines", thread.PlotLineID); thread.ProjectID = projectId; thread.ThreadTypeID = ResolveLookupID(threadTypeIds, thread.ThreadTypeID, thread.ThreadTypeName, $"Plot thread \"{thread.ThreadTitle}\" type"); thread.ThreadStatusID = ResolveLookupID(threadStatusIds, thread.ThreadStatusID, thread.ThreadStatusName, $"Plot thread \"{thread.ThreadTitle}\" status"); thread.IntroducedSceneID = ExistingOrNewNullable("Scenes", thread.IntroducedSceneID); thread.PlannedResolutionSceneID = ExistingOrNewNullable("Scenes", thread.PlannedResolutionSceneID); thread.ActualResolutionSceneID = ExistingOrNewNullable("Scenes", thread.ActualResolutionSceneID); } foreach (var threadEvent in backup.Data.ThreadEvents) { threadEvent.ThreadEventID = NewId("ThreadEvents", threadEvent.ThreadEventID); threadEvent.PlotThreadID = ExistingOrNew("PlotThreads", threadEvent.PlotThreadID); threadEvent.PlotLineID = ExistingOrNew("PlotLines", threadEvent.PlotLineID); threadEvent.SceneID = ExistingOrNew("Scenes", threadEvent.SceneID); threadEvent.EventTypeID = ResolveLookupID(threadEventTypeIds, threadEvent.EventTypeID, threadEvent.EventTypeName, $"Thread event \"{threadEvent.EventTitle}\" type"); } foreach (var asset in backup.Data.StoryAssets) { asset.StoryAssetID = NewId("StoryAssets", asset.StoryAssetID); asset.ProjectID = projectId; asset.AssetKindID = ResolveLookupID(assetKindIds, asset.AssetKindID, asset.KindName, $"Story asset \"{asset.AssetName}\" kind"); asset.CurrentStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, asset.CurrentStateID, asset.CurrentStateName, $"Story asset \"{asset.AssetName}\" current state"); asset.CurrentLocationID = ExistingOrNewNullable("Locations", asset.CurrentLocationID); } foreach (var assetEvent in backup.Data.AssetEvents) { assetEvent.AssetEventID = NewId("AssetEvents", assetEvent.AssetEventID); assetEvent.StoryAssetID = ExistingOrNew("StoryAssets", assetEvent.StoryAssetID); assetEvent.ProjectID = projectId; assetEvent.SceneID = ExistingOrNew("Scenes", assetEvent.SceneID); assetEvent.AssetKindID = ResolveLookupID(assetKindIds, assetEvent.AssetKindID, assetEvent.KindName, $"Asset event \"{assetEvent.EventTitle}\" kind"); assetEvent.AssetEventTypeID = ResolveLookupID(assetEventTypeIds, assetEvent.AssetEventTypeID, assetEvent.AssetEventTypeName, $"Asset event \"{assetEvent.EventTitle}\" type"); assetEvent.FromStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, assetEvent.FromStateID, assetEvent.FromStateName, $"Asset event \"{assetEvent.EventTitle}\" from state"); assetEvent.ToStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, assetEvent.ToStateID, assetEvent.ToStateName, $"Asset event \"{assetEvent.EventTitle}\" to state"); } foreach (var dependency in backup.Data.AssetDependencies) { dependency.AssetDependencyID = NewId("AssetDependencies", dependency.AssetDependencyID); dependency.SourceAssetID = ExistingOrNew("StoryAssets", dependency.SourceAssetID); dependency.TargetAssetID = ExistingOrNew("StoryAssets", dependency.TargetAssetID); dependency.AssetDependencyTypeID = ResolveLookupID(assetDependencyTypeIds, dependency.AssetDependencyTypeID, dependency.DependencyTypeName, $"Asset dependency {dependency.AssetDependencyID} type"); dependency.SourceRequiredStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, dependency.SourceRequiredStateID, dependency.SourceRequiredStateName, $"Asset dependency {dependency.AssetDependencyID} source state"); dependency.TargetBlockedStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, dependency.TargetBlockedStateID, dependency.TargetBlockedStateName, $"Asset dependency {dependency.AssetDependencyID} target state"); } foreach (var custodyEvent in backup.Data.AssetCustodyEvents) { custodyEvent.AssetCustodyEventID = NewId("AssetCustodyEvents", custodyEvent.AssetCustodyEventID); custodyEvent.StoryAssetID = ExistingOrNew("StoryAssets", custodyEvent.StoryAssetID); custodyEvent.SceneID = ExistingOrNew("Scenes", custodyEvent.SceneID); custodyEvent.AssetCustodyEventTypeID = ResolveLookupID(assetCustodyEventTypeIds, custodyEvent.AssetCustodyEventTypeID, custodyEvent.CustodyEventTypeName, $"Asset custody event {custodyEvent.AssetCustodyEventID} type"); } foreach (var custodyCharacter in backup.Data.AssetCustodyCharacters) { custodyCharacter.AssetCustodyEventCharacterID = NewId("AssetCustodyEventCharacters", custodyCharacter.AssetCustodyEventCharacterID); custodyCharacter.AssetCustodyEventID = ExistingOrNew("AssetCustodyEvents", custodyCharacter.AssetCustodyEventID); custodyCharacter.CharacterID = ExistingOrNewNullable("Characters", custodyCharacter.CharacterID); custodyCharacter.CustodyRoleID = ResolveLookupID(custodyRoleIds, custodyCharacter.CustodyRoleID, custodyCharacter.CustodyRoleName, $"Asset custody character {custodyCharacter.AssetCustodyEventCharacterID} role"); } foreach (var sceneCharacter in backup.Data.SceneCharacters) { sceneCharacter.SceneCharacterID = NewId("SceneCharacters", sceneCharacter.SceneCharacterID); sceneCharacter.SceneID = ExistingOrNew("Scenes", sceneCharacter.SceneID); sceneCharacter.CharacterID = ExistingOrNew("Characters", sceneCharacter.CharacterID); sceneCharacter.ProjectID = projectId; sceneCharacter.RoleInSceneTypeID = ResolveNullableLookupID(characterRoleTypeIds, sceneCharacter.RoleInSceneTypeID, sceneCharacter.RoleInSceneTypeName, $"Scene character \"{sceneCharacter.CharacterName}\" role"); sceneCharacter.PresenceTypeID = ResolveNullableLookupID(presenceTypeIds, sceneCharacter.PresenceTypeID, sceneCharacter.PresenceTypeName, $"Scene character \"{sceneCharacter.CharacterName}\" presence"); sceneCharacter.LocationID = ExistingOrNewNullable("Locations", sceneCharacter.LocationID); sceneCharacter.EntryLocationID = ExistingOrNewNullable("Locations", sceneCharacter.EntryLocationID); sceneCharacter.ExitLocationID = ExistingOrNewNullable("Locations", sceneCharacter.ExitLocationID); } foreach (var attributeEvent in backup.Data.CharacterAttributeEvents) { attributeEvent.CharacterAttributeEventID = NewId("CharacterAttributeEvents", attributeEvent.CharacterAttributeEventID); attributeEvent.CharacterID = ExistingOrNew("Characters", attributeEvent.CharacterID); attributeEvent.CharacterAttributeTypeID = ExistingOrNew("CharacterAttributeTypes", attributeEvent.CharacterAttributeTypeID); attributeEvent.SceneID = ExistingOrNew("Scenes", attributeEvent.SceneID); } foreach (var knowledge in backup.Data.CharacterKnowledge) { knowledge.CharacterKnowledgeID = NewId("CharacterKnowledge", knowledge.CharacterKnowledgeID); knowledge.CharacterID = ExistingOrNew("Characters", knowledge.CharacterID); knowledge.StoryAssetID = ExistingOrNewNullable("StoryAssets", knowledge.StoryAssetID); knowledge.PlotThreadID = ExistingOrNewNullable("PlotThreads", knowledge.PlotThreadID); knowledge.SceneID = ExistingOrNew("Scenes", knowledge.SceneID); knowledge.KnowledgeStateID = ResolveLookupID(knowledgeStateIds, knowledge.KnowledgeStateID, knowledge.KnowledgeStateName, $"Character knowledge {knowledge.CharacterKnowledgeID} state"); knowledge.SourceEventID = ExistingOrNewNullable("ThreadEvents", knowledge.SourceEventID); } foreach (var relationship in backup.Data.CharacterRelationships) { relationship.CharacterRelationshipID = NewId("CharacterRelationships", relationship.CharacterRelationshipID); relationship.ProjectID = projectId; relationship.CharacterAID = ExistingOrNew("Characters", relationship.CharacterAID); relationship.CharacterBID = ExistingOrNew("Characters", relationship.CharacterBID); relationship.RelationshipTypeID = ResolveRelationshipTypeID(relationship); relationship.StartSceneID = ExistingOrNewNullable("Scenes", relationship.StartSceneID); relationship.EndSceneID = ExistingOrNewNullable("Scenes", relationship.EndSceneID); relationship.InitialBookID = ExistingOrNewNullable("Books", relationship.InitialBookID); relationship.InitialRelationshipStateID = ResolveRelationshipStateID( relationship.InitialRelationshipStateID, relationship.InitialRelationshipStateName, $"Initial relationship {relationship.CharacterRelationshipID} between \"{relationship.CharacterAName}\" and \"{relationship.CharacterBName}\""); } foreach (var relationshipEvent in backup.Data.RelationshipEvents) { relationshipEvent.RelationshipEventID = NewId("RelationshipEvents", relationshipEvent.RelationshipEventID); relationshipEvent.CharacterRelationshipID = ExistingOrNew("CharacterRelationships", relationshipEvent.CharacterRelationshipID); relationshipEvent.ProjectID = projectId; relationshipEvent.CharacterAID = ExistingOrNew("Characters", relationshipEvent.CharacterAID); relationshipEvent.CharacterBID = ExistingOrNew("Characters", relationshipEvent.CharacterBID); relationshipEvent.SceneID = ExistingOrNew("Scenes", relationshipEvent.SceneID); relationshipEvent.RelationshipStateID = ResolveRelationshipStateID( relationshipEvent.RelationshipStateID, relationshipEvent.RelationshipStateName, $"Relationship event {relationshipEvent.RelationshipEventID}") ?? relationshipEvent.RelationshipStateID; } foreach (var relationship in backup.Data.LocationRelationships) { relationship.LocationRelationshipID = NewId("LocationRelationships", relationship.LocationRelationshipID); relationship.FromLocationID = ExistingOrNew("Locations", relationship.FromLocationID); relationship.ToLocationID = ExistingOrNew("Locations", relationship.ToLocationID); relationship.LocationRelationshipTypeID = ResolveLookupID(locationRelationshipTypeIds, relationship.LocationRelationshipTypeID, relationship.RelationshipTypeName, $"Location relationship {relationship.LocationRelationshipID} type"); } foreach (var sceneAssetLocation in backup.Data.SceneAssetLocations) { sceneAssetLocation.SceneAssetLocationID = NewId("SceneAssetLocations", sceneAssetLocation.SceneAssetLocationID); sceneAssetLocation.SceneID = ExistingOrNew("Scenes", sceneAssetLocation.SceneID); sceneAssetLocation.StoryAssetID = ExistingOrNew("StoryAssets", sceneAssetLocation.StoryAssetID); sceneAssetLocation.LocationID = ExistingOrNew("Locations", sceneAssetLocation.LocationID); } foreach (var dependency in backup.Data.SceneDependencies) { dependency.SceneDependencyID = NewId("SceneDependencies", dependency.SceneDependencyID); dependency.ProjectID = projectId; dependency.SourceSceneID = ExistingOrNew("Scenes", dependency.SourceSceneID); dependency.TargetSceneID = ExistingOrNew("Scenes", dependency.TargetSceneID); dependency.SceneDependencyTypeID = ResolveLookupID(sceneDependencyTypeIds, dependency.SceneDependencyTypeID, dependency.SceneDependencyTypeName, $"Scene dependency {dependency.SceneDependencyID} type"); } foreach (var validationRun in backup.Data.ValidationRuns) { validationRun.ValidationRunID = NewId("ValidationRuns", validationRun.ValidationRunID); validationRun.ProjectID = projectId; validationRun.BookID = ExistingOrNewNullable("Books", validationRun.BookID); } foreach (var warning in backup.Data.ContinuityWarnings) { warning.ContinuityWarningID = NewId("ContinuityWarnings", warning.ContinuityWarningID); warning.ValidationRunID = ExistingOrNewNullable("ValidationRuns", warning.ValidationRunID); warning.ProjectID = projectId; warning.BookID = ExistingOrNewNullable("Books", warning.BookID); warning.ChapterID = ExistingOrNewNullable("Chapters", warning.ChapterID); warning.SceneID = ExistingOrNewNullable("Scenes", warning.SceneID); warning.EntityID = RemapPolymorphicEntityID(warning.EntityType, warning.EntityID, ExistingOrNewNullable); } foreach (var note in backup.Data.SceneNotes) { note.SceneNoteID = NewId("SceneNotes", note.SceneNoteID); note.SceneID = ExistingOrNew("Scenes", note.SceneID); note.SceneNoteTypeID = ResolveLookupID(sceneNoteTypeIds, note.SceneNoteTypeID, note.TypeName, $"Scene note \"{note.NoteTitle}\" type"); } foreach (var workflow in backup.Data.SceneWorkflow) { workflow.SceneWorkflowID = NewId("SceneWorkflow", workflow.SceneWorkflowID); workflow.SceneID = ExistingOrNew("Scenes", workflow.SceneID); } foreach (var item in backup.Data.SceneChecklistItems) { item.SceneChecklistItemID = NewId("SceneChecklistItems", item.SceneChecklistItemID); item.SceneID = ExistingOrNew("Scenes", item.SceneID); } foreach (var attachment in backup.Data.SceneAttachments) { attachment.SceneAttachmentID = NewId("SceneAttachments", attachment.SceneAttachmentID); attachment.SceneID = ExistingOrNew("Scenes", attachment.SceneID); attachment.SceneAttachmentTypeID = ResolveLookupID(sceneAttachmentTypeIds, attachment.SceneAttachmentTypeID, attachment.TypeName, $"Scene attachment \"{attachment.Title}\" type"); } foreach (var goal in backup.Data.ChapterGoals) { goal.ChapterGoalID = NewId("ChapterGoals", goal.ChapterGoalID); goal.ChapterID = ExistingOrNew("Chapters", goal.ChapterID); } foreach (var scenario in backup.Data.Scenarios) { scenario.ScenarioID = NewId("Scenarios", scenario.ScenarioID); scenario.ProjectID = projectId; scenario.BookID = ExistingOrNewNullable("Books", scenario.BookID); } foreach (var order in backup.Data.ScenarioSceneOrder) { order.ScenarioSceneOrderID = NewId("ScenarioSceneOrder", order.ScenarioSceneOrderID); order.ScenarioID = ExistingOrNew("Scenarios", order.ScenarioID); order.SceneID = ExistingOrNew("Scenes", order.SceneID); order.ProposedChapterID = ExistingOrNewNullable("Chapters", order.ProposedChapterID); } foreach (var run in backup.Data.ScenarioValidationRuns) { run.ScenarioValidationRunID = NewId("ScenarioValidationRuns", run.ScenarioValidationRunID); run.ScenarioID = ExistingOrNew("Scenarios", run.ScenarioID); } foreach (var warning in backup.Data.ScenarioWarnings) { warning.ScenarioWarningID = NewId("ScenarioWarnings", warning.ScenarioWarningID); warning.ScenarioValidationRunID = ExistingOrNew("ScenarioValidationRuns", warning.ScenarioValidationRunID); warning.ScenarioID = ExistingOrNew("Scenarios", warning.ScenarioID); warning.SceneID = ExistingOrNewNullable("Scenes", warning.SceneID); warning.EntityID = RemapPolymorphicEntityID(warning.EntityType, warning.EntityID, ExistingOrNewNullable); } } private static int? RemapPolymorphicEntityID(string? entityType, int? entityId, Func remap) { if (!entityId.HasValue || string.IsNullOrWhiteSpace(entityType)) { return entityId; } var normalized = entityType.Trim().ToLowerInvariant(); var table = normalized switch { "project" => null, "book" => "Books", "chapter" => "Chapters", "scene" => "Scenes", "character" => "Characters", "location" => "Locations", "plotline" or "plot line" => "PlotLines", "plotthread" or "plot thread" => "PlotThreads", "storyasset" or "story asset" or "asset" => "StoryAssets", "relationship" or "characterrelationship" or "character relationship" => "CharacterRelationships", "thread event" or "threadevent" => "ThreadEvents", "asset event" or "assetevent" => "AssetEvents", _ => null }; return table is null ? entityId : remap(table, entityId); } private static ImportResult BuildDryRunResult(ImportPreview preview) { var unresolvedThreadEvents = preview.UnresolvedThreadEventReferences.Count; var unresolvedAssetStates = preview.UnresolvedAssetReferences.Count(x => x.Contains("state", StringComparison.OrdinalIgnoreCase)); var unresolvedAssetEvents = preview.UnresolvedAssetReferences.Count(x => x.Contains("event", StringComparison.OrdinalIgnoreCase)); return new ImportResult { Succeeded = true, IsDryRun = true, ProjectID = null, ProjectName = preview.Package?.Project.Title ?? "Dry run", BooksCreated = preview.BookCount, ChaptersCreated = preview.ChapterCount, ScenesCreated = preview.SceneCount, CharactersCreated = preview.CharacterCount, LocationsCreated = preview.LocationCount, SceneCharacterAppearancesCreated = preview.SceneCharacterAppearanceCount, PlotLinesCreated = preview.PlotLineCount, PlotThreadsCreated = preview.PlotThreadCount, ThreadEventsCreated = Math.Max(0, preview.ThreadEventCount - unresolvedThreadEvents), StoryAssetsCreated = preview.StoryAssetCount, AssetStatesCreated = Math.Max(0, preview.AssetStateCount - unresolvedAssetStates), AssetEventsCreated = Math.Max(0, preview.AssetEventCount - unresolvedAssetEvents), AssetCustodyEventsCreated = preview.ResolvedAssetCustodyCount, SceneDependenciesCreated = preview.ResolvedSceneDependencyCount, ErrorCount = preview.ErrorCount, WarningCount = preview.WarningCount, AutoCorrectionCount = preview.AutoCorrectionCount, SkippedItemCount = preview.SkippedItemCount, UnresolvedReferenceCount = preview.UnresolvedReferenceCount, AutoCorrections = preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), SkippedOptionalFields = preview.SkippedItems.Concat(preview.Validation.SkippedItems).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), UnresolvedWarnings = preview.UnresolvedThreadEventReferences .Concat(preview.UnresolvedAssetReferences) .Concat(preview.UnresolvedSceneDependencyReferences) .ToList(), Message = "Dry run complete. No data was saved." }; } private static ImportValidationResult Validate(PlotLineImportPackage package) { var result = new ImportValidationResult(); if (string.IsNullOrWhiteSpace(package.Project.Title)) { result.Errors.Add("Project title is required."); } else if (package.Project.Title.Trim().Length > TitleMaxLength) { result.Errors.Add($"Project title is longer than {TitleMaxLength} characters."); } ValidateLargeText(result, "Project description", package.Project.Description); ValidateLargeText(result, "Project notes", package.Project.Notes); ValidateCharacters(package, result); ValidateLocations(package, result); ValidatePlotLines(package, result); ValidateStoryAssets(package, result); ValidateSceneDependencies(package, result); if (string.IsNullOrWhiteSpace(package.PackageVersion)) { result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1F."); } else if (package.PackageVersion.Trim() != "1.0") { result.Warnings.Add($"packageVersion \"{package.PackageVersion}\" is not the current supported version. The importer will continue if the structure is valid."); } if (package.Books.Count == 0) { result.Warnings.Add("No books were supplied. The import will create an empty project."); } var totalChapters = package.Books.Sum(book => book.Chapters.Count); var totalScenes = package.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count)); if (package.Books.Count > SuspiciousBookCount || totalChapters > SuspiciousChapterCount || totalScenes > SuspiciousSceneCount) { result.Warnings.Add($"This is a large import ({package.Books.Count} books, {totalChapters} chapters, {totalScenes} scenes). Review the preview carefully before importing."); } foreach (var group in package.Books.GroupBy(x => x.SeriesOrder).Where(x => x.Count() > 1)) { result.Errors.Add($"Book order {group.Key} is used more than once."); } foreach (var duplicateTitle in package.Books .Where(book => !string.IsNullOrWhiteSpace(book.Title)) .GroupBy(book => NormalizeNameKey(book.Title), StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1)) { result.Warnings.Add($"Book title \"{duplicateTitle.First().Title.Trim()}\" appears more than once."); } for (var bookIndex = 0; bookIndex < package.Books.Count; bookIndex++) { var book = package.Books[bookIndex]; var bookLabel = string.IsNullOrWhiteSpace(book.Title) ? $"Book {bookIndex + 1}" : book.Title; if (string.IsNullOrWhiteSpace(book.Title)) { result.Errors.Add($"Book {bookIndex + 1} is missing a title."); } else if (FormatBookTitle(book).Length > TitleMaxLength) { result.Errors.Add($"{bookLabel} title is longer than {TitleMaxLength} characters after combining title and subtitle."); } if (book.SeriesOrder <= 0) { result.Errors.Add($"{bookLabel} is missing a valid seriesOrder."); } if (book.Chapters.Count == 0) { result.Warnings.Add($"{bookLabel} has no chapters."); } ValidateLargeText(result, $"{bookLabel} description", book.Description); ValidateLargeText(result, $"{bookLabel} notes", book.Notes); foreach (var group in book.Chapters.GroupBy(x => x.Order).Where(x => x.Count() > 1)) { result.Errors.Add($"{bookLabel} has duplicate chapter order {group.Key}."); } foreach (var duplicateTitle in book.Chapters .Where(chapter => !string.IsNullOrWhiteSpace(chapter.Title)) .GroupBy(chapter => NormalizeNameKey(chapter.Title), StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1)) { result.Warnings.Add($"{bookLabel} has duplicate chapter title \"{duplicateTitle.First().Title.Trim()}\"."); } for (var chapterIndex = 0; chapterIndex < book.Chapters.Count; chapterIndex++) { var chapter = book.Chapters[chapterIndex]; var chapterLabel = string.IsNullOrWhiteSpace(chapter.Title) ? $"chapter {chapterIndex + 1}" : chapter.Title; if (string.IsNullOrWhiteSpace(chapter.Title)) { result.Errors.Add($"{bookLabel} chapter {chapterIndex + 1} is missing a title."); } else if (chapter.Title.Trim().Length > TitleMaxLength) { result.Errors.Add($"{bookLabel} / {chapterLabel} title is longer than {TitleMaxLength} characters."); } if (chapter.Order <= 0) { result.Errors.Add($"{bookLabel} / {chapterLabel} is missing a valid chapter order."); } if (chapter.Scenes.Count == 0) { result.Warnings.Add($"{bookLabel} / {chapterLabel} has no scenes."); } else if (chapter.Scenes.Count == 1) { result.Warnings.Add($"{bookLabel} / {chapterLabel} has only one scene. This may be intentional, but is worth checking in manuscript-derived data."); } ValidateLargeText(result, $"{bookLabel} / {chapterLabel} summary", chapter.Summary); ValidateLargeText(result, $"{bookLabel} / {chapterLabel} notes", chapter.Notes); foreach (var group in chapter.Scenes.GroupBy(x => x.Order).Where(x => x.Count() > 1)) { result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}."); } foreach (var duplicateTitle in chapter.Scenes .Where(scene => !string.IsNullOrWhiteSpace(scene.Title)) .GroupBy(scene => NormalizeNameKey(scene.Title), StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1)) { result.Warnings.Add($"{bookLabel} / {chapterLabel} has duplicate scene title \"{duplicateTitle.First().Title.Trim()}\"."); } for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++) { var scene = chapter.Scenes[sceneIndex]; var sceneLabel = string.IsNullOrWhiteSpace(scene.Title) ? $"scene {sceneIndex + 1}" : scene.Title; if (string.IsNullOrWhiteSpace(scene.Title)) { result.Errors.Add($"{bookLabel} / {chapterLabel} scene {sceneIndex + 1} is missing a title."); } else if (scene.Title.Trim().Length > TitleMaxLength) { result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} title is longer than {TitleMaxLength} characters."); } if (scene.Order <= 0) { result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} is missing a valid scene order."); } if (string.IsNullOrWhiteSpace(scene.Summary)) { result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} has an empty scene summary."); } if (!string.IsNullOrWhiteSpace(scene.DateTimeText) && scene.DateTimeText.Trim().Length > RelativeTimeMaxLength) { result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} dateTimeText is longer than {RelativeTimeMaxLength} characters and will be truncated."); } ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} summary", scene.Summary); ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} notes", scene.Notes); ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} purposeText", scene.PurposeText); ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} outcomeText", scene.OutcomeText); foreach (var duplicate in scene.SceneCharacters .Where(x => !string.IsNullOrWhiteSpace(x.CharacterName)) .GroupBy(x => NormalizeNameKey(x.CharacterName), StringComparer.OrdinalIgnoreCase) .Where(x => x.Count() > 1)) { var duplicateName = duplicate.First().CharacterName.Trim(); result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicateName} more than once in sceneCharacters. Duplicate appearances will be skipped."); result.SkippedItems.Add($"Duplicate scene appearance skipped: {bookLabel} / {chapterLabel} / {sceneLabel} / {duplicateName}"); } } } } foreach (var book in package.Books.Where(book => book.SeriesOrder > 1 && !package.SceneDependencies.Any(dependency => dependency.ToScene?.BookOrder == book.SeriesOrder || dependency.FromScene?.BookOrder == book.SeriesOrder))) { result.Warnings.Add($"{book.Title} is in the middle of the series but has no scene dependencies."); } foreach (var name in FindUnmatchedPovNames(package)) { result.Warnings.Add($"POV character \"{name}\" does not match a top-level imported character. The text will be preserved in scene notes."); } foreach (var name in FindUnmatchedLocationNames(package)) { result.Warnings.Add($"Scene location \"{name}\" does not match a top-level imported location. The text will be preserved in scene notes."); } foreach (var name in FindUnmatchedSceneCharacterNames(package)) { result.Warnings.Add($"Scene character \"{name}\" does not match a top-level imported character and will not create an appearance."); } foreach (var reference in FindUnresolvedThreadEventReferences(package)) { result.Warnings.Add($"Thread event scene reference could not be resolved and will be skipped: {reference}"); } foreach (var reference in FindUnresolvedAssetReferences(package)) { result.Warnings.Add($"Asset state/event scene reference could not be resolved and will be skipped: {reference}"); } foreach (var name in FindUnmatchedAssetOwnerNames(package)) { result.Warnings.Add($"Story asset owner \"{name}\" does not match a top-level imported character."); } foreach (var name in FindUnmatchedAssetLocationNames(package)) { result.Warnings.Add($"Story asset location \"{name}\" does not match a top-level imported location."); } foreach (var reference in FindUnresolvedSceneDependencyReferences(package)) { result.Warnings.Add($"Scene dependency reference could not be resolved and will be skipped: {reference}"); } return result; } private static void ValidateSceneDependencies(PlotLineImportPackage package, ImportValidationResult result) { foreach (var duplicate in package.SceneDependencies .Where(dependency => HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene)) .GroupBy(SceneDependencyKey, StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1)) { result.Warnings.Add($"Duplicate scene dependency will be imported once: {duplicate.Key}."); } for (var index = 0; index < package.SceneDependencies.Count; index++) { var dependency = package.SceneDependencies[index]; var label = $"Scene dependency {index + 1}"; if (dependency.FromScene is null) { result.Warnings.Add($"{label} is missing fromScene and will be skipped."); } else if (!HasSceneOrderReference(dependency.FromScene) && string.IsNullOrWhiteSpace(dependency.FromScene.SceneRef)) { result.Warnings.Add($"{label} fromScene is missing bookOrder/chapterOrder/sceneOrder."); } if (dependency.ToScene is null) { result.Warnings.Add($"{label} is missing toScene and will be skipped."); } else if (!HasSceneOrderReference(dependency.ToScene) && string.IsNullOrWhiteSpace(dependency.ToScene.SceneRef)) { result.Warnings.Add($"{label} toScene is missing bookOrder/chapterOrder/sceneOrder."); } if (HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene) && SceneReferenceKey(dependency.FromScene!) == SceneReferenceKey(dependency.ToScene!)) { result.Warnings.Add($"{label} links a scene to itself and will be skipped."); } if (!string.IsNullOrWhiteSpace(dependency.DependencyType) && dependency.DependencyType.Trim().Length > 100) { result.Warnings.Add($"{label} dependencyType is longer than 100 characters and may not match an existing dependency type."); } if (!string.IsNullOrWhiteSpace(dependency.Strength) && !IsSupportedDependencyStrength(dependency.Strength)) { result.Warnings.Add($"{label} strength \"{dependency.Strength.Trim()}\" is not a recognized hard/soft value. It will be preserved in the description."); } ValidateLargeText(result, $"{label} description", dependency.Description); ValidateLargeText(result, $"{label} notes", dependency.Notes); } } private static void ValidateStoryAssets(PlotLineImportPackage package, ImportValidationResult result) { foreach (var group in package.StoryAssets .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase) .Where(x => x.Count() > 1)) { result.Errors.Add($"Story asset \"{group.Key}\" appears more than once in the import package."); } for (var assetIndex = 0; assetIndex < package.StoryAssets.Count; assetIndex++) { var asset = package.StoryAssets[assetIndex]; var assetLabel = string.IsNullOrWhiteSpace(asset.Name) ? $"Story asset {assetIndex + 1}" : asset.Name.Trim(); if (string.IsNullOrWhiteSpace(asset.Name)) { result.Errors.Add($"Story asset {assetIndex + 1} is missing a name."); } if (DisplayName(asset.DisplayName, asset.Name).Length > TitleMaxLength) { result.Errors.Add($"{assetLabel} name/displayName is longer than {TitleMaxLength} characters."); } ValidateLargeText(result, $"{assetLabel} description", asset.Description); ValidateLargeText(result, $"{assetLabel} notes", asset.Notes); if (asset.States.Count == 0 && asset.Events.Count == 0) { result.Warnings.Add($"{assetLabel} has no states or events."); } foreach (var duplicate in asset.States .Where(HasSceneOrderReference) .GroupBy(AssetStateSceneKey) .Where(x => x.Count() > 1)) { result.Errors.Add($"{assetLabel} has duplicate states for scene {duplicate.Key}."); } foreach (var duplicate in asset.Events .Where(HasSceneOrderReference) .GroupBy(AssetEventSceneKey) .Where(x => x.Count() > 1)) { result.Errors.Add($"{assetLabel} has duplicate events for scene {duplicate.Key}."); } for (var stateIndex = 0; stateIndex < asset.States.Count; stateIndex++) { var state = asset.States[stateIndex]; var stateLabel = string.IsNullOrWhiteSpace(state.StateName) ? $"State {stateIndex + 1}" : state.StateName.Trim(); if (string.IsNullOrWhiteSpace(state.StateName) && string.IsNullOrWhiteSpace(state.Description)) { result.Errors.Add($"{assetLabel} state {stateIndex + 1} needs stateName or description."); } if (!HasSceneOrderReference(state) && string.IsNullOrWhiteSpace(state.SceneRef)) { result.Warnings.Add($"{assetLabel} / {stateLabel} is missing bookOrder/chapterOrder/sceneOrder."); } if (!string.IsNullOrWhiteSpace(state.StateName) && state.StateName.Trim().Length > 100) { result.Warnings.Add($"{assetLabel} / {stateLabel} stateName is longer than 100 characters and may not match an existing asset state."); } ValidateLargeText(result, $"{assetLabel} / {stateLabel} description", state.Description); ValidateLargeText(result, $"{assetLabel} / {stateLabel} notes", state.Notes); } for (var eventIndex = 0; eventIndex < asset.Events.Count; eventIndex++) { var assetEvent = asset.Events[eventIndex]; var eventLabel = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {eventIndex + 1}" : assetEvent.Title.Trim(); if (string.IsNullOrWhiteSpace(assetEvent.Title) && string.IsNullOrWhiteSpace(assetEvent.Summary)) { result.Errors.Add($"{assetLabel} event {eventIndex + 1} needs a title or summary."); } if (!HasSceneOrderReference(assetEvent) && string.IsNullOrWhiteSpace(assetEvent.SceneRef)) { result.Warnings.Add($"{assetLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder."); } if (!string.IsNullOrWhiteSpace(assetEvent.Title) && assetEvent.Title.Trim().Length > TitleMaxLength) { result.Errors.Add($"{assetLabel} / {eventLabel} title is longer than {TitleMaxLength} characters."); } ValidateLargeText(result, $"{assetLabel} / {eventLabel} summary", assetEvent.Summary); ValidateLargeText(result, $"{assetLabel} / {eventLabel} notes", assetEvent.Notes); } } } private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result) { foreach (var duplicateThread in package.PlotLines .SelectMany(plotLine => plotLine.Threads) .Where(thread => !string.IsNullOrWhiteSpace(thread.Name)) .GroupBy(thread => NormalizeNameKey(thread.Name), StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1)) { result.Warnings.Add($"Plot thread name \"{duplicateThread.First().Name.Trim()}\" appears in more than one place."); } foreach (var group in package.PlotLines .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase) .Where(x => x.Count() > 1)) { result.Errors.Add($"Plot line \"{group.Key}\" appears more than once in the import package."); } for (var plotIndex = 0; plotIndex < package.PlotLines.Count; plotIndex++) { var plotLine = package.PlotLines[plotIndex]; var plotLabel = string.IsNullOrWhiteSpace(plotLine.Name) ? $"Plot line {plotIndex + 1}" : plotLine.Name.Trim(); if (string.IsNullOrWhiteSpace(plotLine.Name)) { result.Errors.Add($"Plot line {plotIndex + 1} is missing a name."); } if (DisplayName(plotLine.DisplayName, plotLine.Name).Length > TitleMaxLength) { result.Errors.Add($"{plotLabel} name/displayName is longer than {TitleMaxLength} characters."); } ValidateLargeText(result, $"{plotLabel} description", plotLine.Description); ValidateLargeText(result, $"{plotLabel} notes", plotLine.Notes); foreach (var group in plotLine.Threads .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase) .Where(x => x.Count() > 1)) { result.Errors.Add($"{plotLabel} has duplicate thread \"{group.Key}\"."); } for (var threadIndex = 0; threadIndex < plotLine.Threads.Count; threadIndex++) { var thread = plotLine.Threads[threadIndex]; var threadLabel = string.IsNullOrWhiteSpace(thread.Name) ? $"Thread {threadIndex + 1}" : thread.Name.Trim(); if (string.IsNullOrWhiteSpace(thread.Name)) { result.Errors.Add($"{plotLabel} thread {threadIndex + 1} is missing a name."); } if (DisplayName(thread.DisplayName, thread.Name).Length > TitleMaxLength) { result.Errors.Add($"{plotLabel} / {threadLabel} name/displayName is longer than {TitleMaxLength} characters."); } ValidateLargeText(result, $"{plotLabel} / {threadLabel} description", thread.Description); ValidateLargeText(result, $"{plotLabel} / {threadLabel} notes", thread.Notes); foreach (var duplicate in thread.Events .Where(HasSceneOrderReference) .GroupBy(EventSceneKey) .Where(x => x.Count() > 1)) { result.Errors.Add($"{plotLabel} / {threadLabel} has duplicate events for scene {duplicate.Key}."); } for (var eventIndex = 0; eventIndex < thread.Events.Count; eventIndex++) { var threadEvent = thread.Events[eventIndex]; var eventLabel = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {eventIndex + 1}" : threadEvent.Title.Trim(); if (!HasSceneOrderReference(threadEvent) && string.IsNullOrWhiteSpace(threadEvent.SceneRef)) { result.Warnings.Add($"{plotLabel} / {threadLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder."); } if (string.IsNullOrWhiteSpace(threadEvent.Title) && string.IsNullOrWhiteSpace(threadEvent.Summary)) { result.Errors.Add($"{plotLabel} / {threadLabel} event {eventIndex + 1} needs a title or summary."); } if (!string.IsNullOrWhiteSpace(threadEvent.Title) && threadEvent.Title.Trim().Length > TitleMaxLength) { result.Errors.Add($"{plotLabel} / {threadLabel} / {eventLabel} title is longer than {TitleMaxLength} characters."); } ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} summary", threadEvent.Summary); ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} impact", threadEvent.Impact); ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} notes", threadEvent.Notes); } } } } private static void ValidateCharacters(PlotLineImportPackage package, ImportValidationResult result) { if (package.Characters.Any(character => character.Importance.HasValue || !string.IsNullOrWhiteSpace(character.Role))) { result.Warnings.Add("Character role/importance will be mapped to Story importance for timeline grouping."); } foreach (var group in package.Characters .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase) .Where(x => x.Count() > 1)) { result.Errors.Add($"Character \"{group.Key}\" appears more than once in the import package."); } for (var index = 0; index < package.Characters.Count; index++) { var character = package.Characters[index]; var label = string.IsNullOrWhiteSpace(character.Name) ? $"Character {index + 1}" : character.Name.Trim(); if (string.IsNullOrWhiteSpace(character.Name)) { result.Errors.Add($"Character {index + 1} is missing a name."); } if (DisplayName(character.DisplayName, character.Name).Length > TitleMaxLength) { result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters."); } if (!string.IsNullOrWhiteSpace(character.ShortName) && character.ShortName.Trim().Length > 100) { result.Errors.Add($"{label} shortName is longer than 100 characters."); } ValidateLargeText(result, $"{label} description", character.Description); ValidateLargeText(result, $"{label} notes", character.Notes); } } private static void ValidateLocations(PlotLineImportPackage package, ImportValidationResult result) { var names = package.Locations .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .Select(x => x.Name.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var group in package.Locations .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase) .Where(x => x.Count() > 1)) { result.Errors.Add($"Location \"{group.Key}\" appears more than once in the import package."); } for (var index = 0; index < package.Locations.Count; index++) { var location = package.Locations[index]; var label = string.IsNullOrWhiteSpace(location.Name) ? $"Location {index + 1}" : location.Name.Trim(); if (string.IsNullOrWhiteSpace(location.Name)) { result.Errors.Add($"Location {index + 1} is missing a name."); } if (DisplayName(location.DisplayName, location.Name).Length > TitleMaxLength) { result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters."); } if (!string.IsNullOrWhiteSpace(location.Type) && location.Type.Trim().Length > 100) { result.Warnings.Add($"{label} location type is longer than 100 characters and may not match an existing type."); } if (!string.IsNullOrWhiteSpace(location.ParentLocationName) && !names.Contains(location.ParentLocationName.Trim())) { result.Warnings.Add($"{label} references parentLocationName \"{location.ParentLocationName.Trim()}\", but that location is not in this package. It will be imported without a parent."); } ValidateLargeText(result, $"{label} description", location.Description); ValidateLargeText(result, $"{label} notes", location.Notes); } } private static void AddMetadataWarnings(PlotLineImportPackage package, ImportValidationResult validation) { var hasPov = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName)))); var hasLocation = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName)))); if (hasPov) { validation.Warnings.Add("POV character names that match top-level characters will be linked to scenes. Unmatched POV names stay as plain text notes."); } if (hasLocation) { validation.Warnings.Add("Scene location names that match top-level locations will be linked as primary locations. Unmatched location names stay as plain text notes."); } } private static IReadOnlyList BuildBookPreviews(PlotLineImportPackage package) => package.Books .OrderBy(x => x.SeriesOrder) .Select(book => new ImportBookPreview { Book = book, ChapterPreviews = book.Chapters .OrderBy(x => x.Order) .Select(chapter => new ImportChapterPreview { Chapter = chapter }) .ToList() }) .ToList(); private static IReadOnlyList FindUnmatchedPovNames(PlotLineImportPackage package) { var characterLookup = BuildCharacterLookup(package); return package.Books .SelectMany(book => book.Chapters) .SelectMany(chapter => chapter.Scenes) .Select(scene => scene.PovCharacterName?.Trim()) .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; } private static IReadOnlyList FindUnmatchedLocationNames(PlotLineImportPackage package) { var locationLookup = BuildLocationLookup(package); return package.Books .SelectMany(book => book.Chapters) .SelectMany(chapter => chapter.Scenes) .Select(scene => scene.LocationName?.Trim()) .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; } private static IReadOnlyList FindUnmatchedSceneCharacterNames(PlotLineImportPackage package) { var characterLookup = BuildCharacterLookup(package); return package.Books .SelectMany(book => book.Chapters) .SelectMany(chapter => chapter.Scenes) .SelectMany(scene => scene.SceneCharacters) .Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()) .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; } private static IReadOnlyList FindUnresolvedThreadEventReferences(PlotLineImportPackage package) { var sceneKeys = package.Books .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}"))) .ToHashSet(StringComparer.OrdinalIgnoreCase); return 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 => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(EventSceneKey(x.Event))) .Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle} -> {EventSceneKey(x.Event)}. {SceneReferenceDiagnostic(package, EventSceneKey(x.Event))}") .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x) .ToList(); } private static IReadOnlyList FindUnresolvedAssetReferences(PlotLineImportPackage package) { var sceneKeys = package.Books .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}"))) .ToHashSet(StringComparer.OrdinalIgnoreCase); var stateReferences = 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 => !HasSceneOrderReference(x.State) || !sceneKeys.Contains(AssetStateSceneKey(x.State))) .Select(x => $"{x.AssetName} / state / {x.Label} -> {AssetStateSceneKey(x.State)}. {SceneReferenceDiagnostic(package, AssetStateSceneKey(x.State))}"); var eventReferences = 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 => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(AssetEventSceneKey(x.Event))) .Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}. {SceneReferenceDiagnostic(package, AssetEventSceneKey(x.Event))}"); return stateReferences .Concat(eventReferences) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x) .ToList(); } private static IReadOnlyList FindUnmatchedAssetOwnerNames(PlotLineImportPackage package) { var characterLookup = BuildCharacterLookup(package); return package.StoryAssets .Select(asset => asset.InitialOwnerCharacterName?.Trim()) .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim()))) .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; } private static IReadOnlyList FindUnmatchedAssetLocationNames(PlotLineImportPackage package) { var locationLookup = BuildLocationLookup(package); return package.StoryAssets .Select(asset => asset.InitialLocationName?.Trim()) .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim()))) .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; } private static IReadOnlyList FindUnresolvedSceneDependencyReferences(PlotLineImportPackage package) { var sceneKeys = package.Books .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}"))) .ToHashSet(StringComparer.OrdinalIgnoreCase); return 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 => !HasSceneOrderReference(x.Reference) || !sceneKeys.Contains(SceneReferenceKey(x.Reference!))) .Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}. {SceneReferenceDiagnostic(package, SceneReferenceKey(x.Reference))}") .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x) .ToList(); } private static bool HasSceneOrderReference(ImportThreadEventDto threadEvent) => threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue; private static string EventSceneKey(ImportThreadEventDto threadEvent) => $"{threadEvent.BookOrder?.ToString() ?? "?"}/{threadEvent.ChapterOrder?.ToString() ?? "?"}/{threadEvent.SceneOrder?.ToString() ?? "?"}"; private static bool HasSceneOrderReference(ImportAssetStateDto state) => state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue; private static bool HasSceneOrderReference(ImportAssetEventDto assetEvent) => assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue; private static string AssetStateSceneKey(ImportAssetStateDto state) => $"{state.BookOrder?.ToString() ?? "?"}/{state.ChapterOrder?.ToString() ?? "?"}/{state.SceneOrder?.ToString() ?? "?"}"; private static string AssetEventSceneKey(ImportAssetEventDto assetEvent) => $"{assetEvent.BookOrder?.ToString() ?? "?"}/{assetEvent.ChapterOrder?.ToString() ?? "?"}/{assetEvent.SceneOrder?.ToString() ?? "?"}"; private static bool HasSceneOrderReference(ImportSceneReferenceDto? reference) => reference?.BookOrder.HasValue == true && reference.ChapterOrder.HasValue && reference.SceneOrder.HasValue; private static string SceneReferenceKey(ImportSceneReferenceDto? reference) => reference is null ? "(missing)" : $"{reference.BookOrder?.ToString() ?? "?"}/{reference.ChapterOrder?.ToString() ?? "?"}/{reference.SceneOrder?.ToString() ?? "?"}"; private static string SceneDependencyKey(ImportSceneDependencyDto dependency) => $"{SceneReferenceKey(dependency.FromScene)} -> {SceneReferenceKey(dependency.ToScene)} / {CleanDependencyType(dependency.DependencyType)}"; private static string SceneReferenceDiagnostic(PlotLineImportPackage package, string requestedKey) { var scenes = BuildSceneReferenceInfos(package); if (!TryParseSceneKey(requestedKey, out var requestedBook, out var requestedChapter, out var requestedScene)) { return "Likely cause: missing bookOrder, chapterOrder, or sceneOrder."; } var closest = scenes .OrderBy(scene => Math.Abs(scene.BookOrder - requestedBook) * 10_000 + Math.Abs(scene.ChapterOrder - requestedChapter) * 100 + Math.Abs(scene.SceneOrder - requestedScene)) .FirstOrDefault(); var cause = !scenes.Any(scene => scene.BookOrder == requestedBook) ? $"Book {requestedBook} was not found." : !scenes.Any(scene => scene.BookOrder == requestedBook && scene.ChapterOrder == requestedChapter) ? $"Book {requestedBook} chapter {requestedChapter} was not found." : $"Book {requestedBook} chapter {requestedChapter} scene {requestedScene} was not found."; return closest is null ? $"Likely cause: {cause}" : $"Closest scene: Book {closest.BookOrder} Chapter {closest.ChapterOrder} Scene {closest.SceneOrder} ({closest.SceneTitle}). Likely cause: {cause}"; } private static IReadOnlyList BuildSceneReferenceInfos(PlotLineImportPackage package) => package.Books .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => new ImportSceneReferenceInfo(book.SeriesOrder, chapter.Order, scene.Order, scene.Title)))) .ToList(); private static bool TryParseSceneKey(string key, out int bookOrder, out int chapterOrder, out int sceneOrder) { bookOrder = 0; chapterOrder = 0; sceneOrder = 0; var parts = key.Split('/'); return parts.Length == 3 && int.TryParse(parts[0], out bookOrder) && int.TryParse(parts[1], out chapterOrder) && int.TryParse(parts[2], out sceneOrder); } private static string CleanDependencyType(string? dependencyType) => string.IsNullOrWhiteSpace(dependencyType) ? "Must Occur Before" : dependencyType.Trim(); private static bool IsSupportedDependencyStrength(string strength) { var normalized = strength.Trim(); return normalized.Equals("hard", StringComparison.OrdinalIgnoreCase) || normalized.Equals("soft", StringComparison.OrdinalIgnoreCase) || normalized.Equals("strong", StringComparison.OrdinalIgnoreCase) || normalized.Equals("weak", StringComparison.OrdinalIgnoreCase) || normalized.Equals("required", StringComparison.OrdinalIgnoreCase) || normalized.Equals("optional", StringComparison.OrdinalIgnoreCase) || normalized.Equals("high", StringComparison.OrdinalIgnoreCase) || normalized.Equals("medium", StringComparison.OrdinalIgnoreCase) || normalized.Equals("low", StringComparison.OrdinalIgnoreCase) || (int.TryParse(normalized, out var numeric) && numeric is >= 1 and <= 10); } private static IReadOnlyList FindAliasWarnings(PlotLineImportPackage package) { var warnings = new List(); var characterNames = BuildNameLookup(package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName })); var locationNames = BuildNameLookup(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName })); foreach (var alias in package.Aliases?.Characters ?? []) { if (!ContainsLookupKey(characterNames, alias.Value)) { warnings.Add($"Character alias \"{alias.Key}\" points to \"{alias.Value}\", but that target is not a top-level character."); } } foreach (var alias in package.Aliases?.Locations ?? []) { if (!ContainsLookupKey(locationNames, alias.Value)) { warnings.Add($"Location alias \"{alias.Key}\" points to \"{alias.Value}\", but that target is not a top-level location."); } } return warnings; } private static IReadOnlyList FindAutoCorrections(PlotLineImportPackage package) { var corrections = new List(); AddAliasCorrections(corrections, "Character alias", package.Aliases?.Characters); AddAliasCorrections(corrections, "Location alias", package.Aliases?.Locations); foreach (var value in AllImportedNames(package).Where(value => !string.IsNullOrWhiteSpace(value))) { var collapsed = CollapseSpaces(value!); if (!string.Equals(value!.Trim(), collapsed, StringComparison.Ordinal)) { corrections.Add($"Repeated spaces normalized: \"{value}\" -> \"{collapsed}\"."); } } return corrections.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(); } private static IReadOnlyList FindSkippedItems(PlotLineImportPackage package) { var skipped = new List(); foreach (var duplicate in package.Books .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => scene.SceneCharacters.Select(sceneCharacter => new { Book = book.Title, Chapter = chapter.Title, Scene = scene.Title, sceneCharacter.CharacterName })))) .Where(x => !string.IsNullOrWhiteSpace(x.CharacterName)) .GroupBy(x => $"{NormalizeNameKey(x.Book)}/{NormalizeNameKey(x.Chapter)}/{NormalizeNameKey(x.Scene)}/{NormalizeNameKey(x.CharacterName)}", StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1)) { var item = duplicate.First(); skipped.Add($"Duplicate scene appearance skipped: {item.Book} / {item.Chapter} / {item.Scene} / {item.CharacterName.Trim()}"); } foreach (var duplicate in package.SceneDependencies .Where(dependency => HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene)) .GroupBy(SceneDependencyKey, StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1)) { skipped.Add($"Duplicate scene dependency skipped: {duplicate.Key}"); } return skipped.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(); } private static void AddAliasCorrections(List corrections, string label, Dictionary? aliases) { foreach (var alias in aliases ?? []) { corrections.Add($"{label} applied: \"{alias.Key}\" -> \"{alias.Value}\"."); } } private static Dictionary BuildCharacterLookup(PlotLineImportPackage package) => BuildNameLookup(package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName })); private static Dictionary BuildLocationLookup(PlotLineImportPackage package) => BuildNameLookup(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName })); private static Dictionary BuildNameLookup(IEnumerable names) { var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); var candidateOwners = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var name in names.Where(name => !string.IsNullOrWhiteSpace(name))) { foreach (var key in CandidateNameKeys(name)) { if (candidateOwners.TryGetValue(key, out var existing) && !string.Equals(existing, name, StringComparison.OrdinalIgnoreCase)) { lookup[key] = false; } else { candidateOwners[key] = name!; lookup[key] = true; } } } return lookup; } private static bool ContainsLookupKey(Dictionary lookup, string? name) => IsKnownName(lookup, name); private static bool IsKnownName(Dictionary lookup, string? name) => CandidateNameKeys(name).Any(key => lookup.TryGetValue(key, out var isSafe) && isSafe); 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)) { var match = aliases.FirstOrDefault(alias => CandidateNameKeys(alias.Key).Contains(key, StringComparer.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(match.Value)) { return CollapseSpaces(match.Value); } } return clean; } private static IEnumerable AllImportedNames(PlotLineImportPackage package) => package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName, character.ShortName }) .Concat(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName, location.ParentLocationName })) .Concat(package.Books.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => new[] { scene.PovCharacterName, scene.LocationName })))) .Concat(package.Books.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => scene.SceneCharacters.Select(sceneCharacter => sceneCharacter.CharacterName))))) .Concat(package.StoryAssets.SelectMany(asset => new[] { asset.InitialOwnerCharacterName, asset.InitialLocationName })); 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 = (value ?? string.Empty).Trim(); 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 void ValidateLargeText(ImportValidationResult result, string fieldName, string? value) { if (!string.IsNullOrWhiteSpace(value) && value.Length > LargeTextWarningLength) { result.Warnings.Add($"{fieldName} is unusually large. It can be imported, but review the source JSON for accidental pasted manuscript text."); } } private static string FormatBookTitle(ImportBookDto book) => string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title.Trim() : $"{book.Title.Trim()}: {book.Subtitle.Trim()}"; private static string DisplayName(string? displayName, string fallback) => string.IsNullOrWhiteSpace(displayName) ? fallback.Trim() : displayName.Trim(); private static void AppendReportSection(StringBuilder builder, string title, IReadOnlyList messages) { builder.AppendLine(title); if (messages.Count == 0) { builder.AppendLine("- None"); } else { foreach (var message in messages) { builder.AppendLine($"- {message}"); } } builder.AppendLine(); } private static async Task ReadJsonTextAsync(ImportIndexViewModel model) { if (model.JsonFile is { Length: > 0 } && !string.IsNullOrWhiteSpace(model.JsonText) && !string.Equals(model.JsonText.Trim(), ImportIndexViewModel.SampleJson.Trim(), StringComparison.Ordinal)) { return model.JsonText; } if (model.JsonFile is { Length: > 0 }) { using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); return await reader.ReadToEndAsync(); } return model.JsonText ?? string.Empty; } private sealed record ImportSceneReferenceInfo(int BookOrder, int ChapterOrder, int SceneOrder, string SceneTitle); }