From a39b5cb7d877b7aa1a6af5422efb765d7f13e22e Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 1 Jun 2026 21:28:09 +0100 Subject: [PATCH] Importer Phase 1C --- PlotLine/Data/ImportRepository.cs | 523 +++++++++++++++++++++++- PlotLine/Models/ImportModels.cs | 146 +++++++ PlotLine/Services/ImportServices.cs | 300 +++++++++++++- PlotLine/ViewModels/ImportViewModels.cs | 137 ++++++- PlotLine/Views/Imports/Index.cshtml | 69 +++- PlotLine/Views/Imports/Result.cshtml | 19 + PlotLine/wwwroot/css/site.css | 19 + 7 files changed, 1178 insertions(+), 35 deletions(-) diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index 23fd488..e7d2dd7 100644 --- a/PlotLine/Data/ImportRepository.cs +++ b/PlotLine/Data/ImportRepository.cs @@ -13,7 +13,7 @@ public interface IImportRepository public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository { - private const string ImportMarker = "Imported via JSON Import Phase 1B"; + private const string ImportMarker = "Imported via JSON Import Phase 1D"; public async Task ProjectTitleExistsAsync(string projectTitle) { @@ -40,7 +40,15 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : var booksCreated = 0; var chaptersCreated = 0; var scenesCreated = 0; + var charactersCreated = 0; + var locationsCreated = 0; + var appearancesCreated = 0; + var plotLinesCreated = 0; + var plotThreadsCreated = 0; + var threadEventsCreated = 0; var skippedFields = GetSkippedOptionalFields(package); + var unresolvedWarnings = GetUnresolvedWarnings(package); + var sceneMap = new Dictionary(StringComparer.OrdinalIgnoreCase); var projectName = string.IsNullOrWhiteSpace(importedProjectTitle) ? Clean(package.Project.Title) : Clean(importedProjectTitle); @@ -59,6 +67,93 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : transaction, commandType: CommandType.StoredProcedure); + var characterMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var character in package.Characters) + { + var characterName = Clean(DisplayName(character.DisplayName, character.Name)); + if (characterName.Length == 0 || characterMap.ContainsKey(character.Name.Trim())) + { + continue; + } + + var characterId = await connection.QuerySingleAsync( + "dbo.Character_Save", + new + { + CharacterID = 0, + ProjectID = projectId, + CharacterName = characterName, + ShortName = Truncate(character.ShortName, 100), + Sex = (string?)null, + BirthDate = (DateTime?)null, + AgeAtSeriesStart = (int?)null, + AgeReferenceSceneID = (int?)null, + Height = (string?)null, + EyeColour = (string?)null, + DefaultDescription = CombineText( + character.Description, + MetadataLine("Role", character.Role), + MetadataLine("Importance", character.Importance?.ToString()), + character.Notes, + ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + + characterMap[character.Name.Trim()] = characterId; + charactersCreated++; + } + + var locationMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var location in package.Locations) + { + var locationName = Clean(DisplayName(location.DisplayName, location.Name)); + if (locationName.Length == 0 || locationMap.ContainsKey(location.Name.Trim())) + { + continue; + } + + var locationId = await connection.QuerySingleAsync( + "dbo.Location_Save", + new + { + LocationID = 0, + ProjectID = projectId, + ParentLocationID = (int?)null, + LocationName = locationName, + LocationTypeID = LookupLocationTypeId(lookupIds, location.Type), + Description = CombineText(location.Description, location.Notes, ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + + locationMap[location.Name.Trim()] = locationId; + locationsCreated++; + } + + foreach (var location in package.Locations.Where(x => !string.IsNullOrWhiteSpace(x.ParentLocationName))) + { + if (!locationMap.TryGetValue(location.Name.Trim(), out var locationId) + || !locationMap.TryGetValue(location.ParentLocationName!.Trim(), out var parentLocationId)) + { + continue; + } + + await connection.QuerySingleAsync( + "dbo.Location_Save", + new + { + LocationID = locationId, + ProjectID = projectId, + ParentLocationID = parentLocationId, + LocationName = Clean(DisplayName(location.DisplayName, location.Name)), + LocationTypeID = LookupLocationTypeId(lookupIds, location.Type), + Description = CombineText(location.Description, location.Notes, ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + } + foreach (var book in package.Books.OrderBy(x => x.SeriesOrder)) { var bookId = await connection.QuerySingleAsync( @@ -98,7 +193,9 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) { - await connection.QuerySingleAsync( + var primaryLocationId = ResolveId(locationMap, scene.LocationName); + var povCharacterId = ResolveId(characterMap, scene.PovCharacterName); + var sceneId = await connection.QuerySingleAsync( "dbo.Scene_Save", new { @@ -108,8 +205,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : SceneTitle = Clean(scene.Title), Summary = CombineText( scene.Summary, - MetadataLine("POV", scene.PovCharacterName), - MetadataLine("Location", scene.LocationName), + povCharacterId.HasValue ? null : MetadataLine("POV", scene.PovCharacterName), + primaryLocationId.HasValue ? null : MetadataLine("Location", scene.LocationName), scene.Notes, ImportMarker), TimeModeID = lookupIds.TimeModeID, @@ -121,11 +218,139 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : TimeConfidenceID = lookupIds.TimeConfidenceID, ScenePurposeNotes = CombineText(scene.PurposeText), SceneOutcomeNotes = CombineText(scene.OutcomeText), - RevisionStatusID = lookupIds.RevisionStatusID + RevisionStatusID = lookupIds.RevisionStatusID, + PrimaryLocationID = primaryLocationId }, transaction, commandType: CommandType.StoredProcedure); + if (povCharacterId.HasValue) + { + await connection.ExecuteAsync( + "UPDATE dbo.Scenes SET POVCharacterID = @POVCharacterID, UpdatedDate = SYSUTCDATETIME() WHERE SceneID = @SceneID;", + new { SceneID = sceneId, POVCharacterID = povCharacterId.Value }, + transaction); + } + + foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap)) + { + await connection.QuerySingleAsync( + "dbo.SceneCharacter_Save", + new + { + SceneCharacterID = 0, + SceneID = sceneId, + CharacterID = appearance.CharacterId, + RoleInSceneTypeID = ResolveRoleTypeId(lookupIds, appearance.RoleInScene), + PresenceTypeID = ResolvePresenceTypeId(lookupIds, appearance.RoleInScene), + LocationID = primaryLocationId, + EntryLocationID = (int?)null, + ExitLocationID = (int?)null, + AppearanceNotes = CombineText( + MetadataLine("Imported role", appearance.RoleInScene), + MetadataLine("Importance", appearance.Importance?.ToString()), + appearance.Notes), + OutfitDescription = (string?)null, + PhysicalCondition = (string?)null, + EmotionalState = (string?)null, + KnowledgeNotes = (string?)null + }, + transaction, + commandType: CommandType.StoredProcedure); + appearancesCreated++; + } scenesCreated++; + sceneMap[SceneKey(book.SeriesOrder, chapter.Order, scene.Order)] = sceneId; + } + } + } + + foreach (var plotLine in package.PlotLines) + { + if (string.IsNullOrWhiteSpace(plotLine.Name)) + { + continue; + } + + var plotLineId = await connection.QuerySingleAsync( + "dbo.PlotLine_Save", + new + { + PlotLineID = 0, + ProjectID = projectId, + BookID = (int?)null, + PlotLineName = Clean(DisplayName(plotLine.DisplayName, plotLine.Name)), + PlotLineTypeID = ResolvePlotLineTypeId(lookupIds, plotLine.Type), + Description = CombineText( + plotLine.Description, + MetadataLine("Type", plotLine.Type), + MetadataLine("Importance", plotLine.Importance?.ToString()), + plotLine.Notes, + ImportMarker), + ParentPlotLineID = (int?)null, + SortOrder = (int?)null, + Colour = "#2f6f63", + IsMainPlot = string.Equals(plotLine.Type?.Trim(), "Main Plot", StringComparison.OrdinalIgnoreCase), + IsVisibleOnTimeline = true + }, + transaction, + commandType: CommandType.StoredProcedure); + plotLinesCreated++; + + foreach (var thread in plotLine.Threads) + { + if (string.IsNullOrWhiteSpace(thread.Name)) + { + continue; + } + + var threadId = await connection.QuerySingleAsync( + "dbo.PlotThread_Save", + new + { + PlotThreadID = 0, + PlotLineID = plotLineId, + ThreadTitle = Clean(DisplayName(thread.DisplayName, thread.Name)), + ThreadTypeID = ResolveThreadTypeId(lookupIds, thread.Type), + ThreadStatusID = ResolveThreadStatusId(lookupIds, thread.Status), + Importance = ClampImportance(thread.Importance), + Summary = CombineText(thread.Description, MetadataLine("Status", thread.Status), thread.Notes, ImportMarker), + IntroducedSceneID = (int?)null, + PlannedResolutionSceneID = (int?)null, + ActualResolutionSceneID = (int?)null + }, + transaction, + commandType: CommandType.StoredProcedure); + plotThreadsCreated++; + + var importedEventSceneIds = new HashSet(); + foreach (var threadEvent in thread.Events) + { + if (!TryResolveThreadEventScene(sceneMap, threadEvent, out var sceneId) || importedEventSceneIds.Contains(sceneId)) + { + continue; + } + + await connection.QuerySingleAsync( + "dbo.ThreadEvent_Save", + new + { + ThreadEventID = 0, + PlotThreadID = threadId, + SceneID = sceneId, + EventTypeID = ResolveThreadEventTypeId(lookupIds, threadEvent.EventType), + EventTitle = Truncate(Clean(threadEvent.Title).Length == 0 ? Clean(threadEvent.Summary) : threadEvent.Title, 200) ?? "Imported thread event", + EventDescription = CombineText( + threadEvent.Summary, + MetadataLine("Event type", threadEvent.EventType), + MetadataLine("Impact", threadEvent.Impact), + MetadataLine("Importance", threadEvent.Importance?.ToString()), + threadEvent.Notes, + ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + importedEventSceneIds.Add(sceneId); + threadEventsCreated++; } } } @@ -139,7 +364,14 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : BooksCreated = booksCreated, ChaptersCreated = chaptersCreated, ScenesCreated = scenesCreated, + CharactersCreated = charactersCreated, + LocationsCreated = locationsCreated, + SceneCharacterAppearancesCreated = appearancesCreated, + PlotLinesCreated = plotLinesCreated, + PlotThreadsCreated = plotThreadsCreated, + ThreadEventsCreated = threadEventsCreated, SkippedOptionalFields = skippedFields, + UnresolvedWarnings = unresolvedWarnings, Message = "Import complete." }; } @@ -162,12 +394,44 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : "SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences WHERE TimeConfidenceName = N'Unknown' ORDER BY SortOrder;", transaction: transaction); + var locationTypes = (await connection.QueryAsync( + "SELECT LocationTypeID AS LookupID, TypeName FROM dbo.LocationTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var roleTypes = (await connection.QueryAsync( + "SELECT CharacterRoleInSceneTypeID AS LookupID, TypeName FROM dbo.CharacterRoleInSceneTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var presenceTypes = (await connection.QueryAsync( + "SELECT PresenceTypeID AS LookupID, TypeName FROM dbo.PresenceTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var plotLineTypes = (await connection.QueryAsync( + "SELECT PlotLineTypeID AS LookupID, TypeName FROM dbo.PlotLineTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var threadTypes = (await connection.QueryAsync( + "SELECT ThreadTypeID AS LookupID, TypeName FROM dbo.ThreadTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var threadStatuses = (await connection.QueryAsync( + "SELECT ThreadStatusID AS LookupID, StatusName FROM dbo.ThreadStatuses WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.StatusName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var threadEventTypes = (await connection.QueryAsync( + "SELECT ThreadEventTypeID AS LookupID, TypeName FROM dbo.ThreadEventTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue) { throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing."); } - return new ImportLookupIds(revisionStatusId.Value, timeModeId.Value, timeConfidenceId.Value); + return new ImportLookupIds( + revisionStatusId.Value, + timeModeId.Value, + timeConfidenceId.Value, + locationTypes, + roleTypes, + presenceTypes, + plotLineTypes, + threadTypes, + threadStatuses, + threadEventTypes); } private static string FormatBookTitle(ImportBookDto book) @@ -182,19 +446,233 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : private static IReadOnlyList GetSkippedOptionalFields(PlotLineImportPackage package) { var skipped = new List(); - if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName))))) + var characterNames = package.Characters + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .Select(x => x.Name.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var locationNames = package.Locations + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .Select(x => x.Name.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !characterNames.Contains(scene.PovCharacterName.Trim()))))) { skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created."); } - if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName))))) + if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !locationNames.Contains(scene.LocationName.Trim()))))) { skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created."); } + if (package.PlotLines.Any(plotLine => plotLine.Importance.HasValue)) + { + skipped.Add("Plot line importance was stored as text in plot line descriptions; PlotLine records do not have an importance column."); + } + + if (package.PlotLines.Any(plotLine => plotLine.Threads.Any(thread => thread.Events.Any(threadEvent => threadEvent.Importance.HasValue || !string.IsNullOrWhiteSpace(threadEvent.Impact))))) + { + skipped.Add("Thread event impact and importance were stored in event descriptions."); + } + return skipped; } + private static IReadOnlyList GetUnresolvedWarnings(PlotLineImportPackage package) + { + var warnings = new List(); + var characterNames = package.Characters + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .Select(x => x.Name.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var locationNames = package.Locations + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .Select(x => x.Name.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unmatched POV character: {name}"); + } + + foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.LocationName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unmatched scene location: {name}"); + } + + foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).SelectMany(scene => scene.SceneCharacters).Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unmatched scene character: {name}"); + } + + var sceneKeys = package.Books + .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => SceneKey(book.SeriesOrder, chapter.Order, scene.Order)))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in package.PlotLines.SelectMany(plotLine => plotLine.Threads.SelectMany(thread => thread.Events.Select((threadEvent, index) => new + { + PlotLineName = DisplayName(plotLine.DisplayName, plotLine.Name), + ThreadName = DisplayName(thread.DisplayName, thread.Name), + EventTitle = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {index + 1}" : threadEvent.Title.Trim(), + Event = threadEvent + }))) + .Where(x => !x.Event.BookOrder.HasValue || !x.Event.ChapterOrder.HasValue || !x.Event.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Event.BookOrder.GetValueOrDefault(), x.Event.ChapterOrder.GetValueOrDefault(), x.Event.SceneOrder.GetValueOrDefault()))) + .Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle}") + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unresolved thread event scene reference: {reference}"); + } + + return warnings; + } + + private static IEnumerable BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary characterMap) + { + var appearances = new Dictionary(); + + foreach (var sceneCharacter in scene.SceneCharacters) + { + if (string.IsNullOrWhiteSpace(sceneCharacter.CharacterName) + || !characterMap.TryGetValue(sceneCharacter.CharacterName.Trim(), out var characterId)) + { + continue; + } + + appearances[characterId] = new ImportAppearance(characterId, sceneCharacter.RoleInScene, sceneCharacter.Importance, sceneCharacter.Notes); + } + + if (povCharacterId.HasValue && !appearances.ContainsKey(povCharacterId.Value)) + { + appearances[povCharacterId.Value] = new ImportAppearance(povCharacterId.Value, "POV", null, "Auto-added from povCharacterName."); + } + + return appearances.Values; + } + + private static int? ResolveId(IReadOnlyDictionary map, string? name) + => !string.IsNullOrWhiteSpace(name) && map.TryGetValue(name.Trim(), out var id) ? id : null; + + private static int? LookupLocationTypeId(ImportLookupIds lookupIds, string? type) + { + if (!string.IsNullOrWhiteSpace(type) && lookupIds.LocationTypeIds.TryGetValue(type.Trim(), out var id)) + { + return id; + } + + return lookupIds.LocationTypeIds.TryGetValue("Other", out var otherId) ? otherId : null; + } + + private static int? ResolveRoleTypeId(ImportLookupIds lookupIds, string? role) + { + var normalized = Clean(role); + if (normalized.Length == 0) + { + normalized = "Present"; + } + + var roleName = normalized.Equals("POV", StringComparison.OrdinalIgnoreCase) + ? "POV Character" + : normalized.Equals("Present", StringComparison.OrdinalIgnoreCase) + ? "Main Participant" + : normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase) + ? "Supporting Participant" + : normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase) + ? "Mentioned Only" + : normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase) + ? "Main Participant" + : normalized; + + if (lookupIds.RoleTypeIds.TryGetValue(roleName, out var id)) + { + return id; + } + + return lookupIds.RoleTypeIds.TryGetValue("Supporting Participant", out var supportingId) ? supportingId : null; + } + + private static int? ResolvePresenceTypeId(ImportLookupIds lookupIds, string? role) + { + var normalized = Clean(role); + var presenceName = normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase) + ? "Mentioned Only" + : normalized.Equals("Background", StringComparison.OrdinalIgnoreCase) + ? "Present" + : normalized.Equals("POV", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase) + ? "Present" + : normalized.Length == 0 ? "Present" : normalized; + + if (lookupIds.PresenceTypeIds.TryGetValue(presenceName, out var id)) + { + return id; + } + + return lookupIds.PresenceTypeIds.TryGetValue("Present", out var presentId) ? presentId : null; + } + + private static int ResolvePlotLineTypeId(ImportLookupIds lookupIds, string? type) + { + if (!string.IsNullOrWhiteSpace(type) && lookupIds.PlotLineTypeIds.TryGetValue(type.Trim(), out var id)) + { + return id; + } + + return lookupIds.PlotLineTypeIds.TryGetValue("Side Plot", out var sidePlotId) + ? sidePlotId + : lookupIds.PlotLineTypeIds.Values.First(); + } + + private static int ResolveThreadTypeId(ImportLookupIds lookupIds, string? type) + { + if (!string.IsNullOrWhiteSpace(type) && lookupIds.ThreadTypeIds.TryGetValue(type.Trim(), out var id)) + { + return id; + } + + return lookupIds.ThreadTypeIds.TryGetValue("Question", out var questionId) + ? questionId + : lookupIds.ThreadTypeIds.Values.First(); + } + + private static int ResolveThreadStatusId(ImportLookupIds lookupIds, string? status) + { + if (!string.IsNullOrWhiteSpace(status) && lookupIds.ThreadStatusIds.TryGetValue(status.Trim(), out var id)) + { + return id; + } + + return lookupIds.ThreadStatusIds.TryGetValue("Planned", out var plannedId) + ? plannedId + : lookupIds.ThreadStatusIds.Values.First(); + } + + private static int ResolveThreadEventTypeId(ImportLookupIds lookupIds, string? eventType) + { + if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.ThreadEventTypeIds.TryGetValue(eventType.Trim(), out var id)) + { + return id; + } + + return lookupIds.ThreadEventTypeIds.TryGetValue("Mentioned", out var mentionedId) + ? mentionedId + : lookupIds.ThreadEventTypeIds.Values.First(); + } + + private static int ClampImportance(int? importance) + => Math.Clamp(importance ?? 5, 1, 10); + + private static bool TryResolveThreadEventScene(IReadOnlyDictionary sceneMap, ImportThreadEventDto threadEvent, out int sceneId) + { + if (threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue) + { + return sceneMap.TryGetValue(SceneKey(threadEvent.BookOrder.Value, threadEvent.ChapterOrder.Value, threadEvent.SceneOrder.Value), out sceneId); + } + + sceneId = 0; + return false; + } + + private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder) + => $"{bookOrder}/{chapterOrder}/{sceneOrder}"; + private static string Clean(string? value) => (value ?? string.Empty).Trim(); private static string? MetadataLine(string label, string? value) @@ -233,5 +711,32 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : return builder.ToString(); } - private sealed record ImportLookupIds(int RevisionStatusID, int TimeModeID, int TimeConfidenceID); + private static string DisplayName(string? displayName, string fallback) + => string.IsNullOrWhiteSpace(displayName) ? fallback : displayName; + + private sealed record ImportLookupIds( + int RevisionStatusID, + int TimeModeID, + int TimeConfidenceID, + IReadOnlyDictionary LocationTypeIds, + IReadOnlyDictionary RoleTypeIds, + IReadOnlyDictionary PresenceTypeIds, + IReadOnlyDictionary PlotLineTypeIds, + IReadOnlyDictionary ThreadTypeIds, + IReadOnlyDictionary ThreadStatusIds, + IReadOnlyDictionary ThreadEventTypeIds); + + private sealed record ImportAppearance(int CharacterId, string? RoleInScene, int? Importance, string? Notes); + + private sealed class ImportLookupRow + { + public int LookupID { get; set; } + public string TypeName { get; set; } = string.Empty; + } + + private sealed class ImportStatusLookupRow + { + public int LookupID { get; set; } + public string StatusName { get; set; } = string.Empty; + } } diff --git a/PlotLine/Models/ImportModels.cs b/PlotLine/Models/ImportModels.cs index 1b34ac1..69ef180 100644 --- a/PlotLine/Models/ImportModels.cs +++ b/PlotLine/Models/ImportModels.cs @@ -7,6 +7,10 @@ public sealed class PlotLineImportPackage public string? PackageVersion { get; set; } public string? Source { get; set; } public ImportProjectDto Project { get; set; } = new(); + public List Characters { get; set; } = []; + public List Locations { get; set; } = []; + public List PlotLines { get; set; } = []; + public List StoryAssets { get; set; } = []; public List Books { get; set; } = []; [JsonExtensionData] @@ -31,6 +35,103 @@ public sealed class ImportBookDto public List Chapters { get; set; } = []; } +public sealed class ImportCharacterDto +{ + public string Name { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public string? ShortName { get; set; } + public string? Role { get; set; } + public int? Importance { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } +} + +public sealed class ImportLocationDto +{ + public string Name { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public string? Type { get; set; } + public string? ParentLocationName { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } +} + +public sealed class ImportPlotLineDto +{ + public string Name { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public string? Type { get; set; } + public int? Importance { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } + public List Threads { get; set; } = []; +} + +public sealed class ImportPlotThreadDto +{ + public string Name { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public string? Type { get; set; } + public string? Status { get; set; } + public int? Importance { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } + public List Events { get; set; } = []; +} + +public sealed class ImportThreadEventDto +{ + public string? SceneRef { get; set; } + public int? BookOrder { get; set; } + public int? ChapterOrder { get; set; } + public int? SceneOrder { get; set; } + public string? Title { get; set; } + public string? EventType { get; set; } + public string? Summary { get; set; } + public string? Impact { get; set; } + public int? Importance { get; set; } + public string? Notes { get; set; } +} + +public sealed class ImportStoryAssetDto +{ + public string Name { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public string? Type { get; set; } + public int? Importance { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } + public string? InitialOwnerCharacterName { get; set; } + public string? InitialLocationName { get; set; } + public List States { get; set; } = []; + public List Events { get; set; } = []; +} + +public sealed class ImportAssetStateDto +{ + public string? SceneRef { get; set; } + public int? BookOrder { get; set; } + public int? ChapterOrder { get; set; } + public int? SceneOrder { get; set; } + public string? StateName { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } +} + +public sealed class ImportAssetEventDto +{ + public string? SceneRef { get; set; } + public int? BookOrder { get; set; } + public int? ChapterOrder { get; set; } + public int? SceneOrder { get; set; } + public string? EventType { get; set; } + public string? Title { get; set; } + public string? Summary { get; set; } + public string? CharacterName { get; set; } + public string? LocationName { get; set; } + public string? Notes { get; set; } +} + public sealed class ImportChapterDto { public decimal ChapterNumber { get; set; } @@ -53,6 +154,15 @@ public sealed class ImportSceneDto public string? PurposeText { get; set; } public string? OutcomeText { get; set; } public string? Notes { get; set; } + public List SceneCharacters { get; set; } = []; +} + +public sealed class ImportSceneCharacterDto +{ + public string CharacterName { get; set; } = string.Empty; + public string? RoleInScene { get; set; } + public int? Importance { get; set; } + public string? Notes { get; set; } } public sealed class ImportValidationResult @@ -69,9 +179,34 @@ public sealed class ImportPreview public bool HasDuplicateProjectTitle { get; init; } public string? DuplicateImportProjectTitle { get; init; } public IReadOnlyList BookPreviews { get; init; } = []; + public IReadOnlyList UnmatchedPovNames { get; init; } = []; + public IReadOnlyList UnmatchedLocationNames { get; init; } = []; + public IReadOnlyList UnmatchedSceneCharacterNames { get; init; } = []; + public IReadOnlyList UnresolvedThreadEventReferences { get; init; } = []; + public IReadOnlyList UnresolvedAssetReferences { get; init; } = []; + public IReadOnlyList UnmatchedAssetOwnerNames { get; init; } = []; + public IReadOnlyList UnmatchedAssetLocationNames { get; init; } = []; public int BookCount => Package?.Books.Count ?? 0; + public int CharacterCount => Package?.Characters.Count ?? 0; + public int LocationCount => Package?.Locations.Count ?? 0; + public int PlotLineCount => Package?.PlotLines.Count ?? 0; + public int PlotThreadCount => Package?.PlotLines.Sum(plotLine => plotLine.Threads.Count) ?? 0; + public int ThreadEventCount => Package?.PlotLines.Sum(plotLine => plotLine.Threads.Sum(thread => thread.Events.Count)) ?? 0; + public int ResolvedThreadEventCount => Math.Max(0, ThreadEventCount - UnresolvedThreadEventReferences.Count); + public int StoryAssetCount => Package?.StoryAssets.Count ?? 0; + public int AssetStateCount => Package?.StoryAssets.Sum(asset => asset.States.Count) ?? 0; + public int AssetEventCount => Package?.StoryAssets.Sum(asset => asset.Events.Count) ?? 0; + public int ResolvedAssetStateCount => Math.Max(0, AssetStateCount - UnresolvedAssetReferences.Count(x => x.Contains("state", StringComparison.OrdinalIgnoreCase))); + public int ResolvedAssetEventCount => Math.Max(0, AssetEventCount - UnresolvedAssetReferences.Count(x => x.Contains("event", StringComparison.OrdinalIgnoreCase))); public int ChapterCount => Package?.Books.Sum(book => book.Chapters.Count) ?? 0; public int SceneCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count)) ?? 0; + public int SceneCharacterAppearanceCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Sum(scene => scene.SceneCharacters.Count + AutoPovAppearanceCount(scene)))) ?? 0; + + private static int AutoPovAppearanceCount(ImportSceneDto scene) + => !string.IsNullOrWhiteSpace(scene.PovCharacterName) + && scene.SceneCharacters.All(x => !string.Equals(x.CharacterName?.Trim(), scene.PovCharacterName.Trim(), StringComparison.OrdinalIgnoreCase)) + ? 1 + : 0; } public sealed class ImportBookPreview @@ -96,7 +231,18 @@ public sealed class ImportResult public int BooksCreated { get; init; } public int ChaptersCreated { get; init; } public int ScenesCreated { get; init; } + public int CharactersCreated { get; init; } + public int LocationsCreated { get; init; } + public int SceneCharacterAppearancesCreated { get; init; } + public int PlotLinesCreated { get; init; } + public int PlotThreadsCreated { get; init; } + public int ThreadEventsCreated { get; init; } + public int StoryAssetsCreated { get; init; } + public int AssetStatesCreated { get; init; } + public int AssetEventsCreated { get; init; } + public int AssetCustodyEventsCreated { get; init; } public IReadOnlyList SkippedOptionalFields { get; init; } = []; + public IReadOnlyList UnresolvedWarnings { get; init; } = []; public string? TechnicalDetail { get; init; } public string Message { get; init; } = string.Empty; } diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs index b8d921d..0cf3524 100644 --- a/PlotLine/Services/ImportServices.cs +++ b/PlotLine/Services/ImportServices.cs @@ -103,12 +103,22 @@ public sealed class ImportService(IImportRepository imports, ILogger !string.IsNullOrWhiteSpace(x.CharacterName)) + .GroupBy(x => x.CharacterName.Trim(), StringComparer.OrdinalIgnoreCase) + .Where(x => x.Count() > 1)) + { + result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicate.Key} more than once in sceneCharacters."); + } } } } + 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}"); + } + return result; } + private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result) + { + 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) + { + 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)))); @@ -309,12 +519,12 @@ public sealed class ImportService(IImportRepository imports, ILogger FindUnmatchedPovNames(PlotLineImportPackage package) + { + var characterNames = CharacterNames(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)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name) + .ToList()!; + } + + private static IReadOnlyList FindUnmatchedLocationNames(PlotLineImportPackage package) + { + var locationNames = LocationNames(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)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name) + .ToList()!; + } + + private static IReadOnlyList FindUnmatchedSceneCharacterNames(PlotLineImportPackage package) + { + var characterNames = CharacterNames(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)) + .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)}") + .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 HashSet CharacterNames(PlotLineImportPackage package) + => package.Characters + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .Select(x => x.Name.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + private static HashSet LocationNames(PlotLineImportPackage package) + => package.Locations + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .Select(x => x.Name.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + private static void ValidateLargeText(ImportValidationResult result, string fieldName, string? value) { if (!string.IsNullOrWhiteSpace(value) && value.Length > LargeTextWarningLength) @@ -342,6 +631,9 @@ public sealed class ImportService(IImportRepository imports, ILogger 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); diff --git a/PlotLine/ViewModels/ImportViewModels.cs b/PlotLine/ViewModels/ImportViewModels.cs index d8a3805..7cb728a 100644 --- a/PlotLine/ViewModels/ImportViewModels.cs +++ b/PlotLine/ViewModels/ImportViewModels.cs @@ -21,12 +21,81 @@ public sealed class ImportIndexViewModel public static string SampleJson => """ { "packageVersion": "1.0", - "source": "JSON Import Phase 1B sample", + "source": "JSON Import Phase 1D sample", "project": { "title": "The Glass Meridian", "description": "A placeholder two-book fantasy outline.", "notes": "Imported sample data for testing." }, + "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." }, + { "name": "Sera", "displayName": "Sera Quill", "shortName": "Sera", "role": "Antagonist", "importance": 7, "description": "Rival mapmaker.", "notes": "Opposes the expedition." }, + { "name": "Tovin", "displayName": "Tovin Reed", "shortName": "Tovin", "role": "Supporting", "importance": 5, "description": "Mechanic and signal reader.", "notes": "Useful for technical scenes." } + ], + "locations": [ + { "name": "Old Arcade", "displayName": "Old Arcade", "type": "Building", "description": "Covered shopping arcade above the hidden route.", "notes": "Opening location." }, + { "name": "Service stair", "displayName": "Service Stair", "type": "Room", "parentLocationName": "Old Arcade", "description": "Narrow stair behind a locked panel.", "notes": "Connects to the lower city." }, + { "name": "Lantern Court", "displayName": "Lantern Court", "type": "Landmark", "description": "Underground civic court lit by glass lamps.", "notes": "Central discovery location." }, + { "name": "Clockwork Shore", "displayName": "Clockwork Shore", "type": "Outdoor Area", "description": "Mechanical coastline beyond the city.", "notes": "Book two setting." }, + { "name": "Tidal causeway", "displayName": "Tidal Causeway", "type": "Outdoor Area", "parentLocationName": "Clockwork Shore", "description": "Crossing exposed at low tide.", "notes": "Final sample scene location." } + ], + "plotLines": [ + { + "name": "Hidden City Mystery", + "displayName": "Hidden City Mystery", + "type": "Mystery", + "importance": 10, + "description": "The central question of what lies beneath the city.", + "notes": "Sample main mystery line.", + "threads": [ + { + "name": "The Brass Key", + "displayName": "The Brass Key", + "type": "Clue", + "status": "Developing", + "importance": 8, + "description": "The key points toward the lower city.", + "notes": "Thread imported from sample JSON.", + "events": [ + { "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 1, "title": "Key discovered", "eventType": "Introduced", "summary": "Mara finds the brass key.", "impact": "Opens the route beneath the arcade.", "importance": 8, "notes": "First marker." }, + { "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 1, "title": "Map gap revealed", "eventType": "Developed", "summary": "The key does not match the visible map.", "impact": "Expands the mystery.", "importance": 7, "notes": "Shows there is more below." } + ] + }, + { + "name": "Missing Captain", + "displayName": "Missing Captain", + "type": "Question", + "status": "Introduced", + "importance": 6, + "description": "A name on the signal plate points to a missing captain.", + "events": [ + { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1, "title": "Captain named", "eventType": "Question Raised", "summary": "The signal names a missing captain.", "impact": "Sets up the next search.", "importance": 6, "notes": "Book two escalation." } + ] + } + ] + }, + { + "name": "Rival Expedition", + "displayName": "Rival Expedition", + "type": "Threat", + "importance": 7, + "description": "Sera's group races the protagonists.", + "threads": [ + { + "name": "Sera's Pressure", + "displayName": "Sera's Pressure", + "type": "Threat", + "status": "Developing", + "importance": 7, + "description": "Sera appears as active opposition.", + "events": [ + { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 2, "title": "Camp sighted", "eventType": "Introduced", "summary": "Sera's camp is visible across the causeway.", "impact": "Turns the crossing into a race.", "importance": 7, "notes": "Visible antagonist pressure." } + ] + } + ] + } + ], "books": [ { "title": "Book One", @@ -40,7 +109,7 @@ public sealed class ImportIndexViewModel "chapterNumber": 1, "title": "A Door in the Rain", "order": 1, - "summary": "The protagonist finds a hidden route under the old district.", + "summary": "Mara finds a hidden route under the old district.", "notes": "Establish the central mystery.", "scenes": [ { @@ -53,7 +122,11 @@ public sealed class ImportIndexViewModel "dateTimeText": "Rainy evening", "purposeText": "Introduce the mystery", "outcomeText": "Mara keeps the brass key", - "notes": "Quiet, tense opening." + "notes": "Quiet, tense opening.", + "sceneCharacters": [ + { "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Finds the key." }, + { "characterName": "Tovin", "roleInScene": "Supporting", "importance": 4, "notes": "Warns Mara not to linger." } + ] }, { "sceneNumber": 2, @@ -65,7 +138,11 @@ public sealed class ImportIndexViewModel "dateTimeText": "Same evening", "purposeText": "Escalate the discovery", "outcomeText": "She chooses to descend", - "notes": "End on a decision." + "notes": "End on a decision.", + "sceneCharacters": [ + { "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Chooses to descend." }, + { "characterName": "Ilen", "roleInScene": "Present", "importance": 6, "notes": "Unseen at first." } + ] } ] }, @@ -73,7 +150,7 @@ public sealed class ImportIndexViewModel "chapterNumber": 2, "title": "The Lantern Court", "order": 2, - "summary": "The hidden route opens into a forgotten civic space.", + "summary": "The route opens into a forgotten civic space.", "notes": "Add wonder, but keep danger near.", "scenes": [ { @@ -86,19 +163,27 @@ public sealed class ImportIndexViewModel "dateTimeText": "Later that night", "purposeText": "Reveal the hidden setting", "outcomeText": "Mara realizes the map is incomplete", - "notes": "Use strong visual contrast." + "notes": "Use strong visual contrast.", + "sceneCharacters": [ + { "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "First glimpse of the court." }, + { "characterName": "Ilen", "roleInScene": "Supporting", "importance": 7, "notes": "Explains only part of the truth." } + ] }, { "sceneNumber": 2, "title": "The Watchman's Offer", "order": 2, - "summary": "A stranger offers help at a cost.", - "povCharacterName": "Mara", + "summary": "Ilen offers help at a cost.", + "povCharacterName": "Ilen", "locationName": "Lantern Court", "dateTimeText": "Later that night", "purposeText": "Introduce an uneasy ally", "outcomeText": "Mara accepts a temporary guide", - "notes": "Trust should feel provisional." + "notes": "Trust should feel provisional.", + "sceneCharacters": [ + { "characterName": "Ilen", "roleInScene": "POV", "importance": 8, "notes": "Frames the bargain." }, + { "characterName": "Mara", "roleInScene": "Present", "importance": 8, "notes": "Accepts with reservations." } + ] } ] } @@ -125,11 +210,16 @@ public sealed class ImportIndexViewModel "order": 1, "summary": "The expedition leaves before dawn.", "povCharacterName": "Ilen", - "locationName": "East Station", + "locationName": "Clockwork Shore", "dateTimeText": "Early morning", "purposeText": "Start the next journey", - "outcomeText": "The group leaves the city", - "notes": "Keep momentum high." + "outcomeText": "The group reaches the shore", + "notes": "Keep momentum high.", + "sceneCharacters": [ + { "characterName": "Ilen", "roleInScene": "POV", "importance": 8, "notes": "Knows the tide table." }, + { "characterName": "Mara", "roleInScene": "Present", "importance": 8, "notes": "Questions the plan." }, + { "characterName": "Tovin", "roleInScene": "Supporting", "importance": 5, "notes": "Carries tools." } + ] }, { "sceneNumber": 2, @@ -141,7 +231,11 @@ public sealed class ImportIndexViewModel "dateTimeText": "Midday", "purposeText": "Show the new world's rules", "outcomeText": "They find a damaged signal plate", - "notes": "Make the mechanism readable." + "notes": "Make the mechanism readable.", + "sceneCharacters": [ + { "characterName": "Ilen", "roleInScene": "POV", "importance": 8, "notes": "Recognizes sabotage." }, + { "characterName": "Tovin", "roleInScene": "Supporting", "importance": 6, "notes": "Repairs enough to continue." } + ] } ] }, @@ -158,23 +252,32 @@ public sealed class ImportIndexViewModel "order": 1, "summary": "The plate reveals a warning.", "povCharacterName": "Mara", - "locationName": "Shore workshop", + "locationName": "Clockwork Shore", "dateTimeText": "Afternoon", "purposeText": "Translate the clue", "outcomeText": "The warning names a missing captain", - "notes": "Make the clue actionable." + "notes": "Make the clue actionable.", + "sceneCharacters": [ + { "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Interprets the warning." }, + { "characterName": "Sera", "roleInScene": "Mentioned", "importance": 6, "notes": "Named by the signal." } + ] }, { "sceneNumber": 2, "title": "Smoke on the Causeway", "order": 2, - "summary": "A rival camp appears across the water.", + "summary": "Sera's camp appears across the water.", "povCharacterName": "Mara", "locationName": "Tidal causeway", "dateTimeText": "Sunset", "purposeText": "Introduce opposition", "outcomeText": "The group prepares to cross", - "notes": "End with forward motion." + "notes": "End with forward motion.", + "sceneCharacters": [ + { "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Chooses to cross." }, + { "characterName": "Sera", "roleInScene": "Antagonist", "importance": 7, "notes": "Visible across the causeway." }, + { "characterName": "Ilen", "roleInScene": "Supporting", "importance": 6, "notes": "Warns about the tide." } + ] } ] } diff --git a/PlotLine/Views/Imports/Index.cshtml b/PlotLine/Views/Imports/Index.cshtml index 57d9b7e..c906b2b 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 1B package, preview it, confirm the summary, then import a new project.

+

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

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

Optional

-

packageVersion, source, descriptions, notes, subtitles, chapter summaries, scene summaries, plain-text POV, plain-text location, date/time text, purpose text, and outcome text.

+

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

Not imported yet

-

No AI extraction, Word/text parsing, character records, location records, plot lines, assets, dependencies, relationships, merge/update, or export logic. Unknown JSON fields are ignored safely.

+

No AI extraction, Word/text parsing, story assets, asset states/custody, dependencies, relationship graphs, merge/update, or export logic. Thread events link to scenes with bookOrder, chapterOrder, and sceneOrder; sceneRef is accepted as future-friendly text only.

@@ -87,6 +87,12 @@ @preview.BookCount books @preview.ChapterCount chapters @preview.SceneCount scenes + @preview.CharacterCount characters + @preview.LocationCount locations + @preview.SceneCharacterAppearanceCount appearances + @preview.PlotLineCount plot lines + @preview.PlotThreadCount threads + @preview.ResolvedThreadEventCount events @@ -125,6 +131,28 @@ @if (preview.Package is not null) { + @if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any()) + { +
+ @if (preview.UnmatchedPovNames.Any()) + { + Unmatched POV: @string.Join(", ", preview.UnmatchedPovNames) + } + @if (preview.UnmatchedLocationNames.Any()) + { + Unmatched locations: @string.Join(", ", preview.UnmatchedLocationNames) + } + @if (preview.UnmatchedSceneCharacterNames.Any()) + { + Unmatched scene characters: @string.Join(", ", preview.UnmatchedSceneCharacterNames) + } + @if (preview.UnresolvedThreadEventReferences.Any()) + { + Unresolved thread events: @string.Join(", ", preview.UnresolvedThreadEventReferences) + } +
+ } +
@foreach (var bookPreview in preview.BookPreviews) { @@ -145,7 +173,14 @@
    @foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) { -
  • Scene @scene.SceneNumber: @scene.Title
  • +
  • + Scene @scene.SceneNumber: @scene.Title + + POV: @(scene.PovCharacterName ?? "Not set") / + Location: @(scene.LocationName ?? "Not set") / + Characters: @scene.SceneCharacters.Count + +
  • }
@@ -154,9 +189,33 @@ }
+ @if (preview.Package.PlotLines.Any()) + { +
+ @foreach (var plotLine in preview.Package.PlotLines) + { +
+ + Plot line: @(string.IsNullOrWhiteSpace(plotLine.DisplayName) ? plotLine.Name : plotLine.DisplayName) + @plotLine.Threads.Count threads / @plotLine.Threads.Sum(x => x.Events.Count) events + + @foreach (var thread in plotLine.Threads) + { +
+ + @(string.IsNullOrWhiteSpace(thread.DisplayName) ? thread.Name : thread.DisplayName) + @thread.Events.Count events + +
+ } +
+ } +
+ } +

Confirm import

-

This will create @preview.Package.Project.Title with @preview.BookCount books, @preview.ChapterCount chapters, and @preview.SceneCount scenes.

+

This will create @preview.Package.Project.Title with @preview.BookCount books, @preview.ChapterCount chapters, @preview.SceneCount scenes, @preview.CharacterCount characters, @preview.LocationCount locations, @preview.SceneCharacterAppearanceCount scene character appearances, @preview.PlotLineCount plot lines, @preview.PlotThreadCount plot threads, and @preview.ResolvedThreadEventCount resolved thread events.

@if (preview.HasDuplicateProjectTitle) {

Because this title already exists, the created project will be named @preview.DuplicateImportProjectTitle if you continue.

diff --git a/PlotLine/Views/Imports/Result.cshtml b/PlotLine/Views/Imports/Result.cshtml index e89fc65..ceb1223 100644 --- a/PlotLine/Views/Imports/Result.cshtml +++ b/PlotLine/Views/Imports/Result.cshtml @@ -20,6 +20,12 @@ @Model.BooksCreated books created @Model.ChaptersCreated chapters created @Model.ScenesCreated scenes created + @Model.CharactersCreated characters created + @Model.LocationsCreated locations created + @Model.SceneCharacterAppearancesCreated appearances created + @Model.PlotLinesCreated plot lines created + @Model.PlotThreadsCreated plot threads created + @Model.ThreadEventsCreated thread events created @if (Model.SkippedOptionalFields.Any()) @@ -35,5 +41,18 @@ } + @if (Model.UnresolvedWarnings.Any()) + { +
+ Unresolved mapping warnings +
    + @foreach (var warning in Model.UnresolvedWarnings) + { +
  • @warning
  • + } +
+
+ } +

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

diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index bab0bd4..9a4ef73 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -262,6 +262,25 @@ td.text-end { color: var(--plotline-muted); } +.import-scene-meta { + display: block; + margin-top: 2px; + color: var(--plotline-muted); + font-size: 0.82rem; +} + +.import-mapping-summary { + display: grid; + gap: 6px; + border: 1px solid rgba(184, 118, 43, 0.28); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 14px; + background: rgba(251, 236, 211, 0.5); + color: var(--plotline-muted); + font-size: 0.9rem; +} + .import-duplicate-confirm { border: 1px solid rgba(184, 118, 43, 0.34); border-radius: 8px;