diff --git a/PlotLine/Controllers/ImportsController.cs b/PlotLine/Controllers/ImportsController.cs index 906c49b..d48961a 100644 --- a/PlotLine/Controllers/ImportsController.cs +++ b/PlotLine/Controllers/ImportsController.cs @@ -28,7 +28,7 @@ public sealed class ImportsController(IImportService imports) : Controller public async Task Import(ImportIndexViewModel model) { var result = await imports.ImportAsync(model); - if (result.Succeeded && result.ProjectID.HasValue) + if (result.Succeeded && (result.ProjectID.HasValue || result.IsDryRun)) { return View("Result", result); } diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index c6a2e07..4725178 100644 --- a/PlotLine/Data/ImportRepository.cs +++ b/PlotLine/Data/ImportRepository.cs @@ -76,7 +76,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : foreach (var character in package.Characters) { var characterName = Clean(DisplayName(character.DisplayName, character.Name)); - if (characterName.Length == 0 || characterMap.ContainsKey(character.Name.Trim())) + if (characterName.Length == 0 || ResolveId(characterMap, character.Name, package.Aliases.Characters).HasValue) { continue; } @@ -105,7 +105,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : transaction, commandType: CommandType.StoredProcedure); - characterMap[character.Name.Trim()] = characterId; + AddLookupKeys(characterMap, character.Name, characterId); + AddLookupKeys(characterMap, character.DisplayName, characterId); charactersCreated++; } @@ -113,7 +114,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : foreach (var location in package.Locations) { var locationName = Clean(DisplayName(location.DisplayName, location.Name)); - if (locationName.Length == 0 || locationMap.ContainsKey(location.Name.Trim())) + if (locationName.Length == 0 || ResolveId(locationMap, location.Name, package.Aliases.Locations).HasValue) { continue; } @@ -132,14 +133,16 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : transaction, commandType: CommandType.StoredProcedure); - locationMap[location.Name.Trim()] = locationId; + AddLookupKeys(locationMap, location.Name, locationId); + AddLookupKeys(locationMap, location.DisplayName, locationId); locationsCreated++; } foreach (var location in package.Locations.Where(x => !string.IsNullOrWhiteSpace(x.ParentLocationName))) { - if (!locationMap.TryGetValue(location.Name.Trim(), out var locationId) - || !locationMap.TryGetValue(location.ParentLocationName!.Trim(), out var parentLocationId)) + var locationId = ResolveId(locationMap, location.Name, package.Aliases.Locations); + var parentLocation = ResolveId(locationMap, location.ParentLocationName, package.Aliases.Locations); + if (!locationId.HasValue || !parentLocation.HasValue) { continue; } @@ -148,9 +151,9 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : "dbo.Location_Save", new { - LocationID = locationId, + LocationID = locationId.Value, ProjectID = projectId, - ParentLocationID = parentLocationId, + ParentLocationID = parentLocation.Value, LocationName = Clean(DisplayName(location.DisplayName, location.Name)), LocationTypeID = LookupLocationTypeId(lookupIds, location.Type), Description = CombineText(location.Description, location.Notes, ImportMarker) @@ -198,8 +201,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) { - var primaryLocationId = ResolveId(locationMap, scene.LocationName); - var povCharacterId = ResolveId(characterMap, scene.PovCharacterName); + var primaryLocationId = ResolveId(locationMap, scene.LocationName, package.Aliases.Locations); + var povCharacterId = ResolveId(characterMap, scene.PovCharacterName, package.Aliases.Characters); var sceneId = await connection.QuerySingleAsync( "dbo.Scene_Save", new @@ -236,7 +239,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : transaction); } - foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap)) + foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap, package.Aliases.Characters)) { await connection.QuerySingleAsync( "dbo.SceneCharacter_Save", @@ -368,8 +371,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : continue; } - var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName); - var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName); + var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName, package.Aliases.Locations); + var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName, package.Aliases.Characters); var storyAssetId = await connection.QuerySingleAsync( "dbo.StoryAsset_Save", new @@ -448,7 +451,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : continue; } - var eventLocationId = ResolveId(locationMap, assetEvent.LocationName); + var eventLocationId = ResolveId(locationMap, assetEvent.LocationName, package.Aliases.Locations); if (eventLocationId.HasValue) { await connection.QuerySingleAsync( @@ -551,6 +554,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : AssetEventsCreated = assetEventsCreated, AssetCustodyEventsCreated = assetCustodyEventsCreated, SceneDependenciesCreated = sceneDependenciesCreated, + SkippedItemCount = skippedFields.Count, + UnresolvedReferenceCount = unresolvedWarnings.Count, SkippedOptionalFields = skippedFields, UnresolvedWarnings = unresolvedWarnings, Message = "Import complete." @@ -645,27 +650,27 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : private static string ImportMetadata(PlotLineImportPackage package) { var source = string.IsNullOrWhiteSpace(package.Source) ? null : $"Source: {package.Source.Trim()}"; - return CombineText(ImportMarker, $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", source) ?? ImportMarker; + return CombineText( + ImportMarker, + $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", + source, + MetadataLine("Package created by", package.CreatedBy), + MetadataLine("Package created date", package.CreatedDate), + MetadataLine("Package description", package.Description)) ?? ImportMarker; } private static IReadOnlyList GetSkippedOptionalFields(PlotLineImportPackage package) { var skipped = new List(); - var characterNames = package.Characters - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .Select(x => x.Name.Trim()) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var locationNames = package.Locations - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .Select(x => x.Name.Trim()) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName })); + var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName })); - if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !characterNames.Contains(scene.PovCharacterName.Trim()))))) + if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !ContainsNameKey(characterNames, ResolveAlias(scene.PovCharacterName, package.Aliases.Characters)))))) { skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created."); } - if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !locationNames.Contains(scene.LocationName.Trim()))))) + if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !ContainsNameKey(locationNames, ResolveAlias(scene.LocationName, package.Aliases.Locations)))))) { skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created."); } @@ -696,26 +701,20 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : private static IReadOnlyList GetUnresolvedWarnings(PlotLineImportPackage package) { var warnings = new List(); - var characterNames = package.Characters - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .Select(x => x.Name.Trim()) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var locationNames = package.Locations - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .Select(x => x.Name.Trim()) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName })); + var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName })); - foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched POV character: {name}"); } - foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.LocationName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.LocationName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations))).Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched scene location: {name}"); } - foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).SelectMany(scene => scene.SceneCharacters).Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).SelectMany(scene => scene.SceneCharacters).Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched scene character: {name}"); } @@ -739,7 +738,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : foreach (var name in package.StoryAssets.Select(asset => asset.InitialOwnerCharacterName?.Trim()) .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim()))) - .Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched asset character: {name}"); @@ -747,7 +746,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : foreach (var name in package.StoryAssets.Select(asset => asset.InitialLocationName?.Trim()) .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim()))) - .Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations))) .Distinct(StringComparer.OrdinalIgnoreCase)) { warnings.Add($"Unmatched asset location: {name}"); @@ -797,18 +796,19 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : return warnings; } - private static IEnumerable BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary characterMap) + private static IEnumerable BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary characterMap, Dictionary? aliases) { var appearances = new Dictionary(); foreach (var sceneCharacter in scene.SceneCharacters) { if (string.IsNullOrWhiteSpace(sceneCharacter.CharacterName) - || !characterMap.TryGetValue(sceneCharacter.CharacterName.Trim(), out var characterId)) + || !ResolveId(characterMap, sceneCharacter.CharacterName, aliases).HasValue) { continue; } + var characterId = ResolveId(characterMap, sceneCharacter.CharacterName, aliases)!.Value; appearances[characterId] = new ImportAppearance(characterId, sceneCharacter.RoleInScene, sceneCharacter.Importance, sceneCharacter.Notes); } @@ -820,8 +820,129 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : return appearances.Values; } - private static int? ResolveId(IReadOnlyDictionary map, string? name) - => !string.IsNullOrWhiteSpace(name) && map.TryGetValue(name.Trim(), out var id) ? id : null; + private static int? ResolveId(IReadOnlyDictionary map, string? name, Dictionary? aliases) + { + var resolvedName = ResolveAlias(name, aliases); + foreach (var key in CandidateNameKeys(resolvedName)) + { + if (map.TryGetValue(key, out var id) && id > 0) + { + return id; + } + } + + return null; + } + + private static void AddLookupKeys(Dictionary map, string? name, int id) + { + foreach (var key in CandidateNameKeys(name)) + { + if (map.TryGetValue(key, out var existingId) && existingId != id) + { + map[key] = 0; + } + else + { + map[key] = id; + } + } + } + + private static HashSet BuildNameKeySet(IEnumerable names) + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var name in names) + { + foreach (var key in CandidateNameKeys(name)) + { + keys.Add(key); + } + } + + return keys; + } + + private static bool ContainsNameKey(HashSet keys, string? name) + => CandidateNameKeys(name).Any(keys.Contains); + + private static string ResolveAlias(string? name, Dictionary? aliases) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + var clean = CollapseSpaces(name); + if (aliases is null || aliases.Count == 0) + { + return clean; + } + + foreach (var key in CandidateNameKeys(clean)) + { + foreach (var alias in aliases) + { + if (CandidateNameKeys(alias.Key).Contains(key, StringComparer.OrdinalIgnoreCase)) + { + return CollapseSpaces(alias.Value); + } + } + } + + return clean; + } + + private static IEnumerable CandidateNameKeys(string? name) + { + var normalized = NormalizeNameKey(name); + if (normalized.Length == 0) + { + yield break; + } + + yield return normalized; + + var punctuationInsensitive = new string(normalized.Where(ch => !char.IsPunctuation(ch)).ToArray()); + punctuationInsensitive = CollapseSpaces(punctuationInsensitive); + if (punctuationInsensitive.Length > 0 && punctuationInsensitive != normalized) + { + yield return punctuationInsensitive; + } + } + + private static string NormalizeNameKey(string? value) + => CollapseSpaces(value).ToUpperInvariant(); + + private static string CollapseSpaces(string? value) + { + var clean = Clean(value); + if (clean.Length == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(clean.Length); + var lastWasSpace = false; + foreach (var ch in clean) + { + if (char.IsWhiteSpace(ch)) + { + if (!lastWasSpace) + { + builder.Append(' '); + lastWasSpace = true; + } + } + else + { + builder.Append(ch); + lastWasSpace = false; + } + } + + return builder.ToString(); + } private static int? LookupLocationTypeId(ImportLookupIds lookupIds, string? type) { diff --git a/PlotLine/Models/ImportModels.cs b/PlotLine/Models/ImportModels.cs index 3e1d3ac..2e7e3cd 100644 --- a/PlotLine/Models/ImportModels.cs +++ b/PlotLine/Models/ImportModels.cs @@ -6,7 +6,11 @@ public sealed class PlotLineImportPackage { public string? PackageVersion { get; set; } public string? Source { get; set; } + public string? CreatedBy { get; set; } + public string? CreatedDate { get; set; } + public string? Description { get; set; } public ImportProjectDto Project { get; set; } = new(); + public ImportAliasesDto Aliases { get; set; } = new(); public List Characters { get; set; } = []; public List Locations { get; set; } = []; public List PlotLines { get; set; } = []; @@ -18,6 +22,12 @@ public sealed class PlotLineImportPackage public Dictionary? ExtensionData { get; set; } } +public sealed class ImportAliasesDto +{ + public Dictionary Characters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary Locations { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + public sealed class ImportProjectDto { public string Title { get; set; } = string.Empty; @@ -189,6 +199,9 @@ public sealed class ImportValidationResult public bool IsValid => Errors.Count == 0; public List Errors { get; } = []; public List Warnings { get; } = []; + public List AutoCorrections { get; } = []; + public List SkippedItems { get; } = []; + public List UnresolvedReferences { get; } = []; } public sealed class ImportPreview @@ -206,6 +219,14 @@ public sealed class ImportPreview public IReadOnlyList UnmatchedAssetOwnerNames { get; init; } = []; public IReadOnlyList UnmatchedAssetLocationNames { get; init; } = []; public IReadOnlyList UnresolvedSceneDependencyReferences { get; init; } = []; + public IReadOnlyList AliasWarnings { get; init; } = []; + public IReadOnlyList AutoCorrections { get; init; } = []; + public IReadOnlyList SkippedItems { get; init; } = []; + public int ErrorCount => Validation.Errors.Count; + public int WarningCount => Validation.Warnings.Count; + public int AutoCorrectionCount => AutoCorrections.Count + Validation.AutoCorrections.Count; + public int SkippedItemCount => SkippedItems.Count + Validation.SkippedItems.Count; + public int UnresolvedReferenceCount => UnresolvedThreadEventReferences.Count + UnresolvedAssetReferences.Count + UnresolvedSceneDependencyReferences.Count; public int BookCount => Package?.Books.Count ?? 0; public int CharacterCount => Package?.Characters.Count ?? 0; public int LocationCount => Package?.Locations.Count ?? 0; @@ -247,7 +268,7 @@ public sealed class ImportChapterPreview public int SceneCount => Chapter.Scenes.Count; } -public sealed class ImportResult +public sealed record ImportResult { public bool Succeeded { get; init; } public int? ProjectID { get; init; } @@ -266,6 +287,13 @@ public sealed class ImportResult public int AssetEventsCreated { get; init; } public int AssetCustodyEventsCreated { get; init; } public int SceneDependenciesCreated { get; init; } + public bool IsDryRun { get; init; } + public int ErrorCount { get; init; } + public int WarningCount { get; init; } + public int AutoCorrectionCount { get; init; } + public int SkippedItemCount { get; init; } + public int UnresolvedReferenceCount { get; init; } + public IReadOnlyList AutoCorrections { get; init; } = []; public IReadOnlyList SkippedOptionalFields { get; init; } = []; public IReadOnlyList UnresolvedWarnings { get; init; } = []; public string? TechnicalDetail { get; init; } diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs index a64e979..e24210e 100644 --- a/PlotLine/Services/ImportServices.cs +++ b/PlotLine/Services/ImportServices.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using System.Diagnostics; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; @@ -31,8 +32,15 @@ public sealed class ImportService(IImportRepository imports, ILogger 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; } @@ -78,9 +86,35 @@ public sealed class ImportService(IImportRepository imports, ILogger 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." }; } @@ -238,6 +337,14 @@ public sealed class ImportService(IImportRepository imports, ILogger !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]; @@ -269,6 +376,14 @@ public sealed class ImportService(IImportRepository imports, ILogger !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]; @@ -291,6 +406,10 @@ public sealed class ImportService(IImportRepository imports, ILogger !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]; @@ -318,6 +445,11 @@ public sealed class ImportService(IImportRepository imports, ILogger RelativeTimeMaxLength) { result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} dateTimeText is longer than {RelativeTimeMaxLength} characters and will be truncated."); @@ -330,15 +462,22 @@ public sealed class ImportService(IImportRepository imports, ILogger !string.IsNullOrWhiteSpace(x.CharacterName)) - .GroupBy(x => x.CharacterName.Trim(), StringComparer.OrdinalIgnoreCase) + .GroupBy(x => NormalizeNameKey(x.CharacterName), StringComparer.OrdinalIgnoreCase) .Where(x => x.Count() > 1)) { - result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicate.Key} more than once in sceneCharacters."); + 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."); @@ -463,6 +602,11 @@ public sealed class ImportService(IImportRepository imports, ILogger 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) @@ -723,12 +876,12 @@ public sealed class ImportService(IImportRepository imports, ILogger FindUnmatchedPovNames(PlotLineImportPackage package) { - var characterNames = CharacterNames(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) && !characterNames.Contains(name)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; @@ -736,12 +889,12 @@ public sealed class ImportService(IImportRepository imports, ILogger FindUnmatchedLocationNames(PlotLineImportPackage package) { - var locationNames = LocationNames(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) && !locationNames.Contains(name)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; @@ -749,13 +902,13 @@ public sealed class ImportService(IImportRepository imports, ILogger FindUnmatchedSceneCharacterNames(PlotLineImportPackage package) { - var characterNames = CharacterNames(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) && !characterNames.Contains(name)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; @@ -776,7 +929,7 @@ public sealed class ImportService(IImportRepository imports, ILogger !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(EventSceneKey(x.Event))) - .Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle} -> {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(); @@ -796,7 +949,7 @@ public sealed class ImportService(IImportRepository imports, ILogger !HasSceneOrderReference(x.State) || !sceneKeys.Contains(AssetStateSceneKey(x.State))) - .Select(x => $"{x.AssetName} / state / {x.Label} -> {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 @@ -806,7 +959,7 @@ public sealed class ImportService(IImportRepository imports, ILogger !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(AssetEventSceneKey(x.Event))) - .Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}"); + .Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}. {SceneReferenceDiagnostic(package, AssetEventSceneKey(x.Event))}"); return stateReferences .Concat(eventReferences) @@ -817,11 +970,11 @@ public sealed class ImportService(IImportRepository imports, ILogger FindUnmatchedAssetOwnerNames(PlotLineImportPackage package) { - var characterNames = CharacterNames(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) && !characterNames.Contains(name)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; @@ -829,11 +982,11 @@ public sealed class ImportService(IImportRepository imports, ILogger FindUnmatchedAssetLocationNames(PlotLineImportPackage package) { - var locationNames = LocationNames(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) && !locationNames.Contains(name)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations))) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList()!; @@ -860,7 +1013,7 @@ public sealed class ImportService(IImportRepository imports, ILogger !HasSceneOrderReference(x.Reference) || !sceneKeys.Contains(SceneReferenceKey(x.Reference!))) - .Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}") + .Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}. {SceneReferenceDiagnostic(package, SceneReferenceKey(x.Reference))}") .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x) .ToList(); @@ -895,6 +1048,46 @@ public sealed class ImportService(IImportRepository imports, ILogger $"{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(); @@ -913,17 +1106,206 @@ public sealed class ImportService(IImportRepository imports, ILogger= 1 and <= 10); } - private static HashSet CharacterNames(PlotLineImportPackage package) - => package.Characters - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .Select(x => x.Name.Trim()) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + 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 })); - private static HashSet LocationNames(PlotLineImportPackage package) - => package.Locations - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .Select(x => x.Name.Trim()) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + 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) { @@ -967,4 +1349,6 @@ public sealed class ImportService(IImportRepository imports, ILogger """ { "packageVersion": "1.0", - "source": "JSON Import Phase 1F sample", + "source": "JSON Import Phase 1G sample", + "createdBy": "PlotLine sample generator", + "createdDate": "2026-06-02", + "description": "Phase 1G sample with aliases, metadata, dependencies, and a few validation warnings.", "project": { "title": "The Glass Meridian", "description": "A placeholder two-book fantasy outline.", "notes": "Imported sample data for testing." }, + "aliases": { + "characters": { + "mara vale": "Mara", + "Ilen Or": "Ilen" + }, + "locations": { + "service staircase": "Service stair", + "Tidal Causeway": "Tidal causeway", + "old stairs": "Missing Stairwell" + } + }, "characters": [ { "name": "Mara", "displayName": "Mara Vale", "shortName": "Mara", "role": "Protagonist", "importance": 10, "description": "Courier drawn into the hidden city.", "notes": "Primary POV for book one." }, { "name": "Ilen", "displayName": "Ilen Or", "shortName": "Ilen", "role": "Guide", "importance": 8, "description": "Watchman with partial knowledge of the old routes.", "notes": "Carries secrets into book two." }, diff --git a/PlotLine/Views/Imports/Index.cshtml b/PlotLine/Views/Imports/Index.cshtml index d78885a..2d1dffd 100644 --- a/PlotLine/Views/Imports/Index.cshtml +++ b/PlotLine/Views/Imports/Index.cshtml @@ -8,7 +8,7 @@

Tools

JSON Import

-

Paste or upload a Phase 1F package, preview it, confirm the summary, then import a new project.

+

Paste or upload a Phase 1G package, preview it, dry-run if needed, then import a new project.

Download sample JSON @@ -65,7 +65,11 @@

Optional

-

packageVersion, source, top-level characters, top-level locations, top-level plotLines, top-level storyAssets, top-level sceneDependencies, plot line threads, thread events, asset states, asset events, initial asset owner/location mapping, POV/location mapping, date/time text, purpose/outcome text, and sceneCharacters.

+

packageVersion, source, createdBy, createdDate, description, top-level aliases, top-level characters, top-level locations, top-level plotLines, top-level storyAssets, top-level sceneDependencies, plot line threads, thread events, asset states, asset events, initial asset owner/location mapping, POV/location mapping, date/time text, purpose/outcome text, and sceneCharacters.

+
+
+

Dry run and aliases

+

Dry run performs validation and mapping diagnostics without saving data. aliases.characters and aliases.locations map imported names to top-level names before matching. Matching is case-insensitive, trims whitespace, normalizes repeated spaces, and uses punctuation-insensitive matching only when safe.

Scene links

@@ -73,7 +77,7 @@

Not imported yet

-

No AI extraction, Word/text parsing, manuscript parsing, metrics import, continuity warning import, relationship graphs, merge/update, or export logic.

+

No AI extraction, Word/text parsing, manuscript parsing, metrics import, continuity warning import, relationship graphs, merge/update, or export logic. This importer is ready for manuscript-derived JSON, and unknown fields are ignored safely.

@@ -102,9 +106,37 @@ @preview.ResolvedAssetEventCount asset events @preview.ResolvedAssetCustodyCount custody links @preview.ResolvedSceneDependencyCount dependencies + @preview.ErrorCount errors + @preview.WarningCount warnings + @preview.UnresolvedReferenceCount unresolved + @preview.AutoCorrectionCount corrections + @preview.SkippedItemCount skipped +
+ Import health +
+ @preview.ErrorCount errors + @preview.WarningCount warnings + @preview.AutoCorrectionCount auto-corrections + @preview.SkippedItemCount skipped items + @preview.UnresolvedReferenceCount unresolved references +
+ @if (preview.Package is not null && (!string.IsNullOrWhiteSpace(preview.Package.Source) || !string.IsNullOrWhiteSpace(preview.Package.CreatedBy) || !string.IsNullOrWhiteSpace(preview.Package.CreatedDate) || !string.IsNullOrWhiteSpace(preview.Package.Description))) + { +

+ Source: @(preview.Package.Source ?? "Not supplied") / + Created by: @(preview.Package.CreatedBy ?? "Not supplied") / + Created date: @(preview.Package.CreatedDate ?? "Not supplied") + @if (!string.IsNullOrWhiteSpace(preview.Package.Description)) + { + / @preview.Package.Description + } +

+ } +
+
@@ -138,6 +170,25 @@
} + @if (preview.AutoCorrections.Any() || preview.Validation.AutoCorrections.Any() || preview.SkippedItems.Any() || preview.Validation.SkippedItems.Any() || preview.AliasWarnings.Any()) + { +
+ Health details + @if (preview.AutoCorrections.Any() || preview.Validation.AutoCorrections.Any()) + { + Auto-corrections: @string.Join("; ", preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Take(12)) + } + @if (preview.SkippedItems.Any() || preview.Validation.SkippedItems.Any()) + { + Skipped items: @string.Join("; ", preview.SkippedItems.Concat(preview.Validation.SkippedItems).Take(12)) + } + @if (preview.AliasWarnings.Any()) + { + Alias warnings: @string.Join("; ", preview.AliasWarnings.Take(12)) + } +
+ } + @if (preview.Package is not null) { @if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any() || preview.UnresolvedAssetReferences.Any() || preview.UnmatchedAssetOwnerNames.Any() || preview.UnmatchedAssetLocationNames.Any() || preview.UnresolvedSceneDependencyReferences.Any()) @@ -182,7 +233,7 @@ @foreach (var bookPreview in preview.BookPreviews) { var book = bookPreview.Book; -
+
Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}") @bookPreview.ChapterCount chapters / @bookPreview.SceneCount scenes @@ -317,7 +368,11 @@ - +
+ + +
+ } diff --git a/PlotLine/Views/Imports/Result.cshtml b/PlotLine/Views/Imports/Result.cshtml index cc1a4b2..c293835 100644 --- a/PlotLine/Views/Imports/Result.cshtml +++ b/PlotLine/Views/Imports/Result.cshtml @@ -1,16 +1,19 @@ @model ImportResult @{ - ViewData["Title"] = "Import Complete"; + ViewData["Title"] = Model.IsDryRun ? "Dry Run Complete" : "Import Complete"; }

Import result

-

Import complete

-

@Model.ProjectName was created successfully.

+

@(Model.IsDryRun ? "Dry run complete" : "Import complete")

+

@(Model.IsDryRun ? $"{Model.ProjectName} was validated and simulated. No data was saved." : $"{Model.ProjectName} was created successfully.")

- Open imported project + @if (!Model.IsDryRun && Model.ProjectID.HasValue) + { + Open imported project + } Import another JSON package
@@ -31,8 +34,31 @@ @Model.AssetEventsCreated asset events created @Model.AssetCustodyEventsCreated custody records created @Model.SceneDependenciesCreated scene dependencies created + @Model.ErrorCount errors + @Model.WarningCount warnings + @Model.AutoCorrectionCount corrections + @Model.SkippedItemCount skipped + @Model.UnresolvedReferenceCount unresolved +
+ @(Model.IsDryRun ? "Dry run health" : "Import health") +

Errors: @Model.ErrorCount / Warnings: @Model.WarningCount / Auto-corrections: @Model.AutoCorrectionCount / Skipped items: @Model.SkippedItemCount / Unresolved references: @Model.UnresolvedReferenceCount

+
+ + @if (Model.AutoCorrections.Any()) + { +
+ Auto-corrections +
    + @foreach (var item in Model.AutoCorrections.Take(20)) + { +
  • @item
  • + } +
+
+ } + @if (Model.SkippedOptionalFields.Any()) {
@@ -59,5 +85,5 @@
} -

The import was transactional, so all listed records were created together.

+

@(Model.IsDryRun ? "Dry run wrote nothing to the database." : "The import was transactional, so all listed records were created together.")