Fix onboarding Word marker save acknowledgement

This commit is contained in:
Nick Beckley 2026-08-26 08:54:23 +00:00
parent a6c870ab6d
commit 5a38560f47
6 changed files with 498 additions and 33 deletions

View File

@ -82,6 +82,7 @@ var tests = new (string Name, Action Test)[]
("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames),
("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
@ -1921,14 +1922,39 @@ static void WordSyncRequiresSaveAcknowledgement()
var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs"));
var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql"));
Assert(companion.Contains("saveWordDocumentIfAvailable", StringComparison.Ordinal), "Companion should save Word before acknowledging marker sync.");
Assert(companion.Contains("markersChanged", StringComparison.Ordinal), "Companion should distinguish marker insertion from an idempotent no-change retry.");
Assert(companion.Contains("SaveNotRequired", StringComparison.Ordinal), "Companion should acknowledge marker sync when no Word save is required.");
Assert(companion.Contains("UserConfirmedSave", StringComparison.Ordinal), "Companion should support an explicit manual save acknowledgement.");
Assert(companion.Contains("RequireOnboardingBuildManualSave", StringComparison.Ordinal), "Companion should report manual-save-required state without throwing a generic SignalR error.");
Assert(companion.Contains("CompleteOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should acknowledge successful marker sync.");
Assert(companion.Contains("FailOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should report failed marker sync.");
Assert(companion.Contains("officeRuntimeDiagnostics", StringComparison.Ordinal), "Companion should capture Word host diagnostics for save failures.");
Assert(hub.Contains("MarkerSyncClientResult", StringComparison.Ordinal), "Server should accept marker sync diagnostics.");
Assert(hub.Contains("MarkMarkerSyncCompletedAsync", StringComparison.Ordinal), "Server should persist completed marker sync.");
Assert(hub.Contains("MarkMarkerSyncUserSaveRequiredAsync", StringComparison.Ordinal), "Server should persist manual-save-required marker sync.");
Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting.");
Assert(migration.Contains("MarkerSyncCompletedUtc", StringComparison.Ordinal), "Database should persist marker sync completion time.");
Assert(migration.Contains("UserSaveRequired", StringComparison.Ordinal), "Database should persist manual-save-required marker sync state.");
Assert(migration.Contains("SaveNotRequired", StringComparison.Ordinal), "Database should persist no-change marker sync state.");
}
static void WordSyncRetryResumesExistingStructure()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql"));
var resumeCheck = onboarding.IndexOf("await builds.GetByPreviewAsync(userId, preview.PreviewID)", StringComparison.Ordinal);
var sceneDetection = onboarding.IndexOf("\"Detecting scene boundaries...\"", StringComparison.Ordinal);
Assert(resumeCheck >= 0 && sceneDetection > resumeCheck, "Existing structural builds should be checked before rerunning scene boundary AI.");
Assert(onboarding.Contains("BuildExistingStructureResultAsync", StringComparison.Ordinal), "Retry should rebuild marker instructions from persisted structure.");
Assert(onboarding.Contains("BuildSceneMarkerInstructionsFromSources", StringComparison.Ordinal), "Retry should use stored SceneID paragraph positions for marker sync.");
Assert(repository.Contains("Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Existing build lookup should use a stored procedure.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Forward migration should provide the existing build lookup procedure.");
}
static void StoryIntelligenceEnrichesExistingStructuralScenes()

View File

@ -8,9 +8,11 @@ namespace PlotLine.Data;
public interface IOnboardingBuildRepository
{
Task<OnboardingManuscriptBuildResult?> BuildAsync(int userId, OnboardingManuscriptBuildRequest request);
Task<OnboardingManuscriptBuildResult?> GetByPreviewAsync(int userId, Guid previewId);
Task MarkMarkerSyncSentAsync(int buildId);
Task MarkMarkerSyncCompletedAsync(int buildId);
Task MarkMarkerSyncFailedAsync(int buildId, string message);
Task MarkMarkerSyncCompletedAsync(int buildId, string markerStatus, string message);
Task MarkMarkerSyncUserSaveRequiredAsync(int buildId, string message);
Task MarkMarkerSyncFailedAsync(int buildId, string message, string? diagnostics = null);
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request);
Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary);
@ -38,6 +40,22 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
},
commandType: CommandType.StoredProcedure);
return await ReadBuildResultAsync(result);
}
public async Task<OnboardingManuscriptBuildResult?> GetByPreviewAsync(int userId, Guid previewId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.Onboarding_ManuscriptBuild_GetByPreview",
new { UserID = userId, PreviewID = previewId },
commandType: CommandType.StoredProcedure);
return await ReadBuildResultAsync(result);
}
private static async Task<OnboardingManuscriptBuildResult?> ReadBuildResultAsync(SqlMapper.GridReader result)
{
var header = await result.ReadSingleOrDefaultAsync<OnboardingManuscriptBuildResult>();
if (header is null)
{
@ -78,21 +96,30 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
commandType: CommandType.StoredProcedure);
}
public async Task MarkMarkerSyncCompletedAsync(int buildId)
public async Task MarkMarkerSyncCompletedAsync(int buildId, string markerStatus, string message)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Onboarding_ManuscriptBuild_MarkerSyncCompleted",
new { BuildID = buildId },
new { BuildID = buildId, MarkerStatus = markerStatus, Message = message },
commandType: CommandType.StoredProcedure);
}
public async Task MarkMarkerSyncFailedAsync(int buildId, string message)
public async Task MarkMarkerSyncUserSaveRequiredAsync(int buildId, string message)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Onboarding_ManuscriptBuild_MarkerSyncUserSaveRequired",
new { BuildID = buildId, Message = message },
commandType: CommandType.StoredProcedure);
}
public async Task MarkMarkerSyncFailedAsync(int buildId, string message, string? diagnostics = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Onboarding_ManuscriptBuild_MarkerSyncFailed",
new { BuildID = buildId, Message = message },
new { BuildID = buildId, Message = message, Diagnostics = diagnostics },
commandType: CommandType.StoredProcedure);
}

View File

@ -4,6 +4,7 @@ using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using System.Security.Claims;
using System.Text.Json;
namespace PlotLine.Hubs;
@ -262,23 +263,74 @@ public sealed class WordCompanionFollowHub(
return result;
}
public async Task CompleteOnboardingBuildMarkerSync(int buildId)
public async Task CompleteOnboardingBuildMarkerSync(int buildId, MarkerSyncClientResult? result = null)
{
var userId = RequireUserId();
await onboardingBuilds.MarkMarkerSyncCompletedAsync(buildId);
var markerStatus = NormaliseCompletedMarkerStatus(result?.MarkerStatus);
var message = string.IsNullOrWhiteSpace(result?.Message)
? markerStatus switch
{
"SaveNotRequired" => "Scene markers already existed. No Word save was required.",
"UserConfirmedSave" => "Scene markers added; user confirmed the Word document was saved.",
_ => "Scene markers added and document saved."
}
: result.Message;
logger.LogInformation(
"Onboarding marker sync completed. UserID={UserID} BuildID={BuildID} MarkerStatus={MarkerStatus} MarkersChanged={MarkersChanged} SaveState={SaveState} SaveApi={SaveApi} Host={Host} Platform={Platform} Version={Version} DocumentGuid={DocumentGuid}",
userId,
buildId,
markerStatus,
result?.MarkersChanged,
result?.SaveState,
result?.SaveApi,
result?.Host,
result?.Platform,
result?.Version,
result?.DocumentGuid);
await onboardingBuilds.MarkMarkerSyncCompletedAsync(buildId, markerStatus, message);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Complete",
Message = "Manuscript structure has been created and saved. Word may now be closed; keep this browser workflow open to review characters.",
PercentComplete = 100
});
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId });
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId, markerStatus });
}
public async Task FailOnboardingBuildMarkerSync(int buildId, string message)
public async Task RequireOnboardingBuildManualSave(int buildId, string message, MarkerSyncClientResult? result = null)
{
var userId = RequireUserId();
await onboardingBuilds.MarkMarkerSyncFailedAsync(buildId, message);
logger.LogWarning(
"Onboarding marker sync requires manual Word save. UserID={UserID} BuildID={BuildID} Message={Message} Diagnostics={Diagnostics}",
userId,
buildId,
message,
SerialiseMarkerDiagnostics(result));
await onboardingBuilds.MarkMarkerSyncUserSaveRequiredAsync(buildId, message);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Running",
Message = string.IsNullOrWhiteSpace(message)
? "Scene markers have been added. Save the document in Word, then continue the import again."
: message,
PercentComplete = 90
});
}
public async Task FailOnboardingBuildMarkerSync(int buildId, string message, MarkerSyncClientResult? result = null)
{
var userId = RequireUserId();
logger.LogWarning(
"Onboarding marker sync failed. UserID={UserID} BuildID={BuildID} Message={Message} Diagnostics={Diagnostics}",
userId,
buildId,
message,
SerialiseMarkerDiagnostics(result));
await onboardingBuilds.MarkMarkerSyncFailedAsync(buildId, message, SerialiseMarkerDiagnostics(result));
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Failed",
@ -343,6 +395,12 @@ public sealed class WordCompanionFollowHub(
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|| state.UsesWordCompanion == true;
private static string NormaliseCompletedMarkerStatus(string? markerStatus)
=> markerStatus is "SaveNotRequired" or "UserConfirmedSave" ? markerStatus : "Completed";
private static string? SerialiseMarkerDiagnostics(MarkerSyncClientResult? result)
=> result is null ? null : JsonSerializer.Serialize(result);
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
{
return new()
@ -366,3 +424,20 @@ public sealed class WordCompanionFollowHub(
};
}
}
public sealed class MarkerSyncClientResult
{
public string? MarkerStatus { get; init; }
public string? Message { get; init; }
public bool? MarkersChanged { get; init; }
public string? SaveState { get; init; }
public string? SaveApi { get; init; }
public string? Host { get; init; }
public string? Platform { get; init; }
public string? Version { get; init; }
public string? DocumentGuid { get; init; }
public string? DocumentUrl { get; init; }
public string? ErrorName { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@ -235,6 +235,16 @@ public sealed class OnboardingService(
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
}
var existingBuild = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
if (existingBuild is not null && existingBuild.SceneMappings.Count > 0)
{
var resumed = await BuildExistingStructureResultAsync(userId, preview, review, existingBuild);
await scanPreviews.SaveBuildResultAsync(userId, resumed);
await PublishBuildProgress(userId, preview.PreviewID, "Resuming Word marker sync. No scene detection was rerun.", 90, progress, resumed);
return resumed;
}
await PublishBuildProgress(userId, preview.PreviewID, "Preparing approved chapters for scene detection...", 10, progress);
ValidateScanStructure(review, preview);
@ -348,6 +358,42 @@ public sealed class OnboardingService(
return result;
}
private async Task<OnboardingManuscriptBuildResult> BuildExistingStructureResultAsync(
int userId,
ManuscriptScanPreview preview,
ManuscriptScanReviewDecision review,
OnboardingManuscriptBuildResult existingBuild)
{
var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources);
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
return new OnboardingManuscriptBuildResult
{
BuildID = existingBuild.BuildID,
PreviewID = existingBuild.PreviewID,
ProjectID = existingBuild.ProjectID,
BookID = existingBuild.BookID,
ManuscriptDocumentID = existingBuild.ManuscriptDocumentID,
Status = existingBuild.Status,
MarkerStatus = existingBuild.MarkerStatus,
ChaptersCreated = existingBuild.ChaptersCreated,
ScenesCreated = existingBuild.ScenesCreated,
CharactersCreated = existingBuild.CharactersCreated,
CharactersReused = existingBuild.CharactersReused,
SceneAppearancesCreated = existingBuild.SceneAppearancesCreated,
AlreadyBuilt = true,
Message = "Manuscript structure already exists. Updating and saving Word markers...",
ChapterMappings = existingBuild.ChapterMappings,
SceneMappings = existingBuild.SceneMappings,
CharacterMappings = existingBuild.CharacterMappings,
ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, existingBuild.ChapterMappings),
SceneMarkerInstructions = BuildSceneMarkerInstructionsFromSources(sceneSources, existingBuild.SceneMappings),
SceneStructure = BuildSceneStructureSummaryFromSources(review.Chapters.Where(chapter => chapter.Include).ToList(), sceneSources),
MarkerWarning = existingBuild.MarkerWarning
};
}
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
=> await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId);
@ -514,6 +560,31 @@ public sealed class OnboardingService(
.ToList();
}
private static IReadOnlyList<OnboardingBuildMarkerInstruction> BuildSceneMarkerInstructionsFromSources(
IReadOnlyList<SceneManuscriptSourceRecord> sources,
IReadOnlyList<OnboardingBuildSceneMap> mappings)
{
var sourcesBySceneId = sources
.Where(source => source.WordStartParagraphIndex.HasValue)
.ToDictionary(source => source.SceneID);
return mappings
.Where(mapping => sourcesBySceneId.ContainsKey(mapping.SceneID))
.Select(mapping =>
{
var source = sourcesBySceneId[mapping.SceneID];
return new OnboardingBuildMarkerInstruction
{
TemporaryKey = mapping.TemporarySceneKey,
EntityID = mapping.SceneID,
WordParagraphIndex = source.WordStartParagraphIndex!.Value,
MarkerTag = $"PD-SCENE-{mapping.SceneID}",
MarkerTitle = "PlotDirector Scene",
MarkerPrefix = "PD-SCENE"
};
})
.ToList();
}
private static IReadOnlyList<OnboardingSceneStructureSummary> BuildSceneStructureSummary(
IReadOnlyList<ManuscriptScanChapterReviewDecision> chapters,
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes)
@ -545,6 +616,44 @@ public sealed class OnboardingService(
})
.ToList();
private static IReadOnlyList<OnboardingSceneStructureSummary> BuildSceneStructureSummaryFromSources(
IReadOnlyList<ManuscriptScanChapterReviewDecision> chapters,
IReadOnlyList<SceneManuscriptSourceRecord> sources)
{
var byChapter = sources
.GroupBy(source => source.ChapterID)
.OrderBy(group => group.Min(source => source.SourceStartParagraph ?? int.MaxValue))
.ThenBy(group => group.Key)
.ToList();
return chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select((chapter, index) =>
{
var chapterSources = byChapter.ElementAtOrDefault(index)?
.OrderBy(source => source.SourceStartParagraph ?? int.MaxValue)
.ThenBy(source => source.SceneID)
.ToList() ?? [];
return new OnboardingSceneStructureSummary
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
ChapterNumber = chapter.ChapterNumber,
ChapterTitle = chapter.Title,
SceneCount = chapterSources.Count,
WordCount = chapterSources.Sum(scene => scene.SourceWordCount),
Scenes = chapterSources.Select((scene, sceneIndex) => new OnboardingSceneStructureDetail
{
SceneNumberWithinChapter = sceneIndex + 1,
Title = $"Scene {sceneIndex + 1:N0}",
WordCount = scene.SourceWordCount,
StructuralSummary = scene.StructuralSummary,
StartParagraph = scene.SourceStartParagraph ?? 0,
EndParagraph = scene.SourceEndParagraph ?? 0
}).ToList()
};
})
.ToList();
}
private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result)
{
var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal);

View File

@ -0,0 +1,109 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview
@UserID int,
@PreviewID uniqueidentifier
AS
BEGIN
SET NOCOUNT ON;
DECLARE @BuildID int;
SELECT @BuildID = OnboardingManuscriptBuildID
FROM dbo.OnboardingManuscriptBuilds
WHERE PreviewID = @PreviewID
AND UserID = @UserID;
IF @BuildID IS NULL
RETURN;
SELECT OnboardingManuscriptBuildID AS BuildID, PreviewID, ProjectID, BookID, ManuscriptDocumentID, Status, MarkerStatus,
ChaptersCreated, ScenesCreated, CharactersCreated, CharactersReused, SceneAppearancesCreated,
CAST(1 AS bit) AS AlreadyBuilt,
CASE MarkerStatus
WHEN N'Completed' THEN N'This manuscript structure has already been built and saved.'
WHEN N'SaveNotRequired' THEN N'This manuscript structure has already been built; the expected Word markers were already present.'
WHEN N'UserConfirmedSave' THEN N'This manuscript structure has already been built and the Word save was confirmed.'
WHEN N'UserSaveRequired' THEN N'This manuscript structure has already been built. Save the Word document, then continue the import again.'
ELSE N'This manuscript structure has already been built into PlotDirector.'
END AS Message
FROM dbo.OnboardingManuscriptBuilds
WHERE OnboardingManuscriptBuildID = @BuildID;
SELECT TemporaryChapterKey, ChapterID
FROM dbo.OnboardingManuscriptBuildChapterMap
WHERE OnboardingManuscriptBuildID = @BuildID
ORDER BY TemporaryChapterKey;
SELECT TemporarySceneKey, SceneID
FROM dbo.OnboardingManuscriptBuildSceneMap
WHERE OnboardingManuscriptBuildID = @BuildID
ORDER BY TemporarySceneKey;
SELECT TemporaryCharacterKey, CharacterID, Created
FROM dbo.OnboardingManuscriptBuildCharacterMap
WHERE OnboardingManuscriptBuildID = @BuildID
ORDER BY TemporaryCharacterKey;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncCompleted
@BuildID int,
@MarkerStatus nvarchar(40) = N'Completed',
@Message nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @MarkerStatus NOT IN (N'Completed', N'SaveNotRequired', N'UserConfirmedSave')
SET @MarkerStatus = N'Completed';
UPDATE dbo.OnboardingManuscriptBuilds
SET MarkerStatus = @MarkerStatus,
MarkerSyncMessage = COALESCE(
LEFT(NULLIF(LTRIM(RTRIM(@Message)), N''), 500),
CASE @MarkerStatus
WHEN N'SaveNotRequired' THEN N'Scene markers already existed. No Word save was required.'
WHEN N'UserConfirmedSave' THEN N'Scene markers added; user confirmed the Word document was saved.'
ELSE N'Scene markers added and document saved.'
END),
MarkerSyncCompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE OnboardingManuscriptBuildID = @BuildID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncUserSaveRequired
@BuildID int,
@Message nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.OnboardingManuscriptBuilds
SET MarkerStatus = N'UserSaveRequired',
MarkerSyncMessage = COALESCE(
LEFT(NULLIF(LTRIM(RTRIM(@Message)), N''), 500),
N'Scene markers have been added. Save the document in Word, then continue the import again.'),
UpdatedUtc = SYSUTCDATETIME()
WHERE OnboardingManuscriptBuildID = @BuildID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncFailed
@BuildID int,
@Message nvarchar(500) = NULL,
@Diagnostics nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.OnboardingManuscriptBuilds
SET MarkerStatus = N'Failed',
MarkerSyncMessage = LEFT(NULLIF(LTRIM(RTRIM(@Message)), N''), 500),
UpdatedUtc = SYSUTCDATETIME()
WHERE OnboardingManuscriptBuildID = @BuildID;
END;
GO

View File

@ -1771,17 +1771,80 @@
});
});
const officeRuntimeDiagnostics = () => {
const diagnostics = window.Office?.context?.diagnostics || {};
const document = window.Office?.context?.document;
return {
host: diagnostics.host || window.Office?.context?.host || officeHost || "",
platform: diagnostics.platform || window.Office?.context?.platform || officePlatform || "",
version: diagnostics.version || "",
documentUrl: document?.url || "",
documentGuid: currentDocumentGuid()
};
};
const officeAsyncErrorDetails = (error) => ({
errorName: error?.name || "",
errorCode: error?.code || "",
errorMessage: error?.message || errorText(error)
});
const saveWordDocumentIfAvailable = () => new Promise((resolve) => {
const document = window.Office?.context?.document;
if (!document || typeof document.saveAsync !== "function") {
resolve(false);
resolve({
saveState: "Unsupported",
saveApi: "Office.context.document.saveAsync",
...officeRuntimeDiagnostics(),
errorMessage: "Office.context.document.saveAsync is not available in this Word host."
});
return;
}
document.saveAsync((result) => {
resolve(result.status === window.Office.AsyncResultStatus.Succeeded);
const succeeded = result.status === window.Office.AsyncResultStatus.Succeeded;
resolve({
saveState: succeeded ? "Saved" : "Failed",
saveApi: "Office.context.document.saveAsync",
...officeRuntimeDiagnostics(),
...(succeeded ? {} : officeAsyncErrorDetails(result.error))
});
});
});
const invokeMarkerSyncFailure = async (buildId, message, diagnostics = {}) => {
if (Number.isInteger(buildId) && buildId > 0 && companionPresenceConnection) {
try {
await companionPresenceConnection.invoke("FailOnboardingBuildMarkerSync", buildId, message, diagnostics);
} catch (error) {
console.warn("Unable to report onboarding marker sync failure.", error);
}
}
};
const invokeMarkerSyncManualSaveRequired = async (buildId, message, diagnostics = {}) => {
if (Number.isInteger(buildId) && buildId > 0 && companionPresenceConnection) {
try {
await companionPresenceConnection.invoke("RequireOnboardingBuildManualSave", buildId, message, diagnostics);
} catch (error) {
console.warn("Unable to report onboarding manual save requirement.", error);
}
}
};
const invokeMarkerSyncCompleted = async (buildId, markerStatus, message, diagnostics = {}) => {
if (Number.isInteger(buildId) && buildId > 0 && companionPresenceConnection) {
try {
await companionPresenceConnection.invoke("CompleteOnboardingBuildMarkerSync", buildId, {
markerStatus,
message,
...diagnostics
});
} catch (error) {
console.warn("Unable to report onboarding marker sync completion.", error);
}
}
};
const ensureDocumentGuid = () => {
if (firstRunDocumentBinding?.documentGuid) {
@ -2368,10 +2431,6 @@
};
const updateOnboardingBuildMarkers = async (result) => {
if (!wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable.");
}
const buildId = Number.parseInt(result?.buildID || result?.BuildID || "0", 10);
const chapterMap = new Map((result?.chapterMappings || result?.ChapterMappings || [])
.map((item) => [item.temporaryChapterKey || item.TemporaryChapterKey, item.chapterID || item.ChapterID]));
@ -2384,6 +2443,11 @@
}
try {
if (!wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable.");
}
let markersChanged = false;
await window.Word.run(async (context) => {
const paragraphs = context.document.body.paragraphs;
paragraphs.load("items/text,items/style,items/styleBuiltIn");
@ -2396,7 +2460,7 @@
const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Chapter";
const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-CHAPTER";
if (tag && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], title, tag, prefix);
markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged;
}
}
@ -2406,7 +2470,7 @@
const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Scene";
const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-SCENE";
if (tag && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], title, tag, prefix);
markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged;
}
}
} else {
@ -2414,7 +2478,7 @@
const chapterId = chapterMap.get(chapter.temporaryChapterKey);
const index = Number.parseInt(chapter.startPosition, 10);
if (chapterId && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER") || markersChanged;
}
}
@ -2422,7 +2486,7 @@
const sceneId = sceneMap.get(scene.temporarySceneKey);
const index = Number.parseInt(scene.startPosition, 10);
if (sceneId && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE") || markersChanged;
}
}
}
@ -2430,20 +2494,71 @@
await context.sync();
});
const saved = await saveWordDocumentIfAvailable();
if (!saved) {
throw new Error("Word markers were inserted, but the document could not be saved.");
await inspectPlotDirectorMarkers().catch(() => null);
if (!markersChanged) {
const diagnostics = {
markersChanged: false,
saveState: "NotRequired",
saveApi: "none",
...officeRuntimeDiagnostics()
};
console.info("Onboarding marker sync completed without Word document changes.", diagnostics);
await invokeMarkerSyncCompleted(
buildId,
"SaveNotRequired",
"Scene markers already existed. No Word save was required.",
diagnostics);
setDocumentMessage("Scene markers already existed. Word may now be closed.");
setDiagnostics({ lastError: "-" });
return;
}
await inspectPlotDirectorMarkers().catch(() => null);
if (Number.isInteger(buildId) && buildId > 0) {
await companionPresenceConnection.invoke("CompleteOnboardingBuildMarkerSync", buildId);
const saveResult = await saveWordDocumentIfAvailable();
const saveDiagnostics = {
markersChanged: true,
...saveResult
};
console.info("Onboarding marker sync Word save result.", saveDiagnostics);
if (saveResult.saveState === "Saved") {
await invokeMarkerSyncCompleted(
buildId,
"Completed",
"Scene markers added and document saved.",
saveDiagnostics);
setDocumentMessage("Scene markers added and document saved. Word may now be closed.");
setDiagnostics({ lastError: "-" });
return;
}
const manualMessage = "Scene markers have been added. Save the document in Word now, then click OK to continue.";
setDocumentMessage(manualMessage);
setDiagnostics({ lastError: saveResult.errorMessage || "Word document save requires manual confirmation." });
const userConfirmedSave = window.confirm(`${manualMessage}\n\nClick Cancel if the document was not saved.`);
if (userConfirmedSave) {
await invokeMarkerSyncCompleted(
buildId,
"UserConfirmedSave",
"Scene markers added; user confirmed the Word document was saved.",
saveDiagnostics);
setDocumentMessage("Scene markers added and save confirmed. Word may now be closed.");
setDiagnostics({ lastError: "-" });
return;
}
await invokeMarkerSyncManualSaveRequired(
buildId,
"Scene markers were added, but Word save still needs confirmation. Save the document in Word, then continue the import again.",
saveDiagnostics);
} catch (error) {
if (Number.isInteger(buildId) && buildId > 0) {
await companionPresenceConnection.invoke("FailOnboardingBuildMarkerSync", buildId, errorText(error));
}
throw error;
console.error("Unable to update onboarding Word markers.", error);
setDocumentMessage("Unable to update PlotDirector scene markers.");
setDiagnostics({ lastError: errorText(error) });
await invokeMarkerSyncFailure(buildId, errorText(error), {
markersChanged: null,
saveState: "Failed",
...officeRuntimeDiagnostics(),
errorMessage: errorText(error)
});
}
};
@ -4336,11 +4451,15 @@
const ensureAnchorControl = (paragraph, title, tag, prefix) => {
const existing = findAnchorControl(paragraph, prefix);
if (existing && existing.tag === tag && existing.title === title) {
return false;
}
const control = existing || paragraph.getRange().insertContentControl();
control.title = title;
control.tag = tag;
control.appearance = "Hidden";
return control;
return true;
};
const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => {