From ed341587de3702353346168c109a47f2502ca1bc Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 1 Jun 2026 21:41:06 +0100 Subject: [PATCH] Importer Phase 1D --- PlotLine/Data/ImportRepository.cs | 302 +++++++++++++++++++++++- PlotLine/Models/ImportModels.cs | 2 + PlotLine/Services/ImportServices.cs | 188 ++++++++++++++- PlotLine/ViewModels/ImportViewModels.cs | 54 ++++- PlotLine/Views/Imports/Index.cshtml | 44 +++- PlotLine/Views/Imports/Result.cshtml | 4 + 6 files changed, 584 insertions(+), 10 deletions(-) diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index e7d2dd7..c03684d 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 1D"; + private const string ImportMarker = "Imported via JSON Import Phase 1E"; public async Task ProjectTitleExistsAsync(string projectTitle) { @@ -46,6 +46,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : var plotLinesCreated = 0; var plotThreadsCreated = 0; var threadEventsCreated = 0; + var storyAssetsCreated = 0; + var assetStatesCreated = 0; + var assetEventsCreated = 0; + var assetCustodyEventsCreated = 0; var skippedFields = GetSkippedOptionalFields(package); var unresolvedWarnings = GetUnresolvedWarnings(package); var sceneMap = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -355,6 +359,138 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : } } + var firstSceneId = sceneMap.Values.FirstOrDefault(); + foreach (var storyAsset in package.StoryAssets) + { + if (string.IsNullOrWhiteSpace(storyAsset.Name)) + { + continue; + } + + var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName); + var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName); + var storyAssetId = await connection.QuerySingleAsync( + "dbo.StoryAsset_Save", + new + { + StoryAssetID = 0, + ProjectID = projectId, + AssetName = Clean(DisplayName(storyAsset.DisplayName, storyAsset.Name)), + AssetKindID = ResolveAssetKindId(lookupIds, storyAsset.Type), + Description = CombineText( + storyAsset.Description, + MetadataLine("Type", storyAsset.Type), + initialOwnerCharacterId.HasValue ? null : MetadataLine("Initial owner", storyAsset.InitialOwnerCharacterName), + initialLocationId.HasValue ? null : MetadataLine("Initial location", storyAsset.InitialLocationName), + storyAsset.Notes, + ImportMarker), + Importance = ClampImportance(storyAsset.Importance), + CurrentStateID = ResolveAssetStateId(lookupIds, storyAsset.States.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.StateName))?.StateName), + CurrentLocationID = initialLocationId, + IsResolved = false + }, + transaction, + commandType: CommandType.StoredProcedure); + storyAssetsCreated++; + + if (initialOwnerCharacterId.HasValue && firstSceneId > 0) + { + await connection.QuerySingleAsync( + "dbo.AssetCustodyEvent_Save", + new + { + AssetCustodyEventID = 0, + StoryAssetID = storyAssetId, + SceneID = firstSceneId, + AssetCustodyEventTypeID = ResolveAssetCustodyEventTypeId(lookupIds, "Has"), + Description = CombineText("Initial owner imported from JSON.", ImportMarker), + CustodianNames = storyAsset.InitialOwnerCharacterName, + CustodyRoleID = ResolveCustodyRoleId(lookupIds, "Has Custody") + }, + transaction, + commandType: CommandType.StoredProcedure); + assetCustodyEventsCreated++; + } + + var importedAssetStateSceneIds = new HashSet(); + foreach (var state in storyAsset.States) + { + if (!TryResolveAssetStateScene(sceneMap, state, out var sceneId) || importedAssetStateSceneIds.Contains(sceneId)) + { + continue; + } + + await connection.QuerySingleAsync( + "dbo.AssetEvent_Save", + new + { + AssetEventID = 0, + StoryAssetID = storyAssetId, + SceneID = sceneId, + AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, "State Changed"), + FromStateID = (int?)null, + ToStateID = ResolveAssetStateId(lookupIds, state.StateName), + EventTitle = Truncate(Clean(state.StateName).Length == 0 ? "Imported asset state" : state.StateName, 200), + EventDescription = CombineText(state.Description, state.Notes, ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + importedAssetStateSceneIds.Add(sceneId); + assetStatesCreated++; + } + + var importedAssetEventSceneIds = new HashSet(); + foreach (var assetEvent in storyAsset.Events) + { + if (!TryResolveAssetEventScene(sceneMap, assetEvent, out var sceneId) || importedAssetEventSceneIds.Contains(sceneId)) + { + continue; + } + + var eventLocationId = ResolveId(locationMap, assetEvent.LocationName); + if (eventLocationId.HasValue) + { + await connection.QuerySingleAsync( + "dbo.SceneAssetLocation_Save", + new + { + SceneAssetLocationID = 0, + SceneID = sceneId, + StoryAssetID = storyAssetId, + LocationID = eventLocationId.Value, + Description = CombineText(assetEvent.Summary, assetEvent.Notes, ImportMarker), + UpdateCurrentLocation = true + }, + transaction, + commandType: CommandType.StoredProcedure); + } + + await connection.QuerySingleAsync( + "dbo.AssetEvent_Save", + new + { + AssetEventID = 0, + StoryAssetID = storyAssetId, + SceneID = sceneId, + AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, assetEvent.EventType), + FromStateID = (int?)null, + ToStateID = (int?)null, + EventTitle = Truncate(Clean(assetEvent.Title).Length == 0 ? Clean(assetEvent.Summary) : assetEvent.Title, 200) ?? "Imported asset event", + EventDescription = CombineText( + assetEvent.Summary, + MetadataLine("Event type", assetEvent.EventType), + MetadataLine("Character", assetEvent.CharacterName), + eventLocationId.HasValue ? null : MetadataLine("Location", assetEvent.LocationName), + assetEvent.Notes, + ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + importedAssetEventSceneIds.Add(sceneId); + assetEventsCreated++; + } + } + transaction.Commit(); return new ImportResult { @@ -370,6 +506,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : PlotLinesCreated = plotLinesCreated, PlotThreadsCreated = plotThreadsCreated, ThreadEventsCreated = threadEventsCreated, + StoryAssetsCreated = storyAssetsCreated, + AssetStatesCreated = assetStatesCreated, + AssetEventsCreated = assetEventsCreated, + AssetCustodyEventsCreated = assetCustodyEventsCreated, SkippedOptionalFields = skippedFields, UnresolvedWarnings = unresolvedWarnings, Message = "Import complete." @@ -415,6 +555,21 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : 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); + var assetKinds = (await connection.QueryAsync( + "SELECT AssetKindID AS LookupID, KindName AS TypeName FROM dbo.AssetKinds WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var assetStates = (await connection.QueryAsync( + "SELECT AssetStateID AS LookupID, StateName AS TypeName FROM dbo.AssetStates WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var assetEventTypes = (await connection.QueryAsync( + "SELECT AssetEventTypeID AS LookupID, TypeName FROM dbo.AssetEventTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var assetCustodyEventTypes = (await connection.QueryAsync( + "SELECT AssetCustodyEventTypeID AS LookupID, TypeName FROM dbo.AssetCustodyEventTypes WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); + var custodyRoles = (await connection.QueryAsync( + "SELECT CustodyRoleID AS LookupID, RoleName AS TypeName FROM dbo.CustodyRoles WHERE IsActive = 1;", + transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase); if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue) { @@ -431,7 +586,12 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : plotLineTypes, threadTypes, threadStatuses, - threadEventTypes); + threadEventTypes, + assetKinds, + assetStates, + assetEventTypes, + assetCustodyEventTypes, + custodyRoles); } private static string FormatBookTitle(ImportBookDto book) @@ -475,6 +635,11 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : skipped.Add("Thread event impact and importance were stored in event descriptions."); } + if (package.StoryAssets.Any(asset => asset.Events.Any(assetEvent => !string.IsNullOrWhiteSpace(assetEvent.CharacterName)))) + { + skipped.Add("Asset event character names were stored in event descriptions."); + } + return skipped; } @@ -522,6 +687,48 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : warnings.Add($"Unresolved thread event scene reference: {reference}"); } + foreach (var name in package.StoryAssets.Select(asset => asset.InitialOwnerCharacterName?.Trim()) + .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim()))) + .Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unmatched asset character: {name}"); + } + + foreach (var name in package.StoryAssets.Select(asset => asset.InitialLocationName?.Trim()) + .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim()))) + .Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unmatched asset location: {name}"); + } + + foreach (var reference in package.StoryAssets.SelectMany(asset => asset.States.Select((state, index) => new + { + AssetName = DisplayName(asset.DisplayName, asset.Name), + Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(), + State = state + })) + .Where(x => !x.State.BookOrder.HasValue || !x.State.ChapterOrder.HasValue || !x.State.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.State.BookOrder.GetValueOrDefault(), x.State.ChapterOrder.GetValueOrDefault(), x.State.SceneOrder.GetValueOrDefault()))) + .Select(x => $"{x.AssetName} / state / {x.Label}") + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unresolved asset state scene reference: {reference}"); + } + + foreach (var reference in package.StoryAssets.SelectMany(asset => asset.Events.Select((assetEvent, index) => new + { + AssetName = DisplayName(asset.DisplayName, asset.Name), + Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(), + Event = assetEvent + })) + .Where(x => !x.Event.BookOrder.HasValue || !x.Event.ChapterOrder.HasValue || !x.Event.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Event.BookOrder.GetValueOrDefault(), x.Event.ChapterOrder.GetValueOrDefault(), x.Event.SceneOrder.GetValueOrDefault()))) + .Select(x => $"{x.AssetName} / event / {x.Label}") + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + warnings.Add($"Unresolved asset event scene reference: {reference}"); + } + return warnings; } @@ -656,6 +863,62 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : : lookupIds.ThreadEventTypeIds.Values.First(); } + private static int ResolveAssetKindId(ImportLookupIds lookupIds, string? type) + { + if (!string.IsNullOrWhiteSpace(type) && lookupIds.AssetKindIds.TryGetValue(type.Trim(), out var id)) + { + return id; + } + + return lookupIds.AssetKindIds.TryGetValue("Object", out var objectId) + ? objectId + : lookupIds.AssetKindIds.Values.First(); + } + + private static int? ResolveAssetStateId(ImportLookupIds lookupIds, string? state) + { + if (!string.IsNullOrWhiteSpace(state) && lookupIds.AssetStateIds.TryGetValue(state.Trim(), out var id)) + { + return id; + } + + return null; + } + + private static int ResolveAssetEventTypeId(ImportLookupIds lookupIds, string? eventType) + { + if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetEventTypeIds.TryGetValue(eventType.Trim(), out var id)) + { + return id; + } + + return lookupIds.AssetEventTypeIds.TryGetValue("Mentioned", out var mentionedId) + ? mentionedId + : lookupIds.AssetEventTypeIds.Values.First(); + } + + private static int ResolveAssetCustodyEventTypeId(ImportLookupIds lookupIds, string? eventType) + { + if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetCustodyEventTypeIds.TryGetValue(eventType.Trim(), out var id)) + { + return id; + } + + return lookupIds.AssetCustodyEventTypesFallback(); + } + + private static int ResolveCustodyRoleId(ImportLookupIds lookupIds, string? role) + { + if (!string.IsNullOrWhiteSpace(role) && lookupIds.CustodyRoleIds.TryGetValue(role.Trim(), out var id)) + { + return id; + } + + return lookupIds.CustodyRoleIds.TryGetValue("Holder", out var holderId) + ? holderId + : lookupIds.CustodyRoleIds.Values.First(); + } + private static int ClampImportance(int? importance) => Math.Clamp(importance ?? 5, 1, 10); @@ -670,6 +933,28 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : return false; } + private static bool TryResolveAssetStateScene(IReadOnlyDictionary sceneMap, ImportAssetStateDto state, out int sceneId) + { + if (state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue) + { + return sceneMap.TryGetValue(SceneKey(state.BookOrder.Value, state.ChapterOrder.Value, state.SceneOrder.Value), out sceneId); + } + + sceneId = 0; + return false; + } + + private static bool TryResolveAssetEventScene(IReadOnlyDictionary sceneMap, ImportAssetEventDto assetEvent, out int sceneId) + { + if (assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue) + { + return sceneMap.TryGetValue(SceneKey(assetEvent.BookOrder.Value, assetEvent.ChapterOrder.Value, assetEvent.SceneOrder.Value), out sceneId); + } + + sceneId = 0; + return false; + } + private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder) => $"{bookOrder}/{chapterOrder}/{sceneOrder}"; @@ -724,7 +1009,18 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IReadOnlyDictionary PlotLineTypeIds, IReadOnlyDictionary ThreadTypeIds, IReadOnlyDictionary ThreadStatusIds, - IReadOnlyDictionary ThreadEventTypeIds); + IReadOnlyDictionary ThreadEventTypeIds, + IReadOnlyDictionary AssetKindIds, + IReadOnlyDictionary AssetStateIds, + IReadOnlyDictionary AssetEventTypeIds, + IReadOnlyDictionary AssetCustodyEventTypeIds, + IReadOnlyDictionary CustodyRoleIds) + { + public int AssetCustodyEventTypesFallback() + => AssetCustodyEventTypeIds.TryGetValue("Has", out var hasId) + ? hasId + : AssetCustodyEventTypeIds.Values.First(); + } private sealed record ImportAppearance(int CharacterId, string? RoleInScene, int? Importance, string? Notes); diff --git a/PlotLine/Models/ImportModels.cs b/PlotLine/Models/ImportModels.cs index 69ef180..ea81f26 100644 --- a/PlotLine/Models/ImportModels.cs +++ b/PlotLine/Models/ImportModels.cs @@ -196,8 +196,10 @@ public sealed class ImportPreview 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 AssetCustodyCount => Package?.StoryAssets.Count(asset => !string.IsNullOrWhiteSpace(asset.InitialOwnerCharacterName)) ?? 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 ResolvedAssetCustodyCount => Math.Max(0, AssetCustodyCount - Package?.StoryAssets.Count(asset => !string.IsNullOrWhiteSpace(asset.InitialOwnerCharacterName) && UnmatchedAssetOwnerNames.Contains(asset.InitialOwnerCharacterName.Trim(), StringComparer.OrdinalIgnoreCase)) ?? 0); 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; diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs index 0cf3524..7a9ed68 100644 --- a/PlotLine/Services/ImportServices.cs +++ b/PlotLine/Services/ImportServices.cs @@ -108,6 +108,10 @@ public sealed class ImportService(IImportRepository imports, ILogger !string.IsNullOrWhiteSpace(x.Name)) + .GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase) + .Where(x => x.Count() > 1)) + { + result.Errors.Add($"Story asset \"{group.Key}\" appears more than once in the import package."); + } + + for (var assetIndex = 0; assetIndex < package.StoryAssets.Count; assetIndex++) + { + var asset = package.StoryAssets[assetIndex]; + var assetLabel = string.IsNullOrWhiteSpace(asset.Name) ? $"Story asset {assetIndex + 1}" : asset.Name.Trim(); + if (string.IsNullOrWhiteSpace(asset.Name)) + { + result.Errors.Add($"Story asset {assetIndex + 1} is missing a name."); + } + + if (DisplayName(asset.DisplayName, asset.Name).Length > TitleMaxLength) + { + result.Errors.Add($"{assetLabel} name/displayName is longer than {TitleMaxLength} characters."); + } + + ValidateLargeText(result, $"{assetLabel} description", asset.Description); + ValidateLargeText(result, $"{assetLabel} notes", asset.Notes); + + foreach (var duplicate in asset.States + .Where(HasSceneOrderReference) + .GroupBy(AssetStateSceneKey) + .Where(x => x.Count() > 1)) + { + result.Errors.Add($"{assetLabel} has duplicate states for scene {duplicate.Key}."); + } + + foreach (var duplicate in asset.Events + .Where(HasSceneOrderReference) + .GroupBy(AssetEventSceneKey) + .Where(x => x.Count() > 1)) + { + result.Errors.Add($"{assetLabel} has duplicate events for scene {duplicate.Key}."); + } + + for (var stateIndex = 0; stateIndex < asset.States.Count; stateIndex++) + { + var state = asset.States[stateIndex]; + var stateLabel = string.IsNullOrWhiteSpace(state.StateName) ? $"State {stateIndex + 1}" : state.StateName.Trim(); + if (string.IsNullOrWhiteSpace(state.StateName) && string.IsNullOrWhiteSpace(state.Description)) + { + result.Errors.Add($"{assetLabel} state {stateIndex + 1} needs stateName or description."); + } + + if (!HasSceneOrderReference(state) && string.IsNullOrWhiteSpace(state.SceneRef)) + { + result.Warnings.Add($"{assetLabel} / {stateLabel} is missing bookOrder/chapterOrder/sceneOrder."); + } + + if (!string.IsNullOrWhiteSpace(state.StateName) && state.StateName.Trim().Length > 100) + { + result.Warnings.Add($"{assetLabel} / {stateLabel} stateName is longer than 100 characters and may not match an existing asset state."); + } + + ValidateLargeText(result, $"{assetLabel} / {stateLabel} description", state.Description); + ValidateLargeText(result, $"{assetLabel} / {stateLabel} notes", state.Notes); + } + + for (var eventIndex = 0; eventIndex < asset.Events.Count; eventIndex++) + { + var assetEvent = asset.Events[eventIndex]; + var eventLabel = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {eventIndex + 1}" : assetEvent.Title.Trim(); + if (string.IsNullOrWhiteSpace(assetEvent.Title) && string.IsNullOrWhiteSpace(assetEvent.Summary)) + { + result.Errors.Add($"{assetLabel} event {eventIndex + 1} needs a title or summary."); + } + + if (!HasSceneOrderReference(assetEvent) && string.IsNullOrWhiteSpace(assetEvent.SceneRef)) + { + result.Warnings.Add($"{assetLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder."); + } + + if (!string.IsNullOrWhiteSpace(assetEvent.Title) && assetEvent.Title.Trim().Length > TitleMaxLength) + { + result.Errors.Add($"{assetLabel} / {eventLabel} title is longer than {TitleMaxLength} characters."); + } + + ValidateLargeText(result, $"{assetLabel} / {eventLabel} summary", assetEvent.Summary); + ValidateLargeText(result, $"{assetLabel} / {eventLabel} notes", assetEvent.Notes); + } + } + } + private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result) { foreach (var group in package.PlotLines @@ -602,12 +719,81 @@ public sealed class ImportService(IImportRepository imports, ILogger FindUnresolvedAssetReferences(PlotLineImportPackage package) + { + var sceneKeys = package.Books + .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}"))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var stateReferences = package.StoryAssets + .SelectMany(asset => asset.States.Select((state, index) => new + { + AssetName = DisplayName(asset.DisplayName, asset.Name), + Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(), + State = state + })) + .Where(x => !HasSceneOrderReference(x.State) || !sceneKeys.Contains(AssetStateSceneKey(x.State))) + .Select(x => $"{x.AssetName} / state / {x.Label} -> {AssetStateSceneKey(x.State)}"); + + var eventReferences = package.StoryAssets + .SelectMany(asset => asset.Events.Select((assetEvent, index) => new + { + AssetName = DisplayName(asset.DisplayName, asset.Name), + Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(), + Event = assetEvent + })) + .Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(AssetEventSceneKey(x.Event))) + .Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}"); + + return stateReferences + .Concat(eventReferences) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x) + .ToList(); + } + + private static IReadOnlyList FindUnmatchedAssetOwnerNames(PlotLineImportPackage package) + { + var characterNames = CharacterNames(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)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name) + .ToList()!; + } + + private static IReadOnlyList FindUnmatchedAssetLocationNames(PlotLineImportPackage package) + { + var locationNames = LocationNames(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)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name) + .ToList()!; + } + private static bool HasSceneOrderReference(ImportThreadEventDto threadEvent) => threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue; private static string EventSceneKey(ImportThreadEventDto threadEvent) => $"{threadEvent.BookOrder?.ToString() ?? "?"}/{threadEvent.ChapterOrder?.ToString() ?? "?"}/{threadEvent.SceneOrder?.ToString() ?? "?"}"; + private static bool HasSceneOrderReference(ImportAssetStateDto state) + => state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue; + + private static bool HasSceneOrderReference(ImportAssetEventDto assetEvent) + => assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue; + + private static string AssetStateSceneKey(ImportAssetStateDto state) + => $"{state.BookOrder?.ToString() ?? "?"}/{state.ChapterOrder?.ToString() ?? "?"}/{state.SceneOrder?.ToString() ?? "?"}"; + + private static string AssetEventSceneKey(ImportAssetEventDto assetEvent) + => $"{assetEvent.BookOrder?.ToString() ?? "?"}/{assetEvent.ChapterOrder?.ToString() ?? "?"}/{assetEvent.SceneOrder?.ToString() ?? "?"}"; + private static HashSet CharacterNames(PlotLineImportPackage package) => package.Characters .Where(x => !string.IsNullOrWhiteSpace(x.Name)) diff --git a/PlotLine/ViewModels/ImportViewModels.cs b/PlotLine/ViewModels/ImportViewModels.cs index 7cb728a..7d617be 100644 --- a/PlotLine/ViewModels/ImportViewModels.cs +++ b/PlotLine/ViewModels/ImportViewModels.cs @@ -21,7 +21,7 @@ public sealed class ImportIndexViewModel public static string SampleJson => """ { "packageVersion": "1.0", - "source": "JSON Import Phase 1D sample", + "source": "JSON Import Phase 1E sample", "project": { "title": "The Glass Meridian", "description": "A placeholder two-book fantasy outline.", @@ -96,6 +96,58 @@ public sealed class ImportIndexViewModel ] } ], + "storyAssets": [ + { + "name": "Brass key", + "displayName": "The Brass Key", + "type": "Object", + "importance": 9, + "description": "A warm brass key with a notch pattern that does not match city locks.", + "notes": "Tracks the mystery through book one.", + "initialOwnerCharacterName": "Mara", + "initialLocationName": "Old Arcade", + "states": [ + { "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 1, "stateName": "Known", "description": "Mara finds the key in the old arcade.", "notes": "First asset state." }, + { "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 1, "stateName": "Changed", "description": "The key reacts to the lantern court map.", "notes": "Shows the key is more than a door tool." } + ], + "events": [ + { "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 1, "eventType": "Introduced", "title": "Key found", "summary": "Mara pockets the key after the impossible doorway appears.", "characterName": "Mara", "locationName": "Old Arcade", "notes": "Creates initial custody." }, + { "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 1, "eventType": "Used", "title": "Key warms near the map", "summary": "The brass key warms in Mara's hand near the incomplete map.", "characterName": "Mara", "locationName": "Lantern Court", "notes": "Moves the asset to the hidden city." } + ] + }, + { + "name": "Signal plate", + "displayName": "The Signal Plate", + "type": "Clue", + "importance": 8, + "description": "A damaged brass plate that carries a warning from the coast.", + "initialOwnerCharacterName": "Tovin", + "initialLocationName": "Clockwork Shore", + "states": [ + { "bookOrder": 2, "chapterOrder": 1, "sceneOrder": 2, "stateName": "Damaged", "description": "The plate is cracked by salt and failed gears." }, + { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1, "stateName": "Known", "description": "Mara translates the warning." } + ], + "events": [ + { "bookOrder": 2, "chapterOrder": 1, "sceneOrder": 2, "eventType": "Discovered", "title": "Plate recovered", "summary": "Tovin pulls the plate free from the broken machine.", "characterName": "Tovin", "locationName": "Clockwork Shore" }, + { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1, "eventType": "Revealed", "title": "Warning translated", "summary": "The plate names the missing captain and points toward Sera's route.", "characterName": "Mara", "locationName": "Clockwork Shore" } + ] + }, + { + "name": "Tide chart", + "displayName": "Ilen's Tide Chart", + "type": "Document", + "importance": 6, + "description": "A folded chart showing when the causeway opens.", + "initialOwnerCharacterName": "Ilen", + "initialLocationName": "Tidal causeway", + "states": [ + { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 2, "stateName": "Known", "description": "Ilen confirms the safe crossing window." } + ], + "events": [ + { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 2, "eventType": "Used", "title": "Crossing window found", "summary": "Ilen uses the chart to time the causeway crossing.", "characterName": "Ilen", "locationName": "Tidal causeway" } + ] + } + ], "books": [ { "title": "Book One", diff --git a/PlotLine/Views/Imports/Index.cshtml b/PlotLine/Views/Imports/Index.cshtml index c906b2b..056735b 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 1D package, preview it, confirm the summary, then import a new project.

+

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

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

Optional

-

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.

+

packageVersion, source, top-level characters, top-level locations, top-level plotLines, top-level storyAssets, 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.

Not imported yet

-

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.

+

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

@@ -93,6 +93,10 @@ @preview.PlotLineCount plot lines @preview.PlotThreadCount threads @preview.ResolvedThreadEventCount events + @preview.StoryAssetCount assets + @preview.ResolvedAssetStateCount asset states + @preview.ResolvedAssetEventCount asset events + @preview.ResolvedAssetCustodyCount custody links @@ -131,7 +135,7 @@ @if (preview.Package is not null) { - @if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any()) + @if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any() || preview.UnresolvedAssetReferences.Any() || preview.UnmatchedAssetOwnerNames.Any() || preview.UnmatchedAssetLocationNames.Any()) {
@if (preview.UnmatchedPovNames.Any()) @@ -150,6 +154,18 @@ { Unresolved thread events: @string.Join(", ", preview.UnresolvedThreadEventReferences) } + @if (preview.UnresolvedAssetReferences.Any()) + { + Unresolved asset references: @string.Join(", ", preview.UnresolvedAssetReferences) + } + @if (preview.UnmatchedAssetOwnerNames.Any()) + { + Unmatched asset owners/events: @string.Join(", ", preview.UnmatchedAssetOwnerNames) + } + @if (preview.UnmatchedAssetLocationNames.Any()) + { + Unmatched asset locations: @string.Join(", ", preview.UnmatchedAssetLocationNames) + }
} @@ -213,9 +229,27 @@ } + @if (preview.Package.StoryAssets.Any()) + { +
+ @foreach (var asset in preview.Package.StoryAssets) + { +
+ + Asset: @(string.IsNullOrWhiteSpace(asset.DisplayName) ? asset.Name : asset.DisplayName) + @asset.States.Count states / @asset.Events.Count events + +
    +
  • Type: @(asset.Type ?? "Not set") / Initial owner: @(asset.InitialOwnerCharacterName ?? "Not set") / Initial location: @(asset.InitialLocationName ?? "Not set")
  • +
+
+ } +
+ } +

Confirm import

-

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.

+

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, @preview.ResolvedThreadEventCount resolved thread events, @preview.StoryAssetCount story assets, @preview.ResolvedAssetStateCount resolved asset states, @preview.ResolvedAssetEventCount resolved asset events, and @preview.ResolvedAssetCustodyCount custody links.

@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 ceb1223..00d116d 100644 --- a/PlotLine/Views/Imports/Result.cshtml +++ b/PlotLine/Views/Imports/Result.cshtml @@ -26,6 +26,10 @@ @Model.PlotLinesCreated plot lines created @Model.PlotThreadsCreated plot threads created @Model.ThreadEventsCreated thread events created + @Model.StoryAssetsCreated story assets created + @Model.AssetStatesCreated asset states created + @Model.AssetEventsCreated asset events created + @Model.AssetCustodyEventsCreated custody records created @if (Model.SkippedOptionalFields.Any())