Phase 16F - Scene Word Count Sync and Writing Progress Update
This commit is contained in:
parent
85404b9c42
commit
d442de5837
@ -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)
|
||||
{
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user