Phase 16F - Scene Word Count Sync and Writing Progress Update

This commit is contained in:
Nick Beckley 2026-06-27 22:07:17 +01:00
parent 85404b9c42
commit d442de5837
8 changed files with 288 additions and 20 deletions

View File

@ -109,6 +109,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
return response is null ? NotFound() : Ok(response); return response is null ? NotFound() : Ok(response);
} }
[HttpPost("runtime/scene-word-count")]
public async Task<IActionResult> UpdateRuntimeSceneWordCount(WordCompanionRuntimeSceneWordCountRequest request)
{
var response = await wordCompanion.UpdateRuntimeSceneWordCountAsync(request);
return response is null ? BadRequest() : Ok(response);
}
[HttpGet("manuscript/book/{bookId:int}")] [HttpGet("manuscript/book/{bookId:int}")]
public async Task<IActionResult> GetManuscriptForBook(int bookId) public async Task<IActionResult> GetManuscriptForBook(int bookId)
{ {

View File

@ -19,6 +19,7 @@ public interface IWordCompanionRepository
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request); Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request); Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn); Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request); Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request); Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request);
Task<bool> HasProjectAccessAsync(int projectId, int userId); Task<bool> HasProjectAccessAsync(int projectId, int userId);
@ -235,6 +236,23 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WordCompanionUpdateWordCountResponse>(
"dbo.WordCompanion_Runtime_SceneWordCount_Update",
new
{
UserID = userId,
request.DocumentGuid,
request.BookId,
request.ChapterId,
request.SceneId,
request.WordCount
},
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request) public async Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -219,6 +219,15 @@ public sealed class WordCompanionUpdateWordCountResponse
public string Message { get; init; } = "Word count updated."; public string Message { get; init; } = "Word count updated.";
} }
public sealed class WordCompanionRuntimeSceneWordCountRequest
{
public Guid DocumentGuid { get; set; }
public int BookId { get; set; }
public int ChapterId { get; set; }
public int SceneId { get; set; }
public int WordCount { get; set; }
}
public sealed class WordCompanionManuscriptDocumentDto public sealed class WordCompanionManuscriptDocumentDto
{ {
public int ManuscriptDocumentId { get; init; } public int ManuscriptDocumentId { get; init; }

View File

@ -19,6 +19,7 @@ public interface IWordCompanionService
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request); Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request); Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request); Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid); Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId); Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request); Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
@ -222,6 +223,17 @@ public sealed class WordCompanionService(
: repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn); : repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn);
} }
public Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request)
{
return request.DocumentGuid == Guid.Empty
|| request.BookId <= 0
|| request.ChapterId <= 0
|| request.SceneId <= 0
|| request.WordCount < 0
? Task.FromResult<WordCompanionUpdateWordCountResponse?>(null)
: repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid) public async Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid)
{ {
if (bookId <= 0 || documentGuid == Guid.Empty) if (bookId <= 0 || documentGuid == Guid.Empty)

View File

@ -0,0 +1,78 @@
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_SceneWordCount_Update
@UserID int,
@DocumentGuid uniqueidentifier,
@BookID int,
@ChapterID int,
@SceneID int,
@WordCount int
AS
BEGIN
SET NOCOUNT ON;
IF @DocumentGuid IS NULL
OR @DocumentGuid = '00000000-0000-0000-0000-000000000000'
OR @BookID <= 0
OR @ChapterID <= 0
OR @SceneID <= 0
OR @WordCount < 0
RETURN;
DECLARE @ManuscriptDocumentID int;
SELECT @ManuscriptDocumentID = md.ManuscriptDocumentID
FROM dbo.ManuscriptDocuments md
INNER JOIN dbo.Books b ON b.BookID = md.BookID
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE md.DocumentGuid = @DocumentGuid
AND md.BookID = @BookID
AND md.IsActive = 1
AND b.IsArchived = 0
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1;
IF @ManuscriptDocumentID IS NULL
RETURN;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
WHERE s.SceneID = @SceneID
AND s.ChapterID = @ChapterID
AND c.BookID = @BookID
AND s.IsArchived = 0
AND c.IsArchived = 0
AND s.IsLinkedToManuscript = 1
AND s.ManuscriptDocumentID = @ManuscriptDocumentID
)
RETURN;
DECLARE @LastWorkedOnDate date = CONVERT(date, SYSUTCDATETIME());
IF EXISTS (SELECT 1 FROM dbo.SceneWorkflow WHERE SceneID = @SceneID)
BEGIN
UPDATE dbo.SceneWorkflow
SET ActualWordCount = @WordCount,
LastWorkedOn = @LastWorkedOnDate,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END
ELSE
BEGIN
INSERT dbo.SceneWorkflow (SceneID, ActualWordCount, LastWorkedOn)
VALUES (@SceneID, @WordCount, @LastWorkedOnDate);
END;
UPDATE dbo.ManuscriptDocuments
SET LastSyncUtc = SYSUTCDATETIME()
WHERE ManuscriptDocumentID = @ManuscriptDocumentID;
SELECT @SceneID AS SceneId,
@WordCount AS ActualWords,
@LastWorkedOnDate AS LastWorkedOn,
N'Word count synced.' AS Message;
END;
GO

View File

@ -116,8 +116,8 @@
<section class="word-companion-section word-companion-current-scene" aria-label="Current Scene"> <section class="word-companion-section word-companion-current-scene" aria-label="Current Scene">
<div class="word-companion-actions"> <div class="word-companion-actions">
<button type="button" class="word-companion-primary-action" data-refresh-current-scene>Refresh Current Scene</button> <button type="button" class="word-companion-primary-action" data-sync-scene-progress disabled>Sync Current Scene</button>
<button type="button" data-sync-scene-progress disabled>Sync Progress</button> <button type="button" data-refresh-current-scene>Refresh Current Scene</button>
</div> </div>
<p class="word-companion-message" data-document-message></p> <p class="word-companion-message" data-document-message></p>
<p class="word-companion-message" data-sync-status>No resolved PlotDirector scene.</p> <p class="word-companion-message" data-sync-status>No resolved PlotDirector scene.</p>

View File

@ -198,6 +198,10 @@
let selectionRefreshTimer = null; let selectionRefreshTimer = null;
let selectionHandlerRegistered = false; let selectionHandlerRegistered = false;
let isAutoRefreshingScene = false; let isAutoRefreshingScene = false;
let sceneWordCountSyncTimer = null;
let sceneWordCountSyncInFlight = false;
let lastSyncedSceneId = null;
let lastSyncedWordCount = null;
let lastLinkedSceneTitle = ""; let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = ""; let lastLinkedChapterTitle = "";
let currentSceneLinks = { let currentSceneLinks = {
@ -353,6 +357,13 @@
updateAnchorDisplay(); updateAnchorDisplay();
}; };
const clearPendingSceneWordCountSync = () => {
if (sceneWordCountSyncTimer) {
window.clearTimeout(sceneWordCountSyncTimer);
sceneWordCountSyncTimer = null;
}
};
const setHidden = (element, hidden) => { const setHidden = (element, hidden) => {
if (element) { if (element) {
element.hidden = hidden; element.hidden = hidden;
@ -410,8 +421,9 @@
const updateSceneActionButtons = () => { const updateSceneActionButtons = () => {
const blocked = sceneContextStale || !resolvedSceneId; const blocked = sceneContextStale || !resolvedSceneId;
const syncBlocked = blocked || !resolvedChapterId || sceneWordCountSyncInFlight;
if (syncSceneProgressButton) { if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = blocked; syncSceneProgressButton.disabled = syncBlocked;
} }
if (saveSceneLinksButton) { if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = blocked; saveSceneLinksButton.disabled = blocked;
@ -435,6 +447,9 @@
const setSceneContextStale = (isStale, message = "") => { const setSceneContextStale = (isStale, message = "") => {
sceneContextStale = !!isStale; sceneContextStale = !!isStale;
if (sceneContextStale) {
clearPendingSceneWordCountSync();
}
setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode); setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode);
updateSceneActionButtons(); updateSceneActionButtons();
if (message) { if (message) {
@ -447,6 +462,12 @@
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null; resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null; resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null;
currentResolutionMethod = resolutionMethod || ""; currentResolutionMethod = resolutionMethod || "";
if (resolvedSceneId !== lastSyncedSceneId) {
lastSyncedWordCount = null;
}
if (!resolvedSceneId) {
clearPendingSceneWordCountSync();
}
updateSceneActionButtons(); updateSceneActionButtons();
setSyncStatus(sceneContextStale setSyncStatus(sceneContextStale
? "Refresh Current Scene before syncing." ? "Refresh Current Scene before syncing."
@ -484,10 +505,28 @@
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
}; };
const formatLastSynced = (value) => {
if (!value) {
return "-";
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
const today = new Date();
const dayLabel = date.toDateString() === today.toDateString()
? "Today"
: date.toLocaleDateString();
return `Last synced: ${dayLabel} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
};
const setRuntimeBookContext = (context) => { const setRuntimeBookContext = (context) => {
runtimeBookContext = context || null; runtimeBookContext = context || null;
setText(runtimeLastSync, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc)); const lastSyncUtc = runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc;
setText(syncLastSynced, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc)); setText(runtimeLastSync, formatLastSynced(lastSyncUtc));
setText(syncLastSynced, formatLastSynced(lastSyncUtc));
}; };
const clearRuntimeCaches = () => { const clearRuntimeCaches = () => {
@ -847,6 +886,7 @@
hideSceneCreateLink(); hideSceneCreateLink();
setResolvedScene(sceneId, chapterId, resolutionMethod); setResolvedScene(sceneId, chapterId, resolutionMethod);
renderCompanionData(companionData); renderCompanionData(companionData);
scheduleSceneWordCountSync();
}; };
const setText = (element, value) => { const setText = (element, value) => {
@ -1260,6 +1300,8 @@
(Number(char) ^ Math.random() * 16 >> Number(char) / 4).toString(16)); (Number(char) ^ Math.random() * 16 >> Number(char) / 4).toString(16));
}; };
const currentDocumentGuid = () => firstRunDocumentBinding?.documentGuid || readDocumentBinding()?.documentGuid || "";
const updateSummary = () => { const updateSummary = () => {
const project = selectedProject(); const project = selectedProject();
const book = selectedBook(); const book = selectedBook();
@ -2097,6 +2139,45 @@
return selectionParagraphs.items; return selectionParagraphs.items;
}; };
const recountCachedCurrentScene = async () => {
const scene = documentStructureCache?.currentScene;
if (!scene || !Number.isInteger(scene.startParagraphIndex) || !Number.isInteger(scene.endParagraphIndex)) {
return detectedSceneWordCount;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
return detectedSceneWordCount;
}
return await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("text");
await context.sync();
let wordCount = 0;
const endIndex = Math.min(scene.endParagraphIndex, bodyParagraphs.items.length - 1);
const chapterHeadingIndex = documentStructureCache.currentChapter?.startParagraphIndex;
for (let index = scene.startParagraphIndex; index <= endIndex; index += 1) {
const text = String(bodyParagraphs.items[index]?.text || "").trim();
if (!text || sceneSeparatorTexts.has(text) || index === chapterHeadingIndex) {
continue;
}
wordCount += countWords(text);
}
scene.wordCount = wordCount;
setCurrentStructure(
documentStructureCache.currentChapter?.title,
scene.title,
wordCount,
documentStructureCache.currentChapter?.anchorId,
scene.anchorId,
documentStructureCache.currentChapter?.index
);
return wordCount;
});
};
const refreshDocumentStructure = async () => { const refreshDocumentStructure = async () => {
setDocumentMessage(""); setDocumentMessage("");
@ -3568,13 +3649,52 @@
return; return;
} }
if (!resolvedChapterId) {
setSyncStatus("No resolved PlotDirector chapter.");
return;
}
if (sceneContextStale) { if (sceneContextStale) {
setSyncStatus("Refresh Current Scene before syncing."); setSyncStatus("Refresh Current Scene before syncing.");
return; return;
} }
if (!Number.isInteger(detectedSceneWordCount)) { const book = selectedBook();
setSyncStatus("Unable to sync progress."); const documentGuid = currentDocumentGuid();
if (!book || !documentGuid) {
setSyncStatus("Bound manuscript metadata is unavailable.");
return;
}
if (sceneWordCountSyncInFlight) {
return;
}
clearPendingSceneWordCountSync();
sceneWordCountSyncInFlight = true;
let wordCount = detectedSceneWordCount;
try {
wordCount = await recountCachedCurrentScene();
} catch (error) {
setSyncStatus("Sync failed");
setDiagnostics({ lastError: errorText(error) });
sceneWordCountSyncInFlight = false;
updateSceneActionButtons();
return;
}
if (!Number.isInteger(wordCount) || wordCount < 0) {
setSyncStatus("Sync failed");
sceneWordCountSyncInFlight = false;
updateSceneActionButtons();
return;
}
if (lastSyncedSceneId === resolvedSceneId && lastSyncedWordCount === wordCount) {
setSyncStatus("Synced");
sceneWordCountSyncInFlight = false;
updateSceneActionButtons();
return; return;
} }
@ -3583,26 +3703,48 @@
syncSceneProgressButton.textContent = "Syncing..."; syncSceneProgressButton.textContent = "Syncing...";
} }
const lastWorkedOn = new Date().toISOString(); setSyncStatus("Syncing...");
try { try {
const response = await patchJson(`/api/word-companion/scenes/${resolvedSceneId}/word-count`, { const response = await postJson("/api/word-companion/runtime/scene-word-count", {
actualWordCount: detectedSceneWordCount, documentGuid,
lastWorkedOn bookId: book.bookId,
chapterId: resolvedChapterId,
sceneId: resolvedSceneId,
wordCount
}); });
setText(syncPlotDirectorCount, displayValue(response?.actualWords ?? detectedSceneWordCount)); const actualWords = response?.actualWords ?? wordCount;
setText(plotDirectorActualWords, displayValue(response?.actualWords ?? detectedSceneWordCount)); const syncedAt = new Date();
setText(syncLastSynced, new Date().toLocaleString()); lastSyncedSceneId = resolvedSceneId;
setSyncStatus("Progress synced successfully."); lastSyncedWordCount = actualWords;
} catch { setText(syncPlotDirectorCount, displayValue(actualWords));
setSyncStatus("Unable to sync progress."); setText(plotDirectorActualWords, displayValue(actualWords));
setText(syncLastSynced, formatLastSynced(syncedAt));
setText(runtimeLastSync, formatLastSynced(syncedAt));
setSyncStatus("Synced");
} catch (error) {
setSyncStatus("Sync failed");
setDiagnostics({ lastError: errorText(error) });
} finally { } finally {
sceneWordCountSyncInFlight = false;
if (syncSceneProgressButton) { if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId || sceneContextStale; syncSceneProgressButton.textContent = "Sync Current Scene";
syncSceneProgressButton.textContent = "Sync Progress";
} }
updateSceneActionButtons();
} }
}; };
const scheduleSceneWordCountSync = (delayMs = 4000) => {
clearPendingSceneWordCountSync();
if (!resolvedSceneId || !resolvedChapterId || sceneContextStale || sceneWordCountSyncInFlight) {
return;
}
sceneWordCountSyncTimer = window.setTimeout(() => {
sceneWordCountSyncTimer = null;
void syncSceneProgress();
}, delayMs);
};
const refreshPlotDirectorChapter = async () => { const refreshPlotDirectorChapter = async () => {
const project = selectedProject(); const project = selectedProject();
const book = selectedBook(); const book = selectedBook();
@ -3907,6 +4049,7 @@
if (!changed) { if (!changed) {
setSceneContextStale(false); setSceneContextStale(false);
setSceneTrackingState("Live"); setSceneTrackingState("Live");
scheduleSceneWordCountSync();
return; return;
} }
@ -3918,6 +4061,7 @@
setSceneContextStale(false); setSceneContextStale(false);
setSceneTrackingState("Live"); setSceneTrackingState("Live");
setDocumentMessage(""); setDocumentMessage("");
scheduleSceneWordCountSync();
} catch (error) { } catch (error) {
console.error("Unable to refresh current scene from Word selection.", error); console.error("Unable to refresh current scene from Word selection.", error);
setDiagnostics({ lastError: errorText(error) }); setDiagnostics({ lastError: errorText(error) });

File diff suppressed because one or more lines are too long