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);
}
[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}")]
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<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
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<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request);
Task<bool> HasProjectAccessAsync(int projectId, int userId);
@ -235,6 +236,23 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
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)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -219,6 +219,15 @@ public sealed class WordCompanionUpdateWordCountResponse
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 int ManuscriptDocumentId { get; init; }

View File

@ -19,6 +19,7 @@ public interface IWordCompanionService
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
@ -222,6 +223,17 @@ public sealed class WordCompanionService(
: 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)
{
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">
<div class="word-companion-actions">
<button type="button" class="word-companion-primary-action" data-refresh-current-scene>Refresh Current Scene</button>
<button type="button" data-sync-scene-progress disabled>Sync Progress</button>
<button type="button" class="word-companion-primary-action" data-sync-scene-progress disabled>Sync Current Scene</button>
<button type="button" data-refresh-current-scene>Refresh Current Scene</button>
</div>
<p class="word-companion-message" data-document-message></p>
<p class="word-companion-message" data-sync-status>No resolved PlotDirector scene.</p>

View File

@ -198,6 +198,10 @@
let selectionRefreshTimer = null;
let selectionHandlerRegistered = false;
let isAutoRefreshingScene = false;
let sceneWordCountSyncTimer = null;
let sceneWordCountSyncInFlight = false;
let lastSyncedSceneId = null;
let lastSyncedWordCount = null;
let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = "";
let currentSceneLinks = {
@ -353,6 +357,13 @@
updateAnchorDisplay();
};
const clearPendingSceneWordCountSync = () => {
if (sceneWordCountSyncTimer) {
window.clearTimeout(sceneWordCountSyncTimer);
sceneWordCountSyncTimer = null;
}
};
const setHidden = (element, hidden) => {
if (element) {
element.hidden = hidden;
@ -410,8 +421,9 @@
const updateSceneActionButtons = () => {
const blocked = sceneContextStale || !resolvedSceneId;
const syncBlocked = blocked || !resolvedChapterId || sceneWordCountSyncInFlight;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = blocked;
syncSceneProgressButton.disabled = syncBlocked;
}
if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = blocked;
@ -435,6 +447,9 @@
const setSceneContextStale = (isStale, message = "") => {
sceneContextStale = !!isStale;
if (sceneContextStale) {
clearPendingSceneWordCountSync();
}
setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode);
updateSceneActionButtons();
if (message) {
@ -447,6 +462,12 @@
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null;
currentResolutionMethod = resolutionMethod || "";
if (resolvedSceneId !== lastSyncedSceneId) {
lastSyncedWordCount = null;
}
if (!resolvedSceneId) {
clearPendingSceneWordCountSync();
}
updateSceneActionButtons();
setSyncStatus(sceneContextStale
? "Refresh Current Scene before syncing."
@ -484,10 +505,28 @@
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) => {
runtimeBookContext = context || null;
setText(runtimeLastSync, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc));
setText(syncLastSynced, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc));
const lastSyncUtc = runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc;
setText(runtimeLastSync, formatLastSynced(lastSyncUtc));
setText(syncLastSynced, formatLastSynced(lastSyncUtc));
};
const clearRuntimeCaches = () => {
@ -847,6 +886,7 @@
hideSceneCreateLink();
setResolvedScene(sceneId, chapterId, resolutionMethod);
renderCompanionData(companionData);
scheduleSceneWordCountSync();
};
const setText = (element, value) => {
@ -1260,6 +1300,8 @@
(Number(char) ^ Math.random() * 16 >> Number(char) / 4).toString(16));
};
const currentDocumentGuid = () => firstRunDocumentBinding?.documentGuid || readDocumentBinding()?.documentGuid || "";
const updateSummary = () => {
const project = selectedProject();
const book = selectedBook();
@ -2097,6 +2139,45 @@
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 () => {
setDocumentMessage("");
@ -3568,13 +3649,52 @@
return;
}
if (!resolvedChapterId) {
setSyncStatus("No resolved PlotDirector chapter.");
return;
}
if (sceneContextStale) {
setSyncStatus("Refresh Current Scene before syncing.");
return;
}
if (!Number.isInteger(detectedSceneWordCount)) {
setSyncStatus("Unable to sync progress.");
const book = selectedBook();
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;
}
@ -3583,26 +3703,48 @@
syncSceneProgressButton.textContent = "Syncing...";
}
const lastWorkedOn = new Date().toISOString();
setSyncStatus("Syncing...");
try {
const response = await patchJson(`/api/word-companion/scenes/${resolvedSceneId}/word-count`, {
actualWordCount: detectedSceneWordCount,
lastWorkedOn
const response = await postJson("/api/word-companion/runtime/scene-word-count", {
documentGuid,
bookId: book.bookId,
chapterId: resolvedChapterId,
sceneId: resolvedSceneId,
wordCount
});
setText(syncPlotDirectorCount, displayValue(response?.actualWords ?? detectedSceneWordCount));
setText(plotDirectorActualWords, displayValue(response?.actualWords ?? detectedSceneWordCount));
setText(syncLastSynced, new Date().toLocaleString());
setSyncStatus("Progress synced successfully.");
} catch {
setSyncStatus("Unable to sync progress.");
const actualWords = response?.actualWords ?? wordCount;
const syncedAt = new Date();
lastSyncedSceneId = resolvedSceneId;
lastSyncedWordCount = actualWords;
setText(syncPlotDirectorCount, displayValue(actualWords));
setText(plotDirectorActualWords, displayValue(actualWords));
setText(syncLastSynced, formatLastSynced(syncedAt));
setText(runtimeLastSync, formatLastSynced(syncedAt));
setSyncStatus("Synced");
} catch (error) {
setSyncStatus("Sync failed");
setDiagnostics({ lastError: errorText(error) });
} finally {
sceneWordCountSyncInFlight = false;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId || sceneContextStale;
syncSceneProgressButton.textContent = "Sync Progress";
syncSceneProgressButton.textContent = "Sync Current Scene";
}
updateSceneActionButtons();
}
};
const scheduleSceneWordCountSync = (delayMs = 4000) => {
clearPendingSceneWordCountSync();
if (!resolvedSceneId || !resolvedChapterId || sceneContextStale || sceneWordCountSyncInFlight) {
return;
}
sceneWordCountSyncTimer = window.setTimeout(() => {
sceneWordCountSyncTimer = null;
void syncSceneProgress();
}, delayMs);
};
const refreshPlotDirectorChapter = async () => {
const project = selectedProject();
const book = selectedBook();
@ -3907,6 +4049,7 @@
if (!changed) {
setSceneContextStale(false);
setSceneTrackingState("Live");
scheduleSceneWordCountSync();
return;
}
@ -3918,6 +4061,7 @@
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("");
scheduleSceneWordCountSync();
} catch (error) {
console.error("Unable to refresh current scene from Word selection.", error);
setDiagnostics({ lastError: errorText(error) });

File diff suppressed because one or more lines are too long