diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index db2226f..eba2db8 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -152,6 +152,7 @@ public interface IAssetRepository Task DeleteDependencyAsync(int assetDependencyId); Task> ListCustodyBySceneAsync(int sceneId); Task> ListCustodyByAssetAsync(int storyAssetId); + Task> ListCustodyCharactersByProjectAsync(int projectId); Task SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable custodianCharacterIds, int custodyRoleId); Task DeleteCustodyEventAsync(int assetCustodyEventId); Task<(IReadOnlyList Assets, IReadOnlyList Events)> GetTimelineAsync(int projectId, int? bookId); @@ -2775,6 +2776,31 @@ public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : I return rows.ToList(); } + public async Task> ListCustodyCharactersByProjectAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT acec.AssetCustodyEventID, + ace.StoryAssetID, + ace.SceneID, + acec.CharacterID, + c.CharacterName, + acec.CharacterNameText, + cr.RoleName, + ace.UpdatedDate + FROM dbo.AssetCustodyEventCharacters acec + INNER JOIN dbo.AssetCustodyEvents ace ON ace.AssetCustodyEventID = acec.AssetCustodyEventID + INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID + LEFT JOIN dbo.Characters c ON c.CharacterID = acec.CharacterID + LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = acec.CustodyRoleID + WHERE sa.ProjectID = @ProjectID + AND sa.IsArchived = 0; + """, + new { ProjectID = projectId }); + return rows.ToList(); + } + public async Task SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable custodianCharacterIds, int custodyRoleId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index c7ac09c..16446a6 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -688,6 +688,18 @@ public sealed class AssetCustodyEvent public DateTime UpdatedDate { get; set; } } +public sealed class AssetCustodyCharacter +{ + public int AssetCustodyEventID { get; set; } + public int StoryAssetID { get; set; } + public int SceneID { get; set; } + public int? CharacterID { get; set; } + public string? CharacterName { get; set; } + public string? CharacterNameText { get; set; } + public string RoleName { get; set; } = string.Empty; + public DateTime UpdatedDate { get; set; } +} + public sealed class AssetDependencyIssue { public int AssetDependencyID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 4a4966d..7ca8589 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -2448,27 +2448,46 @@ public sealed class StoryStateService( } var characterTimeline = await characters.GetTimelineAsync(projectId, null); - var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes); + var maxScopedSceneIndex = context.ScopedScenes + .Select(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1)) + .DefaultIfEmpty(-1) + .Max(); + var scenesThroughRange = context.OrderedScenes + .Where(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1) <= maxScopedSceneIndex) + .ToList(); + var assetTimeline = await assets.GetTimelineAsync(projectId, null); + var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughRange); + var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId); var characterIds = filter.CharacterIDs.ToHashSet(); var assetIds = filter.AssetIDs.ToHashSet(); + var characterAssignments = characterTimeline.Appearances + .Where(x => x.LocationID.HasValue) + .GroupBy(x => x.CharacterID) + .ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList()); + var assetAssignments = BuildAssetLocationAssignments(assetLocations, custodyCharacters) + .GroupBy(x => x.StoryAssetID) + .ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList()); var result = new List(); foreach (var scene in context.ScopedScenes) { + var sceneIndex = context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1); var sceneCharacters = filter.IncludeCharacters - ? characterTimeline.Appearances - .Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!characterIds.Any() || characterIds.Contains(x.CharacterID))) - .Select(x => x.CharacterName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(x => x) + ? characterTimeline.Characters + .Where(character => !characterIds.Any() || characterIds.Contains(character.CharacterID)) + .Select(character => BuildCurrentCharacterPresence(character, characterAssignments.GetValueOrDefault(character.CharacterID, []), locationId, scene.SceneID, sceneIndex, context)) + .Where(x => x is not null) + .Select(x => x!) + .OrderBy(x => x.EntityName) .ToList() : []; var sceneAssets = filter.IncludeAssets - ? assetLocations - .Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!assetIds.Any() || assetIds.Contains(x.StoryAssetID))) - .Select(x => x.AssetName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(x => x) + ? assetTimeline.Assets + .Where(asset => !assetIds.Any() || assetIds.Contains(asset.StoryAssetID)) + .Select(asset => BuildCurrentAssetPresence(asset, assetAssignments.GetValueOrDefault(asset.StoryAssetID, []), characterAssignments, locationId, scene.SceneID, sceneIndex, context)) + .Where(x => x is not null) + .Select(x => x!) + .OrderBy(x => x.EntityName) .ToList() : []; @@ -2490,8 +2509,10 @@ public sealed class StoryStateService( SceneTitle = sceneContext?.SceneTitle ?? scene.SceneTitle, SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle), SceneDisplayName = sceneContext?.SceneDisplayName ?? SceneLabel(scene.SceneNumber, scene.SceneTitle), - CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters) : "None", - AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets) : "None" + Characters = sceneCharacters, + Assets = sceneAssets, + CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters.Select(x => x.EntityName)) : "None", + AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets.Select(x => x.EntityName)) : "None" }); } @@ -2505,6 +2526,7 @@ public sealed class StoryStateService( var orderedScenes = OrderScenes(timeline).ToList(); var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList(); return new RangeContext( + orderedScenes, scopedScenes, scopedScenes.Select(x => x.SceneID).ToHashSet(), BuildSceneIndexes(orderedScenes), @@ -2713,6 +2735,146 @@ public sealed class StoryStateService( .ToList(); } + private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence( + Character character, + IReadOnlyList assignments, + int locationId, + int currentSceneId, + int currentSceneIndex, + RangeContext context) + { + var latest = assignments + .Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex) + .OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ThenByDescending(x => x.UpdatedDate) + .FirstOrDefault(); + + if (latest?.LocationID != locationId) + { + return null; + } + + return new LocationActivityEntityViewModel + { + EntityID = character.CharacterID, + EntityName = character.CharacterName, + EntityType = "Character", + StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", + LastConfirmedSceneID = latest.SceneID, + LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, SceneLabel(latest.SceneNumber, latest.SceneTitle)) + }; + } + + private static LocationActivityEntityViewModel? BuildCurrentAssetPresence( + StoryAsset asset, + IReadOnlyList assignments, + IReadOnlyDictionary> characterAssignments, + int locationId, + int currentSceneId, + int currentSceneIndex, + RangeContext context) + { + var latest = assignments + .Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex) + .OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ThenByDescending(x => x.Priority) + .ThenByDescending(x => x.UpdatedDate) + .FirstOrDefault(); + + if (latest is null) + { + return null; + } + + var note = latest.Note; + if (latest.LocationID != locationId) + { + if (!latest.HolderCharacterIDs.Any()) + { + return null; + } + + var holderInLocation = latest.HolderCharacterIDs + .Select(holderId => ResolveCharacterLocationAtScene(characterAssignments.GetValueOrDefault(holderId, []), currentSceneIndex, context)) + .FirstOrDefault(x => x?.LocationID == locationId); + + if (holderInLocation is null) + { + return null; + } + + note = string.IsNullOrWhiteSpace(holderInLocation.CharacterName) + ? latest.Note + : $"Via {holderInLocation.CharacterName}"; + } + + return new LocationActivityEntityViewModel + { + EntityID = asset.StoryAssetID, + EntityName = asset.AssetName, + EntityType = "Asset", + StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", + LastConfirmedSceneID = latest.SceneID, + LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, "Unknown scene"), + Note = note + }; + } + + private static SceneCharacter? ResolveCharacterLocationAtScene( + IReadOnlyList assignments, + int currentSceneIndex, + RangeContext context) + { + return assignments + .Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex) + .OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ThenByDescending(x => x.UpdatedDate) + .FirstOrDefault(); + } + + private static List BuildAssetLocationAssignments( + IReadOnlyList assetLocations, + IReadOnlyList custodyCharacters) + { + var locationAssignments = assetLocations.Select(x => new AssetLocationActivityAssignment( + x.StoryAssetID, + x.SceneID, + x.LocationID, + [], + x.LocationPath, + 2, + x.UpdatedDate, + null)); + var custodyAssignments = custodyCharacters + .GroupBy(x => new { x.AssetCustodyEventID, x.StoryAssetID, x.SceneID }) + .Select(group => + { + var first = group.First(); + var holderIds = group + .Select(x => x.CharacterID) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .Distinct() + .ToList(); + var holderNames = group + .Select(x => x.CharacterName ?? x.CharacterNameText) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + return new AssetLocationActivityAssignment( + group.Key.StoryAssetID, + group.Key.SceneID, + null, + holderIds, + string.Join(", ", holderNames), + 3, + first.UpdatedDate, + holderIds.Any() ? null : "Named holder has no known character location"); + }); + + return locationAssignments.Concat(custodyAssignments).ToList(); + } + private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter) { storyState.Characters = filter.IncludeCharacters @@ -2836,7 +2998,18 @@ public sealed class StoryStateService( int Priority, DateTime UpdatedDate); + private sealed record AssetLocationActivityAssignment( + int StoryAssetID, + int SceneID, + int? LocationID, + IReadOnlyList HolderCharacterIDs, + string Label, + int Priority, + DateTime UpdatedDate, + string? Note); + private sealed record RangeContext( + IReadOnlyList OrderedScenes, IReadOnlyList ScopedScenes, IReadOnlySet AllowedSceneIds, IReadOnlyDictionary SceneIndexes, diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 87d255d..acac4bb 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -355,6 +355,19 @@ public sealed class LocationActivityViewModel public string SceneDisplayName { get; set; } = string.Empty; public string CharactersPresent { get; set; } = string.Empty; public string AssetsPresent { get; set; } = string.Empty; + public List Characters { get; set; } = []; + public List Assets { get; set; } = []; +} + +public sealed class LocationActivityEntityViewModel +{ + public int EntityID { get; set; } + public string EntityName { get; set; } = string.Empty; + public string EntityType { get; set; } = string.Empty; + public string StateType { get; set; } = string.Empty; + public int? LastConfirmedSceneID { get; set; } + public string LastConfirmedSceneDisplayName { get; set; } = string.Empty; + public string? Note { get; set; } } public sealed class SceneWorkflowEditViewModel diff --git a/PlotLine/Views/ContinuityExplorer/Index.cshtml b/PlotLine/Views/ContinuityExplorer/Index.cshtml index 12a982b..f88715d 100644 --- a/PlotLine/Views/ContinuityExplorer/Index.cshtml +++ b/PlotLine/Views/ContinuityExplorer/Index.cshtml @@ -364,8 +364,54 @@ else @row.ChapterDisplayName } @row.SceneDisplayName - @row.CharactersPresent - @row.AssetsPresent + + @if (!row.Characters.Any()) + { + None + } + else + { +
+ @foreach (var character in row.Characters) + { +
+ @character.EntityName + @character.StateType + @if (!string.IsNullOrWhiteSpace(character.LastConfirmedSceneDisplayName)) + { + Last confirmed: @character.LastConfirmedSceneDisplayName + } +
+ } +
+ } + + + @if (!row.Assets.Any()) + { + None + } + else + { +
+ @foreach (var asset in row.Assets) + { +
+ @asset.EntityName + @asset.StateType + @if (!string.IsNullOrWhiteSpace(asset.Note)) + { + @asset.Note + } + @if (!string.IsNullOrWhiteSpace(asset.LastConfirmedSceneDisplayName)) + { + Last confirmed: @asset.LastConfirmedSceneDisplayName + } +
+ } +
+ } + }