Phase 10K chapter create/link foundation.
This commit is contained in:
parent
867d536f82
commit
ffcbe99028
@ -27,6 +27,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
|
||||
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")]
|
||||
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);
|
||||
}
|
||||
|
||||
[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")]
|
||||
public async Task<IActionResult> CreateScene(int bookId, int chapterId, WordCompanionCreateSceneRequest request)
|
||||
{
|
||||
|
||||
@ -9,8 +9,10 @@ public interface IWordCompanionRepository
|
||||
Task<IReadOnlyList<WordCompanionProjectDto>> ListProjectsAsync(int userId);
|
||||
Task<IReadOnlyList<WordCompanionBookDto>> ListBooksAsync(int projectId, 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<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<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, int userId, WordCompanionSyncSceneRequest 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 };
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -47,6 +47,18 @@ public sealed class WordCompanionBookStructureResponse
|
||||
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 string? ChapterTitle { get; set; }
|
||||
@ -127,6 +139,20 @@ public sealed class WordCompanionCreateSceneResponse
|
||||
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 string? DocumentId { get; set; }
|
||||
|
||||
@ -8,8 +8,10 @@ public interface IWordCompanionService
|
||||
Task<WordCompanionProjectsResponse> ListProjectsAsync();
|
||||
Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId);
|
||||
Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId);
|
||||
Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId);
|
||||
Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request);
|
||||
Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId);
|
||||
Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request);
|
||||
Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request);
|
||||
Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest 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());
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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());
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle))
|
||||
|
||||
82
PlotLine/Sql/064_Phase10K_WordCompanionChapterCreateLink.sql
Normal file
82
PlotLine/Sql/064_Phase10K_WordCompanionChapterCreateLink.sql
Normal 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
|
||||
@ -121,6 +121,23 @@
|
||||
<p class="word-companion-message" data-sync-note></p>
|
||||
</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">
|
||||
<div class="word-companion-section-header">
|
||||
<span>Scene Create / Link</span>
|
||||
@ -300,6 +317,20 @@
|
||||
</form>
|
||||
</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>
|
||||
<form method="dialog">
|
||||
<div class="word-companion-section-header">
|
||||
@ -315,6 +346,22 @@
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
<script src="~/js/word-companion-host.js" asp-append-version="true"></script>
|
||||
</body>
|
||||
|
||||
@ -51,6 +51,11 @@
|
||||
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 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 createLinkChapter = document.querySelector("[data-create-link-chapter]");
|
||||
const createLinkScene = document.querySelector("[data-create-link-scene]");
|
||||
@ -68,6 +73,17 @@
|
||||
const createDialogChapter = document.querySelector("[data-create-dialog-chapter]");
|
||||
const createDialogConfirm = document.querySelector("[data-create-dialog-confirm]");
|
||||
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 resolveProjectTitle = document.querySelector("[data-resolve-project-title]");
|
||||
const resolveBookId = document.querySelector("[data-resolve-book-id]");
|
||||
@ -103,6 +119,7 @@
|
||||
let detectedChapterTitle = "";
|
||||
let detectedSceneTitle = "";
|
||||
let detectedSceneWordCount = null;
|
||||
let detectedChapterIndex = null;
|
||||
let detectedChapterAnchorId = null;
|
||||
let detectedSceneAnchorId = null;
|
||||
let resolvedSceneId = null;
|
||||
@ -113,6 +130,9 @@
|
||||
let linkDialogScenes = [];
|
||||
let selectedLinkSceneId = null;
|
||||
let pendingCreateSceneConfirmation = null;
|
||||
let linkDialogChapters = [];
|
||||
let selectedLinkChapterId = null;
|
||||
let pendingCreateChapterConfirmation = null;
|
||||
let currentSceneLinks = {
|
||||
characters: [],
|
||||
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 || "";
|
||||
detectedSceneTitle = sceneTitle || "";
|
||||
detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : null;
|
||||
detectedChapterIndex = Number.isInteger(chapterIndex) && chapterIndex > 0 ? chapterIndex : null;
|
||||
detectedChapterAnchorId = Number.isInteger(chapterAnchorId) && chapterAnchorId > 0 ? chapterAnchorId : null;
|
||||
detectedSceneAnchorId = Number.isInteger(sceneAnchorId) && sceneAnchorId > 0 ? sceneAnchorId : null;
|
||||
|
||||
@ -268,11 +289,48 @@
|
||||
setText(createLinkStatus, message);
|
||||
};
|
||||
|
||||
const setChapterCreateLinkStatus = (message) => {
|
||||
setText(chapterCreateLinkStatus, message);
|
||||
};
|
||||
|
||||
const canUseChapterCreateLinkActions = () => !!selectedBook()
|
||||
&& !!detectedChapterTitle
|
||||
&& !resolvedChapterId;
|
||||
|
||||
const canUseCreateLinkActions = () => !!selectedBook()
|
||||
&& !!detectedSceneTitle
|
||||
&& Number.isInteger(resolvedChapterId)
|
||||
&& 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 = () => {
|
||||
setHidden(sceneCreateLink, true);
|
||||
setCreateLinkStatus("");
|
||||
@ -308,6 +366,7 @@
|
||||
setHidden(plotDirectorSceneDetails, true);
|
||||
setHidden(writingBriefBlock, true);
|
||||
setHidden(plotDirectorLinkGroups, true);
|
||||
hideChapterCreateLink();
|
||||
hideSceneCreateLink();
|
||||
currentResolutionMethod = "";
|
||||
currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match";
|
||||
@ -506,6 +565,11 @@
|
||||
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) => {
|
||||
if (Number.isInteger(resolvedChapterId) && resolvedChapterId > 0) {
|
||||
const anchoredChapter = chapters.find((chapter) => chapter.chapterId === resolvedChapterId);
|
||||
@ -543,6 +607,7 @@
|
||||
storedAnchorInvalid = false;
|
||||
setPlotDirectorSceneStatus("Linked to PlotDirector");
|
||||
setDocumentMessage("");
|
||||
hideChapterCreateLink();
|
||||
hideSceneCreateLink();
|
||||
setResolvedScene(sceneId, chapterId, resolutionMethod);
|
||||
renderCompanionData(companionData);
|
||||
@ -918,7 +983,8 @@
|
||||
structure.currentScene?.title,
|
||||
structure.currentScene?.wordCount,
|
||||
structure.currentChapter?.anchorId,
|
||||
structure.currentScene?.anchorId
|
||||
structure.currentScene?.anchorId,
|
||||
structure.currentChapter?.index
|
||||
);
|
||||
resetPlotDirectorScene("Not detected");
|
||||
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 () => {
|
||||
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 book = selectedBook();
|
||||
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 project = selectedProject();
|
||||
const book = selectedBook();
|
||||
@ -1546,12 +1945,9 @@
|
||||
sceneId: null
|
||||
});
|
||||
} else if (!detectedSceneAnchorId) {
|
||||
storedAnchorInvalid = true;
|
||||
currentResolutionAnchorType = "ChapterID Anchor";
|
||||
setPlotDirectorSceneStatus("Not found in PlotDirector");
|
||||
setDocumentMessage("Stored PlotDirector ID is invalid.");
|
||||
setResolveDiagnostics({
|
||||
result: "Error",
|
||||
result: "Chapter anchor not found",
|
||||
chapterId: detectedChapterAnchorId,
|
||||
sceneId: null
|
||||
});
|
||||
@ -1586,9 +1982,13 @@
|
||||
sceneId: resolveResponse?.sceneId
|
||||
});
|
||||
if (!hadInvalidAnchor) {
|
||||
showSceneCreateLink(chapterMatched
|
||||
? "Scene not found in PlotDirector."
|
||||
: "This chapter does not exist in PlotDirector. Create or link the chapter first.");
|
||||
if (chapterMatched) {
|
||||
hideChapterCreateLink();
|
||||
showSceneCreateLink("Scene not found in PlotDirector.");
|
||||
} else {
|
||||
hideSceneCreateLink();
|
||||
showChapterCreateLink("Chapter not found in PlotDirector.");
|
||||
}
|
||||
}
|
||||
restorePlotDirectorSceneButton();
|
||||
return false;
|
||||
@ -1636,8 +2036,7 @@
|
||||
}
|
||||
|
||||
if (!detectedSceneTitle) {
|
||||
resetPlotDirectorScene("Not detected");
|
||||
setDocumentMessage("No scene detected in Word. Use Heading 2 for scenes.");
|
||||
await refreshPlotDirectorChapter();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1759,6 +2158,17 @@
|
||||
syncSceneProgressButton?.addEventListener("click", syncSceneProgress);
|
||||
saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
|
||||
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);
|
||||
linkExistingSceneButton?.addEventListener("click", openLinkExistingSceneDialog);
|
||||
linkSceneSearch?.addEventListener("input", renderLinkSceneOptions);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user