Phase 10H.2: Fix Word Companion Scene Links to Use Real PlotDirector Scene Data

This commit is contained in:
Nick Beckley 2026-06-13 21:20:43 +01:00
parent 825a66f66a
commit 6dd46c2632
6 changed files with 265 additions and 12 deletions

View File

@ -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<WordCompanionLinkItemDto>()).ToList(),
Assets = (await result.ReadAsync<WordCompanionLinkItemDto>()).ToList(),
Locations = (await result.ReadAsync<WordCompanionLinkItemDto>()).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);
}

View File

@ -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<WordCompanionLinkItemDto> Characters { get; init; } = [];
public IReadOnlyList<WordCompanionLinkItemDto> Assets { get; init; } = [];
public IReadOnlyList<WordCompanionLinkItemDto> Locations { get; init; } = [];
@ -150,6 +151,7 @@ public sealed class WordCompanionUpdateSceneLinksRequest
public IReadOnlyList<int> CharacterIds { get; set; } = [];
public IReadOnlyList<int> AssetIds { get; set; } = [];
public IReadOnlyList<int> LocationIds { get; set; } = [];
public int? PrimaryLocationId { get; set; }
}
public sealed class WordCompanionUpdateSceneLinksResponse

View File

@ -97,7 +97,11 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC
public Task<WordCompanionUpdateSceneLinksResponse?> 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<WordCompanionUpdateSceneLinksResponse?>(null);
}

View File

@ -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

View File

@ -124,8 +124,10 @@
<div class="word-companion-check-list" data-asset-chips></div>
</div>
<div>
<span>Locations</span>
<div class="word-companion-check-list" data-location-chips></div>
<label for="word-companion-primary-location">Primary Location</label>
<select id="word-companion-primary-location" data-primary-location-select disabled>
<option value="">None</option>
</select>
</div>
</section>

View File

@ -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.");