Phase 10K chapter create/link foundation.

This commit is contained in:
Nick Beckley 2026-06-14 10:04:41 +01:00
parent 867d536f82
commit ffcbe99028
7 changed files with 635 additions and 11 deletions

View File

@ -27,6 +27,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
return response is null ? NotFound() : Ok(response); return response is null ? NotFound() : Ok(response);
} }
[HttpGet("books/{bookId:int}/chapters")]
public async Task<IActionResult> GetChapters(int bookId)
{
var response = await wordCompanion.ListChaptersAsync(bookId);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("books/{bookId:int}/resolve-scene")] [HttpPost("books/{bookId:int}/resolve-scene")]
public async Task<IActionResult> ResolveScene(int bookId, WordCompanionResolveSceneRequest request) public async Task<IActionResult> ResolveScene(int bookId, WordCompanionResolveSceneRequest request)
{ {
@ -46,6 +53,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
return response is null ? NotFound() : Ok(response); return response is null ? NotFound() : Ok(response);
} }
[HttpPost("books/{bookId:int}/chapters")]
public async Task<IActionResult> CreateChapter(int bookId, WordCompanionCreateChapterRequest request)
{
var response = await wordCompanion.CreateChapterAsync(bookId, request);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("books/{bookId:int}/chapters/{chapterId:int}/scenes")] [HttpPost("books/{bookId:int}/chapters/{chapterId:int}/scenes")]
public async Task<IActionResult> CreateScene(int bookId, int chapterId, WordCompanionCreateSceneRequest request) public async Task<IActionResult> CreateScene(int bookId, int chapterId, WordCompanionCreateSceneRequest request)
{ {

View File

@ -9,8 +9,10 @@ public interface IWordCompanionRepository
Task<IReadOnlyList<WordCompanionProjectDto>> ListProjectsAsync(int userId); Task<IReadOnlyList<WordCompanionProjectDto>> ListProjectsAsync(int userId);
Task<IReadOnlyList<WordCompanionBookDto>> ListBooksAsync(int projectId, int userId); Task<IReadOnlyList<WordCompanionBookDto>> ListBooksAsync(int projectId, int userId);
Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId, int userId); Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId, int userId);
Task<IReadOnlyList<WordCompanionChapterDto>> ListChaptersAsync(int bookId, int userId);
Task<WordCompanionResolveSceneResponse> ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle); Task<WordCompanionResolveSceneResponse> ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle);
Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId, int userId); Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId, int userId);
Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, int userId, WordCompanionCreateChapterRequest request);
Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, int userId, WordCompanionCreateSceneRequest request); Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, int userId, WordCompanionCreateSceneRequest request);
Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, int userId, WordCompanionSyncSceneRequest request); Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, int userId, WordCompanionSyncSceneRequest request);
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request); Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request);
@ -74,6 +76,16 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
return new WordCompanionBookStructureResponse { BookId = header.BookId, Chapters = chapters }; return new WordCompanionBookStructureResponse { BookId = header.BookId, Chapters = chapters };
} }
public async Task<IReadOnlyList<WordCompanionChapterDto>> ListChaptersAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<WordCompanionChapterDto>(
"dbo.WordCompanion_Chapter_List",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<WordCompanionResolveSceneResponse> ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle) public async Task<WordCompanionResolveSceneResponse> ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -116,6 +128,15 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
}; };
} }
public async Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, int userId, WordCompanionCreateChapterRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WordCompanionCreateChapterResponse>(
"dbo.WordCompanion_Chapter_Create",
new { BookID = bookId, UserID = userId, request.Title, request.SortOrder },
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, int userId, WordCompanionCreateSceneRequest request) public async Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, int userId, WordCompanionCreateSceneRequest request)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -47,6 +47,18 @@ public sealed class WordCompanionBookStructureResponse
public IReadOnlyList<WordCompanionChapterStructureDto> Chapters { get; init; } = []; public IReadOnlyList<WordCompanionChapterStructureDto> Chapters { get; init; } = [];
} }
public sealed class WordCompanionChaptersResponse
{
public IReadOnlyList<WordCompanionChapterDto> Chapters { get; init; } = [];
}
public sealed class WordCompanionChapterDto
{
public int ChapterId { get; init; }
public string Title { get; init; } = string.Empty;
public int SortOrder { get; init; }
}
public sealed class WordCompanionResolveSceneRequest public sealed class WordCompanionResolveSceneRequest
{ {
public string? ChapterTitle { get; set; } public string? ChapterTitle { get; set; }
@ -127,6 +139,20 @@ public sealed class WordCompanionCreateSceneResponse
public string Message { get; init; } = "Scene created."; public string Message { get; init; } = "Scene created.";
} }
public sealed class WordCompanionCreateChapterRequest
{
public string? Title { get; set; }
public int? SortOrder { get; set; }
}
public sealed class WordCompanionCreateChapterResponse
{
public int ChapterId { get; init; }
public string Title { get; init; } = string.Empty;
public int SortOrder { get; init; }
public string Message { get; init; } = "Chapter created successfully.";
}
public sealed class WordCompanionSyncManuscriptRequest public sealed class WordCompanionSyncManuscriptRequest
{ {
public string? DocumentId { get; set; } public string? DocumentId { get; set; }

View File

@ -8,8 +8,10 @@ public interface IWordCompanionService
Task<WordCompanionProjectsResponse> ListProjectsAsync(); Task<WordCompanionProjectsResponse> ListProjectsAsync();
Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId); Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId);
Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId); Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId);
Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId);
Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request); Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request);
Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId); Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId);
Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request);
Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request); Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request);
Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request); Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request);
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request); Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
@ -41,6 +43,17 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC
return bookId <= 0 ? Task.FromResult<WordCompanionBookStructureResponse?>(null) : repository.GetBookStructureAsync(bookId, RequireUserId()); return bookId <= 0 ? Task.FromResult<WordCompanionBookStructureResponse?>(null) : repository.GetBookStructureAsync(bookId, RequireUserId());
} }
public async Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId)
{
if (bookId <= 0)
{
return null;
}
var chapters = await repository.ListChaptersAsync(bookId, RequireUserId());
return new WordCompanionChaptersResponse { Chapters = chapters };
}
public Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request) public Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request)
{ {
if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle)) if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle))
@ -56,6 +69,17 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC
return sceneId <= 0 ? Task.FromResult<WordCompanionSceneCompanionResponse?>(null) : repository.GetSceneCompanionAsync(sceneId, RequireUserId()); return sceneId <= 0 ? Task.FromResult<WordCompanionSceneCompanionResponse?>(null) : repository.GetSceneCompanionAsync(sceneId, RequireUserId());
} }
public Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request)
{
if (bookId <= 0 || string.IsNullOrWhiteSpace(request.Title) || request.SortOrder is <= 0)
{
return Task.FromResult<WordCompanionCreateChapterResponse?>(null);
}
request.Title = request.Title.Trim();
return repository.CreateChapterAsync(bookId, RequireUserId(), request);
}
public Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request) public Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request)
{ {
if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle)) if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle))

View File

@ -0,0 +1,82 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Chapter_List
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Books b
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE b.BookID = @BookID
AND b.IsArchived = 0
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1
)
RETURN;
SELECT c.ChapterID AS ChapterId,
c.ChapterTitle AS Title,
c.SortOrder
FROM dbo.Chapters c
WHERE c.BookID = @BookID
AND c.IsArchived = 0
ORDER BY c.SortOrder, c.ChapterNumber, c.ChapterID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Chapter_Create
@BookID int,
@UserID int,
@Title nvarchar(200),
@SortOrder int = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @TrimmedTitle nvarchar(200) = LTRIM(RTRIM(COALESCE(@Title, N'')));
IF NULLIF(@TrimmedTitle, N'') IS NULL OR @SortOrder <= 0
RETURN;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Books b
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE b.BookID = @BookID
AND b.IsArchived = 0
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1
)
RETURN;
DECLARE @PlannedRevisionStatusID int = (SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder);
IF @PlannedRevisionStatusID IS NULL
RETURN;
DECLARE @NextChapterNumber decimal(9,2) = ISNULL((SELECT MAX(ChapterNumber) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 1;
DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10;
DECLARE @ChapterID int;
INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, SortOrder, RevisionStatusID)
VALUES (@BookID, @NextChapterNumber, @TrimmedTitle, NULL, COALESCE(@SortOrder, @NextChapterOrder), @PlannedRevisionStatusID);
SET @ChapterID = CAST(SCOPE_IDENTITY() AS int);
SELECT @ChapterID AS ChapterId,
@TrimmedTitle AS Title,
COALESCE(@SortOrder, @NextChapterOrder) AS SortOrder,
N'Chapter created successfully.' AS Message;
END;
GO

View File

@ -121,6 +121,23 @@
<p class="word-companion-message" data-sync-note></p> <p class="word-companion-message" data-sync-note></p>
</section> </section>
<section class="word-companion-section word-companion-create-link" data-chapter-create-link hidden aria-label="Chapter Create Link">
<div class="word-companion-section-header">
<span>Chapter Create / Link</span>
</div>
<div class="word-companion-scene-card">
<div>
<span>Current Chapter</span>
<strong data-chapter-create-link-chapter>-</strong>
</div>
</div>
<div class="word-companion-actions">
<button type="button" data-create-plotdirector-chapter disabled>Create Chapter in PlotDirector</button>
<button type="button" data-link-existing-chapter disabled>Link to Existing Chapter</button>
</div>
<p class="word-companion-message" data-chapter-create-link-status></p>
</section>
<section class="word-companion-section word-companion-create-link" data-scene-create-link hidden aria-label="Scene Create Link"> <section class="word-companion-section word-companion-create-link" data-scene-create-link hidden aria-label="Scene Create Link">
<div class="word-companion-section-header"> <div class="word-companion-section-header">
<span>Scene Create / Link</span> <span>Scene Create / Link</span>
@ -300,6 +317,20 @@
</form> </form>
</dialog> </dialog>
<dialog class="word-companion-dialog" data-link-existing-chapter-dialog>
<form method="dialog">
<div class="word-companion-section-header">
<span>Link to Existing Chapter</span>
<button type="button" data-link-chapter-dialog-cancel>Cancel</button>
</div>
<label for="word-companion-chapter-search">Search chapters</label>
<input id="word-companion-chapter-search" type="search" data-link-chapter-search autocomplete="off" />
<div class="word-companion-scene-options" data-link-chapter-options></div>
<button type="button" data-link-selected-chapter disabled>Link Chapter</button>
<p class="word-companion-message" data-link-chapter-dialog-status></p>
</form>
</dialog>
<dialog class="word-companion-dialog" data-create-scene-dialog> <dialog class="word-companion-dialog" data-create-scene-dialog>
<form method="dialog"> <form method="dialog">
<div class="word-companion-section-header"> <div class="word-companion-section-header">
@ -315,6 +346,22 @@
</div> </div>
</form> </form>
</dialog> </dialog>
<dialog class="word-companion-dialog" data-create-chapter-dialog>
<form method="dialog">
<div class="word-companion-section-header">
<span>Create PlotDirector Chapter</span>
</div>
<p class="word-companion-dialog-copy">Create PlotDirector chapter:</p>
<strong data-create-chapter-dialog-chapter>-</strong>
<p class="word-companion-dialog-copy">in book:</p>
<strong data-create-chapter-dialog-book>-</strong>
<div class="word-companion-dialog-actions">
<button type="button" data-create-chapter-dialog-confirm>Create</button>
<button type="button" data-create-chapter-dialog-cancel>Cancel</button>
</div>
</form>
</dialog>
</main> </main>
<script src="~/js/word-companion-host.js" asp-append-version="true"></script> <script src="~/js/word-companion-host.js" asp-append-version="true"></script>
</body> </body>

View File

@ -51,6 +51,11 @@
const primaryLocationSelect = document.querySelector("[data-primary-location-select]"); const primaryLocationSelect = document.querySelector("[data-primary-location-select]");
const saveSceneLinksButton = document.querySelector("[data-save-scene-links]"); const saveSceneLinksButton = document.querySelector("[data-save-scene-links]");
const sceneLinksStatus = document.querySelector("[data-scene-links-status]"); const sceneLinksStatus = document.querySelector("[data-scene-links-status]");
const chapterCreateLink = document.querySelector("[data-chapter-create-link]");
const chapterCreateLinkChapter = document.querySelector("[data-chapter-create-link-chapter]");
const createPlotDirectorChapterButton = document.querySelector("[data-create-plotdirector-chapter]");
const linkExistingChapterButton = document.querySelector("[data-link-existing-chapter]");
const chapterCreateLinkStatus = document.querySelector("[data-chapter-create-link-status]");
const sceneCreateLink = document.querySelector("[data-scene-create-link]"); const sceneCreateLink = document.querySelector("[data-scene-create-link]");
const createLinkChapter = document.querySelector("[data-create-link-chapter]"); const createLinkChapter = document.querySelector("[data-create-link-chapter]");
const createLinkScene = document.querySelector("[data-create-link-scene]"); const createLinkScene = document.querySelector("[data-create-link-scene]");
@ -68,6 +73,17 @@
const createDialogChapter = document.querySelector("[data-create-dialog-chapter]"); const createDialogChapter = document.querySelector("[data-create-dialog-chapter]");
const createDialogConfirm = document.querySelector("[data-create-dialog-confirm]"); const createDialogConfirm = document.querySelector("[data-create-dialog-confirm]");
const createDialogCancel = document.querySelector("[data-create-dialog-cancel]"); const createDialogCancel = document.querySelector("[data-create-dialog-cancel]");
const linkExistingChapterDialog = document.querySelector("[data-link-existing-chapter-dialog]");
const linkChapterSearch = document.querySelector("[data-link-chapter-search]");
const linkChapterOptions = document.querySelector("[data-link-chapter-options]");
const linkSelectedChapterButton = document.querySelector("[data-link-selected-chapter]");
const linkChapterDialogCancel = document.querySelector("[data-link-chapter-dialog-cancel]");
const linkChapterDialogStatus = document.querySelector("[data-link-chapter-dialog-status]");
const createChapterDialog = document.querySelector("[data-create-chapter-dialog]");
const createChapterDialogChapter = document.querySelector("[data-create-chapter-dialog-chapter]");
const createChapterDialogBook = document.querySelector("[data-create-chapter-dialog-book]");
const createChapterDialogConfirm = document.querySelector("[data-create-chapter-dialog-confirm]");
const createChapterDialogCancel = document.querySelector("[data-create-chapter-dialog-cancel]");
const resolveProjectId = document.querySelector("[data-resolve-project-id]"); const resolveProjectId = document.querySelector("[data-resolve-project-id]");
const resolveProjectTitle = document.querySelector("[data-resolve-project-title]"); const resolveProjectTitle = document.querySelector("[data-resolve-project-title]");
const resolveBookId = document.querySelector("[data-resolve-book-id]"); const resolveBookId = document.querySelector("[data-resolve-book-id]");
@ -103,6 +119,7 @@
let detectedChapterTitle = ""; let detectedChapterTitle = "";
let detectedSceneTitle = ""; let detectedSceneTitle = "";
let detectedSceneWordCount = null; let detectedSceneWordCount = null;
let detectedChapterIndex = null;
let detectedChapterAnchorId = null; let detectedChapterAnchorId = null;
let detectedSceneAnchorId = null; let detectedSceneAnchorId = null;
let resolvedSceneId = null; let resolvedSceneId = null;
@ -113,6 +130,9 @@
let linkDialogScenes = []; let linkDialogScenes = [];
let selectedLinkSceneId = null; let selectedLinkSceneId = null;
let pendingCreateSceneConfirmation = null; let pendingCreateSceneConfirmation = null;
let linkDialogChapters = [];
let selectedLinkChapterId = null;
let pendingCreateChapterConfirmation = null;
let currentSceneLinks = { let currentSceneLinks = {
characters: [], characters: [],
assets: [], assets: [],
@ -198,10 +218,11 @@
}); });
}; };
const setCurrentStructure = (chapterTitle, sceneTitle, wordCount, chapterAnchorId = null, sceneAnchorId = null) => { const setCurrentStructure = (chapterTitle, sceneTitle, wordCount, chapterAnchorId = null, sceneAnchorId = null, chapterIndex = null) => {
detectedChapterTitle = chapterTitle || ""; detectedChapterTitle = chapterTitle || "";
detectedSceneTitle = sceneTitle || ""; detectedSceneTitle = sceneTitle || "";
detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : null; detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : null;
detectedChapterIndex = Number.isInteger(chapterIndex) && chapterIndex > 0 ? chapterIndex : null;
detectedChapterAnchorId = Number.isInteger(chapterAnchorId) && chapterAnchorId > 0 ? chapterAnchorId : null; detectedChapterAnchorId = Number.isInteger(chapterAnchorId) && chapterAnchorId > 0 ? chapterAnchorId : null;
detectedSceneAnchorId = Number.isInteger(sceneAnchorId) && sceneAnchorId > 0 ? sceneAnchorId : null; detectedSceneAnchorId = Number.isInteger(sceneAnchorId) && sceneAnchorId > 0 ? sceneAnchorId : null;
@ -268,11 +289,48 @@
setText(createLinkStatus, message); setText(createLinkStatus, message);
}; };
const setChapterCreateLinkStatus = (message) => {
setText(chapterCreateLinkStatus, message);
};
const canUseChapterCreateLinkActions = () => !!selectedBook()
&& !!detectedChapterTitle
&& !resolvedChapterId;
const canUseCreateLinkActions = () => !!selectedBook() const canUseCreateLinkActions = () => !!selectedBook()
&& !!detectedSceneTitle && !!detectedSceneTitle
&& Number.isInteger(resolvedChapterId) && Number.isInteger(resolvedChapterId)
&& resolvedChapterId > 0; && resolvedChapterId > 0;
const hideChapterCreateLink = () => {
setHidden(chapterCreateLink, true);
setChapterCreateLinkStatus("");
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = true;
createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector";
}
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = true;
linkExistingChapterButton.textContent = "Link to Existing Chapter";
}
};
const showChapterCreateLink = (message = "") => {
setText(chapterCreateLinkChapter, displayValue(detectedChapterTitle));
setHidden(chapterCreateLink, false);
setChapterCreateLinkStatus(message);
const enabled = canUseChapterCreateLinkActions();
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = !enabled;
createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector";
}
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = !enabled;
linkExistingChapterButton.textContent = "Link to Existing Chapter";
}
};
const hideSceneCreateLink = () => { const hideSceneCreateLink = () => {
setHidden(sceneCreateLink, true); setHidden(sceneCreateLink, true);
setCreateLinkStatus(""); setCreateLinkStatus("");
@ -308,6 +366,7 @@
setHidden(plotDirectorSceneDetails, true); setHidden(plotDirectorSceneDetails, true);
setHidden(writingBriefBlock, true); setHidden(writingBriefBlock, true);
setHidden(plotDirectorLinkGroups, true); setHidden(plotDirectorLinkGroups, true);
hideChapterCreateLink();
hideSceneCreateLink(); hideSceneCreateLink();
currentResolutionMethod = ""; currentResolutionMethod = "";
currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match"; currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match";
@ -506,6 +565,11 @@
return Array.isArray(structure?.chapters) ? structure.chapters : []; return Array.isArray(structure?.chapters) ? structure.chapters : [];
}; };
const fetchBookChapters = async (bookId) => {
const response = await fetchJson(`/api/word-companion/books/${bookId}/chapters`);
return Array.isArray(response?.chapters) ? response.chapters : [];
};
const findCurrentChapterInChapters = (chapters) => { const findCurrentChapterInChapters = (chapters) => {
if (Number.isInteger(resolvedChapterId) && resolvedChapterId > 0) { if (Number.isInteger(resolvedChapterId) && resolvedChapterId > 0) {
const anchoredChapter = chapters.find((chapter) => chapter.chapterId === resolvedChapterId); const anchoredChapter = chapters.find((chapter) => chapter.chapterId === resolvedChapterId);
@ -543,6 +607,7 @@
storedAnchorInvalid = false; storedAnchorInvalid = false;
setPlotDirectorSceneStatus("Linked to PlotDirector"); setPlotDirectorSceneStatus("Linked to PlotDirector");
setDocumentMessage(""); setDocumentMessage("");
hideChapterCreateLink();
hideSceneCreateLink(); hideSceneCreateLink();
setResolvedScene(sceneId, chapterId, resolutionMethod); setResolvedScene(sceneId, chapterId, resolutionMethod);
renderCompanionData(companionData); renderCompanionData(companionData);
@ -918,7 +983,8 @@
structure.currentScene?.title, structure.currentScene?.title,
structure.currentScene?.wordCount, structure.currentScene?.wordCount,
structure.currentChapter?.anchorId, structure.currentChapter?.anchorId,
structure.currentScene?.anchorId structure.currentScene?.anchorId,
structure.currentChapter?.index
); );
resetPlotDirectorScene("Not detected"); resetPlotDirectorScene("Not detected");
renderOutline(structure); renderOutline(structure);
@ -1163,10 +1229,269 @@
} }
}; };
const attachResolvedChapterId = async (successMessage) => {
if (!resolvedChapterId) {
setChapterCreateLinkStatus("No resolved PlotDirector chapter.");
return false;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setChapterCreateLinkStatus("Word document access is unavailable.");
return false;
}
try {
await window.Word.run(async (context) => {
const structure = await readDocumentStructure(context);
if (!structure.currentChapter) {
throw new Error("No chapter detected in Word. Use Heading 1 for chapters.");
}
const chapterParagraph = structure.bodyParagraphs[structure.currentChapter.paragraphIndex];
if (!chapterParagraph) {
throw new Error("Unable to locate the current Word chapter heading.");
}
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
await context.sync();
});
detectedChapterAnchorId = resolvedChapterId;
storedAnchorInvalid = false;
currentResolutionAnchorType = "ChapterID Anchor";
currentResolutionMethod = "Chapter anchor";
setChapterCreateLinkStatus(successMessage);
setDocumentMessage(successMessage);
setDiagnostics({ lastError: "-" });
updateAnchorDisplay();
return true;
} catch (error) {
console.error("Unable to attach PlotDirector chapter ID.", error);
setChapterCreateLinkStatus("Unable to attach PlotDirector chapter ID.");
setDiagnostics({ lastError: errorText(error) });
updateAnchorDisplay();
return false;
}
};
const attachPlotDirectorIds = async () => { const attachPlotDirectorIds = async () => {
await attachResolvedPlotDirectorIds(); await attachResolvedPlotDirectorIds();
}; };
const refreshAfterChapterLink = async (message) => {
hideChapterCreateLink();
await refreshPlotDirectorScene();
setDocumentMessage(message);
setChapterCreateLinkStatus(message);
};
const closeCreateChapterDialog = (confirmed) => {
if (pendingCreateChapterConfirmation) {
pendingCreateChapterConfirmation(confirmed);
pendingCreateChapterConfirmation = null;
}
if (createChapterDialog?.close) {
createChapterDialog.close();
} else if (createChapterDialog) {
createChapterDialog.hidden = true;
}
};
const confirmCreateChapter = (chapterTitle, bookTitle) => {
setText(createChapterDialogChapter, `"${chapterTitle}"`);
setText(createChapterDialogBook, `"${bookTitle}"`);
if (!createChapterDialog) {
return Promise.resolve(window.confirm(`Create PlotDirector chapter:\n\n"${chapterTitle}"\n\nin book:\n\n"${bookTitle}"\n\n?`));
}
return new Promise((resolve) => {
pendingCreateChapterConfirmation = resolve;
if (createChapterDialog.showModal) {
createChapterDialog.showModal();
} else {
createChapterDialog.hidden = false;
}
});
};
const createPlotDirectorChapter = async () => {
const book = selectedBook();
if (!book || !detectedChapterTitle || resolvedChapterId) {
showChapterCreateLink("Chapter not found in PlotDirector.");
return;
}
const chapterTitle = normalizeWhitespace(detectedChapterTitle);
const confirmed = await confirmCreateChapter(chapterTitle, book.title || "selected book");
if (!confirmed) {
return;
}
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = true;
createPlotDirectorChapterButton.textContent = "Creating...";
}
try {
const sortOrder = Number.isInteger(detectedChapterIndex) && detectedChapterIndex > 0
? detectedChapterIndex * 10
: null;
const response = await postJson(`/api/word-companion/books/${book.bookId}/chapters`, {
title: chapterTitle,
sortOrder
});
if (!response?.chapterId) {
throw new Error("Chapter creation did not return a chapter ID.");
}
setResolvedScene(null, response.chapterId, "Manual chapter link");
const anchored = await attachResolvedChapterId("Chapter created and linked successfully.");
if (!anchored) {
return;
}
await refreshAfterChapterLink("Chapter created and linked successfully.");
} catch (error) {
setChapterCreateLinkStatus("Unable to create chapter.");
setDiagnostics({ lastError: errorText(error) });
} finally {
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = !canUseChapterCreateLinkActions();
createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector";
}
}
};
const renderLinkChapterOptions = () => {
if (!linkChapterOptions) {
return;
}
const query = normalizeWhitespace(linkChapterSearch?.value || "").toLocaleLowerCase();
const rows = linkDialogChapters.filter((chapter) => !query || String(chapter.title || "").toLocaleLowerCase().includes(query));
linkChapterOptions.innerHTML = "";
selectedLinkChapterId = null;
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = true;
}
if (rows.length === 0) {
const empty = document.createElement("p");
empty.className = "word-companion-empty-list";
empty.textContent = "No chapters found.";
linkChapterOptions.append(empty);
return;
}
for (const chapter of rows) {
const chapterId = Number.parseInt(chapter.chapterId || "", 10);
if (!Number.isInteger(chapterId) || chapterId <= 0) {
continue;
}
const label = document.createElement("label");
label.className = "word-companion-scene-option";
const radio = document.createElement("input");
radio.type = "radio";
radio.name = "word-companion-link-chapter";
radio.value = String(chapterId);
radio.addEventListener("change", () => {
selectedLinkChapterId = chapterId;
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = false;
}
});
const text = document.createElement("span");
text.textContent = chapter.title || "Untitled chapter";
label.append(radio, text);
linkChapterOptions.append(label);
}
};
const openLinkExistingChapterDialog = async () => {
const book = selectedBook();
if (!book || !detectedChapterTitle || resolvedChapterId) {
showChapterCreateLink("Chapter not found in PlotDirector.");
return;
}
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = true;
linkExistingChapterButton.textContent = "Loading...";
}
try {
linkDialogChapters = await fetchBookChapters(book.bookId);
selectedLinkChapterId = null;
if (linkChapterSearch) {
linkChapterSearch.value = "";
}
setText(linkChapterDialogStatus, "");
renderLinkChapterOptions();
if (linkExistingChapterDialog?.showModal) {
linkExistingChapterDialog.showModal();
} else if (linkExistingChapterDialog) {
linkExistingChapterDialog.hidden = false;
}
} catch (error) {
setChapterCreateLinkStatus("Unable to load chapters.");
setDiagnostics({ lastError: errorText(error) });
} finally {
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = !canUseChapterCreateLinkActions();
linkExistingChapterButton.textContent = "Link to Existing Chapter";
}
}
};
const closeLinkExistingChapterDialog = () => {
if (linkExistingChapterDialog?.close) {
linkExistingChapterDialog.close();
} else if (linkExistingChapterDialog) {
linkExistingChapterDialog.hidden = true;
}
};
const linkSelectedExistingChapter = async () => {
const chapter = linkDialogChapters.find((item) => item.chapterId === selectedLinkChapterId);
if (!chapter) {
setText(linkChapterDialogStatus, "Select a chapter.");
return;
}
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = true;
linkSelectedChapterButton.textContent = "Linking...";
}
try {
setResolvedScene(null, chapter.chapterId, "Manual chapter link");
const anchored = await attachResolvedChapterId("Chapter linked successfully.");
if (!anchored) {
return;
}
closeLinkExistingChapterDialog();
await refreshAfterChapterLink("Chapter linked successfully.");
} catch (error) {
setText(linkChapterDialogStatus, "Unable to link chapter.");
setChapterCreateLinkStatus("Unable to link chapter.");
setDiagnostics({ lastError: errorText(error) });
} finally {
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = !selectedLinkChapterId;
linkSelectedChapterButton.textContent = "Link Chapter";
}
}
};
const resolveCreatedOrLinkedScene = async (sceneId, chapterId, successMessage) => { const resolveCreatedOrLinkedScene = async (sceneId, chapterId, successMessage) => {
const book = selectedBook(); const book = selectedBook();
if (!book) { if (!book) {
@ -1432,6 +1757,80 @@
} }
}; };
const refreshPlotDirectorChapter = async () => {
const project = selectedProject();
const book = selectedBook();
const requestChapterTitle = normalizeTitleForResolve(detectedChapterTitle, "chapter");
setResolveDiagnostics({
projectId: project?.projectId,
projectTitle: project?.title,
bookId: book?.bookId,
bookTitle: book?.title,
detectedChapter: detectedChapterTitle,
detectedScene: detectedSceneTitle,
requestChapter: requestChapterTitle,
requestScene: "",
result: "Not run",
chapterId: null,
sceneId: null
});
if (!book) {
resetPlotDirectorScene("Not detected");
setDocumentMessage("Select a PlotDirector book first.");
return false;
}
if (!detectedChapterTitle) {
resetPlotDirectorScene("Not detected");
setDocumentMessage("No chapter detected in Word. Use Heading 1 for chapters.");
return false;
}
resetPlotDirectorScene("Not detected");
try {
const chapters = await fetchBookChapters(book.bookId);
const anchoredChapter = detectedChapterAnchorId
? chapters.find((chapter) => chapter.chapterId === detectedChapterAnchorId)
: null;
const requestChapterKey = requestChapterTitle.toLocaleLowerCase();
const titleChapter = requestChapterKey
? chapters.find((chapter) => normalizeTitleForResolve(chapter.title, "chapter").toLocaleLowerCase() === requestChapterKey)
: null;
const chapter = anchoredChapter || titleChapter;
if (chapter) {
hideChapterCreateLink();
setResolvedScene(null, chapter.chapterId, anchoredChapter ? "Chapter anchor" : "Chapter title");
currentResolutionAnchorType = anchoredChapter ? "ChapterID Anchor" : "Title Match";
setPlotDirectorSceneStatus("Chapter linked to PlotDirector");
setDocumentMessage("No scene detected in Word. Use Heading 2 for scenes.");
setResolveDiagnostics({
result: "Chapter matched",
chapterId: chapter.chapterId,
sceneId: null
});
return true;
}
setResolvedScene(null);
setPlotDirectorSceneStatus("Chapter not found in PlotDirector.");
setDocumentMessage("Chapter not found in PlotDirector.");
setResolveDiagnostics({
result: "Chapter not matched",
chapterId: null,
sceneId: null
});
showChapterCreateLink("Chapter not found in PlotDirector.");
return false;
} catch (error) {
resetPlotDirectorScene("Not found in PlotDirector");
setDiagnostics({ lastError: errorText(error) });
setDocumentMessage("Unable to load PlotDirector chapter data.");
return false;
}
};
const refreshPlotDirectorScene = async () => { const refreshPlotDirectorScene = async () => {
const project = selectedProject(); const project = selectedProject();
const book = selectedBook(); const book = selectedBook();
@ -1546,12 +1945,9 @@
sceneId: null sceneId: null
}); });
} else if (!detectedSceneAnchorId) { } else if (!detectedSceneAnchorId) {
storedAnchorInvalid = true;
currentResolutionAnchorType = "ChapterID Anchor"; currentResolutionAnchorType = "ChapterID Anchor";
setPlotDirectorSceneStatus("Not found in PlotDirector");
setDocumentMessage("Stored PlotDirector ID is invalid.");
setResolveDiagnostics({ setResolveDiagnostics({
result: "Error", result: "Chapter anchor not found",
chapterId: detectedChapterAnchorId, chapterId: detectedChapterAnchorId,
sceneId: null sceneId: null
}); });
@ -1586,9 +1982,13 @@
sceneId: resolveResponse?.sceneId sceneId: resolveResponse?.sceneId
}); });
if (!hadInvalidAnchor) { if (!hadInvalidAnchor) {
showSceneCreateLink(chapterMatched if (chapterMatched) {
? "Scene not found in PlotDirector." hideChapterCreateLink();
: "This chapter does not exist in PlotDirector. Create or link the chapter first."); showSceneCreateLink("Scene not found in PlotDirector.");
} else {
hideSceneCreateLink();
showChapterCreateLink("Chapter not found in PlotDirector.");
}
} }
restorePlotDirectorSceneButton(); restorePlotDirectorSceneButton();
return false; return false;
@ -1636,8 +2036,7 @@
} }
if (!detectedSceneTitle) { if (!detectedSceneTitle) {
resetPlotDirectorScene("Not detected"); await refreshPlotDirectorChapter();
setDocumentMessage("No scene detected in Word. Use Heading 2 for scenes.");
return; return;
} }
@ -1759,6 +2158,17 @@
syncSceneProgressButton?.addEventListener("click", syncSceneProgress); syncSceneProgressButton?.addEventListener("click", syncSceneProgress);
saveSceneLinksButton?.addEventListener("click", saveSceneLinks); saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds); attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions);
linkSelectedChapterButton?.addEventListener("click", linkSelectedExistingChapter);
linkChapterDialogCancel?.addEventListener("click", closeLinkExistingChapterDialog);
createChapterDialogConfirm?.addEventListener("click", () => closeCreateChapterDialog(true));
createChapterDialogCancel?.addEventListener("click", () => closeCreateChapterDialog(false));
createChapterDialog?.addEventListener("cancel", (event) => {
event.preventDefault();
closeCreateChapterDialog(false);
});
createPlotDirectorSceneButton?.addEventListener("click", createPlotDirectorScene); createPlotDirectorSceneButton?.addEventListener("click", createPlotDirectorScene);
linkExistingSceneButton?.addEventListener("click", openLinkExistingSceneDialog); linkExistingSceneButton?.addEventListener("click", openLinkExistingSceneDialog);
linkSceneSearch?.addEventListener("input", renderLinkSceneOptions); linkSceneSearch?.addEventListener("input", renderLinkSceneOptions);