Restore Word Companion existing book linking
This commit is contained in:
parent
9d8d443067
commit
398579c5c9
@ -114,6 +114,13 @@ var tests = new (string Name, Action Test)[]
|
||||
("Writing workspace pages register roles without changing follow behavior", WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior),
|
||||
("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
|
||||
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
|
||||
("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow),
|
||||
("Word Companion existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable),
|
||||
("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect),
|
||||
("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable),
|
||||
("Word Companion modes remain distinct", WordCompanionModesRemainDistinct),
|
||||
("Writing pages do not require Story Intelligence", WritingPagesDoNotRequireStoryIntelligence),
|
||||
("Word Companion host script has valid tab mapping", WordCompanionHostScriptHasValidTabMapping),
|
||||
("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates),
|
||||
("Word Companion three-screen flow logs connection identifiers", WordCompanionThreeScreenFlowLogsConnectionIdentifiers),
|
||||
("Admin Story Intelligence reset returns to diagnostics with messages", AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages),
|
||||
@ -506,6 +513,145 @@ static void WordCompanionWritingScreensReceiveResolvedContextIdentifiers()
|
||||
Assert(intentView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Intent should retain the document identifier.");
|
||||
}
|
||||
|
||||
static void WordCompanionCanonicalMarkersDriveRuntimeFollow()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/WordCompanionRepository.cs"));
|
||||
|
||||
Assert(script.Contains("chapter: \"PD-CHAPTER-\"", StringComparison.Ordinal), "Canonical chapter marker prefix is missing.");
|
||||
Assert(script.Contains("scene: \"PD-SCENE-\"", StringComparison.Ordinal), "Canonical scene marker prefix is missing.");
|
||||
Assert(script.Contains("const classifyPlotDirectorContentControl", StringComparison.Ordinal), "Word marker classifier is missing.");
|
||||
Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Document scan should read canonical chapter IDs from Word anchors.");
|
||||
Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Document scan should read canonical scene IDs from Word anchors.");
|
||||
Assert(script.Contains("detectedSceneAnchorId = Number.isInteger(sceneAnchorId)", StringComparison.Ordinal), "Current scene detection should preserve canonical scene IDs.");
|
||||
Assert(script.Contains("postJson(\"/api/word-companion/runtime/current-scene\"", StringComparison.Ordinal), "Word Companion should notify PlotDirector of the runtime current scene.");
|
||||
Assert(script.Contains("sceneId: numericSceneId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical SceneID.");
|
||||
Assert(script.Contains("chapterId: numericChapterId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical ChapterID.");
|
||||
Assert(controller.Contains("presence.UpdateCurrentStoryPositionAsync", StringComparison.Ordinal), "Current-scene API should update writing workspace presence.");
|
||||
Assert(controller.Contains("BroadcastToCurrentUserAsync(\"storyPositionChanged\"", StringComparison.Ordinal), "Current-scene API should broadcast canonical story-position changes.");
|
||||
Assert(repository.Contains("\"dbo.WordCompanion_Runtime_CurrentScene_Validate\"", StringComparison.Ordinal), "Runtime current scene validation must remain stored-procedure based.");
|
||||
}
|
||||
|
||||
static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
||||
|
||||
Assert(view.Contains("data-project-select", StringComparison.Ordinal), "Runtime Project selector should remain visible for unbound documents.");
|
||||
Assert(view.Contains("data-book-select", StringComparison.Ordinal), "Runtime Book selector should remain visible for unbound documents.");
|
||||
Assert(view.Contains("data-first-run-project-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Project.");
|
||||
Assert(view.Contains("data-first-run-book-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Book.");
|
||||
Assert(view.Contains("data-first-run-link", StringComparison.Ordinal), "Bind flow should expose a direct existing-book link action.");
|
||||
Assert(script.Contains("firstRunLinkButton?.addEventListener(\"click\", linkFirstRunManuscript)", StringComparison.Ordinal), "Existing-book link action should be wired.");
|
||||
Assert(script.Contains("postJson(\"/api/word-companion/manuscript/link\"", StringComparison.Ordinal), "Existing-book link action should call the link endpoint.");
|
||||
Assert(script.Contains("/api/word-companion/projects", StringComparison.Ordinal), "Project selector should load existing Projects.");
|
||||
Assert(script.Contains("/api/word-companion/projects/${projectId}/books", StringComparison.Ordinal), "Book selector should load existing Books.");
|
||||
Assert(controller.Contains("[HttpPost(\"manuscript/link\")", StringComparison.Ordinal), "Link endpoint is missing.");
|
||||
Assert(service.Contains("LinkManuscriptAsync", StringComparison.Ordinal), "Link service method is missing.");
|
||||
Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Existing-book linking should save a document binding directly.");
|
||||
Assert(!service[service.IndexOf("public async Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync", StringComparison.Ordinal)..service.IndexOf("public async Task<WordCompanionManuscriptUnlinkResponse?> UnlinkManuscriptAsync", StringComparison.Ordinal)].Contains("ImportManuscriptAsync", StringComparison.Ordinal), "Existing-book linking must not route through manuscript import.");
|
||||
Assert(repository.Contains("\"dbo.ManuscriptDocument_Save\"", StringComparison.Ordinal), "Document binding save must remain stored-procedure based.");
|
||||
}
|
||||
|
||||
static void WordCompanionDocumentIdentitySurvivesReconnect()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
||||
|
||||
Assert(script.Contains("plotdirector.word.documentGuid", StringComparison.Ordinal), "Word document GUID metadata key is missing.");
|
||||
Assert(script.Contains("plotdirector.word.boundBookId", StringComparison.Ordinal), "Word bound BookID metadata key is missing.");
|
||||
Assert(script.Contains("plotdirector.word.boundProjectId", StringComparison.Ordinal), "Word bound ProjectID metadata key is missing.");
|
||||
Assert(script.Contains("const readDocumentBinding", StringComparison.Ordinal), "Word host should read durable document binding metadata.");
|
||||
Assert(script.Contains("const writeDocumentBinding", StringComparison.Ordinal), "Word host should write durable document binding metadata.");
|
||||
Assert(script.Contains("settings.saveAsync", StringComparison.Ordinal), "Word document binding metadata must be saved into the document.");
|
||||
Assert(script.Contains("const ensureDocumentGuid", StringComparison.Ordinal), "Word host should mint a document GUID for new bindings.");
|
||||
Assert(script.Contains("/api/word-companion/runtime/book/${binding.bookId}?documentGuid=${encodeURIComponent(binding.documentGuid)}", StringComparison.Ordinal), "Reconnect should validate saved document binding against the server.");
|
||||
Assert(service.Contains("GetRuntimeBookAsync(int bookId, Guid documentGuid)", StringComparison.Ordinal), "Runtime book service is missing.");
|
||||
Assert(service.Contains("manuscriptDocuments.GetByGuidAsync(documentGuid, userId)", StringComparison.Ordinal), "Reconnect should resolve documents by saved GUID.");
|
||||
Assert(service.Contains("manuscriptDocuments.UpdateLastOpenedAsync", StringComparison.Ordinal), "Reconnect should touch LastOpened for the durable binding.");
|
||||
Assert(repository.Contains("\"dbo.ManuscriptDocument_GetByGuid\"", StringComparison.Ordinal), "Document GUID lookup must remain stored-procedure based.");
|
||||
Assert(repository.Contains("\"dbo.ManuscriptDocument_UpdateLastOpened\"", StringComparison.Ordinal), "Last-opened update must remain stored-procedure based.");
|
||||
}
|
||||
|
||||
static void WordCompanionMultiscreenLaunchAndFollowRemainReachable()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
||||
var hostScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
||||
var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
|
||||
var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
|
||||
|
||||
Assert(view.Contains("data-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose the workspace launch action.");
|
||||
Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose the Context launch action.");
|
||||
Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose the Intent launch action.");
|
||||
Assert(hostScript.Contains("openWritingWindow(\"context\")", StringComparison.Ordinal), "Context launch should call the writing window helper.");
|
||||
Assert(hostScript.Contains("openWritingWindow(\"intent\")", StringComparison.Ordinal), "Intent launch should call the writing window helper.");
|
||||
Assert(hostScript.Contains("new URL(target === \"workspace\" ? \"/Writer\" : `/Writing/${target}`", StringComparison.Ordinal), "Writing windows should target the dedicated Context, Intent, and Workspace routes.");
|
||||
Assert(hostScript.Contains("url.searchParams.set(\"sceneId\", sceneId)", StringComparison.Ordinal), "Writing windows should receive the current canonical SceneID.");
|
||||
Assert(follow.Contains("connection.invoke(\"WatchWritingWorkspace\"", StringComparison.Ordinal), "Writing panes should attach to the Word Companion follow hub.");
|
||||
Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing panes should receive runtime scene changes.");
|
||||
Assert(follow.Contains("registerWindow", StringComparison.Ordinal), "Writing panes should register their multiscreen role.");
|
||||
Assert(contextView.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Context page should load shared follow script.");
|
||||
Assert(intentView.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Intent page should load shared follow script.");
|
||||
}
|
||||
|
||||
static void WordCompanionModesRemainDistinct()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||
|
||||
Assert(view.Contains("data-first-run-wizard hidden", StringComparison.Ordinal), "Bind/import wizard should be hidden until a document is unbound.");
|
||||
Assert(view.Contains("data-linked-manuscript-card hidden", StringComparison.Ordinal), "Linked manuscript card should be hidden until a durable binding is present.");
|
||||
Assert(view.Contains("data-runtime-book-selectors", StringComparison.Ordinal), "Normal Project/Book selectors should remain available outside the bind wizard.");
|
||||
Assert(view.Contains("data-first-run-link", StringComparison.Ordinal), "Existing-book link action should be distinct from import.");
|
||||
Assert(view.Contains("data-first-run-scan", StringComparison.Ordinal), "Scan action should remain available for first-run import.");
|
||||
Assert(view.Contains("data-first-run-import", StringComparison.Ordinal), "Import action should remain a separate confirmation after scan.");
|
||||
Assert(view.Contains("data-unlink-word-document", StringComparison.Ordinal), "Unlink action should remain available for linked documents.");
|
||||
Assert(script.Contains("setHidden(runtimeBookSelectors, linked)", StringComparison.Ordinal), "Runtime selectors should only hide after binding.");
|
||||
Assert(script.Contains("setFirstRunMode(true)", StringComparison.Ordinal), "Unbound documents should enter the bind/import mode.");
|
||||
Assert(script.Contains("setFirstRunMode(false)", StringComparison.Ordinal), "Linked documents should leave the bind/import mode.");
|
||||
}
|
||||
|
||||
static void WritingPagesDoNotRequireStoryIntelligence()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
|
||||
var contextPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingContextContent.cshtml"));
|
||||
var intentPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
|
||||
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
||||
|
||||
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing pages should use canonical story context.");
|
||||
Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should use existing writer workspace planning data.");
|
||||
Assert(!controller.Contains("IStoryIntelligence", StringComparison.Ordinal), "Writing routes should not depend on Story Intelligence services.");
|
||||
Assert(!controller.Contains("Onboarding", StringComparison.Ordinal), "Writing routes should not depend on import onboarding.");
|
||||
Assert(contextPartial.Contains("@Model.EmptyTitle", StringComparison.Ordinal), "Writing Context should render a normal empty state when context is unavailable.");
|
||||
Assert(intentPartial.Contains("@Model.EmptyMessage", StringComparison.Ordinal), "Writing Intent should render a normal empty state when planning data is unavailable.");
|
||||
Assert(follow.Contains("No current Word scene has been received", StringComparison.Ordinal), "Writing panes should tolerate no current Word scene without Story Intelligence.");
|
||||
}
|
||||
|
||||
static void WordCompanionHostScriptHasValidTabMapping()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
||||
|
||||
Assert(script.Contains("const normalizeTabName = (tabName) =>", StringComparison.Ordinal), "Tab-name compatibility helper is missing.");
|
||||
Assert(script.Contains("return \"review\";", StringComparison.Ordinal), "Legacy links tab should map to Review.");
|
||||
Assert(script.Contains("return \"maintenance\";", StringComparison.Ordinal), "Legacy structure tab should map to Maintenance.");
|
||||
Assert(!script.Contains("return \"review\";\n }\n }\n if", StringComparison.Ordinal), "Tab-name mapping contains an extra closing brace.");
|
||||
Assert(view.Split("word-companion-review-group").Length - 1 == 3, "Review panel should contain exactly the three review groups.");
|
||||
}
|
||||
|
||||
static void WritingScreensShowDiagnosticEmptyStates()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
|
||||
@ -70,6 +70,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="word-companion-actions">
|
||||
<button type="button" class="word-companion-primary-action" data-first-run-link disabled>Link Existing Book</button>
|
||||
<button type="button" class="word-companion-primary-action" data-first-run-scan disabled>Scan Manuscript</button>
|
||||
<button type="button" data-first-run-cancel>Cancel</button>
|
||||
</div>
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
const firstRunBookSelect = document.querySelector("[data-first-run-book-select]");
|
||||
const firstRunNewBookTitle = document.querySelector("[data-first-run-new-book-title]");
|
||||
const firstRunCreateBookButton = document.querySelector("[data-first-run-create-book]");
|
||||
const firstRunLinkButton = document.querySelector("[data-first-run-link]");
|
||||
const firstRunScanButton = document.querySelector("[data-first-run-scan]");
|
||||
const firstRunCancelButton = document.querySelector("[data-first-run-cancel]");
|
||||
const firstRunStatus = document.querySelector("[data-first-run-status]");
|
||||
@ -1499,6 +1500,9 @@
|
||||
if (firstRunScanButton) {
|
||||
firstRunScanButton.disabled = !project || !book || isFirstRunImporting;
|
||||
}
|
||||
if (firstRunLinkButton) {
|
||||
firstRunLinkButton.disabled = !project || !book || isFirstRunImporting;
|
||||
}
|
||||
if (firstRunCreateBookButton) {
|
||||
firstRunCreateBookButton.disabled = !project || !String(firstRunNewBookTitle?.value || "").trim() || isFirstRunImporting;
|
||||
}
|
||||
@ -3766,6 +3770,67 @@
|
||||
cleanUnlinkWordDocumentDialog.showModal();
|
||||
});
|
||||
|
||||
const linkFirstRunManuscript = async () => {
|
||||
const project = selectedFirstRunProject();
|
||||
const book = selectedFirstRunBook();
|
||||
if (!project || !book) {
|
||||
updateFirstRunButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
isFirstRunImporting = true;
|
||||
updateFirstRunButtons();
|
||||
setFirstRunStatus("Linking Word document...");
|
||||
|
||||
try {
|
||||
const documentGuid = ensureDocumentGuid();
|
||||
const response = await postJson("/api/word-companion/manuscript/link", {
|
||||
bookId: book.bookId,
|
||||
documentGuid
|
||||
});
|
||||
const document = response.manuscriptDocument;
|
||||
await writeDocumentBinding({
|
||||
documentGuid: document?.documentGuid || documentGuid,
|
||||
bookId: response.bookId || book.bookId,
|
||||
projectId: document?.projectId || project.projectId,
|
||||
bindingVersion: document?.bindingVersion || 1
|
||||
});
|
||||
|
||||
firstRunDocumentBinding = {
|
||||
documentGuid: document?.documentGuid || documentGuid,
|
||||
bookId: response.bookId || book.bookId,
|
||||
projectId: document?.projectId || project.projectId,
|
||||
bindingVersion: document?.bindingVersion || 1
|
||||
};
|
||||
writeStoredId(storageKeys.projectId, firstRunDocumentBinding.projectId);
|
||||
writeStoredId(storageKeys.bookId, firstRunDocumentBinding.bookId);
|
||||
if (projectSelect) {
|
||||
projectSelect.value = String(firstRunDocumentBinding.projectId);
|
||||
}
|
||||
await loadBooks(firstRunDocumentBinding.projectId, firstRunDocumentBinding.bookId);
|
||||
setRuntimeBookContext({
|
||||
projectId: firstRunDocumentBinding.projectId,
|
||||
projectTitle: project.title,
|
||||
bookId: firstRunDocumentBinding.bookId,
|
||||
bookTitle: book.title,
|
||||
bookSubtitle: book.subtitle,
|
||||
usesExplicitScenes: !!book.usesExplicitScenes,
|
||||
manuscriptDocument: document
|
||||
});
|
||||
setFirstRunMode(false);
|
||||
setConnectionStatus("Connected");
|
||||
setBookMessage(response.message || "Manuscript linked.");
|
||||
setDocumentMessage(response.message || "Manuscript linked.");
|
||||
await loadRuntimeDocumentAwareness();
|
||||
} catch (error) {
|
||||
console.error("Unable to link Word document.", error);
|
||||
setFirstRunStatus(`Unable to link manuscript: ${errorText(error)}`);
|
||||
} finally {
|
||||
isFirstRunImporting = false;
|
||||
updateFirstRunButtons();
|
||||
}
|
||||
};
|
||||
|
||||
const importFirstRunManuscript = async () => {
|
||||
const project = selectedFirstRunProject();
|
||||
const book = selectedFirstRunBook();
|
||||
@ -6110,6 +6175,7 @@
|
||||
});
|
||||
|
||||
firstRunScanButton?.addEventListener("click", analyseFirstRunManuscript);
|
||||
firstRunLinkButton?.addEventListener("click", linkFirstRunManuscript);
|
||||
firstRunImportButton?.addEventListener("click", importFirstRunManuscript);
|
||||
firstRunCreateCharactersButton?.addEventListener("click", createFirstRunCharacters);
|
||||
firstRunSkipCharactersButton?.addEventListener("click", () => finishFirstRunOnboarding("Character creation skipped."));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user