From 6dd46c2632203b5750d542aec0d0f8c397903f4e Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 13 Jun 2026 21:20:43 +0100 Subject: [PATCH] Phase 10H.2: Fix Word Companion Scene Links to Use Real PlotDirector Scene Data --- PlotLine/Data/WordCompanionRepository.cs | 4 +- PlotLine/Models/WordCompanionApiModels.cs | 2 + PlotLine/Services/WordCompanionService.cs | 6 +- ...2_WordCompanionSceneLinksRealSceneData.sql | 206 ++++++++++++++++++ PlotLine/Views/WordCompanionHost/Index.cshtml | 6 +- PlotLine/wwwroot/js/word-companion-host.js | 53 ++++- 6 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 PlotLine/Sql/061_Phase10H2_WordCompanionSceneLinksRealSceneData.sql diff --git a/PlotLine/Data/WordCompanionRepository.cs b/PlotLine/Data/WordCompanionRepository.cs index 18b0441..dc1e9b3 100644 --- a/PlotLine/Data/WordCompanionRepository.cs +++ b/PlotLine/Data/WordCompanionRepository.cs @@ -108,6 +108,7 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact WritingBrief = scene.WritingBrief, Blocked = scene.Blocked, BlockedReason = scene.BlockedReason, + PrimaryLocationId = scene.PrimaryLocationId, Characters = (await result.ReadAsync()).ToList(), Assets = (await result.ReadAsync()).ToList(), Locations = (await result.ReadAsync()).ToList() @@ -174,7 +175,8 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact UserID = userId, CharacterIds = ToCsv(request.CharacterIds), AssetIds = ToCsv(request.AssetIds), - LocationIds = ToCsv(request.LocationIds) + LocationIds = ToCsv(request.LocationIds), + request.PrimaryLocationId }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Models/WordCompanionApiModels.cs b/PlotLine/Models/WordCompanionApiModels.cs index 8d27a07..c3a05d3 100644 --- a/PlotLine/Models/WordCompanionApiModels.cs +++ b/PlotLine/Models/WordCompanionApiModels.cs @@ -87,6 +87,7 @@ public sealed class WordCompanionSceneCompanionResponse public string? WritingBrief { get; init; } public bool Blocked { get; init; } public string? BlockedReason { get; init; } + public int? PrimaryLocationId { get; init; } public IReadOnlyList Characters { get; init; } = []; public IReadOnlyList Assets { get; init; } = []; public IReadOnlyList Locations { get; init; } = []; @@ -150,6 +151,7 @@ public sealed class WordCompanionUpdateSceneLinksRequest public IReadOnlyList CharacterIds { get; set; } = []; public IReadOnlyList AssetIds { get; set; } = []; public IReadOnlyList LocationIds { get; set; } = []; + public int? PrimaryLocationId { get; set; } } public sealed class WordCompanionUpdateSceneLinksResponse diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index 4932699..42717a9 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -97,7 +97,11 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC public Task UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request) { - if (sceneId <= 0 || HasInvalidIds(request.CharacterIds) || HasInvalidIds(request.AssetIds) || HasInvalidIds(request.LocationIds)) + if (sceneId <= 0 + || request.PrimaryLocationId is <= 0 + || HasInvalidIds(request.CharacterIds) + || HasInvalidIds(request.AssetIds) + || HasInvalidIds(request.LocationIds)) { return Task.FromResult(null); } diff --git a/PlotLine/Sql/061_Phase10H2_WordCompanionSceneLinksRealSceneData.sql b/PlotLine/Sql/061_Phase10H2_WordCompanionSceneLinksRealSceneData.sql new file mode 100644 index 0000000..23df54c --- /dev/null +++ b/PlotLine/Sql/061_Phase10H2_WordCompanionSceneLinksRealSceneData.sql @@ -0,0 +1,206 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Scene_Companion + @SceneID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + DECLARE @BookID int; + + SELECT @ProjectID = b.ProjectID, @BookID = b.BookID + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE s.SceneID = @SceneID + AND s.IsArchived = 0 + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; + + IF @ProjectID IS NULL + RETURN; + + SELECT s.SceneID, c.ChapterID, c.ChapterTitle, s.SceneTitle, + sw.ActualWordCount AS ActualWords, + sw.EstimatedWordCount AS EstimatedWords, + s.ScenePurposeNotes AS WritingBrief, + COALESCE(sw.IsBlocked, 0) AS Blocked, + sw.BlockedReason, + s.PrimaryLocationID AS PrimaryLocationId + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID + WHERE s.SceneID = @SceneID; + + SELECT ch.CharacterID, ch.CharacterName AS Name, + CAST(CASE WHEN EXISTS (SELECT 1 FROM dbo.SceneCharacters sc WHERE sc.SceneID = @SceneID AND sc.CharacterID = ch.CharacterID) THEN 1 ELSE 0 END AS bit) AS Linked + FROM dbo.Characters ch + WHERE ch.ProjectID = @ProjectID + AND ch.IsArchived = 0 + ORDER BY ch.CharacterName, ch.CharacterID; + + SELECT sa.StoryAssetID AS AssetId, sa.AssetName AS Name, + CAST(CASE WHEN EXISTS (SELECT 1 FROM dbo.AssetEvents ae WHERE ae.SceneID = @SceneID AND ae.StoryAssetID = sa.StoryAssetID) + OR EXISTS (SELECT 1 FROM dbo.SceneAssetLocations sal WHERE sal.SceneID = @SceneID AND sal.StoryAssetID = sa.StoryAssetID) + THEN 1 ELSE 0 END AS bit) AS Linked + FROM dbo.StoryAssets sa + WHERE sa.ProjectID = @ProjectID + AND sa.IsArchived = 0 + ORDER BY sa.AssetName, sa.StoryAssetID; + + SELECT l.LocationID, l.LocationName AS Name, + CAST(CASE WHEN EXISTS (SELECT 1 FROM dbo.Scenes s WHERE s.SceneID = @SceneID AND s.PrimaryLocationID = l.LocationID) THEN 1 ELSE 0 END AS bit) AS Linked + FROM dbo.Locations l + WHERE l.ProjectID = @ProjectID + AND l.IsArchived = 0 + ORDER BY l.LocationName, l.LocationID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_SceneLinks_Update + @SceneID int, + @UserID int, + @CharacterIds nvarchar(max) = NULL, + @AssetIds nvarchar(max) = NULL, + @LocationIds nvarchar(max) = NULL, + @PrimaryLocationId int = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + DECLARE @UsedAssetEventTypeID int; + SELECT @ProjectID = b.ProjectID + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE s.SceneID = @SceneID + AND s.IsArchived = 0 + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; + + IF @ProjectID IS NULL + RETURN; + + SELECT TOP (1) @UsedAssetEventTypeID = AssetEventTypeID + FROM dbo.AssetEventTypes + WHERE TypeName = N'Used' AND IsActive = 1 + ORDER BY SortOrder, AssetEventTypeID; + + IF @UsedAssetEventTypeID IS NULL + SELECT TOP (1) @UsedAssetEventTypeID = AssetEventTypeID + FROM dbo.AssetEventTypes + WHERE IsActive = 1 + ORDER BY SortOrder, AssetEventTypeID; + + IF @UsedAssetEventTypeID IS NULL + RETURN; + + DECLARE @Characters TABLE (ID int PRIMARY KEY); + DECLARE @Assets TABLE (ID int PRIMARY KEY); + + INSERT @Characters (ID) + SELECT DISTINCT TRY_CONVERT(int, LTRIM(RTRIM(value))) + FROM STRING_SPLIT(ISNULL(@CharacterIds, N''), N',') + WHERE NULLIF(LTRIM(RTRIM(value)), N'') IS NOT NULL + AND TRY_CONVERT(int, LTRIM(RTRIM(value))) IS NOT NULL; + + INSERT @Assets (ID) + SELECT DISTINCT TRY_CONVERT(int, LTRIM(RTRIM(value))) + FROM STRING_SPLIT(ISNULL(@AssetIds, N''), N',') + WHERE NULLIF(LTRIM(RTRIM(value)), N'') IS NOT NULL + AND TRY_CONVERT(int, LTRIM(RTRIM(value))) IS NOT NULL; + + IF EXISTS (SELECT 1 FROM @Characters requested WHERE requested.ID <= 0 OR NOT EXISTS (SELECT 1 FROM dbo.Characters c WHERE c.CharacterID = requested.ID AND c.ProjectID = @ProjectID AND c.IsArchived = 0)) + RETURN; + IF EXISTS (SELECT 1 FROM @Assets requested WHERE requested.ID <= 0 OR NOT EXISTS (SELECT 1 FROM dbo.StoryAssets sa WHERE sa.StoryAssetID = requested.ID AND sa.ProjectID = @ProjectID AND sa.IsArchived = 0)) + RETURN; + IF @PrimaryLocationId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Locations l WHERE l.LocationID = @PrimaryLocationId AND l.ProjectID = @ProjectID AND l.IsArchived = 0) + RETURN; + + UPDATE dbo.Scenes + SET PrimaryLocationID = @PrimaryLocationId, + UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; + + DELETE sc + FROM dbo.SceneCharacters sc + WHERE sc.SceneID = @SceneID + AND NOT EXISTS (SELECT 1 FROM @Characters requested WHERE requested.ID = sc.CharacterID) + AND sc.RoleInSceneTypeID IS NULL + AND sc.PresenceTypeID IS NULL + AND sc.LocationID IS NULL + AND sc.EntryLocationID IS NULL + AND sc.ExitLocationID IS NULL + AND sc.AppearanceNotes IS NULL + AND sc.OutfitDescription IS NULL + AND sc.PhysicalCondition IS NULL + AND sc.EmotionalState IS NULL + AND sc.KnowledgeNotes IS NULL; + + INSERT dbo.SceneCharacters (SceneID, CharacterID) + SELECT @SceneID, requested.ID + FROM @Characters requested + WHERE NOT EXISTS (SELECT 1 FROM dbo.SceneCharacters sc WHERE sc.SceneID = @SceneID AND sc.CharacterID = requested.ID); + + DELETE ae + FROM dbo.AssetEvents ae + WHERE ae.SceneID = @SceneID + AND ae.AssetEventTypeID = @UsedAssetEventTypeID + AND ae.EventTitle = N'Used' + AND ae.EventDescription IS NULL + AND ae.FromStateID IS NULL + AND ae.ToStateID IS NULL + AND NOT EXISTS (SELECT 1 FROM @Assets requested WHERE requested.ID = ae.StoryAssetID); + + DELETE sal + FROM dbo.SceneAssetLocations sal + WHERE sal.SceneID = @SceneID + AND sal.Description IS NULL + AND NOT EXISTS (SELECT 1 FROM @Assets requested WHERE requested.ID = sal.StoryAssetID); + + INSERT dbo.AssetEvents (StoryAssetID, SceneID, AssetEventTypeID, FromStateID, ToStateID, EventTitle, EventDescription) + SELECT requested.ID, @SceneID, @UsedAssetEventTypeID, NULL, NULL, N'Used', NULL + FROM @Assets requested + WHERE NOT EXISTS (SELECT 1 FROM dbo.AssetEvents ae WHERE ae.SceneID = @SceneID AND ae.StoryAssetID = requested.ID) + AND NOT EXISTS (SELECT 1 FROM dbo.SceneAssetLocations sal WHERE sal.SceneID = @SceneID AND sal.StoryAssetID = requested.ID); + + IF @PrimaryLocationId IS NOT NULL + BEGIN + INSERT dbo.SceneAssetLocations (SceneID, StoryAssetID, LocationID, Description) + SELECT @SceneID, requested.ID, @PrimaryLocationId, NULL + FROM @Assets requested + WHERE NOT EXISTS (SELECT 1 FROM dbo.SceneAssetLocations sal WHERE sal.SceneID = @SceneID AND sal.StoryAssetID = requested.ID); + + UPDATE sal + SET LocationID = @PrimaryLocationId, + UpdatedDate = SYSUTCDATETIME() + FROM dbo.SceneAssetLocations sal + INNER JOIN @Assets requested ON requested.ID = sal.StoryAssetID + WHERE sal.SceneID = @SceneID + AND sal.Description IS NULL; + END; + + SELECT @SceneID AS SceneId, + (SELECT COUNT(*) FROM @Characters) AS CharactersUpdated, + (SELECT COUNT(*) FROM @Assets) AS AssetsUpdated, + CASE WHEN @PrimaryLocationId IS NULL THEN 0 ELSE 1 END AS LocationsUpdated, + N'Scene links updated.' AS Message; +END; +GO diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index a1e65e3..36dbb36 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -124,8 +124,10 @@
- Locations -
+ +
diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index cd77c19..7a3e725 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -43,6 +43,7 @@ const characterChips = document.querySelector("[data-character-chips]"); const assetChips = document.querySelector("[data-asset-chips]"); const locationChips = document.querySelector("[data-location-chips]"); + const primaryLocationSelect = document.querySelector("[data-primary-location-select]"); const saveSceneLinksButton = document.querySelector("[data-save-scene-links]"); const sceneLinksStatus = document.querySelector("[data-scene-links-status]"); const resolveProjectId = document.querySelector("[data-resolve-project-id]"); @@ -80,7 +81,8 @@ let currentSceneLinks = { characters: [], assets: [], - locations: [] + locations: [], + primaryLocationId: null }; const setOfficeStatus = (message) => { @@ -187,10 +189,10 @@ setHidden(plotDirectorSceneDetails, true); setHidden(writingBriefBlock, true); setHidden(plotDirectorLinkGroups, true); - currentSceneLinks = { characters: [], assets: [], locations: [] }; + currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null }; renderSceneLinkList(characterChips, [], "character"); renderSceneLinkList(assetChips, [], "asset"); - renderSceneLinkList(locationChips, [], "location"); + renderPrimaryLocationOptions([], null, false); setResolvedScene(null); setText(syncPlotDirectorCount, "-"); setText(plotDirectorSceneTitle, "-"); @@ -252,6 +254,34 @@ } }; + const renderPrimaryLocationOptions = (items, selectedLocationId, enabled = false) => { + if (!primaryLocationSelect) { + return; + } + + primaryLocationSelect.innerHTML = ""; + const none = document.createElement("option"); + none.value = ""; + none.textContent = "None"; + primaryLocationSelect.append(none); + + const selectedId = Number.parseInt(selectedLocationId || "", 10); + for (const item of Array.isArray(items) ? items : []) { + const id = Number.parseInt(linkItemId(item, "location"), 10); + if (!Number.isInteger(id) || id <= 0) { + continue; + } + + const option = document.createElement("option"); + option.value = String(id); + option.textContent = item.name || "Unnamed"; + option.selected = id === selectedId; + primaryLocationSelect.append(option); + } + + primaryLocationSelect.disabled = !enabled; + }; + const renderCompanionData = (data) => { setText(plotDirectorSceneTitle, displayValue(data?.sceneTitle)); setText(plotDirectorRevisionStatus, displayValue(data?.revisionStatus || data?.revisionStatusName || "Not provided")); @@ -275,11 +305,12 @@ currentSceneLinks = { characters: Array.isArray(data?.characters) ? data.characters : [], assets: Array.isArray(data?.assets) ? data.assets : [], - locations: Array.isArray(data?.locations) ? data.locations : [] + locations: Array.isArray(data?.locations) ? data.locations : [], + primaryLocationId: data?.primaryLocationId ?? null }; renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId); renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId); - renderSceneLinkList(locationChips, currentSceneLinks.locations, "location", !!resolvedSceneId); + renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId); setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene."); setHidden(plotDirectorSceneDetails, false); setHidden(writingBriefBlock, false); @@ -779,15 +810,20 @@ .filter((id) => Number.isInteger(id) && id > 0); }; + const selectedPrimaryLocationId = () => { + const id = Number.parseInt(primaryLocationSelect?.value || "", 10); + return Number.isInteger(id) && id > 0 ? id : null; + }; + const updateCurrentSceneLinkState = () => { const selectedCharacters = new Set(selectedLinkIds(characterChips)); const selectedAssets = new Set(selectedLinkIds(assetChips)); - const selectedLocations = new Set(selectedLinkIds(locationChips)); currentSceneLinks = { characters: currentSceneLinks.characters.map((item) => ({ ...item, linked: selectedCharacters.has(Number.parseInt(linkItemId(item, "character"), 10)) })), assets: currentSceneLinks.assets.map((item) => ({ ...item, linked: selectedAssets.has(Number.parseInt(linkItemId(item, "asset"), 10)) })), - locations: currentSceneLinks.locations.map((item) => ({ ...item, linked: selectedLocations.has(Number.parseInt(linkItemId(item, "location"), 10)) })) + locations: currentSceneLinks.locations, + primaryLocationId: selectedPrimaryLocationId() }; }; @@ -806,7 +842,8 @@ await putJson(`/api/word-companion/scenes/${resolvedSceneId}/links`, { characterIds: selectedLinkIds(characterChips), assetIds: selectedLinkIds(assetChips), - locationIds: selectedLinkIds(locationChips) + locationIds: [], + primaryLocationId: selectedPrimaryLocationId() }); updateCurrentSceneLinkState(); setSceneLinksStatus("Scene links saved successfully.");